Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions custom_components/aurum/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
11 changes: 10 additions & 1 deletion custom_components/aurum/const.py
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand Up @@ -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"
Expand Down
29 changes: 26 additions & 3 deletions custom_components/aurum/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ──────────────────────────────
Expand Down Expand Up @@ -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."""
Expand All @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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)
172 changes: 170 additions & 2 deletions custom_components/aurum/frontend/aurum-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand All @@ -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",
},
};

Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -191,6 +260,7 @@ class AurumPanel extends HTMLElement {

_build(devices) {
this._refs = {};
this._advReasons = null;
this.innerHTML = "";

const style = document.createElement("style");
Expand All @@ -209,6 +279,10 @@ class AurumPanel extends HTMLElement {
'<div class="aurum-sub">Solar Surplus Optimizer</div>';
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());

Expand All @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion custom_components/aurum/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading
Loading