diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 8cd512d..258d641 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -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: "/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) diff --git a/app/main.py b/app/main.py index 9a1b0de..28b1b20 100644 --- a/app/main.py +++ b/app/main.py @@ -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: diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index 577a4aa..4112a31 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -393,7 +393,7 @@ Phase D(API + 前端) - **Reviewer checklist**: Decimal 算钱;寄存器差为"末−初";进出口分开;周期边界 UTC 刻钟;快照不可变(重算才覆盖,且显式);版本选择按 `effective_from≤t0 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]="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" + ) diff --git a/tests/test_expose_catalog.py b/tests/test_expose_catalog.py index 0b886c0..29bafa8 100644 --- a/tests/test_expose_catalog.py +++ b/tests/test_expose_catalog.py @@ -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):