Added visual rule marker with general explanation to battery-bar.
This commit is contained in:
1 parent
74dbb8a63b
commit
a744206703
2 files changed
+369
-1
No files matched your search
@@ -17,6 +17,134 @@ from .shelly import ShellyTarget
|
|||||||
EVENT_HISTORY = 200
|
EVENT_HISTORY = 200
|
||||||
|
|
||||||
|
|
||||||
|
FIELD_WORDS = {
|
||||||
|
"pv_surplus": "solar surplus",
|
||||||
|
"pv_total": "solar output",
|
||||||
|
"output_power_total": "the load",
|
||||||
|
"ac_input_power": "grid input",
|
||||||
|
"temperature": "temperature",
|
||||||
|
"usb_total": "USB output",
|
||||||
|
"soc_headroom": "room above the floor",
|
||||||
|
}
|
||||||
|
|
||||||
|
FIELD_UNITS = {
|
||||||
|
"pv_surplus": "W",
|
||||||
|
"pv_total": "W",
|
||||||
|
"output_power_total": "W",
|
||||||
|
"ac_input_power": "W",
|
||||||
|
"usb_total": "W",
|
||||||
|
"temperature": " degrees",
|
||||||
|
"soc_headroom": " points",
|
||||||
|
}
|
||||||
|
|
||||||
|
COMPARISON_WORDS = {
|
||||||
|
"Lt": "below",
|
||||||
|
"LtE": "at or below",
|
||||||
|
"Gt": "above",
|
||||||
|
"GtE": "at or above",
|
||||||
|
"Eq": "exactly",
|
||||||
|
"NotEq": "not",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def describe_clause(node):
|
||||||
|
import ast as _ast
|
||||||
|
|
||||||
|
if not isinstance(node.left, _ast.Name):
|
||||||
|
return None
|
||||||
|
field = node.left.id
|
||||||
|
if field == "battery_soc" or not node.ops or len(node.comparators) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
comparator = node.comparators[0]
|
||||||
|
if not isinstance(comparator, _ast.Constant):
|
||||||
|
return None
|
||||||
|
if not isinstance(comparator.value, (int, float)) or isinstance(
|
||||||
|
comparator.value, bool
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
words = FIELD_WORDS.get(field, field)
|
||||||
|
direction = COMPARISON_WORDS.get(type(node.ops[0]).__name__, "at")
|
||||||
|
unit = FIELD_UNITS.get(field, "")
|
||||||
|
return f"{words} is {direction} {comparator.value:g}{unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def explain_rule(rule, at, tree):
|
||||||
|
import ast as _ast
|
||||||
|
|
||||||
|
state = rule.desired_state()
|
||||||
|
action = "Turns the switch on" if state else "Turns the switch off"
|
||||||
|
|
||||||
|
direction = "at"
|
||||||
|
extras = []
|
||||||
|
|
||||||
|
for node in _ast.walk(tree):
|
||||||
|
if not isinstance(node, _ast.Compare):
|
||||||
|
continue
|
||||||
|
if isinstance(node.left, _ast.Name) and node.left.id == "battery_soc":
|
||||||
|
if node.ops:
|
||||||
|
direction = COMPARISON_WORDS.get(
|
||||||
|
type(node.ops[0]).__name__, "at"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
clause = describe_clause(node)
|
||||||
|
if clause:
|
||||||
|
extras.append(clause)
|
||||||
|
|
||||||
|
sentence = f"{action} when the battery is {direction} {at:g}%"
|
||||||
|
if extras:
|
||||||
|
sentence += ", and " + " and ".join(extras)
|
||||||
|
sentence += f". Waits {format_duration(rule.dwell)} first."
|
||||||
|
return sentence
|
||||||
|
|
||||||
|
|
||||||
|
def battery_thresholds(rule):
|
||||||
|
import ast as _ast
|
||||||
|
|
||||||
|
points = []
|
||||||
|
try:
|
||||||
|
tree = _ast.parse(rule.when_source, mode="eval")
|
||||||
|
except SyntaxError:
|
||||||
|
return points
|
||||||
|
|
||||||
|
other_fields = {
|
||||||
|
node.id
|
||||||
|
for node in _ast.walk(tree)
|
||||||
|
if isinstance(node, _ast.Name) and node.id != "battery_soc"
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in _ast.walk(tree):
|
||||||
|
if not isinstance(node, _ast.Compare):
|
||||||
|
continue
|
||||||
|
if not isinstance(node.left, _ast.Name) or node.left.id != "battery_soc":
|
||||||
|
continue
|
||||||
|
if len(node.comparators) != 1:
|
||||||
|
continue
|
||||||
|
comparator = node.comparators[0]
|
||||||
|
if not isinstance(comparator, _ast.Constant):
|
||||||
|
continue
|
||||||
|
if not isinstance(comparator.value, (int, float)):
|
||||||
|
continue
|
||||||
|
if isinstance(comparator.value, bool):
|
||||||
|
continue
|
||||||
|
|
||||||
|
state = rule.desired_state()
|
||||||
|
value = float(comparator.value)
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"at": value,
|
||||||
|
"label": rule.name,
|
||||||
|
"kind": "on" if state else ("off" if state is False else "rule"),
|
||||||
|
"condition": rule.when_source,
|
||||||
|
"explain": explain_rule(rule, value, tree),
|
||||||
|
"compound": bool(other_fields),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -516,10 +644,48 @@ class Engine:
|
|||||||
identity = self.anker_profile.get("identity", {})
|
identity = self.anker_profile.get("identity", {})
|
||||||
serial = identity.get("serial") or "unknown"
|
serial = identity.get("serial") or "unknown"
|
||||||
|
|
||||||
|
thresholds = []
|
||||||
|
floor = self.profile.battery_floor
|
||||||
|
if floor is not None and floor.enabled and floor.field == "battery_soc":
|
||||||
|
thresholds.append(
|
||||||
|
{
|
||||||
|
"at": floor.threshold,
|
||||||
|
"label": "safety floor",
|
||||||
|
"kind": "floor",
|
||||||
|
"condition": f"battery_soc <= {floor.threshold:g}",
|
||||||
|
"explain": (
|
||||||
|
f"Emergency. Turns the switch "
|
||||||
|
f"{'on' if floor.desired_state() else 'off'} when the "
|
||||||
|
f"battery reaches {floor.threshold:g}% and holds it there "
|
||||||
|
f"until {floor.release:g}%. Ignores the rate limits and "
|
||||||
|
"overrides every rule."
|
||||||
|
),
|
||||||
|
"compound": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
thresholds.append(
|
||||||
|
{
|
||||||
|
"at": floor.release,
|
||||||
|
"label": "safety floor releases",
|
||||||
|
"kind": "release",
|
||||||
|
"condition": f"battery_soc >= {floor.release:g}",
|
||||||
|
"explain": (
|
||||||
|
f"The safety floor stops overriding the rules once the "
|
||||||
|
f"battery reaches {floor.release:g}%."
|
||||||
|
),
|
||||||
|
"compound": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for rule in self.profile.active_rules():
|
||||||
|
for point in battery_thresholds(rule):
|
||||||
|
thresholds.append(point)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
"name": identity.get("name") or identity.get("model") or serial,
|
"name": identity.get("name") or identity.get("model") or serial,
|
||||||
"model": identity.get("model") or identity.get("part_number") or "",
|
"model": identity.get("model") or identity.get("part_number") or "",
|
||||||
|
"thresholds": thresholds,
|
||||||
"updated": stamp(),
|
"updated": stamp(),
|
||||||
"epoch": time.time(),
|
"epoch": time.time(),
|
||||||
"age_seconds": round(age) if age is not None else None,
|
"age_seconds": round(age) if age is not None else None,
|
||||||
|
|||||||
+203
-1
@@ -142,6 +142,7 @@ class Sampler:
|
|||||||
"updated": (live or {}).get("updated"),
|
"updated": (live or {}).get("updated"),
|
||||||
"age": (live or {}).get("age_seconds"),
|
"age": (live or {}).get("age_seconds"),
|
||||||
"profile": (live or {}).get("profile"),
|
"profile": (live or {}).get("profile"),
|
||||||
|
"thresholds": (live or {}).get("thresholds") or [],
|
||||||
"floor_latched": (live or {}).get("floor_latched", False),
|
"floor_latched": (live or {}).get("floor_latched", False),
|
||||||
"battery_soc": values.get("battery_soc"),
|
"battery_soc": values.get("battery_soc"),
|
||||||
"output_watts": values.get("output_power_total"),
|
"output_watts": values.get("output_power_total"),
|
||||||
@@ -573,6 +574,98 @@ PAGE = """<!doctype html>
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.battery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr 62px;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
gap: 0 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-track {
|
||||||
|
position: relative;
|
||||||
|
height: 15px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 5px, var(--etch) 5px 6px);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--charge);
|
||||||
|
transition: width .5s ease, background-color .3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-fill.low { background: var(--solar); }
|
||||||
|
.battery-fill.critical { background: var(--alert); }
|
||||||
|
|
||||||
|
.battery-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--ink-soft);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-mark.floor { background: var(--alert); opacity: 0.9; width: 2px; }
|
||||||
|
|
||||||
|
.battery-words {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-values {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 3;
|
||||||
|
position: relative;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery > .bus-label { grid-column: 1; grid-row: 2; }
|
||||||
|
.battery > .battery-track { grid-column: 2; grid-row: 2; }
|
||||||
|
.battery > .bus-value { grid-column: 3; grid-row: 2; }
|
||||||
|
|
||||||
|
.battery-tag {
|
||||||
|
position: absolute;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
white-space: nowrap;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-words .battery-tag { top: 0; }
|
||||||
|
.battery-values .battery-tag { top: 6px; }
|
||||||
|
|
||||||
|
.battery-tag.floor { color: var(--alert); }
|
||||||
|
.battery-tag { cursor: help; }
|
||||||
|
.battery-tag:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.battery-leader {
|
||||||
|
position: absolute;
|
||||||
|
border-left: 1px solid var(--rule);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-words .battery-leader {
|
||||||
|
bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-values .battery-leader { top: 0; }
|
||||||
|
|
||||||
|
.battery-leader.flat { border-top: none; border-bottom: none; }
|
||||||
.swatch { display: inline-block; width: 9px; height: 9px; margin-right: 5px; }
|
.swatch { display: inline-block; width: 9px; height: 9px; margin-right: 5px; }
|
||||||
.sw-solar { background: var(--solar); }
|
.sw-solar { background: var(--solar); }
|
||||||
.sw-grid { background: var(--grid); }
|
.sw-grid { background: var(--grid); }
|
||||||
@@ -873,6 +966,110 @@ function busBar(pv, ac, out) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tagTitle(t) {
|
||||||
|
const parts = [];
|
||||||
|
if (t.label) parts.push(t.label);
|
||||||
|
if (t.condition) parts.push(t.condition);
|
||||||
|
if (t.explain) parts.push(t.explain);
|
||||||
|
return parts.join('\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeTags(points, words, px) {
|
||||||
|
const charPx = 5.4;
|
||||||
|
let cursor = -Infinity;
|
||||||
|
|
||||||
|
return points.map((t, index) => {
|
||||||
|
const word = words[index];
|
||||||
|
const halfPx = (word.length * charPx) / 2;
|
||||||
|
const wantPx = (t.at / 100) * px;
|
||||||
|
let leftPx = Math.max(wantPx, cursor + halfPx + 6);
|
||||||
|
leftPx = Math.min(leftPx, px - halfPx);
|
||||||
|
cursor = leftPx + halfPx;
|
||||||
|
|
||||||
|
const shifted = Math.abs(leftPx - wantPx) > 1.5;
|
||||||
|
const leaderLeft = Math.min(wantPx, leftPx);
|
||||||
|
const leaderWidth = Math.abs(leftPx - wantPx);
|
||||||
|
|
||||||
|
return '<span class="battery-leader' + (shifted ? '' : ' flat')
|
||||||
|
+ '" style="left:' + leaderLeft + 'px;width:'
|
||||||
|
+ (shifted ? leaderWidth : 0) + 'px"></span>'
|
||||||
|
+ '<span class="battery-tag ' + (t.kind === 'floor' ? 'floor' : '')
|
||||||
|
+ '" style="left:' + leftPx + 'px" title="' + esc(tagTitle(t)) + '">'
|
||||||
|
+ esc(word) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryBar(soc, thresholds) {
|
||||||
|
if (soc === null || soc === undefined) return '';
|
||||||
|
|
||||||
|
const RANK = { floor: 0, on: 1, off: 2, release: 3, rule: 4 };
|
||||||
|
|
||||||
|
const byValue = {};
|
||||||
|
(thresholds || [])
|
||||||
|
.filter(t => typeof t.at === 'number' && t.at >= 0 && t.at <= 100)
|
||||||
|
.forEach(t => {
|
||||||
|
const key = Math.round(t.at);
|
||||||
|
const existing = byValue[key];
|
||||||
|
if (!existing || (RANK[t.kind] ?? 9) < (RANK[existing.kind] ?? 9)) {
|
||||||
|
byValue[key] = t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const points = Object.values(byValue).sort((a, b) => a.at - b.at);
|
||||||
|
|
||||||
|
const marks = points.map(t =>
|
||||||
|
'<span class="battery-mark ' + (t.kind === 'floor' ? 'floor' : '')
|
||||||
|
+ '" style="left:' + t.at + '%" title="'
|
||||||
|
+ esc(tagTitle(t)) + '"></span>').join('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const floor = points.find(t => t.kind === 'floor');
|
||||||
|
const onPoint = points.find(t => t.kind === 'on');
|
||||||
|
let cls = '';
|
||||||
|
if (floor && soc <= floor.at) cls = 'critical';
|
||||||
|
else if (onPoint && soc <= onPoint.at) cls = 'low';
|
||||||
|
|
||||||
|
const payload = encodeURIComponent(JSON.stringify(points));
|
||||||
|
|
||||||
|
return '<div class="battery" data-points="' + payload + '">'
|
||||||
|
+ '<div class="battery-words"></div>'
|
||||||
|
+ '<div class="bus-label">battery</div>'
|
||||||
|
+ '<div class="battery-track">'
|
||||||
|
+ '<i class="battery-fill ' + cls + '" style="width:' + soc + '%"></i>'
|
||||||
|
+ marks
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="bus-value">' + Math.round(soc) + ' %</div>'
|
||||||
|
+ '<div class="battery-values"></div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutBatteryTags() {
|
||||||
|
document.querySelectorAll('.battery').forEach(box => {
|
||||||
|
let points;
|
||||||
|
try {
|
||||||
|
points = JSON.parse(decodeURIComponent(box.dataset.points || '[]'));
|
||||||
|
} catch (e) { return; }
|
||||||
|
if (!points.length) return;
|
||||||
|
|
||||||
|
const track = box.querySelector('.battery-track');
|
||||||
|
const px = track ? track.clientWidth : 0;
|
||||||
|
if (!px) return;
|
||||||
|
|
||||||
|
const words = box.querySelector('.battery-words');
|
||||||
|
const values = box.querySelector('.battery-values');
|
||||||
|
if (words) {
|
||||||
|
words.innerHTML = placeTags(
|
||||||
|
points, points.map(t => t.kind === 'floor' ? 'floor' : 'rule'), px);
|
||||||
|
}
|
||||||
|
if (values) {
|
||||||
|
values.innerHTML = placeTags(
|
||||||
|
points, points.map(t => Math.round(t.at) + '%'), px);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let canEdit = false;
|
let canEdit = false;
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
@@ -1020,6 +1217,7 @@ function renderAnker(devices) {
|
|||||||
+ '<div class="readout"><div class="label">Surplus</div><div class="value">' + fmt(d.pv_surplus, 'W') + '</div></div>'
|
+ '<div class="readout"><div class="label">Surplus</div><div class="value">' + fmt(d.pv_surplus, 'W') + '</div></div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ busBar(d.pv_watts, d.ac_in_watts, d.output_watts)
|
+ busBar(d.pv_watts, d.ac_in_watts, d.output_watts)
|
||||||
|
+ batteryBar(d.battery_soc, d.thresholds)
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -1281,6 +1479,7 @@ async function refresh() {
|
|||||||
document.getElementById('stamp').textContent = data.updated || '--:--:--';
|
document.getElementById('stamp').textContent = data.updated || '--:--:--';
|
||||||
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
|
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
|
||||||
renderAnker(data.anker);
|
renderAnker(data.anker);
|
||||||
|
layoutBatteryTags();
|
||||||
renderShelly(data.shelly);
|
renderShelly(data.shelly);
|
||||||
renderEvents(data.events, data.multi);
|
renderEvents(data.events, data.multi);
|
||||||
|
|
||||||
@@ -1308,7 +1507,10 @@ async function refresh() {
|
|||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 5000);
|
setInterval(refresh, 5000);
|
||||||
window.addEventListener('resize', () => refresh());
|
window.addEventListener('resize', () => {
|
||||||
|
layoutBatteryTags();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in new issue
Block a user