diff --git a/solixauto/cli.py b/solixauto/cli.py index 1ca8454..b948beb 100644 --- a/solixauto/cli.py +++ b/solixauto/cli.py @@ -663,6 +663,51 @@ def cmd_status(args): print("stopped") +def record_manual_event(profile_path, target, desired): + import json + import time + from datetime import datetime + + from .profiles import load_yaml as _load + + try: + identity = (_load(profile_path).get("identity") or {}) + except Exception: + identity = {} + + event = { + "epoch": time.time(), + "when": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "state": bool(desired), + "cause": "manual", + "profile": None, + "rule": None, + "condition": None, + "reason": "switched manually from the command line", + "target": identity.get("name") or identity.get("model") or target.host, + "target_channel": target.channel, + "source": None, + "source_serial": None, + "values": {}, + } + + path = paths.STATE_DIR / "events-manual.json" + try: + paths.STATE_DIR.mkdir(parents=True, exist_ok=True) + history = [] + if path.exists(): + try: + history = json.loads(path.read_text(encoding="utf-8")) + except Exception: + history = [] + if not isinstance(history, list): + history = [] + history.append(event) + path.write_text(json.dumps(history[-200:]), encoding="utf-8") + except Exception: + pass + + def cmd_switch(args): from .shelly import ShellyTarget import aiohttp @@ -704,6 +749,8 @@ def cmd_switch(args): except Exception as err: fail(f"{type(err).__name__}: {err}") + record_manual_event(path, target, desired) + await asyncio.sleep(1.0) confirmed = await target.get_state(session) if confirmed is None: diff --git a/solixauto/engine.py b/solixauto/engine.py index ef4df55..2f1d2fa 100644 --- a/solixauto/engine.py +++ b/solixauto/engine.py @@ -8,12 +8,15 @@ from datetime import datetime import aiohttp from . import paths -from .profiles import load_yaml +from .profiles import load_yaml, slugify from .notify import Notifier, render from .rules import derived_values, format_duration, validate from .shelly import ShellyTarget +EVENT_HISTORY = 200 + + async def interruptible_sleep(seconds): remaining = float(seconds or 0) while remaining > 0: @@ -150,7 +153,15 @@ class Engine: return None async def apply( - self, session, desired, reason, now, rule=None, variables=None, force=False + self, + session, + desired, + reason, + now, + rule=None, + variables=None, + force=False, + cause="rule", ): if self.last_commanded is desired: return False @@ -176,6 +187,7 @@ class Engine: self.recent_actions.append(now) self.report(f"turned {self._word(desired)} - {reason}") self.save_state(desired, reason) + self.record_event(desired, reason, rule, variables, cause=cause) await self.notify(desired, rule, variables, event="action") return True @@ -341,6 +353,7 @@ class Engine: "restored after telemetry recovered", now, force=True, + cause="recovered", ) async def check_floor(self, session, variables, now): @@ -377,6 +390,7 @@ class Engine: now, variables=variables, force=True, + cause="floor", ) return True @@ -404,6 +418,7 @@ class Engine: now, variables=variables, force=True, + cause="floor", ) if floor.notify: @@ -445,6 +460,58 @@ class Engine: 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", {}) + + detail = {} + for key in ( + "battery_soc", + "pv_total", + "pv_surplus", + "output_power_total", + "ac_input_power", + ): + value = (variables or {}).get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + detail[key] = round(value) + + event = { + "epoch": time.time(), + "when": stamp(), + "state": bool(desired), + "cause": cause, + "profile": self.profile.name, + "rule": rule.name if rule else None, + "condition": rule.when_source if rule else None, + "reason": reason, + "target": target_identity.get("name") + or target_identity.get("model") + or self.target.host, + "target_channel": self.target.channel, + "source": source_identity.get("name") + or source_identity.get("model") + or source_identity.get("serial"), + "source_serial": source_identity.get("serial"), + "values": detail, + } + + path = paths.STATE_DIR / f"events-{slugify(self.profile.name)}.json" + try: + paths.STATE_DIR.mkdir(parents=True, exist_ok=True) + history = [] + if path.exists(): + try: + history = json.loads(path.read_text(encoding="utf-8")) + except Exception: + history = [] + if not isinstance(history, list): + history = [] + history.append(event) + path.write_text(json.dumps(history[-EVENT_HISTORY:]), encoding="utf-8") + except Exception: + pass + def publish_live(self, variables, age): identity = self.anker_profile.get("identity", {}) serial = identity.get("serial") or "unknown" @@ -531,7 +598,11 @@ class Engine: self.pre_stale_state = self.last_commanded self.stale_action_state = self.profile.safe_state await self.apply( - session, self.profile.safe_state, "mqtt disconnected safe state", now + session, + self.profile.safe_state, + "connection to Anker lost", + now, + cause="stale", ) if not self.stale_notified: @@ -597,7 +668,11 @@ class Engine: self.pre_stale_state = self.last_commanded self.stale_action_state = self.profile.safe_state await self.apply( - session, self.profile.safe_state, "stale telemetry safe state", now + session, + self.profile.safe_state, + "telemetry went stale", + now, + cause="stale", ) return diff --git a/solixauto/monitor.py b/solixauto/monitor.py index c9ed959..d61f06f 100644 --- a/solixauto/monitor.py +++ b/solixauto/monitor.py @@ -17,7 +17,14 @@ class Sampler: def __init__(self, interval=5.0, history=HISTORY_SIZE): self.interval = interval self.history = deque(maxlen=history) - self.snapshot = {"anker": [], "shelly": [], "updated": None, "engine": False} + self.snapshot = { + "anker": [], + "shelly": [], + "events": [], + "multi": False, + "updated": None, + "engine": False, + } self.lock = threading.Lock() self.stop_event = threading.Event() self.thread = None @@ -196,9 +203,26 @@ class Sampler: ) return devices + def _events(self): + events = [] + if not paths.STATE_DIR.exists(): + return events + + for path in sorted(paths.STATE_DIR.glob("events-*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + if isinstance(data, list): + events.extend(entry for entry in data if isinstance(entry, dict)) + + events.sort(key=lambda entry: entry.get("epoch", 0), reverse=True) + return events[:60] + def _sample(self): anker = self._anker_devices() shelly = self._shelly_devices() + events = self._events() point = {"t": time.time()} for device in anker: @@ -216,6 +240,8 @@ class Sampler: self.snapshot = { "anker": anker, "shelly": shelly, + "events": events, + "multi": len(anker) > 1 or len(shelly) > 1, "updated": time.strftime("%H:%M:%S"), "engine": any(d["live"] for d in anker), "interval": self.interval, @@ -558,6 +584,106 @@ PAGE = """ canvas { width: 100%; height: 300px; display: block; } + .event-scroll { + max-height: 232px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--rule) transparent; + } + + .event-scroll::-webkit-scrollbar { width: 9px; } + .event-scroll::-webkit-scrollbar-track { background: transparent; } + .event-scroll::-webkit-scrollbar-thumb { + background: var(--rule); + border: 2px solid var(--panel-raised); + } + .event-scroll::-webkit-scrollbar-thumb:hover { background: var(--ink-soft); } + + .event { + display: flex; + align-items: baseline; + gap: 12px; + padding: 9px 12px 9px 0; + border-top: 1px solid var(--rule); + white-space: nowrap; + } + + .event:first-child { border-top: none; } + + .event-time { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; + flex: 0 0 auto; + } + + .event-state { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 10px; + letter-spacing: 0.08em; + text-align: center; + padding: 1px 0; + border: 1px solid currentColor; + flex: 0 0 38px; + } + + .event-state.on { color: var(--charge); } + .event-state.off { color: var(--ink-soft); } + + .event-target { + font-size: 13px; + font-weight: 600; + flex: 0 0 auto; + max-width: 20ch; + overflow: hidden; + text-overflow: ellipsis; + } + + .event-why { + font-size: 13px; + color: var(--ink); + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + .event-cause { + display: inline-block; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 9px; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 1px 6px; + margin-right: 7px; + border: 1px solid var(--rule); + color: var(--ink-soft); + vertical-align: 1px; + } + + .event-cause.floor { color: var(--alert); border-color: currentColor; } + .event-cause.stale { color: var(--solar); border-color: currentColor; } + + .event-detail { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; + color: var(--ink-soft); + flex: 0 0 auto; + } + + .event-day { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-soft); + padding: 14px 0 6px; + border-top: 1px solid var(--rule); + } + + .event-day:first-child { border-top: none; padding-top: 0; } + .empty { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; @@ -586,18 +712,23 @@ PAGE = """
-

Anker SOLIX

+

Anker devices

-

Shelly switches

+

Shelly devices

+
+

Actions

+
+
+
-

Recent history

+

History

solar W grid in W @@ -806,6 +937,82 @@ function renderShelly(devices) { + ''; } +const CAUSE_LABEL = { + rule: 'rule', + floor: 'safety floor', + stale: 'telemetry lost', + recovered: 'recovered', + manual: 'manual' +}; + +function dayLabel(epoch) { + const d = new Date(epoch * 1000); + const today = new Date(); + const yesterday = new Date(today.getTime() - 86400000); + const same = (a, b) => a.toDateString() === b.toDateString(); + if (same(d, today)) return 'Today'; + if (same(d, yesterday)) return 'Yesterday'; + return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); +} + +function clockLabel(epoch) { + const d = new Date(epoch * 1000); + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); +} + +function eventDetail(e) { + const v = e.values || {}; + const bits = []; + if (v.battery_soc !== undefined) bits.push('battery ' + v.battery_soc + '%'); + if (v.pv_total !== undefined) bits.push('solar ' + v.pv_total + 'W'); + if (v.output_power_total !== undefined) bits.push('load ' + v.output_power_total + 'W'); + if (v.ac_input_power) bits.push('grid ' + v.ac_input_power + 'W'); + if (!bits.length) return ''; + const who = e.source ? esc(e.source) + ': ' : ''; + return who + bits.join(', '); +} + +function renderEvents(events, multi) { + const box = document.getElementById('events'); + + if (!events || !events.length) { + box.innerHTML = '

Every change to a plug will appear here ' + + 'with the reason for it.

'; + return; + } + + let html = ''; + let lastDay = null; + + events.forEach(e => { + const day = dayLabel(e.epoch); + if (day !== lastDay) { + html += '
' + day + '
'; + lastDay = day; + } + + const cause = e.cause || 'rule'; + const label = CAUSE_LABEL[cause] || cause; + const why = e.rule ? esc(e.rule) : esc(e.reason || 'changed'); + const detail = eventDetail(e); + const tip = [e.condition, detail].filter(Boolean).join(' | '); + + html += '
' + + '
' + clockLabel(e.epoch) + '
' + + '
' + + (e.state ? 'ON' : 'OFF') + '
' + + '
' + esc(e.target || 'switch') + '
' + + '
' + + '' + label + '' + + why + + '
' + + (detail ? '
' + detail + '
' : '') + + '
'; + }); + + box.innerHTML = html; +} + function drawChart(history, devices) { const canvas = document.getElementById('chart'); const ratio = window.devicePixelRatio || 1; @@ -928,6 +1135,7 @@ async function refresh() { document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale'; renderAnker(data.anker); renderShelly(data.shelly); + renderEvents(data.events, data.multi); drawChart(data.history, data.anker); } catch (err) { failures++;