Hotfix merge: OTE import (format %.3f) + Tesla reauth/redirect opravy + bazén příprava + měkký EV cíl
All checks were successful
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-12 13:39:29 +02:00
16 changed files with 437 additions and 19 deletions

View File

@@ -37,6 +37,8 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
return SimpleNamespace(
target_deadline=td,
energy_needed_wh=float(obj["energy_needed_wh"]),
headroom_wh=float(obj.get("headroom_wh") or 0.0),
opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0),
)
async def _load_site_context(site_id: int, db):

View File

@@ -26,6 +26,11 @@
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
# Malá prémie za držení energie dřív (DB planner_pv_risk_frontload_czk_kwh)
# vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy.
# - oportunistické EV („měkký cíl"): nad tvrdý target smí auto vzít až
# headroom_wh (do 100 %), oceněno opportunistic_value_czk_kwh (= budoucí
# ušetřené nabíjení, DB) — kupuje jen velmi levnou/zápornou energii.
# Dekompozice Σ(EV energie) == needed unmet + opp zároveň stropuje
# celkovou energii do auta (dřív při buy<0 bez stropu).
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
# reserve+noc, 619 h) platí za slot nájem buy×faktor (DB
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
@@ -160,6 +165,7 @@ def solve_dispatch_v2(
for e in range(EV)
]
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
ev_opp: list = [] # (var, value_czk_kwh) — energie nad target (měkký cíl)
nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots]
safety_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
safety_tgt_wh = [
@@ -281,6 +287,20 @@ def solve_dispatch_v2(
>= float(sess.energy_needed_wh)
), f"ev_deadline_{e}"
# měkký cíl: dekompozice celkové energie == needed unmet + opp
headroom = max(0.0, float(getattr(sess, "headroom_wh", 0.0) or 0.0))
opp_val = float(getattr(sess, "opportunistic_value_czk_kwh", 0.0) or 0.0)
opp = pulp.LpVariable(f"ev_opp_{e}", 0, headroom if opp_val > 0 else 0.0)
ev_opp.append((opp, opp_val))
prob += (
pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
for t in range(T)
if _connected(e, t)
)
== float(sess.energy_needed_wh) - unmet + opp
), f"ev_total_{e}"
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
rated_hp = float(heat_pump.rated_heating_power_w)
if tuv_delta_stats and rated_hp > 0 and getattr(heat_pump, "tuv_min_temp_c", None):
@@ -322,6 +342,8 @@ def solve_dispatch_v2(
)
if ev_unmet:
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
if ev_opp:
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0)
nb_terms = [
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
for t in range(T)
@@ -453,6 +475,7 @@ def solve_dispatch_v2(
"extras_czk": round(float(pulp.value(extras)), 3) if not isinstance(extras, float) else 0.0,
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
"ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp],
},
"solver_duration_ms": duration_ms,
"solver_status": status_str,

View File

@@ -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/<name>/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)

View File

@@ -47,7 +47,11 @@ def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None:
if level is None:
return None
odo_miles = vs.get("odometer")
ds = resp.get("drive_state") or {}
return {
"latitude": ds.get("latitude"),
"longitude": ds.get("longitude"),
"shift_state": ds.get("shift_state"),
"vin": resp.get("vin"),
"battery_level": int(level),
"charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None,
@@ -91,7 +95,18 @@ async def _get_access_token(db: asyncpg.Connection) -> Optional[str]:
"refresh_token": refresh,
},
)
r.raise_for_status()
if r.status_code >= 400:
# 400 invalid_grant = token spálený rotací NEBO ~10min výpadek po
# revokaci souhlasu (Tesla docs). Neshazovat volajícího tracebackem.
body = r.text[:300]
logger.error(
"Tesla token refresh selhal (HTTP %s): %s — pokud jsi právě "
"revokoval souhlas, počkej ~10 min; jinak obnov token dle "
"docs/tesla-fleet-api.md (deploy/tesla/reauth.sh)",
r.status_code,
body,
)
return None
data = r.json()
new_access = str(data["access_token"])
@@ -144,7 +159,7 @@ async def get_charge_state(
r = await client.get(
f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data",
params={"endpoints": "charge_state;vehicle_state"},
params={"endpoints": "charge_state;vehicle_state;location_data"},
)
if r.status_code == 408:
logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno")

View File

@@ -247,6 +247,55 @@ class PvRiskFrontloadTests(unittest.TestCase):
)
class EvOpportunisticTests(unittest.TestCase):
def _session(self, needed=4000.0, headroom=20000.0, opp=1.0):
return SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=needed,
headroom_wh=headroom,
opportunistic_value_czk_kwh=opp,
)
def test_negative_prices_fill_beyond_target(self) -> None:
# buy<0 celé okno → nad target se vyplatí brát (hodnota 1 Kč/kWh + platí ti síť)
slots = [_slot(_BASE, i, buy=-1.0, sell=-0.5, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreater(delivered, 4000.0 + 2000.0, "měkký cíl má nasávat")
self.assertLessEqual(delivered, 4000.0 + 20000.0 + 1.0, "strop headroom")
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0)
def test_normal_prices_no_opportunistic(self) -> None:
# běžné ceny (buy 3) > hodnota 1 Kč/kWh → jen tvrdý cíl
slots = [_slot(_BASE, i, buy=3.0, sell=2.0, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLess(delivered, 4000.0 + 200.0)
self.assertLess(snap["objective_terms"]["ev_opp_wh"][0], 100.0)
def test_cheap_sell_prefers_car_over_grid(self) -> None:
# sell 0.3 < opp 1.0, plná domácí baterka, velký PV přebytek
# → přebytek do auta, ne za babku do sítě
bat = _battery()
slots = [_slot(_BASE, i, buy=3.0, sell=0.3, pv_a=9000, load=500, ev1=True) for i in range(16)]
results, _, snap = _solve(
slots, battery=bat, soc0=bat.soc_max_wh, # baterka plná
ev_sessions=(self._session(needed=2000.0, headroom=25000.0), None),
)
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
exported = sum(-r.grid_setpoint_w * 0.25 for r in results if r.grid_setpoint_w < 0)
self.assertGreater(delivered, 15000.0, "přebytek má téct do auta")
self.assertLess(exported, delivered, "prodej za 0.3 nemá vyhrát nad autem")
def test_total_energy_capped_even_at_negative_buy(self) -> None:
# fix latentního bugu: bez headroom (opp=0) nesmí buy<0 pumpovat nad needed
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(16)]
sess = self._session(needed=3000.0, headroom=0.0, opp=0.0)
results, _, _ = _solve(slots, ev_sessions=(sess, None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLessEqual(delivered, 3000.0 + 1.0)
class EvDeadlineTests(unittest.TestCase):
def test_ev_energy_delivered_before_deadline(self) -> None:
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]

View File

@@ -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/<loxone_name>/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].';

View File

@@ -0,0 +1,11 @@
-- Tesla Model Y 2025 Standard RWD (LFP): kapacita ~62.5 kWh (v seedu bylo 75 =
-- hodnota LR varianty) a default cíl 100 % — LFP chemie pravidelné nabití na
-- 100 % vyžaduje (balancování), žádná degradační penalizace jako u NMC.
-- Kapacita vstupuje do energy_needed (target soc) × kWh a do EV usage stats.
update ems.asset_vehicle
set battery_capacity_kwh = 62.5,
default_target_soc_pct = 100,
notes = coalesce(notes, '') || ' [2026-06-12: LFP 62.5 kWh, cíl 100 % (balancování).]'
where code = 'tesla-my'
and site_id = (select id from ems.site where code = 'home-01');

View File

@@ -0,0 +1,13 @@
-- Oportunistické EV nabíjení („měkký cíl"): nad tvrdý target smí auto nasát
-- přebytky až do 100 %, oceněné hodnotou BUDOUCÍHO ušetřeného nabíjení
-- (~1 Kč/kWh — budoucí nabíjení je stejně v levných slotech). Uplatní se
-- hlavně při záporných cenách / plné domácí baterce (lepší než curtail);
-- běžné ceny ho nezaplatí. 0 = vypnuto. Víkend: páteční malý tvrdý cíl
-- + víkendové negativní ceny → auto se doplní samo, bez speciální logiky.
alter table ems.asset_vehicle
add column if not exists opportunistic_value_czk_kwh numeric(6, 3)
not null default 1.0;
comment on column ems.asset_vehicle.opportunistic_value_czk_kwh is
'v2: hodnota kWh nabité NAD target session (do 100 %) = ušetřené budoucí nabíjení. Solver ji zaplatí jen při velmi levné/záporné energii. 0 = vypnuto.';

View File

@@ -191,7 +191,11 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
) <= 0
and (
coalesce(v.opportunistic_value_czk_kwh, 0) <= 0
or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0
) then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
@@ -200,7 +204,16 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
),
'headroom_wh', case
when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
0,
(100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric
/ 100.0 * (v.battery_capacity_kwh * 1000)
)
else 0
end,
'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0)
)
end
from ems.ev_session es
@@ -223,7 +236,11 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
) <= 0 then null::jsonb
) <= 0
and (
coalesce(v.opportunistic_value_czk_kwh, 0) <= 0
or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0
) then null::jsonb
else jsonb_build_object(
'target_deadline', es.target_deadline,
'energy_needed_wh', greatest(
@@ -232,7 +249,16 @@ begin
- es.soc_at_connect_pct::numeric) / 100.0
* (v.battery_capacity_kwh * 1000)
- coalesce(es.energy_delivered_wh, 0)::numeric
),
'headroom_wh', case
when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest(
0,
(100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric
/ 100.0 * (v.battery_capacity_kwh * 1000)
)
else 0
end,
'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0)
)
end
from ems.ev_session es

View File

@@ -71,7 +71,7 @@ as $fn$
'code', 'NEG_EXTREME',
'severity', 3,
'title', 'extrémně záporné ceny',
'detail', format('min %.3f Kč/kWh, záporné sloty %s', (select min_price from day_agg), (select neg_slots from day_agg))
'detail', format('min %s Kč/kWh, záporné sloty %s', round((select min_price from day_agg), 3), (select neg_slots from day_agg))
) s
where (select min_price from day_agg) <= -0.50
or (select neg_slots from day_agg) >= 8
@@ -81,7 +81,7 @@ as $fn$
'code', 'NEG_PRESENT',
'severity', 2,
'title', 'záporné ceny',
'detail', format('min %.3f Kč/kWh, záporné sloty %s', (select min_price from day_agg), (select neg_slots from day_agg))
'detail', format('min %s Kč/kWh, záporné sloty %s', round((select min_price from day_agg), 3), (select neg_slots from day_agg))
) s
where (select min_price from day_agg) < 0
and not (
@@ -95,7 +95,7 @@ as $fn$
'code', 'NOON_ZEROISH',
'severity', 2,
'title', 'poledne okolo nuly',
'detail', format('polední průměr %.3f Kč/kWh (1014)', (select noon_avg_price from day_agg))
'detail', format('polední průměr %s Kč/kWh (1014)', round((select noon_avg_price from day_agg), 3))
) s
where coalesce((select noon_avg_price from day_agg), 999) <= (select zeroish_abs_czk_kwh from params)
@@ -104,7 +104,7 @@ as $fn$
'code', 'MANY_ZEROISH',
'severity', 1,
'title', 'hodně slotů okolo nuly',
'detail', format('okolo nuly slotů %s (|p| ≤ %.2f Kč/kWh)', (select zeroish_slots from day_agg), (select zeroish_abs_czk_kwh from params))
'detail', format('okolo nuly slotů %s (|p| ≤ %s Kč/kWh)', (select zeroish_slots from day_agg), round((select zeroish_abs_czk_kwh from params), 2))
) s
where (select zeroish_slots from day_agg) >= 16
@@ -114,7 +114,7 @@ as $fn$
'code', 'EVENING_SPIKE_EXTREME',
'severity', 3,
'title', 'večer extrémně drahý',
'detail', format('max večer %.3f Kč/kWh (1721)', (select evening_max_price from day_agg))
'detail', format('max večer %s Kč/kWh (1721)', round((select evening_max_price from day_agg), 3))
) s
where coalesce((select evening_max_price from day_agg), 0) >= (select spike_extreme_czk_kwh from params)
@@ -123,7 +123,7 @@ as $fn$
'code', 'EVENING_SPIKE_INTERESTING',
'severity', 2,
'title', 'večer drahý',
'detail', format('max večer %.3f Kč/kWh (1721)', (select evening_max_price from day_agg))
'detail', format('max večer %s Kč/kWh (1721)', round((select evening_max_price from day_agg), 3))
) s
where coalesce((select evening_max_price from day_agg), 0) >= (select spike_interesting_czk_kwh from params)
and coalesce((select evening_max_price from day_agg), 0) < (select spike_extreme_czk_kwh from params)
@@ -135,9 +135,9 @@ as $fn$
'severity', 2,
'title', 'večer nadprůměrná špička',
'detail', format(
'max večer %.3f vs. průměr %.3f (lookback %s dní)',
(select evening_max_price from day_agg),
(select avg_evening from hist_windows),
'max večer %s vs. průměr %s (lookback %s dní)',
round((select evening_max_price from day_agg), 3),
round((select avg_evening from hist_windows), 3),
(select lookback_days from params)
)
) s

View File

@@ -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×(teplotaref), min, max) z poslední teploty vody (<24 h), jinak daily_runtime_min. JSON s detailem pro UI/solver.';

67
deploy/tesla/reauth.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Jednorázová obnova Tesla refresh tokenu (rotační past: žádné mezikroky).
# Použití NA SERVERU:
# 1) otevři authorize URL (vypíše ji tento skript bez argumentů)
# 2) ./reauth.sh <CODE> — výměna → zápis do DB → ověřovací test
# Env: CID, CSEC (client id/secret). Redirect URI: https://ems.vojacek.eu/t-auth
set -euo pipefail
REDIRECT="https://ems.vojacek.eu/t-auth"
AUTH="https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3"
: "${CID:?export CID='<client_id>'}"
: "${CSEC:?export CSEC='<client_secret>'}"
if [[ $# -lt 1 ]]; then
echo "Otevři v prohlížeči (consent musí ukázat 'Vehicle location'):"
echo "$AUTH/authorize?response_type=code&client_id=$CID&redirect_uri=$REDIRECT&scope=openid%20offline_access%20vehicle_device_data%20vehicle_location&state=ems"
echo
echo "Pak: $0 <CODE> (do minuty!)"
exit 0
fi
CODE="$1"
RESP=$(curl -s "$AUTH/token" \
-d grant_type=authorization_code \
--data-urlencode "client_id=$CID" \
--data-urlencode "client_secret=$CSEC" \
--data-urlencode "redirect_uri=$REDIRECT" \
--data-urlencode "code=$CODE")
RT=$(echo "$RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('refresh_token',''))" 2>/dev/null || true)
if [[ -z "$RT" ]]; then
echo "VÝMĚNA SELHALA:"; echo "$RESP"; exit 1
fi
echo "refresh token OK (${#RT} znaků) → DB"
docker exec -i ems-deploy-db-1 psql -U ems_user -d ems -c \
"update ems.tesla_token set refresh_token='$RT', access_token=null, access_expires_at=null, updated_at=now() where id=1;"
echo "=== ověření ==="
docker exec -i ems-deploy-backend-1 python - <<'PY'
import asyncio, asyncpg, os, httpx
from services.tesla_client import _get_access_token, API_BASE
async def m():
c = await asyncpg.connect(host=os.environ["DB_HOST"], port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], database="ems")
try:
tok = await _get_access_token(c)
if not tok:
print("REFRESH SELHAL (viz log backendu — po revokaci počkej ~10 min)"); return
h = {"Authorization": f"Bearer {tok}"}
async with httpx.AsyncClient(timeout=15, headers=h) as cl:
r = await cl.get(f"{API_BASE}/api/1/vehicles")
v = (r.json().get("response") or [None])[0]
print("vozidlo:", v and v.get("state"))
if v and v.get("state") == "online":
r2 = await cl.get(f"{API_BASE}/api/1/vehicles/{v['id']}/vehicle_data",
params={"endpoints": "location_data"})
ds = (r2.json().get("response") or {}).get("drive_state") or {}
print("location scope:", "OK" if ds.get("latitude") is not None
else f"CHYBI (HTTP {r2.status_code})")
else:
print("auto spí — refresh OK, location doověřit po jízdě")
finally:
await c.close()
asyncio.run(m())
PY

View File

@@ -54,6 +54,11 @@ ems.vojacek.eu {
}
# Jednorázový OAuth callback (statická stránka zobrazí ?code=)
# POZOR: redirect URI registrovaná v Tesla dev portálu je /t-auth.
handle /t-auth* {
rewrite * /tesla/callback.html
file_server
}
handle /tesla/callback* {
file_server
}

View File

@@ -342,3 +342,17 @@ Po detekci příjezdu + Tesla SoC + replanu odejde na site webhook souhrn:
stav baterie auta → cíl (+kWh), deadline, plánovaná nabíjecí okna s ø cenou
(`_notify_ev_arrival_plan` v telemetry_collector). Interaktivní fáze B
(tlačítka „odjíždím za 2 h" → patch session + replan): `docs/discord-ev-interaction.md`.
## Měkký cíl — oportunistické nabíjení nad target (2026-06-12, dev)
Tvrdý cíl (deadline) = „bez tohohle neodjedu"; měkký cíl = „klidně doplň
do 100 %, když je energie skoro zadarmo". Implementace: dekompozice
Σ(EV energie) == needed unmet + opp; `opp ∈ [0, headroom]`
(headroom = (100 target) % kapacity, jen když `asset_vehicle.
opportunistic_value_czk_kwh > 0`; default 1 Kč/kWh, 0 = vypnuto).
Hodnota = ušetřené BUDOUCÍ nabíjení (auto neumí zpět — žádný noční prodej),
proto nízká → uplatní se při záporných cenách / plné domácí baterce
(lepší než curtail), běžné ceny ji nezaplatí. Víkendový vzor „pátek
nemusím do plna, víkend doplní zadarmo" z toho plyne sám. Dekompozice
zároveň stropuje celkovou energii do auta (dřív při buy<0 chyběl strop).
Session zůstává v plánu i po dosažení targetu, dokud má headroom.

View File

@@ -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', '<JmenoVLoxonu>', '°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.

View File

@@ -1,5 +1,10 @@
# Tesla Fleet API — napojení EMS (čtení SoC vozidla)
> **REDIRECT URI registrovaná v dev portálu: `https://ems.vojacek.eu/t-auth`**
> (musí se znak po znaku shodovat v authorize URL, v token výměně i v portálu).
> Stránka s kódem se servíruje na /t-auth (Caddy rewrite); kód jde vždy
> zkopírovat i z adresního řádku (`?code=...`).
Cíl: po příjezdu EV přečíst skutečné SoC → `ev_session.energy_needed_wh`
přesně místo defaultu. Doména `ems.vojacek.eu` slouží JEN jako veřejná
vizitka pro Tesla (cert + public key + jednorázový OAuth callback) — EMS
@@ -14,14 +19,14 @@ bash /opt/ems-deploy/app/deploy/tesla/setup_tesla_domain.sh
Veřejné je pouze:
- `https://ems.vojacek.eu/.well-known/appspecific/com.tesla.3p.public-key.pem`
- `https://ems.vojacek.eu/tesla/callback` (statická stránka zobrazí `?code=`)
- `https://ems.vojacek.eu/t-auth` (statická stránka zobrazí `?code=`)
- vše ostatní → 404; certifikát řeší Caddy (Let's Encrypt) automaticky.
## 2. Tesla developer portál (developer.tesla.com)
Vytvořit aplikaci:
- **Allowed Origin:** `https://ems.vojacek.eu`
- **Allowed Redirect URI:** `https://ems.vojacek.eu/tesla/callback`
- **Allowed Redirect URI:** `https://ems.vojacek.eu/t-auth`
- **Scopes:** `openid offline_access vehicle_device_data` (čtení SoC stačí;
`vehicle_charging_cmds` až kdybychom chtěli vozidlu poroučet my — teď řídí
wallbox, ne auto)
@@ -49,7 +54,7 @@ curl -s -X POST https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/partner_accoun
```
https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize?
response_type=code&client_id=$CLIENT_ID&
redirect_uri=https://ems.vojacek.eu/tesla/callback&
redirect_uri=https://ems.vojacek.eu/t-auth&
scope=openid%20offline_access%20vehicle_device_data&state=ems
```
Přihlásíš se Tesla účtem → redirect na callback stránku → zkopíruješ `code`
@@ -59,7 +64,7 @@ výměna za tokeny:
curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \
-d grant_type=authorization_code -d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET -d code=$CODE \
-d redirect_uri=https://ems.vojacek.eu/tesla/callback
-d redirect_uri=https://ems.vojacek.eu/t-auth
# → access_token (krátký) + refresh_token (ULOŽIT — viz krok 5)
```