Add an actions box showing why each switch changed

This commit is contained in:
Sterling Archer
2026-08-12 10:53:34 -07:00
parent a28db4d1c9
commit 6c43256088
3 changed files with 338 additions and 8 deletions
+47
View File
@@ -663,6 +663,51 @@ def cmd_status(args):
print("stopped") 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): def cmd_switch(args):
from .shelly import ShellyTarget from .shelly import ShellyTarget
import aiohttp import aiohttp
@@ -704,6 +749,8 @@ def cmd_switch(args):
except Exception as err: except Exception as err:
fail(f"{type(err).__name__}: {err}") fail(f"{type(err).__name__}: {err}")
record_manual_event(path, target, desired)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
confirmed = await target.get_state(session) confirmed = await target.get_state(session)
if confirmed is None: if confirmed is None:
+79 -4
View File
@@ -8,12 +8,15 @@ from datetime import datetime
import aiohttp import aiohttp
from . import paths from . import paths
from .profiles import load_yaml from .profiles import load_yaml, slugify
from .notify import Notifier, render from .notify import Notifier, render
from .rules import derived_values, format_duration, validate from .rules import derived_values, format_duration, validate
from .shelly import ShellyTarget from .shelly import ShellyTarget
EVENT_HISTORY = 200
async def interruptible_sleep(seconds): async def interruptible_sleep(seconds):
remaining = float(seconds or 0) remaining = float(seconds or 0)
while remaining > 0: while remaining > 0:
@@ -150,7 +153,15 @@ class Engine:
return None return None
async def apply( 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: if self.last_commanded is desired:
return False return False
@@ -176,6 +187,7 @@ class Engine:
self.recent_actions.append(now) self.recent_actions.append(now)
self.report(f"turned {self._word(desired)} - {reason}") self.report(f"turned {self._word(desired)} - {reason}")
self.save_state(desired, reason) self.save_state(desired, reason)
self.record_event(desired, reason, rule, variables, cause=cause)
await self.notify(desired, rule, variables, event="action") await self.notify(desired, rule, variables, event="action")
return True return True
@@ -341,6 +353,7 @@ class Engine:
"restored after telemetry recovered", "restored after telemetry recovered",
now, now,
force=True, force=True,
cause="recovered",
) )
async def check_floor(self, session, variables, now): async def check_floor(self, session, variables, now):
@@ -377,6 +390,7 @@ class Engine:
now, now,
variables=variables, variables=variables,
force=True, force=True,
cause="floor",
) )
return True return True
@@ -404,6 +418,7 @@ class Engine:
now, now,
variables=variables, variables=variables,
force=True, force=True,
cause="floor",
) )
if floor.notify: if floor.notify:
@@ -445,6 +460,58 @@ class Engine:
force=True, 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): def publish_live(self, variables, age):
identity = self.anker_profile.get("identity", {}) identity = self.anker_profile.get("identity", {})
serial = identity.get("serial") or "unknown" serial = identity.get("serial") or "unknown"
@@ -531,7 +598,11 @@ class Engine:
self.pre_stale_state = self.last_commanded self.pre_stale_state = self.last_commanded
self.stale_action_state = self.profile.safe_state self.stale_action_state = self.profile.safe_state
await self.apply( 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: if not self.stale_notified:
@@ -597,7 +668,11 @@ class Engine:
self.pre_stale_state = self.last_commanded self.pre_stale_state = self.last_commanded
self.stale_action_state = self.profile.safe_state self.stale_action_state = self.profile.safe_state
await self.apply( 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 return
+212 -4
View File
@@ -17,7 +17,14 @@ class Sampler:
def __init__(self, interval=5.0, history=HISTORY_SIZE): def __init__(self, interval=5.0, history=HISTORY_SIZE):
self.interval = interval self.interval = interval
self.history = deque(maxlen=history) 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.lock = threading.Lock()
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.thread = None self.thread = None
@@ -196,9 +203,26 @@ class Sampler:
) )
return devices 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): def _sample(self):
anker = self._anker_devices() anker = self._anker_devices()
shelly = self._shelly_devices() shelly = self._shelly_devices()
events = self._events()
point = {"t": time.time()} point = {"t": time.time()}
for device in anker: for device in anker:
@@ -216,6 +240,8 @@ class Sampler:
self.snapshot = { self.snapshot = {
"anker": anker, "anker": anker,
"shelly": shelly, "shelly": shelly,
"events": events,
"multi": len(anker) > 1 or len(shelly) > 1,
"updated": time.strftime("%H:%M:%S"), "updated": time.strftime("%H:%M:%S"),
"engine": any(d["live"] for d in anker), "engine": any(d["live"] for d in anker),
"interval": self.interval, "interval": self.interval,
@@ -558,6 +584,106 @@ PAGE = """<!doctype html>
canvas { width: 100%; height: 300px; display: block; } 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 { .empty {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; font-size: 12px;
@@ -586,18 +712,23 @@ PAGE = """<!doctype html>
</header> </header>
<section> <section>
<h2>Anker SOLIX</h2> <h2>Anker devices</h2>
<div id="anker"></div> <div id="anker"></div>
</section> </section>
<section> <section>
<h2>Shelly switches</h2> <h2>Shelly devices</h2>
<div id="shelly"></div> <div id="shelly"></div>
</section> </section>
<section>
<h2>Actions</h2>
<div class="event-scroll"><div id="events"></div></div>
</section>
<section> <section>
<div class="chart-head"> <div class="chart-head">
<h2 style="margin:0">Recent history</h2> <h2 style="margin:0">History</h2>
<div class="legend"> <div class="legend">
<span><i class="swatch sw-solar"></i>solar W</span> <span><i class="swatch sw-solar"></i>solar W</span>
<span><i class="swatch sw-grid"></i>grid in W</span> <span><i class="swatch sw-grid"></i>grid in W</span>
@@ -806,6 +937,82 @@ function renderShelly(devices) {
+ '</tbody></table>'; + '</tbody></table>';
} }
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 = '<p class="empty">Every change to a plug will appear here '
+ 'with the reason for it.</p>';
return;
}
let html = '';
let lastDay = null;
events.forEach(e => {
const day = dayLabel(e.epoch);
if (day !== lastDay) {
html += '<div class="event-day">' + day + '</div>';
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 += '<div class="event"' + (tip ? ' title="' + esc(tip) + '"' : '') + '>'
+ '<div class="event-time">' + clockLabel(e.epoch) + '</div>'
+ '<div class="event-state ' + (e.state ? 'on' : 'off') + '">'
+ (e.state ? 'ON' : 'OFF') + '</div>'
+ '<div class="event-target">' + esc(e.target || 'switch') + '</div>'
+ '<div class="event-why">'
+ '<span class="event-cause ' + cause + '">' + label + '</span>'
+ why
+ '</div>'
+ (detail ? '<div class="event-detail">' + detail + '</div>' : '')
+ '</div>';
});
box.innerHTML = html;
}
function drawChart(history, devices) { function drawChart(history, devices) {
const canvas = document.getElementById('chart'); const canvas = document.getElementById('chart');
const ratio = window.devicePixelRatio || 1; const ratio = window.devicePixelRatio || 1;
@@ -928,6 +1135,7 @@ async function refresh() {
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale'; document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
renderAnker(data.anker); renderAnker(data.anker);
renderShelly(data.shelly); renderShelly(data.shelly);
renderEvents(data.events, data.multi);
drawChart(data.history, data.anker); drawChart(data.history, data.anker);
} catch (err) { } catch (err) {
failures++; failures++;