Compare commits

...
10 Commits
5 changed files with 378 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

+126 -20
View File
@@ -166,6 +166,12 @@ def stamp():
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:
def __init__(self, log_path=None, quiet=False):
self.quiet = quiet
@@ -313,6 +319,49 @@ class Engine:
self.report(f"FAILED to turn {self._word(desired)}: {type(err).__name__}: {err}")
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_action_at = now
self.recent_actions.append(now)
@@ -322,6 +371,32 @@ class Engine:
await self.notify(desired, rule, variables, event="action")
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
def _word(desired):
return "ON" if desired else "OFF"
@@ -355,6 +430,7 @@ class Engine:
"target_host": self.target.host,
"target_channel": self.target.channel,
"time": stamp(),
"clock": clock_stamp(),
}
)
return context
@@ -467,6 +543,13 @@ class Engine:
self.stale_action_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:
return
@@ -810,16 +893,27 @@ class Engine:
if self.profile.on_stale == "stop":
raise RuntimeError("stopping: MQTT session disconnected")
if self.profile.on_stale == "safe_state":
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 self.floor_latched:
floor_state = self.profile.battery_floor.desired_state()
await self.apply(
session,
floor_state,
"battery floor holding through a lost connection",
now,
force=True,
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:
self.stale_notified = True
@@ -880,16 +974,27 @@ class Engine:
if self.profile.on_stale == "stop":
raise RuntimeError("stopping: telemetry went stale")
if self.profile.on_stale == "safe_state":
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",
)
if self.floor_latched:
floor_state = self.profile.battery_floor.desired_state()
await self.apply(
session,
floor_state,
"battery floor holding through stale telemetry",
now,
force=True,
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
if self.stale_reported:
@@ -1208,6 +1313,7 @@ def _preview_notification(profile, rule, variables, desired):
"target_host": target_profile.get("access", {}).get("host", ""),
"target_channel": profile.target_channel or 0,
"time": stamp(),
"clock": clock_stamp(),
"event": "action",
}
)
+191 -19
View File
@@ -264,16 +264,35 @@ class Sampler:
def windowed_history(self, window):
from . import tune
window_seconds = {"1d": 86400, "7d": 7 * 86400, "30d": 30 * 86400}.get(
window, 86400
now = time.time()
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()
if not serial:
return {"points": [], "grid": None, "serial": None, "name": None}
since_epoch = time.time() - window_seconds
records = tune.load_telemetry(serial, since_epoch=since_epoch)
records = [r for r in records if r[0] <= until_epoch]
max_points = 600
step = max(1, len(records) // max_points)
@@ -408,9 +427,8 @@ PAGE = """<!doctype html>
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding-bottom: 14px;
border-bottom: 2px solid var(--ink);
margin-bottom: 26px;
padding-bottom: 6px;
margin-bottom: 10px;
flex-wrap: wrap;
}
@@ -795,6 +813,37 @@ PAGE = """<!doctype html>
.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; }
th {
@@ -991,6 +1040,8 @@ PAGE = """<!doctype html>
.event-cause.floor { color: var(--alert); 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 {
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="clock24Btn" aria-pressed="false">24H</button>
</span>
<button type="button" id="fitBtn" class="fit-btn" aria-pressed="false"
title="Fit the whole page to your window, no scrolling">⛶</button>
</div>
</header>
@@ -1090,9 +1143,10 @@ PAGE = """<!doctype html>
<span><i class="swatch sw-batt"></i>battery</span>
</div>
<span class="theme" role="group" aria-label="Time window">
<button type="button" id="window1d" aria-pressed="true">1 DAY</button>
<button type="button" id="window7d" aria-pressed="false">7 DAYS</button>
<button type="button" id="window30d" aria-pressed="false">30 DAYS</button>
<button type="button" id="windowtoday" aria-pressed="true">TODAY</button>
<button type="button" id="windowyesterday" aria-pressed="false">YESTERDAY</button>
<button type="button" id="windowweek" aria-pressed="false">WEEK</button>
<button type="button" id="windowmonth" aria-pressed="false">MONTH</button>
</span>
</div>
<canvas id="chart"></canvas>
@@ -1477,7 +1531,9 @@ const CAUSE_LABEL = {
floor: 'safety floor',
stale: 'telemetry lost',
recovered: 'recovered',
manual: 'manual'
manual: 'manual',
switch_failed: 'switch did not take effect',
unverified: 'switch unverified'
};
function dayLabel(epoch) {
@@ -1490,6 +1546,11 @@ function dayLabel(epoch) {
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;
function clockLabel(epoch) {
@@ -1557,6 +1618,8 @@ function renderEvents(events, multi) {
box.innerHTML = html;
}
let chartGeom = null;
function drawChart(history, serial) {
const canvas = document.getElementById('chart');
const ratio = window.devicePixelRatio || 1;
@@ -1577,6 +1640,7 @@ function drawChart(history, serial) {
ctx.fillStyle = css('--ink-soft');
if (!serial || history.length < 2) {
chartGeom = null;
ctx.fillText('Waiting for readings. The chart fills in as samples arrive.', pad.l, height / 2);
return;
}
@@ -1603,6 +1667,8 @@ function drawChart(history, serial) {
const yW = v => pad.t + plotH - (v / maxW) * plotH;
const yP = v => pad.t + plotH - (v / 100) * plotH;
chartGeom = { pad, plotW, plotH, t0, span, width, height };
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const yy = pad.t + plotH * i / 4;
@@ -1617,9 +1683,12 @@ function drawChart(history, serial) {
}
ctx.textAlign = 'center';
const spansMultipleDaysAxis = currentWindow === 'week' || currentWindow === 'month';
[0, 0.5, 1].forEach(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);
});
@@ -1642,10 +1711,62 @@ function drawChart(history, serial) {
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 paused = false;
let currentHistory = { points: [], grid: null, serial: null, name: null };
let currentWindow = '1d';
let currentWindow = 'today';
const PANEL_KEY = 'solixauto-collapsed';
@@ -1673,6 +1794,7 @@ function togglePanel(key) {
if (!collapsed[key] && key === 'history') {
drawChart(currentHistory.points, currentHistory.serial);
}
refitIfEnabled();
}
function setCount(key, text) {
@@ -1695,6 +1817,34 @@ function applyTheme(mode) {
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) {
use12Hour = is12Hour;
document.getElementById('clock12Btn').setAttribute('aria-pressed', is12Hour);
@@ -1704,6 +1854,7 @@ function applyClockFormat(is12Hour) {
renderEvents(latest.events, latest.multi);
}
if (!collapsed.history) drawChart(currentHistory.points, currentHistory.serial);
refitIfEnabled();
}
const stored = (() => {
@@ -1719,10 +1870,17 @@ const storedClock12 = (() => {
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('darkBtn').addEventListener('click', () => applyTheme('dark'));
document.getElementById('clock12Btn').addEventListener('click', () => applyClockFormat(true));
document.getElementById('clock24Btn').addEventListener('click', () => applyClockFormat(false));
document.getElementById('fitBtn').addEventListener('click', () => applyFitToScreen(!fitToScreen));
let failures = 0;
@@ -1751,6 +1909,7 @@ async function refresh() {
? data.shelly.length + ', ' + on + ' on' : 'none');
setCount('actions', (data.events || []).length
? (data.events || []).length + ' logged' : 'none yet');
refitIfEnabled();
} catch (err) {
failures++;
document.getElementById('dot').className = 'dot stale';
@@ -1802,7 +1961,7 @@ function renderGridUsage(grid) {
async function loadHistory(win) {
currentWindow = win;
['1d', '7d', '30d'].forEach(w => {
['today', 'yesterday', 'week', 'month'].forEach(w => {
const button = document.getElementById('window' + w);
if (button) button.setAttribute('aria-pressed', w === win ? 'true' : 'false');
});
@@ -1820,11 +1979,13 @@ async function loadHistory(win) {
drawChart(currentHistory.points, currentHistory.serial);
}
renderGridUsage(currentHistory.grid);
refitIfEnabled();
}
document.getElementById('window1d').addEventListener('click', () => loadHistory('1d'));
document.getElementById('window7d').addEventListener('click', () => loadHistory('7d'));
document.getElementById('window30d').addEventListener('click', () => loadHistory('30d'));
document.getElementById('windowtoday').addEventListener('click', () => loadHistory('today'));
document.getElementById('windowyesterday').addEventListener('click', () => loadHistory('yesterday'));
document.getElementById('windowweek').addEventListener('click', () => loadHistory('week'));
document.getElementById('windowmonth').addEventListener('click', () => loadHistory('month'));
refresh();
loadHistory(currentWindow);
@@ -1835,6 +1996,17 @@ window.addEventListener('resize', () => {
refresh();
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>
</body>
</html>
@@ -1862,9 +2034,9 @@ class Handler(BaseHTTPRequestHandler):
from urllib.parse import urlparse, parse_qs
query = parse_qs(urlparse(self.path).query)
window = (query.get("window") or ["1d"])[0]
if window not in ("1d", "7d", "30d"):
window = "1d"
window = (query.get("window") or ["today"])[0]
if window not in ("today", "yesterday", "week", "month"):
window = "today"
result = Handler.sampler.windowed_history(window)
self._send(200, json.dumps(result), "application/json")
return
+29 -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)
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}."
)
DEFAULT_NOTIFY_THROTTLE = 300
@@ -197,14 +197,28 @@ class NotificationSettings:
self.stale_template = str(
raw.get("stale_template")
or (
"{source_name}: telemetry lost ({reason}). Last seen battery "
"{battery_soc}% at {last_seen}. {target_name} was turned {action} "
"as a precaution."
"{clock} {source_name}: telemetry lost ({reason}). Last seen "
"battery {battery_soc}% at {last_seen}. {target_name} was "
"turned {action} as a precaution."
)
)
self.recovered_template = str(
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.throttle = parse_duration(
@@ -529,7 +543,14 @@ def validate(profile):
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,
]
for rule in profile.active_rules():
if rule.notify_template:
templates_to_check.append(rule.notify_template)
@@ -537,7 +558,8 @@ def validate(profile):
context_names = {
"profile", "rule", "action", "action_word", "source_name", "source_model",
"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",
}
for template in templates_to_check:
for field in notify_module.template_fields(template):
+32 -11
View File
@@ -2,6 +2,7 @@ import os
import subprocess
import sys
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
from . import paths
@@ -73,13 +74,20 @@ def environment():
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_escaped = xml_escape(script)
working_dir = xml_escape(str(Path(script).parent))
profile_arg = xml_escape(profile_name)
log_dir = paths.LOG_DIR
env_entries = ""
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 = ""
if env_entries:
@@ -90,6 +98,9 @@ def launchd_plist(profile_name):
" </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"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -100,15 +111,15 @@ def launchd_plist(profile_name):
<key>ProgramArguments</key>
<array>
<string>{preferred_python()}</string>
<string>{script}</string>
<string>{python}</string>
<string>{script_escaped}</string>
<string>run</string>
<string>{profile_name}</string>
<string>{profile_arg}</string>
<string>--quiet</string>
</array>
<key>WorkingDirectory</key>
<string>{Path(script).parent}</string>
<string>{working_dir}</string>
{env_block} <key>RunAtLoad</key>
<true/>
@@ -120,19 +131,29 @@ def launchd_plist(profile_name):
<integer>60</integer>
<key>StandardOutPath</key>
<string>{log_dir / f"{profile_name}.out.log"}</string>
<string>{out_path}</string>
<key>StandardErrorPath</key>
<string>{log_dir / f"{profile_name}.err.log"}</string>
<string>{err_path}</string>
</dict>
</plist>
"""
def _systemd_quote(value):
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def systemd_unit(profile_name):
script = script_path()
exec_start = " ".join(
_systemd_quote(token)
for token in (preferred_python(), script, "run", profile_name, "--quiet")
)
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]
@@ -141,7 +162,7 @@ After=network-online.target
[Service]
Type=simple
ExecStart={preferred_python()} {script} run {profile_name} --quiet
ExecStart={exec_start}
WorkingDirectory={Path(script).parent}
{env_lines}Restart=always
RestartSec=60
@@ -158,7 +179,7 @@ def windows_instructions(profile_name):
others. Use Task Scheduler:
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: