From 815cceabc6b3421792353421911b415585298b76 Mon Sep 17 00:00:00 2001 From: Oksion Date: Mon, 3 Aug 2026 15:28:09 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(display):=20=D0=BB=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=20=D1=8F=D1=80=D0=BA=D0=BE=D1=81=D1=82=D0=B8=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=82=D0=B8=20=D0=B8=20=D0=B1=D0=B0=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=B8=20(XIC-29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выше лимита яркость плавно сводится обратно: вместо ультиматума — вежливый торг (раз в минуту разрыв вдвое, остаток ≤2% сразу). Повторный подъём после нашего шага — backoff 2 часа, сбрасывается блокировкой/сном/сменой питания. С адаптивной яркостью Windows фича не работает (иначе качель с датчиком) — причина честно показана в настройках. - BrightnessCapGuard: машина состояний на фейкуемых швах (WorkerTimer — тики на пуле, WinForms-таймер с потока WMI-событий не тикает никогда) - Brightness.Ramp: плавный ход 1%-шагами, ~10 с на весь путь, с отменой; Brightness.Own (OwnWrites): свои записи отличаются от пользовательских по меткам значений, а не по окну времени - «Запоминать яркость»: превышение лимита не сохраняется вообще (слот хранит намерение), восстановление клампится к текущему лимиту - Вкладка «Экран» видна всегда: раздел «Яркость» (лимиты + переехавший из «Общих» тумблер запоминания); фича частоты скрывает только свой раздел - Тайминги — в config.json (BrightnessRampMs/ConvergeMs/BackoffMin и др.) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 + src/Config/AppConfig.cs | 36 +- src/Localization/lang/en.json | 13 +- src/Localization/lang/ru.json | 13 +- src/Localization/lang/zh.json | 13 +- src/Program.cs | 1 + src/SystemIntegration/Brightness.cs | 162 ++++++++- src/SystemIntegration/BrightnessCapGuard.cs | 240 ++++++++++++++ src/SystemIntegration/IAppTimer.cs | 18 + src/SystemIntegration/PowerProfileGuard.cs | 44 ++- src/Ui/AppController.cs | 31 +- src/Ui/Settings/DisplayTab.cs | 38 ++- src/Ui/Settings/FeaturesTab.cs | 3 +- src/Ui/Settings/GeneralTab.cs | 3 +- src/Ui/SettingsActions.cs | 3 + src/Ui/SettingsForm.cs | 5 +- src/Ui/TrayApp.cs | 5 + tests/XiControl.Tests/AppControllerTests.cs | 5 +- .../BrightnessCapGuardTests.cs | 313 ++++++++++++++++++ .../XiControl.Tests/PowerProfileGuardTests.cs | 17 +- 20 files changed, 937 insertions(+), 43 deletions(-) create mode 100644 src/SystemIntegration/BrightnessCapGuard.cs create mode 100644 tests/XiControl.Tests/BrightnessCapGuardTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b47b033..7896bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ ## [Unreleased] ### Добавлено +- **Лимит яркости экрана** (Настройки → Экран) — два независимых лимита, для сети и для батареи: + яркость выше лимита плавно (за ~10 с, без скачка) сводится обратно. Бережёт OLED-панель от + выгорания. Заблокировать сам ползунок Windows невозможно — только вернуть после факта, о чём + честно написано в настройке. Вместо ультиматума — вежливый торг: раз в минуту разрыв сокращается + вдвое, а если пользователь после нашего шага снова поднял яркость — приложение уступает и не + трогает её 2 часа (пауза сбрасывается блокировкой, сном, сменой питания и перезапуском). + Понижение ниже лимита не трогается вовсе — приложение никогда не поднимает яркость. При + включённой адаптивной яркости Windows фича не работает и объясняет причину прямо в настройках. + Опция «Запоминать яркость» при этом хранит намерение пользователя: превышение лимита не + запоминается вообще (а не обрезается), восстановление клампится к текущему лимиту. Тайминги + правятся в config.json (`BrightnessRampMs`, `BrightnessConvergeMs`, `BrightnessBackoffMin` и др.) + (XIC-29). + +### Изменено +- **Вкладка «Экран» видна всегда** — на ней поселилась яркость (лимиты + переехавший из «Общих» + тумблер «Запоминать яркость экрана»), поэтому фича «Управление частотой» теперь скрывает только + раздел частоты внутри вкладки, а не вкладку целиком (XIC-29). - **Сведения о железе на вкладке «О программе»** — рядом с моделью появились версия BIOS с датой выпуска и серийный номер. Модель теперь показывается вместе с кодом платы («Xiaomi Book Pro 14 (TM2424)») — в отчётах о совместимости важен именно код. **Серийный номер закрыт по умолчанию**, diff --git a/src/Config/AppConfig.cs b/src/Config/AppConfig.cs index e923488..6033004 100644 --- a/src/Config/AppConfig.cs +++ b/src/Config/AppConfig.cs @@ -121,11 +121,45 @@ public sealed class AppConfig public PerfMode? BatteryPerfMode { get; set; } /// Запоминать и восстанавливать яркость экрана отдельно для сети и батареи. - /// Самостоятельная опция (в «Общих»): работает и без «Профилей питания» — держит её + /// Самостоятельная опция (вкладка «Экран»): работает и без «Профилей питания» — держит её /// PowerProfileGuard независимо. По умолчанию выкл: утилита перебивает яркость Windows /// только по явному выбору пользователя. public bool RememberBrightness { get; set; } = false; + /// + /// Ограничивать яркость экрана (XIC-29, бережёт OLED от выгорания): выше лимита яркость + /// плавно сводится обратно. Заблокировать сам ползунок Windows невозможно — только вернуть + /// после факта, о чём честно сказано в описании настройки. Держит BrightnessCapGuard. + /// + public bool BrightnessCapEnabled { get; set; } = false; + + /// Лимит яркости (%) при питании от сети. + public int BrightnessCapAc { get; set; } = 80; + + /// Лимит яркости (%) при питании от батареи. + public int BrightnessCapBattery { get; set; } = 60; + + // Тайминги лимита яркости — только правкой config.json (в UI не выносим, дефолты согласованы + // в XIC-29). Кривые значения клэмпит BrightnessCapGuard при чтении. + + /// Длительность плавного хода яркости на весь путь, мс: интервал шага = длительность / + /// дельта, поэтому спуск 100→70 и 75→70 занимает одинаковое время. Дефолт 10 с. + public int BrightnessRampMs { get; set; } = 10_000; + + /// Интервал между шагами схождения к лимиту. Дефолт 1 мин. + public int BrightnessConvergeMs { get; set; } = 60_000; + + /// Пауза после повторного подъёма яркости пользователем («мне правда нужно ярче»), минут. + /// Сбрасывается блокировкой, сном, сменой питания и перезапуском. Дефолт 2 часа. + public int BrightnessBackoffMin { get; set; } = 120; + + /// Делитель разрыва при схождении: за шаг сокращаем (яркость − лимит) во столько раз. + public int BrightnessGapDivisor { get; set; } = 2; + + /// Порог схождения, %: если до лимита осталось не больше — доводим сразу + /// (иначе гонялись бы за половинками бесконечно). + public int BrightnessSnapPercent { get; set; } = 2; + /// Запомненная яркость экрана (0–100) от сети; null — ещё не запомнена. public int? AcBrightness { get; set; } diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index b71031e..a8644a8 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -65,7 +65,16 @@ "settings.battery.capacity": "Capacity", "settings.battery.capacity.desc": "Current maximum and design capacity.", "settings.battery.capacity.val": "{0:0.0} / {1:0.0} Wh", - "settings.display.sub": "Refresh rate depending on the power source.", + "settings.display.sub": "Screen brightness and refresh rate.", + "settings.bright.group": "Brightness", + "settings.bright.cap": "Limit brightness", + "settings.bright.cap.desc": "Protects an OLED panel from burn-in: brightness above the limit smoothly slides back to it. Windows offers no way to lock the slider itself — it can only be brought back after the fact. Raise it again and the app yields, leaving brightness alone for 2 hours.", + "settings.bright.cap.ac": "Limit on AC", + "settings.bright.cap.ac.desc": "Maximum brightness while plugged in.", + "settings.bright.cap.battery": "Limit on battery", + "settings.bright.cap.battery.desc": "Maximum brightness on battery power.", + "settings.bright.adaptive": "The limit is inactive right now: adaptive brightness is enabled in the power plan, so Windows itself adjusts brightness from the light sensor — the two would fight each other. Turn it off in Settings → System → Display → \"Change brightness automatically when lighting changes\".", + "settings.hz.group": "Refresh rate", "settings.hz.auto": "Auto refresh rate by power", "settings.hz.auto.desc": "Higher on AC, lower on battery — saves power.", "settings.hz.hold": "Keep refresh rate", @@ -138,7 +147,7 @@ "settings.touchscreen.feature": "Touchscreen control", "settings.touchscreen.feature.desc": "Panel cell and key action. A disabled touchscreen always re-enables itself after a reboot.", "settings.refresh.feature": "Refresh rate control", - "settings.refresh.feature.desc": "Menu item, panel cell and the Display tab. Turn off if you don't need auto Hz switching.", + "settings.refresh.feature.desc": "Menu item, panel cell and the refresh-rate section of the Display tab. Turn off if you don't need auto Hz switching.", "settings.touchpad.sub": "How the touchpad behaves.", "settings.touchpad.deadzone": "Bottom dead zone", "settings.touchpad.deadzone.desc": "Don't start a touch in the bottom strip — protection from a resting palm.", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index ec0c06e..a4eeb80 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -65,7 +65,16 @@ "settings.battery.capacity": "Ёмкость", "settings.battery.capacity.desc": "Текущая максимальная и проектная ёмкость.", "settings.battery.capacity.val": "{0:0.0} / {1:0.0} Вт·ч", - "settings.display.sub": "Частота обновления в зависимости от питания.", + "settings.display.sub": "Яркость и частота обновления экрана.", + "settings.bright.group": "Яркость", + "settings.bright.cap": "Ограничивать яркость", + "settings.bright.cap.desc": "Бережёт OLED-панель от выгорания: яркость выше лимита плавно возвращается к нему. Заблокировать сам ползунок Windows нельзя — только вернуть после факта. Поднимете снова — приложение уступит и не будет трогать яркость 2 часа.", + "settings.bright.cap.ac": "Лимит от сети", + "settings.bright.cap.ac.desc": "Максимальная яркость при питании от сети.", + "settings.bright.cap.battery": "Лимит от батареи", + "settings.bright.cap.battery.desc": "Максимальная яркость при питании от батареи.", + "settings.bright.adaptive": "Сейчас лимит не работает: в схеме электропитания включена адаптивная яркость, и Windows сама меняла бы яркость по датчику освещённости — две системы мешали бы друг другу. Отключить её можно в Параметры → Система → Дисплей → «Автоматически менять яркость при изменении освещения».", + "settings.hz.group": "Частота обновления", "settings.hz.auto": "Авто-частота по питанию", "settings.hz.auto.desc": "Повышенная от сети, пониженная от батареи — экономит заряд.", "settings.hz.hold": "Удерживать частоту", @@ -138,7 +147,7 @@ "settings.touchscreen.feature": "Управление сенсорным экраном", "settings.touchscreen.feature.desc": "Ячейка в панели и действие для клавиш. Отключённый сенсорный экран всегда включается сам после перезагрузки.", "settings.refresh.feature": "Управление частотой экрана", - "settings.refresh.feature.desc": "Пункт меню, ячейка панели и вкладка «Экран». Выключите, если авто-герцовка вам не нужна.", + "settings.refresh.feature.desc": "Пункт меню, ячейка панели и раздел частоты во вкладке «Экран». Выключите, если авто-герцовка вам не нужна.", "settings.touchpad.sub": "Поведение сенсорной панели.", "settings.touchpad.deadzone": "Мёртвая зона снизу", "settings.touchpad.deadzone.desc": "Не начинать касание в нижней полосе — защита от лежащей ладони.", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index 11178d4..74261f8 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -65,7 +65,16 @@ "settings.battery.capacity": "容量", "settings.battery.capacity.desc": "当前最大容量与设计容量。", "settings.battery.capacity.val": "{0:0.0} / {1:0.0} 瓦时", - "settings.display.sub": "根据电源自动切换刷新率。", + "settings.display.sub": "屏幕亮度与刷新率。", + "settings.bright.group": "亮度", + "settings.bright.cap": "限制亮度", + "settings.bright.cap.desc": "保护 OLED 屏幕免于烧屏:亮度超过上限时会平滑地降回上限。Windows 不允许锁定亮度滑块本身,只能事后调回。若您再次调高,应用会让步,2 小时内不再干预亮度。", + "settings.bright.cap.ac": "接通电源时的上限", + "settings.bright.cap.ac.desc": "接通电源时的最大亮度。", + "settings.bright.cap.battery": "使用电池时的上限", + "settings.bright.cap.battery.desc": "使用电池时的最大亮度。", + "settings.bright.adaptive": "上限当前不生效:电源计划中已启用自适应亮度,Windows 会根据光线传感器自动调节亮度,两者会互相干扰。可在 设置 → 系统 → 屏幕 → “光线变化时自动更改亮度” 中关闭。", + "settings.hz.group": "刷新率", "settings.hz.auto": "按电源自动刷新率", "settings.hz.auto.desc": "接通电源时更高,使用电池时更低,省电。", "settings.hz.hold": "保持刷新率", @@ -138,7 +147,7 @@ "settings.touchscreen.feature": "触摸屏控制", "settings.touchscreen.feature.desc": "面板单元格与按键动作。禁用的触摸屏重启后总会自动恢复。", "settings.refresh.feature": "屏幕刷新率控制", - "settings.refresh.feature.desc": "菜单项、面板单元格和“屏幕”选项卡。若不需要自动切换刷新率,可关闭。", + "settings.refresh.feature.desc": "菜单项、面板单元格和“屏幕”选项卡中的刷新率部分。若不需要自动切换刷新率,可关闭。", "settings.touchpad.sub": "触摸板的行为。", "settings.touchpad.deadzone": "底部死区", "settings.touchpad.deadzone.desc": "不在底部区域开始触摸——防止手掌误触。", diff --git a/src/Program.cs b/src/Program.cs index 7642521..adac8a7 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -44,6 +44,7 @@ private static void Main() () => c.ChargeCare && !c.TravelMode ? c.CarePercent() : 100); }); services.AddSingleton(); + services.AddSingleton(); // лимит яркости (XIC-29); события ему раздаёт PowerProfileGuard services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SystemIntegration/Brightness.cs b/src/SystemIntegration/Brightness.cs index 2a8a0b7..8ad0dcd 100644 --- a/src/SystemIntegration/Brightness.cs +++ b/src/SystemIntegration/Brightness.cs @@ -11,6 +11,14 @@ public static class Brightness { private const string ScopePath = @"root\wmi"; + /// + /// Метки наших записей яркости: событие WmiMonitorBrightnessEvent с помеченным значением — + /// наше (восстановление слота, шаг плавного хода), любое другое — человек. Сравнение по + /// значению надёжнее окна затишья по времени: WMI-вызовы асинхронные и по таймингу не + /// выстраиваются, а плавный ход длиннее любого разумного окна (XIC-29). + /// + public static readonly OwnWrites Own = new(); + /// Текущая яркость 0–100, либо null (панель не отдаёт WMI-яркость). public static int? Get() { @@ -29,7 +37,8 @@ public static class Brightness /// /// Установить яркость (0–100). WMI-вызов может подтормаживать — уводим в фон, как смену /// видеорежима. Если нужное значение уже стоит — не трогаем (без лишнего моргания и без - /// паразитного WmiMonitorBrightnessEvent, который иначе запишется как «пользовательский»). + /// паразитного WmiMonitorBrightnessEvent; метку Own в этом случае тоже не ставим — событие + /// не придёт, а протухшая метка позже «съела» бы настоящий пользовательский выбор). /// public static void Apply(int percent) { @@ -39,24 +48,147 @@ public static void Apply(int percent) try { if (Get() == lvl) return; - using var s = new ManagementObjectSearcher(ScopePath, - "SELECT * FROM WmiMonitorBrightnessMethods"); - foreach (ManagementObject mo in s.Get()) - using (mo) - { - try - { - using var args = mo.GetMethodParameters("WmiSetBrightness"); - args["Timeout"] = (uint)1; - args["Brightness"] = (byte)lvl; - mo.InvokeMethod("WmiSetBrightness", args, null); - } - catch (Exception ex) { Log.Ex("Brightness.Apply.instance", ex); /* внешний монитор и т.п. */ } - } + Own.Note(lvl); + Set(lvl); } catch (Exception ex) { Log.Ex("Brightness.Apply", ex); } }); } + + /// + /// Плавный ход от к шагами по 1% (шкала + /// непрерывная, проверено на TM2424). Весь путь занимает ~ + /// независимо от дельты. Идёт в фоне: WmiSetBrightness небыстрый, спуск на 30% — 30 вызовов, + /// UI-поток их не ждёт. Отмена — пользователь схватил ползунок или сменились условия. + /// + public static void Ramp(int from, int to, int durationMs, CancellationToken ct) + { + from = Math.Clamp(from, 0, 100); + to = Math.Clamp(to, 0, 100); + int delta = Math.Abs(from - to); + if (delta == 0) return; + int interval = Math.Max(30, durationMs / delta); // пол на случай кривого config.json + int step = to > from ? 1 : -1; + Task.Run(() => + { + try + { + for (int v = from + step; ; v += step) + { + if (ct.IsCancellationRequested) return; + Own.Note(v); + Set(v); + if (v == to) return; + if (ct.WaitHandle.WaitOne(interval)) return; // сон с мгновенной отменой + } + } + catch (Exception ex) { Log.Ex("Brightness.Ramp", ex); } + }, ct); // отменили до старта — ход и не начнётся (CA2016) + } + + // Синхронная запись во все панели (вызывать только с фонового потока). + private static void Set(int lvl) + { + using var s = new ManagementObjectSearcher(ScopePath, + "SELECT * FROM WmiMonitorBrightnessMethods"); + foreach (ManagementObject mo in s.Get()) + using (mo) + { + try + { + using var args = mo.GetMethodParameters("WmiSetBrightness"); + args["Timeout"] = (uint)1; + args["Brightness"] = (byte)lvl; + mo.InvokeMethod("WmiSetBrightness", args, null); + } + catch (Exception ex) { Log.Ex("Brightness.Set.instance", ex); /* внешний монитор и т.п. */ } + } + } +} + +/// +/// Учёт значений яркости, выставленных нами и ещё не подтверждённых событием. Метка живёт +/// недолго: если запись не породила событие (панель не ответила), протухшая метка не должна +/// позже проглотить настоящий пользовательский выбор того же значения. Чистая логика — +/// тестируется с явным временем. +/// +public sealed class OwnWrites +{ + private const int TtlMs = 10_000; + + private readonly Dictionary _until = []; // значение → тик, до которого метка жива + private readonly object _lock = new(); + + public void Note(int level) => Note(level, Environment.TickCount64); + + public void Note(int level, long nowMs) + { + lock (_lock) _until[level] = nowMs + TtlMs; + } + + /// true — событие с этим значением наше; метка снимается (одноразовая). + public bool Consume(int level) => Consume(level, Environment.TickCount64); + + public bool Consume(int level, long nowMs) + { + lock (_lock) + { + if (!_until.TryGetValue(level, out long until)) return false; + _until.Remove(level); + return nowMs <= until; + } + } +} + +/// +/// Детект адаптивной яркости (ADAPTBRIGHT в активной схеме питания) — чистый Win32 из +/// powrprof.dll, без запуска powercfg. С ней лимит яркости не работает: Windows поднимала бы +/// яркость по датчику, мы — возвращали, получилась бы качель (XIC-29). +/// +public static class AdaptiveBrightness +{ + private static readonly Guid SubVideo = new("7516b95f-f776-4464-8c53-06167f40cc99"); + private static readonly Guid AdaptBright = new("fbd9aa66-9553-4097-ba44-ed6e9d65eab8"); + + [System.Runtime.InteropServices.DllImport("powrprof.dll")] + private static extern uint PowerGetActiveScheme(IntPtr rootKey, out IntPtr scheme); + + [System.Runtime.InteropServices.DllImport("powrprof.dll")] + private static extern uint PowerReadACValueIndex(IntPtr rootKey, ref Guid scheme, ref Guid sub, ref Guid setting, out uint value); + + [System.Runtime.InteropServices.DllImport("powrprof.dll")] + private static extern uint PowerReadDCValueIndex(IntPtr rootKey, ref Guid scheme, ref Guid sub, ref Guid setting, out uint value); + + [System.Runtime.InteropServices.DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr mem); + + /// Включена ли адаптивная яркость для указанного источника питания. + /// Ошибка чтения (нет датчика, урезанная схема) трактуется как «выключена» — + /// лучше работающий лимит, чем молча отключённая фича. + public static bool IsEnabled(bool ac) + { + try + { + if (PowerGetActiveScheme(IntPtr.Zero, out var p) != 0 || p == IntPtr.Zero) return false; + try + { + var scheme = System.Runtime.InteropServices.Marshal.PtrToStructure(p); + var sub = SubVideo; + var setting = AdaptBright; + uint v; + uint r = ac + ? PowerReadACValueIndex(IntPtr.Zero, ref scheme, ref sub, ref setting, out v) + : PowerReadDCValueIndex(IntPtr.Zero, ref scheme, ref sub, ref setting, out v); + return r == 0 && v != 0; + } + finally { LocalFree(p); } + } + catch (Exception ex) + { + Log.Ex("AdaptiveBrightness.IsEnabled", ex); + return false; + } + } } /// diff --git a/src/SystemIntegration/BrightnessCapGuard.cs b/src/SystemIntegration/BrightnessCapGuard.cs new file mode 100644 index 0000000..dbf5782 --- /dev/null +++ b/src/SystemIntegration/BrightnessCapGuard.cs @@ -0,0 +1,240 @@ +using XiControl.Config; + +namespace XiControl.SystemIntegration; + +/// +/// Лимит яркости экрана (XIC-29): выше лимита (свой для сети и батареи) яркость плавно +/// сводится обратно. Заблокировать ползунок Windows невозможно — только вернуть после факта, +/// поэтому вместо ультиматума — вежливый торг: раз в BrightnessConvergeMs разрыв сокращается +/// в BrightnessGapDivisor раз (каждый шаг — плавный ход Brightness.Ramp), остаток ≤ +/// BrightnessSnapPercent доводится сразу. Повторный подъём пользователем после нашего шага — +/// осознанный сигнал «мне правда нужно ярче»: отступаем на BrightnessBackoffMin (сбрасывается +/// блокировкой, сном, сменой питания, перезапуском). Понижение ниже лимита не трогаем вовсе — +/// мы никогда не поднимаем. При включённой адаптивной яркости не работает: Windows поднимала +/// бы яркость по датчику, мы — возвращали, получилась бы качель. +/// +/// События яркости сюда доставляет PowerProfileGuard (единственный подписчик BrightnessWatcher), +/// уже отличив наши записи от пользовательских по меткам Brightness.Own. Всё происходит на +/// потоках пула (события WMI, WorkerTimer) — состояние под общим замком, UI-поток не участвует. +/// +public sealed class BrightnessCapGuard : IDisposable +{ + /// Плавный ход яркости; шов для тестов (прод — Brightness.Ramp). + public delegate void RampFn(int from, int to, CancellationToken ct); + + private readonly AppConfig _cfg; + private readonly IPowerEvents _power; + private readonly IAppTimer _converge; // шаги схождения, раз в BrightnessConvergeMs + private readonly IAppTimer _backoffTimer; // одноразовый: конец паузы после «протеста» + private readonly Func _read; + private readonly RampFn _ramp; + private readonly Func _adaptive; + private readonly object _lock = new(); + + private int _last = -1; // последняя известная яркость (события и чтения); -1 — неизвестна + private bool _converging; // таймер схождения взведён (у IAppTimer нет IsRunning) + private bool _stepped; // в этом эпизоде уже был наш шаг вниз → подъём = осознанный протест + private bool _backoff; // отступили: до конца паузы яркость не трогаем + private CancellationTokenSource? _rampCts; + + public BrightnessCapGuard(AppConfig cfg, IPowerEvents power, + IAppTimer? converge = null, IAppTimer? backoff = null, + Func? read = null, RampFn? ramp = null, Func? adaptive = null) + { + _cfg = cfg; + _power = power; + _converge = converge ?? new WorkerTimer(); + _backoffTimer = backoff ?? new WorkerTimer(); + _read = read ?? Brightness.Get; + _ramp = ramp ?? ((f, t, ct) => Brightness.Ramp(f, t, Math.Max(1000, cfg.BrightnessRampMs), ct)); + _adaptive = adaptive ?? AdaptiveBrightness.IsEnabled; + + _converge.Tick += OnConvergeTick; + _backoffTimer.Tick += OnBackoffTick; + } + + /// Лимит для источника питания; кривое config.json-значение клэмпится + /// (0% погасил бы экран совсем; 100 = лимит фактически выключен). + public int Cap(bool online) => + Math.Clamp(online ? _cfg.BrightnessCapAc : _cfg.BrightnessCapBattery, 10, 100); + + /// + /// Можно ли записать эту яркость в слот «Запоминать яркость»: слот хранит намерение + /// пользователя, превышение лимита не сохраняется ВООБЩЕ (не обрезается!) — кламп при + /// записи постепенно съедал бы настройку человека: комфортные 55 при лимите 60 после + /// одного подъёма до 80 навсегда превратились бы в 60. + /// + public bool AllowsRemember(int level) => + !_cfg.BrightnessCapEnabled || level <= Cap(_power.IsOnline); + + /// Кламп восстанавливаемой яркости: слот, запомненный при старом (высоком) лимите, + /// не должен пробить новый. Сам слот в конфиге не трогается — вернут лимит, вернётся и яркость. + public int ClampRestore(int level, bool online) => + _cfg.BrightnessCapEnabled ? Math.Min(level, Cap(online)) : level; + + /// + /// Следующий шаг схождения: разрыв (current − cap) сокращается в divisor раз (вверх до + /// целого), остаток ≤ snap доводится до лимита сразу — иначе гонялись бы за половинками + /// бесконечно. Пример (лимит 60, делитель 2): 80 → 70 → 65 → 63 → 62 → 60. + /// + public static int NextStep(int current, int cap, int divisor, int snap) + { + int gap = current - cap; + if (gap <= Math.Max(1, snap)) return cap; + divisor = Math.Max(2, divisor); // 1 не сокращал бы разрыв — схождение стояло бы на месте + return cap + (gap + divisor - 1) / divisor; + } + + /// + /// Событие яркости от PowerProfileGuard. Наши записи (own) только обновляют «последнюю + /// известную»; для пользовательских решается судьба эпизода: ниже лимита — не наше дело, + /// подъём после нашего шага — отступаем, иначе — (пере)взводим схождение. settling — + /// окно затишья после смены питания: яркость в нём меняет сама Windows, протестом не считаем. + /// + public void OnBrightness(int level, bool own, bool settling) + { + lock (_lock) + { + int prev = _last; + _last = level; + if (own) return; + if (!_cfg.BrightnessCapEnabled) return; + + CancelRampLocked(); // человек взялся за ползунок — наш недоигранный ход неактуален + + if (level <= Cap(_power.IsOnline)) { EpisodeDoneLocked(); return; } + if (_backoff) return; + if (!settling && prev >= 0 && level > prev && _stepped) { BackoffLocked(); return; } + EnsureConvergeLocked(); + } + } + + /// + /// Свериться с текущей яркостью: старт приложения, включение фичи, смена лимитов, смена + /// питания, конец паузы. Читает WMI синхронно — звать с фонового потока (Task.Run). + /// + public void Evaluate() + { + bool online = _power.IsOnline; + if (!_cfg.BrightnessCapEnabled || _adaptive(online)) { Halt(); return; } + + int? cur = _read(); + lock (_lock) + { + if (cur is int c) _last = c; + if (_backoff) return; + if (_last > Cap(online)) EnsureConvergeLocked(); + else EpisodeDoneLocked(); // яркость неизвестна (-1) или в норме — следить нечего + } + } + + /// Сбросить паузу «не трогаем» (блокировка/разблокировка, пробуждение, смена + /// питания, смена настроек): условия сменились — торг начинается заново. + public void ResetBackoff() + { + lock (_lock) + { + if (!_backoff) return; + _backoffTimer.Stop(); + _backoff = false; + } + } + + // Шаг схождения. Тикает на пуле (WorkerTimer) — синхронные Win32/WMI-проверки здесь законны. + private void OnConvergeTick() + { + bool online = _power.IsOnline; + if (!_cfg.BrightnessCapEnabled || _adaptive(online)) { Halt(); return; } // адаптивную включили на ходу + + int level; + lock (_lock) + { + if (_backoff || !_converging) return; + level = _last; + } + if (level < 0 && _read() is int c) { level = c; lock (_lock) _last = c; } + + lock (_lock) + { + int cap = Cap(online); + if (level <= cap) { EpisodeDoneLocked(); return; } // сошлись (или яркость так и не прочлась) + + int to = NextStep(level, cap, _cfg.BrightnessGapDivisor, _cfg.BrightnessSnapPercent); + _stepped = true; + if (to <= cap) + { + // финальный шаг — доводим до лимита и закрываем эпизод; подъём во время этого + // хода начнёт новый эпизод (снова вежливо, с минуты ожидания), не протест + EpisodeDoneLocked(); + } + StartRampLocked(level, to); + } + } + + private void OnBackoffTick() + { + lock (_lock) + { + _backoffTimer.Stop(); // одноразовый + if (!_backoff) return; + _backoff = false; + } + Evaluate(); // пауза вышла — всё ещё выше лимита? снова сходимся + } + + // Полная остановка (фича выключена / адаптивная яркость): забыть эпизод и паузу. + private void Halt() + { + lock (_lock) + { + EpisodeDoneLocked(); + _backoffTimer.Stop(); + _backoff = false; + } + } + + private void EnsureConvergeLocked() + { + if (_converging) return; + _converge.Interval = Math.Max(5000, _cfg.BrightnessConvergeMs); // пол — защита от кривого config.json + _converge.Start(); + _converging = true; + } + + private void EpisodeDoneLocked() + { + _converge.Stop(); + _converging = false; + _stepped = false; + CancelRampLocked(); + } + + private void BackoffLocked() + { + EpisodeDoneLocked(); + _backoff = true; + _backoffTimer.Interval = Math.Clamp(_cfg.BrightnessBackoffMin, 1, 24 * 60) * 60_000; + _backoffTimer.Start(); + } + + private void StartRampLocked(int from, int to) + { + CancelRampLocked(); + _rampCts = new CancellationTokenSource(); + _ramp(from, to, _rampCts.Token); + } + + private void CancelRampLocked() + { + _rampCts?.Cancel(); + _rampCts?.Dispose(); + _rampCts = null; + } + + public void Dispose() + { + lock (_lock) CancelRampLocked(); + _converge.Dispose(); + _backoffTimer.Dispose(); + } +} diff --git a/src/SystemIntegration/IAppTimer.cs b/src/SystemIntegration/IAppTimer.cs index 59697eb..65cec00 100644 --- a/src/SystemIntegration/IAppTimer.cs +++ b/src/SystemIntegration/IAppTimer.cs @@ -14,6 +14,24 @@ public interface IAppTimer : IDisposable void Stop(); } +/// Прод-реализация на System.Threading.Timer: тикает в пуле потоков, Start/Stop +/// потокобезопасны и работают с любого потока. Для логики, живущей вне UI-потока +/// (BrightnessCapGuard: события яркости приходят с пула, WinForms-таймер оттуда не тикает +/// никогда — см. UiTimer ниже). Подписчик Tick сам отвечает за свою потокобезопасность. +public sealed class WorkerTimer : IAppTimer +{ + private readonly System.Threading.Timer _t; + + public event Action? Tick; + + public WorkerTimer() => _t = new System.Threading.Timer(_ => Tick?.Invoke()); + + public int Interval { get; set; } = 100; + public void Start() => _t.Change(Interval, Interval); + public void Stop() => _t.Change(Timeout.Infinite, Timeout.Infinite); + public void Dispose() => _t.Dispose(); +} + /// Прод-реализация поверх System.Windows.Forms.Timer (тикает в UI-потоке). /// Start — только с потока с насосом сообщений: WinForms-таймер, стартованный с фонового /// потока (напр. SystemEvents), не тикает никогда. Событийные источники это учитывают — diff --git a/src/SystemIntegration/PowerProfileGuard.cs b/src/SystemIntegration/PowerProfileGuard.cs index 5af03f2..7a9797e 100644 --- a/src/SystemIntegration/PowerProfileGuard.cs +++ b/src/SystemIntegration/PowerProfileGuard.cs @@ -10,6 +10,11 @@ namespace XiControl.SystemIntegration; /// AcPerfMode/BatteryPerfMode и запомненную яркость для этого состояния. Пока пользователь /// работает — запоминает его яркость в текущий слот, чтобы восстановить в следующий раз. /// Паттерн как у ChargeGuard/RefreshRateGuard: событие питания + дебаунс + переустановка. +/// +/// Единственный подписчик BrightnessWatcher: каждое событие сначала классифицируется +/// (наша запись или человек — по меткам Brightness.Own), затем раздаётся лимиту яркости +/// (BrightnessCapGuard, XIC-29) и запоминанию. Слот хранит намерение пользователя, лимит +/// работает фильтром на выходе: превышение не запоминается вообще, восстановление клампится. /// public sealed class PowerProfileGuard : IDisposable { @@ -20,6 +25,7 @@ public sealed class PowerProfileGuard : IDisposable private readonly IMifsClient _mifs; private readonly AppConfig _cfg; private readonly IPowerEvents _power; + private readonly BrightnessCapGuard _cap; private readonly IAppTimer _debounce; private readonly BrightnessWatcher _brightness = new(); private readonly System.Threading.Timer _save; @@ -29,11 +35,13 @@ public sealed class PowerProfileGuard : IDisposable /// Вызывается (на потоке пула) после применения режима — обновить значок трея. public Action? ModeApplied; - public PowerProfileGuard(IMifsClient mifs, AppConfig cfg, IPowerEvents power, IAppTimer? debounce = null) + public PowerProfileGuard(IMifsClient mifs, AppConfig cfg, IPowerEvents power, + BrightnessCapGuard cap, IAppTimer? debounce = null) { _mifs = mifs; _cfg = cfg; _power = power; + _cap = cap; _debounce = debounce ?? new UiTimer(); _debounce.Interval = DebounceMs; @@ -45,12 +53,17 @@ public PowerProfileGuard(IMifsClient mifs, AppConfig cfg, IPowerEvents power, IA _brightness.Start(); _power.PowerModeChanged += OnPowerModeChanged; + // блокировка/разблокировка сбрасывают паузу лимита яркости (XIC-29). Напрямую из + // SystemEvents (как _locked в TrayApp): узкому шву IPowerEvents событие сеанса чужое, + // а guard-у от него нужен только потокобезопасный сброс + фоновая сверка. + SystemEvents.SessionSwitch += OnSessionSwitch; } private void OnPowerModeChanged(PowerModes mode) { // Resume — выход из сна; StatusChange — смена питания AC↔батарея if (mode is not (PowerModes.Resume or PowerModes.StatusChange)) return; + _cap.ResetBackoff(); // сон/смена питания — условия сменились, торг лимита заново // окно «затишья» ставим сразу: и переход яркости от Windows, и наше применение через // дебаунс не должны попасть в «пользовательскую» яркость (иначе слоты перезапишутся мусором) _settleUntil = Environment.TickCount + SettleMs; @@ -58,6 +71,13 @@ private void OnPowerModeChanged(PowerModes mode) _debounce.Start(); } + private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e) + { + if (e.Reason is not (SessionSwitchReason.SessionLock or SessionSwitchReason.SessionUnlock)) return; + _cap.ResetBackoff(); + Task.Run(_cap.Evaluate); // WMI-чтение яркости — не на потоке SystemEvents + } + /// Применить профиль текущего питания прямо сейчас (старт / включение опции). public void Reapply() { @@ -67,12 +87,14 @@ public void Reapply() private void Apply() { - // режим держат «Профили питания», яркость — самостоятельная опция «Запоминать яркость» - // (может работать и без профилей). Нечего делать — выходим, не будим пул. - if (!_cfg.PowerProfiles && !_cfg.RememberBrightness) return; + // режим держат «Профили питания», яркость — самостоятельные опции «Запоминать яркость» + // и лимит (XIC-29). Нечего делать — выходим, не будим пул. + if (!_cfg.PowerProfiles && !_cfg.RememberBrightness && !_cfg.BrightnessCapEnabled) return; bool online = _power.IsOnline; PerfMode? wantMode = _cfg.PowerProfiles ? (online ? _cfg.AcPerfMode : _cfg.BatteryPerfMode) : null; int? wantBright = _cfg.RememberBrightness ? (online ? _cfg.AcBrightness : _cfg.BatteryBrightness) : null; + // слот, запомненный при старом (высоком) лимите, не должен пробить новый; сам слот не трогаем + if (wantBright is int b) wantBright = _cap.ClampRestore(b, online); // WMI-вызовы (смена режима + яркость) — в фон, чтобы не держать UI-поток Task.Run(() => @@ -88,14 +110,22 @@ private void Apply() catch (Exception ex) { Log.Ex("PowerProfileGuard.Apply.mode", ex); /* железо могло быть недоступно */ } if (wantBright is int lvl) Brightness.Apply(lvl); + _cap.Evaluate(); // после смены питания яркость могла остаться выше лимита нового источника }); } - // пользователь поменял яркость — запомнить её в слот текущего питания (но не в окно «затишья») + // событие яркости: классифицировать (наша запись/человек) и раздать лимиту и запоминанию private void OnBrightnessChanged(int level) { + bool own = Brightness.Own.Consume(level); + bool settling = Environment.TickCount - _settleUntil < 0; + _cap.OnBrightness(level, own, settling); // лимиту — все события: свои шаги он не считает протестом + + // дальше — запоминание пользовательского выбора в слот текущего питания + if (own) return; // восстановление слота / шаг лимита — не выбор человека if (!_cfg.RememberBrightness) return; // яркость независима от «Профилей питания» - if (Environment.TickCount - _settleUntil < 0) return; // ещё «затишье» после смены питания + if (settling) return; // ещё «затишье» после смены питания + if (!_cap.AllowsRemember(level)) return; // выше лимита: намерение не запоминаем (и не обрезаем) bool online = _power.IsOnline; lock (_lock) @@ -108,9 +138,11 @@ private void OnBrightnessChanged(int level) public void Dispose() { + SystemEvents.SessionSwitch -= OnSessionSwitch; _power.PowerModeChanged -= OnPowerModeChanged; _debounce.Dispose(); _brightness.Dispose(); _save.Dispose(); + // _cap диспоузит DI-провайдер (инжектированное не трогаем) } } diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index fe51209..dca11be 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -20,6 +20,7 @@ public sealed class AppController private readonly ChargeGuard _charge; private readonly RefreshRateGuard _hz; private readonly PowerProfileGuard _profiles; + private readonly BrightnessCapGuard _capGuard; private readonly TravelChargeMonitor _travel; private readonly TouchpadControl _touchpad; private readonly TouchscreenControl _touchscreen; @@ -52,7 +53,7 @@ public sealed class AppController public Action? FirmwareFailed; // команда прошивке не прошла — UI показывает честную ошибку public AppController(IMifsClient mifs, AppConfig cfg, IPowerEvents power, ILocalizer loc, - ChargeGuard charge, RefreshRateGuard hz, PowerProfileGuard profiles, + ChargeGuard charge, RefreshRateGuard hz, PowerProfileGuard profiles, BrightnessCapGuard capGuard, TravelChargeMonitor travel, TouchpadControl touchpad, TouchscreenControl touchscreen, TouchpadDeadZone deadZone) { @@ -63,6 +64,7 @@ public AppController(IMifsClient mifs, AppConfig cfg, IPowerEvents power, ILocal _charge = charge; _hz = hz; _profiles = profiles; + _capGuard = capGuard; _travel = travel; _touchpad = touchpad; _touchscreen = touchscreen; @@ -127,6 +129,11 @@ public void Startup() // текущего питания на старте (при профилях это уже сделал _profiles.Reapply выше). if (_cfg.RememberBrightness && !_cfg.PowerProfiles) _profiles.Reapply(); + // Лимит яркости (XIC-29): превышение на старте сводится тем же вежливым механизмом. + // Reapply выше уже сверяется сам; отдельная сверка нужна, когда включён только лимит. + if (_cfg.BrightnessCapEnabled && !_cfg.RememberBrightness && !_cfg.PowerProfiles) + Task.Run(_capGuard.Evaluate); + // «Режим совы»: восстановить после сбоя, включить заново, либо погасить, если фичу отключили if (_cfg.Awake && !_cfg.OwlMode) { AwakeMode.Disable(_cfg); _cfg.Awake = false; _cfg.Save(); } else if (_cfg.Awake) { AwakeMode.Enable(_cfg); _cfg.Save(); } @@ -396,6 +403,28 @@ private void SeedCurrentBrightness() else _cfg.BatteryBrightness = lvl; } + /// Лимит яркости вкл/выкл (XIC-29). Включили — текущее превышение сводится тем же + /// вежливым механизмом схождения; выключили — guard сам останавливает всё, включая паузу. + public void SetBrightnessCap(bool on) + { + if (_cfg.BrightnessCapEnabled == on) return; + _cfg.BrightnessCapEnabled = on; + _cfg.Save(); + _capGuard.ResetBackoff(); + Task.Run(_capGuard.Evaluate); // сверка читает WMI — не с UI-потока + } + + /// Лимиты яркости из окна настроек (сеть, батарея) — сохранить и свериться. + public void SetBrightnessCaps(int ac, int batt) + { + _cfg.BrightnessCapAc = ac; + _cfg.BrightnessCapBattery = batt; + _cfg.Save(); + if (!_cfg.BrightnessCapEnabled) return; + _capGuard.ResetBackoff(); // лимит сменили осознанно — старая пауза больше не про эти условия + Task.Run(_capGuard.Evaluate); + } + // ---- Авто-герцовка ---- /// Авто-герцовка: вкл — сразу применить частоту по текущему питанию, выкл — не трогаем. diff --git a/src/Ui/Settings/DisplayTab.cs b/src/Ui/Settings/DisplayTab.cs index f911d54..16ec80f 100644 --- a/src/Ui/Settings/DisplayTab.cs +++ b/src/Ui/Settings/DisplayTab.cs @@ -1,14 +1,39 @@ -using XiControl.Config; +using XiControl.Config; using XiControl.Localization; namespace XiControl.Ui.Settings; -/// Вкладка «Экран»: авто-герцовка и частоты для сети/батареи. +/// +/// Вкладка «Экран»: яркость (лимит + запоминание, XIC-29) и авто-герцовка. С XIC-29 вкладка +/// видна всегда — яркость от фичи «управление частотой» не зависит; при выключенной фиче +/// скрывается только раздел частоты. +/// public sealed class DisplayTab : SettingsPane { public DisplayTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act, Action rebuild) : base(ui) { ui.AddHeader(this, "settings.tab.display", "settings.display.sub"); + + // ---- Яркость ---- + ui.AddGroup(this, "settings.bright.group"); + // rebuild — зажечь/погасить комбо лимитов и плашку про адаптивную яркость + ui.AddRow(this, "settings.bright.cap", "settings.bright.cap.desc", + ui.Toggle(cfg.BrightnessCapEnabled, on => { act.SetBrightnessCap(on); rebuild(); })); + var capAc = PercentCombo(cfg.BrightnessCapAc, v => act.SetBrightnessCaps(v, cfg.BrightnessCapBattery)); + capAc.Enabled = cfg.BrightnessCapEnabled; + ui.AddRow(this, "settings.bright.cap.ac", "settings.bright.cap.ac.desc", capAc); + var capBatt = PercentCombo(cfg.BrightnessCapBattery, v => act.SetBrightnessCaps(cfg.BrightnessCapAc, v)); + capBatt.Enabled = cfg.BrightnessCapEnabled; + ui.AddRow(this, "settings.bright.cap.battery", "settings.bright.cap.battery.desc", capBatt); + // честная плашка: с адаптивной яркостью лимит не работает (иначе качель с датчиком) + if (cfg.BrightnessCapEnabled && act.IsAdaptiveBrightness()) + ui.AddNote(this, "settings.bright.adaptive"); + ui.AddRow(this, "settings.profile.brightness", "settings.brightness.desc", + ui.Toggle(cfg.RememberBrightness, act.SetRememberBrightness)); + + // ---- Частота — только пока «управление частотой» включено во вкладке «Функции» ---- + if (!cfg.RefreshRateFeature) return; + ui.AddGroup(this, "settings.hz.group"); // мастер-тумблер: rebuild гасит/зажигает «удерживать» — без авто-частоты возвращать нечего ui.AddRow(this, "settings.hz.auto", "settings.hz.auto.desc", ui.Toggle(cfg.AutoRefreshRate, on => { act.SetAutoHz(on); rebuild(); })); @@ -32,4 +57,13 @@ private ComboBox HzCombo(int current, Action apply) return Ui.Combo([.. rates.Select(r => $"{r} " + Loc.T("settings.hz.unit"))], Array.IndexOf(rates, current), i => apply(rates[i]), Ui.Sc(110)); } + + // Комбо лимита яркости: та же механика — рукописное значение из config.json не подменяем пресетом + private ComboBox PercentCombo(int current, Action apply) + { + int[] presets = [90, 80, 70, 60, 50, 40, 30]; + int[] caps = presets.Contains(current) ? presets : [current, .. presets]; + return Ui.Combo([.. caps.Select(c => $"{c}%")], + Array.IndexOf(caps, current), i => apply(caps[i]), Ui.Sc(110)); + } } diff --git a/src/Ui/Settings/FeaturesTab.cs b/src/Ui/Settings/FeaturesTab.cs index 4fa55c9..08d4acd 100644 --- a/src/Ui/Settings/FeaturesTab.cs +++ b/src/Ui/Settings/FeaturesTab.cs @@ -19,7 +19,8 @@ public FeaturesTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act, Actio ui.Toggle(cfg.TouchpadFeature, on => { cfg.TouchpadFeature = on; cfg.Save(); })); ui.AddRow(this, "settings.touchscreen.feature", "settings.touchscreen.feature.desc", ui.Toggle(cfg.TouchscreenFeature, on => { cfg.TouchscreenFeature = on; cfg.Save(); })); - // выкл/вкл прячет-показывает вкладку «Экран» → пересобрать окно (после выхода из обработчика) + // выкл/вкл прячет-показывает раздел частоты во вкладке «Экран» → пересобрать окно + // (после выхода из обработчика); сама вкладка с XIC-29 видна всегда — там яркость ui.AddRow(this, "settings.refresh.feature", "settings.refresh.feature.desc", ui.Toggle(cfg.RefreshRateFeature, on => { act.SetRefreshRateFeature(on); rebuild(); })); } diff --git a/src/Ui/Settings/GeneralTab.cs b/src/Ui/Settings/GeneralTab.cs index 8f809b0..451015f 100644 --- a/src/Ui/Settings/GeneralTab.cs +++ b/src/Ui/Settings/GeneralTab.cs @@ -29,8 +29,7 @@ public GeneralTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act, Action i => act.SetFlyoutTheme(themeValues[i]), ui.Sc(150))); ui.AddGroup(this, "settings.general.comfort"); - ui.AddRow(this, "settings.profile.brightness", "settings.brightness.desc", - ui.Toggle(cfg.RememberBrightness, act.SetRememberBrightness)); + // «Запоминать яркость» переехала на вкладку «Экран» — к лимитам яркости (XIC-29) // выключенный тумблер = ноль исходящих запросов, поэтому подпись прямая, без оговорок ui.AddRow(this, "settings.updates.check", "settings.updates.check.desc", ui.Toggle(cfg.CheckUpdates, act.SetCheckUpdates)); diff --git a/src/Ui/SettingsActions.cs b/src/Ui/SettingsActions.cs index 3409174..08038dc 100644 --- a/src/Ui/SettingsActions.cs +++ b/src/Ui/SettingsActions.cs @@ -29,6 +29,9 @@ public sealed class SettingsActions public required Action SetStartStrategy; public required Action SetProfileMode; // ac, mode public required Action SetRememberBrightness; + public required Action SetBrightnessCap; // лимит яркости вкл/выкл (XIC-29) + public required Action SetBrightnessCaps; // лимиты яркости: ac, batt + public required Func IsAdaptiveBrightness; // адаптивная яркость в схеме питания → лимит не работает public required Action SetAutoHz; public required Action SetHoldRefreshRate; // возвращать частоту после чужих изменений public required Action SetRefreshRateFeature; // «управление частотой» как фича вкл/выкл diff --git a/src/Ui/SettingsForm.cs b/src/Ui/SettingsForm.cs index 3ac3ffa..838a6f4 100644 --- a/src/Ui/SettingsForm.cs +++ b/src/Ui/SettingsForm.cs @@ -224,8 +224,9 @@ void AddTab(string key, NavGlyph glyph, Func make) AddTab("settings.tab.general", NavGlyph.General, () => new GeneralTab(_ui, _cfg, _act, rebuild)); AddTab("settings.tab.features", NavGlyph.Features, () => new FeaturesTab(_ui, _cfg, _act, rebuild)); AddTab("settings.tab.battery", NavGlyph.Battery, () => new BatteryTab(_ui, _cfg, _act, rebuild)); - if (_cfg.RefreshRateFeature) - AddTab("settings.tab.display", NavGlyph.Display, () => new DisplayTab(_ui, _cfg, _act, rebuild)); + // «Экран» с XIC-29 виден всегда (там яркость); фича «управление частотой» скрывает + // только раздел частоты внутри вкладки + AddTab("settings.tab.display", NavGlyph.Display, () => new DisplayTab(_ui, _cfg, _act, rebuild)); AddTab("settings.tab.touchpad", NavGlyph.Touchpad, () => new TouchpadTab(_ui, _cfg, _act, rebuild)); AddTab("settings.tab.perf", NavGlyph.Perf, () => new PerfTab(_ui, _cfg, _act, rebuild)); AddTab("settings.tab.keys", NavGlyph.Keys, () => new KeysTab(_ui, _cfg, rebuild)); diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs index 8887564..55987d3 100644 --- a/src/Ui/TrayApp.cs +++ b/src/Ui/TrayApp.cs @@ -539,6 +539,11 @@ private void OpenSettings() SetStartStrategy = _controller.SetStartStrategy, SetProfileMode = _controller.SetProfileMode, SetRememberBrightness = _controller.SetRememberBrightness, + SetBrightnessCap = _controller.SetBrightnessCap, + SetBrightnessCaps = _controller.SetBrightnessCaps, + // предупреждаем, если адаптивная яркость включена хоть для одного источника: + // лимит не сработает ровно тогда, когда питание переключится на него + IsAdaptiveBrightness = () => AdaptiveBrightness.IsEnabled(true) || AdaptiveBrightness.IsEnabled(false), SetAutoHz = _controller.ToggleAutoHz, SetRefreshRateFeature = _controller.ToggleRefreshRateFeature, SetHoldRefreshRate = _controller.SetHoldRefreshRate, diff --git a/tests/XiControl.Tests/AppControllerTests.cs b/tests/XiControl.Tests/AppControllerTests.cs index 4f6f165..e8f98cb 100644 --- a/tests/XiControl.Tests/AppControllerTests.cs +++ b/tests/XiControl.Tests/AppControllerTests.cs @@ -24,10 +24,13 @@ public sealed class AppControllerTests public AppControllerTests() { Log.Enabled = false; + // лимит яркости — на фейках: без WMI и реальных таймеров (сам тестируется отдельно) + var cap = new BrightnessCapGuard(_cfg, _power, + new FakeTimer(), new FakeTimer(), () => null, (_, _, _) => { }, _ => false); _c = new AppController(_mifs, _cfg, _power, new Localizer(), new ChargeGuard(_mifs, _power, () => _cfg.ChargeCare ? _cfg.CarePercent() : 100, new FakeTimer()), new RefreshRateGuard(_cfg, _power, new FakeDisplayEvents(), new FakeTimer()), - new PowerProfileGuard(_mifs, _cfg, _power, new FakeTimer()), + new PowerProfileGuard(_mifs, _cfg, _power, cap, new FakeTimer()), cap, new TravelChargeMonitor(_cfg, _power, new FakeTimer()), new TouchpadControl(_cfg), new TouchscreenControl(_cfg), new TouchpadDeadZone(_cfg, new TouchpadControl(_cfg))) diff --git a/tests/XiControl.Tests/BrightnessCapGuardTests.cs b/tests/XiControl.Tests/BrightnessCapGuardTests.cs new file mode 100644 index 0000000..97e2008 --- /dev/null +++ b/tests/XiControl.Tests/BrightnessCapGuardTests.cs @@ -0,0 +1,313 @@ +using FluentAssertions; +using XiControl.Config; +using XiControl.SystemIntegration; +using Xunit; + +namespace XiControl.Tests; + +/// +/// Лимит яркости (XIC-29): арифметика схождения, отличение своих записей от пользовательских +/// (OwnWrites), машина состояний guard-а на фейках — схождение, протест → пауза, сбросы. +/// Живая плавность хода и WMI — глазами. +/// +public sealed class BrightnessCapGuardTests +{ + private readonly AppConfig _cfg = new() { BrightnessCapEnabled = true, BrightnessCapAc = 60, BrightnessCapBattery = 60 }; + private readonly FakePowerEvents _power = new(); + private readonly FakeTimer _converge = new(); + private readonly FakeTimer _backoff = new(); + private readonly List<(int From, int To)> _ramps = []; + private int? _brightness = 50; // что вернёт «чтение яркости» + private bool _adaptive; + + public BrightnessCapGuardTests() => Log.Enabled = false; + + private BrightnessCapGuard NewGuard() => + new(_cfg, _power, _converge, _backoff, + () => _brightness, (f, t, _) => _ramps.Add((f, t)), _ => _adaptive); + + // ---- Чистая арифметика схождения ---- + + [Theory] + [InlineData(80, 60, 70)] // разрыв 20 → половина + [InlineData(70, 60, 65)] + [InlineData(65, 60, 63)] // ceil(5/2) = 3 + [InlineData(63, 60, 62)] + [InlineData(62, 60, 60)] // остаток ≤ 2 — доводим сразу + [InlineData(61, 60, 60)] + public void NextStep_HalvesGapAndSnaps(int current, int cap, int expected) => + BrightnessCapGuard.NextStep(current, cap, divisor: 2, snap: 2).Should().Be(expected); + + [Fact] + public void NextStep_DivisorOne_StillConverges() => + // делитель 1 не сокращал бы разрыв — клэмпится к 2, схождение не стоит на месте + BrightnessCapGuard.NextStep(80, 60, divisor: 1, snap: 2).Should().Be(70); + + // ---- Метки своих записей ---- + + [Fact] + public void OwnWrites_ConsumeIsOneShot() + { + var own = new OwnWrites(); + own.Note(70, nowMs: 1000); + + own.Consume(70, nowMs: 2000).Should().BeTrue("помеченное значение — наша запись"); + own.Consume(70, nowMs: 2000).Should().BeFalse("метка одноразовая"); + own.Consume(55, nowMs: 2000).Should().BeFalse("чужое значение — пользователь"); + } + + [Fact] + public void OwnWrites_ExpiredMark_IsNotOurs() + { + // запись не породила событие (панель молчит) → протухшая метка не должна + // проглотить настоящий пользовательский выбор того же значения + var own = new OwnWrites(); + own.Note(70, nowMs: 1000); + + own.Consume(70, nowMs: 1000 + 60_000).Should().BeFalse(); + } + + // ---- Машина состояний guard-а ---- + + [Fact] + public void UserAboveCap_ArmsConverge_ButDoesNotTouchYet() + { + using var g = NewGuard(); + + g.OnBrightness(80, own: false, settling: false); + + _converge.Running.Should().BeTrue("превышение — ждём минуту, потом первый шаг"); + _ramps.Should().BeEmpty("не отматываем сразу — вежливый торг"); + } + + [Fact] + public void ConvergeTicks_HalveGap_ThenFinishAtCap() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + + _converge.Fire(); // 80 → 70 + g.OnBrightness(70, own: true, settling: false); // событие нашего хода + _converge.Fire(); // 70 → 65 + g.OnBrightness(65, own: true, settling: false); + _converge.Fire(); // 65 → 63 + g.OnBrightness(63, own: true, settling: false); + _converge.Fire(); // 63 → 62 + g.OnBrightness(62, own: true, settling: false); + _converge.Fire(); // 62 → 60: финал + + _ramps.Should().Equal((80, 70), (70, 65), (65, 63), (63, 62), (62, 60)); + _converge.Running.Should().BeFalse("сошлись — следить больше нечего"); + } + + [Fact] + public void RaiseAfterOurStep_BacksOff() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + _converge.Fire(); // наш шаг 80 → 70 состоялся + g.OnBrightness(70, own: true, settling: false); + + g.OnBrightness(90, own: false, settling: false); // «мне правда нужно ярче» + + _backoff.Running.Should().BeTrue("осознанный протест — отступаем"); + _converge.Running.Should().BeFalse(); + int before = _ramps.Count; + _converge.Fire(); + _ramps.Should().HaveCount(before, "во время паузы яркость не трогаем"); + } + + [Fact] + public void RaiseBeforeFirstStep_IsNotProtest() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + + g.OnBrightness(90, own: false, settling: false); // мы ещё ничего не отняли — просто ждём дальше + + _backoff.Running.Should().BeFalse(); + _converge.Running.Should().BeTrue(); + } + + [Fact] + public void BackoffExpiry_ResumesConvergence() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + _converge.Fire(); + g.OnBrightness(70, own: true, settling: false); + g.OnBrightness(90, own: false, settling: false); // пауза + + _brightness = 90; + _backoff.Fire(); // 2 часа вышли + + _backoff.Running.Should().BeFalse(); + _converge.Running.Should().BeTrue("всё ещё выше лимита — торг заново"); + } + + [Fact] + public void ResetBackoff_ClearsPause() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + _converge.Fire(); + g.OnBrightness(70, own: true, settling: false); + g.OnBrightness(90, own: false, settling: false); // пауза + + g.ResetBackoff(); // блокировка/сон/смена питания + _brightness = 90; + g.Evaluate(); + + _backoff.Running.Should().BeFalse(); + _converge.Running.Should().BeTrue("условия сменились — снова сходимся"); + } + + [Fact] + public void UserBelowCap_NeverTouched() + { + using var g = NewGuard(); + + g.OnBrightness(40, own: false, settling: false); + g.OnBrightness(20, own: false, settling: false); // понижение — тем более не наше дело + + _converge.Running.Should().BeFalse(); + _ramps.Should().BeEmpty("мы никогда не поднимаем яркость"); + } + + [Fact] + public void UserLoweringButStillAboveCap_KeepsConverging() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + _converge.Fire(); + g.OnBrightness(70, own: true, settling: false); + + g.OnBrightness(65, own: false, settling: false); // сам пошёл навстречу — не протест + + _backoff.Running.Should().BeFalse(); + _converge.Running.Should().BeTrue(); + _converge.Fire(); + _ramps[^1].Should().Be((65, 63), "схождение продолжается от нового уровня"); + } + + [Fact] + public void UserDropsBelowCap_EndsEpisode() + { + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + + g.OnBrightness(55, own: false, settling: false); + + _converge.Running.Should().BeFalse("яркость в норме — эпизод закрыт"); + } + + [Fact] + public void SettlingRaise_IsNotProtest() + { + // после смены питания яркость поднимает сама Windows — это не «мне нужно ярче» + using var g = NewGuard(); + g.OnBrightness(80, own: false, settling: false); + _converge.Fire(); + g.OnBrightness(70, own: true, settling: false); + + g.OnBrightness(95, own: false, settling: true); + + _backoff.Running.Should().BeFalse(); + _converge.Running.Should().BeTrue("превышение сводим, но без обид"); + } + + [Fact] + public void AdaptiveBrightness_DisablesFeature() + { + _adaptive = true; + using var g = NewGuard(); + + _brightness = 90; + g.Evaluate(); + + _converge.Running.Should().BeFalse("с адаптивной яркостью вышла бы качель — не работаем"); + } + + [Fact] + public void Disabled_DoesNothing() + { + _cfg.BrightnessCapEnabled = false; + using var g = NewGuard(); + + g.OnBrightness(95, own: false, settling: false); + _brightness = 95; + g.Evaluate(); + + _converge.Running.Should().BeFalse(); + _ramps.Should().BeEmpty(); + } + + [Fact] + public void Evaluate_OnStart_ConvergesExistingExcess() + { + using var g = NewGuard(); + + _brightness = 85; // старт приложения: яркость уже выше лимита + g.Evaluate(); + + _converge.Running.Should().BeTrue(); + _ramps.Should().BeEmpty("тем же вежливым механизмом — первый шаг через минуту"); + } + + // ---- Фильтр для «Запоминать яркость» ---- + + [Fact] + public void AllowsRemember_RejectsAboveCap_DoesNotClamp() + { + using var g = NewGuard(); + _power.IsOnline = true; + + g.AllowsRemember(55).Should().BeTrue(); + g.AllowsRemember(60).Should().BeTrue("ровно лимит — легально"); + g.AllowsRemember(80).Should().BeFalse("превышение не запоминается вообще (и не обрезается)"); + } + + [Fact] + public void AllowsRemember_UsesCapOfCurrentPowerSource() + { + _cfg.BrightnessCapAc = 90; + _cfg.BrightnessCapBattery = 50; + using var g = NewGuard(); + + _power.IsOnline = true; + g.AllowsRemember(80).Should().BeTrue(); + _power.IsOnline = false; + g.AllowsRemember(80).Should().BeFalse(); + } + + [Fact] + public void AllowsRemember_CapDisabled_AllowsEverything() + { + _cfg.BrightnessCapEnabled = false; + using var g = NewGuard(); + + g.AllowsRemember(100).Should().BeTrue(); + } + + [Fact] + public void ClampRestore_OldSlotDoesNotPierceNewCap() + { + using var g = NewGuard(); + + g.ClampRestore(85, online: true).Should().Be(60, "слот из времён высокого лимита клампится при восстановлении"); + g.ClampRestore(55, online: true).Should().Be(55); + _cfg.BrightnessCapEnabled = false; + g.ClampRestore(85, online: true).Should().Be(85, "лимит выключен — слот восстанавливается как есть"); + } + + [Fact] + public void Cap_ClampsHandEditedConfig() + { + _cfg.BrightnessCapAc = 0; // погасило бы экран совсем + _cfg.BrightnessCapBattery = 250; + using var g = NewGuard(); + + g.Cap(online: true).Should().Be(10); + g.Cap(online: false).Should().Be(100); + } +} diff --git a/tests/XiControl.Tests/PowerProfileGuardTests.cs b/tests/XiControl.Tests/PowerProfileGuardTests.cs index 5194869..c71be33 100644 --- a/tests/XiControl.Tests/PowerProfileGuardTests.cs +++ b/tests/XiControl.Tests/PowerProfileGuardTests.cs @@ -23,12 +23,17 @@ public sealed class PowerProfileGuardTests public PowerProfileGuardTests() => Log.Enabled = false; // не сорим в реальный log.txt + // Лимит яркости на фейках: без WMI-чтений и без реальных таймеров (сам он проверяется + // в BrightnessCapGuardTests, здесь — лишь обязательная зависимость guard-а профилей) + private BrightnessCapGuard NewCap(AppConfig cfg) => + new(cfg, _power, new FakeTimer(), new FakeTimer(), () => null, (_, _, _) => { }, _ => false); + [Fact] public void Reapply_OnAc_AppliesAcMode() { var cfg = new AppConfig { PowerProfiles = true, AcPerfMode = PerfMode.Turbo, BatteryPerfMode = PerfMode.Quiet }; _power.IsOnline = true; - using var guard = new PowerProfileGuard(_mifs, cfg, _power, _timer); + using var guard = new PowerProfileGuard(_mifs, cfg, _power, NewCap(cfg), _timer); guard.Reapply(); @@ -41,7 +46,7 @@ public void Reapply_OnBattery_AppliesBatteryMode() { var cfg = new AppConfig { PowerProfiles = true, AcPerfMode = PerfMode.Turbo, BatteryPerfMode = PerfMode.Quiet }; _power.IsOnline = false; - using var guard = new PowerProfileGuard(_mifs, cfg, _power, _timer); + using var guard = new PowerProfileGuard(_mifs, cfg, _power, NewCap(cfg), _timer); guard.Reapply(); @@ -56,7 +61,7 @@ public void RejectedMode_FallsBackToAuto() var cfg = new AppConfig { PowerProfiles = true, BatteryPerfMode = PerfMode.FullSpeed }; _power.IsOnline = false; _mifs.SetPerfModeResult = false; - using var guard = new PowerProfileGuard(_mifs, cfg, _power, _timer); + using var guard = new PowerProfileGuard(_mifs, cfg, _power, NewCap(cfg), _timer); guard.Reapply(); @@ -69,7 +74,7 @@ public void RejectedMode_FallsBackToAuto() public void ProfilesDisabled_ReapplyDoesNothing() { var cfg = new AppConfig { PowerProfiles = false, RememberBrightness = false, AcPerfMode = PerfMode.Turbo }; - using var guard = new PowerProfileGuard(_mifs, cfg, _power, _timer); + using var guard = new PowerProfileGuard(_mifs, cfg, _power, NewCap(cfg), _timer); guard.Reapply(); // ранний выход до Task.Run — синхронно @@ -81,7 +86,7 @@ public void PowerChange_AppliesModeAfterDebounce() { var cfg = new AppConfig { PowerProfiles = true, AcPerfMode = PerfMode.Auto }; _power.IsOnline = true; - using var guard = new PowerProfileGuard(_mifs, cfg, _power, _timer); + using var guard = new PowerProfileGuard(_mifs, cfg, _power, NewCap(cfg), _timer); _power.RaisePower(PowerModes.StatusChange); _mifs.PerfModeCalls.Should().BeEmpty(); // до тика дебаунса — тишина @@ -97,7 +102,7 @@ public void PowerChange_AppliesModeAfterDebounce() public void Suspend_DoesNotTriggerApply() { var cfg = new AppConfig { PowerProfiles = true, AcPerfMode = PerfMode.Turbo }; - using var guard = new PowerProfileGuard(_mifs, cfg, _power, _timer); + using var guard = new PowerProfileGuard(_mifs, cfg, _power, NewCap(cfg), _timer); _power.RaisePower(PowerModes.Suspend); // профили реагируют только на Resume/StatusChange From 52a95ce09db70d3f289c783baa218b20beb72916 Mon Sep 17 00:00:00 2001 From: Oksion Date: Mon, 3 Aug 2026 16:14:07 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(display):=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=20WMI-=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=8F=D1=80=D0=BA=D0=BE=D1=81=D1=82=D0=B8=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=81=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=D1=81=D1=85=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20(XIC-29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поймано вживую на TM2424: события WmiMonitorBrightnessEvent приходят с пула вразнобой и дублируются. Одноразовая метка Own снималась первой проверкой, и дубль нашей же записи читался как «пользователь поднял» — ложный протест замораживал схождение на полпути (90 → 80 и пауза на 2 часа). - OwnWrites: метка живёт по TTL и не снимается при проверке (Consume → IsOwn); протухшее прибирается при Note - Brightness.Ramp дополнительно метит исходный уровень: запоздалое эхо стартового значения во время хода — не действие человека - BrightnessCapGuard: постоянное скупое логирование переходов (взвод схождения, шаг, пауза, стоп по адаптивной) — диагностика по log.txt Проверено вживую: 80 → 55 → 43 → 37 → 34 → 32 → 30 при лимите 30, шаг в минуту, каждый плавным ходом, без ложных пауз. Co-Authored-By: Claude Fable 5 --- src/SystemIntegration/Brightness.cs | 30 +++++++++++-------- src/SystemIntegration/BrightnessCapGuard.cs | 15 ++++++++-- src/SystemIntegration/PowerProfileGuard.cs | 2 +- .../BrightnessCapGuardTests.cs | 17 ++++++----- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/SystemIntegration/Brightness.cs b/src/SystemIntegration/Brightness.cs index 8ad0dcd..9f22afa 100644 --- a/src/SystemIntegration/Brightness.cs +++ b/src/SystemIntegration/Brightness.cs @@ -69,6 +69,7 @@ public static void Ramp(int from, int to, int durationMs, CancellationToken ct) if (delta == 0) return; int interval = Math.Max(30, durationMs / delta); // пол на случай кривого config.json int step = to > from ? 1 : -1; + Own.Note(from); // запоздалое событие исходного уровня во время хода — эхо, не действие человека Task.Run(() => { try @@ -107,9 +108,11 @@ private static void Set(int lvl) } /// -/// Учёт значений яркости, выставленных нами и ещё не подтверждённых событием. Метка живёт -/// недолго: если запись не породила событие (панель не ответила), протухшая метка не должна -/// позже проглотить настоящий пользовательский выбор того же значения. Чистая логика — +/// Учёт значений яркости, выставленных нами недавно. Метка живёт по TTL и НЕ снимается при +/// проверке: WMI-события приходят с потоков пула вразнобой и могут дублироваться, а «съеденная» +/// первой проверкой метка делала бы дубль нашей же записи «пользовательским» — на живом железе +/// это давало ложный протест и замораживало схождение на полпути. TTL короткий: протухшая метка +/// не должна проглотить настоящий пользовательский выбор того же значения. Чистая логика — /// тестируется с явным временем. /// public sealed class OwnWrites @@ -123,20 +126,21 @@ public sealed class OwnWrites public void Note(int level, long nowMs) { - lock (_lock) _until[level] = nowMs + TtlMs; + lock (_lock) + { + // заодно прибираем протухшее — словарь не растёт бесконечно + foreach (var k in _until.Where(p => nowMs > p.Value).Select(p => p.Key).ToArray()) + _until.Remove(k); + _until[level] = nowMs + TtlMs; + } } - /// true — событие с этим значением наше; метка снимается (одноразовая). - public bool Consume(int level) => Consume(level, Environment.TickCount64); + /// true — событие с этим значением наше (недавно писали его сами). + public bool IsOwn(int level) => IsOwn(level, Environment.TickCount64); - public bool Consume(int level, long nowMs) + public bool IsOwn(int level, long nowMs) { - lock (_lock) - { - if (!_until.TryGetValue(level, out long until)) return false; - _until.Remove(level); - return nowMs <= until; - } + lock (_lock) return _until.TryGetValue(level, out long until) && nowMs <= until; } } diff --git a/src/SystemIntegration/BrightnessCapGuard.cs b/src/SystemIntegration/BrightnessCapGuard.cs index dbf5782..6a552e5 100644 --- a/src/SystemIntegration/BrightnessCapGuard.cs +++ b/src/SystemIntegration/BrightnessCapGuard.cs @@ -116,14 +116,23 @@ public void OnBrightness(int level, bool own, bool settling) public void Evaluate() { bool online = _power.IsOnline; - if (!_cfg.BrightnessCapEnabled || _adaptive(online)) { Halt(); return; } + if (!_cfg.BrightnessCapEnabled || _adaptive(online)) + { + if (_cfg.BrightnessCapEnabled) Log.Write("BrightnessCap: адаптивная яркость включена — лимит не работает"); + Halt(); + return; + } int? cur = _read(); lock (_lock) { if (cur is int c) _last = c; if (_backoff) return; - if (_last > Cap(online)) EnsureConvergeLocked(); + if (_last > Cap(online)) + { + Log.Write($"BrightnessCap: яркость {_last}% выше лимита {Cap(online)}% — схождение взведено"); + EnsureConvergeLocked(); + } else EpisodeDoneLocked(); // яркость неизвестна (-1) или в норме — следить нечего } } @@ -161,6 +170,7 @@ private void OnConvergeTick() int to = NextStep(level, cap, _cfg.BrightnessGapDivisor, _cfg.BrightnessSnapPercent); _stepped = true; + Log.Write($"BrightnessCap: шаг схождения {level}% → {to}% (лимит {cap}%)"); if (to <= cap) { // финальный шаг — доводим до лимита и закрываем эпизод; подъём во время этого @@ -211,6 +221,7 @@ private void EpisodeDoneLocked() private void BackoffLocked() { + Log.Write($"BrightnessCap: пользователь поднял яркость после нашего шага — пауза {_cfg.BrightnessBackoffMin} мин"); EpisodeDoneLocked(); _backoff = true; _backoffTimer.Interval = Math.Clamp(_cfg.BrightnessBackoffMin, 1, 24 * 60) * 60_000; diff --git a/src/SystemIntegration/PowerProfileGuard.cs b/src/SystemIntegration/PowerProfileGuard.cs index 7a9797e..6296bee 100644 --- a/src/SystemIntegration/PowerProfileGuard.cs +++ b/src/SystemIntegration/PowerProfileGuard.cs @@ -117,7 +117,7 @@ private void Apply() // событие яркости: классифицировать (наша запись/человек) и раздать лимиту и запоминанию private void OnBrightnessChanged(int level) { - bool own = Brightness.Own.Consume(level); + bool own = Brightness.Own.IsOwn(level); bool settling = Environment.TickCount - _settleUntil < 0; _cap.OnBrightness(level, own, settling); // лимиту — все события: свои шаги он не считает протестом diff --git a/tests/XiControl.Tests/BrightnessCapGuardTests.cs b/tests/XiControl.Tests/BrightnessCapGuardTests.cs index 97e2008..54dd4e4 100644 --- a/tests/XiControl.Tests/BrightnessCapGuardTests.cs +++ b/tests/XiControl.Tests/BrightnessCapGuardTests.cs @@ -46,25 +46,28 @@ public void NextStep_DivisorOne_StillConverges() => // ---- Метки своих записей ---- [Fact] - public void OwnWrites_ConsumeIsOneShot() + public void OwnWrites_DuplicateEvent_IsStillOurs() { + // WMI-события приходят с пула вразнобой и дублируются: метка живёт по TTL, а не + // снимается первой проверкой — иначе дубль нашей записи читался бы как «пользователь + // поднял» и замораживал схождение ложной паузой (поймано вживую на TM2424) var own = new OwnWrites(); own.Note(70, nowMs: 1000); - own.Consume(70, nowMs: 2000).Should().BeTrue("помеченное значение — наша запись"); - own.Consume(70, nowMs: 2000).Should().BeFalse("метка одноразовая"); - own.Consume(55, nowMs: 2000).Should().BeFalse("чужое значение — пользователь"); + own.IsOwn(70, nowMs: 2000).Should().BeTrue("помеченное значение — наша запись"); + own.IsOwn(70, nowMs: 2500).Should().BeTrue("дубль события — всё ещё наша запись, не протест"); + own.IsOwn(55, nowMs: 2000).Should().BeFalse("чужое значение — пользователь"); } [Fact] public void OwnWrites_ExpiredMark_IsNotOurs() { - // запись не породила событие (панель молчит) → протухшая метка не должна - // проглотить настоящий пользовательский выбор того же значения + // метка живёт недолго: протухшая не должна проглотить настоящий + // пользовательский выбор того же значения var own = new OwnWrites(); own.Note(70, nowMs: 1000); - own.Consume(70, nowMs: 1000 + 60_000).Should().BeFalse(); + own.IsOwn(70, nowMs: 1000 + 60_000).Should().BeFalse(); } // ---- Машина состояний guard-а ----