From 815a2330493063ef77adf246827052a531ed45ef Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 12 Jun 2026 19:06:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(telemetry):=20idle-skip=20z=C3=A1pis=C5=AF?= =?UTF-8?q?=20=E2=80=94=20neukl=C3=A1dat=201min=20=C5=99=C3=A1dky=20idle?= =?UTF-8?q?=20za=C5=99=C3=ADzen=C3=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slabý server: dict (tabulka, asset_id) → (signature, last_stored_at); _idle_skip ukládá vždy při změně signature, aktivitě, po startu procesu a heartbeat po > 840 s (každý 15min bucket má ≥ 1 řádek). - telemetry_ev_charger: aktivní = status != 'available' nebo power > 50 W; signature (status, výkon na 100 W) - telemetry_pool_pump: aktivní = is_on nebo power > 5 W (ON řádky 1/min kvůli on_minutes); signature (is_on, výkon na 10 W) - telemetry_loxone_sensor: jen změna hodnoty ≥ 0.1 / heartbeat - telemetry_heat_pump: aktivní = mode != 'off' nebo defrost; signature (mode, teploty na 0.2 °C) - telemetry_inverter: beze změny — NIKDY se nepřeskakuje (audit Wh split, baseline, SoC plánovače) Detekce příjezdu/odjezdu EV: previous_status přesunut z posledního řádku DB do in-memory _EV_LAST_STATUS (po startu seed z vw_latest_ev_charger — přechod během výpadku se pozná, prázdná DB nevystřelí falešný příjezd); fn_ev_session_transition se volá jen při změně statusu. PoolCard: staleness práh 5 → 16 min (> heartbeat 840 s). Docs: telemetry.md sekce „Idle-skip zápisů" (pravidla pro nové čtecí dotazy: sumy/gapfill, ne avg přes řádky), planning-changelog (TUV °C/min). Testy: tests/test_telemetry_idle_skip.py — _idle_skip jednotkově + EV arrival/departure přežije skip i restart procesu (303 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/services/telemetry_collector.py | 168 +++++++++++++++---- backend/tests/test_telemetry_idle_skip.py | 189 ++++++++++++++++++++++ docs/04-modules/telemetry.md | 51 ++++++ docs/planning-changelog.md | 22 +++ frontend/src/components/PoolCard.tsx | 5 +- 5 files changed, 405 insertions(+), 30 deletions(-) create mode 100644 backend/tests/test_telemetry_idle_skip.py diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index db476b5..086e278 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -21,6 +21,57 @@ logger = logging.getLogger(__name__) #: příjezdu EV). Nastavuje run_telemetry_loop_wrapper. _BG_POOL: asyncpg.Pool | None = None +# --------------------------------------------------------------------------- +# Idle-skip zápisů telemetrie (slabý server) +# --------------------------------------------------------------------------- +#: Heartbeat: max. rozestup uložených vzorků při idle. 840 s < 900 s → každý +#: 15min bucket má ≥1 řádek (latest view, TUV teplota, teplota bazénu). +IDLE_SKIP_MAX_GAP_S = 840.0 + +#: klíč (tabulka, asset_id) → (signature, last_stored_at epoch s) +_IDLE_SKIP_STATE: dict[tuple[str, int], tuple[object, float]] = {} + + +def _idle_skip( + key: tuple[str, int], + signature: object, + is_active: bool, + now: float, + max_gap_s: float = IDLE_SKIP_MAX_GAP_S, +) -> bool: + """True = vzorek PŘESKOČIT (idle, signature beze změny, heartbeat neuplynul). + + Ukládá se vždy když: klíč je po startu procesu neznámý; zařízení je aktivní; + signature se změnila; nebo od posledního uložení uplynulo > max_gap_s. + Čtecí dotazy nad takto řidšími tabulkami musí používat sumy / gapfill, + ne avg přes přítomné řádky — viz docs/04-modules/telemetry.md (Idle-skip). + Střídač (telemetry_inverter) se NIKDY nepřeskakuje. + """ + state = _IDLE_SKIP_STATE.get(key) + if ( + state is None + or is_active + or signature != state[0] + or now - state[1] > max_gap_s + ): + _IDLE_SKIP_STATE[key] = (signature, now) + return False + return True + + +def _sig_round(value: float | None, step: float) -> float | None: + """Kvantizace hodnoty do idle-skip signature (None propouští).""" + if value is None: + return None + return round(round(value / step) * step, 3) + + +#: In-memory poslední pozorovaný status EV konektoru (charger_id, connector_id). +#: Detekce příjezdu/odjezdu nesmí stát na posledním ŘÁDKU v DB — idle-skip řádky +#: ředí. Po startu procesu se seeduje z vw_latest_ev_charger (přechod během +#: výpadku backendu se pozná; prázdná DB → žádný falešný příjezd). +_EV_LAST_STATUS: dict[tuple[int, int], str] = {} + async def _on_ev_arrival(site_id: int, charger_code: str) -> None: """Okamžitý replan + export po příjezdu EV (jinak by se čekalo až na */15). @@ -377,31 +428,43 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: code, frame["error_bits"], frame["warning_bits"], ) - previous_status = await db.fetchval( - """ - select status - from ems.telemetry_ev_charger - where charger_id = $1 and connector_id = $2 - order by measured_at desc - limit 1 - """, - charger_id, - connector_id, - ) + state_key = (int(charger_id), connector_id) + previous_status = _EV_LAST_STATUS.get(state_key) + if previous_status is None: + # Start procesu: seed z posledního uloženého řádku (stejná sémantika + # jako dřívější čtení z telemetry_ev_charger; read přes view). + previous_status = await db.fetchval( + """ + select status from ems.vw_latest_ev_charger + where charger_id = $1 and connector_id = $2 + """, + charger_id, + connector_id, + ) - await db.execute( - "select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8, $8::float8)", - site_id, - charger_id, - measured_at, - connector_id, - current_status, - int(frame["power_w"]), - float(frame["session_energy_kwh"]), - float(frame["current_a"]), - ) + power_w = int(frame["power_w"]) + is_active = current_status != "available" or power_w > 50 + signature = (current_status, round(power_w / 100.0) * 100) + if not _idle_skip( + ("telemetry_ev_charger", int(charger_id)), + signature, + is_active, + measured_at.timestamp(), + ): + await db.execute( + "select ems.fn_telemetry_ev_charger_sample($1::int, $2::int, $3::timestamptz, $4::int, $5::text, $6::int, $7::float8, $8::float8)", + site_id, + charger_id, + measured_at, + connector_id, + current_status, + power_w, + float(frame["session_energy_kwh"]), + float(frame["current_a"]), + ) - if previous_status is not None: + _EV_LAST_STATUS[state_key] = current_status + if previous_status is not None and str(previous_status) != current_status: await db.fetchval( "select ems.fn_ev_session_transition($1::int, $2::int, $3::text, $4::text, $5::timestamptz)", site_id, @@ -496,6 +559,30 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: ) continue + water_out_c = _mim_temp_c(iu[MIM_OFF_WATER_OUT]) + dhw_temp_c = _mim_temp_c(iu[MIM_OFF_DHW_TEMP]) + water_in_c = _mim_temp_c(iu[MIM_OFF_WATER_IN]) + room_temp_c = _mim_temp_c(iu[MIM_OFF_ROOM_TEMP]) + defrost = bool(defrost_raw) + # Idle-skip: TČ vypnuté a teploty (na 0.2 °C) beze změny → heartbeat; + # pomalý drift teplot zachytí heartbeat 840 s (TUV delta se normalizuje + # per minutu ve fn_update_tuv_usage_stats). + is_active = defrost or mode_txt not in ("off",) + signature = ( + mode_txt, + _sig_round(water_out_c, 0.2), + _sig_round(dhw_temp_c, 0.2), + _sig_round(water_in_c, 0.2), + _sig_round(room_temp_c, 0.2), + ) + if _idle_skip( + ("telemetry_heat_pump", int(row["id"])), + signature, + is_active, + measured_at.timestamp(), + ): + continue + await db.execute( "select ems.fn_telemetry_heat_pump_sample(" "$1::int, $2::int, $3::timestamptz, $4::int, $5::float8, $6::float8," @@ -505,12 +592,12 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: measured_at, None, # příkon: MIM neměří — doplní elektroměr (Shelly/Chint) None, # venkovní teplota: v MIM mapě není - _mim_temp_c(iu[MIM_OFF_WATER_OUT]), - _mim_temp_c(iu[MIM_OFF_DHW_TEMP]), + water_out_c, + dhw_temp_c, mode_txt, - _mim_temp_c(iu[MIM_OFF_WATER_IN]), - _mim_temp_c(iu[MIM_OFF_ROOM_TEMP]), - bool(defrost_raw), + water_in_c, + room_temp_c, + defrost, error_code, ) if error_code: @@ -561,6 +648,14 @@ async def poll_loxone_sensors(site_id: int, db: asyncpg.Connection) -> None: except Exception as e: logger.warning("Loxone sensor %s read failed: %s", r["loxone_name"], e) continue + # Idle-skip: čidlo nemá „aktivitu" — ukládá se změna ≥ 0.1 / heartbeat. + if _idle_skip( + ("telemetry_loxone_sensor", int(r["id"])), + round(value, 1), + False, + measured_at.timestamp(), + ): + continue await db.execute( """ insert into ems.telemetry_loxone_sensor (sensor_id, measured_at, value) @@ -786,6 +881,23 @@ async def poll_pool_pumps(site_id: int, db: asyncpg.Connection) -> None: logger.warning("pool pump %s (%s) read failed: %s", code, base, e) continue + # Idle-skip: zapnuté čerpadlo (is_on) se ukládá KAŽDOU minutu — + # vw_pool_pump_day_energy.on_minutes počítá ON řádky; vypnuté jen + # při změně / heartbeatu. + power_w = status.apower_w + is_active = bool(status.output) or (power_w is not None and power_w > 5) + signature = ( + bool(status.output), + None if power_w is None else int(round(power_w / 10.0)) * 10, + ) + if _idle_skip( + ("telemetry_pool_pump", int(row["id"])), + signature, + is_active, + measured_at.timestamp(), + ): + continue + await db.execute( "select ems.fn_telemetry_pool_pump_sample($1::int, $2::int, $3::timestamptz, $4::boolean, $5::int, $6::bigint)", site_id, diff --git a/backend/tests/test_telemetry_idle_skip.py b/backend/tests/test_telemetry_idle_skip.py new file mode 100644 index 0000000..f1680fc --- /dev/null +++ b/backend/tests/test_telemetry_idle_skip.py @@ -0,0 +1,189 @@ +"""Idle-skip zápisů telemetrie: _idle_skip + detekce příjezdu/odjezdu EV přes skip.""" + +from __future__ import annotations + +import asyncio +import unittest +from unittest.mock import AsyncMock, patch + +import services.telemetry_collector as tc +from services.telemetry_collector import ( + IDLE_SKIP_MAX_GAP_S, + TELTO_REG_BLOCK_COUNT, + _idle_skip, + _sig_round, +) + + +class IdleSkipTests(unittest.TestCase): + KEY = ("telemetry_test", 1) + + def setUp(self) -> None: + tc._IDLE_SKIP_STATE.clear() + + def test_first_sample_after_start_is_stored(self) -> None: + self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0)) + + def test_unchanged_idle_sample_is_skipped(self) -> None: + self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0)) + self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0)) + self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1120.0)) + + def test_signature_change_is_stored(self) -> None: + self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0)) + self.assertFalse(_idle_skip(self.KEY, ("a", 100), False, 1060.0)) + + def test_active_device_is_always_stored(self) -> None: + self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1000.0)) + self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1060.0)) + + def test_heartbeat_after_max_gap(self) -> None: + self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0)) + # přesně na hranici se ještě přeskakuje (> max_gap_s, ne >=) + self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S)) + self.assertFalse( + _idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 1.0) + ) + # heartbeat resetuje last_stored_at → další idle vzorek se zase přeskočí + self.assertTrue( + _idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 61.0) + ) + + def test_keys_are_independent(self) -> None: + other = ("telemetry_test", 2) + self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0)) + self.assertFalse(_idle_skip(other, ("a", 0), False, 1060.0)) + self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0)) + + def test_sig_round(self) -> None: + self.assertIsNone(_sig_round(None, 0.2)) + self.assertEqual(_sig_round(47.31, 0.2), 47.4) + self.assertEqual(_sig_round(47.29, 0.2), 47.2) + self.assertEqual(_sig_round(-3.1, 0.2), -3.2) + + +def _frame_regs(status_raw: int, power_w: int = 0) -> list[int]: + regs = [0] * TELTO_REG_BLOCK_COUNT + regs[0] = 230 + regs[6] = status_raw # 7 = available, 0 = charging + regs[38] = power_w + return regs + + +class _FakeBatch: + def __init__(self, regs: list[int]) -> None: + self._regs = regs + + async def __aenter__(self) -> "_FakeBatch": + return self + + async def __aexit__(self, *args: object) -> bool: + return False + + async def read_holding_registers(self, start: int, count: int) -> list[int]: + return self._regs + + +class _FakeModbusClient: + def __init__(self) -> None: + self.regs: list[int] = _frame_regs(7) + + def batch(self, unit_id: int) -> _FakeBatch: + return _FakeBatch(self.regs) + + +class _FakeDB: + """Min. asyncpg.Connection náhrada pro poll_ev_chargers.""" + + def __init__(self, latest_status: str | None) -> None: + self.latest_status = latest_status + self.inserts: list[tuple] = [] + self.transitions: list[tuple[str, str]] = [] + + async def fetch(self, query: str, *args: object) -> list[dict]: + return [{"id": 7, "code": "ev-charger-1", "host": "h", "port": 502, "unit_id": 1}] + + async def fetchval(self, query: str, *args: object): + if "vw_latest_ev_charger" in query: + return self.latest_status + if "fn_ev_session_transition" in query: + self.transitions.append((str(args[2]), str(args[3]))) + return None + raise AssertionError(f"unexpected fetchval: {query}") + + async def execute(self, query: str, *args: object) -> None: + assert "fn_telemetry_ev_charger_sample" in query + self.inserts.append(args) + + +class EvArrivalSurvivesIdleSkipTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + tc._IDLE_SKIP_STATE.clear() + tc._EV_LAST_STATUS.clear() + + async def _poll(self, db: _FakeDB, client: _FakeModbusClient) -> None: + with ( + patch.object(tc, "get_modbus_client", AsyncMock(return_value=client)), + patch.object(tc, "_on_ev_arrival", AsyncMock()) as arrival, + patch.object(tc, "_on_ev_departure", AsyncMock()) as departure, + ): + await tc.poll_ev_chargers(1, db) # type: ignore[arg-type] + await asyncio.sleep(0) # nechat doběhnout create_task + self.arrival_called = arrival.await_count > 0 + self.departure_called = departure.await_count > 0 + + async def test_arrival_detected_after_skipped_idle_samples(self) -> None: + db = _FakeDB(latest_status=None) + client = _FakeModbusClient() + + # 1. tick po startu: available → uloží se (prázdný stav), žádný příjezd + await self._poll(db, client) + self.assertEqual(len(db.inserts), 1) + self.assertFalse(self.arrival_called) + self.assertEqual(db.transitions, []) + + # 2.–3. tick: idle beze změny → řádky se přeskočí + await self._poll(db, client) + await self._poll(db, client) + self.assertEqual(len(db.inserts), 1) + + # 4. tick: EV se připojí (charging) → insert + transition + arrival hook + client.regs = _frame_regs(0, power_w=11000) + await self._poll(db, client) + self.assertEqual(len(db.inserts), 2) + self.assertEqual(db.transitions, [("available", "charging")]) + self.assertTrue(self.arrival_called) + + # 5. tick: nabíjí dál (aktivní) → ukládá se každou minutu, bez transition + await self._poll(db, client) + self.assertEqual(len(db.inserts), 3) + self.assertEqual(len(db.transitions), 1) + + # 6. tick: odpojení → departure + client.regs = _frame_regs(7) + await self._poll(db, client) + self.assertEqual(db.transitions[-1], ("charging", "available")) + self.assertTrue(self.departure_called) + + async def test_no_false_arrival_after_restart(self) -> None: + # restart procesu: in-memory stav prázdný, DB má poslední řádek 'charging', + # nabíječka stále nabíjí → žádný falešný příjezd + db = _FakeDB(latest_status="charging") + client = _FakeModbusClient() + client.regs = _frame_regs(0, power_w=11000) + await self._poll(db, client) + self.assertFalse(self.arrival_called) + self.assertEqual(db.transitions, []) + + async def test_transition_across_restart_detected(self) -> None: + # během výpadku backendu EV přijelo: DB 'available', teď 'charging' + db = _FakeDB(latest_status="available") + client = _FakeModbusClient() + client.regs = _frame_regs(0, power_w=11000) + await self._poll(db, client) + self.assertEqual(db.transitions, [("available", "charging")]) + self.assertTrue(self.arrival_called) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/04-modules/telemetry.md b/docs/04-modules/telemetry.md index 0b617cd..a2e7cea 100644 --- a/docs/04-modules/telemetry.md +++ b/docs/04-modules/telemetry.md @@ -127,6 +127,57 @@ Implementace: `backend/services/telemetry_collector.py` — `poll_inverter()` po --- +## Idle-skip zápisů (úspora zápisů na slabém serveru) + +Zařízení v klidu negeneruje nový 1min řádek — vzorek se zahodí, pokud je +zařízení **idle** a **signature** (kvantizovaný stav) se nezměnila. Mechanismus: +`_idle_skip(key, signature, is_active, now, max_gap_s=840)` v +`telemetry_collector.py`, modulový stav `(tabulka, asset_id) → (signature, +last_stored_at)`. + +**Uloží se vždy, když:** signature se změnila; zařízení je aktivní; od +posledního uložení uplynulo **> 840 s** (heartbeat — každý 15min bucket má +≥ 1 řádek); nebo jde o první vzorek po startu procesu. + +| Tabulka | Aktivní = ukládá se 1/min | Signature | +|---|---|---| +| `telemetry_ev_charger` | `status != 'available'` nebo `power_w > 50` | (status, výkon na 100 W) | +| `telemetry_pool_pump` | `is_on` nebo `power_w > 5` | (is_on, výkon na 10 W) | +| `telemetry_loxone_sensor` | nikdy (čidlo) — jen změna/heartbeat | hodnota na 0.1 | +| `telemetry_heat_pump` | `operating_mode != 'off'` nebo defrost | (mode, teploty na 0.2 °C) | +| `telemetry_inverter` | **vždy** — NIKDY se nepřeskakuje | — | + +Střídač se nepřeskakuje: je vstupem auditu (per-minute Wh split, 7 směrových +toků), baseline a SoC plánovače — hustá řada je nutná. + +**Detekce příjezdu/odjezdu EV** už nečte předchozí status z posledního řádku +DB (ten je při idle-skip řidší), ale z in-memory `_EV_LAST_STATUS`; po startu +procesu se seeduje z `vw_latest_ev_charger` (přechod během výpadku backendu se +pozná, prázdná DB nevystřelí falešný příjezd). `fn_ev_session_transition` se +volá jen při změně statusu. + +**Důsledky pro čtecí dotazy (POVINNÉ pravidlo):** nad idle-skip tabulkami +nesmí nový dotaz počítat `avg(power)` přes přítomné řádky — chybějící minuta +znamená „zařízení idle ≈ 0 W“ a avg by aktivitu části okna nadhodnotil. +Správně: **suma / počet minut okna** (`sum(power_w) / 15.0` pro 15min slot), +`time_bucket_gapfill`, nebo delta čítače energie. Poslední hodnota +(`vw_latest_*`, TUV teplota, teplota bazénu) je díky heartbeatu max. ~14 min +stará — staleness prahy musí být > 840 s (PoolCard používá 16 min). + +Přizpůsobené čtecí cesty: + +- `fn_fill_audit_interval` (R__019): EV a TČ `sum(power_w)/15` místo avg. +- `fn_update_tuv_usage_stats` (R__018): delta TUV normalizovaná na **°C/min** + délkou mezery mezi řádky (`gap_min`), mezery > 30 min vyloučeny. +- `fn_update_baseline_stats` (R__003): beze změny — `coalesce(avg, 0)` + v okně ±30 s; chybějící řádek = 0 W, což při idle-skip platí. +- `vw_pool_pump_day_energy` (R__097): `on_minutes` počítá ON řádky — drží, + protože zapnuté čerpadlo se ukládá každou minutu; kWh je delta čítače. +- `fn_pool_daily_runtime_min`, `fn_planning_site_context` (TUV), + `fn_load_planning_slots_full` (EV status): poslední hodnota — heartbeat stačí. + +--- + ## Agregace 1min → 15min Prováděna PostgreSQL funkcí `ems.fn_fill_audit_interval()` a navazujícími diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 207f546..0ba85b6 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,28 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-12 — idle-skip telemetrie: TUV delta normalizovaná na °C/min + +**Problém:** telemetry_collector nově přeskakuje 1min zápisy idle zařízení +(heartbeat 840 s — viz `telemetry.md`, sekce Idle-skip zápisů). Vstupy +plánovače čtené z těchto tabulek nesmí předpokládat hustou 1min řadu. + +**Mechanismus:** `fn_update_tuv_usage_stats` (R__018) počítá deltu TUV jako +`(temp − lag(temp)) / gap_min` (°C/min, mezery > 30 min vyloučeny) — pro +hustá 1min data numericky identické s původním per-row LAG; po idle-skip bez +až 14× nadhodnocení delty. Ostatní vstupy solveru (poslední TUV teplota v +`fn_planning_site_context`, poslední EV status v `fn_load_planning_slots_full`, +baseline stats) pokrývá heartbeat beze změny. Audit: EV/TČ `sum/15` v R__019. + +**Soubory:** `telemetry_collector.py`, `R__018_fn_extended_planning.sql`, +`R__019_fn_fill_audit_interval.sql`, `R__097_vw_pool_pump.sql`, `PoolCard.tsx`, +`docs/04-modules/telemetry.md`. + +**Ověření:** `tests/test_telemetry_idle_skip.py` (změna/aktivita/heartbeat/ +start; EV arrival přežije skip i restart procesu); celá sada 303 passed. + +--- + ## 2026-06-12 — v2 AKTIVNÍ v produkci + robustnostní trojice „nejistota jako cena" **Přepnutí (847015f):** `PLANNING_ENGINE_VERSION` default **v2** v deploy compose; v1 běží diff --git a/frontend/src/components/PoolCard.tsx b/frontend/src/components/PoolCard.tsx index a1bb955..3b05b64 100644 --- a/frontend/src/components/PoolCard.tsx +++ b/frontend/src/components/PoolCard.tsx @@ -51,9 +51,10 @@ export function PoolCard({ siteId }: { siteId: number }) { if (!latest) return null + // Telemetrie je idle-skip: vypnuté čerpadlo zapisuje jen heartbeat po 14 min. const stale = latest.measured_at != null && - Date.now() - new Date(latest.measured_at).getTime() > 5 * 60_000 + Date.now() - new Date(latest.measured_at).getTime() > 16 * 60_000 const running = latest.is_on === true const today = days[0] @@ -67,7 +68,7 @@ export function PoolCard({ siteId }: { siteId: number }) { className={`inline-block h-2.5 w-2.5 rounded-full ${ stale ? 'bg-slate-600' : running ? 'bg-emerald-400' : 'bg-slate-500' }`} - title={stale ? 'telemetrie >5 min stará' : running ? 'běží' : 'stojí'} + title={stale ? 'telemetrie >16 min stará' : running ? 'běží' : 'stojí'} />