M6-T08: add energy_cost expose provider + post-billing state publish
- expose.py: _energy_cost_provider yields buy/sell_price_now + import_cost_total /export_revenue_total (total_increasing, monetary), value_getters read from energy_cost_period; 2-element identifiers so the existing HA Discovery builder (which indexes identifiers[1]) works. Other providers/mechanism untouched. - main.py: energy-cost job calls publish_states after computing periods (no-op when MQTT/Discovery off). Tests added. Note: energy-cost sensors lack an availability heartbeat (ha_discovery mechanism limitation, out of scope here).
This commit is contained in:
@@ -348,3 +348,232 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
# Register the modbus provider at module load time.
|
||||
register_provider(_modbus_provider)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Energy Cost provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||
|
||||
Produces 4 sensor entities grouped under a single HA device "Energy Cost":
|
||||
|
||||
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
||||
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
||||
- ``import_cost_total`` — cumulative import cost (total_increasing, monetary).
|
||||
- ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary).
|
||||
|
||||
Current-price algorithm (source-agnostic, with fallback)
|
||||
---------------------------------------------------------
|
||||
Strategy A: read the ``pricing`` snapshot from the most recent non-degraded
|
||||
``energy_cost_period`` row.
|
||||
|
||||
- For ``kind="tibber"``: the snapshot contains ``"buy"`` and ``"sell"`` keys
|
||||
(per-unit prices in the contract currency).
|
||||
- For ``kind="manual"``: ``"buy_normal"`` and ``"sell_normal"`` are used as
|
||||
representative effective per-unit prices. (Both tariff-slot prices differ
|
||||
only by the base rate; energy_tax and ODE are the same for both, so
|
||||
buy_normal is the higher/conservative single representative value.)
|
||||
|
||||
When no non-degraded period exists or the pricing snapshot lacks the
|
||||
expected keys, ``value_getter`` returns ``None`` (``publish_states``
|
||||
skips None values automatically).
|
||||
|
||||
Cumulative totals
|
||||
-----------------
|
||||
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
||||
``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded
|
||||
to avoid double-counting when they are later overwritten by real values.
|
||||
|
||||
Currency
|
||||
--------
|
||||
Taken from the most recent non-degraded period's ``currency`` column.
|
||||
Falls back to ``"EUR"`` when no such row exists.
|
||||
|
||||
Key convention
|
||||
--------------
|
||||
Fixed stable string keys (not derived from any mutable field or DB id):
|
||||
- ``"energy.buy_price_now"``
|
||||
- ``"energy.sell_price_now"``
|
||||
- ``"energy.import_cost_total"``
|
||||
- ``"energy.export_revenue_total"``
|
||||
|
||||
DeviceInfo identifiers
|
||||
----------------------
|
||||
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that
|
||||
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid
|
||||
(the service uses index [1] as the node_id throughout).
|
||||
"""
|
||||
from app.models.energy import EnergyCostPeriod # local import to avoid circular
|
||||
|
||||
# --- Determine currency and representative pricing from the latest non-degraded row ---
|
||||
|
||||
latest_period: EnergyCostPeriod | None = (
|
||||
session.query(EnergyCostPeriod)
|
||||
.filter(EnergyCostPeriod.degraded.is_(False))
|
||||
.order_by(EnergyCostPeriod.period_start.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
currency: str = "EUR"
|
||||
if latest_period is not None and latest_period.currency:
|
||||
currency = latest_period.currency
|
||||
|
||||
# --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) ---
|
||||
device_info = DeviceInfo(
|
||||
identifiers=("energy-cost", "energy-cost"),
|
||||
name="Energy Cost",
|
||||
)
|
||||
|
||||
# --- value_getter: current buy price ---
|
||||
|
||||
def _make_buy_price_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the current buy price per kWh."""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
|
||||
period = (
|
||||
sess.query(_ECP)
|
||||
.filter(_ECP.degraded.is_(False))
|
||||
.order_by(_ECP.period_start.desc())
|
||||
.first()
|
||||
)
|
||||
if period is None or not period.pricing:
|
||||
return None
|
||||
snap = period.pricing
|
||||
kind = snap.get("kind")
|
||||
if kind == "tibber":
|
||||
raw = snap.get("buy")
|
||||
elif kind == "manual":
|
||||
raw = snap.get("buy_normal")
|
||||
else:
|
||||
# Unknown kind — attempt common keys gracefully.
|
||||
raw = snap.get("buy") or snap.get("buy_normal")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: current sell price ---
|
||||
|
||||
def _make_sell_price_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the current sell price per kWh."""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
|
||||
period = (
|
||||
sess.query(_ECP)
|
||||
.filter(_ECP.degraded.is_(False))
|
||||
.order_by(_ECP.period_start.desc())
|
||||
.first()
|
||||
)
|
||||
if period is None or not period.pricing:
|
||||
return None
|
||||
snap = period.pricing
|
||||
kind = snap.get("kind")
|
||||
if kind == "tibber":
|
||||
raw = snap.get("sell")
|
||||
elif kind == "manual":
|
||||
raw = snap.get("sell_normal")
|
||||
else:
|
||||
raw = snap.get("sell") or snap.get("sell_normal")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: cumulative import cost ---
|
||||
|
||||
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the cumulative import cost (non-degraded rows only)."""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
total = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if total is None:
|
||||
return None
|
||||
return float(total)
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: cumulative export revenue ---
|
||||
|
||||
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for the cumulative export revenue (non-degraded rows only)."""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
total = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if total is None:
|
||||
return None
|
||||
return float(total)
|
||||
|
||||
return _getter
|
||||
|
||||
# Price unit string: "<currency>/kWh"
|
||||
price_unit = f"{currency}/kWh"
|
||||
|
||||
entities: list[ExposableEntity] = [
|
||||
ExposableEntity(
|
||||
key="energy.buy_price_now",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class=None,
|
||||
unit=price_unit,
|
||||
name="Energy Buy Price Now",
|
||||
value_getter=_make_buy_price_getter(),
|
||||
state_class="measurement",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.sell_price_now",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class=None,
|
||||
unit=price_unit,
|
||||
name="Energy Sell Price Now",
|
||||
value_getter=_make_sell_price_getter(),
|
||||
state_class="measurement",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.import_cost_total",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Import Cost Total",
|
||||
value_getter=_make_import_cost_getter(),
|
||||
state_class="total_increasing",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.export_revenue_total",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Export Revenue Total",
|
||||
value_getter=_make_export_revenue_getter(),
|
||||
state_class="total_increasing",
|
||||
),
|
||||
]
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
# Register the energy cost provider at module load time.
|
||||
register_provider(_energy_cost_provider)
|
||||
|
||||
Reference in New Issue
Block a user