Skip to content
Open
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
35 changes: 35 additions & 0 deletions apps/predbat/alertfeed.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,41 @@ def apply_alerts(self, alerts, keep, minutes_now, midnight_utc):
alert_show.append(item)
self.dashboard_item("sensor." + self.prefix + "_alertfeed_status", state=active_alert_text, attributes={"friendly_name": "Weather alerts", "icon": "mdi:alert-outline", "keep": alert_keep, "alerts": alert_show}, app="alertfeed")

# Also publish to the unified alerts framework so downstream consumers
# (dashboards, gateways, SaaS) see weather alerts alongside other
# categories. TTL-only lifecycle: we re-record each cycle, entries
# drop off when no longer active (stops being re-recorded + TTL expires).
for alert in alerts or []:
expires = alert.get("expires")
if not expires:
continue
cap_severity = (alert.get("severity") or "").lower()
framework_severity = "critical" if cap_severity == "extreme" else "warning" if cap_severity == "severe" else "info"
event = alert.get("event") or alert.get("title") or "Weather alert"
onset = alert.get("onset")
area = alert.get("areaDesc") or "your area"
metadata = {
"event": alert.get("event"),
"severity_cap": alert.get("severity"),
"certainty": alert.get("certainty"),
"urgency": alert.get("urgency"),
"area": area,
"onset": str(onset) if onset else None,
}
if keep and keep > 0:
metadata["action"] = "keep_reserve"
metadata["keep_percent"] = keep
dedup_key = "weather:{}:{}".format(event, str(onset) if onset else "no-onset")
self.record_alert(
category="weather",
severity=framework_severity,
title=event,
message="{} until {} ({}/{}/{})".format(area, expires, alert.get("severity") or "unknown", alert.get("certainty") or "unknown", alert.get("urgency") or "unknown"),
dedup_key=dedup_key,
metadata=metadata,
expires_at=expires.isoformat() if hasattr(expires, "isoformat") else str(expires),
)
Comment on lines +165 to +198
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apply_alerts() now loops over potentially multiple CAP alerts and calls record_alert() for each one; record_alert() immediately publishes the aggregated system_alerts entity each time. This can cause multiple HA state updates per cycle. Consider batching (e.g., add a publish=False option and publish once after the loop, or have AlertFeed build alerts then call a single publish) to reduce churn.

Copilot uses AI. Check for mistakes.

return alert_active_keep

def is_point_in_polygon(self, lat, lon, polygon):
Expand Down
16 changes: 16 additions & 0 deletions apps/predbat/component_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ def get_ha_config(self, name, default):
"""
return self.base.get_ha_config(name, default)

def record_alert(self, category, severity, title, message, dedup_key=None, metadata=None, expires_at=None, action_url=None):
"""
Record a user-facing alert via the base system. See
`PredBat.record_alert` for full semantics.
"""
return self.base.record_alert(
category=category,
severity=severity,
title=title,
message=message,
dedup_key=dedup_key,
metadata=metadata,
expires_at=expires_at,
action_url=action_url,
)

def set_arg(self, arg, value):
"""
Set a configuration argument in the base system.
Expand Down
85 changes: 85 additions & 0 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,10 @@ def initialize(self, key, account_id, automatic):
self.commands = []
self.mpan = None
self.free_electricity_events = []
# Track when each intelligent device first reported a non-capable
# currentState (e.g. SMART_CONTROL_NOT_AVAILABLE). When the condition
# persists beyond 24h we raise a user-facing alert via record_alert.
self.smart_control_degraded_since = {}

# API request metrics for monitoring
self.requests_total = 0
Expand Down Expand Up @@ -587,6 +591,15 @@ async def load_octopus_cache(self):
self.saving_sessions = data.get("saving_sessions", {})
self.intelligent_devices = data.get("intelligent_devices", {})
self.graphql_token = data.get("kraken_token")
# Restore first-seen timestamps for the IOG smart-control
# degradation check so the 24h clock survives restarts.
raw = data.get("smart_control_degraded_since", {}) or {}
self.smart_control_degraded_since = {}
for device_id, iso in raw.items():
try:
self.smart_control_degraded_since[device_id] = datetime.fromisoformat(iso)
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_octopus_cache() restores smart_control_degraded_since with datetime.fromisoformat(iso), but if the cached ISO string is naive (no offset) this will create a naive datetime. Later _maybe_raise_smart_control_alert() subtracts it from now (timezone-aware), which will raise a TypeError and break the update loop. Please normalise loaded timestamps to timezone-aware (e.g., assume UTC or local_tz when tzinfo is missing, and handle trailing 'Z' like output.py does).

Suggested change
self.smart_control_degraded_since[device_id] = datetime.fromisoformat(iso)
if isinstance(iso, str) and iso.endswith("Z"):
iso = iso[:-1] + "+00:00"
parsed = datetime.fromisoformat(iso)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
self.smart_control_degraded_since[device_id] = parsed

Copilot uses AI. Check for mistakes.
except (TypeError, ValueError):
pass

# Load tariffs from individual shared cache files
# Tariffs will be loaded on-demand when needed via load_tariff_from_cache()
Expand Down Expand Up @@ -615,6 +628,9 @@ async def save_octopus_cache(self):
octopus_cache["saving_sessions"] = self.saving_sessions
octopus_cache["intelligent_devices"] = self.intelligent_devices
octopus_cache["kraken_token"] = self.graphql_token
# Persist the smart-control degradation first-seen timestamps as ISO
# strings so the 24h alert window survives AppDaemon restarts.
octopus_cache["smart_control_degraded_since"] = {device_id: dt.isoformat() for device_id, dt in self.smart_control_degraded_since.items()}
with open(self.user_cache_file, "w") as f:
yaml.dump(octopus_cache, f)

Expand Down Expand Up @@ -1676,9 +1692,19 @@ async def async_get_intelligent_devices(self, account_id, device_id):
if vehicle_info.get("model", None) == model:
vehicleBatterySizeInKwh = vehicle_info.get("batterySize", None)

# currentState comes from the dispatches query's devices node.
# Example values seen: SMART_CONTROL_CAPABLE, SMART_CONTROL_NOT_AVAILABLE.
current_state = None
if dispatch_result:
for dev in dispatch_result.get("devices", []) or []:
if dev.get("id") == IntelligentdeviceID:
current_state = (dev.get("status") or {}).get("currentState")
break

intelligent_device = {
"deviceType": deviceType,
"status": status,
"current_state": current_state,
"provider": make,
"model": model,
"is_charger": isCharger,
Expand Down Expand Up @@ -1843,6 +1869,65 @@ async def async_intelligent_update_sensor(self, account_id):
)
self.dashboard_item(self.get_entity_name("number", "intelligent_target_soc", index=device_index), target_soc, attributes={"friendly_name": "Octopus Intelligent Target SOC", "icon": "mdi:battery-percent", "min": 0, "max": 100}, app="octopus")

# Surface SMART_CONTROL_NOT_AVAILABLE as a user-facing alert when
# it persists beyond 24h. The customer's charger has lost Octopus's
# smart control — PredBat will ignore IOG slots, but there is no
# feedback in the app beyond empty plannedDispatches. Alert nudges
# them to re-authorise MyEnergi in the Octopus app.
self._maybe_raise_smart_control_alert(device_id, device)

def _maybe_raise_smart_control_alert(self, device_id, device):
"""Check the device's currentState and raise / refresh a system alert
if it has been non-capable for more than 24h. TTL-only: while the
condition persists we re-record each sensor cycle to keep the alert
alive; when currentState returns to capable we stop re-recording and
the alert expires on its own."""
Comment on lines +1872 to +1884
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New smart-control degradation alert logic in _maybe_raise_smart_control_alert() is user-facing behavior (24h threshold + TTL refresh) but there are already Octopus tests under apps/predbat/tests/. Please add/extend tests to cover: tracking first_seen across cycles, alert firing after 24h, and clearing when SMART_CONTROL_CAPABLE returns (including cache restore path).

Copilot generated this review using guidance from repository custom instructions.
current_state = device.get("current_state")
# SMART_CONTROL_CAPABLE is the healthy state. Treat anything else
# (e.g. SMART_CONTROL_NOT_AVAILABLE) as degraded. None means we did
# not observe a state this cycle — leave tracking as-is.
if not current_state:
return

if current_state == "SMART_CONTROL_CAPABLE":
self.smart_control_degraded_since.pop(device_id, None)
return

now = self.now_utc_exact
first_seen = self.smart_control_degraded_since.get(device_id)
if first_seen is None:
self.smart_control_degraded_since[device_id] = now
return

degraded_seconds = (now - first_seen).total_seconds()
if degraded_seconds < 24 * 60 * 60:
return

# TTL slightly longer than our sensor refresh cadence so the alert
# survives between cycles but drops off once we stop re-recording.
from datetime import timedelta

ttl = timedelta(hours=2)
expires_at = (now + ttl).isoformat()

model = device.get("model") or device.get("provider") or "charger"
self.record_alert(
category="system",
severity="warning",
title="Octopus has lost smart control of your {}".format(model),
message=("Octopus can't schedule Intelligent charging sessions right " "now — PredBat can't see your charging slots. Re-authorise " "MyEnergi in the Octopus app (Smart devices) to fix it."),
dedup_key="iog_smart_control_lost:{}".format(device_id),
metadata={
"device_id": device_id,
"current_state": current_state,
"provider": device.get("provider"),
"model": device.get("model"),
"degraded_since": first_seen.isoformat(),
"degraded_hours": round(degraded_seconds / 3600, 1),
},
expires_at=expires_at,
)

async def async_get_account(self, account_id):
"""
Get the user's account
Expand Down
91 changes: 90 additions & 1 deletion apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"""

import math
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from config import THIS_VERSION
from const import TIME_FORMAT, PREDICT_STEP
from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state
Expand Down Expand Up @@ -2429,6 +2429,95 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra
if had_errors:
self.had_errors = True

def record_alert(self, category, severity, title, message, dedup_key=None, metadata=None, expires_at=None, action_url=None):
"""
Record a user-facing alert. Published as a list of dicts on the
`sensor.<prefix>_system_alerts` entity (attribute `alerts`).
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says alerts are published to sensor.<prefix>_system_alerts, but the code actually publishes to self.prefix + ".system_alerts" (a different entity id pattern). Please align the docstring (and any user-facing docs) with the actual published entity name.

Suggested change
`sensor.<prefix>_system_alerts` entity (attribute `alerts`).
`<prefix>.system_alerts` entity (attribute `alerts`).

Copilot uses AI. Check for mistakes.

Producers re-call each cycle while the condition holds and must pass
an `expires_at` slightly longer than their re-check cadence (usually
2× the plan interval). Alerts with a past `expires_at` are auto-pruned
on the next publish. To fast-resolve an alert early, re-record it with
`expires_at` set to now.

Args:
category: Short string grouping (e.g. "weather", "system",
"api_keys"). Consumers may filter or route by this.
severity: One of "critical", "warning", "info".
title: Short user-facing title.
message: Longer description.
dedup_key: Optional — if provided, an existing alert with the same
key is replaced (so repeated calls don't accumulate). Defaults
to a composite of category + title.
metadata: Optional dict carried through to consumers for routing
hints (e.g. `{"action": "keep_reserve", "percent": 50}`).
expires_at: ISO-8601 timestamp. Strongly recommended — without
one, an alert persists until the process restarts.
action_url: Optional deep link for consumers that can render it.
"""
key = dedup_key or "{}::{}".format(category, title)
self._active_alerts[key] = {
"category": category,
"severity": severity,
"title": title,
"message": message,
"dedup_key": key,
Comment on lines +2458 to +2464
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

record_alert() stores alerts under a plain key = dedup_key or "{category}::{title}". If callers provide a dedup_key without including the category (as in octopus.py), alerts from different categories could collide and overwrite each other. Consider composing the internal dict key from both category + dedup_key (while keeping the original dedup_key field intact for consumers).

Suggested change
key = dedup_key or "{}::{}".format(category, title)
self._active_alerts[key] = {
"category": category,
"severity": severity,
"title": title,
"message": message,
"dedup_key": key,
consumer_dedup_key = dedup_key or "{}::{}".format(category, title)
internal_key = "{}::{}".format(category, dedup_key) if dedup_key else consumer_dedup_key
self._active_alerts[internal_key] = {
"category": category,
"severity": severity,
"title": title,
"message": message,
"dedup_key": consumer_dedup_key,

Copilot uses AI. Check for mistakes.
"metadata": metadata or {},
"expires_at": expires_at,
"action_url": action_url,
"recorded_at": self.now_utc_exact.isoformat() if hasattr(self, "now_utc_exact") and self.now_utc_exact else None,
}
self._publish_system_alerts()

def _publish_system_alerts(self):
"""Prune expired entries and publish the current active list."""
# Parse expires_at to timezone-aware datetime and compare against a UTC
# now so producers can pass ISO strings with any offset (e.g. CAP
# weather alerts arrive as +00:00, octopus alerts as local offset).
# Lex comparison on mixed-offset ISO strings would order wrongly.
# Use the engine's canonical "now" (aware, local tz) so mocked-time
# tests and deterministic plan cycles stay consistent. Python compares
# aware datetimes across timezones correctly.
now_dt = self.now_utc_exact if hasattr(self, "now_utc_exact") and self.now_utc_exact else None
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_publish_system_alerts() only prunes expired alerts when now_dt is available, but PredBat itself does not define now_utc_exact (only ComponentBase does). In that case now_dt will be None, TTL pruning never happens, and recorded_at is always None. Recommend adding a now_utc_exact property/attribute on PredBat (e.g., set in update_time) or switching this logic to use an existing canonical timestamp like self.now_utc so TTL-only lifecycle works in both HA and standalone modes.

Suggested change
now_dt = self.now_utc_exact if hasattr(self, "now_utc_exact") and self.now_utc_exact else None
now_dt = getattr(self, "now_utc_exact", None) or getattr(self, "now_utc", None)
if now_dt is not None and now_dt.tzinfo is None:
now_dt = now_dt.replace(tzinfo=timezone.utc)

Copilot uses AI. Check for mistakes.

if now_dt is not None:
expired_keys = []
for k, a in self._active_alerts.items():
expires_at = a.get("expires_at")
if not expires_at:
continue
try:
# Python 3.11+: fromisoformat handles the trailing "Z"; older
# versions don't. Normalise by replacing "Z" with "+00:00".
iso = expires_at.replace("Z", "+00:00") if isinstance(expires_at, str) else None
if iso is None:
continue
expires_dt = datetime.fromisoformat(iso)
except (TypeError, ValueError):
continue
# Treat naive timestamps as UTC for backward compatibility.
if expires_dt.tzinfo is None:
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
if expires_dt < now_dt:
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expiry pruning uses if expires_dt < now_dt, but the API description says re-recording with expires_at=now should be pruned on the next publish. Using a strict < can leave an alert around for an extra cycle when the timestamps are equal; consider using <= for the comparison so "expires at now" reliably clears.

Suggested change
if expires_dt < now_dt:
if expires_dt <= now_dt:

Copilot uses AI. Check for mistakes.
expired_keys.append(k)
for k in expired_keys:
del self._active_alerts[k]

active = sorted(
self._active_alerts.values(),
key=lambda a: ({"critical": 0, "warning": 1, "info": 2}.get(a.get("severity", "info"), 2), a.get("recorded_at") or ""),
)
self.dashboard_item(
self.prefix + ".system_alerts",
state="on" if active else "off",
Comment on lines +2510 to +2512
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the new entity is published as sensor.<prefix>.system_alerts, but the implementation publishes to self.prefix + ".system_alerts". Please reconcile the PR description and the actual entity id pattern (and ideally keep it consistent with other published entities).

Copilot uses AI. Check for mistakes.
attributes={
"friendly_name": "PredBat system alerts",
"icon": "mdi:alert-circle-outline",
"alerts": active,
"count": len(active),
},
)

def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import_minutes, minutes_now, step=5, save=True):
"""
Compare predicted vs actual load
Expand Down
3 changes: 3 additions & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ def reset(self):
self.current_status = None
self.previous_status = None
self.had_errors = False
# Active user-facing alerts keyed by (category, dedup_key). See
# record_alert()/clear_alert() in output.py.
Comment on lines +476 to +477
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says alerts are keyed by (category, dedup_key) and references clear_alert(), but the implementation added in output.py keys only by a single string and there is no clear_alert() in this PR. Please update this comment to match the actual API/lifecycle to avoid misleading future changes.

Suggested change
# Active user-facing alerts keyed by (category, dedup_key). See
# record_alert()/clear_alert() in output.py.
# Active user-facing alerts keyed by the alert identifier string used
# by the output layer. Updated via the alert-recording flow in output.py.

Copilot uses AI. Check for mistakes.
self._active_alerts = {}
self.plan_valid = False
self.plan_last_updated = None
self.plan_last_updated_minutes = 0
Expand Down
Loading