Compare commits

...
12 Commits
5 changed files with 526 additions and 57 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 108 KiB

+223 -20
View File
@@ -166,6 +166,12 @@ def stamp():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S") return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def clock_stamp():
now = datetime.now()
hour12 = now.hour % 12 or 12
return f"{hour12}:{now.minute:02d} {now.strftime('%p')}"
class Reporter: class Reporter:
def __init__(self, log_path=None, quiet=False): def __init__(self, log_path=None, quiet=False):
self.quiet = quiet self.quiet = quiet
@@ -258,6 +264,8 @@ class Engine:
self.last_seen_at = None self.last_seen_at = None
self.stale_notified = False self.stale_notified = False
self.telemetry_writes = 0 self.telemetry_writes = 0
self.ac_watchdog_since = None
self.ac_watchdog_tripped = False
def evaluate(self, variables, now): def evaluate(self, variables, now):
for state in self.states: for state in self.states:
@@ -313,6 +321,49 @@ class Engine:
self.report(f"FAILED to turn {self._word(desired)}: {type(err).__name__}: {err}") self.report(f"FAILED to turn {self._word(desired)}: {type(err).__name__}: {err}")
return False return False
await asyncio.sleep(1.0)
try:
confirmed = await self.target.get_state(session)
except Exception:
confirmed = None
if confirmed is None:
self.report(
f"sent {self._word(desired)} but could not read back the plug's "
"actual state to confirm it took effect"
)
await self.notify_switch_problem(
"unconfirmed", desired, None, rule, variables, reason
)
self.record_event(
None,
f"{reason} (sent {self._word(desired)}, could not verify)",
rule,
variables,
cause="unverified",
)
return False
if confirmed != desired:
self.report(
f"WARNING: commanded {self._word(desired)} ({reason}) but the "
f"plug reports {self._word(confirmed)}. Not treating this as "
"applied; will retry."
)
self.last_commanded = confirmed
await self.notify_switch_problem(
"failed", desired, confirmed, rule, variables, reason
)
self.record_event(
confirmed,
f"{reason} (commanded {self._word(desired)}, plug stayed "
f"{self._word(confirmed)})",
rule,
variables,
cause="switch_failed",
)
return False
self.last_commanded = desired self.last_commanded = desired
self.last_action_at = now self.last_action_at = now
self.recent_actions.append(now) self.recent_actions.append(now)
@@ -322,6 +373,32 @@ class Engine:
await self.notify(desired, rule, variables, event="action") await self.notify(desired, rule, variables, event="action")
return True return True
async def notify_switch_problem(
self, kind, desired, confirmed, rule, variables, reason
):
if not self.notifier.available():
self.report(
f"switch {kind} but no notification channel is enabled", force=True
)
return
settings = self.profile.notifications
context = self.context(confirmed, rule, variables, "switch_" + kind)
context["reason"] = reason
context["attempted"] = self._word(desired)
context["actual"] = self._word(confirmed) if confirmed is not None else "unknown"
template = (
settings.switch_unconfirmed_template
if kind == "unconfirmed"
else settings.switch_failed_template
)
body = render(template, context)
title = render(settings.title or "{profile}", context)
await self.notifier.send(title, body, priority="urgent", key="switch_" + kind)
@staticmethod @staticmethod
def _word(desired): def _word(desired):
return "ON" if desired else "OFF" return "ON" if desired else "OFF"
@@ -355,6 +432,7 @@ class Engine:
"target_host": self.target.host, "target_host": self.target.host,
"target_channel": self.target.channel, "target_channel": self.target.channel,
"time": stamp(), "time": stamp(),
"clock": clock_stamp(),
} }
) )
return context return context
@@ -398,6 +476,9 @@ class Engine:
floor = self.profile.battery_floor floor = self.profile.battery_floor
if floor is not None and floor.enabled: if floor is not None and floor.enabled:
names.add(floor.field) names.add(floor.field)
watchdog = self.profile.ac_input_watchdog
if watchdog is not None and watchdog.enabled:
names.add(watchdog.field)
return names return names
def summarize(self, variables, now): def summarize(self, variables, now):
@@ -467,6 +548,13 @@ class Engine:
self.stale_action_state = None self.stale_action_state = None
self.pre_stale_state = None self.pre_stale_state = None
if self.floor_latched:
self.report(
"battery floor is still active, not restoring pre-outage state",
force=True,
)
return
if previous is None or previous == applied: if previous is None or previous == applied:
return return
@@ -591,6 +679,90 @@ class Engine:
force=True, 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"): def record_event(self, desired, reason, rule, variables, cause="rule"):
source_identity = self.anker_profile.get("identity", {}) source_identity = self.anker_profile.get("identity", {})
target_identity = self.target.profile.get("identity", {}) target_identity = self.target.profile.get("identity", {})
@@ -810,16 +982,27 @@ class Engine:
if self.profile.on_stale == "stop": if self.profile.on_stale == "stop":
raise RuntimeError("stopping: MQTT session disconnected") raise RuntimeError("stopping: MQTT session disconnected")
if self.profile.on_stale == "safe_state": if self.profile.on_stale == "safe_state":
if self.stale_action_state is None: if self.floor_latched:
self.pre_stale_state = self.last_commanded floor_state = self.profile.battery_floor.desired_state()
self.stale_action_state = self.profile.safe_state await self.apply(
await self.apply( session,
session, floor_state,
self.profile.safe_state, "battery floor holding through a lost connection",
"connection to Anker lost", now,
now, force=True,
cause="stale", cause="floor",
) )
else:
if self.stale_action_state is None:
self.pre_stale_state = self.last_commanded
self.stale_action_state = self.profile.safe_state
await self.apply(
session,
self.profile.safe_state,
"connection to Anker lost",
now,
cause="stale",
)
if not self.stale_notified: if not self.stale_notified:
self.stale_notified = True self.stale_notified = True
@@ -880,16 +1063,27 @@ class Engine:
if self.profile.on_stale == "stop": if self.profile.on_stale == "stop":
raise RuntimeError("stopping: telemetry went stale") raise RuntimeError("stopping: telemetry went stale")
if self.profile.on_stale == "safe_state": if self.profile.on_stale == "safe_state":
if self.stale_action_state is None: if self.floor_latched:
self.pre_stale_state = self.last_commanded floor_state = self.profile.battery_floor.desired_state()
self.stale_action_state = self.profile.safe_state await self.apply(
await self.apply( session,
session, floor_state,
self.profile.safe_state, "battery floor holding through stale telemetry",
"telemetry went stale", now,
now, force=True,
cause="stale", cause="floor",
) )
else:
if self.stale_action_state is None:
self.pre_stale_state = self.last_commanded
self.stale_action_state = self.profile.safe_state
await self.apply(
session,
self.profile.safe_state,
"telemetry went stale",
now,
cause="stale",
)
return return
if self.stale_reported: if self.stale_reported:
@@ -923,6 +1117,8 @@ class Engine:
self.report("telemetry complete, resuming evaluation", force=True) self.report("telemetry complete, resuming evaluation", force=True)
self.incomplete_reported = False self.incomplete_reported = False
await self.check_ac_watchdog(variables, now)
if await self.check_floor(session, variables, now): if await self.check_floor(session, variables, now):
self.heartbeat(variables, now) self.heartbeat(variables, now)
return return
@@ -958,6 +1154,12 @@ class Engine:
else: else:
self.report("battery floor: NONE SET", force=True) 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: if self.profile.monitor_fields:
self.report( self.report(
"also logging (not used by any rule): " "also logging (not used by any rule): "
@@ -1208,6 +1410,7 @@ def _preview_notification(profile, rule, variables, desired):
"target_host": target_profile.get("access", {}).get("host", ""), "target_host": target_profile.get("access", {}).get("host", ""),
"target_channel": profile.target_channel or 0, "target_channel": profile.target_channel or 0,
"time": stamp(), "time": stamp(),
"clock": clock_stamp(),
"event": "action", "event": "action",
} }
) )
+191 -19
View File
@@ -264,16 +264,35 @@ class Sampler:
def windowed_history(self, window): def windowed_history(self, window):
from . import tune from . import tune
window_seconds = {"1d": 86400, "7d": 7 * 86400, "30d": 30 * 86400}.get( now = time.time()
window, 86400 local_now = time.localtime(now)
midnight_struct = (
local_now.tm_year,
local_now.tm_mon,
local_now.tm_mday,
0,
0,
0,
0,
0,
-1,
) )
today_start = time.mktime(midnight_struct)
window_bounds = {
"today": (today_start, now),
"yesterday": (today_start - 86400, today_start - 0.001),
"week": (now - 7 * 86400, now),
"month": (now - 30 * 86400, now),
}
since_epoch, until_epoch = window_bounds.get(window, window_bounds["today"])
serial, name = self._live_anker_serial() serial, name = self._live_anker_serial()
if not serial: if not serial:
return {"points": [], "grid": None, "serial": None, "name": None} return {"points": [], "grid": None, "serial": None, "name": None}
since_epoch = time.time() - window_seconds
records = tune.load_telemetry(serial, since_epoch=since_epoch) records = tune.load_telemetry(serial, since_epoch=since_epoch)
records = [r for r in records if r[0] <= until_epoch]
max_points = 600 max_points = 600
step = max(1, len(records) // max_points) step = max(1, len(records) // max_points)
@@ -408,9 +427,8 @@ PAGE = """<!doctype html>
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding-bottom: 14px; padding-bottom: 6px;
border-bottom: 2px solid var(--ink); margin-bottom: 10px;
margin-bottom: 26px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -795,6 +813,37 @@ PAGE = """<!doctype html>
.theme button:focus-visible { outline: 2px solid var(--solar); outline-offset: 1px; } .theme button:focus-visible { outline: 2px solid var(--solar); outline-offset: 1px; }
.fit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-left: 14px;
padding: 0;
font-size: 14px;
line-height: 1;
border: 1px solid var(--rule);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
vertical-align: middle;
}
.fit-btn:hover { color: var(--ink); }
.fit-btn[aria-pressed="true"] {
background: var(--ink);
color: var(--panel-raised);
}
[data-theme="dark"] .fit-btn[aria-pressed="true"] {
background: var(--solar);
color: var(--panel);
}
.fit-btn:focus-visible { outline: 2px solid var(--solar); outline-offset: 1px; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th { th {
@@ -991,6 +1040,8 @@ PAGE = """<!doctype html>
.event-cause.floor { color: var(--alert); border-color: currentColor; } .event-cause.floor { color: var(--alert); border-color: currentColor; }
.event-cause.stale { color: var(--solar); border-color: currentColor; } .event-cause.stale { color: var(--solar); border-color: currentColor; }
.event-cause.switch_failed { color: var(--alert); border-color: currentColor; }
.event-cause.unverified { color: var(--alert); border-color: currentColor; }
.event-detail { .event-detail {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-family: ui-monospace, "SF Mono", Menlo, monospace;
@@ -1039,6 +1090,8 @@ PAGE = """<!doctype html>
<button type="button" id="clock12Btn" aria-pressed="false">12H</button> <button type="button" id="clock12Btn" aria-pressed="false">12H</button>
<button type="button" id="clock24Btn" aria-pressed="false">24H</button> <button type="button" id="clock24Btn" aria-pressed="false">24H</button>
</span> </span>
<button type="button" id="fitBtn" class="fit-btn" aria-pressed="false"
title="Fit the whole page to your window, no scrolling">⛶</button>
</div> </div>
</header> </header>
@@ -1090,9 +1143,10 @@ PAGE = """<!doctype html>
<span><i class="swatch sw-batt"></i>battery</span> <span><i class="swatch sw-batt"></i>battery</span>
</div> </div>
<span class="theme" role="group" aria-label="Time window"> <span class="theme" role="group" aria-label="Time window">
<button type="button" id="window1d" aria-pressed="true">1 DAY</button> <button type="button" id="windowtoday" aria-pressed="true">TODAY</button>
<button type="button" id="window7d" aria-pressed="false">7 DAYS</button> <button type="button" id="windowyesterday" aria-pressed="false">YESTERDAY</button>
<button type="button" id="window30d" aria-pressed="false">30 DAYS</button> <button type="button" id="windowweek" aria-pressed="false">WEEK</button>
<button type="button" id="windowmonth" aria-pressed="false">MONTH</button>
</span> </span>
</div> </div>
<canvas id="chart"></canvas> <canvas id="chart"></canvas>
@@ -1477,7 +1531,9 @@ const CAUSE_LABEL = {
floor: 'safety floor', floor: 'safety floor',
stale: 'telemetry lost', stale: 'telemetry lost',
recovered: 'recovered', recovered: 'recovered',
manual: 'manual' manual: 'manual',
switch_failed: 'switch did not take effect',
unverified: 'switch unverified'
}; };
function dayLabel(epoch) { function dayLabel(epoch) {
@@ -1490,6 +1546,11 @@ function dayLabel(epoch) {
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
} }
function shortDateLabel(epoch) {
const d = new Date(epoch * 1000);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
let use12Hour = false; let use12Hour = false;
function clockLabel(epoch) { function clockLabel(epoch) {
@@ -1557,6 +1618,8 @@ function renderEvents(events, multi) {
box.innerHTML = html; box.innerHTML = html;
} }
let chartGeom = null;
function drawChart(history, serial) { function drawChart(history, serial) {
const canvas = document.getElementById('chart'); const canvas = document.getElementById('chart');
const ratio = window.devicePixelRatio || 1; const ratio = window.devicePixelRatio || 1;
@@ -1577,6 +1640,7 @@ function drawChart(history, serial) {
ctx.fillStyle = css('--ink-soft'); ctx.fillStyle = css('--ink-soft');
if (!serial || history.length < 2) { if (!serial || history.length < 2) {
chartGeom = null;
ctx.fillText('Waiting for readings. The chart fills in as samples arrive.', pad.l, height / 2); ctx.fillText('Waiting for readings. The chart fills in as samples arrive.', pad.l, height / 2);
return; return;
} }
@@ -1603,6 +1667,8 @@ function drawChart(history, serial) {
const yW = v => pad.t + plotH - (v / maxW) * plotH; const yW = v => pad.t + plotH - (v / maxW) * plotH;
const yP = v => pad.t + plotH - (v / 100) * plotH; const yP = v => pad.t + plotH - (v / 100) * plotH;
chartGeom = { pad, plotW, plotH, t0, span, width, height };
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
const yy = pad.t + plotH * i / 4; const yy = pad.t + plotH * i / 4;
@@ -1617,9 +1683,12 @@ function drawChart(history, serial) {
} }
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const spansMultipleDaysAxis = currentWindow === 'week' || currentWindow === 'month';
[0, 0.5, 1].forEach(f => { [0, 0.5, 1].forEach(f => {
const tt = t0 + span * f; const tt = t0 + span * f;
const label = clockLabel(tt); const label = spansMultipleDaysAxis
? shortDateLabel(tt) + ' ' + clockLabel(tt)
: clockLabel(tt);
ctx.fillText(label, pad.l + plotW * f, height - 8); ctx.fillText(label, pad.l + plotW * f, height - 8);
}); });
@@ -1642,10 +1711,62 @@ function drawChart(history, serial) {
ctx.setLineDash([]); ctx.setLineDash([]);
} }
function drawChartCrosshair(clientX) {
const canvas = document.getElementById('chart');
if (!canvas || !chartGeom) return;
const rect = canvas.getBoundingClientRect();
const cssX = clientX - rect.left;
const { pad, plotW, plotH, t0, span, height } = chartGeom;
if (cssX < pad.l || cssX > pad.l + plotW) {
drawChart(currentHistory.points, currentHistory.serial);
return;
}
drawChart(currentHistory.points, currentHistory.serial);
const ctx = canvas.getContext('2d');
ctx.setLineDash([3, 3]);
ctx.strokeStyle = css('--ink');
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cssX, pad.t);
ctx.lineTo(cssX, pad.t + plotH);
ctx.stroke();
ctx.setLineDash([]);
const t = t0 + (cssX - pad.l) / plotW * span;
const spansMultipleDays = currentWindow === 'week' || currentWindow === 'month';
const label = spansMultipleDays
? shortDateLabel(t) + ' ' + clockLabel(t)
: clockLabel(t);
ctx.font = '10px ui-monospace, Menlo, monospace';
const textWidth = ctx.measureText(label).width;
const boxPad = 4;
const boxY = height - 8;
ctx.fillStyle = css('--panel-raised');
ctx.fillRect(cssX - textWidth / 2 - boxPad, boxY - 10, textWidth + boxPad * 2, 16);
ctx.strokeStyle = css('--solar');
ctx.lineWidth = 1;
ctx.strokeRect(cssX - textWidth / 2 - boxPad, boxY - 10, textWidth + boxPad * 2, 16);
ctx.fillStyle = css('--solar');
ctx.textAlign = 'center';
ctx.fillText(label, cssX, boxY + 2);
}
function clearChartCrosshair() {
if (!chartGeom) return;
drawChart(currentHistory.points, currentHistory.serial);
}
let latest = null; let latest = null;
let paused = false; let paused = false;
let currentHistory = { points: [], grid: null, serial: null, name: null }; let currentHistory = { points: [], grid: null, serial: null, name: null };
let currentWindow = '1d'; let currentWindow = 'today';
const PANEL_KEY = 'solixauto-collapsed'; const PANEL_KEY = 'solixauto-collapsed';
@@ -1673,6 +1794,7 @@ function togglePanel(key) {
if (!collapsed[key] && key === 'history') { if (!collapsed[key] && key === 'history') {
drawChart(currentHistory.points, currentHistory.serial); drawChart(currentHistory.points, currentHistory.serial);
} }
refitIfEnabled();
} }
function setCount(key, text) { function setCount(key, text) {
@@ -1695,6 +1817,34 @@ function applyTheme(mode) {
renderGridUsage(currentHistory.grid); renderGridUsage(currentHistory.grid);
} }
let fitToScreen = false;
function computeFitScale() {
const previousZoom = document.documentElement.style.zoom;
document.documentElement.style.zoom = '';
const naturalHeight = document.documentElement.scrollHeight;
document.documentElement.style.zoom = previousZoom;
if (!naturalHeight) return 1;
return Math.min(1, window.innerHeight / naturalHeight);
}
function refitIfEnabled() {
if (!fitToScreen) return;
document.documentElement.style.zoom = computeFitScale();
}
function applyFitToScreen(enabled) {
fitToScreen = enabled;
const btn = document.getElementById('fitBtn');
if (btn) btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
try { localStorage.setItem('solixauto-fit', enabled ? '1' : '0'); } catch (e) {}
if (enabled) {
refitIfEnabled();
} else {
document.documentElement.style.zoom = '';
}
}
function applyClockFormat(is12Hour) { function applyClockFormat(is12Hour) {
use12Hour = is12Hour; use12Hour = is12Hour;
document.getElementById('clock12Btn').setAttribute('aria-pressed', is12Hour); document.getElementById('clock12Btn').setAttribute('aria-pressed', is12Hour);
@@ -1704,6 +1854,7 @@ function applyClockFormat(is12Hour) {
renderEvents(latest.events, latest.multi); renderEvents(latest.events, latest.multi);
} }
if (!collapsed.history) drawChart(currentHistory.points, currentHistory.serial); if (!collapsed.history) drawChart(currentHistory.points, currentHistory.serial);
refitIfEnabled();
} }
const stored = (() => { const stored = (() => {
@@ -1719,10 +1870,17 @@ const storedClock12 = (() => {
applyClockFormat(storedClock12 === '1'); applyClockFormat(storedClock12 === '1');
const storedFit = (() => {
try { return localStorage.getItem('solixauto-fit'); } catch (e) { return null; }
})();
applyFitToScreen(storedFit === '1');
document.getElementById('lightBtn').addEventListener('click', () => applyTheme('light')); document.getElementById('lightBtn').addEventListener('click', () => applyTheme('light'));
document.getElementById('darkBtn').addEventListener('click', () => applyTheme('dark')); document.getElementById('darkBtn').addEventListener('click', () => applyTheme('dark'));
document.getElementById('clock12Btn').addEventListener('click', () => applyClockFormat(true)); document.getElementById('clock12Btn').addEventListener('click', () => applyClockFormat(true));
document.getElementById('clock24Btn').addEventListener('click', () => applyClockFormat(false)); document.getElementById('clock24Btn').addEventListener('click', () => applyClockFormat(false));
document.getElementById('fitBtn').addEventListener('click', () => applyFitToScreen(!fitToScreen));
let failures = 0; let failures = 0;
@@ -1751,6 +1909,7 @@ async function refresh() {
? data.shelly.length + ', ' + on + ' on' : 'none'); ? data.shelly.length + ', ' + on + ' on' : 'none');
setCount('actions', (data.events || []).length setCount('actions', (data.events || []).length
? (data.events || []).length + ' logged' : 'none yet'); ? (data.events || []).length + ' logged' : 'none yet');
refitIfEnabled();
} catch (err) { } catch (err) {
failures++; failures++;
document.getElementById('dot').className = 'dot stale'; document.getElementById('dot').className = 'dot stale';
@@ -1802,7 +1961,7 @@ function renderGridUsage(grid) {
async function loadHistory(win) { async function loadHistory(win) {
currentWindow = win; currentWindow = win;
['1d', '7d', '30d'].forEach(w => { ['today', 'yesterday', 'week', 'month'].forEach(w => {
const button = document.getElementById('window' + w); const button = document.getElementById('window' + w);
if (button) button.setAttribute('aria-pressed', w === win ? 'true' : 'false'); if (button) button.setAttribute('aria-pressed', w === win ? 'true' : 'false');
}); });
@@ -1820,11 +1979,13 @@ async function loadHistory(win) {
drawChart(currentHistory.points, currentHistory.serial); drawChart(currentHistory.points, currentHistory.serial);
} }
renderGridUsage(currentHistory.grid); renderGridUsage(currentHistory.grid);
refitIfEnabled();
} }
document.getElementById('window1d').addEventListener('click', () => loadHistory('1d')); document.getElementById('windowtoday').addEventListener('click', () => loadHistory('today'));
document.getElementById('window7d').addEventListener('click', () => loadHistory('7d')); document.getElementById('windowyesterday').addEventListener('click', () => loadHistory('yesterday'));
document.getElementById('window30d').addEventListener('click', () => loadHistory('30d')); document.getElementById('windowweek').addEventListener('click', () => loadHistory('week'));
document.getElementById('windowmonth').addEventListener('click', () => loadHistory('month'));
refresh(); refresh();
loadHistory(currentWindow); loadHistory(currentWindow);
@@ -1835,6 +1996,17 @@ window.addEventListener('resize', () => {
refresh(); refresh();
if (!collapsed.history) drawChart(currentHistory.points, currentHistory.serial); if (!collapsed.history) drawChart(currentHistory.points, currentHistory.serial);
}); });
const chartCanvas = document.getElementById('chart');
if (chartCanvas) {
chartCanvas.addEventListener('mousemove', event => {
drawChartCrosshair(event.clientX);
});
chartCanvas.addEventListener('mouseleave', () => {
clearChartCrosshair();
});
}
</script> </script>
</body> </body>
</html> </html>
@@ -1862,9 +2034,9 @@ class Handler(BaseHTTPRequestHandler):
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
query = parse_qs(urlparse(self.path).query) query = parse_qs(urlparse(self.path).query)
window = (query.get("window") or ["1d"])[0] window = (query.get("window") or ["today"])[0]
if window not in ("1d", "7d", "30d"): if window not in ("today", "yesterday", "week", "month"):
window = "1d" window = "today"
result = Handler.sampler.windowed_history(window) result = Handler.sampler.windowed_history(window)
self._send(200, json.dumps(result), "application/json") self._send(200, json.dumps(result), "application/json")
return return
+80 -7
View File
@@ -43,7 +43,7 @@ ACTIONS = {"target.on", "target.off", "none"}
DURATION_PATTERN = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smh]?)\s*$", re.IGNORECASE) DURATION_PATTERN = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smh]?)\s*$", re.IGNORECASE)
DEFAULT_NOTIFY_TEMPLATE = ( DEFAULT_NOTIFY_TEMPLATE = (
"{source_name} battery {battery_soc}%, solar {pv_total}W. " "{clock} {source_name} battery {battery_soc}%, solar {pv_total}W. "
"{target_name} turned {action}." "{target_name} turned {action}."
) )
DEFAULT_NOTIFY_THROTTLE = 300 DEFAULT_NOTIFY_THROTTLE = 300
@@ -181,6 +181,42 @@ class BatteryFloor:
) )
class AcInputWatchdog:
def __init__(self, raw):
if not isinstance(raw, dict):
raise ProfileError("safety.ac_input_watchdog must be a mapping")
self.enabled = bool(raw.get("enabled", False))
self.field = str(raw.get("field") or "ac_input_power")
try:
self.min_watts = float(raw.get("min_watts", 50))
except (TypeError, ValueError):
raise ProfileError(
"safety.ac_input_watchdog.min_watts must be a number"
) from None
self.dwell = parse_duration(
raw.get("for", 300), "safety.ac_input_watchdog.for"
)
self.notify_recovered = bool(raw.get("notify_recovered", True))
self.notify_template = raw.get("notify_template") or (
"{clock} {source_name}: {target_name} has been commanded ON but "
"{field} has stayed under {min_watts}W for {duration}. Check that "
"grid power is actually reaching the plug."
)
self.recovered_template = raw.get("recovered_template") or (
"{clock} {source_name}: {field} is back above {min_watts}W. "
"{target_name} is charging normally again."
)
def describe(self):
return (
f"{self.field} < {self.min_watts:g}W while ON for "
f"{format_duration(self.dwell)}"
)
class NotificationSettings: class NotificationSettings:
def __init__(self, raw): def __init__(self, raw):
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -197,14 +233,28 @@ class NotificationSettings:
self.stale_template = str( self.stale_template = str(
raw.get("stale_template") raw.get("stale_template")
or ( or (
"{source_name}: telemetry lost ({reason}). Last seen battery " "{clock} {source_name}: telemetry lost ({reason}). Last seen "
"{battery_soc}% at {last_seen}. {target_name} was turned {action} " "battery {battery_soc}% at {last_seen}. {target_name} was "
"as a precaution." "turned {action} as a precaution."
) )
) )
self.recovered_template = str( self.recovered_template = str(
raw.get("recovered_template") raw.get("recovered_template")
or "{source_name}: telemetry is back after {outage}. Rules resumed." or "{clock} {source_name}: telemetry is back after {outage}. Rules resumed."
)
self.switch_failed_template = str(
raw.get("switch_failed_template")
or (
"{clock} {source_name}: commanded {target_name} {attempted}, but "
"it still reports {actual}. Will keep retrying. ({reason})"
)
)
self.switch_unconfirmed_template = str(
raw.get("switch_unconfirmed_template")
or (
"{clock} {source_name}: sent {target_name} {attempted}, but could "
"not read back its state to confirm. ({reason})"
)
) )
self.title = str(raw.get("title") or "{profile}") self.title = str(raw.get("title") or "{profile}")
self.throttle = parse_duration( self.throttle = parse_duration(
@@ -367,6 +417,9 @@ class PowerProfile:
floor = safety.get("battery_floor") floor = safety.get("battery_floor")
self.battery_floor = BatteryFloor(floor) if floor else None self.battery_floor = BatteryFloor(floor) if floor else None
watchdog = safety.get("ac_input_watchdog")
self.ac_input_watchdog = AcInputWatchdog(watchdog) if watchdog else None
limits = raw.get("limits") or {} limits = raw.get("limits") or {}
self.min_gap = parse_duration( self.min_gap = parse_duration(
limits.get("min_seconds_between_actions", DEFAULT_MIN_GAP), limits.get("min_seconds_between_actions", DEFAULT_MIN_GAP),
@@ -492,6 +545,14 @@ def validate(profile):
"field on this device" "field on this device"
) )
if profile.ac_input_watchdog is not None:
if profile.ac_input_watchdog.field not in available:
problems.append(
f"safety.ac_input_watchdog.field "
f"{profile.ac_input_watchdog.field!r} is not a field on this "
"device"
)
on_rules = [r for r in profile.active_rules() if r.desired_state() is True] on_rules = [r for r in profile.active_rules() if r.desired_state() is True]
off_rules = [r for r in profile.active_rules() if r.desired_state() is False] off_rules = [r for r in profile.active_rules() if r.desired_state() is False]
if not on_rules: if not on_rules:
@@ -529,7 +590,17 @@ def validate(profile):
f"{sorted(configured)} are enabled in notifications.yaml" f"{sorted(configured)} are enabled in notifications.yaml"
) )
templates_to_check = [profile.notifications.template, profile.notifications.title] templates_to_check = [
profile.notifications.template,
profile.notifications.title,
profile.notifications.stale_template,
profile.notifications.recovered_template,
profile.notifications.switch_failed_template,
profile.notifications.switch_unconfirmed_template,
]
if profile.ac_input_watchdog is not None:
templates_to_check.append(profile.ac_input_watchdog.notify_template)
templates_to_check.append(profile.ac_input_watchdog.recovered_template)
for rule in profile.active_rules(): for rule in profile.active_rules():
if rule.notify_template: if rule.notify_template:
templates_to_check.append(rule.notify_template) templates_to_check.append(rule.notify_template)
@@ -537,7 +608,9 @@ def validate(profile):
context_names = { context_names = {
"profile", "rule", "action", "action_word", "source_name", "source_model", "profile", "rule", "action", "action_word", "source_name", "source_model",
"source_serial", "target_name", "target_model", "target_host", "source_serial", "target_name", "target_model", "target_host",
"target_channel", "condition", "time", "reason", "event", "target_channel", "condition", "time", "clock", "reason", "event",
"last_seen", "outage", "attempted", "actual", "field", "min_watts",
"duration",
} }
for template in templates_to_check: for template in templates_to_check:
for field in notify_module.template_fields(template): for field in notify_module.template_fields(template):
+32 -11
View File
@@ -2,6 +2,7 @@ import os
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
from . import paths from . import paths
@@ -73,13 +74,20 @@ def environment():
def launchd_plist(profile_name): def launchd_plist(profile_name):
label = service_label(profile_name) label = xml_escape(service_label(profile_name))
python = xml_escape(preferred_python())
script = script_path() script = script_path()
script_escaped = xml_escape(script)
working_dir = xml_escape(str(Path(script).parent))
profile_arg = xml_escape(profile_name)
log_dir = paths.LOG_DIR log_dir = paths.LOG_DIR
env_entries = "" env_entries = ""
for key, value in environment().items(): for key, value in environment().items():
env_entries += f" <key>{key}</key>\n <string>{value}</string>\n" env_entries += (
f" <key>{xml_escape(key)}</key>\n"
f" <string>{xml_escape(value)}</string>\n"
)
env_block = "" env_block = ""
if env_entries: if env_entries:
@@ -90,6 +98,9 @@ def launchd_plist(profile_name):
" </dict>\n" " </dict>\n"
) )
out_path = xml_escape(str(log_dir / f"{profile_name}.out.log"))
err_path = xml_escape(str(log_dir / f"{profile_name}.err.log"))
return f"""<?xml version="1.0" encoding="UTF-8"?> return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd"> "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -100,15 +111,15 @@ def launchd_plist(profile_name):
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>{preferred_python()}</string> <string>{python}</string>
<string>{script}</string> <string>{script_escaped}</string>
<string>run</string> <string>run</string>
<string>{profile_name}</string> <string>{profile_arg}</string>
<string>--quiet</string> <string>--quiet</string>
</array> </array>
<key>WorkingDirectory</key> <key>WorkingDirectory</key>
<string>{Path(script).parent}</string> <string>{working_dir}</string>
{env_block} <key>RunAtLoad</key> {env_block} <key>RunAtLoad</key>
<true/> <true/>
@@ -120,19 +131,29 @@ def launchd_plist(profile_name):
<integer>60</integer> <integer>60</integer>
<key>StandardOutPath</key> <key>StandardOutPath</key>
<string>{log_dir / f"{profile_name}.out.log"}</string> <string>{out_path}</string>
<key>StandardErrorPath</key> <key>StandardErrorPath</key>
<string>{log_dir / f"{profile_name}.err.log"}</string> <string>{err_path}</string>
</dict> </dict>
</plist> </plist>
""" """
def _systemd_quote(value):
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def systemd_unit(profile_name): def systemd_unit(profile_name):
script = script_path() script = script_path()
exec_start = " ".join(
_systemd_quote(token)
for token in (preferred_python(), script, "run", profile_name, "--quiet")
)
env_lines = "".join( env_lines = "".join(
f"Environment={key}={value}\n" for key, value in environment().items() f"Environment={_systemd_quote(f'{key}={value}')}\n"
for key, value in environment().items()
) )
return f"""[Unit] return f"""[Unit]
@@ -141,7 +162,7 @@ After=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart={preferred_python()} {script} run {profile_name} --quiet ExecStart={exec_start}
WorkingDirectory={Path(script).parent} WorkingDirectory={Path(script).parent}
{env_lines}Restart=always {env_lines}Restart=always
RestartSec=60 RestartSec=60
@@ -158,7 +179,7 @@ def windows_instructions(profile_name):
others. Use Task Scheduler: others. Use Task Scheduler:
schtasks /create /tn "{label}" /sc onlogon /rl highest ^ schtasks /create /tn "{label}" /sc onlogon /rl highest ^
/tr "\\"{preferred_python()}\\" \\"{script}\\" run {profile_name} --quiet" /tr "\\"{preferred_python()}\\" \\"{script}\\" run \\"{profile_name}\\" --quiet"
To remove it: To remove it: