Compare commits
10
Commits
f18b9fab72
...
ed49dbf429
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed49dbf429 | ||
|
|
3007c1caa1 | ||
|
|
f05ebceefc | ||
|
|
c4e9de0f03 | ||
|
|
56eb1547f8 | ||
|
|
c0b161a932 | ||
|
|
e6b3e34509 | ||
|
|
c9451eeeb8 | ||
|
|
3f98e41063 | ||
|
|
d05b097cbd |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 108 KiB |
+126
-20
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user