From a07f5d57cbac9e465fcbdb9fe7d1a949e26ac994 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 20 Apr 2026 11:11:47 +0200 Subject: [PATCH] fix 500 --- backend/app/lifespan.py | 16 ++++++ backend/app/routers/full_status.py | 4 +- backend/services/heartbeat_service.py | 2 +- .../test_full_status_heartbeat_parsing.py | 53 +++++++++++++++++++ db/routines/R__044_fn_set_mode.sql | 24 --------- db/routines/R__077_fn_update_heartbeat.sql | 22 ++++++++ 6 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_full_status_heartbeat_parsing.py create mode 100644 db/routines/R__077_fn_update_heartbeat.sql diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index 85f0dc0..28b50c5 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -54,6 +54,22 @@ async def lifespan(app: FastAPI): set_pg_pool(pg_pool) app.state.pg_pool = pg_pool + # Fail fast if Flyway routines are missing (otherwise heartbeat silently goes stale in FE). + async with pg_pool.acquire() as conn: + fn_ok = await conn.fetchval( + """ + select exists( + select 1 + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'ems' + and p.proname = 'fn_update_heartbeat' + ) + """ + ) + if not fn_ok: + raise RuntimeError("Missing DB routine: ems.fn_update_heartbeat") + app.state.ws_log_handler = WSLogHandler() app.state.ws_log_handler.setLevel(logging.INFO) logging.getLogger().addHandler(app.state.ws_log_handler) diff --git a/backend/app/routers/full_status.py b/backend/app/routers/full_status.py index acc9e0c..0bd917b 100644 --- a/backend/app/routers/full_status.py +++ b/backend/app/routers/full_status.py @@ -126,9 +126,9 @@ async def get_site_status_full( tomorrow_slots = int(bundle.get("tomorrow_price_slot_count") or 0) now_utc = datetime.now(timezone.utc) - hb_last = hb_row.get("last_seen") if hb_row else None + hb_last = _parse_ts(hb_row.get("last_seen") if hb_row else None) hb_age = _age_seconds(hb_last) - inv_measured = inv_row.get("measured_at") if inv_row else None + inv_measured = _parse_ts(inv_row.get("measured_at") if inv_row else None) inv_age = _age_seconds(inv_measured) next_start, next_bat = _next_plan_interval(intervals, now_utc) diff --git a/backend/services/heartbeat_service.py b/backend/services/heartbeat_service.py index dac5469..3b60c76 100644 --- a/backend/services/heartbeat_service.py +++ b/backend/services/heartbeat_service.py @@ -61,7 +61,7 @@ async def send_heartbeat( status = "ok" if (not endpoint or loxone_ok) else "degraded" await db.execute( - "SELECT ems.fn_update_heartbeat($1, $2, $3)", + "select ems.fn_update_heartbeat($1, $2, $3)", site_id, status, EMS_BACKEND_VERSION, diff --git a/backend/tests/test_full_status_heartbeat_parsing.py b/backend/tests/test_full_status_heartbeat_parsing.py new file mode 100644 index 0000000..24ae19a --- /dev/null +++ b/backend/tests/test_full_status_heartbeat_parsing.py @@ -0,0 +1,53 @@ +import asyncio + + +class _FakeAcquire: + def __init__(self, conn): + self._conn = conn + + async def __aenter__(self): + return self._conn + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakePool: + def __init__(self, conn): + self._conn = conn + + def acquire(self): + return _FakeAcquire(self._conn) + + +def test_status_full_parses_heartbeat_and_inverter_timestamps(monkeypatch): + # Regression: /status/full used to pass string timestamps into _age_seconds() + # which expects datetime and accesses .tzinfo. + from app.routers import full_status + + async def _fake_fetch_json(conn, sql, *args): + assert "fn_site_full_status" in sql + return { + "site": {"code": "X"}, + "operating_mode": {"mode_code": "AUTO"}, + "heartbeat": {"last_seen": "2026-04-20T08:56:36.186Z"}, + "inverter_latest": {"measured_at": "2026-04-20T08:56:31.165Z"}, + "ev_chargers": [], + "heat_pump_latest": None, + "battery_limits": {}, + "active_plan": None, + "planning_intervals": [], + "tomorrow_price_slot_count": 96, + } + + monkeypatch.setattr(full_status, "fetch_json", _fake_fetch_json) + + out = asyncio.run( + full_status.get_site_status_full(site_id=2, pool=_FakePool(conn=object())) + ) + assert isinstance(out, dict) + assert out["heartbeat"]["last_seen"] is not None + assert out["heartbeat"]["age_seconds"] is not None + assert out["telemetry"]["inverter"]["measured_at"] is not None + assert out["telemetry"]["inverter"]["age_seconds"] is not None + diff --git a/db/routines/R__044_fn_set_mode.sql b/db/routines/R__044_fn_set_mode.sql index 56a2f06..bc3d013 100644 --- a/db/routines/R__044_fn_set_mode.sql +++ b/db/routines/R__044_fn_set_mode.sql @@ -142,27 +142,3 @@ $$; COMMENT ON FUNCTION ems.fn_expire_modes() IS 'Zkontroluje všechny lokality s dočasným režimem (valid_until IS NOT NULL) a přepne zpět ty s prosahlým časem. Volat každou minutu jako scheduled task. Vrátí řádky (site_id, site_code, old_mode, new_mode) pro každé provedené přepnutí — backend z toho pošle Discord notifikace.'; - --- ============================================================ - -CREATE OR REPLACE FUNCTION ems.fn_update_heartbeat( - p_site_id INT, - p_status TEXT DEFAULT 'ok', - p_ems_version TEXT DEFAULT NULL -) -RETURNS VOID -LANGUAGE sql -AS $$ - INSERT INTO ems.site_heartbeat (site_id, last_seen, status, ems_version) - VALUES (p_site_id, now(), p_status, p_ems_version) - ON CONFLICT (site_id) DO UPDATE SET - last_seen = now(), - status = EXCLUDED.status, - ems_version = COALESCE(EXCLUDED.ems_version, ems.site_heartbeat.ems_version); -$$; - -COMMENT ON FUNCTION ems.fn_update_heartbeat(INT, TEXT, TEXT) IS -'Aktualizuje informační heartbeat záznam EMS pro danou lokalitu. -Volat každou minutu z backend service po úspěšném odeslání pulzu do Loxone. -Slouží pouze pro EMS dashboard – Loxone watchdog nezávisí na této tabulce, -sleduje HTTP pulzy přímo a nezávisle na dostupnosti DB.'; diff --git a/db/routines/R__077_fn_update_heartbeat.sql b/db/routines/R__077_fn_update_heartbeat.sql new file mode 100644 index 0000000..88494a1 --- /dev/null +++ b/db/routines/R__077_fn_update_heartbeat.sql @@ -0,0 +1,22 @@ +create or replace function ems.fn_update_heartbeat( + p_site_id int, + p_status text default 'ok', + p_ems_version text default null +) +returns void +language sql +as $$ + insert into ems.site_heartbeat (site_id, last_seen, status, ems_version) + values (p_site_id, now(), p_status, p_ems_version) + on conflict (site_id) do update set + last_seen = now(), + status = excluded.status, + ems_version = coalesce(excluded.ems_version, ems.site_heartbeat.ems_version); +$$; + +comment on function ems.fn_update_heartbeat(int, text, text) is +'Aktualizuje informační heartbeat záznam EMS pro danou lokalitu. +Volat každou minutu z backend service po úspěšném odeslání pulzu do Loxone. +Slouží pouze pro EMS dashboard – Loxone watchdog nezávisí na této tabulce, +sleduje HTTP pulzy přímo a nezávisle na dostupnosti DB.'; +