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)