From 5be6f6685ecfd0e0cc550327debff590464df586 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 01:22:06 +0500 Subject: [PATCH 1/6] =?UTF-8?q?feat(charge):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B0=20=D0=B7=D0=B0=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=B0=20+=20=D1=85=D0=B5=D0=BB=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D1=8B=20(XIC-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фундамент под многоуровневый лимит заряда: Mifs.ChargeCarePresets (80/70/60/50/40), ChargeCodeForPercent / ChargePercentForCode (код 0x10/02 <-> %), по docs/12. Аддитивно, сборка 0/0. Дальше — client-слой, конфиг+миграция, UI, Loc, тесты. Co-Authored-By: Claude Opus 4.8 --- src/Wmi/Mifs.cs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Wmi/Mifs.cs b/src/Wmi/Mifs.cs index d369c00..6dfb10e 100644 --- a/src/Wmi/Mifs.cs +++ b/src/Wmi/Mifs.cs @@ -28,7 +28,7 @@ public static class Mifs // под-функции команды 0x10 (offset 4). Помимо защиты заряда, эта группа отдаёт // сенсоры — подтверждено на TM2424 (см. reference/probe-sensors.ps1): - public const byte ChargeSubEnable = 0x02; // val 1=беречь(~80%), 0=до 100% + public const byte ChargeSubEnable = 0x02; // val = КОД уровня порога (не бинарно!) — см. таблицу ниже public const byte ChargeSubFlag = 0x03; // индикатор зоны заряда public const byte SensorAdapterWatts = 0x06; // ватты подключённого PD-адаптера (0 = нет/не PD) public const byte SensorBatteryHealth = 0x01; // SOH1: здоровье батареи, % от исходной ёмкости @@ -47,8 +47,30 @@ public static class Mifs public const byte KeyAiUp = 0x24; // нейропомощник (отпускание) public const byte KeyFnLock = 0x07; // Fn-Lock (Fn+Esc): value = новое состояние 0/1 - /// Фиксированный порог прошивки для «беречь батарею» на этой модели. + /// Порог «беречь батарею» по умолчанию (не «фиксирован» — уровень выбирается, см. ниже). public const int ChargeThresholdPercent = 80; + + // ---- Уровни порога заряда (cmd 0x10 sub 0x02) ---- + // ПЕРЕОТКРЫТО 2026-07-29: sub 0x02 — не бинарный флаг, а КОД уровня. Прошивка держит + // набор {0,1,4,5,6,7,8} и сама его валидирует (невалидные отвергает: SET → OUT[1]!=0x80, + // и зажимает к ближайшему). granular-шкала: % = 120 − код·10 (коды 4–8). Разбор — + // docs/12-charge-levels.md. Код 4 = 80% (granular-дубль) — наружу не пишем, используем legacy 1. + + /// Пресеты «беречь» для UI, по убыванию (без 100% — это «выкл защиты»). + public static readonly int[] ChargeCarePresets = { 80, 70, 60, 50, 40 }; + + /// Процент порога → код для записи в ; null — % не поддержан. + public static byte? ChargeCodeForPercent(int percent) => percent switch + { + 100 => 0, 80 => 1, 70 => 5, 60 => 6, 50 => 7, 40 => 8, _ => null, + }; + + /// Код из → процент порога; null — код неизвестен. + /// Код 4 трактуем как 80% (granular-дубль legacy 1). + public static int? ChargePercentForCode(byte code) => code switch + { + 0 => 100, 1 => 80, 4 => 80, 5 => 70, 6 => 60, 7 => 50, 8 => 40, _ => null, + }; } /// From 6ef3e25e223a49014af3b364111cbf931e5060b9 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 01:42:59 +0500 Subject: [PATCH 2/6] =?UTF-8?q?feat(charge):=20=D0=BF=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=20=D0=B7=D0=B0=D1=80=D1=8F=D0=B4=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D1=82=D0=BE=D0=BC=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B2=D0=B5=D1=81=D1=8C=20=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=20(XIC-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шов IMifsClient: GetChargeCare/SetChargeCare(bool) -> GetChargeLimit/SetChargeLimit(percent). - MifsClient пишет код уровня по таблице (re-arm off->on), возвращает признак приёма прошивкой - AppConfig.CareLimitPercent (дефолт 80 = старое поведение, миграция автоматическая) + CarePercent() с фолбэком при руками-правленном значении - AppController/ChargeGuard/TrayMenuBuilder/Program(DI) — на проценты; гвард армит выбранный порог - Fakes/тесты обновлены под новый шов Сборка 0/0, тесты 141 зелёных. Co-Authored-By: Claude Opus 4.8 --- src/Config/AppConfig.cs | 14 +++++++++++ src/Program.cs | 2 +- src/SystemIntegration/ChargeGuard.cs | 13 ++++++----- src/Ui/AppController.cs | 20 +++++++++------- src/Ui/TrayMenuBuilder.cs | 3 ++- src/Wmi/IMifsClient.cs | 9 +++---- src/Wmi/MifsClient.cs | 26 +++++++++++---------- src/XiControl.csproj | 6 ++++- tests/XiControl.Tests/AppControllerTests.cs | 14 +++++------ tests/XiControl.Tests/ChargeGuardTests.cs | 18 +++++++------- tests/XiControl.Tests/Fakes.cs | 15 +++++++----- 11 files changed, 85 insertions(+), 55 deletions(-) diff --git a/src/Config/AppConfig.cs b/src/Config/AppConfig.cs index 82eab1b..33a4bf4 100644 --- a/src/Config/AppConfig.cs +++ b/src/Config/AppConfig.cs @@ -19,6 +19,15 @@ public sealed class AppConfig public string? FlyoutTheme { get; set; } public bool ChargeCare { get; set; } = false; + + /// + /// Порог «беречь батарею», % — значение X, когда ChargeCare=true. Пресеты 40/50/60/70/80 + /// (см. ); панель/меню переключают ChargeCare между этим X + /// и 100%. Дефолт 80 = прежнее поведение, поэтому старые config.json мигрируются автоматически + /// (поля нет → дефолт 80). Невалидное (руками правленное) значение гасится фолбэком к 80 на месте. + /// + public int CareLimitPercent { get; set; } = Mifs.ChargeThresholdPercent; + public bool AutoStart { get; set; } = false; /// Логировать ошибки и проблемы в %APPDATA%\XiControl\log.txt. @@ -295,6 +304,11 @@ public void MigrateKeyActions() /// Сохранить через привязанный store. Без store (голый POCO в тестах) — no-op. public void Save() => Store?.Save(this); + /// Валидный порог «беречь», % — клэмп руками-правленного + /// к поддержанному пресету (неизвестное значение → дефолт 80). + public int CarePercent() => + Mifs.ChargeCodeForPercent(CareLimitPercent) is null ? Mifs.ChargeThresholdPercent : CareLimitPercent; + /// Запомнить режим для восстановления — только если опция включена и значение изменилось (бережём SSD). public void RememberMode(PerfMode mode) { diff --git a/src/Program.cs b/src/Program.cs index be3c994..9305e44 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -36,7 +36,7 @@ private static void Main() { var c = sp.GetRequiredService(); return new ChargeGuard(sp.GetRequiredService(), sp.GetRequiredService(), - () => c.ChargeCare && !c.TravelMode); + () => c.ChargeCare && !c.TravelMode ? c.CarePercent() : 100); }); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SystemIntegration/ChargeGuard.cs b/src/SystemIntegration/ChargeGuard.cs index e982b00..9f680c5 100644 --- a/src/SystemIntegration/ChargeGuard.cs +++ b/src/SystemIntegration/ChargeGuard.cs @@ -16,14 +16,14 @@ public sealed class ChargeGuard : IDisposable { private readonly IMifsClient _mifs; private readonly IPowerEvents _power; - private readonly Func _careWanted; // желаемое состояние (из настроек/UI) + private readonly Func _limitWanted; // желаемый порог %, 100 = «беречь» выкл (из настроек/UI) private readonly IAppTimer _debounce; - public ChargeGuard(IMifsClient mifs, IPowerEvents power, Func careWanted, IAppTimer? debounce = null) + public ChargeGuard(IMifsClient mifs, IPowerEvents power, Func limitWanted, IAppTimer? debounce = null) { _mifs = mifs; _power = power; - _careWanted = careWanted; + _limitWanted = limitWanted; // события StatusChange могут сыпаться пачкой — гасим дребезг _debounce = debounce ?? new UiTimer(); @@ -55,13 +55,14 @@ private void OnPowerModeChanged(PowerModes mode) // перед периодом «выключено»; заодно закрывает окно, когда дебаунс (1.5 с) не успел private void OnSessionEnding() => Reapply(); - /// Применить желаемое состояние заряда прямо сейчас (напр. при старте). + /// Применить желаемый порог заряда прямо сейчас (напр. при старте). public void Reapply() { try { - if (_careWanted()) - _mifs.SetChargeCare(true); // ре-арм off→on внутри + int pct = _limitWanted(); + if (pct < 100) + _mifs.SetChargeLimit(pct); // ре-арм только когда «беречь» включено (порог < 100) } catch (Exception ex) { Log.Ex("ChargeGuard.Reapply", ex); /* железо могло быть недоступно */ } } diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index 60476ec..4c53678 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -146,30 +146,34 @@ public void Shutdown() // ---- Заряд и «в дорогу» ---- - /// Переключить лимит заряда на противоположный (текущий читается из прошивки). + /// Переключить лимит заряда «беречь X% ↔ 100%» (текущий читается из прошивки). public void ToggleCharge() - => ToggleCare(!Safe(() => _mifs.GetChargeCare(), _cfg.ChargeCare)); + { + int cur = Safe(() => _mifs.GetChargeLimit(), _cfg.ChargeCare ? _cfg.CarePercent() : 100) ?? 100; + ToggleCare(cur >= 100); // сейчас 100 → включить «беречь X%»; иначе → 100 + } - /// Установить «беречь батарею». Ручная смена отменяет временный режим «В дорогу». - /// Прошивка не приняла → конфиг не трогаем (реальное состояние не изменилось) и честно + /// Установить «беречь батарею» (порог X% из настроек) либо 100%. Ручная смена отменяет + /// «В дорогу». Прошивка не приняла → конфиг не трогаем (реальное состояние не изменилось) и честно /// сообщаем об ошибке вместо оптимистичного «успеха» (Фаза 6.2). public void ToggleCare(bool on) { - if (!Safe(() => { _mifs.SetChargeCare(on); return true; }, false)) { FirmwareFailed?.Invoke(); return; } + int percent = on ? _cfg.CarePercent() : 100; + if (!Safe(() => _mifs.SetChargeLimit(percent), false)) { FirmwareFailed?.Invoke(); return; } if (_cfg.TravelMode) { _cfg.TravelMode = false; _travel.Rearm(); } _cfg.ChargeCare = on; _cfg.Save(); CareChanged?.Invoke(on); } - /// «В дорогу»: временный заряд до 100% поверх «беречь 80%». + /// «В дорогу»: временный заряд до 100% поверх «беречь X%». /// Доступно только при базовом ChargeCare=true (при постоянном 100% смысла нет). public void SetTravel(bool on) { if (on && !_cfg.ChargeCare) return; - // on → снять защиту (заряд до 100); off → вернуть базовый режим заряда. + // on → снять защиту (заряд до 100); off → вернуть базовый порог X%. // Сначала прошивка: не приняла → состояние не изменилось, конфиг не трогаем (6.2) - if (!Safe(() => { _mifs.SetChargeCare(on ? false : _cfg.ChargeCare); return true; }, false)) + if (!Safe(() => _mifs.SetChargeLimit(on ? 100 : _cfg.CarePercent()), false)) { FirmwareFailed?.Invoke(); return; } _cfg.TravelMode = on; _cfg.Save(); diff --git a/src/Ui/TrayMenuBuilder.cs b/src/Ui/TrayMenuBuilder.cs index f3c3f38..2825ea3 100644 --- a/src/Ui/TrayMenuBuilder.cs +++ b/src/Ui/TrayMenuBuilder.cs @@ -80,7 +80,8 @@ private void Build() Menu.ImageScalingSize = new Size(imgSz, imgSz); // --- Заряд --- - bool care = Safe(() => _mifs.GetChargeCare(), _cfg.ChargeCare); + int lim = Safe(() => _mifs.GetChargeLimit(), _cfg.ChargeCare ? _cfg.CarePercent() : 100) ?? 100; + bool care = lim < 100; // «беречь» включено, когда порог не 100% var charge = new ToolStripMenuItem(Loc.T("menu.charge")) { Checked = care }; // состояние читаем в момент клика: пока меню висело, его мог сменить ChargeGuard charge.Click += (_, _) => _controller.ToggleCharge(); diff --git a/src/Wmi/IMifsClient.cs b/src/Wmi/IMifsClient.cs index 17484cb..8e2c5cf 100644 --- a/src/Wmi/IMifsClient.cs +++ b/src/Wmi/IMifsClient.cs @@ -13,11 +13,12 @@ public interface IMifsClient : IDisposable /// true, если прошивка приняла режим. bool SetPerfMode(PerfMode mode); - /// Включена ли защита заряда («беречь ~80%»). - bool GetChargeCare(); + /// Текущий порог заряда, %; null — прошивка не ответила. 100 = «беречь» выключено. + int? GetChargeLimit(); - /// Включает/выключает «беречь батарею» (с ре-армом off→on при включении). - void SetChargeCare(bool care); + /// Ставит порог заряда (%): пишет код по таблице уровней (с ре-армом off→on для «беречь»). + /// true — прошивка приняла; неподдержанный/отвергнутый прошивкой % → false (не применено). + bool SetChargeLimit(int percent); /// Мощность адаптера в ваттах; 0 — не подключён или не-PD. int GetAdapterWatts(); diff --git a/src/Wmi/MifsClient.cs b/src/Wmi/MifsClient.cs index a67cbd9..69c66b0 100644 --- a/src/Wmi/MifsClient.cs +++ b/src/Wmi/MifsClient.cs @@ -67,26 +67,28 @@ public bool SetPerfMode(PerfMode mode) return GetPerfMode() == mode; } - // ---- Защита заряда (беречь ~80% / полный 100%) ---- + // ---- Порог заряда (уровни 40/50/60/70/80 / полный 100%) ---- - public bool GetChargeCare() + /// Текущий порог заряда, %; null — прошивка не ответила или код неизвестен. + public int? GetChargeLimit() { var r = Get(Mifs.CmdCharge, Mifs.ChargeSubEnable); - return r.Ok && r.Val6 != 0; + return r.Ok ? Mifs.ChargePercentForCode(r.Val6) : null; } /// - /// Включает/выключает «беречь батарею». При включении делает ре-арм (off→on), - /// чтобы сбросить стейт-машину EC (как в референсе). + /// Ставит порог заряда процентом. Пишет код по таблице + /// с ре-армом off→on (сброс стейт-машины EC, как в референсе). Неподдержанный % — не пишем + /// вслепую (false); прошивка сама валидирует набор — отвергла код → false (сигнал для фолбэка). /// - public void SetChargeCare(bool care) + public bool SetChargeLimit(int percent) { - Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, 0); - if (care) - { - Thread.Sleep(80); - Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, 1); - } + var code = Mifs.ChargeCodeForPercent(percent); + if (code is null) return false; + Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, 0); // «выкл» = 100% + if (code.Value == 0) return true; // сам 100% — второй записи не нужно + Thread.Sleep(80); + return Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, code.Value).Ok; } // ---- Сенсоры (та же группа команд 0x10, driver-free) ---- diff --git a/src/XiControl.csproj b/src/XiControl.csproj index eddea0e..1921aa6 100644 --- a/src/XiControl.csproj +++ b/src/XiControl.csproj @@ -11,7 +11,11 @@ XiControl app.manifest app.ico - 0.6.0 + + 0.0.0-dev diff --git a/tests/XiControl.Tests/AppControllerTests.cs b/tests/XiControl.Tests/AppControllerTests.cs index 6d6d0c4..b238072 100644 --- a/tests/XiControl.Tests/AppControllerTests.cs +++ b/tests/XiControl.Tests/AppControllerTests.cs @@ -25,7 +25,7 @@ public AppControllerTests() { Log.Enabled = false; _c = new AppController(_mifs, _cfg, _power, new Localizer(), - new ChargeGuard(_mifs, _power, () => _cfg.ChargeCare, new FakeTimer()), + new ChargeGuard(_mifs, _power, () => _cfg.ChargeCare ? _cfg.CarePercent() : 100, new FakeTimer()), new RefreshRateGuard(_cfg, _power, new FakeTimer()), new PowerProfileGuard(_mifs, _cfg, _power, new FakeTimer()), new TravelChargeMonitor(_cfg, _power, new FakeTimer()), @@ -48,7 +48,7 @@ public void ToggleCare_WritesFirmwareAndConfig_AndNotifies() { _c.ToggleCare(true); - _mifs.ChargeCareCalls.Should().Equal(true); + _mifs.ChargeLimitCalls.Should().Equal(80); _cfg.ChargeCare.Should().BeTrue(); _events.Should().Equal("care:True"); } @@ -81,7 +81,7 @@ public void SetTravel_On_DropsFirmwareProtection() _c.SetTravel(true); - _mifs.ChargeCareCalls.Should().Equal(false); // снять защиту — заряд до 100% + _mifs.ChargeLimitCalls.Should().Equal(100); // снять защиту — заряд до 100% _cfg.TravelMode.Should().BeTrue(); _events.Should().Equal("travel:True"); } @@ -94,7 +94,7 @@ public void SetTravel_Off_RestoresBaseCare() _c.SetTravel(false); - _mifs.ChargeCareCalls.Should().Equal(true); // вернуть «беречь 80%» + _mifs.ChargeLimitCalls.Should().Equal(80); // вернуть «беречь 80%» _events.Should().Equal("travel:False"); } @@ -106,7 +106,7 @@ public void DisableTravel_IsSilentReset() _c.DisableTravel(); _cfg.TravelMode.Should().BeFalse(); - _mifs.ChargeCareCalls.Should().BeEmpty("защиту вернёт ChargeGuard по событию питания"); + _mifs.ChargeLimitCalls.Should().BeEmpty("защиту вернёт ChargeGuard по событию питания"); _events.Should().Equal("travel-cancelled"); } @@ -185,7 +185,7 @@ public void ToggleCare_FirmwareFailure_KeepsConfigAndReportsError() { bool failed = false; _c.FirmwareFailed = () => failed = true; - _mifs.ThrowOnSetChargeCare = true; + _mifs.ThrowOnSetChargeLimit = true; _cfg.TravelMode = true; _c.ToggleCare(true); @@ -202,7 +202,7 @@ public void SetTravel_FirmwareFailure_KeepsTravelOff() bool failed = false; _c.FirmwareFailed = () => failed = true; _cfg.ChargeCare = true; - _mifs.ThrowOnSetChargeCare = true; + _mifs.ThrowOnSetChargeLimit = true; _c.SetTravel(true); diff --git a/tests/XiControl.Tests/ChargeGuardTests.cs b/tests/XiControl.Tests/ChargeGuardTests.cs index a878047..689dfcc 100644 --- a/tests/XiControl.Tests/ChargeGuardTests.cs +++ b/tests/XiControl.Tests/ChargeGuardTests.cs @@ -16,7 +16,7 @@ public sealed class ChargeGuardTests private readonly FakeTimer _timer = new(); private ChargeGuard Create(bool careWanted = true) => - new(_mifs, _power, () => careWanted, _timer); + new(_mifs, _power, () => careWanted ? 80 : 100, _timer); // порог 80% (беречь) / 100% (выкл) [Fact] public void Reapply_WhenCareWanted_ArmsEc() @@ -25,7 +25,7 @@ public void Reapply_WhenCareWanted_ArmsEc() guard.Reapply(); - _mifs.ChargeCareCalls.Should().Equal(true); + _mifs.ChargeLimitCalls.Should().Equal(80); } [Fact] @@ -35,7 +35,7 @@ public void Reapply_WhenCareNotWanted_DoesNotTouchEc() guard.Reapply(); - _mifs.ChargeCareCalls.Should().BeEmpty(); + _mifs.ChargeLimitCalls.Should().BeEmpty(); } [Theory] @@ -48,12 +48,12 @@ public void PowerChange_DebouncesBeforeRearm(PowerModes mode) _power.RaisePower(mode); // событие пришло — но EC трогаем только после тика дебаунса (события сыплются пачкой) - _mifs.ChargeCareCalls.Should().BeEmpty(); + _mifs.ChargeLimitCalls.Should().BeEmpty(); _timer.Running.Should().BeTrue(); _timer.Fire(); - _mifs.ChargeCareCalls.Should().Equal(true); + _mifs.ChargeLimitCalls.Should().Equal(80); _timer.Running.Should().BeFalse(); // одноразовый: тик сам себя останавливает } @@ -65,7 +65,7 @@ public void Suspend_RearmsImmediately_WithoutDebounce() _power.RaisePower(PowerModes.Suspend); // после Suspend наш код уже не выполнится — ре-арм строго до засыпания - _mifs.ChargeCareCalls.Should().Equal(true); + _mifs.ChargeLimitCalls.Should().Equal(80); _timer.Running.Should().BeFalse(); } @@ -76,7 +76,7 @@ public void SessionEnding_RearmsImmediately() _power.RaiseSession(); - _mifs.ChargeCareCalls.Should().Equal(true); + _mifs.ChargeLimitCalls.Should().Equal(80); } [Fact] @@ -87,7 +87,7 @@ public void PendingDebounce_IsCancelledBySuspend() _power.RaisePower(PowerModes.StatusChange); // взводим дебаунс _power.RaisePower(PowerModes.Suspend); // сон пришёл раньше тика - _mifs.ChargeCareCalls.Should().Equal(true); // ре-арм от Suspend + _mifs.ChargeLimitCalls.Should().Equal(80); // ре-арм от Suspend _timer.Running.Should().BeFalse(); // дебаунс снят — второго ре-арма не будет } @@ -100,6 +100,6 @@ public void Dispose_UnsubscribesFromPowerEvents() _power.RaisePower(PowerModes.Suspend); _power.RaiseSession(); - _mifs.ChargeCareCalls.Should().BeEmpty(); + _mifs.ChargeLimitCalls.Should().BeEmpty(); } } diff --git a/tests/XiControl.Tests/Fakes.cs b/tests/XiControl.Tests/Fakes.cs index fbb5580..f6a9b9c 100644 --- a/tests/XiControl.Tests/Fakes.cs +++ b/tests/XiControl.Tests/Fakes.cs @@ -7,12 +7,14 @@ namespace XiControl.Tests; /// Фейк прошивки: пишет вызовы, отвечает настроенными значениями. internal sealed class FakeMifsClient : IMifsClient { - public readonly List ChargeCareCalls = []; + public readonly List ChargeLimitCalls = []; // проценты, отправленные в SetChargeLimit public readonly List PerfModeCalls = []; public bool SetPerfModeResult = true; + public bool SetChargeLimitResult = true; // валидирует ли прошивка (для проверки фолбэка) + public int? ChargeLimit { get; set; } // что вернёт GetChargeLimit (null — «не прочиталось») public PerfMode? Mode; // что вернёт GetPerfMode public bool ThrowOnGetPerfMode; // симуляция недоступного железа - public bool ThrowOnSetChargeCare; // симуляция отказа прошивки на запись + public bool ThrowOnSetChargeLimit; // симуляция отказа прошивки на запись /// Сигнал «SetPerfMode вызван» — для ожидания асинхронных Apply (Task.Run в guard-ах). public readonly SemaphoreSlim PerfModeHit = new(0); @@ -27,12 +29,13 @@ public bool SetPerfMode(PerfMode mode) return SetPerfModeResult; } - public bool GetChargeCare() => false; + public int? GetChargeLimit() => ChargeLimit; - public void SetChargeCare(bool care) + public bool SetChargeLimit(int percent) { - if (ThrowOnSetChargeCare) throw new InvalidOperationException("прошивка не ответила"); - ChargeCareCalls.Add(care); + if (ThrowOnSetChargeLimit) throw new InvalidOperationException("прошивка не ответила"); + ChargeLimitCalls.Add(percent); + return SetChargeLimitResult; } public int GetAdapterWatts() => 0; From 1e487b92506ccd53ee04962c680edaa88cb16379 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 01:48:33 +0500 Subject: [PATCH 3/6] =?UTF-8?q?feat(charge):=20=D0=BF=D0=B8=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B0=20=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85=20+=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20%%=20=D0=B2?= =?UTF-8?q?=20UI=20(XIC-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Настройки -> Батарея: пикер порога (пресеты 80/70/60/50/40) через новый SettingsActions.SetCareLimit -> AppController.SetCareLimit (применяет на железе, только когда защита активна и не перебита travel; отказ прошивки -> откат выбора) - Панель/меню/OSD показывают реальный выбранный %, не хардкод 80 - Loc ru/en/zh: 3 новых ключа + menu.charge с параметром; переписан settings.battery.note (утверждал, что порог фиксирован — теперь неверно) - Тесты: ChargeLevelsTests (маппинг код<->%, клэмп конфига) + 5 кейсов SetCareLimit Сборка 0/0, тесты 169 зелёных (было 141). Co-Authored-By: Claude Opus 4.8 --- src/Localization/lang/en.json | 9 ++- src/Localization/lang/ru.json | 9 ++- src/Localization/lang/zh.json | 9 ++- src/Ui/AppController.cs | 13 ++++ src/Ui/QuickPanelForm.cs | 3 +- src/Ui/Settings/BatteryTab.cs | 11 ++++ src/Ui/SettingsActions.cs | 1 + src/Ui/TrayApp.cs | 3 +- src/Ui/TrayMenuBuilder.cs | 2 +- tests/XiControl.Tests/AppControllerTests.cs | 58 +++++++++++++++++ tests/XiControl.Tests/ChargeLevelsTests.cs | 70 +++++++++++++++++++++ 11 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 tests/XiControl.Tests/ChargeLevelsTests.cs diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index 64690c7..c641711 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -3,7 +3,7 @@ "_name": "English", "_order": 1, "app.name": "Xi Control", - "menu.charge": "Battery care (80%)", + "menu.charge": "Battery care ({0}%)", "menu.travel": "Travel charge (100%)", "menu.perf": "Performance mode", "mode.eco": "Eco", @@ -32,8 +32,11 @@ "settings.autostart.desc": "Via Task Scheduler, without a UAC prompt.", "settings.general.comfort": "Comfort", "settings.brightness.desc": "Separately for AC and battery; no Power profiles.", - "settings.battery.sub": "Travel mode and the charge-ready sound.", - "settings.battery.note": "The battery-care threshold (~80%) is fixed in firmware for this model — an arbitrary percent isn’t possible. The 80/100 toggle itself lives in the tray and panel as a quick action.", + "settings.battery.sub": "Charge threshold, travel mode and the charge-ready sound.", + "settings.battery.note": "The battery-care threshold is picked from the values your firmware supports (an arbitrary percent isn’t possible). The threshold/100% toggle itself lives in the tray and panel as a quick action.", + "settings.battery.care": "Charge threshold", + "settings.battery.care.limit": "Keep battery at", + "settings.battery.care.limit.desc": "Charge up to this level while “Battery care” is on. The threshold ↔ 100% toggle lives in the tray and panel.", "settings.battery.travel": "Travel mode", "settings.travel.sound": "Ready sound", "settings.travel.sound.desc": "A jingle when the battery reaches 100%.", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index 12ba191..633e13f 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -3,7 +3,7 @@ "_name": "Русский", "_order": 0, "app.name": "Xi Control", - "menu.charge": "Беречь батарею (80%)", + "menu.charge": "Беречь батарею ({0}%)", "menu.travel": "Зарядить «в дорогу» (100%)", "menu.perf": "Режим производительности", "mode.eco": "Эко", @@ -32,8 +32,11 @@ "settings.autostart.desc": "Через Планировщик заданий, без запроса UAC.", "settings.general.comfort": "Удобства", "settings.brightness.desc": "Отдельно для сети и батареи; без «Профилей».", - "settings.battery.sub": "Режим «В дорогу» и звук готовности заряда.", - "settings.battery.note": "Порог «Беречь батарею» (≈80%) зашит в прошивку и одинаков для модели — произвольный процент недоступен. Сам тумблер 80/100 живёт в трее и панели как быстрое действие.", + "settings.battery.sub": "Порог заряда, режим «В дорогу» и звук готовности.", + "settings.battery.note": "Порог «Беречь батарею» выбирается из значений, которые поддерживает прошивка (произвольный процент недоступен). Сам тумблер «порог/100%» живёт в трее и панели как быстрое действие.", + "settings.battery.care": "Порог заряда", + "settings.battery.care.limit": "Беречь батарею до", + "settings.battery.care.limit.desc": "До какого уровня заряжать, когда «Беречь батарею» включено. Переключение «порог ↔ 100%» — в трее и панели.", "settings.battery.travel": "Режим «В дорогу»", "settings.travel.sound": "Звук готовности", "settings.travel.sound.desc": "Джингл, когда батарея дозарядилась до 100%.", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index 41e7349..e3ce1e0 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -3,7 +3,7 @@ "_name": "中文", "_order": 2, "app.name": "Xi Control", - "menu.charge": "电池保护 (80%)", + "menu.charge": "电池保护 ({0}%)", "menu.travel": "出行充电 (100%)", "menu.perf": "性能模式", "mode.eco": "节能", @@ -32,8 +32,11 @@ "settings.autostart.desc": "通过任务计划程序,无需 UAC 提示。", "settings.general.comfort": "便捷功能", "settings.brightness.desc": "分别用于电源和电池,无需电源配置。", - "settings.battery.sub": "出行模式与充电完成提示音。", - "settings.battery.note": "“电池保护”阈值(约 80%)由固件固定,无法自定义百分比。80/100 开关作为快捷操作位于托盘和面板中。", + "settings.battery.sub": "充电阈值、出行模式与充电完成提示音。", + "settings.battery.note": "“电池保护”阈值从固件支持的数值中选择(无法自定义任意百分比)。阈值/100% 开关作为快捷操作位于托盘和面板中。", + "settings.battery.care": "充电阈值", + "settings.battery.care.limit": "电池保护上限", + "settings.battery.care.limit.desc": "开启“电池保护”时充电到该电量。阈值 ↔ 100% 切换位于托盘和面板中。", "settings.battery.travel": "出行模式", "settings.travel.sound": "完成提示音", "settings.travel.sound.desc": "电池充至 100% 时播放提示音。", diff --git a/src/Ui/AppController.cs b/src/Ui/AppController.cs index 4c53678..a6e0eab 100644 --- a/src/Ui/AppController.cs +++ b/src/Ui/AppController.cs @@ -166,6 +166,19 @@ public void ToggleCare(bool on) CareChanged?.Invoke(on); } + /// Сменить порог «беречь батарею» (%). На железе применяем сразу, только если защита + /// сейчас активна и не перебита «В дорогу»; иначе порог просто запоминается до включения. + /// Прошивка отвергла уровень (модель его не держит) → откатываем выбор и честно сообщаем. + public void SetCareLimit(int percent) + { + if (Mifs.ChargeCodeForPercent(percent) is null) return; // не пишем вслепую неизвестный уровень + if (_cfg.ChargeCare && !_cfg.TravelMode + && !Safe(() => _mifs.SetChargeLimit(percent), false)) { FirmwareFailed?.Invoke(); return; } + _cfg.CareLimitPercent = percent; + _cfg.Save(); + CareChanged?.Invoke(_cfg.ChargeCare); // обновить подписи (панель/меню/значок) + } + /// «В дорогу»: временный заряд до 100% поверх «беречь X%». /// Доступно только при базовом ChargeCare=true (при постоянном 100% смысла нет). public void SetTravel(bool on) diff --git a/src/Ui/QuickPanelForm.cs b/src/Ui/QuickPanelForm.cs index 27ffb7a..45885ce 100644 --- a/src/Ui/QuickPanelForm.cs +++ b/src/Ui/QuickPanelForm.cs @@ -488,7 +488,8 @@ protected override void OnPaint(PaintEventArgs e) else SvgIcons.Draw(g, SvgIcons.TravelOff, trRect, trOp, trSat); - DrawPill(g, _care80, "80%", _cfg.ChargeCare, _hover == 10, FlyoutPalette.Green, PillFont); + // левая пилюля — выбранный порог «беречь» (X% из настроек), не хардкод 80 + DrawPill(g, _care80, _cfg.CarePercent() + "%", _cfg.ChargeCare, _hover == 10, FlyoutPalette.Green, PillFont); DrawPill(g, _care100, "100%", !_cfg.ChargeCare, _hover == 11, Color.FromArgb(120, 120, 125), PillFont); // тачпад: подсвечиваем ячейку, когда он ВЫКЛЮЧЕН — нестандартное состояние заметнее diff --git a/src/Ui/Settings/BatteryTab.cs b/src/Ui/Settings/BatteryTab.cs index a3a527f..9282a4b 100644 --- a/src/Ui/Settings/BatteryTab.cs +++ b/src/Ui/Settings/BatteryTab.cs @@ -9,7 +9,18 @@ public sealed class BatteryTab : SettingsPane public BatteryTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act) : base(ui) { ui.AddHeader(this, "settings.tab.battery", "settings.battery.sub"); + + // Порог «беречь батарею»: панель/меню переключают между этим X и 100%, поэтому сам выбор + // живёт здесь (редкая настройка), а не в быстрой панели. Набор — уровни, которые держит + // прошивка (docs/12-charge-levels.md); модель без granular отвергнет — AppController откатит. + ui.AddGroup(this, "settings.battery.care"); + int[] presets = Wmi.Mifs.ChargeCarePresets; + ui.AddRow(this, "settings.battery.care.limit", "settings.battery.care.limit.desc", + ui.Combo([.. presets.Select(p => $"{p}%")], + Math.Max(0, Array.IndexOf(presets, cfg.CarePercent())), + i => act.SetCareLimit(presets[i]), ui.Sc(120))); ui.AddNote(this, "settings.battery.note"); + ui.AddGroup(this, "settings.battery.travel"); ui.AddRow(this, "settings.travel.sound", "settings.travel.sound.desc", ui.Toggle(cfg.TravelSound, on => { cfg.TravelSound = on; cfg.Save(); })); diff --git a/src/Ui/SettingsActions.cs b/src/Ui/SettingsActions.cs index 66b2a59..fa08edb 100644 --- a/src/Ui/SettingsActions.cs +++ b/src/Ui/SettingsActions.cs @@ -32,6 +32,7 @@ public sealed class SettingsActions public required Action SetRefreshRateFeature; // «управление частотой» как фича вкл/выкл public required Action SetRefreshRates; // ac, batt public required Action SetOwlFeature; + public required Action SetCareLimit; // порог «беречь батарею», % (применить на железе) public required Func GetBatteryReport; // здоровье батареи (WMI + SOH1) public required Func GetApiSettings; // настройки HTTP API (api.json, XIC-13) public required Action ApiApplied; // вкладка изменила настройки API → сохранить + перезапустить хост/фаервол diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs index 92f7fd0..40ad572 100644 --- a/src/Ui/TrayApp.cs +++ b/src/Ui/TrayApp.cs @@ -386,7 +386,7 @@ private void ShowPowerOsd(bool online) if (_cfg.TravelMode) _osd.Flash(OsdKind.Travel, Loc.T("osd.travel"), Append(Loc.T("osd.travel.sub"), note), badge); else if (_cfg.ChargeCare) - _osd.Flash(OsdKind.ChargingLimited, Loc.T("osd.charging.limited", Mifs.ChargeThresholdPercent), Append(sub, note), badge); + _osd.Flash(OsdKind.ChargingLimited, Loc.T("osd.charging.limited", _cfg.CarePercent()), Append(sub, note), badge); else _osd.Flash(OsdKind.Charging, Loc.T("osd.charging"), Append(sub, note), badge); } @@ -493,6 +493,7 @@ private void OpenSettings() SetRefreshRateFeature = _controller.ToggleRefreshRateFeature, SetRefreshRates = _controller.SetRefreshRates, SetOwlFeature = _controller.ToggleOwlFeature, + SetCareLimit = _controller.SetCareLimit, GetBatteryReport = BatteryReportCached, GetApiSettings = () => _api, ApiApplied = ApiApplied, diff --git a/src/Ui/TrayMenuBuilder.cs b/src/Ui/TrayMenuBuilder.cs index 2825ea3..9fe4d08 100644 --- a/src/Ui/TrayMenuBuilder.cs +++ b/src/Ui/TrayMenuBuilder.cs @@ -82,7 +82,7 @@ private void Build() // --- Заряд --- int lim = Safe(() => _mifs.GetChargeLimit(), _cfg.ChargeCare ? _cfg.CarePercent() : 100) ?? 100; bool care = lim < 100; // «беречь» включено, когда порог не 100% - var charge = new ToolStripMenuItem(Loc.T("menu.charge")) { Checked = care }; + var charge = new ToolStripMenuItem(Loc.T("menu.charge", _cfg.CarePercent())) { Checked = care }; // состояние читаем в момент клика: пока меню висело, его мог сменить ChargeGuard charge.Click += (_, _) => _controller.ToggleCharge(); Menu.Items.Add(charge); diff --git a/tests/XiControl.Tests/AppControllerTests.cs b/tests/XiControl.Tests/AppControllerTests.cs index b238072..31899b5 100644 --- a/tests/XiControl.Tests/AppControllerTests.cs +++ b/tests/XiControl.Tests/AppControllerTests.cs @@ -98,6 +98,64 @@ public void SetTravel_Off_RestoresBaseCare() _events.Should().Equal("travel:False"); } + [Fact] + public void SetCareLimit_WhenCareActive_AppliesToFirmware() + { + _cfg.ChargeCare = true; + + _c.SetCareLimit(60); + + _mifs.ChargeLimitCalls.Should().Equal(60); + _cfg.CareLimitPercent.Should().Be(60); + _events.Should().Equal("care:True"); + } + + [Fact] + public void SetCareLimit_WhenCareOff_OnlyRemembers() + { + _cfg.ChargeCare = false; + + _c.SetCareLimit(50); + + _mifs.ChargeLimitCalls.Should().BeEmpty("защита выключена — железо не трогаем, порог ждёт включения"); + _cfg.CareLimitPercent.Should().Be(50); + } + + [Fact] + public void SetCareLimit_DuringTravel_OnlyRemembers() + { + _cfg.ChargeCare = true; + _cfg.TravelMode = true; // «в дорогу» временно держит 100% — не перебиваем его + + _c.SetCareLimit(40); + + _mifs.ChargeLimitCalls.Should().BeEmpty(); + _cfg.CareLimitPercent.Should().Be(40); + } + + [Fact] + public void SetCareLimit_UnsupportedPercent_IsIgnored() + { + _c.SetCareLimit(90); // прошивка такого уровня не держит (docs/12) + + _mifs.ChargeLimitCalls.Should().BeEmpty("неизвестный уровень не пишем вслепую"); + _cfg.CareLimitPercent.Should().Be(80, "выбор не изменился"); + } + + [Fact] + public void SetCareLimit_FirmwareRejects_KeepsPreviousChoice() + { + bool failed = false; + _c.FirmwareFailed = () => failed = true; + _cfg.ChargeCare = true; + _mifs.SetChargeLimitResult = false; // модель без granular — прошивка отвергла уровень + + _c.SetCareLimit(40); + + _cfg.CareLimitPercent.Should().Be(80, "откатываем выбор — на железе он не применился"); + failed.Should().BeTrue(); + } + [Fact] public void DisableTravel_IsSilentReset() { diff --git a/tests/XiControl.Tests/ChargeLevelsTests.cs b/tests/XiControl.Tests/ChargeLevelsTests.cs new file mode 100644 index 0000000..3fa713c --- /dev/null +++ b/tests/XiControl.Tests/ChargeLevelsTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using XiControl.Config; +using XiControl.Wmi; +using Xunit; + +namespace XiControl.Tests; + +/// +/// Уровни порога заряда (MIFS 0x10/0x02) — маппинг код↔% и клэмп конфига. +/// Таблица снята с железа (TM2424) и задокументирована в docs/12-charge-levels.md: +/// прошивка держит {0,1,4,5,6,7,8}, остальное отвергает. +/// +public sealed class ChargeLevelsTests +{ + [Theory] + [InlineData(100, 0)] + [InlineData(80, 1)] // legacy-код (его же пишет OEM для 80%) + [InlineData(70, 5)] + [InlineData(60, 6)] + [InlineData(50, 7)] + [InlineData(40, 8)] + public void ChargeCodeForPercent_MapsSupportedLevels(int percent, byte expected) + => Mifs.ChargeCodeForPercent(percent).Should().Be(expected); + + [Theory] + [InlineData(90)] // код 3 прошивка отвергает — 90% недоступно + [InlineData(75)] + [InlineData(0)] + [InlineData(-10)] + [InlineData(120)] + public void ChargeCodeForPercent_RejectsUnsupported(int percent) + => Mifs.ChargeCodeForPercent(percent).Should().BeNull("прошивка держит только фиксированный набор уровней"); + + [Theory] + [InlineData((byte)0, 100)] + [InlineData((byte)1, 80)] + [InlineData((byte)4, 80)] // granular-дубль legacy-единицы + [InlineData((byte)5, 70)] + [InlineData((byte)8, 40)] + public void ChargePercentForCode_MapsBack(byte code, int expected) + => Mifs.ChargePercentForCode(code).Should().Be(expected); + + [Theory] + [InlineData((byte)2)] + [InlineData((byte)3)] + [InlineData((byte)9)] + public void ChargePercentForCode_UnknownCode_IsNull(byte code) + => Mifs.ChargePercentForCode(code).Should().BeNull(); + + [Fact] + public void Presets_AreAllWritable() + => Mifs.ChargeCarePresets.Should().OnlyContain(p => Mifs.ChargeCodeForPercent(p) != null, + "каждый пресет UI должен быть записываемым уровнем"); + + [Fact] + public void CarePercent_DefaultsTo80_ForFreshConfig() + => new AppConfig().CarePercent().Should().Be(80, "дефолт = прежнее поведение (миграция старых config.json)"); + + [Fact] + public void CarePercent_ClampsHandEditedGarbage() + { + var cfg = new AppConfig { CareLimitPercent = 73 }; // правка руками мимо набора + + cfg.CarePercent().Should().Be(80, "неподдержанное значение гасим фолбэком, а не пишем в прошивку"); + } + + [Fact] + public void CarePercent_KeepsSupportedValue() + => new AppConfig { CareLimitPercent = 60 }.CarePercent().Should().Be(60); +} From c5a97a1661b108919fa90884df7799c2f21212dc Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 01:49:47 +0500 Subject: [PATCH 4/6] =?UTF-8?q?docs(charge):=20=D0=BA=D0=B0=D0=BA=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B0?= =?UTF-8?q?=20+=20=D1=80=D0=B0=D0=BD=D1=82=D0=B0=D0=B9=D0=BC-=D0=B4=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20(XIC-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/12-charge-levels.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/12-charge-levels.md b/docs/12-charge-levels.md index bc24210..4c6367d 100644 --- a/docs/12-charge-levels.md +++ b/docs/12-charge-levels.md @@ -58,4 +58,23 @@ OEM (`Xiaomi PC Manager`) даёт селектор лимита заряда (4 поддержан (валидация вернёт `0x00`/зажатие) — деградируем к бинарному 80/100 (рантайм-проба набора). > Порог **зашит по модели**: на TM2424 это 40–100 % шагом кроме 90. На других моделях набор может -> отличаться — определять в рантайме перечислением (какие коды прошивка держит), не хардкодить. +> отличаться — определять в рантайме, не хардкодить. + +--- + +## Как реализовано (XIC-4) + +- `Mifs.ChargeCarePresets` + `ChargeCodeForPercent`/`ChargePercentForCode` — таблица уровней. +- Шов `IMifsClient`: `GetChargeLimit()` / `SetChargeLimit(percent)` (вместо прежних bool-методов). + `SetChargeLimit` пишет код с ре-армом off→on и **возвращает, приняла ли прошивка**. +- `AppConfig.CareLimitPercent` (дефолт 80 → старые `config.json` мигрируются сами) + `CarePercent()` + с фолбэком, если значение правили руками мимо набора. +- `AppController.SetCareLimit` — применяет на железе, только когда защита активна и не перебита + «В дорогу»; иначе просто запоминает. `ChargeGuard` армит **выбранный** порог. +- UI: панель/меню/OSD показывают реальный %, пикер — Настройки → Батарея. + +**Деградация на моделях без granular** сделана рантаймом, а не гейтом по SKU (в духе «набор функций +определяем в рантайме»): прошивка сама валидирует уровень, отказ (`OUT[1] != 0x80`) → `SetChargeLimit` +возвращает `false` → выбор откатывается, пользователь получает честный error-OSD, а 80/100 (legacy-коды +`1`/`0`) работают всегда. Известное ограничение: пикер показывает все пресеты и на такой модели узнать +о неподдержке можно только попыткой — read-only запроса «какие уровни держит прошивка» в MIFS нет. From d19b3c81fa49e772502f69835b6e03222b844124 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 13:04:52 +0500 Subject: [PATCH 5/6] =?UTF-8?q?review(charge):=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B0=D1=82=20Set(100),=20=D0=BB=D0=BE=D0=B3=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=20=D0=B2=20ChargeGuard,=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=20=D0=BA=D0=BE=D0=BC=D0=B1=D0=BE,=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=20=D0=B4=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20(XIC-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MifsClient.SetChargeLimit(100): возвращаем Ok первой записи, а не безусловный true — иначе ToggleCare(off)/SetTravel(on) рапортуют успех при отвергнутой записи - ChargeGuard.Reapply: отказ прошивки на ре-арме теперь оставляет след в log.txt - BatteryTab: комбо реально откатывается к фактическому порогу, когда прошивка отвергла уровень (раньше визуально залипал отвергнутый выбор до переоткрытия окна) - Доки: CLAUDE.md/README/docs 01-02 больше не утверждают «порог зашит, бинарно» — многоуровневый набор, прошивка валидирует (docs/12) Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 ++- README.md | 6 +++--- README.ru.md | 6 +++--- docs/01-wmi-protocol.md | 2 +- docs/02-feature-catalog.md | 2 +- src/SystemIntegration/ChargeGuard.cs | 6 ++++-- src/Ui/Settings/BatteryTab.cs | 20 ++++++++++++++++---- src/Wmi/MifsClient.cs | 4 ++-- 8 files changed, 32 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 221383d..8fcba8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,5 +220,6 @@ WMI-событий). `Program.cs`: single-instance mutex → DI-контейне - Железо-специфично: разработка и проверка — на Xiaomi Book Pro 14 (TM2424). WMI-вызовы к прошивке нельзя проверить без этого железа; сборку — можно всегда. -- Порог «беречь батарею» (~80%) зашит в прошивку, произвольный процент невозможен. +- Порог «беречь батарею» — дискретный набор уровней прошивки (на TM2424: 40/50/60/70/80/100%), + произвольный процент невозможен; прошивка сама валидирует набор (см. `docs/12-charge-levels.md`). - Датчик тока батареи беззнаковый (величина, не направление) — детали в `docs/09-power-monitoring.md`. diff --git a/README.md b/README.md index b62b833..a2ef248 100644 --- a/README.md +++ b/README.md @@ -400,9 +400,9 @@ None of this runs or spends resources while the API is off (the server simply is ## Limitations -- The "battery care" threshold is baked into the firmware — an arbitrary percentage via WMI is - impossible. On the tested model (TM2424) it's ≈80%; on other models the threshold may differ - (e.g. 70% on models serviced by MI Control). +- The "battery care" threshold is picked from a discrete set the firmware supports — an arbitrary + percentage via WMI is impossible. On the tested model (TM2424) that's 40/50/60/70/80/100%; + on other models the set may differ (the firmware validates it itself and rejects unsupported levels). - The Fn+Mi combo is indistinguishable from a single Mi (the firmware sends identical events), which is why short/long presses are used. - The feature set depends on the model: firmware telemetry (fan RPM) is unsupported on the tested diff --git a/README.ru.md b/README.ru.md index e40b49b..7483388 100644 --- a/README.ru.md +++ b/README.ru.md @@ -401,9 +401,9 @@ curl -X POST http://192.168.1.50:58125/travel \ ## Ограничения -- Порог «беречь батарею» зашит в прошивку — произвольный процент через WMI невозможен. - На проверенной модели (TM2424) это ≈80%; на других моделях порог может отличаться - (например, 70% на моделях, которые обслуживал MI Control). +- Порог «беречь батарею» выбирается из дискретного набора прошивки — произвольный процент + через WMI невозможен. На проверенной модели (TM2424) это 40/50/60/70/80/100%; на других + моделях набор может отличаться (прошивка сама валидирует его и отвергает неподдержанные уровни). - Комбинация Fn+Mi не отличима от одиночной Mi (прошивка шлёт одинаковые события), поэтому используется короткое/длинное нажатие. - Набор функций зависит от модели: прошивочная телеметрия (обороты вентиляторов) на проверенной diff --git a/docs/01-wmi-protocol.md b/docs/01-wmi-protocol.md index c1e1ea9..99957bb 100644 --- a/docs/01-wmi-protocol.md +++ b/docs/01-wmi-protocol.md @@ -94,7 +94,7 @@ OUT[6] = значение для charge-protect (вкл/выкл) | `0x09` | Режим GPU | SET/GET | `0`=Hybrid, `1`=Discrete, `2`=UMA | [Linux] | | `0x0A` | **Микрофон (аппаратный mute)** | SET/GET | on/off; в одном пути `arg=0x05` | [MIControl] | | `0x0D` (13) | Обороты вентиляторов | GET | RPM: CPU `[0..1]`, GPU `[2..3]`, SYS `[6..7]`, little-endian | [Linux] | -| `0x10` (16) | **Защита заряда (порог/100%)** | SET/GET | `arg=0x02`; `arg2` `0`=выкл(100%), `1`=вкл(порог). GET → статус `OutData[6]`. Порог зашит в прошивку и зависит от модели: **на TM2424 ≈ 80%**, в UI MIControl для других моделей — 70% | [MIControl] | +| `0x10` (16) | **Порог заряда (уровни/100%)** | SET/GET | `arg=0x02`; `arg2` = КОД уровня (не бинарно): на TM2424 `{0:100%, 1:80%, 4:80%, 5:70%, 6:60%, 7:50%, 8:40%}`, невалидный код прошивка отвергает. GET → код в `OutData[6]`. Набор зависит от модели — см. `12-charge-levels.md` | [MIControl] [RE] | | `0x12` (18) | Яркость подсветки клавиатуры | SET/GET | `0…10` уровней, `128` = Auto | [Linux] | | `0x13` (19) | Тип блока питания | GET | `1`=USB-C, `2`=DC-разъём | [Linux] | | `0x16` (22) | Температура CPU | GET | °C | [Linux] [?] | diff --git a/docs/02-feature-catalog.md b/docs/02-feature-catalog.md index 887c74f..ff77aeb 100644 --- a/docs/02-feature-catalog.md +++ b/docs/02-feature-catalog.md @@ -17,7 +17,7 @@ | Фича | Тип | cmd | Оценка | Комментарий | |------|-----|-----|--------|-------------| | Режим производительности | 🟢 | `0x08` | **Core** | Тихий / Турбо / Full-speed / Auto(Smart). Full-speed — только от сети. Auto только на `TM24*`. | -| Защита заряда 80/100% | 🟢 | `0x10` | **Core** | Бинарно. Порог зашит в прошивку: ≈80% на TM2424, на других моделях может отличаться (у MIControl — 70). Перед вкл — выкл (сброс EC). | +| Порог заряда 40–80/100% | 🟢 | `0x10` | **Core** | Многоуровневый (переоткрыто, см. `12-charge-levels.md`): на TM2424 40/50/60/70/80/100%, прошивка сама валидирует набор. Перед вкл — выкл (сброс EC). | | Индикация в трее + меню | — | — | **Core** | Текущий режим/лимит, быстрое переключение. | | OSD при переключении | — | — | **Core** | Как в CoreCharge — красиво и понятно (можно взять идею, не код). | | Автозапуск при логине | — | — | **Core** | Task Scheduler, `/rl highest` (нужно для WMI Set — проверить). | diff --git a/src/SystemIntegration/ChargeGuard.cs b/src/SystemIntegration/ChargeGuard.cs index 9f680c5..25a11b0 100644 --- a/src/SystemIntegration/ChargeGuard.cs +++ b/src/SystemIntegration/ChargeGuard.cs @@ -60,9 +60,11 @@ public void Reapply() { try { + // ре-арм только когда «беречь» включено (порог < 100); отказ прошивки — в лог: + // молча оставить батарею на 100% хуже, чем след в log.txt int pct = _limitWanted(); - if (pct < 100) - _mifs.SetChargeLimit(pct); // ре-арм только когда «беречь» включено (порог < 100) + if (pct < 100 && !_mifs.SetChargeLimit(pct)) + Log.Write($"ChargeGuard.Reapply: прошивка отвергла порог {pct}%"); } catch (Exception ex) { Log.Ex("ChargeGuard.Reapply", ex); /* железо могло быть недоступно */ } } diff --git a/src/Ui/Settings/BatteryTab.cs b/src/Ui/Settings/BatteryTab.cs index 9282a4b..4772e20 100644 --- a/src/Ui/Settings/BatteryTab.cs +++ b/src/Ui/Settings/BatteryTab.cs @@ -15,10 +15,22 @@ public BatteryTab(SettingsToolkit ui, AppConfig cfg, SettingsActions act) : base // прошивка (docs/12-charge-levels.md); модель без granular отвергнет — AppController откатит. ui.AddGroup(this, "settings.battery.care"); int[] presets = Wmi.Mifs.ChargeCarePresets; - ui.AddRow(this, "settings.battery.care.limit", "settings.battery.care.limit.desc", - ui.Combo([.. presets.Select(p => $"{p}%")], - Math.Max(0, Array.IndexOf(presets, cfg.CarePercent())), - i => act.SetCareLimit(presets[i]), ui.Sc(120))); + int PresetIndex() => Math.Max(0, Array.IndexOf(presets, cfg.CarePercent())); + bool reverting = false; // откат ниже сам дёргает SelectedIndexChanged — не пишем повторно + ComboBox limit = null!; + limit = ui.Combo([.. presets.Select(p => $"{p}%")], PresetIndex(), i => + { + if (reverting) return; + act.SetCareLimit(presets[i]); + // прошивка отвергла уровень → конфиг не изменился — вернуть комбо фактический выбор + if (limit.SelectedIndex != PresetIndex()) + { + reverting = true; + limit.SelectedIndex = PresetIndex(); + reverting = false; + } + }, ui.Sc(120)); + ui.AddRow(this, "settings.battery.care.limit", "settings.battery.care.limit.desc", limit); ui.AddNote(this, "settings.battery.note"); ui.AddGroup(this, "settings.battery.travel"); diff --git a/src/Wmi/MifsClient.cs b/src/Wmi/MifsClient.cs index 69c66b0..06aca49 100644 --- a/src/Wmi/MifsClient.cs +++ b/src/Wmi/MifsClient.cs @@ -85,8 +85,8 @@ public bool SetChargeLimit(int percent) { var code = Mifs.ChargeCodeForPercent(percent); if (code is null) return false; - Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, 0); // «выкл» = 100% - if (code.Value == 0) return true; // сам 100% — второй записи не нужно + var off = Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, 0); // «выкл» = 100% + if (code.Value == 0) return off.Ok; // сам 100% — второй записи не нужно Thread.Sleep(80); return Set(Mifs.CmdCharge, Mifs.ChargeSubEnable, code.Value).Ok; } From c251d8159284e3dbfe4823ca3a8df45eea871015 Mon Sep 17 00:00:00 2001 From: Oksion Date: Wed, 29 Jul 2026 13:13:15 +0500 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=20=D0=B4=D0=BE=D0=BA=D0=BE=D0=B2=20=D1=81=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=D0=BE=D0=BC=20=D0=BF=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=B7=D0=B0=D1=80=D1=8F=D0=B4=D0=B0=20(XIC-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README (ru/en): фичи, OSD, таблица хоткеев, сценарий «В дорогу», таблица API — «80%» больше не хардкод, а выбираемый порог - CLAUDE.md: «Что это» упоминает выбор порога; «Релизы и CI» — про dev-версию 0.0.0-dev - ROADMAP: XIC-4 перенесён из «Ближайшее» в «Сделано» (unreleased); убран дубль секции «Ближайшее» с уже сделанным HTTP API (XIC-13); открытый вопрос про другие модели уточнён под фактическую реализацию (деградация попыткой, а не перечислением) - CHANGELOG [Unreleased]: запись о фиче + пометка dev-версии Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++++++++++++- CLAUDE.md | 4 +++- README.md | 23 ++++++++++++----------- README.ru.md | 23 ++++++++++++----------- ROADMAP.md | 19 ++++++++----------- 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3d45a..9cea968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ ## [Unreleased] -Пока пусто — планы в [ROADMAP.md](ROADMAP.md). +### Добавлено +- **Выбор порога заряда 40/50/60/70/80/100%** — команда защиты заряда `MIFS 0x10/0x02` оказалась + многоуровневой, а не бинарной (переоткрыто 2026-07-29, разбор — + [docs/12-charge-levels.md](docs/12-charge-levels.md)): пикер порога в Настройки → Батарея; + панель/меню/OSD показывают реальный выбранный %, тумблер в панели остаётся бинарным «X% ↔ 100%»; + `ChargeGuard` переармливает выбранный порог. Старые `config.json` мигрируются сами (дефолт 80 = + прежнее поведение). На моделях без granular-уровней прошивка отвергает код — выбор откатывается + с честной ошибкой, 80/100 работают всегда (XIC-4). + +### Изменено +- Локальная сборка помечается версией `0.0.0-dev` (реальную версию в релизные билды подставляет CI + из тега) — самосборный билд больше не выдаёт себя за релизный. ## [0.7.0] — 2026-07-28 diff --git a/CLAUDE.md b/CLAUDE.md index 8fcba8c..8dd1d1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ ## Что это **XiControl** — трей-утилита для ноутбуков Xiaomi/Redmi (Windows 10/11 x64): защита заряда -батареи (+ разовый заряд «В дорогу»), режимы производительности, OSD, переназначаемые +батареи с выбором порога (+ разовый заряд «В дорогу»), режимы производительности, OSD, переназначаемые Mi-кнопка и «мёртвые» клавиши, авто-герцовка, запоминание яркости по питанию, «режим совы», тачпад и сенсорный экран вкл/выкл, виджет «Монитор», окно настроек в стиле Win11, опциональный HTTP API для управления из локальной сети (телефон/Home Assistant). Всё управление железом — @@ -215,6 +215,8 @@ WMI-событий). `Program.cs`: single-instance mutex → DI-контейне - Тег с суффиксом через дефис (`v0.7.0-pre`) помечается pre-release и winget **не** трогает. - Каждый push в `main` собирает скользящий pre-release под тегом `pre` — всегда свежий билд из main; winget его не видит (слушает только `release: [released]`). +- Локальная сборка помечается версией `0.0.0-dev` (дев-дефолт в `XiControl.csproj`, суффикс виден + в AboutTab); реальную версию подставляет CI из тега: `publish -p:Version=X.Y.Z`. ## Ограничения среды diff --git a/README.md b/README.md index a2ef248..7ee2850 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,13 @@ current direction is shown by color (charging green / discharging orange).* ## Features -- 🔋 **Charge protection** — "battery care" (charge to ~80%) / full charge to 100%. +- 🔋 **Charge protection** — "battery care" with a selectable threshold (40/50/60/70/80%, + picked in Settings → Battery) / full charge to 100%. - **ChargeGuard**: the firmware drops the limit after sleep and power-source changes — the utility re-applies it automatically. - - 🧳 **"Travel" mode** — a one-off charge to 100% on top of "care 80%": the suitcase button + - 🧳 **"Travel" mode** — a one-off charge to 100% on top of battery care: the suitcase button in the panel / a menu item. On reaching 100% — an OSD and a sound; unplugging the charger - resets the mode by itself (the next plug-in is back to 80%). + resets the mode by itself (the next plug-in is back to the threshold). - 🔌 **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 @@ -64,7 +65,7 @@ current direction is shown by color (charging green / discharging orange).* - ⚡ **Performance modes**: Eco (hidden firmware mode) / Quiet / Auto / Turbo / Full speed. Eco and Full speed can be removed from the UI via config. - 🖥️ **OSD overlay** (dark card, custom icons): - - charger plug/unplug ("Charging to 80%" / "On battery" + level); + - charger plug/unplug ("Charging to X%" with the actual threshold / "On battery" + level); - performance mode and charge limit changes; - microphone on/off, keyboard backlight (off / 50% / 100% / auto). - 🅼 **Mi button**: @@ -186,10 +187,10 @@ Launch `XiControl.exe` (confirm the UAC prompt) — a tray icon appears. | Click the tray icon | Quick settings panel | | Right-click the icon | Menu: charge, "travel", owl, auto refresh rate, Monitor, mode, Settings…, exit | | Mi button, single click | Next performance mode + OSD *(configurable)* | -| Mi button, double click | Toggle charge limit 80% ↔ 100% + OSD *(configurable)* | +| Mi button, double click | Toggle charge limit threshold ↔ 100% + OSD *(configurable)* | | Mi button, hold ~0.5 s | Quick settings panel | | Microphone key | Mute/unmute the system microphone + OSD | -| "Settings" key | Toggle charge limit 80% ↔ 100% + OSD *(configurable)* | +| "Settings" key | Toggle charge limit threshold ↔ 100% + OSD *(configurable)* | | Keyboard backlight key | OSD with the level (off / 50% / 100% / auto) | All options live in the **Settings…** window (tray menu item): tabs General (language, @@ -209,14 +210,14 @@ moved the file), it repairs itself on launch. ### Travel mode (a one-off charge to 100%) -You usually keep "care 80%", but before a trip you want a full charge. Press the -**suitcase button** in the panel (left of the 80/100 pills) or the **"Charge for the road"** +You usually keep battery care on (say, 80%), but before a trip you want a full charge. Press the +**suitcase button** in the panel (left of the threshold/100 pills) or the **"Charge for the road"** menu item — the utility lifts the limit once and tops up to 100%. - On reaching **100%** — a "ready for the road" OSD and a sound (toggle **Settings → Battery → "Ready sound"**, on by default). -- **Unplug the charger → the mode turns off by itself**; the next plug-in is back to caring at 80%. -- Manually picking the 80/100 pill also cancels the mode. With a permanent "100%" the button is +- **Unplug the charger → the mode turns off by itself**; the next plug-in is back to the care threshold. +- 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 @@ -375,7 +376,7 @@ Routes (all with an `Authorization: Bearer ` header, body — JSON): |---|---| | `GET /status` | Mode, charge protection, "travel", owl, charge %, charging fact, watts, battery health | | `POST /mode` `{"value":"turbo"}` | Performance mode (`eco`/`quiet`/`auto`/`turbo`/`fullspeed`) | -| `POST /care` `{"on":true}` | "Care ~80%" on/off | +| `POST /care` `{"on":true}` | Battery care on/off (the configured threshold) | | `POST /travel` `{"on":true}` | "Travel" mode (a one-off charge to 100%) | | `POST /owl` `{"on":true}` | "Owl mode" (stay awake) on/off | diff --git a/README.ru.md b/README.ru.md index 7483388..2d46036 100644 --- a/README.ru.md +++ b/README.ru.md @@ -49,12 +49,13 @@ ## Возможности -- 🔋 **Защита заряда** — «беречь батарею» (зарядка до ~80%) / полный заряд 100%. +- 🔋 **Защита заряда** — «беречь батарею» с выбираемым порогом (40/50/60/70/80%, выбор в + Настройки → Батарея) / полный заряд 100%. - **ChargeGuard**: прошивка сбрасывает лимит после сна и переключения питания — утилита автоматически переустанавливает его. - - 🧳 **Режим «В дорогу»** — разово зарядить до 100% поверх «беречь 80%»: кнопка-чемоданчик + - 🧳 **Режим «В дорогу»** — разово зарядить до 100% поверх «беречь батарею»: кнопка-чемоданчик в панели / пункт меню. По достижении 100% — OSD и звуковой сигнал; при отключении зарядника - режим сам сбрасывается (следующее подключение снова 80%). + режим сам сбрасывается (следующее подключение снова порог). - 🔌 **Мощность зарядника** — при подключении зарядки показываем мощность подключённого PD-адаптера (ватты) в OSD и в «Мониторе». Поверх иконки заряда — **бейдж качества блока**: 🔴 «!» если адаптер слабее заданного порога (медленный заряд), ⚪ «?» если блок не-PD (например @@ -65,7 +66,7 @@ - ⚡ **Режимы производительности**: Эко (скрытый режим прошивки) / Тихий / Авто / Турбо / Полная мощность. Эко и Полную мощность можно убрать из UI через конфиг. - 🖥️ **OSD-оверлей** (тёмная карточка, авторские иконки): - - подключение/отключение зарядки («Зарядка до 80%» / «Работа от батареи» + уровень); + - подключение/отключение зарядки («Зарядка до X%» с реальным порогом / «Работа от батареи» + уровень); - смена режима производительности и лимита заряда; - микрофон вкл/выкл, подсветка клавиатуры (выкл / 50% / 100% / авто). - 🅼 **Mi-кнопка**: @@ -189,10 +190,10 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu | Клик по значку в трее | Панель быстрых настроек | | Правый клик по значку | Меню: заряд, «В дорогу», сова, авто-герцовка, «Монитор», режим, «Настройки…», выход | | Mi-кнопка, одинарный клик | Следующий режим производительности + OSD *(настраивается)* | -| Mi-кнопка, двойной клик | Переключение лимита заряда 80% ↔ 100% + OSD *(настраивается)* | +| Mi-кнопка, двойной клик | Переключение лимита заряда порог ↔ 100% + OSD *(настраивается)* | | Mi-кнопка, удержание ~0.5 с | Панель быстрых настроек | | Клавиша микрофона | Мьют/анмьют системного микрофона + OSD | -| Клавиша «настройки» | Переключение лимита заряда 80% ↔ 100% + OSD *(настраивается)* | +| Клавиша «настройки» | Переключение лимита заряда порог ↔ 100% + OSD *(настраивается)* | | Клавиша подсветки клавиатуры | OSD с уровнем (выкл / 50% / 100% / авто) | Все опции собраны в окне **«Настройки…»** (пункт меню трея): вкладки Общие (язык, @@ -212,14 +213,14 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu ### Режим «В дорогу» (разовый заряд до 100%) -Обычно держишь «беречь 80%», но перед поездкой хочется полный заряд. Нажми -**кнопку-чемоданчик** в панели (слева от пилюль 80/100) или пункт **«Зарядить „в дорогу"»** +Обычно держишь «беречь батарею» (скажем, 80%), но перед поездкой хочется полный заряд. Нажми +**кнопку-чемоданчик** в панели (слева от пилюль «порог/100») или пункт **«Зарядить „в дорогу"»** в меню — утилита разово снимет ограничение и дозарядит до 100%. - По достижении **100%** — OSD «можно в дорогу» и звуковой сигнал (переключатель **Настройки → Батарея → «Звук готовности»**, по умолчанию вкл). -- **Отключил зарядник → режим сам выключается**; следующее подключение снова бережёт 80%. -- Ручной выбор пилюли 80/100 тоже отменяет режим. При постоянном «100%» кнопка неактивна +- **Отключил зарядник → режим сам выключается**; следующее подключение снова бережёт до порога. +- Ручной выбор пилюли «порог/100» тоже отменяет режим. При постоянном «100%» кнопка неактивна (дозаряжать некуда). Пилюли 80/100 показывают **базовую** настройку — «В дорогу» это временный оверрайд поверх неё @@ -377,7 +378,7 @@ dotnet publish src/XiControl.csproj -c Release -r win-x64 --self-contained -p:Pu |---|---| | `GET /status` | Режим, защита заряда, «В дорогу», сова, % заряда, факт зарядки, ватты, здоровье батареи | | `POST /mode` `{"value":"turbo"}` | Режим производительности (`eco`/`quiet`/`auto`/`turbo`/`fullspeed`) | -| `POST /care` `{"on":true}` | «Беречь ~80%» вкл/выкл | +| `POST /care` `{"on":true}` | «Беречь батарею» вкл/выкл (настроенный порог) | | `POST /travel` `{"on":true}` | Режим «В дорогу» (разовый заряд до 100%) | | `POST /owl` `{"on":true}` | «Режим совы» (не спать) вкл/выкл | diff --git a/ROADMAP.md b/ROADMAP.md index 21e75d3..aeca63a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ Живой план. Не обязательства, а список идей по приоритету. Отметки: ✅ готово · 🔜 ближайшее · 💡 идея. -Актуально на **v0.7.0-pre + unreleased** (2026-07-22). История версий — в [CHANGELOG.md](CHANGELOG.md). +Актуально на **v0.7.0 + unreleased** (2026-07-29). История версий — в [CHANGELOG.md](CHANGELOG.md). ## ✅ Сделано @@ -80,18 +80,14 @@ рантайма), по умолчанию выключен → 0 CPU; bind `127.0.0.1`, доступ из сети — отдельным тумблером с firewall-правилом LocalSubnet. Токен хранится SHA-256, белый список команд, настройки в ProgramData под ACL (правкой config.json API не включить). Вкладка «HTTP API» в настройках (unreleased). +- **Выбор порога заряда (40/50/60/70/80/100%)** — 🎉 переоткрыто 2026-07-29: `MIFS 0x10/0x02` оказался + многоуровневым, не бинарным (см. [docs/12-charge-levels.md](docs/12-charge-levels.md)). Пикер порога — + Настройки → Батарея; панель — бинарный тумблер «X% / 100%»; везде реальный %, не хардкод 80; + `ChargeGuard` переармливает выбранный код; на моделях без granular — деградация к 80/100 (прошивка + сама валидирует набор, отказ → откат выбора + честная ошибка). Driver-free, тем же каналом (XIC-4, unreleased). ## 🔜 Ближайшее - Зафиксированных ближайших задач нет — см. «Идеи» ниже. -- HTTP API для локальной сети (телефон/Home Assistant): статус и батарея, режимы, заряд, сова — - opt-in, по умолчанию выключен и только localhost, токен-хеш, белый список команд (XIC-13). - -## 🔜 Ближайшее -- **Выбор порога заряда (40/50/60/70/80/100%)** — 🎉 переоткрыто 2026-07-29: `MIFS 0x10/0x02` оказался - многоуровневым, не бинарным (см. [docs/12-charge-levels.md](docs/12-charge-levels.md)). Дизайн: - панель — бинарный тумблер «X% / 100%», выбор `X` — в Настройках → Батарея; везде показываем реальный - %, не хардкод 80; `ChargeGuard` переармливает выбранный код; на моделях без granular — деградация к - 80/100 (рантайм-проба валидного набора). Driver-free, тем же каналом. ## 💡 Идеи - Настройки таймингов в config.json: порог удержания Mi, окно двойного клика, длительность OSD. @@ -101,7 +97,8 @@ ## ❓ Открытые вопросы - Fn отдельным кодом не приходит → комбинации с Fn недоступны (обходим жестами Mi). - Набор уровней порога заряда на ДРУГИХ моделях — на TM2424 это 40/50/60/70/80/100% ([docs/12](docs/12-charge-levels.md)); - на других определять рантайм-перечислением (какие коды `0x10/02` прошивка держит), ждём отчётов тестеров. + read-only запроса набора в MIFS нет, поэтому неподдержка выясняется попыткой (отказ → откат выбора); + ждём отчётов тестеров с других моделей. ## ✔ Закрытые вопросы - WMI `0x0C` — рудиментный слот TPLock игровой таблицы MIFS: флаг хранится, к железу