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:
2026-06-23 22:44:41 +02:00
parent d35ac7d752
commit c61fc2d4ba
5 changed files with 1040 additions and 6 deletions
+229
View File
@@ -348,3 +348,232 @@ def _modbus_provider(session: Session) -> list[ExposableEntity]:
# Register the modbus provider at module load time. # Register the modbus provider at module load time.
register_provider(_modbus_provider) 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)
+9
View File
@@ -109,6 +109,15 @@ def _run_scheduled_energy_cost() -> None:
session: Session = session_local() session: Session = session_local()
try: try:
compute_closed_periods(session) 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: except Exception:
logger.exception("_run_scheduled_energy_cost: unexpected error") logger.exception("_run_scheduled_energy_cost: unexpected error")
finally: finally:
+1 -1
View File
@@ -393,7 +393,7 @@ Phase DAPI + 前端)
- **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0<effective_to`job 不崩。 - **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0<effective_to`job 不崩。
### M6-T08 — energy_cost expose provider + state 发布 ### M6-T08 — energy_cost expose provider + state 发布
- **Status**: `todo` · **Depends**: M6-T07 - **Status**: `done` · **Depends**: M6-T07
- **Context**: 复用 M5 expose/Discovery,发当前买/卖价 + 累计成本/收入。 - **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` - **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`。 - **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`。
+783
View File
@@ -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"
)
+18 -5
View File
@@ -203,16 +203,29 @@ def test_register_provider_direct_call():
def test_build_catalog_empty_with_no_devices(expose_db): 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 from app.integrations.expose import build_catalog
with Session(expose_db) as session: with Session(expose_db) as session:
catalog = build_catalog(session) catalog = build_catalog(session)
# The modbus provider will find no enabled devices; other providers may # The modbus provider finds no enabled devices → no modbus entities.
# add entries only if registered. Since we only register the modbus # The energy_cost provider always produces 4 entities, so the catalog is non-empty.
# provider at module load, the result should be empty. modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
assert catalog == [] 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): def test_build_catalog_produces_entities_for_enabled_device(expose_db):