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
4 changes: 4 additions & 0 deletions apps/predbat/alertfeed.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from utils import str2time, dp1
import xml.etree.ElementTree as etree
from component_base import ComponentBase
from predbat_metrics import record_api_call


class AlertFeed(ComponentBase):
Expand Down Expand Up @@ -260,6 +261,7 @@ async def download_alert_data(self, url):
status_code = response.status
if status_code not in [200, 201]:
self.log("Warn: AlertFeed: Error downloading weather alert data from URL {}, error code {}".format(url, status_code))
record_api_call("alertfeed", False, "server_error")
return None

text = await response.text()
Expand All @@ -269,10 +271,12 @@ async def download_alert_data(self, url):
self.alert_cache[url] = {}
self.alert_cache[url]["stamp"] = now
self.alert_cache[url]["data"] = text
record_api_call("alertfeed")
self.update_success_timestamp()
return text
except (aiohttp.ClientError, Exception) as e:
self.log("Warn: AlertFeed: Exception downloading weather alert data from URL {}: {}".format(url, e))
record_api_call("alertfeed", False, "connection_error")
return None

def parse_alert_data(self, xml):
Expand Down
8 changes: 7 additions & 1 deletion apps/predbat/axle.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import asyncio
import aiohttp
from component_base import ComponentBase
from predbat_metrics import record_api_call
from utils import str2time, minutes_to_time, TIME_FORMAT


Expand Down Expand Up @@ -158,12 +159,16 @@ async def _request_with_retry(self, url, headers, max_retries=3):
async with session.get(url, headers=headers) as response:
if response.status == 200:
try:
return await response.json()
data = await response.json()
record_api_call("axle")
return data
except Exception as e:
self.log(f"Warn: AxleAPI: Failed to parse JSON response: {e}")
record_api_call("axle", False, "decode_error")
return None
else:
self.log(f"Warn: AxleAPI: Failed to fetch data, status code {response.status}")
record_api_call("axle", False, "server_error")
return None
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt < max_retries - 1:
Expand All @@ -172,6 +177,7 @@ async def _request_with_retry(self, url, headers, max_retries=3):
await asyncio.sleep(sleep_time)
else:
self.log(f"Warn: AxleAPI: Request failed after {max_retries} attempts: {e}")
record_api_call("axle", False, "connection_error")
return None
return None

Expand Down
5 changes: 5 additions & 0 deletions apps/predbat/carbon.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import aiohttp
from const import TIME_FORMAT_HA
from component_base import ComponentBase
from predbat_metrics import record_api_call


class CarbonAPI(ComponentBase):
Expand Down Expand Up @@ -63,6 +64,7 @@ async def fetch_carbon_data(self):
data = await response.json()
data_points = data.get("data", {}).get("data", [])
if data_points:
record_api_call("carbon")
self.update_success_timestamp()
self.last_updated_timestamp = self.last_updated_time()
for point in data_points:
Expand All @@ -84,12 +86,15 @@ async def fetch_carbon_data(self):
self.log("Warn: Carbon API: No data points found in response for date {}".format(date))
except Exception as e:
self.log(f"Warn: Carbon API: Failed to parse JSON response: {e}")
record_api_call("carbon", False, "decode_error")
else:
self.failures_total += 1
self.log(f"Warn: Carbon API: Failed to fetch data, status code {response.status}")
record_api_call("carbon", False, "server_error")
except (aiohttp.ClientError, Exception) as e:
self.failures_total += 1
self.log(f"Warn: Carbon API: Request failed: {e}")
record_api_call("carbon", False, "connection_error")
if collected_data:
self.carbon_data_points = collected_data
self.publish_carbon_data()
Expand Down
4 changes: 4 additions & 0 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -1524,12 +1524,15 @@ async def get_ge_url(self, url, headers, now_utc, max_age_minutes=30):
async with session.get(url, headers=headers) as response:
if response.status not in [200, 201]:
self.log("Warn: GeCloud: Failed to get data from {} status code {}".format(url, response.status))
record_api_call("givenergy", False, "server_error")
return {}, None
try:
data = await response.json()
except (aiohttp.ContentTypeError, json.JSONDecodeError) as e:
record_api_call("givenergy", False, "decode_error")
return {}, None
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
record_api_call("givenergy", False, "connection_error")
return {}, None

if not data or "data" not in data:
Expand Down Expand Up @@ -1572,6 +1575,7 @@ async def get_ge_url(self, url, headers, now_utc, max_age_minutes=30):
self.ge_url_cache[url]["stamp"] = now_utc
self.ge_url_cache[url]["data"] = mdata
self.ge_url_cache[url]["next"] = url_next
record_api_call("givenergy")
return mdata, url_next

async def download_ge_data(self, now_utc):
Expand Down
7 changes: 4 additions & 3 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,16 +1078,16 @@ async def async_download_octopus_url(self, url):
if response.status not in [200, 201, 400]:
self.failures_total += 1
self.log("Warn: OctopusAPI: Error downloading Octopus data from URL {}, code {}".format(url, response.status))
record_api_call("octopus", False, "server_error")
record_api_call("octopus_url", False, "server_error")
return {}
try:
data = await response.json()
self.last_success_timestamp = datetime.now(timezone.utc)
record_api_call("octopus")
record_api_call("octopus_url")
except (aiohttp.ContentTypeError, json.JSONDecodeError):
self.failures_total += 1
self.log("Warn: OctopusAPI: Error downloading Octopus data from URL {} (JSONDecodeError)".format(url))
record_api_call("octopus", False, "decode_error")
record_api_call("octopus_url", False, "decode_error")
return {}

if response.status == 400:
Expand Down Expand Up @@ -1428,6 +1428,7 @@ async def async_graphql_query(self, query, request_context, returns_data=True, i

if response_body and ("data" in response_body):
self.update_success_timestamp()
record_api_call("octopus")
return response_body["data"]
else:
if not ignore_errors:
Expand Down
45 changes: 28 additions & 17 deletions apps/predbat/ohme.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from datetime import timedelta, timezone
from const import TIME_FORMAT_HA
from component_base import ComponentBase
from predbat_metrics import record_api_call

GOOGLE_API_KEY = "AIzaSyC8ZeZngm33tpOXLpbXeKfwtyZ1WrkbdBY" # cspell:disable-line
VERSION = "1.5.1"
Expand Down Expand Up @@ -509,6 +510,10 @@ async def _handle_api_error(self, url: str, resp: aiohttp.ClientResponse):
text = await resp.text()
msg = f"Warn:Ohme API response error: {url}, {resp.status}; {text}"
self.log(msg)
if resp.status in (401, 403):
record_api_call("ohme", False, "auth_error")
else:
record_api_call("ohme", False, "server_error")
raise ApiException(msg)

async def _make_request(
Expand All @@ -526,23 +531,29 @@ async def _make_request(
self._close_session = True

async with asyncio.timeout(self._timeout):
async with self._session.request(
method=method,
url=f"https://api.ohme.io{url}",
data=json.dumps(data) if data and method in {"PUT", "POST"} else data,
headers={
"Authorization": f"Firebase {self._token}",
"Content-Type": "application/json",
"User-Agent": f"ohmepy/{VERSION}",
},
) as resp:
# self.log("Info: %s request to %s, status code %s" % (method, url, resp.status))
await self._handle_api_error(url, resp)

if skip_json and method == "POST":
return await resp.text()

return await resp.json() if method != "PUT" else True
try:
async with self._session.request(
method=method,
url=f"https://api.ohme.io{url}",
data=json.dumps(data) if data and method in {"PUT", "POST"} else data,
headers={
"Authorization": f"Firebase {self._token}",
"Content-Type": "application/json",
"User-Agent": f"ohmepy/{VERSION}",
},
) as resp:
# self.log("Info: %s request to %s, status code %s" % (method, url, resp.status))
await self._handle_api_error(url, resp)

if skip_json and method == "POST":
result = await resp.text()
else:
result = await resp.json() if method != "PUT" else True
record_api_call("ohme")
return result
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
record_api_call("ohme", False, "connection_error")
raise ApiException(f"Ohme connection error: {e}") from e

def _charge_in_progress(self) -> bool:
"""Is a charge in progress? Used to determine if schedule or session should be adjusted."""
Expand Down
15 changes: 12 additions & 3 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@
import requests
import asyncio

THIS_VERSION = "v8.34.1"
THIS_VERSION = "v8.34.2"

# fmt: off
PREDBAT_FILES = ["predbat.py", "const.py", "hass.py", "config.py", "prediction.py", "gecloud.py", "utils.py", "inverter.py", "ha.py", "download.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py", "execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py", "carbon.py", "temperature.py", "web_mcp.py", "component_base.py", "axle.py", "solax.py", "solis.py", "unit_test.py", "load_ml_component.py", "load_predictor.py", "oauth_mixin.py", "predbat_metrics.py", "web_metrics_dashboard.py"]
# fmt: on

from download import predbat_update_move, predbat_update_download, check_install
from const import MINUTE_WATT

# Only do the self-install/self-update logic if we are NOT compiled.
if not IS_COMPILED:
Expand Down Expand Up @@ -739,13 +740,21 @@ def _emit_snapshot_metrics(self):
m.battery_soc_kwh.set(self.soc_kw)
m.battery_soc_percent.set(self.soc_percent)
m.battery_max_kwh.set(self.soc_max)
m.charge_rate_kw.set(self.charge_rate_now / 1000.0)
m.discharge_rate_kw.set(self.discharge_rate_now / 1000.0)
m.charge_rate_kw.set(self.charge_rate_now * MINUTE_WATT / 1000.0)
m.discharge_rate_kw.set(self.discharge_rate_now * MINUTE_WATT / 1000.0)
m.grid_power.set(self.grid_power / 1000.0)
m.battery_power.set(self.battery_power / 1000.0)
m.load_power.set(self.load_power / 1000.0)
m.pv_power.set(self.pv_power / 1000.0)

# Currency symbol
m.currency_symbol = self.currency_symbols[0]

# Cost and savings
m.cost_today.set(self.cost_today_sofar)
m.savings_today_pvbat.set(self.savings_today_pvbat)
m.savings_today_actual.set(self.savings_today_actual)
m.savings_today_predbat.set(self.savings_today_predbat)

# Config validity
m.config_valid.set(0 if self.arg_errors else 1)
Expand Down
58 changes: 50 additions & 8 deletions apps/predbat/predbat_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class PredbatMetrics:
"""

def __init__(self):
# -- Currency (plain string, not a Prometheus metric) ------------------
self.currency_symbol = "\u00A3" # default £, overridden by PredBat at runtime

# -- Application health ------------------------------------------------
self.up = _gauge("predbat_up", "Application is running", ["version"])
self.errors_total = _counter("predbat_errors_total", "Total errors", ["type"])
Expand All @@ -92,9 +95,13 @@ def __init__(self):
self.battery_soc_percent = _gauge("predbat_battery_soc_percent", "Battery state of charge percentage")
self.battery_soc_kwh = _gauge("predbat_battery_soc_kwh", "Battery state of charge in kWh")
self.battery_max_kwh = _gauge("predbat_battery_max_kwh", "Battery maximum capacity in kWh")
self.charge_rate_kw = _gauge("predbat_charge_rate_kw", "Current charge rate in kW")
self.discharge_rate_kw = _gauge("predbat_discharge_rate_kw", "Current discharge rate in kW")
self.charge_rate_kw = _gauge("predbat_charge_rate_kw", "Current max charge rate in kW")
self.discharge_rate_kw = _gauge("predbat_discharge_rate_kw", "Current max discharge rate in kW")
self.inverter_register_writes_total = _counter("predbat_inverter_register_writes_total", "Total inverter register writes")
self.grid_power = _gauge("predbat_grid_power", "Current grid power in kW (positive for import, negative for export)")
self.load_power = _gauge("predbat_load_power", "Current load power in kW")
self.pv_power = _gauge("predbat_pv_power", "Current PV power in kW")
self.battery_power = _gauge("predbat_battery_power", "Current battery power in kW (positive for discharge, negative for charge)")

# -- Energy today ------------------------------------------------------
self.load_today_kwh = _gauge("predbat_load_today_kwh", "Load energy today in kWh")
Expand All @@ -105,8 +112,9 @@ def __init__(self):

# -- Cost & savings ----------------------------------------------------
self.cost_today = _gauge("predbat_cost_today", "Cost today in currency units")
self.savings_today_pvbat = _gauge("predbat_savings_today_pvbat", "PV/Battery system savings today")
self.savings_today_actual = _gauge("predbat_savings_today_actual", "Actual savings today")
self.savings_today_pvbat = _gauge("predbat_savings_today_pvbat", "PV/Battery system savings yesterday")
self.savings_today_actual = _gauge("predbat_savings_today_actual", "Actual cost yesterday")
self.savings_today_predbat = _gauge("predbat_savings_today_predbat", "PredBat savings yesterday")
Comment on lines 114 to +117
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The cost/savings metric help text and units are ambiguous: predbat_cost_today / savings values are stored in minor units (e.g., pence/cents) in the core calculations, but the dashboard displays them as major units by dividing by 100. To avoid confusion for Prometheus users, update these metric descriptions to explicitly state the unit (minor currency units) and/or the required scaling.

Copilot uses AI. Check for mistakes.

# -- IOG (Intelligent Octopus Go) --------------------------------------
self.iog_action_latency_seconds = _histogram(
Expand Down Expand Up @@ -149,6 +157,35 @@ def _labeled(metric):
except AttributeError:
return {}

def _api_services(m):
"""Build a pre-aggregated {service: {requests, failures, last_success}} dict."""
out = {}
if not isinstance(m.api_requests_total, _NoOpMetric):
try:
for label_values, child in m.api_requests_total._metrics.items():
svc = label_values[0]
out.setdefault(svc, {"requests": 0, "failures": 0, "last_success": 0})
out[svc]["requests"] += child._value.get()
except AttributeError:
pass
if not isinstance(m.api_last_success_timestamp, _NoOpMetric):
try:
for label_values, child in m.api_last_success_timestamp._metrics.items():
svc = label_values[0]
out.setdefault(svc, {"requests": 0, "failures": 0, "last_success": 0})
out[svc]["last_success"] = child._value.get()
except AttributeError:
pass
if not isinstance(m.api_failures_total, _NoOpMetric):
try:
for label_values, child in m.api_failures_total._metrics.items():
svc = label_values[0]
out.setdefault(svc, {"requests": 0, "failures": 0, "last_success": 0})
out[svc]["failures"] += child._value.get()
except AttributeError:
pass
return out

return {
# Health
"up": _labeled(self.up),
Expand All @@ -165,20 +202,25 @@ def _labeled(metric):
"battery_max_kwh": _val(self.battery_max_kwh),
"charge_rate_kw": _val(self.charge_rate_kw),
"discharge_rate_kw": _val(self.discharge_rate_kw),
"battery_power": _val(self.battery_power),
"grid_power": _val(self.grid_power),
"load_power": _val(self.load_power),
"pv_power": _val(self.pv_power),
# Energy
"load_today_kwh": _val(self.load_today_kwh),
"import_today_kwh": _val(self.import_today_kwh),
"export_today_kwh": _val(self.export_today_kwh),
"pv_today_kwh": _val(self.pv_today_kwh),
"data_age_days": _val(self.data_age_days),
# Currency symbol
"currency_symbol": self.currency_symbol,
# Cost
"cost_today": _val(self.cost_today),
"savings_today_pvbat": _val(self.savings_today_pvbat),
"savings_today_actual": _val(self.savings_today_actual),
# API (labeled)
"api_requests_total": _labeled(self.api_requests_total),
"api_failures_total": _labeled(self.api_failures_total),
"api_last_success_timestamp": _labeled(self.api_last_success_timestamp),
"savings_today_predbat": _val(self.savings_today_predbat),
# API (pre-aggregated per service - avoids JS key-parsing complexity)
"api_services": _api_services(self),
Comment on lines 218 to +223
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

to_dict() no longer includes the previously-exposed labeled API metric maps (api_requests_total, api_failures_total, api_last_success_timestamp). Since /metrics/json is a public endpoint, dropping these keys is a breaking change for any external consumers; consider keeping the old keys alongside the new api_services aggregate (or versioning the JSON schema / documenting the change).

Copilot uses AI. Check for mistakes.
# Solar
"solcast_api_limit": _val(self.solcast_api_limit),
"solcast_api_used": _val(self.solcast_api_used),
Expand Down
Loading