From 431f89210667769c19e9bbe84edd38aa0e5f1964 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 22:51:59 +0500 Subject: [PATCH 1/5] =?UTF-8?q?fix(charge):=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=B8=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B1=D0=B5?= =?UTF-8?q?=D1=80=D1=83=D1=82=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=80=D1=8F=D0=B4=D0=B0=20=D0=B8=D0=B7=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B2=D0=BA=D0=B8=20(XIC-17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если порог менял кто-то снаружи (Xiaomi PC Manager, чужая утилита), быстрая панель продолжала показывать старое значение: она рисовала из конфига и прошивку не перечитывала вовсе. При этом клик по пилюле читал живое состояние — подпись говорила одно, поведение исходило из другого. AppController.SyncCareFromFirmware() читает GET 0x10/02 и приводит конфиг к прошивке (по docs/12 источник истины — она). Зовётся из QuickPanelForm.RefreshState() (до Show(), рядом с чтением режима и тачпада) и из TrayMenuBuilder при пересборке меню — фонового опроса не добавилось, читаем только когда UI открывают. Тонкости, без которых это ломается: - прошивка молчит (null) → конфиг не трогаем, лучше своё значение, чем ноль; - процент принимаем только валидным пресетом, чужой уровень вслепую не пишем; - 100 = «защита выключена», а не «порог 100» — выбранный X храним до включения; - при активном «В дорогу» не примиряем: там 100% держится намеренно, и сброс ChargeCare сломал бы режим; - CareChanged не дёргаем — это не действие пользователя, а подхват чужого, и при скрытой панели TrayApp показал бы на него OSD: всплывашка на каждое открытие; - конфиг пишем только при фактическом отличии. Заодно та же ошибка в меню трея: галочка считалась от живого значения, а процент в подписи брался из конфига, поэтому меню могло показать «беречь 60%» с галочкой при 50% в железе. Теперь панель и меню рисуют из конфига — он приведён к железу. Тултип трея сознательно оставлен как был: он живёт на 30-секундном опросе, и правдивость потребовала бы фонового WMI-вызова. Логика в командном слое, поэтому покрыта тестами на фейках: подхват уровня, выключение защиты снаружи, молчащая прошивка, «в дорогу», неизвестный уровень, отсутствие записи без отличий — плюс проверка, что OSD при подхвате не всплывает. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++++ docs/12-charge-levels.md | 25 +++++++ src/Ui/AppController.cs | 33 ++++++++++ src/Ui/QuickPanelForm.cs | 8 +++ src/Ui/TrayApp.cs | 1 + src/Ui/TrayMenuBuilder.cs | 8 ++- tests/XiControl.Tests/AppControllerTests.cs | 72 +++++++++++++++++++++ 7 files changed, 155 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e0151..a2fff27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ ## [Unreleased] +### Исправлено +- **Порог заряда в панели и меню больше не расходится с железом.** Если порог менял кто-то снаружи + (Xiaomi PC Manager, чужая утилита), быстрая панель продолжала показывать старое значение — она + рисовала из конфига и прошивку не перечитывала вовсе. Теперь порог подтягивается из прошивки в момент + показа панели и пересборки меню трея (`AppController.SyncCareFromFirmware()`): фонового опроса не + добавилось, читаем только когда UI открывают. Прошивка не ответила → остаётся значение конфига; + чужой уровень принимается только из известного набора; при активном «В дорогу» примирение не + выполняется. Заодно исправлена та же ошибка в меню трея: галочка считалась от живого значения, а + процент в подписи брался из конфига, из-за чего меню могло показать «беречь 60 %» при 50 % в железе. + Разбор — [docs/12-charge-levels.md](docs/12-charge-levels.md) (XIC-17). + ### Добавлено - **Загрузка GPU в «Мониторе»** — новый ряд между CPU и RAM: процент + график, под ним частота и мощность (`2450 МГц · 21.4 Вт`), в мини-виде только процент. Данные — через **Intel IGCL**, diff --git a/docs/12-charge-levels.md b/docs/12-charge-levels.md index 70f9006..80ab553 100644 --- a/docs/12-charge-levels.md +++ b/docs/12-charge-levels.md @@ -87,6 +87,31 @@ OEM (`Xiaomi PC Manager`) даёт селектор лимита заряда (4 «В дорогу»; иначе просто запоминает. `ChargeGuard` армит **выбранный** порог. - UI: панель/меню/OSD показывают реальный %, пикер — Настройки → Батарея. +## Примирение с внешней сменой (XIC-17) + +Канала уведомления в MIFS нет, поэтому «на лету» две программы не синхронизируются (см. выше). Но +подхватить чужое изменение **в момент показа UI** можно — и нужно, иначе внешняя смена до нас не +доходит вовсе. `AppController.SyncCareFromFirmware()` читает `GET 0x10/02` и приводит конфиг к +прошивке; зовётся из `QuickPanelForm.RefreshState()` (до `Show()`, рядом с чтением режима и тачпада) и +из `TrayMenuBuilder` при пересборке меню. Фонового опроса не появилось — только когда UI открывают. + +Тонкости, без которых это ломается: + +- **Прошивка молчит** (`null`) → конфиг не трогаем: лучше показать своё значение, чем затереть нулём. +- **Процент принимаем только валидным пресетом** (`ChargeCodeForPercent != null`) — чужой уровень + вслепую в конфиг не пишем. +- **`100` означает «защита выключена», а не «порог 100»** — выбранный X сохраняем до следующего включения. +- **При активном «В дорогу» примирение не выполняется**: там прошивка намеренно держит 100 % при + `ChargeCare = true`, и примирение сбросило бы флаг, сломав режим. +- **`CareChanged` не дёргаем.** Это не действие пользователя, а подхват чужого; при скрытой панели + `TrayApp` показывает на это событие OSD — всплывашка вылетала бы на каждое открытие панели. +- Конфиг пишем только при фактическом отличии, факт принятия — в лог. + +Заодно исправлена та же ошибка в меню трея: галочка считалась от живого значения, а процент в подписи +брался из конфига, поэтому меню могло показать «беречь 60 %» с галочкой при 50 % в железе. Теперь и +панель, и меню рисуют из конфига — он уже приведён к прошивке. Тултип трея сознательно оставлен на +конфиге: он живёт на 30-секундном опросе, и правдивость потребовала бы фонового WMI-вызова. + **Деградация на моделях без granular** сделана рантаймом, а не гейтом по SKU (в духе «набор функций определяем в рантайме»): прошивка сама валидирует уровень, отказ (`OUT[1] != 0x80`) → `SetChargeLimit` возвращает `false` → выбор откатывается, пользователь получает честный error-OSD, а 80/100 (legacy-коды diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index a6e0eab..40dcf0e 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -153,6 +153,39 @@ public void ToggleCharge() ToggleCare(cur >= 100); // сейчас 100 → включить «беречь X%»; иначе → 100 } + /// + /// Принять порог заряда из прошивки: единственный источник истины — она (docs/12), а внешнюю смену + /// (Xiaomi PC Manager, чужая утилита) мы иначе не заметим — канала уведомления в MIFS нет. Зовётся + /// в момент показа панели и пересборки меню трея, поэтому фонового опроса не появляется. + /// Прошивка молчит (null) → конфиг не трогаем: лучше показать своё значение, чем + /// затереть его нулём. Процент принимаем только валидным пресетом (вслепую чужой уровень не пишем). + /// здесь намеренно НЕ дёргаем: это не действие пользователя, а + /// подхват чужого. При скрытой панели TrayApp показывает на это событие OSD — то есть всплывашка + /// вылетала бы на каждое открытие панели. Панель и меню и так рисуются сразу после вызова. + /// + /// true — значение приняли, конфиг изменился. + public bool SyncCareFromFirmware() + { + // «В дорогу» намеренно держит 100% при включённой защите — это наше состояние, не внешнее + if (_cfg.TravelMode) return false; + if (Safe(() => _mifs.GetChargeLimit(), null) is not int live) return false; + + bool care = live < 100; + bool changed = false; + if (care != _cfg.ChargeCare) { _cfg.ChargeCare = care; changed = true; } + // 100% означает «защита выключена», а не «порог 100»: выбранный X сохраняем до включения + if (care && Mifs.ChargeCodeForPercent(live) is not null && live != _cfg.CareLimitPercent) + { + _cfg.CareLimitPercent = live; + changed = true; + } + if (!changed) return false; + + Log.Write($"Заряд: принят порог прошивки — {(care ? $"беречь {live}%" : "100% (защита выключена)")}"); + _cfg.Save(); + return true; + } + /// Установить «беречь батарею» (порог X% из настроек) либо 100%. Ручная смена отменяет /// «В дорогу». Прошивка не приняла → конфиг не трогаем (реальное состояние не изменилось) и честно /// сообщаем об ошибке вместо оптимистичного «успеха» (Фаза 6.2). diff --git a/src/Ui/QuickPanelForm.cs b/src/Ui/QuickPanelForm.cs index 421eafc..3075017 100644 --- a/src/Ui/QuickPanelForm.cs +++ b/src/Ui/QuickPanelForm.cs @@ -61,6 +61,10 @@ public sealed class QuickPanelForm : FlyoutForm public Action? ToggleTouchpad; public Action? ToggleTouchscreen; + /// Перед показом: подтянуть порог заряда из прошивки в конфиг (его мог сменить кто-то + /// снаружи). Запись в конфиг — дело контроллера, панель только просит (XIC-17). + public Action? SyncCare; + /// Кнопка-график слева от крестика: открыть окно «Монитор» (владелец — трей). public Action? MonitorRequested; @@ -110,6 +114,10 @@ private void RefreshState() // деградация с логом: панель откроется и без ответивших подсистем try { _mode = _mifs.GetPerfMode(); } catch (Exception ex) { Log.Ex("QuickPanel.Mode", ex); _mode = null; } + // порог заряда рисуем из конфига, но сперва приводим его к прошивке: иначе внешняя смена + // до панели не доходила вовсе, и подпись расходилась с поведением клика (XIC-17) + try { SyncCare?.Invoke(); } + catch (Exception ex) { Log.Ex("QuickPanel.SyncCare", ex); } try { _tpAvail = _cfg.TouchpadFeature && _tp.Available; _tpOn = _tp.IsEnabled() ?? false; } catch (Exception ex) { Log.Ex("QuickPanel.Touchpad", ex); _tpAvail = false; } try { _tsAvail = _cfg.TouchscreenFeature && _ts.Available; _tsOn = _ts.IsEnabled() ?? false; } diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs index 40ad572..17ae2cb 100644 --- a/src/Ui/TrayApp.cs +++ b/src/Ui/TrayApp.cs @@ -114,6 +114,7 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, _panel.SetAutoHz = _controller.ToggleAutoHz; _panel.ToggleTouchpad = _controller.ToggleTouchpad; _panel.ToggleTouchscreen = _controller.ToggleTouchscreen; + _panel.SyncCare = () => _controller.SyncCareFromFirmware(); // Уведомления контроллера → обратная связь UI: панель открыта — обновляется она, // иначе OSD; значок обновляем после смены режима. Сама логика — в AppController. diff --git a/src/Ui/TrayMenuBuilder.cs b/src/Ui/TrayMenuBuilder.cs index 9fe4d08..f2d50af 100644 --- a/src/Ui/TrayMenuBuilder.cs +++ b/src/Ui/TrayMenuBuilder.cs @@ -80,9 +80,11 @@ private void Build() Menu.ImageScalingSize = new Size(imgSz, imgSz); // --- Заряд --- - int lim = Safe(() => _mifs.GetChargeLimit(), _cfg.ChargeCare ? _cfg.CarePercent() : 100) ?? 100; - bool care = lim < 100; // «беречь» включено, когда порог не 100% - var charge = new ToolStripMenuItem(Loc.T("menu.charge", _cfg.CarePercent())) { Checked = care }; + // Порог приводим к прошивке (её мог сменить кто-то снаружи) и дальше рисуем из конфига: раньше + // галочка считалась от живого значения, а процент в подписи брался из конфига — меню могло + // показать «беречь 60%» с галочкой, когда в железе 50% (XIC-17). + _controller.SyncCareFromFirmware(); + var charge = new ToolStripMenuItem(Loc.T("menu.charge", _cfg.CarePercent())) { Checked = _cfg.ChargeCare }; // состояние читаем в момент клика: пока меню висело, его мог сменить ChargeGuard charge.Click += (_, _) => _controller.ToggleCharge(); Menu.Items.Add(charge); diff --git a/tests/XiControl.Tests/AppControllerTests.cs b/tests/XiControl.Tests/AppControllerTests.cs index 31899b5..70e1cf9 100644 --- a/tests/XiControl.Tests/AppControllerTests.cs +++ b/tests/XiControl.Tests/AppControllerTests.cs @@ -156,6 +156,78 @@ public void SetCareLimit_FirmwareRejects_KeepsPreviousChoice() failed.Should().BeTrue(); } + // ---- Примирение порога с прошивкой (XIC-17) ---- + + [Fact] + public void SyncCare_AdoptsExternalLimit_Silently() + { + _cfg.ChargeCare = true; + _mifs.ChargeLimit = 50; // порог сменили снаружи (Xiaomi PC Manager) + + _c.SyncCareFromFirmware().Should().BeTrue(); + + _cfg.CareLimitPercent.Should().Be(50, "источник истины — прошивка (docs/12)"); + _cfg.ChargeCare.Should().BeTrue(); + _events.Should().BeEmpty("подхват чужого значения — не действие пользователя: OSD не показываем"); + } + + [Fact] + public void SyncCare_ProtectionOffOutside_KeepsChosenPercent() + { + _cfg.ChargeCare = true; + _mifs.ChargeLimit = 100; // снаружи защиту выключили + + _c.SyncCareFromFirmware().Should().BeTrue(); + + _cfg.ChargeCare.Should().BeFalse(); + _cfg.CareLimitPercent.Should().Be(80, "100% — это «выключено», а не порог: выбранный X храним"); + } + + [Fact] + public void SyncCare_FirmwareSilent_KeepsConfig() + { + _cfg.ChargeCare = true; + _mifs.ChargeLimit = null; // прошивка не ответила + + _c.SyncCareFromFirmware().Should().BeFalse(); + + _cfg.ChargeCare.Should().BeTrue("лучше показать своё значение, чем затереть его нулём"); + _cfg.CareLimitPercent.Should().Be(80); + } + + [Fact] + public void SyncCare_DuringTravel_DoesNothing() + { + _cfg.ChargeCare = true; + _cfg.TravelMode = true; + _mifs.ChargeLimit = 100; // «в дорогу» и держит 100% — это наше состояние, не внешнее + + _c.SyncCareFromFirmware().Should().BeFalse(); + + _cfg.ChargeCare.Should().BeTrue("иначе примирение сломало бы «в дорогу»"); + _cfg.TravelMode.Should().BeTrue(); + } + + [Fact] + public void SyncCare_UnknownPercent_KeepsChoice() + { + _cfg.ChargeCare = true; + _mifs.ChargeLimit = 90; // такого уровня в наборе нет (docs/12) + + _c.SyncCareFromFirmware().Should().BeFalse(); + + _cfg.CareLimitPercent.Should().Be(80, "неизвестный уровень в конфиг не принимаем"); + } + + [Fact] + public void SyncCare_AlreadyInSync_ReportsNoChange() + { + _cfg.ChargeCare = true; + _mifs.ChargeLimit = 80; // ровно то, что в конфиге + + _c.SyncCareFromFirmware().Should().BeFalse("без отличий не пишем конфиг на каждое открытие панели"); + } + [Fact] public void DisableTravel_IsSilentReset() { From d6eb1fc6dcc8500e56e73e703283860db03368b3 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 23:05:52 +0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(charge):=20=D0=BD=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D1=82=D1=8C=20=C2=AB100%=C2=BB?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BF=D1=80=D0=BE=D1=88=D0=B8=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=E2=80=94=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0=20=D1=81=20?= =?UTF-8?q?=D1=80=D0=B5-=D0=B0=D1=80=D0=BC=D0=BE=D0=BC=20ChargeGuard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью первого коммита нашло дыру: EC читается как 100 и когда защиту выключили снаружи, и — транзиентно — после сна/смены питания. Лимит на этих событиях теряется, а ChargeGuard переармирует его с дебаунсом 1.5 с. Отличить случаи нельзя. Приняв 100, SyncCareFromFirmware сбрасывал ChargeCare в конфиге — а гард читает желаемый порог из конфига и разоружался навсегда: панель, открытая сразу после resume или выдёргивания зарядника (естественный жест «посмотреть на батарею»), молча убивала защиту до ручного включения. Правило сужено: принимаем только валидный порог < 100. Другой уровень EC сам не породит — это всегда чей-то осознанный SET, его принимать безопасно. Внешнее «выключить» не отражаем вовсе: гард по своей документированной задаче перебивает его на следующем событии питания, поэтому UI показывает намерение пользователя, а не мгновенное состояние EC (то же самое гард сделал бы с самим EC). Тест SyncCare_ProtectionOffOutside_KeepsChosenPercent заменён на SyncCare_Reads100_DoesNotDisarm (пиняет гонку), добавлен SyncCare_ExternalArming_IsAdopted (включение защиты снаружи принимается). docs/12 и CHANGELOG приведены к фактическому правилу; заодно секции Unreleased выровнены по порядку файла (Добавлено → Исправлено). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 24 +++++++++--------- docs/12-charge-levels.md | 12 ++++++--- src/Ui/AppController.cs | 27 ++++++++++----------- tests/XiControl.Tests/AppControllerTests.cs | 23 +++++++++++++++--- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2fff27..e9f2aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,6 @@ ## [Unreleased] -### Исправлено -- **Порог заряда в панели и меню больше не расходится с железом.** Если порог менял кто-то снаружи - (Xiaomi PC Manager, чужая утилита), быстрая панель продолжала показывать старое значение — она - рисовала из конфига и прошивку не перечитывала вовсе. Теперь порог подтягивается из прошивки в момент - показа панели и пересборки меню трея (`AppController.SyncCareFromFirmware()`): фонового опроса не - добавилось, читаем только когда UI открывают. Прошивка не ответила → остаётся значение конфига; - чужой уровень принимается только из известного набора; при активном «В дорогу» примирение не - выполняется. Заодно исправлена та же ошибка в меню трея: галочка считалась от живого значения, а - процент в подписи брался из конфига, из-за чего меню могло показать «беречь 60 %» при 50 % в железе. - Разбор — [docs/12-charge-levels.md](docs/12-charge-levels.md) (XIC-17). - ### Добавлено - **Загрузка GPU в «Мониторе»** — новый ряд между CPU и RAM: процент + график, под ним частота и мощность (`2450 МГц · 21.4 Вт`), в мини-виде только процент. Данные — через **Intel IGCL**, @@ -34,6 +23,19 @@ усыпляет; медиаплееры запрашивают удержание экрана сами, так что фильмы не прерываются. По умолчанию `false` — поведение существующих установок не меняется, пока поле не добавят руками. +### Исправлено +- **Порог заряда в панели и меню больше не расходится с железом.** Если порог менял кто-то снаружи + (Xiaomi PC Manager, чужая утилита), быстрая панель продолжала показывать старое значение — она + рисовала из конфига и прошивку не перечитывала вовсе. Теперь порог подтягивается из прошивки в момент + показа панели и пересборки меню трея (`AppController.SyncCareFromFirmware()`): фонового опроса не + добавилось, читаем только когда UI открывают. Принимается только валидный порог < 100 %: показание + «100» неотличимо от транзиента после сна/смены питания (EC теряет лимит до ре-арма `ChargeGuard`) и + не принимается — иначе панель, открытая сразу после resume, молча выключала бы защиту. Прошивка не + ответила → остаётся значение конфига; при активном «В дорогу» примирение не выполняется. Заодно + исправлена та же ошибка в меню трея: галочка считалась от живого значения, а процент в подписи брался + из конфига, из-за чего меню могло показать «беречь 60 %» при 50 % в железе. + Разбор — [docs/12-charge-levels.md](docs/12-charge-levels.md) (XIC-17). + ## [0.8.0] — 2026-07-29 ### Добавлено diff --git a/docs/12-charge-levels.md b/docs/12-charge-levels.md index 80ab553..01dbcc3 100644 --- a/docs/12-charge-levels.md +++ b/docs/12-charge-levels.md @@ -98,9 +98,15 @@ OEM (`Xiaomi PC Manager`) даёт селектор лимита заряда (4 Тонкости, без которых это ломается: - **Прошивка молчит** (`null`) → конфиг не трогаем: лучше показать своё значение, чем затереть нулём. -- **Процент принимаем только валидным пресетом** (`ChargeCodeForPercent != null`) — чужой уровень - вслепую в конфиг не пишем. -- **`100` означает «защита выключена», а не «порог 100»** — выбранный X сохраняем до следующего включения. +- **Принимаем только валидный порог < 100** (`ChargeCodeForPercent != null`): другой уровень EC сам + не породит — это всегда чей-то осознанный SET, его принимать безопасно. +- **«100 %» НЕ принимаем — это защита от гонки с ChargeGuard.** EC читается как 100 и когда защиту + выключили снаружи, и — транзиентно — после сна/смены питания: лимит на этих событиях теряется, а + гард переармирует его с дебаунсом 1.5 с. Отличить эти случаи нельзя. Приняв 100, мы сбросили бы + `ChargeCare` в конфиге — и гард, который читает желаемый порог из конфига, разоружился бы навсегда: + панель, открытая сразу после resume или выдёргивания зарядника, молча убивала бы защиту. Внешнее + «выключить» гард всё равно перебивает на следующем событии питания (документированное поведение), + поэтому UI показывает намерение пользователя, а не мгновенное состояние EC. - **При активном «В дорогу» примирение не выполняется**: там прошивка намеренно держит 100 % при `ChargeCare = true`, и примирение сбросило бы флаг, сломав режим. - **`CareChanged` не дёргаем.** Это не действие пользователя, а подхват чужого; при скрытой панели diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index 40dcf0e..d965dc0 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -154,11 +154,15 @@ public void ToggleCharge() } /// - /// Принять порог заряда из прошивки: единственный источник истины — она (docs/12), а внешнюю смену - /// (Xiaomi PC Manager, чужая утилита) мы иначе не заметим — канала уведомления в MIFS нет. Зовётся - /// в момент показа панели и пересборки меню трея, поэтому фонового опроса не появляется. - /// Прошивка молчит (null) → конфиг не трогаем: лучше показать своё значение, чем - /// затереть его нулём. Процент принимаем только валидным пресетом (вслепую чужой уровень не пишем). + /// Принять порог заряда из прошивки: внешнюю смену (Xiaomi PC Manager, чужая утилита) мы иначе + /// не заметим — канала уведомления в MIFS нет. Зовётся в момент показа панели и пересборки меню + /// трея, поэтому фонового опроса не появляется. + /// Принимаем ТОЛЬКО валидный порог < 100: другой уровень EC сам не породит — это всегда + /// чей-то осознанный SET. А вот «100%» неотличим от транзиента: EC теряет лимит после сна/смены + /// питания, и до ре-арма ChargeGuard (дебаунс 1.5 с) читается как 100. Принять его = сбросить + /// ChargeCare в конфиге и разоружить гард навсегда (панель, открытая сразу после resume/выдёргивания + /// зарядника, молча убивала бы защиту). Внешнее «выключить» гард всё равно перебивает на следующем + /// событии питания — это документированное поведение, поэтому «100» честно игнорируем. /// здесь намеренно НЕ дёргаем: это не действие пользователя, а /// подхват чужого. При скрытой панели TrayApp показывает на это событие OSD — то есть всплывашка /// вылетала бы на каждое открытие панели. Панель и меню и так рисуются сразу после вызова. @@ -169,19 +173,14 @@ public bool SyncCareFromFirmware() // «В дорогу» намеренно держит 100% при включённой защите — это наше состояние, не внешнее if (_cfg.TravelMode) return false; if (Safe(() => _mifs.GetChargeLimit(), null) is not int live) return false; + if (live >= 100 || Mifs.ChargeCodeForPercent(live) is null) return false; - bool care = live < 100; bool changed = false; - if (care != _cfg.ChargeCare) { _cfg.ChargeCare = care; changed = true; } - // 100% означает «защита выключена», а не «порог 100»: выбранный X сохраняем до включения - if (care && Mifs.ChargeCodeForPercent(live) is not null && live != _cfg.CareLimitPercent) - { - _cfg.CareLimitPercent = live; - changed = true; - } + if (!_cfg.ChargeCare) { _cfg.ChargeCare = true; changed = true; } + if (live != _cfg.CareLimitPercent) { _cfg.CareLimitPercent = live; changed = true; } if (!changed) return false; - Log.Write($"Заряд: принят порог прошивки — {(care ? $"беречь {live}%" : "100% (защита выключена)")}"); + Log.Write($"Заряд: принят порог прошивки — беречь {live}%"); _cfg.Save(); return true; } diff --git a/tests/XiControl.Tests/AppControllerTests.cs b/tests/XiControl.Tests/AppControllerTests.cs index 70e1cf9..b4402ec 100644 --- a/tests/XiControl.Tests/AppControllerTests.cs +++ b/tests/XiControl.Tests/AppControllerTests.cs @@ -172,15 +172,30 @@ public void SyncCare_AdoptsExternalLimit_Silently() } [Fact] - public void SyncCare_ProtectionOffOutside_KeepsChosenPercent() + public void SyncCare_Reads100_DoesNotDisarm() { + // EC читается как 100 и когда защиту выключили снаружи, и — транзиентно — после сна/смены + // питания, пока ChargeGuard не переармил (дебаунс). Принять 100 = сбросить ChargeCare и + // разоружить гард навсегда: панель, открытая сразу после resume, молча убивала бы защиту. _cfg.ChargeCare = true; - _mifs.ChargeLimit = 100; // снаружи защиту выключили + _mifs.ChargeLimit = 100; + + _c.SyncCareFromFirmware().Should().BeFalse(); + + _cfg.ChargeCare.Should().BeTrue("«100» неотличим от транзиента до ре-арма — не принимаем"); + _cfg.CareLimitPercent.Should().Be(80); + } + + [Fact] + public void SyncCare_ExternalArming_IsAdopted() + { + _cfg.ChargeCare = false; + _mifs.ChargeLimit = 60; // защиту включили снаружи: EC сам уровень не породит — это чей-то SET _c.SyncCareFromFirmware().Should().BeTrue(); - _cfg.ChargeCare.Should().BeFalse(); - _cfg.CareLimitPercent.Should().Be(80, "100% — это «выключено», а не порог: выбранный X храним"); + _cfg.ChargeCare.Should().BeTrue(); + _cfg.CareLimitPercent.Should().Be(60); } [Fact] From 7e279b5d71587ea7bb911f53e640c7cc5fa9fd23 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 23:24:58 +0500 Subject: [PATCH 3/5] =?UTF-8?q?fix(l10n):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8?= =?UTF-8?q?=D0=B5=2080%=20=D0=B8=D0=B7=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20POST=20/care?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подпись команды во вкладке «HTTP API» обещала «беречь ~80%», хотя с XIC-4 порог выбирается пользователем (40–80). Процент убран совсем, а не подставляется живым значением: строка описывает эндпоинт, а не текущую настройку, и тянуть сюда фактический порог значило бы держать ещё одно место, где он может разойтись. Формулировка выровнена по соседям (POST /mode — переключение режима). README RU/EN правки не требуют — там уже «настроенный порог». Co-Authored-By: Claude Opus 5 --- src/Localization/lang/en.json | 2 +- src/Localization/lang/ru.json | 2 +- src/Localization/lang/zh.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index 24bbf0d..ff74652 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -143,7 +143,7 @@ "settings.api.cmd.mode": "Performance modes", "settings.api.cmd.mode.desc": "POST /mode — switch the mode.", "settings.api.cmd.care": "Battery protection", - "settings.api.cmd.care.desc": "POST /care — toggle “protect ~80%”.", + "settings.api.cmd.care.desc": "POST /care — toggle the charge limit.", "settings.api.cmd.travel": "Travel mode", "settings.api.cmd.travel.desc": "POST /travel — one-off charge to 100%.", "settings.api.cmd.owl": "Owl mode", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index 585c3a6..3598066 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -143,7 +143,7 @@ "settings.api.cmd.mode": "Режимы производительности", "settings.api.cmd.mode.desc": "POST /mode — переключение режима.", "settings.api.cmd.care": "Защита заряда", - "settings.api.cmd.care.desc": "POST /care — «беречь ~80%» вкл/выкл.", + "settings.api.cmd.care.desc": "POST /care — лимит заряда вкл/выкл.", "settings.api.cmd.travel": "Режим «В дорогу»", "settings.api.cmd.travel.desc": "POST /travel — разовый заряд до 100%.", "settings.api.cmd.owl": "Режим совы", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index 8c88614..61ad9b1 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -143,7 +143,7 @@ "settings.api.cmd.mode": "性能模式", "settings.api.cmd.mode.desc": "POST /mode — 切换性能模式。", "settings.api.cmd.care": "电池保护", - "settings.api.cmd.care.desc": "POST /care — 开关“保持约 80%”。", + "settings.api.cmd.care.desc": "POST /care — 开关充电限制。", "settings.api.cmd.travel": "出行模式", "settings.api.cmd.travel.desc": "POST /travel — 一次性充电至 100%。", "settings.api.cmd.owl": "猫头鹰模式", From 2bfeffbda243aad3dc7592f1c4012ba02fdb8308 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 23:32:03 +0500 Subject: [PATCH 4/5] =?UTF-8?q?fix(l10n):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=2080%=20=D0=B8=D0=B7=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D1=88=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20=D1=88=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D1=80=D1=91=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Те же устаревшие цифры, что и в описании POST /care, но в переназначении клавиш: «Заряд 80 / 100 %» в списке действий (все три языка) и «…всегда заряд 80/100» в подсказке к клавише с шестерёнкой (только ru — en/zh уже были без процента). Действие переименовано по образцу соседних тумблеров («Тачпад вкл/выкл»): «Лимит заряда вкл/выкл» / «Charge limit on/off» / «充电限制开关». Подсказка ru выровнена по en — «всегда переключает лимит заряда». После этого хардкода 80 в локализации не осталось ни в одном языке (grep чист): порог выбирается пользователем с XIC-4, и упоминать конкретное число в статике больше негде. Co-Authored-By: Claude Opus 5 --- src/Localization/lang/en.json | 2 +- src/Localization/lang/ru.json | 4 ++-- src/Localization/lang/zh.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index ff74652..7ad1757 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -106,7 +106,7 @@ "settings.key.proj.desc": "The display-switch key.", "settings.key.command": "Program / file / URL", "settings.act.modes": "Cycle modes", - "settings.act.charge": "Charge 80 / 100 %", + "settings.act.charge": "Charge limit on/off", "settings.act.panel": "Quick panel", "settings.act.owl": "Owl mode", "settings.act.monitor": "Monitor", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index 3598066..3a31dff 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -99,14 +99,14 @@ "settings.key.mi.double.desc": "«Ничего» — жест отключён, одиночный клик срабатывает мгновенно.", "settings.keys.other": "Прочие клавиши", "settings.key.settings": "Клавиша «Настройки»", - "settings.key.settings.desc": "Клавиша с шестерёнкой. При открытой панели — всегда заряд 80/100.", + "settings.key.settings.desc": "Клавиша с шестерёнкой. При открытой панели — всегда переключает лимит заряда.", "settings.key.ai": "AI-клавиша", "settings.key.ai.desc": "Клавиша нейропомощника.", "settings.key.proj": "Клавиша «Проекция»", "settings.key.proj.desc": "Клавиша переключения экранов.", "settings.key.command": "Программа / файл / URL", "settings.act.modes": "Цикл режимов", - "settings.act.charge": "Заряд 80 / 100 %", + "settings.act.charge": "Лимит заряда вкл/выкл", "settings.act.panel": "Быстрая панель", "settings.act.owl": "Режим совы", "settings.act.monitor": "Монитор", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index 61ad9b1..adcd00e 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -106,7 +106,7 @@ "settings.key.proj.desc": "屏幕切换键。", "settings.key.command": "程序 / 文件 / URL", "settings.act.modes": "循环性能模式", - "settings.act.charge": "充电 80 / 100 %", + "settings.act.charge": "充电限制开关", "settings.act.panel": "快速面板", "settings.act.owl": "猫头鹰模式", "settings.act.monitor": "监视器", From cba1e9acc6479687826f2d9931adc32641a7f67d Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 23:54:55 +0500 Subject: [PATCH 5/5] =?UTF-8?q?docs(readme):=20=D0=BF=D1=80=D0=BE=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BD=D0=BE-=D0=BD=D0=B5=D0=B9=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BB=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D0=B0=20=D0=B7=D0=B0=D1=80=D1=8F=D0=B4=D0=B0?= =?UTF-8?q?=20(RU/EN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Те же устаревшие «80/100», что вычистили из локализации, жили и в README обоих языков — по пять мест в каждом. Порог выбирается пользователем с XIC-4, поэтому конкретное число в описании фич врёт всем, кто выбрал не 80: бейдж качества БП иконка показывает лимит 80/100 → текущий лимит заряда двойной клик Mi заряд 80/100 → переключение лимита заряда удержание Mi режимы + заряд 80/100 → режимы + лимит заряда «В дорогу» пилюли 80/100 → пилюли «порог/100» переназначение заряд 80/100 → лимит заряда вкл/выкл Legitimate-упоминания оставлены как есть: набор пресетов (40/50/60/70/80/100%), «В дорогу» до 100%, уровни подсветки клавиатуры, пример запоминания яркости, hex-статус 0x80 и «скажем, 80%» в туториале «В дорогу» — там процент явно подан примером («скажем»), и он помогает понять сценарий. Co-Authored-By: Claude Opus 5 --- README.md | 12 ++++++------ README.ru.md | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8dfdcb6..014d07c 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ double-click; current direction is shown by color (charging green / discharging - 🔌 **Charger wattage** — when the charger is plugged in, show the connected PD adapter's wattage (watts) in the OSD and in the Monitor. Over the charge icon — a **PSU quality badge**: 🔴 "!" if the adapter is weaker than the configured threshold (slow charging), ⚪ "?" if the - PSU is non-PD (e.g. plain 5 V — wattage can't be negotiated). The icon still shows the 80/100 - limit. The threshold is configurable (Settings → Battery). Driver-free (read-only). + PSU is non-PD (e.g. plain 5 V — wattage can't be negotiated). The icon still shows the current + charge limit. The threshold is configurable (Settings → Battery). Driver-free (read-only). - 🩺 **Battery health** — Settings → Battery: actual wear (current vs. design capacity), charge cycle count, capacity in Wh. Stock ACPI/Windows data, read-only. - ⚡ **Performance modes**: Eco (hidden firmware mode) / Quiet / Auto / @@ -70,8 +70,8 @@ double-click; current direction is shown by color (charging green / discharging - microphone on/off, keyboard backlight (off / 50% / 100% / auto). - 🅼 **Mi button**: - short press — cycle through modes with an OSD (configurable); - - double click — charge 80/100 (configurable); - - hold — quick settings panel (modes + charge 80/100, closes on Esc/X/click-outside). + - double click — toggle the charge limit (configurable); + - hold — quick settings panel (modes + charge limit, closes on Esc/X/click-outside). - ⌨️ **Reviving "dead" keys** with remapping: the Mi clicks and the "settings" / AI / "projection" keys can be bound to any function — from cycling modes to launching your own program (see "Key remapping"); the microphone key mutes the system mic, the backlight @@ -222,7 +222,7 @@ menu item — the utility lifts the limit once and tops up to 100%. - Manually picking the threshold/100 pill also cancels the mode. With a permanent "100%" the button is inactive (nothing to top up). -The 80/100 pills show the **base** setting — "travel" is a temporary override on top of it +The "threshold/100" pills show the **base** setting — "travel" is a temporary override on top of it (driver-free, the same charge WMI channel). In `config.json`: `"TravelMode"`, `"TravelSound"`. A custom ready sound — the "Custom sound file" field in the same settings (or `config.json`; @@ -333,7 +333,7 @@ When editing `AcRefreshRate`/`BatteryRefreshRate` directly in the config, restar Each key gets its own action: **Settings → Keys**. The slots are the single and double Mi-button click, the "Settings" (gear), AI and "Projection" keys. Any slot can be bound to: cycle modes, -charge 80/100, quick panel, owl mode, Monitor, "travel", touchpad and touchscreen on/off, the +charge limit on/off, quick panel, owl mode, Monitor, "travel", touchpad and touchscreen on/off, the system "Projection (Win+P)" / "Windows Settings" / "Copilot (Win+C)", launching your own program, or "Nothing". diff --git a/README.ru.md b/README.ru.md index 293d905..7345c41 100644 --- a/README.ru.md +++ b/README.ru.md @@ -59,7 +59,7 @@ RAM, **температура** «горячей точки» (горячая з - 🔌 **Мощность зарядника** — при подключении зарядки показываем мощность подключённого PD-адаптера (ватты) в OSD и в «Мониторе». Поверх иконки заряда — **бейдж качества блока**: 🔴 «!» если адаптер слабее заданного порога (медленный заряд), ⚪ «?» если блок не-PD (например - обычный 5 В — мощность не согласуется). При этом иконка по-прежнему показывает лимит 80/100. + обычный 5 В — мощность не согласуется). При этом иконка по-прежнему показывает текущий лимит заряда. Порог настраивается (Настройки → Батарея). Driver-free (только чтение). - 🩺 **Здоровье батареи** — Настройки → Батарея: реальный износ (текущая ёмкость к проектной), число циклов заряда, ёмкость в Вт·ч. Штатные данные ACPI/Windows, только чтение. @@ -71,8 +71,8 @@ RAM, **температура** «горячей точки» (горячая з - микрофон вкл/выкл, подсветка клавиатуры (выкл / 50% / 100% / авто). - 🅼 **Mi-кнопка**: - короткое нажатие — циклическое переключение режимов с OSD (настраивается); - - двойной клик — заряд 80/100 (настраивается); - - удержание — панель быстрых настроек (режимы + заряд 80/100, закрытие по Esc/X/клику вне). + - двойной клик — переключение лимита заряда (настраивается); + - удержание — панель быстрых настроек (режимы + лимит заряда, закрытие по Esc/X/клику вне). - ⌨️ **Оживление «мёртвых» клавиш** с переназначением: на клики Mi и клавиши «настройки» / AI / «проекция» вешается любая функция — от цикла режимов до запуска своей программы (см. «Переназначение клавиш»); клавиша микрофона мьютит системный @@ -225,7 +225,7 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu - Ручной выбор пилюли «порог/100» тоже отменяет режим. При постоянном «100%» кнопка неактивна (дозаряжать некуда). -Пилюли 80/100 показывают **базовую** настройку — «В дорогу» это временный оверрайд поверх неё +Пилюли «порог/100» показывают **базовую** настройку — «В дорогу» это временный оверрайд поверх неё (driver-free, тот же WMI-канал заряда). В `config.json`: `"TravelMode"`, `"TravelSound"`. Свой звук готовности — поле «Свой звуковой файл» там же в настройках (или `config.json`; @@ -335,7 +335,7 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu Каждой клавише — своё действие: **Настройки → Клавиши**. Слоты — одиночный и двойной клик Mi-кнопки, клавиши «Настройки» (шестерёнка), AI и «Проекция». На любой слот можно -навесить: цикл режимов, заряд 80/100, быструю панель, режим совы, «Монитор», +навесить: цикл режимов, лимит заряда вкл/выкл, быструю панель, режим совы, «Монитор», «В дорогу», тачпад и сенсорный экран вкл/выкл, системные «Проекция (Win+P)» / «Параметры Windows» / «Copilot (Win+C)», запуск своей программы или «Ничего».