Hotfix merge: OTE import (format %.3f) + Tesla reauth/redirect opravy + bazén příprava + měkký EV cíl
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,8 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
target_deadline=td,
|
target_deadline=td,
|
||||||
energy_needed_wh=float(obj["energy_needed_wh"]),
|
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):
|
async def _load_site_context(site_id: int, db):
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
|
# 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)
|
# 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.
|
# 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 →
|
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
|
||||||
# reserve+noc, 6–19 h) platí za slot nájem buy×faktor (DB
|
# reserve+noc, 6–19 h) platí za slot nájem buy×faktor (DB
|
||||||
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
|
# 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)
|
for e in range(EV)
|
||||||
]
|
]
|
||||||
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
|
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]
|
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_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
|
||||||
safety_tgt_wh = [
|
safety_tgt_wh = [
|
||||||
@@ -281,6 +287,20 @@ def solve_dispatch_v2(
|
|||||||
>= float(sess.energy_needed_wh)
|
>= float(sess.energy_needed_wh)
|
||||||
), f"ev_deadline_{e}"
|
), 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)
|
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
|
||||||
rated_hp = float(heat_pump.rated_heating_power_w)
|
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):
|
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:
|
if ev_unmet:
|
||||||
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in 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_terms = [
|
||||||
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
|
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
|
||||||
for t in range(T)
|
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,
|
"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),
|
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
|
||||||
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
|
"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_duration_ms": duration_ms,
|
||||||
"solver_status": status_str,
|
"solver_status": status_str,
|
||||||
|
|||||||
@@ -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:
|
async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||||
"""Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep).
|
"""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_inverter(sid, conn)
|
||||||
await poll_ev_chargers(sid, conn)
|
await poll_ev_chargers(sid, conn)
|
||||||
await poll_heat_pump(sid, conn)
|
await poll_heat_pump(sid, conn)
|
||||||
|
await poll_loxone_sensors(sid, conn)
|
||||||
await poll_pool_pumps(sid, conn)
|
await poll_pool_pumps(sid, conn)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Telemetry loop error site %s: %s", sid, e)
|
logger.error("Telemetry loop error site %s: %s", sid, e)
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
if level is None:
|
if level is None:
|
||||||
return None
|
return None
|
||||||
odo_miles = vs.get("odometer")
|
odo_miles = vs.get("odometer")
|
||||||
|
ds = resp.get("drive_state") or {}
|
||||||
return {
|
return {
|
||||||
|
"latitude": ds.get("latitude"),
|
||||||
|
"longitude": ds.get("longitude"),
|
||||||
|
"shift_state": ds.get("shift_state"),
|
||||||
"vin": resp.get("vin"),
|
"vin": resp.get("vin"),
|
||||||
"battery_level": int(level),
|
"battery_level": int(level),
|
||||||
"charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None,
|
"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,
|
"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()
|
data = r.json()
|
||||||
|
|
||||||
new_access = str(data["access_token"])
|
new_access = str(data["access_token"])
|
||||||
@@ -144,7 +159,7 @@ async def get_charge_state(
|
|||||||
|
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data",
|
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:
|
if r.status_code == 408:
|
||||||
logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno")
|
logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno")
|
||||||
|
|||||||
@@ -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):
|
class EvDeadlineTests(unittest.TestCase):
|
||||||
def test_ev_energy_delivered_before_deadline(self) -> None:
|
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)]
|
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]
|
||||||
|
|||||||
55
db/migration/V092__pool_season_temp_loxone.sql
Normal file
55
db/migration/V092__pool_season_temp_loxone.sql
Normal 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].';
|
||||||
11
db/migration/V093__tesla_lfp_capacity.sql
Normal file
11
db/migration/V093__tesla_lfp_capacity.sql
Normal 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');
|
||||||
13
db/migration/V094__ev_opportunistic.sql
Normal file
13
db/migration/V094__ev_opportunistic.sql
Normal 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.';
|
||||||
@@ -191,7 +191,11 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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(
|
else jsonb_build_object(
|
||||||
'target_deadline', es.target_deadline,
|
'target_deadline', es.target_deadline,
|
||||||
'energy_needed_wh', greatest(
|
'energy_needed_wh', greatest(
|
||||||
@@ -200,7 +204,16 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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
|
end
|
||||||
from ems.ev_session es
|
from ems.ev_session es
|
||||||
@@ -223,7 +236,11 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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(
|
else jsonb_build_object(
|
||||||
'target_deadline', es.target_deadline,
|
'target_deadline', es.target_deadline,
|
||||||
'energy_needed_wh', greatest(
|
'energy_needed_wh', greatest(
|
||||||
@@ -232,7 +249,16 @@ begin
|
|||||||
- es.soc_at_connect_pct::numeric) / 100.0
|
- es.soc_at_connect_pct::numeric) / 100.0
|
||||||
* (v.battery_capacity_kwh * 1000)
|
* (v.battery_capacity_kwh * 1000)
|
||||||
- coalesce(es.energy_delivered_wh, 0)::numeric
|
- 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
|
end
|
||||||
from ems.ev_session es
|
from ems.ev_session es
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ as $fn$
|
|||||||
'code', 'NEG_EXTREME',
|
'code', 'NEG_EXTREME',
|
||||||
'severity', 3,
|
'severity', 3,
|
||||||
'title', 'extrémně záporné ceny',
|
'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
|
) s
|
||||||
where (select min_price from day_agg) <= -0.50
|
where (select min_price from day_agg) <= -0.50
|
||||||
or (select neg_slots from day_agg) >= 8
|
or (select neg_slots from day_agg) >= 8
|
||||||
@@ -81,7 +81,7 @@ as $fn$
|
|||||||
'code', 'NEG_PRESENT',
|
'code', 'NEG_PRESENT',
|
||||||
'severity', 2,
|
'severity', 2,
|
||||||
'title', 'záporné ceny',
|
'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
|
) s
|
||||||
where (select min_price from day_agg) < 0
|
where (select min_price from day_agg) < 0
|
||||||
and not (
|
and not (
|
||||||
@@ -95,7 +95,7 @@ as $fn$
|
|||||||
'code', 'NOON_ZEROISH',
|
'code', 'NOON_ZEROISH',
|
||||||
'severity', 2,
|
'severity', 2,
|
||||||
'title', 'poledne okolo nuly',
|
'title', 'poledne okolo nuly',
|
||||||
'detail', format('polední průměr %.3f Kč/kWh (10–14)', (select noon_avg_price from day_agg))
|
'detail', format('polední průměr %s Kč/kWh (10–14)', round((select noon_avg_price from day_agg), 3))
|
||||||
) s
|
) s
|
||||||
where coalesce((select noon_avg_price from day_agg), 999) <= (select zeroish_abs_czk_kwh from params)
|
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',
|
'code', 'MANY_ZEROISH',
|
||||||
'severity', 1,
|
'severity', 1,
|
||||||
'title', 'hodně slotů okolo nuly',
|
'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
|
) s
|
||||||
where (select zeroish_slots from day_agg) >= 16
|
where (select zeroish_slots from day_agg) >= 16
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ as $fn$
|
|||||||
'code', 'EVENING_SPIKE_EXTREME',
|
'code', 'EVENING_SPIKE_EXTREME',
|
||||||
'severity', 3,
|
'severity', 3,
|
||||||
'title', 'večer extrémně drahý',
|
'title', 'večer extrémně drahý',
|
||||||
'detail', format('max večer %.3f Kč/kWh (17–21)', (select evening_max_price from day_agg))
|
'detail', format('max večer %s Kč/kWh (17–21)', round((select evening_max_price from day_agg), 3))
|
||||||
) s
|
) s
|
||||||
where coalesce((select evening_max_price from day_agg), 0) >= (select spike_extreme_czk_kwh from params)
|
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',
|
'code', 'EVENING_SPIKE_INTERESTING',
|
||||||
'severity', 2,
|
'severity', 2,
|
||||||
'title', 'večer drahý',
|
'title', 'večer drahý',
|
||||||
'detail', format('max večer %.3f Kč/kWh (17–21)', (select evening_max_price from day_agg))
|
'detail', format('max večer %s Kč/kWh (17–21)', round((select evening_max_price from day_agg), 3))
|
||||||
) s
|
) s
|
||||||
where coalesce((select evening_max_price from day_agg), 0) >= (select spike_interesting_czk_kwh from params)
|
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)
|
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,
|
'severity', 2,
|
||||||
'title', 'večer nadprůměrná špička',
|
'title', 'večer nadprůměrná špička',
|
||||||
'detail', format(
|
'detail', format(
|
||||||
'max večer %.3f vs. průměr %.3f (lookback %s dní)',
|
'max večer %s vs. průměr %s (lookback %s dní)',
|
||||||
(select evening_max_price from day_agg),
|
round((select evening_max_price from day_agg), 3),
|
||||||
(select avg_evening from hist_windows),
|
round((select avg_evening from hist_windows), 3),
|
||||||
(select lookback_days from params)
|
(select lookback_days from params)
|
||||||
)
|
)
|
||||||
) s
|
) s
|
||||||
|
|||||||
45
db/routines/R__098_fn_pool_runtime.sql
Normal file
45
db/routines/R__098_fn_pool_runtime.sql
Normal 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×(teplota−ref), 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
67
deploy/tesla/reauth.sh
Executable 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
|
||||||
@@ -54,6 +54,11 @@ ems.vojacek.eu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Jednorázový OAuth callback (statická stránka zobrazí ?code=)
|
# 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* {
|
handle /tesla/callback* {
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
(`_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`.
|
(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.
|
||||||
|
|||||||
@@ -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 —
|
- 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
|
stav (běží/stojí/stale), aktuální W, dnešní kWh a hodiny běhu, mini sloupce
|
||||||
7 dní. Poll 60 s.
|
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.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Tesla Fleet API — napojení EMS (čtení SoC vozidla)
|
# 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`
|
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á
|
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
|
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:
|
Veřejné je pouze:
|
||||||
- `https://ems.vojacek.eu/.well-known/appspecific/com.tesla.3p.public-key.pem`
|
- `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.
|
- vše ostatní → 404; certifikát řeší Caddy (Let's Encrypt) automaticky.
|
||||||
|
|
||||||
## 2. Tesla developer portál (developer.tesla.com)
|
## 2. Tesla developer portál (developer.tesla.com)
|
||||||
|
|
||||||
Vytvořit aplikaci:
|
Vytvořit aplikaci:
|
||||||
- **Allowed Origin:** `https://ems.vojacek.eu`
|
- **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čí;
|
- **Scopes:** `openid offline_access vehicle_device_data` (čtení SoC stačí;
|
||||||
`vehicle_charging_cmds` až kdybychom chtěli vozidlu poroučet my — teď řídí
|
`vehicle_charging_cmds` až kdybychom chtěli vozidlu poroučet my — teď řídí
|
||||||
wallbox, ne auto)
|
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?
|
https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize?
|
||||||
response_type=code&client_id=$CLIENT_ID&
|
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
|
scope=openid%20offline_access%20vehicle_device_data&state=ems
|
||||||
```
|
```
|
||||||
Přihlásíš se Tesla účtem → redirect na callback stránku → zkopíruješ `code` →
|
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 \
|
curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \
|
||||||
-d grant_type=authorization_code -d client_id=$CLIENT_ID \
|
-d grant_type=authorization_code -d client_id=$CLIENT_ID \
|
||||||
-d client_secret=$CLIENT_SECRET -d code=$CODE \
|
-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)
|
# → access_token (krátký) + refresh_token (ULOŽIT — viz krok 5)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user