diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 7b5fb8e..1adcfaa 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -444,6 +444,61 @@ async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: ) +async def poll_loxone_sensors(site_id: int, db: asyncpg.Connection) -> None: + """Čidla z Loxone (teplota bazénu, akumulační nádrže…): GET /jdev/sps/io//state. + + Endpoint = site loxone_http; auth LOXONE_USER/PASSWORD (env). Hodnota + z LL.value ("23.5°" → 23.5). Bez čidel v ems.loxone_sensor no-op. + """ + rows = await db.fetch( + """ + select ls.id, ls.loxone_name, se.host, se.port, se.protocol + from ems.loxone_sensor ls + join ems.site_endpoint se + on se.site_id = ls.site_id and se.endpoint_type = 'loxone_http' and se.enabled + where ls.site_id = $1 and ls.enabled + """, + site_id, + ) + if not rows: + return + import os + import re as _re + + import httpx + + auth = None + user = os.getenv("LOXONE_USER") or "" + if user: + auth = (user, os.getenv("LOXONE_PASSWORD") or "") + measured_at = datetime.now(timezone.utc) + async with httpx.AsyncClient(timeout=5.0, auth=auth) as client: + for r in rows: + proto = (r["protocol"] or "http").lower() + port = int(r["port"] or (443 if proto == "https" else 80)) + url = f"{proto}://{r['host']}:{port}/jdev/sps/io/{r['loxone_name']}/state" + try: + resp = await client.get(url) + resp.raise_for_status() + raw = str((resp.json().get("LL") or {}).get("value", "")) + m = _re.search(r"-?\d+(?:[.,]\d+)?", raw) + if m is None: + continue + value = float(m.group(0).replace(",", ".")) + except Exception as e: + logger.warning("Loxone sensor %s read failed: %s", r["loxone_name"], e) + continue + await db.execute( + """ + insert into ems.telemetry_loxone_sensor (sensor_id, measured_at, value) + values ($1, $2, $3) on conflict do nothing + """, + int(r["id"]), + measured_at, + value, + ) + + async def run_telemetry_loop(conn: asyncpg.Connection) -> float: """Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep). @@ -460,6 +515,7 @@ async def run_telemetry_loop(conn: asyncpg.Connection) -> float: await poll_inverter(sid, conn) await poll_ev_chargers(sid, conn) await poll_heat_pump(sid, conn) + await poll_loxone_sensors(sid, conn) await poll_pool_pumps(sid, conn) except Exception as e: logger.error("Telemetry loop error site %s: %s", sid, e) diff --git a/db/migration/V092__pool_season_temp_loxone.sql b/db/migration/V092__pool_season_temp_loxone.sql new file mode 100644 index 0000000..c7b3a6a --- /dev/null +++ b/db/migration/V092__pool_season_temp_loxone.sql @@ -0,0 +1,55 @@ +-- Bazén: sezóna, délka filtrace dle teploty vody, čtení čidel z Loxone. +-- +-- Sezóna: přepínač = existující asset_pool_pump.schedulable (true = plánovač +-- řídí; konec sezóny -> false: telemetrie běží dál, signály/solver ne). +-- Viz docs/04-modules/pool-shelly.md § Sezóna. +-- +-- Teplotní funkce (slaná voda, chlorinátor potřebuje průtok; teplejší voda = +-- delší filtrace): runtime_min(t) = clamp(base + per_c × (t − ref), min, max). +-- Defaulty pro 30 m³ / 8 m³/h (obrátka 3.75 h): 20 °C → 4.5 h, 26 °C → 7.5 h, +-- 28 °C → 8.5 h, strop 10 h. Bez čidla / starého měření → fallback +-- daily_runtime_min. Vše per čerpadlo v DB (pravidlo 16). + +create table ems.loxone_sensor ( + id serial primary key, + site_id int not null references ems.site (id), + code text not null, + loxone_name text not null, + unit text, + enabled boolean not null default true, + notes text, + constraint uq_loxone_sensor_site_code unique (site_id, code) +); + +comment on table ems.loxone_sensor is +'Čidla čtená z Loxone Miniserveru (GET /jdev/sps/io//state přes loxone_http endpoint site). Telemetrie 60 s do telemetry_loxone_sensor.'; + +create table ems.telemetry_loxone_sensor ( + sensor_id int not null references ems.loxone_sensor (id), + measured_at timestamptz not null, + value numeric(10, 2), + primary key (sensor_id, measured_at) +); + +select create_hypertable( + 'ems.telemetry_loxone_sensor', + 'measured_at', + chunk_time_interval => interval '1 week', + if_not_exists => true +); + +comment on table ems.telemetry_loxone_sensor is +'1min hodnoty Loxone čidel (teplota bazénu, akumulační nádrže, ...).'; + +alter table ems.asset_pool_pump + add column if not exists water_temp_sensor_id int references ems.loxone_sensor (id), + add column if not exists runtime_ref_temp_c numeric(4, 1) not null default 20.0, + add column if not exists runtime_base_min int not null default 270, + add column if not exists runtime_min_per_c int not null default 30, + add column if not exists runtime_min_min int not null default 180, + add column if not exists runtime_max_min int not null default 600; + +comment on column ems.asset_pool_pump.water_temp_sensor_id is +'Loxone čidlo teploty vody; NULL = teplotní funkce vypnutá (fallback daily_runtime_min).'; +comment on column ems.asset_pool_pump.runtime_base_min is +'Minuty filtrace/den při runtime_ref_temp_c; nad ní +runtime_min_per_c za °C, clamp [runtime_min_min, runtime_max_min].'; diff --git a/db/routines/R__098_fn_pool_runtime.sql b/db/routines/R__098_fn_pool_runtime.sql new file mode 100644 index 0000000..d0663a0 --- /dev/null +++ b/db/routines/R__098_fn_pool_runtime.sql @@ -0,0 +1,45 @@ +-- Denní cíl filtrace bazénu: dle teploty vody (poslední měření < 24 h), +-- jinak fallback daily_runtime_min. Vstup pro solver (pool_on[t] budget). + +create or replace function ems.fn_pool_daily_runtime_min(p_pump_id int) +returns jsonb +language sql +stable +as $fn$ + select jsonb_build_object( + 'runtime_min', + coalesce( + case + when t.value is not null then + least( + pp.runtime_max_min, + greatest( + pp.runtime_min_min, + round( + pp.runtime_base_min + + pp.runtime_min_per_c * greatest(0, t.value - pp.runtime_ref_temp_c) + )::int + ) + ) + end, + pp.daily_runtime_min + ), + 'water_temp_c', t.value, + 'temp_measured_at', t.measured_at, + 'source', case when t.value is not null then 'temp_function' else 'static' end, + 'schedulable', pp.schedulable + ) + from ems.asset_pool_pump pp + left join lateral ( + select ts.value, ts.measured_at + from ems.telemetry_loxone_sensor ts + where ts.sensor_id = pp.water_temp_sensor_id + and ts.measured_at > now() - interval '24 hours' + order by ts.measured_at desc + limit 1 + ) t on true + where pp.id = p_pump_id; +$fn$; + +comment on function ems.fn_pool_daily_runtime_min is +'Cíl minut filtrace/den: clamp(base + per_c×(teplota−ref), min, max) z poslední teploty vody (<24 h), jinak daily_runtime_min. JSON s detailem pro UI/solver.'; diff --git a/docs/04-modules/pool-shelly.md b/docs/04-modules/pool-shelly.md index d72bc0f..400793f 100644 --- a/docs/04-modules/pool-shelly.md +++ b/docs/04-modules/pool-shelly.md @@ -119,3 +119,35 @@ V `solver_v2.py` je TČ spojitá proměnná `hp[t] ∈ [0, rated_w]` vstupujíc - Dashboard: `PoolCard` (frontend/src/components/PoolCard.tsx) pod StatePanel — stav (běží/stojí/stale), aktuální W, dnešní kWh a hodiny běhu, mini sloupce 7 dní. Poll 60 s. + +## Sezóna a teplotní funkce (2026-06-12, dev) + +**Sezóna = jeden přepínač** (`asset_pool_pump.schedulable`): +```sql +update ems.asset_pool_pump set schedulable = false where code = 'pool-pump-1'; -- konec sezóny +update ems.asset_pool_pump set schedulable = true where code = 'pool-pump-1'; -- začátek +``` +Off-season: telemetrie běží dál (zimování čerpadla vidíš), plánovač a signály ne. +(Později tlačítko v Konfiguraci.) + +**Délka filtrace dle teploty vody** (`fn_pool_daily_runtime_min`, V092): +`runtime = clamp(base + per_°C × (t − ref), min, max)`; defaulty pro 30 m³ / +8 m³/h (obrátka 3.75 h, slaná voda — chlorinátor potřebuje průtok): +20 °C→4.5 h, 24 °C→6.5 h, 28 °C→8.5 h, strop 10 h. Bez čidla / měření +staršího 24 h → fallback `daily_runtime_min`. Vše sloupce na čerpadle. + +**Čidlo teploty**: až přidáš do Loxonu, jeden INSERT: +```sql +insert into ems.loxone_sensor (site_id, code, loxone_name, unit) + values ((select id from ems.site where code='home-01'), 'pool-water-temp', '', '°C'); +update ems.asset_pool_pump set water_temp_sensor_id = + (select id from ems.loxone_sensor where code='pool-water-temp') where code='pool-pump-1'; +``` +Collector čte každou minutu (`poll_loxone_sensors` — generické, poslouží i +akumulační nádrži pro ohřev). + +**Hranice s ohřevem přes TČ (žádný šelmostroj):** filtrace = denní rozpočet +minut pro solver; ohřev = samostatný explicitní program (docs z 12. 6.: +sekvence Shelly pump → HEX pump → TČ), který si čerpadlo prostě zapne +(interlock) — minuty běhu se započtou samy přes telemetrii. Jediná vazba je +teplotní čidlo, které sdílí obě logiky.