From 06dd47a4bd0561e7b0945d9cb17e5699ff664ddd Mon Sep 17 00:00:00 2001 From: cm-makes Date: Wed, 15 Jul 2026 21:15:03 +0200 Subject: [PATCH] feat: Advisor 'Current Decision' sensor + dashboard banner (v1.14.0) Decision transparency ported from HELIOS, rebuilt for AURUM's per-device model. sensor.aurum_current_decision (ENUM, translation_key, EN/DE) answers "what is AURUM doing right now, and why"; attributes carry a structured per-device reason list for dashboards. The sidebar panel renders a decision banner (headline via hass.formatEntityState) and translated per-device reason lines. - AdvisorManager: pure function of shared state, invoked from the update loop's finally block (single exit, covers startup grace) - No fast-changing numbers in attributes (no watts/timestamps) so idle periods generate no recorder writes - devices.py publishes authoritative off-reasons (disabled, condition_met); scheduling reasons are shared SCHED_REASON_* constants in const.py so producer and consumers can't drift - current_decision added to reserved device-name slugs - 17 unit tests; full suite green (189) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QLgEATa2NRuvft5vU4D5N3 --- CHANGELOG.md | 7 + custom_components/aurum/config_flow.py | 1 + custom_components/aurum/const.py | 11 +- custom_components/aurum/coordinator.py | 29 ++- .../aurum/frontend/aurum-panel.js | 172 ++++++++++++- custom_components/aurum/manifest.json | 2 +- custom_components/aurum/modules/advisor.py | 185 ++++++++++++++ custom_components/aurum/modules/devices.py | 27 +- custom_components/aurum/sensor.py | 34 +++ custom_components/aurum/strings.json | 16 ++ custom_components/aurum/translations/de.json | 16 ++ custom_components/aurum/translations/en.json | 16 ++ tests/test_advisor.py | 238 ++++++++++++++++++ 13 files changed, 738 insertions(+), 16 deletions(-) create mode 100644 custom_components/aurum/modules/advisor.py create mode 100644 tests/test_advisor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2af69..5d882ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.0] - 2026-07-15 + +### Added +- **Advisor: "Current Decision" sensor (`sensor.aurum_current_decision`)** – Decision transparency ported from the HELIOS parent project and rebuilt for AURUM's per-device model. A single ENUM sensor answers *"what is AURUM doing right now, and why"*. The state is a stable, localised decision code (`running_solar`, `running_cheap_grid`, `running`, `waiting`, `battery_charging`, `startup`, `idle`); the attributes carry a structured breakdown for dashboard cards: battery mode, price context, device counts, and a per-device list with a machine-readable `reason` code each (`solar_surplus`, `solar_pv`, `cheap_grid`, `manual_override`, `forced_deadline`, `runtime_done`, `program_done`, `program_paused`, `program_standby`, `battery_charging`, `below_soc_threshold`, `condition_not_met`, `disabled`, `waiting_surplus`). The module is a pure function of the coordinator's shared state — no extra HA polling, no side effects on control — and runs from the update loop's `finally` block so every return path (including the startup grace period) publishes a decision. The attributes deliberately carry no fast-changing numbers (no watts, no timestamps), so idle periods don't generate recorder writes; live power values stay in the dedicated numeric sensors. Always active (no configuration). Decision-state strings localised EN/DE via `translation_key` + `SensorDeviceClass.ENUM`. `current_decision` added to the reserved device-name list. Unit-tested. +- **Dashboard panel: Advisor banner** – The AURUM sidebar panel now shows a decision banner at the top ("☀️ Running on solar surplus · 2/3 active · ⚡ 1240 W · 💶 18.4 ct/kWh"). The headline uses HA's backend-localised ENUM state via `hass.formatEntityState()` (every HA language) with an EN/DE fallback for older frontends; the surplus figure comes live from `sensor.aurum_excess_power`. The per-device cards now show the advisor's translated reason ("→ solar surplus", "→ program paused", "→ disabled (force-off)") instead of the raw `scheduling_reason` code, falling back to the raw code on installs that predate the advisor. +- **`device_states` now publishes `disabled` and `condition_met`** – DeviceManager exposes its authoritative off-reasons (force-off switch, per-device run condition) in the published device state, so the advisor and external automations don't have to re-derive them. Scheduling-reason strings (`surplus_available`, `excess_sufficient`, `cheap_grid`, `solar_pv`) are now shared `SCHED_REASON_*` constants in `const.py` — producer (devices.py) and consumers (advisor, cheap-grid flag) can no longer drift. + ## [1.13.0] - 2026-07-10 ### Added diff --git a/custom_components/aurum/config_flow.py b/custom_components/aurum/config_flow.py index 9b2825f..1ffb5fa 100644 --- a/custom_components/aurum/config_flow.py +++ b/custom_components/aurum/config_flow.py @@ -19,6 +19,7 @@ "battery_discharge", "battery_mode", "excess_power", "budget", "house_consumption", "forecast_remaining", "energy_today", "cycle", "safety_factor", "electricity_price", "cheap_grid_active", + "current_decision", # prefixes whose per-device suffixes collide with hub ids: "pv", "grid", "battery", "energy", "cheap_grid", } diff --git a/custom_components/aurum/const.py b/custom_components/aurum/const.py index 2c2f1a3..d4830db 100644 --- a/custom_components/aurum/const.py +++ b/custom_components/aurum/const.py @@ -1,7 +1,7 @@ """AURUM – Constants and configuration keys.""" DOMAIN = "aurum" -VERSION = "1.13.0" +VERSION = "1.14.0" PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "time"] @@ -98,6 +98,15 @@ PRICE_MODE_SOLAR_ONLY = "solar_only" PRICE_MODE_CHEAP_GRID = "cheap_grid" +# ── Device scheduling reasons ──────────────────────────────────── +# Produced by DeviceManager (dev["_scheduling_reason"], published in +# device_states), consumed by the advisor and the cheap-grid binary +# sensor. Single definition point so producer and consumers can't drift. +SCHED_REASON_SURPLUS_AVAILABLE = "surplus_available" +SCHED_REASON_EXCESS_SUFFICIENT = "excess_sufficient" +SCHED_REASON_CHEAP_GRID = "cheap_grid" +SCHED_REASON_SOLAR_PV = "solar_pv" + # ── Device config keys: Startup Detection ──────────────────────── CONF_DEV_SD_POWER_THRESHOLD = "sd_power_threshold" CONF_DEV_SD_DETECTION_TIME = "sd_detection_time" diff --git a/custom_components/aurum/coordinator.py b/custom_components/aurum/coordinator.py index 3679ea1..15d1bea 100644 --- a/custom_components/aurum/coordinator.py +++ b/custom_components/aurum/coordinator.py @@ -23,6 +23,7 @@ from .modules.budget import BudgetManager from .modules.devices import DeviceManager from .modules.pricing import PricingManager +from .modules.advisor import AdvisorManager from .modules.helpers import CSVLogger from .modules.persistence import PersistenceManager @@ -66,6 +67,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): self.devices = DeviceManager(self.bridge, self.config) self.pricing = PricingManager(self.bridge, self.config) self.devices.pricing = self.pricing # expose to device manager + self.advisor = AdvisorManager(self.bridge, self.config) self.persistence = PersistenceManager(self.bridge, self.config) # ── Daily adaptation tracking ────────────────────────────── @@ -118,6 +120,11 @@ def _init_io(): "sd_state": dev.get("sd_state", ""), "price_mode": dev.get("price_mode", "solar_only"), }) + # Seed the odd-cycle cache with the placeholders: the first + # post-startup cycle is odd (grace=6 → cycle 7), and an empty + # cache would make the advisor report "idle – no devices" for + # one cycle on every restart even with devices configured. + self._cached_device_states = list(self.device_states) async def async_shutdown(self): """Save state, then run base coordinator teardown.""" @@ -136,6 +143,15 @@ async def async_shutdown(self): # MAIN UPDATE LOOP # ══════════════════════════════════════════════════════════════ + def _run_advisor(self, shared): + """Fill shared["advisor"] – a pure summary that must never raise + into the control loop. Invoked from the update loop's finally + block so it covers every return path.""" + try: + shared["advisor"] = self.advisor.update(shared) + except Exception as e: + _LOGGER.warning("Advisor error: %s", e) + def _entities_ready(self): """Check if critical sensors are available.""" grid = self.config.get("grid_power_entity") @@ -147,10 +163,9 @@ def _entities_ready(self): async def _async_update_data(self): """Orchestrate: Energy → Battery → Devices → Persist.""" + self.cycle += 1 + shared = {"now": datetime.now(), "cycle": self.cycle} try: - self.cycle += 1 - shared = {"now": datetime.now(), "cycle": self.cycle} - # ── Startup guard ────────────────────────────────────── startup_mode = self.cycle <= self.STARTUP_GRACE_CYCLES if startup_mode: @@ -285,3 +300,11 @@ async def _async_update_data(self): _LOGGER.error("AURUM update error: %s\n%s", e, traceback.format_exc()) raise UpdateFailed(f"Update failed: {e}") from e + finally: + # ── Advisor (decision transparency) ──────────────────── + # Single exit point: runs on EVERY return path (startup + # early-returns, main path, future guards) so no new + # early-return can forget it. Mutates the shared dict that + # is being returned; the advisor itself handles the startup + # snapshot and never raises into the control loop. + self._run_advisor(shared) diff --git a/custom_components/aurum/frontend/aurum-panel.js b/custom_components/aurum/frontend/aurum-panel.js index f891a23..7cf082e 100644 --- a/custom_components/aurum/frontend/aurum-panel.js +++ b/custom_components/aurum/frontend/aurum-panel.js @@ -20,6 +20,19 @@ const HUB = { house: "sensor.aurum_house_consumption", forecast: "sensor.aurum_forecast_remaining", cheap: "binary_sensor.aurum_cheap_grid_active", + advisor: "sensor.aurum_current_decision", +}; + +// Advisor decision code → banner icon + accent color. +const DECISION_META = { + startup: { icon: "⏳", color: "var(--secondary-text-color,#888)" }, + battery_charging: { icon: "🔋", color: "#ffb300" }, + running_solar: { icon: "☀️", color: "var(--success-color,#43a047)" }, + running_cheap_grid: { icon: "💶", color: "#ffb300" }, + running: { icon: "▶️", color: "var(--success-color,#43a047)" }, + waiting: { icon: "⌛", color: "var(--secondary-text-color,#888)" }, + idle: { icon: "💤", color: "var(--secondary-text-color,#888)" }, + unknown: { icon: "❔", color: "var(--secondary-text-color,#888)" }, }; // Per-device entity_id suffixes, all derived from the device slug. @@ -68,6 +81,34 @@ const STRINGS = { socThreshold: "SOC threshold", maxPrice: "Max price (ct/kWh)", pvThreshold: "Solar power ≥ (W)", deadline: "Finish by", stateOff: "off", + devicesOn: "active", + // Advisor decision headline — FALLBACK ONLY. The banner prefers + // hass.formatEntityState() (backend translations, all HA languages); + // these strings only render on older HA frontends without it. + decision_startup: "Starting up", + decision_battery_charging: "Charging battery – devices paused", + decision_running_solar: "Running on solar surplus", + decision_running_cheap_grid: "Running on cheap grid power", + decision_running: "Devices running", + decision_waiting: "Waiting for surplus", + decision_idle: "Idle – no devices", + decision_unknown: "Unknown", + // Advisor per-device reason codes (attributes – no backend translation) + reason_solar_surplus: "solar surplus", + reason_solar_pv: "solar power ≥ threshold", + reason_cheap_grid: "cheap grid power", + reason_manual_override: "manual override", + reason_forced_deadline: "deadline start", + reason_running: "running", + reason_runtime_done: "daily runtime reached", + reason_program_done: "program finished", + reason_program_paused: "program paused", + reason_program_standby: "waiting for program start", + reason_battery_charging: "battery charging", + reason_below_soc_threshold: "battery below threshold", + reason_condition_not_met: "run condition not met", + reason_disabled: "disabled (force-off)", + reason_waiting_surplus: "waiting for surplus", }, de: { solar: "Solar", grid: "Netz", battery: "Akku", surplus: "Überschuss", @@ -84,6 +125,32 @@ const STRINGS = { socThreshold: "SOC-Schwelle", maxPrice: "Max. Preis (ct/kWh)", pvThreshold: "Solarleistung ≥ (W)", deadline: "Fertig bis", stateOff: "aus", + devicesOn: "aktiv", + // Advisor-Entscheidung — NUR FALLBACK (siehe EN-Kommentar). + decision_startup: "Startet …", + decision_battery_charging: "Batterie lädt – Geräte pausiert", + decision_running_solar: "Läuft mit Solar-Überschuss", + decision_running_cheap_grid: "Läuft mit günstigem Netzstrom", + decision_running: "Geräte laufen", + decision_waiting: "Wartet auf Überschuss", + decision_idle: "Leerlauf – keine Geräte", + decision_unknown: "Unbekannt", + // Advisor-Begründungen pro Gerät (Attribute – keine Backend-Übersetzung) + reason_solar_surplus: "Solar-Überschuss", + reason_solar_pv: "Solarleistung ≥ Schwelle", + reason_cheap_grid: "günstiger Netzstrom", + reason_manual_override: "manuell übersteuert", + reason_forced_deadline: "Deadline-Start", + reason_running: "läuft", + reason_runtime_done: "Tageslaufzeit erreicht", + reason_program_done: "Programm fertig", + reason_program_paused: "Programm pausiert", + reason_program_standby: "wartet auf Programmstart", + reason_battery_charging: "Batterie lädt", + reason_below_soc_threshold: "Akku unter Schwelle", + reason_condition_not_met: "Bedingung nicht erfüllt", + reason_disabled: "deaktiviert (Aus-Schalter)", + reason_waiting_surplus: "wartet auf Überschuss", }, }; @@ -93,6 +160,7 @@ class AurumPanel extends HTMLElement { this._hass = null; this._sig = null; // structural signature (device slugs) this._refs = {}; // id -> update fn + this._advReasons = null; // slug -> advisor reason (memo per update) } set hass(hass) { @@ -172,6 +240,7 @@ class AurumPanel extends HTMLElement { // HA startup the override switch can appear before the rest. const sig = JSON.stringify([ this._lang(), // rebuild with new labels if the UI language changes + this._exists(HUB.advisor), // banner appears when the sensor registers devices.map((d) => [ d.slug, d.name, @@ -191,6 +260,7 @@ class AurumPanel extends HTMLElement { _build(devices) { this._refs = {}; + this._advReasons = null; this.innerHTML = ""; const style = document.createElement("style"); @@ -209,6 +279,10 @@ class AurumPanel extends HTMLElement { '
Solar Surplus Optimizer
'; root.appendChild(header); + // Advisor banner: what AURUM is doing right now, and why. + // Hidden entirely on installs that predate the advisor sensor. + if (this._exists(HUB.advisor)) root.appendChild(this._buildAdvisor()); + // Overview chips root.appendChild(this._buildOverview()); @@ -234,6 +308,81 @@ class AurumPanel extends HTMLElement { root.appendChild(devWrap); } + // Translate an advisor reason code; fall back to the raw code so new + // backend vocabulary still renders (untranslated) instead of vanishing. + _reasonText(code) { + if (!code) return ""; + const key = "reason_" + code; + const lang = STRINGS[this._lang()] || STRINGS.en; + return lang[key] || STRINGS.en[key] || code; + } + + _buildAdvisor() { + const box = document.createElement("div"); + box.className = "aurum-advisor"; + const icon = document.createElement("div"); + icon.className = "aurum-advisor-icon"; + const body = document.createElement("div"); + body.className = "aurum-advisor-body"; + const head = document.createElement("div"); + head.className = "aurum-advisor-head"; + const sub = document.createElement("div"); + sub.className = "aurum-advisor-sub"; + body.appendChild(head); + body.appendChild(sub); + box.appendChild(icon); + box.appendChild(body); + + this._refs["advisor"] = () => { + let code = this._st(HUB.advisor) || "unknown"; + if (code === "unavailable") code = "unknown"; + const meta = DECISION_META[code] || DECISION_META.unknown; + icon.textContent = meta.icon; + box.style.borderLeftColor = meta.color; + + // Headline: prefer HA's backend-localized ENUM state (covers every + // HA language); fall back to the local mirror on older frontends. + let headline = ""; + const stObj = this._hass && this._hass.states[HUB.advisor]; + if (stObj && typeof this._hass.formatEntityState === "function") { + try { + headline = this._hass.formatEntityState(stObj); + } catch (_e) { /* fall back below */ } + } + if (!headline || headline === code) { + const lang = STRINGS[this._lang()] || STRINGS.en; + headline = + lang["decision_" + code] || STRINGS.en["decision_" + code] || code; + } + head.textContent = headline; + + // Memo for the device cards: slug → reason (built once per update + // pass; this updater runs before the card updaters by insertion + // order). Cleared in _build. + this._advReasons = null; + const advDevs = this._attr(HUB.advisor, "devices"); + if (Array.isArray(advDevs)) { + this._advReasons = new Map(); + for (const x of advDevs) { + if (x && x.slug) this._advReasons.set(x.slug, x.reason); + } + } + + // Context line: devices active · surplus (live sensor) · price. + const parts = []; + const on = this._attr(HUB.advisor, "devices_on"); + const total = this._attr(HUB.advisor, "devices_total"); + if (on != null && total != null) + parts.push(`${on}/${total} ${this._t("devicesOn")}`); + const ex = parseFloat(this._st(HUB.surplus)); + if (!isNaN(ex)) parts.push(`⚡ ${Math.round(ex)} W`); + const ct = this._attr(HUB.advisor, "current_price_ct"); + if (ct != null) parts.push(`💶 ${ct} ct/kWh`); + sub.textContent = parts.join(" · "); + }; + return box; + } + _buildOverview() { const sec = document.createElement("div"); sec.className = "aurum-overview"; @@ -523,8 +672,18 @@ class AurumPanel extends HTMLElement { } metrics.textContent = parts.join(" "); - const rs = this._attr(e.status, "scheduling_reason"); - reason.textContent = rs ? `→ ${rs}` : ""; + // Prefer the advisor's per-device reason (translated, via the memo + // the banner updater builds); fall back to the raw scheduling_reason + // attribute on pre-advisor installs. + let reasonText = ""; + if (this._advReasons && this._advReasons.has(d.slug)) { + reasonText = this._reasonText(this._advReasons.get(d.slug)); + } + if (!reasonText) { + const rs = this._attr(e.status, "scheduling_reason"); + if (rs) reasonText = rs; + } + reason.textContent = reasonText ? `→ ${reasonText}` : ""; // Derive the active mode: Aus (disable) beats Manuell (override), // matching the backend priority in devices.py. @@ -582,6 +741,15 @@ const CSS = ` .aurum-header { margin-bottom: 16px; } .aurum-title { font-size: 1.6rem; font-weight: 600; } .aurum-sub { color: var(--secondary-text-color); font-size: .9rem; } +.aurum-advisor { display: flex; align-items: center; gap: 14px; + background: var(--card-background-color, #1c1c1c); border-radius: 14px; + padding: 12px 16px; margin-bottom: 14px; + border: 1px solid var(--divider-color, transparent); + border-left: 4px solid var(--secondary-text-color, #888); + box-shadow: var(--ha-card-box-shadow, none); } +.aurum-advisor-icon { font-size: 1.7rem; } +.aurum-advisor-head { font-weight: 600; font-size: 1.05rem; } +.aurum-advisor-sub { color: var(--secondary-text-color); font-size: .82rem; margin-top: 2px; } .aurum-overview { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 22px; } .aurum-chip { display: flex; align-items: center; gap: 10px; background: var(--card-background-color, #1c1c1c); border-radius: 14px; diff --git a/custom_components/aurum/manifest.json b/custom_components/aurum/manifest.json index f7e3397..29386ef 100644 --- a/custom_components/aurum/manifest.json +++ b/custom_components/aurum/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/cm-makes/aurum-ha/issues", "requirements": [], - "version": "1.13.0" + "version": "1.14.0" } diff --git a/custom_components/aurum/modules/advisor.py b/custom_components/aurum/modules/advisor.py new file mode 100644 index 0000000..4844a24 --- /dev/null +++ b/custom_components/aurum/modules/advisor.py @@ -0,0 +1,185 @@ +""" +AURUM – Advisor (Current Decision) +=================================== +Decision transparency: turns the coordinator's shared state into a single +machine-readable "what is AURUM doing right now, and why" summary. + +Design (community port from HELIOS, rebuilt for AURUM): +- Pure function of ``shared`` – no HA access and no side effects. +- Output is STRUCTURED, not prose: an aggregate decision *code* plus a + per-device reason *code* list. Localisation happens in Home Assistant via + the sensor's ``translation_key`` (entity state) and the dashboard panel + (the structured attributes) – never hard-coded German/English here. +- Attributes deliberately carry NO fast-changing numbers (no watts, no + timestamp): every attribute delta is a recorder write, so the payload is + limited to values that change on real decision transitions. Live power + and surplus numbers live in the dedicated numeric sensors. + +Scope: "current decision" only. The HELIOS advisor's daily-plan and +next-action sensors are intentionally out of scope for this first version. +""" + +from ..const import ( + DEVICE_ACTIVE_STATES, + MODE_CHARGING, + MODE_LOW_SOC, + SCHED_REASON_CHEAP_GRID, + SCHED_REASON_EXCESS_SUFFICIENT, + SCHED_REASON_SOLAR_PV, + SCHED_REASON_SURPLUS_AVAILABLE, + SD_STATE_DETECTED, + SD_STATE_DONE, + SD_STATE_STANDBY, + SD_STATE_WAITING, +) + +# ── Aggregate decision codes (sensor state, translated in HA) ──────── +DECISION_STARTUP = "startup" +DECISION_BATTERY_CHARGING = "battery_charging" +DECISION_RUNNING_SOLAR = "running_solar" +DECISION_RUNNING_CHEAP_GRID = "running_cheap_grid" +DECISION_RUNNING = "running" +DECISION_WAITING = "waiting" +DECISION_IDLE = "idle" + +# Full vocabulary for the ENUM sensor ("unknown" = advisor data missing). +DECISION_OPTIONS = [ + DECISION_STARTUP, + DECISION_BATTERY_CHARGING, + DECISION_RUNNING_SOLAR, + DECISION_RUNNING_CHEAP_GRID, + DECISION_RUNNING, + DECISION_WAITING, + DECISION_IDLE, + "unknown", +] + + +class AdvisorManager: + """Summarise the current control decision from shared state.""" + + def __init__(self, hass, config): + # Stateless – accepts (hass, config) only to match the other + # modules' constructor signature; neither is needed. + pass + + def update(self, shared): + """Build the current-decision summary. + + Reads ``battery_mode``, ``battery_soc`` and ``device_states`` + (filled by BatteryManager and DeviceManager earlier in the cycle) + and returns the data dict the coordinator publishes as + ``shared["advisor"]``. + """ + battery_mode = shared.get("battery_mode", "unknown") + + # Startup grace period: device states are unknown, so emit only + # the headline. Omitting the counts keeps the panel banner from + # claiming "0/0 devices" while sensors are still warming up. + if battery_mode == "startup": + return { + "decision": DECISION_STARTUP, + "mode": battery_mode, + "devices": [], + } + + battery_soc = shared.get("battery_soc", -1) + device_states = shared.get("device_states", []) or [] + + devices = [ + self._device_view(d, battery_mode, battery_soc) + for d in device_states + ] + running = [d for d in devices if d["state"] in DEVICE_ACTIVE_STATES] + + data = { + "decision": self._aggregate(battery_mode, devices, running), + "mode": battery_mode, + "devices_on": len(running), + "devices_total": len(devices), + "devices": devices, + } + # Price context only when a price sensor feeds the pipeline. + # shared["current_price"] is already ct/kWh (see pricing.py). + if shared.get("price_data_available"): + data["price_level"] = shared.get("price_level") + price = shared.get("current_price") + if price is not None: + data["current_price_ct"] = round(price, 1) + + return data + + # ── per-device reason ──────────────────────────────────────────── + + def _device_view(self, d, battery_mode, battery_soc): + state = d.get("state", "off") + return { + "name": d.get("name", ""), + "slug": d.get("slug", ""), + "state": state, + "reason": self._device_reason(d, state, battery_mode, battery_soc), + } + + def _device_reason(self, d, state, battery_mode, battery_soc): + """Map a device's state + published flags to a stable reason code. + + Prefers flags DeviceManager publishes (disabled, condition_met, + scheduling_reason, …) over re-deriving its logic here. + """ + if state == "manual_override": + return "manual_override" + + if state in DEVICE_ACTIVE_STATES: + if d.get("force_started"): + return "forced_deadline" + sr = d.get("scheduling_reason") + if sr == SCHED_REASON_CHEAP_GRID: + return "cheap_grid" + if sr == SCHED_REASON_SOLAR_PV: + return "solar_pv" + if sr in (SCHED_REASON_SURPLUS_AVAILABLE, + SCHED_REASON_EXCESS_SUFFICIENT): + return "solar_surplus" + return "running" + + # ── off / standby / done / waiting ── + # Disable switch is the hard kill-switch – beats everything. + if d.get("disabled"): + return "disabled" + if d.get("runtime_target_reached"): + return "runtime_done" + sd_state = d.get("sd_state") + if sd_state == SD_STATE_DONE: + return "program_done" + if sd_state == SD_STATE_WAITING: + return "program_paused" + if sd_state in (SD_STATE_STANDBY, SD_STATE_DETECTED): + # SD device is monitoring for a program start – surplus is + # irrelevant until the user starts a program. + return "program_standby" + if d.get("condition_met") is False: + return "condition_not_met" + if battery_mode == MODE_CHARGING: + return "battery_charging" + threshold = d.get("soc_threshold", 0) or 0 + if (battery_mode == MODE_LOW_SOC and battery_soc is not None + and battery_soc >= 0 and battery_soc < threshold): + return "below_soc_threshold" + return "waiting_surplus" + + # ── aggregate decision ─────────────────────────────────────────── + + def _aggregate(self, battery_mode, devices, running): + """Collapse the per-device picture into one headline decision code.""" + if running: + reasons = {d["reason"] for d in running} + if reasons & {"solar_surplus", "solar_pv"}: + return DECISION_RUNNING_SOLAR + if "cheap_grid" in reasons: + return DECISION_RUNNING_CHEAP_GRID + return DECISION_RUNNING + if battery_mode == MODE_CHARGING: + return DECISION_BATTERY_CHARGING + if devices: + return DECISION_WAITING + return DECISION_IDLE diff --git a/custom_components/aurum/modules/devices.py b/custom_components/aurum/modules/devices.py index 4394513..e8353d6 100644 --- a/custom_components/aurum/modules/devices.py +++ b/custom_components/aurum/modules/devices.py @@ -33,6 +33,10 @@ DEFAULT_DEV_RESIDUAL_POWER, DEFAULT_EXCESS_DEFICIT_TOLERANCE, DEFAULT_SOC_GRID_DEFICIT_TOLERANCE, + SCHED_REASON_SURPLUS_AVAILABLE, + SCHED_REASON_EXCESS_SUFFICIENT, + SCHED_REASON_CHEAP_GRID, + SCHED_REASON_SOLAR_PV, override_entity_id, muss_heute_entity_id, disable_entity_id, @@ -443,7 +447,7 @@ def update(self, shared): # _should_turn_off no longer recognises the device, # causing night-time on/off ping-pong. reason = dev.get("_scheduling_reason") or \ - "surplus_available" + SCHED_REASON_SURPLUS_AVAILABLE self._turn_on(dev, now, excess, battery_soc, reason) newly_allocated += dev["nominal_power"] available_excess -= dev["nominal_power"] @@ -589,7 +593,7 @@ def _should_turn_on(self, dev, available_excess, available_grid_excess, if (dev.get("price_mode") == "cheap_grid" and self.pricing and self.pricing.should_run_on_grid(dev) is True): - dev["_scheduling_reason"] = "cheap_grid" + dev["_scheduling_reason"] = SCHED_REASON_CHEAP_GRID return True # ── PV-power gate: raw solar above threshold + healthy SOC ── @@ -604,7 +608,7 @@ def _should_turn_on(self, dev, available_excess, available_grid_excess, elapsed = (now - dev["excess_since"]).total_seconds() if elapsed < dev["debounce_on"] * penalty: return False - dev["_scheduling_reason"] = "solar_pv" + dev["_scheduling_reason"] = SCHED_REASON_SOLAR_PV return True # Enough excess? (nominal + hysteresis_on + residual_power) @@ -648,7 +652,7 @@ def _should_turn_off(self, dev, available_excess, battery_soc, # This prevents running on expensive grid power after a price jump. # Only act when the price is *known* to be expensive (False); when # the sensor is unavailable (None) we hold state to avoid oscillation. - if (dev.get("_scheduling_reason") == "cheap_grid" + if (dev.get("_scheduling_reason") == SCHED_REASON_CHEAP_GRID and self.pricing and self.pricing.should_run_on_grid(dev) is False): return "price_no_longer_cheap" @@ -659,7 +663,7 @@ def _should_turn_off(self, dev, available_excess, battery_soc, # otherwise stop once the shortfall persists for debounce_off. # Takes precedence over the generic SOC/excess-deficit blocks below # so a gated device stops on falling sun even while surplus remains. - if dev.get("_scheduling_reason") == "solar_pv": + if dev.get("_scheduling_reason") == SCHED_REASON_SOLAR_PV: threshold = dev.get("pv_power_threshold") or 0 off_level = max(0, threshold - dev["hysteresis_off"]) soc_ok = battery_soc < 0 or battery_soc >= soc_threshold @@ -696,7 +700,7 @@ def _should_turn_off(self, dev, available_excess, battery_soc, # `should_run_on_grid` is True when cheap and None when the price # sensor is unavailable – in both cases we hold (only a known- # expensive price, handled above, drops the cheap_grid grant). - if (dev.get("_scheduling_reason") == "cheap_grid" + if (dev.get("_scheduling_reason") == SCHED_REASON_CHEAP_GRID and self.pricing and self.pricing.should_run_on_grid(dev) is not False): dev["_excess_deficit_since"] = None @@ -879,7 +883,7 @@ def _handle_startup_detection(self, dev, turnon_excess, seconds=dev["sd_max_runtime"]) dev["excess_since"] = None dev["force_started"] = False - dev["_scheduling_reason"] = "excess_sufficient" + dev["_scheduling_reason"] = SCHED_REASON_EXCESS_SUFFICIENT self.hass.log( f"AURUM SD [{dev['name']}]: Started " f"(excess={turnon_excess:.0f}W >= " @@ -1068,7 +1072,8 @@ def _get_device_power(self, dev): dev["nominal_power"]) return dev["nominal_power"] - def _turn_on(self, dev, now, excess, soc, reason="surplus_available"): + def _turn_on(self, dev, now, excess, soc, + reason=SCHED_REASON_SURPLUS_AVAILABLE): """Turn a device on.""" self.hass.turn_on(dev["switch_entity"]) dev["on_since"] = now @@ -1205,6 +1210,10 @@ def _publish_device_states(self, shared, battery_soc): "max_price": dev.get("max_price", 0), "stop_after_runtime": dev.get("stop_after_runtime", False), "runtime_target_reached": self._runtime_target_reached(dev), + # Authoritative off-reasons for the advisor: the manager + # knows WHY a device is off; consumers must not re-derive. + "disabled": self._is_disabled(dev), + "condition_met": self._condition_met(dev), }) shared["device_states"] = device_states @@ -1216,7 +1225,7 @@ def _publish_device_states(self, shared, battery_soc): # when AURUM is intentionally consuming grid power at low prices. # True if any device is currently ON with scheduling_reason=cheap_grid. shared["cheap_grid_active"] = any( - d.get("_scheduling_reason") == "cheap_grid" + d.get("_scheduling_reason") == SCHED_REASON_CHEAP_GRID and self._is_device_on(d) for d in self.devices ) diff --git a/custom_components/aurum/sensor.py b/custom_components/aurum/sensor.py index e2f8881..f898967 100644 --- a/custom_components/aurum/sensor.py +++ b/custom_components/aurum/sensor.py @@ -18,6 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, VERSION +from .modules.advisor import DECISION_OPTIONS def _device_icon(name: str) -> str: @@ -73,6 +74,7 @@ async def async_setup_entry( AurumBudgetWSensor(coordinator, entry), AurumSafetyFactorSensor(coordinator, entry), AurumElectricityPriceSensor(coordinator, entry), + AurumAdvisorSensor(coordinator, entry), ] for dev_state in coordinator.device_states: @@ -129,6 +131,38 @@ def _handle_coordinator_update(self): self.async_write_ha_state() +class AurumAdvisorSensor(CoordinatorEntity, SensorEntity): + """Current-decision advisor: what AURUM is doing right now and why. + + State is a stable decision *code* (localised via translation_key). The + structured breakdown – battery mode, surplus, price context and a + per-device reason list – rides in the attributes for a dashboard card + to render. See modules/advisor.py for the code vocabulary. + """ + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = DECISION_OPTIONS + _attr_translation_key = "advisor_decision" + _attr_icon = "mdi:lightbulb-on-outline" + + def __init__(self, coordinator, entry): + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_advisor" + self._attr_name = "AURUM Current Decision" + self._attr_device_info = _hub_device_info(entry.entry_id) + # Deterministic entity_id so dashboards/docs can rely on it. + self.entity_id = "sensor.aurum_current_decision" + + @callback + def _handle_coordinator_update(self): + data = (self.coordinator.data or {}).get("advisor", {}) or {} + self._attr_native_value = data.get("decision", "unknown") + self._attr_extra_state_attributes = { + k: v for k, v in data.items() if k != "decision" + } + self.async_write_ha_state() + + class AurumCycleSensor(CoordinatorEntity, SensorEntity): """Diagnostic: cycle counter.""" diff --git a/custom_components/aurum/strings.json b/custom_components/aurum/strings.json index 1855175..337eeb7 100644 --- a/custom_components/aurum/strings.json +++ b/custom_components/aurum/strings.json @@ -297,5 +297,21 @@ "above": "Sensor must be above the limit" } } + }, + "entity": { + "sensor": { + "advisor_decision": { + "state": { + "startup": "Starting up", + "battery_charging": "Charging battery – devices paused", + "running_solar": "Running on solar surplus", + "running_cheap_grid": "Running on cheap grid power", + "running": "Devices running", + "waiting": "Waiting for surplus", + "idle": "Idle – no devices", + "unknown": "Unknown" + } + } + } } } diff --git a/custom_components/aurum/translations/de.json b/custom_components/aurum/translations/de.json index b895c6c..ebbf746 100644 --- a/custom_components/aurum/translations/de.json +++ b/custom_components/aurum/translations/de.json @@ -296,5 +296,21 @@ "above": "Sensor muss über dem Grenzwert liegen" } } + }, + "entity": { + "sensor": { + "advisor_decision": { + "state": { + "startup": "Startet …", + "battery_charging": "Batterie lädt – Geräte pausiert", + "running_solar": "Läuft mit Solar-Überschuss", + "running_cheap_grid": "Läuft mit günstigem Netzstrom", + "running": "Geräte laufen", + "waiting": "Wartet auf Überschuss", + "idle": "Leerlauf – keine Geräte", + "unknown": "Unbekannt" + } + } + } } } diff --git a/custom_components/aurum/translations/en.json b/custom_components/aurum/translations/en.json index f340445..7a4f695 100644 --- a/custom_components/aurum/translations/en.json +++ b/custom_components/aurum/translations/en.json @@ -296,5 +296,21 @@ "above": "Sensor must be above the limit" } } + }, + "entity": { + "sensor": { + "advisor_decision": { + "state": { + "startup": "Starting up", + "battery_charging": "Charging battery – devices paused", + "running_solar": "Running on solar surplus", + "running_cheap_grid": "Running on cheap grid power", + "running": "Devices running", + "waiting": "Waiting for surplus", + "idle": "Idle – no devices", + "unknown": "Unknown" + } + } + } } } diff --git a/tests/test_advisor.py b/tests/test_advisor.py new file mode 100644 index 0000000..c95f3bf --- /dev/null +++ b/tests/test_advisor.py @@ -0,0 +1,238 @@ +""" +Unit tests for the Advisor (current-decision) module. + +The AdvisorManager is a pure function of the coordinator's ``shared`` dict — +conftest.py stubs the Home Assistant imports, so the module imports directly. +These tests pin the aggregate decision codes and the per-device reason +mapping so a change in either surfaces immediately. +""" + +from __future__ import annotations + +from custom_components.aurum.modules.advisor import ( + DECISION_OPTIONS, + AdvisorManager, +) + + +def _advisor(): + return AdvisorManager(hass=None, config={}) + + +def test_idle_no_devices(): + shared = {"battery_mode": "normal", "battery_soc": 90, "device_states": []} + data = _advisor().update(shared) + assert data["decision"] == "idle" + assert data["devices_total"] == 0 + + +def test_startup_emits_minimal_payload(): + """During the grace period no device claims are made: the panel must + not render '0/0 devices' while sensors are still warming up.""" + shared = {"battery_mode": "startup", "device_states": []} + data = _advisor().update(shared) + assert data["decision"] == "startup" + assert "devices_on" not in data + assert "devices_total" not in data + + +def test_running_solar_via_surplus_available(): + """The DEFAULT turn-on reason for non-SD devices is 'surplus_available' + (devices.py _turn_on) — the flagship case must map to running_solar.""" + shared = { + "battery_mode": "normal", + "battery_soc": 85, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "on", + "scheduling_reason": "surplus_available", "power": 500}, + ], + } + data = _advisor().update(shared) + assert data["decision"] == "running_solar" + assert data["devices"][0]["reason"] == "solar_surplus" + + +def test_running_solar_takes_precedence(): + shared = { + "battery_mode": "normal", + "battery_soc": 85, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "on", + "scheduling_reason": "excess_sufficient", "power": 500}, + {"name": "Heater", "slug": "heater", "state": "on", + "scheduling_reason": "cheap_grid", "power": 800}, + ], + } + data = _advisor().update(shared) + # solar beats cheap_grid in the headline + assert data["decision"] == "running_solar" + assert data["devices_on"] == 2 + reasons = {d["slug"]: d["reason"] for d in data["devices"]} + assert reasons == {"pool": "solar_surplus", "heater": "cheap_grid"} + + +def test_running_cheap_grid_only(): + shared = { + "battery_mode": "normal", + "device_states": [ + {"name": "Heater", "slug": "heater", "state": "running", + "scheduling_reason": "cheap_grid", "power": 800}, + ], + } + assert _advisor().update(shared)["decision"] == "running_cheap_grid" + + +def test_battery_charging_blocks(): + shared = { + "battery_mode": "charging", + "battery_soc": 8, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "off", + "soc_threshold": 20, "power": 0}, + ], + } + data = _advisor().update(shared) + assert data["decision"] == "battery_charging" + assert data["devices"][0]["reason"] == "battery_charging" + + +def test_manual_override_and_forced(): + shared = { + "battery_mode": "charging", # override still runs despite charging + "battery_soc": 8, + "device_states": [ + {"name": "Boiler", "slug": "boiler", "state": "manual_override", + "power": 1500}, + {"name": "Wash", "slug": "wash", "state": "on", + "force_started": True, "scheduling_reason": "excess_sufficient", + "power": 2000}, + ], + } + data = _advisor().update(shared) + # Devices are running, so the headline reflects that, not charging. + # Both run for non-solar reasons (override / deadline) → generic "running". + assert data["decision"] == "running" + reasons = {d["slug"]: d["reason"] for d in data["devices"]} + assert reasons["boiler"] == "manual_override" + assert reasons["wash"] == "forced_deadline" + + +def test_below_soc_threshold_reason(): + shared = { + "battery_mode": "low_soc", + "battery_soc": 30, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "off", + "soc_threshold": 50, "power": 0}, + {"name": "Fan", "slug": "fan", "state": "off", + "soc_threshold": 10, "power": 0}, + ], + } + data = _advisor().update(shared) + assert data["decision"] == "waiting" + reasons = {d["slug"]: d["reason"] for d in data["devices"]} + assert reasons["pool"] == "below_soc_threshold" # 30 < 50 + assert reasons["fan"] == "waiting_surplus" # 30 >= 10 + + +def test_sd_program_states(): + shared = { + "battery_mode": "normal", + "battery_soc": 90, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "off", + "runtime_target_reached": True, "power": 0}, + {"name": "Dish", "slug": "dish", "state": "waiting", + "sd_state": "waiting", "power": 0}, + {"name": "Wash", "slug": "wash", "state": "done", + "sd_state": "done", "power": 0}, + {"name": "Dryer", "slug": "dryer", "state": "standby", + "sd_state": "standby", "power": 0}, + ], + } + data = _advisor().update(shared) + assert data["decision"] == "waiting" + reasons = {d["slug"]: d["reason"] for d in data["devices"]} + assert reasons["pool"] == "runtime_done" + assert reasons["dish"] == "program_paused" + assert reasons["wash"] == "program_done" + assert reasons["dryer"] == "program_standby" + + +def test_disabled_beats_everything(): + """Force-off switch: the device must NOT read 'waiting for surplus' — + it will never start while disabled.""" + shared = { + "battery_mode": "charging", # even the charging label loses + "battery_soc": 5, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "off", + "disabled": True, "soc_threshold": 20, "power": 0}, + ], + } + data = _advisor().update(shared) + assert data["devices"][0]["reason"] == "disabled" + + +def test_condition_not_met(): + """Run condition (e.g. boiler already hot) must not read as + 'waiting for surplus'.""" + shared = { + "battery_mode": "normal", + "battery_soc": 90, + "device_states": [ + {"name": "Boiler", "slug": "boiler", "state": "off", + "condition_met": False, "power": 0}, + ], + } + data = _advisor().update(shared) + assert data["devices"][0]["reason"] == "condition_not_met" + + +def test_no_churn_attributes(): + """Attributes must not carry fast-changing numbers: every attribute + delta is a recorder write. Watts and timestamps live in the dedicated + numeric sensors.""" + shared = { + "battery_mode": "normal", + "battery_soc": 90, + "excess": 1234.5, + "device_states": [ + {"name": "Pool", "slug": "pool", "state": "on", + "scheduling_reason": "surplus_available", "power": 512.3}, + ], + } + data = _advisor().update(shared) + assert "excess_w" not in data + assert "last_update" not in data + assert "battery_soc" not in data + assert "power" not in data["devices"][0] + + +def test_price_context_only_when_available(): + base = {"battery_mode": "normal", "battery_soc": 90, "device_states": []} + + no_price = _advisor().update(dict(base)) + assert "price_level" not in no_price + + # shared["current_price"] is already ct/kWh (pricing.py) — pass-through. + with_price = _advisor().update({ + **base, + "price_data_available": True, + "price_level": "cheap", + "current_price": 18.42, + }) + assert with_price["price_level"] == "cheap" + assert with_price["current_price_ct"] == 18.4 + + +def test_decision_options_cover_all_codes(): + """Every decision the aggregate can emit must be a valid ENUM option.""" + from custom_components.aurum.modules import advisor as adv + emitted = { + adv.DECISION_STARTUP, adv.DECISION_BATTERY_CHARGING, + adv.DECISION_RUNNING_SOLAR, adv.DECISION_RUNNING_CHEAP_GRID, + adv.DECISION_RUNNING, adv.DECISION_WAITING, adv.DECISION_IDLE, + "unknown", + } + assert emitted == set(DECISION_OPTIONS)