diff --git a/solixauto/engine.py b/solixauto/engine.py index 2f1d2fa..ef28bda 100644 --- a/solixauto/engine.py +++ b/solixauto/engine.py @@ -17,6 +17,134 @@ from .shelly import ShellyTarget 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): remaining = float(seconds or 0) while remaining > 0: @@ -516,10 +644,48 @@ class Engine: identity = self.anker_profile.get("identity", {}) 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 = { "serial": serial, "name": identity.get("name") or identity.get("model") or serial, "model": identity.get("model") or identity.get("part_number") or "", + "thresholds": thresholds, "updated": stamp(), "epoch": time.time(), "age_seconds": round(age) if age is not None else None, diff --git a/solixauto/monitor.py b/solixauto/monitor.py index b2ae975..8b25824 100644 --- a/solixauto/monitor.py +++ b/solixauto/monitor.py @@ -142,6 +142,7 @@ class Sampler: "updated": (live or {}).get("updated"), "age": (live or {}).get("age_seconds"), "profile": (live or {}).get("profile"), + "thresholds": (live or {}).get("thresholds") or [], "floor_latched": (live or {}).get("floor_latched", False), "battery_soc": values.get("battery_soc"), "output_watts": values.get("output_power_total"), @@ -573,6 +574,98 @@ PAGE = """ color: var(--ink-soft); 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; } .sw-solar { background: var(--solar); } .sw-grid { background: var(--grid); } @@ -873,6 +966,110 @@ function busBar(pv, ac, out) { + ''; } +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 '' + + '' + + esc(word) + ''; + }).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 => + '').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 '
' + + '
' + + '
battery
' + + '
' + + '' + + marks + + '
' + + '
' + Math.round(soc) + ' %
' + + '
' + + '
'; +} + +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; function esc(s) { @@ -1020,6 +1217,7 @@ function renderAnker(devices) { + '
Surplus
' + fmt(d.pv_surplus, 'W') + '
' + '' + busBar(d.pv_watts, d.ac_in_watts, d.output_watts) + + batteryBar(d.battery_soc, d.thresholds) + ''; }).join(''); } @@ -1281,6 +1479,7 @@ async function refresh() { document.getElementById('stamp').textContent = data.updated || '--:--:--'; document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale'; renderAnker(data.anker); + layoutBatteryTags(); renderShelly(data.shelly); renderEvents(data.events, data.multi); @@ -1308,7 +1507,10 @@ async function refresh() { refresh(); setInterval(refresh, 5000); -window.addEventListener('resize', () => refresh()); +window.addEventListener('resize', () => { + layoutBatteryTags(); + refresh(); +});