From f44d74e93b1fd7935531bc59a4ad273c64b83d5d Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Thu, 1 Oct 2026 10:38:20 -0700 Subject: [PATCH] Add AC input watchdog that notifies when commanded ON but grid power is not flowing --- solixauto/engine.py | 97 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/solixauto/engine.py b/solixauto/engine.py index 31c5160..8ba9f48 100644 --- a/solixauto/engine.py +++ b/solixauto/engine.py @@ -264,6 +264,8 @@ class Engine: self.last_seen_at = None self.stale_notified = False self.telemetry_writes = 0 + self.ac_watchdog_since = None + self.ac_watchdog_tripped = False def evaluate(self, variables, now): for state in self.states: @@ -474,6 +476,9 @@ class Engine: floor = self.profile.battery_floor if floor is not None and floor.enabled: names.add(floor.field) + watchdog = self.profile.ac_input_watchdog + if watchdog is not None and watchdog.enabled: + names.add(watchdog.field) return names def summarize(self, variables, now): @@ -674,6 +679,90 @@ class Engine: force=True, ) + async def check_ac_watchdog(self, variables, now): + watchdog = self.profile.ac_input_watchdog + if watchdog is None or not watchdog.enabled: + return + + if not self.last_commanded: + if self.ac_watchdog_tripped: + self.ac_watchdog_tripped = False + self.report("AC input watchdog cleared, target is OFF", force=True) + await self.notify_ac_watchdog(variables, recovered=True) + self.ac_watchdog_since = None + return + + value = variables.get(watchdog.field) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return + + if value >= watchdog.min_watts: + if self.ac_watchdog_tripped: + self.ac_watchdog_tripped = False + self.report( + f"AC input watchdog cleared, {watchdog.field}={value:g}W", + force=True, + ) + await self.notify_ac_watchdog(variables, recovered=True) + self.ac_watchdog_since = None + return + + if self.ac_watchdog_since is None: + self.ac_watchdog_since = now + + held = now - self.ac_watchdog_since + if held < (watchdog.dwell or 0): + return + + if self.ac_watchdog_tripped: + return + + self.ac_watchdog_tripped = True + self.report( + f"AC INPUT WATCHDOG: {watchdog.field}={value:g}W has stayed under " + f"{watchdog.min_watts:g}W while commanded ON for " + f"{format_duration(held)}", + force=True, + ) + await self.notify_ac_watchdog(variables, duration_seconds=held) + + async def notify_ac_watchdog(self, variables, duration_seconds=None, recovered=False): + watchdog = self.profile.ac_input_watchdog + if watchdog is None: + return + + if recovered and not watchdog.notify_recovered: + return + + if not self.notifier.available(): + if not recovered: + self.report( + "AC input watchdog tripped but no notification channel is " + "enabled", + force=True, + ) + return + + settings = self.profile.notifications + context = self.context(self.last_commanded, None, variables, "ac_watchdog") + context["field"] = watchdog.field + context["min_watts"] = f"{watchdog.min_watts:g}" + context["duration"] = ( + format_duration(duration_seconds) if duration_seconds else "" + ) + + template = watchdog.recovered_template if recovered else watchdog.notify_template + body = render(template, context) + title = render(settings.title or "{profile}", context) + + await self.notifier.send( + title, + body, + priority=None if recovered else "urgent", + key="ac_watchdog_recovered" if recovered else "ac_watchdog", + force=True, + ) + def record_event(self, desired, reason, rule, variables, cause="rule"): source_identity = self.anker_profile.get("identity", {}) target_identity = self.target.profile.get("identity", {}) @@ -1028,6 +1117,8 @@ class Engine: self.report("telemetry complete, resuming evaluation", force=True) self.incomplete_reported = False + await self.check_ac_watchdog(variables, now) + if await self.check_floor(session, variables, now): self.heartbeat(variables, now) return @@ -1063,6 +1154,12 @@ class Engine: else: self.report("battery floor: NONE SET", force=True) + if self.profile.ac_input_watchdog and self.profile.ac_input_watchdog.enabled: + self.report( + f"AC input watchdog: {self.profile.ac_input_watchdog.describe()}", + force=True, + ) + if self.profile.monitor_fields: self.report( "also logging (not used by any rule): "