FUE-T06: trigger HA discovery republish after meter declare/update
This commit is contained in:
@@ -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:
|
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
|
||||||
"""Return the meter with the given id or raise 404."""
|
"""Return the meter with the given id or raise 404."""
|
||||||
meter: Optional[Meter] = db.get(Meter, meter_id)
|
meter: Optional[Meter] = db.get(Meter, meter_id)
|
||||||
@@ -217,6 +235,11 @@ def declare_energy_meter(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_meter)
|
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(
|
logger.info(
|
||||||
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
|
||||||
body.commodity,
|
body.commodity,
|
||||||
@@ -294,6 +317,11 @@ def patch_energy_meter(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(meter)
|
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(
|
logger.info(
|
||||||
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
|
||||||
meter_id,
|
meter_id,
|
||||||
|
|||||||
@@ -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()
|
@pytest.fixture()
|
||||||
def meters_client(auth_database):
|
def meters_client(auth_database):
|
||||||
"""TestClient + SQLAlchemy engine for Meter API tests."""
|
"""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:
|
if ended is not None and ended.tzinfo is None:
|
||||||
ended = ended.replace(tzinfo=UTC)
|
ended = ended.replace(tzinfo=UTC)
|
||||||
assert ended == t1_earlier
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user