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)
|
||||
|
||||
@@ -109,6 +109,15 @@ def _run_scheduled_energy_cost() -> None:
|
||||
session: Session = session_local()
|
||||
try:
|
||||
compute_closed_periods(session)
|
||||
# After billing periods are computed, push fresh energy-cost state values
|
||||
# to MQTT/HA. publish_states is internally guarded by _should_publish
|
||||
# (MQTT disabled / not connected → no-op), so this never raises due to
|
||||
# unconfigured MQTT and does not block the billing job.
|
||||
try:
|
||||
from app.services.ha_discovery import publish_states
|
||||
publish_states(session)
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_energy_cost: publish_states failed (non-fatal)")
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_energy_cost: unexpected error")
|
||||
finally:
|
||||
|
||||
@@ -393,7 +393,7 @@ Phase D(API + 前端)
|
||||
- **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0<effective_to`;job 不崩。
|
||||
|
||||
### M6-T08 — energy_cost expose provider + state 发布
|
||||
- **Status**: `todo` · **Depends**: M6-T07
|
||||
- **Status**: `done` · **Depends**: M6-T07
|
||||
- **Context**: 复用 M5 expose/Discovery,发当前买/卖价 + 累计成本/收入。
|
||||
- **Files**: `modify app/integrations/expose.py`(`_energy_cost_provider` + 注册);`modify app/services/energy_cost.py` 或 `app/main.py`(算完顺带 `publish_states`);`create tests/test_energy_expose.py`
|
||||
- **Steps**: provider 产出 §3.7 实体(`buy_price_now`/`sell_price_now`/`import_cost_total`/`export_revenue_total`),累计项 `total_increasing`+`monetary`+currency;`value_getter` 从 `energy_cost_period` 取;计费 job 后 `publish_states`。
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
"""Tests for M6-T08: energy_cost expose provider + state publish.
|
||||
|
||||
Coverage:
|
||||
1. build_catalog contains all 4 energy_cost entities.
|
||||
2. Cumulative entities (import_cost_total / export_revenue_total) have
|
||||
state_class="total_increasing" and device_class="monetary".
|
||||
3. All 4 energy_cost entities default to enabled=False (no toggle row).
|
||||
4. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds.
|
||||
5. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from
|
||||
non-degraded rows; degraded rows excluded.
|
||||
6. value_getter returns None when no non-degraded period exists.
|
||||
7. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
|
||||
8. Integration: build_discovery_payload on an energy_cost entity does NOT raise
|
||||
IndexError (validates 2-element identifiers).
|
||||
9. Keys are stable fixed strings (not derived from mutable data or DB ids).
|
||||
10. Provider registered: energy_cost entities appear alongside modbus entities
|
||||
in the full catalog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
cfg = Config("alembic_app.ini")
|
||||
cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
return cfg
|
||||
|
||||
|
||||
def _make_period(
|
||||
session: Session,
|
||||
*,
|
||||
period_start: datetime,
|
||||
import_cost: float = 0.10,
|
||||
export_revenue: float = 0.05,
|
||||
net_cost: float = 0.05,
|
||||
currency: str = "EUR",
|
||||
pricing: dict | None = None,
|
||||
degraded: bool = False,
|
||||
d1_kwh: float = 0.5,
|
||||
d2_kwh: float = 0.3,
|
||||
r1_kwh: float = 0.1,
|
||||
r2_kwh: float = 0.1,
|
||||
) -> Any:
|
||||
"""Insert an EnergyCostPeriod row and return it (session not committed)."""
|
||||
from app.models.energy import EnergyCostPeriod
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
p = EnergyCostPeriod(
|
||||
period_start=period_start,
|
||||
d1_kwh=d1_kwh,
|
||||
d2_kwh=d2_kwh,
|
||||
r1_kwh=r1_kwh,
|
||||
r2_kwh=r2_kwh,
|
||||
import_cost=import_cost,
|
||||
export_revenue=export_revenue,
|
||||
net_cost=net_cost,
|
||||
currency=currency,
|
||||
pricing=pricing or {},
|
||||
contract_version_id=None,
|
||||
degraded=degraded,
|
||||
computed_at=now,
|
||||
)
|
||||
session.add(p)
|
||||
session.flush()
|
||||
return p
|
||||
|
||||
|
||||
def _make_settings(
|
||||
*,
|
||||
mqtt_enabled: bool = True,
|
||||
ha_discovery_enabled: bool = True,
|
||||
ha_discovery_prefix: str = "homeassistant",
|
||||
) -> MagicMock:
|
||||
s = MagicMock()
|
||||
s.mqtt_enabled = mqtt_enabled
|
||||
s.ha_discovery_enabled = ha_discovery_enabled
|
||||
s.ha_discovery_prefix = ha_discovery_prefix
|
||||
return s
|
||||
|
||||
|
||||
def _make_mock_manager(*, is_connected: bool = True) -> MagicMock:
|
||||
mgr = MagicMock()
|
||||
mgr.is_connected = is_connected
|
||||
return mgr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def energy_db(tmp_path: Path):
|
||||
"""Temporary SQLite DB at Alembic head for energy expose tests."""
|
||||
db_path = tmp_path / "energy_expose_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. build_catalog contains all 4 energy_cost entities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None:
|
||||
"""build_catalog must include all 4 energy_cost sensor entities."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
||||
expected_keys = {
|
||||
"energy.buy_price_now",
|
||||
"energy.sell_price_now",
|
||||
"energy.import_cost_total",
|
||||
"energy.export_revenue_total",
|
||||
}
|
||||
assert expected_keys == energy_keys, (
|
||||
f"Expected energy keys {expected_keys!r}, got {energy_keys!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Cumulative entities have correct state_class and device_class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cumulative_entities_have_total_increasing_state_class(energy_db) -> None:
|
||||
"""import_cost_total and export_revenue_total must have state_class='total_increasing'."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
cumulative_keys = {"energy.import_cost_total", "energy.export_revenue_total"}
|
||||
for entry in catalog:
|
||||
if entry.entity.key in cumulative_keys:
|
||||
assert entry.entity.state_class == "total_increasing", (
|
||||
f"Entity {entry.entity.key!r} must have state_class='total_increasing', "
|
||||
f"got {entry.entity.state_class!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_cumulative_entities_have_monetary_device_class(energy_db) -> None:
|
||||
"""import_cost_total and export_revenue_total must have device_class='monetary'."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
cumulative_keys = {"energy.import_cost_total", "energy.export_revenue_total"}
|
||||
for entry in catalog:
|
||||
if entry.entity.key in cumulative_keys:
|
||||
assert entry.entity.device_class == "monetary", (
|
||||
f"Entity {entry.entity.key!r} must have device_class='monetary', "
|
||||
f"got {entry.entity.device_class!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_all_energy_entities_are_sensors(energy_db) -> None:
|
||||
"""All 4 energy_cost entities must have component='sensor'."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4
|
||||
for entry in energy_entries:
|
||||
assert entry.entity.component == "sensor", (
|
||||
f"Expected component='sensor' for {entry.entity.key!r}, "
|
||||
f"got {entry.entity.component!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Default enabled=False (no toggle row)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_energy_cost_entities_default_to_disabled(energy_db) -> None:
|
||||
"""All 4 energy_cost entities must default to enabled=False (no toggle row)."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4
|
||||
for entry in energy_entries:
|
||||
assert entry.enabled is False, (
|
||||
f"Entity {entry.entity.key!r} must default to enabled=False "
|
||||
f"when no toggle row exists, got enabled={entry.enabled!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. value_getter: current price from tibber pricing snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_buy_price_getter_reads_tibber_snapshot(energy_db) -> None:
|
||||
"""buy_price_now value_getter must return the 'buy' price from tibber snapshot."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc)
|
||||
tibber_pricing = {
|
||||
"kind": "tibber",
|
||||
"buy": "0.2850",
|
||||
"sell": "0.1200",
|
||||
"energy_tax": "0.1234",
|
||||
"sell_adjust": "0.0100",
|
||||
"total": "0.2850",
|
||||
"tibber_price_starts_at": t0.isoformat(),
|
||||
"tibber_price_id": 1,
|
||||
}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_period(
|
||||
session,
|
||||
period_start=t0,
|
||||
import_cost=0.10,
|
||||
export_revenue=0.05,
|
||||
currency="EUR",
|
||||
pricing=tibber_pricing,
|
||||
degraded=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
buy_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.buy_price_now"
|
||||
)
|
||||
value = buy_entry.entity.value_getter(session)
|
||||
|
||||
assert value == pytest.approx(0.2850), (
|
||||
f"Expected buy price 0.2850, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_sell_price_getter_reads_tibber_snapshot(energy_db) -> None:
|
||||
"""sell_price_now value_getter must return the 'sell' price from tibber snapshot."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 1, 1, 12, 15, tzinfo=timezone.utc)
|
||||
tibber_pricing = {
|
||||
"kind": "tibber",
|
||||
"buy": "0.3100",
|
||||
"sell": "0.1500",
|
||||
"energy_tax": "0.1234",
|
||||
"sell_adjust": "0.0100",
|
||||
"total": "0.3100",
|
||||
"tibber_price_starts_at": t0.isoformat(),
|
||||
"tibber_price_id": 2,
|
||||
}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_period(
|
||||
session,
|
||||
period_start=t0,
|
||||
import_cost=0.12,
|
||||
export_revenue=0.07,
|
||||
currency="EUR",
|
||||
pricing=tibber_pricing,
|
||||
degraded=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
sell_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.sell_price_now"
|
||||
)
|
||||
value = sell_entry.entity.value_getter(session)
|
||||
|
||||
assert value == pytest.approx(0.1500), (
|
||||
f"Expected sell price 0.1500, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_buy_price_getter_reads_manual_snapshot(energy_db) -> None:
|
||||
"""buy_price_now value_getter must return 'buy_normal' from manual pricing snapshot."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 2, 1, 8, 0, tzinfo=timezone.utc)
|
||||
manual_pricing = {
|
||||
"kind": "manual",
|
||||
"buy_dal": "0.2500",
|
||||
"buy_normal": "0.2700",
|
||||
"sell_dal": "0.0900",
|
||||
"sell_normal": "0.0950",
|
||||
"energy_tax": "0.1234",
|
||||
"ode": "0.0015",
|
||||
}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_period(
|
||||
session,
|
||||
period_start=t0,
|
||||
import_cost=0.08,
|
||||
export_revenue=0.03,
|
||||
currency="EUR",
|
||||
pricing=manual_pricing,
|
||||
degraded=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
buy_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.buy_price_now"
|
||||
)
|
||||
value = buy_entry.entity.value_getter(session)
|
||||
|
||||
# buy_normal is the representative buy price for manual strategy
|
||||
assert value == pytest.approx(0.2700), (
|
||||
f"Expected buy_normal 0.2700, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_sell_price_getter_reads_manual_snapshot(energy_db) -> None:
|
||||
"""sell_price_now value_getter must return 'sell_normal' from manual pricing snapshot."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 2, 1, 8, 15, tzinfo=timezone.utc)
|
||||
manual_pricing = {
|
||||
"kind": "manual",
|
||||
"buy_dal": "0.2500",
|
||||
"buy_normal": "0.2700",
|
||||
"sell_dal": "0.0900",
|
||||
"sell_normal": "0.0950",
|
||||
"energy_tax": "0.1234",
|
||||
"ode": "0.0015",
|
||||
}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_period(
|
||||
session,
|
||||
period_start=t0,
|
||||
import_cost=0.09,
|
||||
export_revenue=0.04,
|
||||
currency="EUR",
|
||||
pricing=manual_pricing,
|
||||
degraded=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
sell_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.sell_price_now"
|
||||
)
|
||||
value = sell_entry.entity.value_getter(session)
|
||||
|
||||
# sell_normal is the representative sell price for manual strategy
|
||||
assert value == pytest.approx(0.0950), (
|
||||
f"Expected sell_normal 0.0950, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. value_getter: cumulative SUM — degraded rows excluded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_import_cost_total_sums_non_degraded_rows(energy_db) -> None:
|
||||
"""import_cost_total must return SUM of non-degraded import_cost values only."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 3, 1, 6, 0, tzinfo=timezone.utc)
|
||||
t1 = datetime(2025, 3, 1, 6, 15, tzinfo=timezone.utc)
|
||||
t2 = datetime(2025, 3, 1, 6, 30, tzinfo=timezone.utc)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
# Two good rows: 0.10 + 0.20 = 0.30
|
||||
_make_period(session, period_start=t0, import_cost=0.10, degraded=False)
|
||||
_make_period(session, period_start=t1, import_cost=0.20, degraded=False)
|
||||
# One degraded row (import_cost=0.0, should be excluded):
|
||||
_make_period(session, period_start=t2, import_cost=0.0, degraded=True)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
import_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
||||
)
|
||||
value = import_entry.entity.value_getter(session)
|
||||
|
||||
assert value == pytest.approx(0.30), (
|
||||
f"Expected cumulative import_cost 0.30 (non-degraded only), got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_export_revenue_total_sums_non_degraded_rows(energy_db) -> None:
|
||||
"""export_revenue_total must return SUM of non-degraded export_revenue values only."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 3, 2, 6, 0, tzinfo=timezone.utc)
|
||||
t1 = datetime(2025, 3, 2, 6, 15, tzinfo=timezone.utc)
|
||||
t2 = datetime(2025, 3, 2, 6, 30, tzinfo=timezone.utc)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
# Two good rows: 0.05 + 0.07 = 0.12
|
||||
_make_period(session, period_start=t0, export_revenue=0.05, degraded=False)
|
||||
_make_period(session, period_start=t1, export_revenue=0.07, degraded=False)
|
||||
# One degraded row (should be excluded):
|
||||
_make_period(session, period_start=t2, export_revenue=0.0, degraded=True)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
export_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
||||
)
|
||||
value = export_entry.entity.value_getter(session)
|
||||
|
||||
assert value == pytest.approx(0.12), (
|
||||
f"Expected cumulative export_revenue 0.12 (non-degraded only), got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_cumulative_getter_excludes_degraded_import_cost_row(energy_db) -> None:
|
||||
"""A degraded row with non-zero import_cost must NOT contribute to the cumulative sum.
|
||||
|
||||
This verifies the exclusion filter on degraded=True rows.
|
||||
(In practice compute_period writes 0.0 for degraded rows; but if a row was
|
||||
previously successful and then set degraded, its import_cost could be non-zero.)
|
||||
"""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 4, 1, 10, 0, tzinfo=timezone.utc)
|
||||
t1 = datetime(2025, 4, 1, 10, 15, tzinfo=timezone.utc)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_period(session, period_start=t0, import_cost=0.50, degraded=False)
|
||||
# A row that is degraded but somehow has a non-zero import_cost (edge case):
|
||||
_make_period(session, period_start=t1, import_cost=0.99, degraded=True)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
import_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
||||
)
|
||||
value = import_entry.entity.value_getter(session)
|
||||
|
||||
# Only the non-degraded row should contribute: 0.50
|
||||
assert value == pytest.approx(0.50), (
|
||||
f"Degraded row must not be included in SUM; expected 0.50, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. value_getter returns None when no non-degraded period exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_buy_price_getter_returns_none_with_no_periods(energy_db) -> None:
|
||||
"""buy_price_now value_getter must return None when no non-degraded periods exist."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
buy_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.buy_price_now"
|
||||
)
|
||||
value = buy_entry.entity.value_getter(session)
|
||||
|
||||
assert value is None, f"Expected None with no periods, got {value!r}"
|
||||
|
||||
|
||||
def test_sell_price_getter_returns_none_with_no_periods(energy_db) -> None:
|
||||
"""sell_price_now value_getter must return None when no non-degraded periods exist."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
sell_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.sell_price_now"
|
||||
)
|
||||
value = sell_entry.entity.value_getter(session)
|
||||
|
||||
assert value is None, f"Expected None with no periods, got {value!r}"
|
||||
|
||||
|
||||
def test_import_cost_getter_returns_none_with_no_non_degraded_periods(energy_db) -> None:
|
||||
"""import_cost_total value_getter must return None when only degraded rows exist."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2025, 5, 1, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
# Only a degraded row → SUM returns None (no rows to aggregate)
|
||||
_make_period(session, period_start=t0, import_cost=0.0, degraded=True)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
import_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
||||
)
|
||||
value = import_entry.entity.value_getter(session)
|
||||
|
||||
assert value is None, (
|
||||
f"Expected None when only degraded rows exist, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_export_revenue_getter_returns_none_with_no_periods(energy_db) -> None:
|
||||
"""export_revenue_total value_getter must return None when no periods at all."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
export_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
||||
)
|
||||
value = export_entry.entity.value_getter(session)
|
||||
|
||||
assert value is None, f"Expected None with no periods, got {value!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. MQTT not enabled → publish_states is a no-op
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_states_noop_when_mqtt_disabled() -> None:
|
||||
"""publish_states must be a no-op and not raise when MQTT is disabled."""
|
||||
settings = _make_settings(mqtt_enabled=False, ha_discovery_enabled=True)
|
||||
mock_mgr = _make_mock_manager(is_connected=False)
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
|
||||
):
|
||||
from app.services.ha_discovery import publish_states
|
||||
from sqlalchemy import create_engine as _ce
|
||||
|
||||
eng = _ce("sqlite:///:memory:")
|
||||
with Session(eng) as session:
|
||||
publish_states(session) # must not raise
|
||||
|
||||
mock_mgr.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_publish_states_noop_when_ha_discovery_disabled() -> None:
|
||||
"""publish_states must be a no-op when ha_discovery_enabled=False."""
|
||||
settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=False)
|
||||
mock_mgr = _make_mock_manager(is_connected=True)
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
|
||||
):
|
||||
from app.services.ha_discovery import publish_states
|
||||
from sqlalchemy import create_engine as _ce
|
||||
|
||||
eng = _ce("sqlite:///:memory:")
|
||||
with Session(eng) as session:
|
||||
publish_states(session) # must not raise
|
||||
|
||||
mock_mgr.publish.assert_not_called()
|
||||
|
||||
|
||||
def test_publish_states_noop_when_not_connected() -> None:
|
||||
"""publish_states must be a no-op when MQTT is configured but not connected."""
|
||||
settings = _make_settings(mqtt_enabled=True, ha_discovery_enabled=True)
|
||||
mock_mgr = _make_mock_manager(is_connected=False)
|
||||
|
||||
with (
|
||||
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
|
||||
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
|
||||
):
|
||||
from app.services.ha_discovery import publish_states
|
||||
from sqlalchemy import create_engine as _ce
|
||||
|
||||
eng = _ce("sqlite:///:memory:")
|
||||
with Session(eng) as session:
|
||||
publish_states(session) # must not raise
|
||||
|
||||
mock_mgr.publish.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Integration: build_discovery_payload does not IndexError for energy entities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -> None:
|
||||
"""build_discovery_payload must NOT raise IndexError for energy_cost entities.
|
||||
|
||||
Validates that the 2-element identifiers=('energy-cost', 'energy-cost') tuple
|
||||
satisfies the ha_discovery.py requirement to access identifiers[1] as node_id.
|
||||
This is a regression guard: if _energy_cost_provider used a 1-element tuple,
|
||||
this call would raise IndexError.
|
||||
"""
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services.ha_discovery import build_discovery_payload
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4, "Expected 4 energy_cost entities in catalog"
|
||||
|
||||
for entry in energy_entries:
|
||||
# Must not raise — specifically no IndexError from identifiers[1]
|
||||
topic, config = build_discovery_payload(entry.entity, "homeassistant")
|
||||
|
||||
# Basic sanity checks on the result.
|
||||
# Note: ha_discovery._node_id() replaces hyphens with underscores, so
|
||||
# identifiers[1]="energy-cost" → node_id="energy_cost" in the topic.
|
||||
assert "energy_cost" in topic, (
|
||||
f"Expected 'energy_cost' (hyphen→underscore) in discovery topic, got {topic!r}"
|
||||
)
|
||||
assert topic.endswith("/config"), (
|
||||
f"Discovery topic must end with /config, got {topic!r}"
|
||||
)
|
||||
assert "unique_id" in config
|
||||
# unique_id = "<identifiers[1]>_<key.replace('.','_')>"
|
||||
# identifiers[1]="energy-cost" (hyphens NOT replaced in unique_id, only in node_id)
|
||||
assert "energy" in config["unique_id"], (
|
||||
f"unique_id must contain 'energy', got {config['unique_id']!r}"
|
||||
)
|
||||
assert "device" in config
|
||||
assert "energy-cost" in config["device"]["identifiers"]
|
||||
|
||||
|
||||
def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
|
||||
"""Discovery topic node_id for energy entities must be 'energy-cost' (hyphens → underscores)."""
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
from app.services.ha_discovery import build_discovery_payload
|
||||
|
||||
device = DeviceInfo(identifiers=("energy-cost", "energy-cost"), name="Energy Cost")
|
||||
entity = ExposableEntity(
|
||||
key="energy.import_cost_total",
|
||||
component="sensor",
|
||||
device=device,
|
||||
device_class="monetary",
|
||||
unit="EUR",
|
||||
name="Energy Import Cost Total",
|
||||
state_class="total_increasing",
|
||||
)
|
||||
|
||||
topic, config = build_discovery_payload(entity, prefix="homeassistant")
|
||||
|
||||
# node_id: hyphens replaced with underscores → "energy_cost"
|
||||
expected_node = "energy_cost"
|
||||
assert f"/{expected_node}/" in topic, (
|
||||
f"Expected node_id 'energy_cost' in topic {topic!r}"
|
||||
)
|
||||
assert topic.startswith("homeassistant/sensor/"), (
|
||||
f"Topic must start with homeassistant/sensor/, got {topic!r}"
|
||||
)
|
||||
assert "state_class" in config
|
||||
assert config["state_class"] == "total_increasing"
|
||||
assert config.get("device_class") == "monetary"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Key stability: fixed strings, not derived from mutable data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_energy_entity_keys_are_stable_fixed_strings(energy_db) -> None:
|
||||
"""Entity keys must be fixed strings, not derived from DB ids or session state."""
|
||||
from app.integrations.expose import build_catalog
|
||||
from datetime import timedelta
|
||||
|
||||
t0 = datetime(2025, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
t1 = t0 + timedelta(minutes=15)
|
||||
|
||||
# Insert two periods with different currencies to ensure key does not drift
|
||||
with Session(energy_db) as session:
|
||||
_make_period(
|
||||
session,
|
||||
period_start=t0,
|
||||
currency="EUR",
|
||||
pricing={"kind": "tibber", "buy": "0.25", "sell": "0.10"},
|
||||
degraded=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog1 = build_catalog(session)
|
||||
keys1 = {e.entity.key for e in catalog1 if e.entity.key.startswith("energy.")}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_period(
|
||||
session,
|
||||
period_start=t1,
|
||||
currency="EUR",
|
||||
pricing={"kind": "tibber", "buy": "0.30", "sell": "0.12"},
|
||||
degraded=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog2 = build_catalog(session)
|
||||
keys2 = {e.entity.key for e in catalog2 if e.entity.key.startswith("energy.")}
|
||||
|
||||
# Keys must be identical across both builds (price changed, key must not)
|
||||
assert keys1 == keys2, (
|
||||
f"Entity keys must be stable across different data states. "
|
||||
f"First: {keys1!r}, Second: {keys2!r}"
|
||||
)
|
||||
|
||||
expected_keys = {
|
||||
"energy.buy_price_now",
|
||||
"energy.sell_price_now",
|
||||
"energy.import_cost_total",
|
||||
"energy.export_revenue_total",
|
||||
}
|
||||
assert keys1 == expected_keys, (
|
||||
f"Energy keys must be exactly {expected_keys!r}, got {keys1!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Provider registered: energy_cost entities co-exist with modbus entities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_energy_provider_registered_alongside_modbus(energy_db) -> None:
|
||||
"""Both modbus and energy_cost providers must be registered and produce entities."""
|
||||
from app.models.modbus import ModbusDevice
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(energy_db) as session:
|
||||
device = ModbusDevice(
|
||||
uuid="cccccccc-0000-0000-0000-000000000099",
|
||||
friendly_name="Co-exist Meter",
|
||||
host="10.0.0.1",
|
||||
port=502,
|
||||
unit_id=1,
|
||||
profile="sdm120",
|
||||
poll_interval_s=5,
|
||||
enabled=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(device)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
all_keys = {e.entity.key for e in catalog}
|
||||
|
||||
# Modbus entities present
|
||||
assert any(k.startswith("modbus.") for k in all_keys), (
|
||||
"Expected modbus entities in catalog"
|
||||
)
|
||||
# Energy entities present
|
||||
assert "energy.import_cost_total" in all_keys, (
|
||||
"Expected energy.import_cost_total in catalog"
|
||||
)
|
||||
assert "energy.export_revenue_total" in all_keys, (
|
||||
"Expected energy.export_revenue_total in catalog"
|
||||
)
|
||||
@@ -203,16 +203,29 @@ def test_register_provider_direct_call():
|
||||
|
||||
|
||||
def test_build_catalog_empty_with_no_devices(expose_db):
|
||||
"""build_catalog with no enabled devices must return an empty list."""
|
||||
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
||||
|
||||
The energy_cost provider is always registered and always produces its 4 entities
|
||||
regardless of device state, so the catalog will not be empty. This test checks
|
||||
that no *modbus* entities are present when there are no enabled modbus devices.
|
||||
"""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(expose_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
# The modbus provider will find no enabled devices; other providers may
|
||||
# add entries only if registered. Since we only register the modbus
|
||||
# provider at module load, the result should be empty.
|
||||
assert catalog == []
|
||||
# The modbus provider finds no enabled devices → no modbus entities.
|
||||
# The energy_cost provider always produces 4 entities, so the catalog is non-empty.
|
||||
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
||||
assert modbus_entities == [], (
|
||||
"Expected no modbus entities when no modbus devices are enabled"
|
||||
)
|
||||
|
||||
# The 4 energy_cost entities should always be present.
|
||||
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
||||
assert len(energy_keys) == 4, (
|
||||
f"Expected exactly 4 energy_cost entities, got {energy_keys!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_build_catalog_produces_entities_for_enabled_device(expose_db):
|
||||
|
||||
Reference in New Issue
Block a user