diff --git a/app/api/routes/api/meters.py b/app/api/routes/api/meters.py index a08d2da..2db6ac0 100644 --- a/app/api/routes/api/meters.py +++ b/app/api/routes/api/meters.py @@ -79,6 +79,24 @@ router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"]) # --------------------------------------------------------------------------- +def _trigger_discovery_republish(session: Session) -> None: + """Call publish_discovery after a meter write operation (best-effort). + + No-op if MQTT / discovery is not enabled or the broker is not connected + (publish_discovery guards internally). All errors are swallowed so that a + discovery failure never breaks the API response. + + Must be called **after** db.commit() so that publish_discovery sees the + final committed state of the meter table when it rebuilds the catalog. + """ + try: + from app.services.ha_discovery import publish_discovery + + publish_discovery(session) + except Exception: + logger.exception("_trigger_discovery_republish: publish_discovery raised an error") + + def _get_meter_or_404(db: Session, meter_id: int) -> Meter: """Return the meter with the given id or raise 404.""" meter: Optional[Meter] = db.get(Meter, meter_id) @@ -217,6 +235,11 @@ def declare_energy_meter( db.commit() db.refresh(new_meter) + # Trigger HA discovery re-publish so the new active meter's energy-cost + # device/sensor configuration is pushed to Home Assistant. Best-effort: + # failures are logged and swallowed; the API response is not affected. + _trigger_discovery_republish(db) + logger.info( "POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s", body.commodity, @@ -294,6 +317,11 @@ def patch_energy_meter( db.commit() db.refresh(meter) + # Trigger HA discovery re-publish so label renames on the active meter + # propagate to the HA device name. Best-effort: failures are logged and + # swallowed; the API response is not affected. + _trigger_discovery_republish(db) + logger.info( "PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s", meter_id, diff --git a/tests/test_api_meters.py b/tests/test_api_meters.py index 618420d..dca035d 100644 --- a/tests/test_api_meters.py +++ b/tests/test_api_meters.py @@ -72,6 +72,24 @@ def _declare_payload(**overrides) -> dict: # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def mock_publish_discovery(): + """Auto-mock publish_discovery for all tests in this module. + + The meters API now calls _trigger_discovery_republish (best-effort) after + every successful write. publish_discovery is lazy-imported inside that + helper, so we patch it at its canonical source path + (app.services.ha_discovery.publish_discovery). Tests that need to assert + the call receive this fixture explicitly; all others benefit from the + isolation it provides (no live MQTT broker required). + """ + with patch( + "app.services.ha_discovery.publish_discovery", + return_value=None, + ) as mock: + yield mock + + @pytest.fixture() def meters_client(auth_database): """TestClient + SQLAlchemy engine for Meter API tests.""" @@ -660,3 +678,78 @@ def test_patch_started_at_earlier_updates_boundary(meters_client): if ended is not None and ended.tzinfo is None: ended = ended.replace(tzinfo=UTC) assert ended == t1_earlier + + +# --------------------------------------------------------------------------- +# FUE-T06: HA discovery re-publish triggered after meter writes +# --------------------------------------------------------------------------- + + +def test_declare_meter_triggers_publish_discovery(meters_client, mock_publish_discovery): + """POST /api/energy/meters triggers publish_discovery after successful commit.""" + client, _ = meters_client + _login(client) + + t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + with patch("app.api.routes.api.meters.recompute_range", return_value=0): + resp = client.post( + "/api/energy/meters", + json=_declare_payload(label="Discovery Meter", started_at=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 201 + # publish_discovery must have been called exactly once after the declare. + mock_publish_discovery.assert_called_once() + + +def test_patch_meter_triggers_publish_discovery(meters_client, mock_publish_discovery): + """PATCH /api/energy/meters/{id} triggers publish_discovery after successful commit.""" + client, _ = meters_client + _login(client) + + t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + with patch("app.api.routes.api.meters.recompute_range", return_value=0): + resp = client.post( + "/api/energy/meters", + json=_declare_payload(label="Original Label", started_at=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + meter_id = resp.json()["id"] + # Reset call count: the POST above also called publish_discovery. + mock_publish_discovery.reset_mock() + + resp = client.patch( + f"/api/energy/meters/{meter_id}", + json={"label": "Renamed Label"}, + headers={"X-CSRF-Token": _CSRF}, + ) + assert resp.status_code == 200 + # publish_discovery must have been called exactly once after the PATCH. + mock_publish_discovery.assert_called_once() + + +def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client): + """publish_discovery raising an exception must NOT cause POST declare to return 500. + + The _trigger_discovery_republish helper is best-effort: it swallows all + exceptions so that a broken MQTT / discovery layer never breaks the API. + """ + client, _ = meters_client + _login(client) + + t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + with ( + patch("app.api.routes.api.meters.recompute_range", return_value=0), + patch( + "app.services.ha_discovery.publish_discovery", + side_effect=RuntimeError("MQTT broker unreachable"), + ), + ): + resp = client.post( + "/api/energy/meters", + json=_declare_payload(label="Best Effort Meter", started_at=t0.isoformat()), + headers={"X-CSRF-Token": _CSRF}, + ) + # The meter must be created successfully despite the discovery failure. + assert resp.status_code == 201 + assert resp.json()["label"] == "Best Effort Meter"