From 74047a0816159367eca8ce13604d0b091d3696e0 Mon Sep 17 00:00:00 2001 From: Oksion Date: Fri, 16 Oct 2026 22:08:43 +0500 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=B2=D0=B5=D0=B1=D1=85=D1=83?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=C2=AB=D0=92=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=83=C2=BB=20(XIC-99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Во время разового заряда до 100% ChargeLimitWatcher продолжал следить за обычным порогом: на 80% уходил chargeLimit с hardwareLimit: true, и розетка из автоматизации обрывала заряд. - ChargeLimitWatcher.Target(..., travel): при «В дорогу» цели нет (и для программного порога). Параметр обязательный — забытый travel и был ошибкой. - Rearm() в TravelChanged и TravelCancelled: наблюдение встаёт на время «В дорогу» и взводится снова после выхода. - Выход из «В дорогу» выше порога — событие на ближайшей проверке (вариант А). - Тесты: 3 на Target + сценарий с таймером. docs/15, CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++ docs/15-http-api.md | 3 ++ src/SystemIntegration/ChargeLimitWatcher.cs | 13 ++++-- src/Ui/TrayApp.cs | 8 +++- tests/XiControl.Tests/SoftChargeTests.cs | 49 ++++++++++++++++++--- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6105c..1433c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ неактивен: она делает то же сама. Выключили — режим питания возвращается в сбалансированный, ручной выбор в параметрах Windows не трогаем. По умолчанию выключено (XIC-96). +### Исправлено +- **Вебхук «заряд дошёл до порога» больше не обрывает «В дорогу».** Во время разового заряда до + 100 % событие уходило всё равно, едва заряд проходил обычный порог, — и розетка из автоматизации + Home Assistant выключалась на 80 %, так что «В дорогу» до конца не доходил. Теперь на время + «В дорогу» порог не отслеживается, а после выхода из него наблюдение возобновляется само + (XIC-99). + ## [0.17.0] — 2026-10-11 ### Добавлено diff --git a/docs/15-http-api.md b/docs/15-http-api.md index 58ec3e8..7978956 100644 --- a/docs/15-http-api.md +++ b/docs/15-http-api.md @@ -148,6 +148,9 @@ Authorization: Bearer <токен> Правила, по которым это работает: - **Один раз за зарядку.** Событие взводится заново, когда зарядник отключили. +- **Во время «В дорогу» не отправляется.** Разовый заряд до 100 % снимает порог — иначе розетка + из вашей автоматизации сама оборвала бы его на 80 %. Выключили «В дорогу», когда заряд уже выше + порога, — событие придёт на ближайшей проверке (до 30 секунд): прошивка снова держит порог. - **Не настроен — ни одного запроса.** Пустое поле адреса означает, что сетевого кода не существует: ни клиента, ни таймера, ни DNS. То же обещание, что у проверки обновлений. - **Только `http`/`https`**, за редиректами не ходим, ответ не читаем — хватает кода состояния. diff --git a/src/SystemIntegration/ChargeLimitWatcher.cs b/src/SystemIntegration/ChargeLimitWatcher.cs index a33186f..74cadfa 100644 --- a/src/SystemIntegration/ChargeLimitWatcher.cs +++ b/src/SystemIntegration/ChargeLimitWatcher.cs @@ -109,14 +109,21 @@ private void Forget() /// За каким порогом следить. Программный порог предлагается ТОЛЬКО там, где аппаратного /// нет: иначе мы подсовывали бы костыль рядом с настоящим решением и будили человека /// ради того, что прошивка делает сама. + /// + /// «В дорогу» порог снимает: прошивка заряжает до 100 (ChargeGuard получает 100), и + /// сообщить «дошли до порога» значило бы, что розетка из вебхука сама оборвёт разовый + /// заряд (XIC-99). О 100% говорит TravelChargeMonitor. Параметр обязательный намеренно: + /// забытый «В дорогу» и был ошибкой. /// - public static ChargeTarget? Target(bool care, int careLimit, bool softOn, int softLimit, bool hardwareMissing) => - hardwareMissing + public static ChargeTarget? Target(bool care, int careLimit, bool softOn, int softLimit, + bool hardwareMissing, bool travel) => + travel ? null + : hardwareMissing ? softOn ? new ChargeTarget(Math.Clamp(softLimit, 20, 100), true) : null : care ? new ChargeTarget(careLimit, false) : null; private ChargeTarget? Target() => Target(_cfg.ChargeCare, _cfg.CarePercent(), - _cfg.SoftChargeAlert, _cfg.SoftChargeLimitPercent, _cfg.ChargeLimitUnsupported); + _cfg.SoftChargeAlert, _cfg.SoftChargeLimitPercent, _cfg.ChargeLimitUnsupported, _cfg.TravelMode); /// /// Пора ли напомнить. Напоминаем, пока выполняются все условия сразу: зарядник в розетке, diff --git a/src/Ui/TrayApp.cs b/src/Ui/TrayApp.cs index 774c817..e9e761e 100644 --- a/src/Ui/TrayApp.cs +++ b/src/Ui/TrayApp.cs @@ -184,6 +184,8 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, IPowerEv }; _controller.TravelChanged = on => { + // «В дорогу» снимает порог — наблюдение за ним встаёт, по выходу взводится снова (XIC-99) + _chargeLimit.Rearm(); // экран заблокирован: OSD под локскрином не виден (secure desktop) — «слепая» обратная // связь: различимый джингл вкл/выкл + toast (Windows сама показывает его на локскрине). // В обычном режиме — как раньше, без звука и тостов (XIC-11) @@ -199,7 +201,11 @@ public TrayApp(IMifsClient mifs, AppConfig cfg, IKeyEventSource events, IPowerEv else if (on) _osd.Flash(OsdKind.Travel, Loc.T("osd.travel"), Loc.T("osd.travel.sub")); else _osd.Flash(OsdKind.TravelOff, Loc.T("osd.travel.off")); }; - _controller.TravelCancelled = () => { if (_panel.Visible) _panel.RefreshUi(); }; + _controller.TravelCancelled = () => + { + _chargeLimit.Rearm(); // зарядку выдернули — на батарее и так стоит, но правило одно (XIC-99) + if (_panel.Visible) _panel.RefreshUi(); + }; _controller.ModeSet = m => { _shownMode = m; // прошивка сейчас пришлёт эхо 0x16 — гасим его diff --git a/tests/XiControl.Tests/SoftChargeTests.cs b/tests/XiControl.Tests/SoftChargeTests.cs index 1129e1b..8d8690d 100644 --- a/tests/XiControl.Tests/SoftChargeTests.cs +++ b/tests/XiControl.Tests/SoftChargeTests.cs @@ -18,7 +18,7 @@ public sealed class SoftChargeTests public void Аппаратный_лимит_есть_следим_за_ним() { var t = ChargeLimitWatcher.Target(care: true, careLimit: 60, softOn: true, softLimit: 90, - hardwareMissing: false); + hardwareMissing: false, travel: false); t.Should().Be(new ChargeTarget(60, false), "прошивка остановит заряд сама — программный порог тут лишний, даже если включён"); @@ -28,7 +28,7 @@ public void Аппаратный_лимит_есть_следим_за_ним() public void Аппаратного_нет_следим_за_программным() { var t = ChargeLimitWatcher.Target(care: true, careLimit: 60, softOn: true, softLimit: 90, - hardwareMissing: true); + hardwareMissing: true, travel: false); t.Should().Be(new ChargeTarget(90, true)); } @@ -36,17 +36,56 @@ public void Аппаратного_нет_следим_за_программны [Fact] public void Аппаратного_нет_и_программный_выключен_следить_не_за_чем() => ChargeLimitWatcher.Target(care: true, careLimit: 60, softOn: false, softLimit: 90, - hardwareMissing: true).Should().BeNull("выключено — значит батарею не опрашиваем вовсе"); + hardwareMissing: true, travel: false).Should().BeNull("выключено — значит батарею не опрашиваем вовсе"); [Fact] public void Защита_выключена_следить_не_за_чем() => ChargeLimitWatcher.Target(care: false, careLimit: 60, softOn: true, softLimit: 90, - hardwareMissing: false).Should().BeNull(); + hardwareMissing: false, travel: false).Should().BeNull(); [Fact] public void Программный_порог_из_конфига_зажимается_в_разумные_рамки() => ChargeLimitWatcher.Target(care: false, careLimit: 60, softOn: true, softLimit: 500, - hardwareMissing: true)!.Value.Limit.Should().Be(100, "порог выше 100% недостижим"); + hardwareMissing: true, travel: false)!.Value.Limit.Should().Be(100, "порог выше 100% недостижим"); + + // ---- «В дорогу» снимает порог (XIC-99) ---- + + [Fact] + public void В_дорогу_аппаратный_порог_не_наблюдаем() + => ChargeLimitWatcher.Target(care: true, careLimit: 80, softOn: false, softLimit: 80, + hardwareMissing: false, travel: true).Should().BeNull( + "прошивка заряжает до 100 — вебхук «дошли до 80» оборвал бы разовый заряд розеткой"); + + [Fact] + public void Без_в_дорогу_аппаратный_порог_как_раньше() + => ChargeLimitWatcher.Target(care: true, careLimit: 80, softOn: false, softLimit: 80, + hardwareMissing: false, travel: false).Should().Be(new ChargeTarget(80, false)); + + [Fact] + public void В_дорогу_программный_порог_тоже_не_наблюдаем() + => ChargeLimitWatcher.Target(care: true, careLimit: 80, softOn: true, softLimit: 80, + hardwareMissing: true, travel: true).Should().BeNull("правило одно на оба порога"); + + [Fact] + public void В_дорогу_заряд_выше_порога_вебхук_молчит_а_после_выхода_сообщает() + { + var cfg = new Config.AppConfig { ChargeCare = true, CareLimitPercent = 80, TravelMode = true }; + var power = new FakePowerEvents { IsOnline = true, BatteryLifePercent = 0.85f }; + var timer = new FakeTimer(); + using var w = new ChargeLimitWatcher(cfg, power, timer); + int fired = 0; + w.Reached = (_, _, _) => fired++; + + w.Rearm(); + timer.Running.Should().BeFalse("во время «В дорогу» следить не за чем"); + timer.Fire(); + fired.Should().Be(0); + + cfg.TravelMode = false; // вышли из «В дорогу» на 85% — порог 80 снова держит прошивка + w.Rearm(); + timer.Fire(); + fired.Should().Be(1, "заряд выше порога — автоматика должна узнать сразу (вариант А)"); + } // ---- Напоминания ----