""" Shelly Gen2+ RPC klient (HTTP, httpx) — Switch.GetStatus / Switch.Set. Záměrně POUZE Gen2 RPC (`/rpc/?...`). Gen1 REST (`/relay/0?turn=on`) nepodporujeme — všechna nasazovaná relé (Plus/Pro řada) mluví Gen2 a fallback by jen maskoval chybnou konfiguraci. Viz docs/04-modules/pool-shelly.md. Žádné retry smyčky: telemetrii volá poll cyklus každých 60 s a další pokus zajistí sám; ovládání jde přes signal_service (vlastní retry + verify). """ from __future__ import annotations from dataclasses import dataclass from typing import Any import httpx DEFAULT_TIMEOUT_S = 5.0 @dataclass(frozen=True) class ShellySwitchStatus: """Stav Switch komponenty ze Switch.GetStatus.""" output: bool apower_w: float | None aenergy_total_wh: float | None def shelly_base_url(protocol: str | None, host: str, port: int | None) -> str: """Base URL Shelly z řádku ems.site_endpoint (protocol/host/port).""" p = (protocol or "http").lower() if p not in ("http", "https"): p = "http" prt = int(port or (443 if p == "https" else 80)) return f"{p}://{host}:{prt}" def parse_switch_status(data: dict[str, Any]) -> ShellySwitchStatus: """Čistý parser odpovědi Switch.GetStatus (testovatelné bez HTTP). Gen2: {"id":0,"output":true,"apower":745.3,"aenergy":{"total":12345.678,...},...} `aenergy.total` je ve Wh; `apower` ve W. Obojí volitelné (ne každý model měří). """ if "output" not in data: raise ValueError("Shelly Switch.GetStatus: missing 'output' (not a Gen2 RPC response?)") output = bool(data["output"]) apower_w: float | None = None if data.get("apower") is not None: apower_w = float(data["apower"]) aenergy_total_wh: float | None = None aenergy = data.get("aenergy") if isinstance(aenergy, dict) and aenergy.get("total") is not None: aenergy_total_wh = float(aenergy["total"]) return ShellySwitchStatus( output=output, apower_w=apower_w, aenergy_total_wh=aenergy_total_wh, ) async def get_switch_status( base_url: str, switch_id: int = 0, *, timeout: float = DEFAULT_TIMEOUT_S, client: httpx.AsyncClient | None = None, ) -> ShellySwitchStatus: """GET {base}/rpc/Switch.GetStatus?id=N → ShellySwitchStatus. `client` lze injektovat (testy, sdílený klient); jinak se vytvoří jednorázový. """ url = f"{base_url.rstrip('/')}/rpc/Switch.GetStatus" params = {"id": int(switch_id)} if client is not None: resp = await client.get(url, params=params) else: async with httpx.AsyncClient(timeout=timeout) as c: resp = await c.get(url, params=params) resp.raise_for_status() return parse_switch_status(resp.json()) async def set_switch( base_url: str, on: bool, switch_id: int = 0, *, timeout: float = DEFAULT_TIMEOUT_S, client: httpx.AsyncClient | None = None, ) -> bool | None: """GET {base}/rpc/Switch.Set?id=N&on=true|false. Vrátí was_on (předchozí stav), pokud ho Shelly poslalo. Pozn.: produkční ovládání bazénu jde přes signal_service (journal + verify); tato funkce je pro ruční zásahy / budoucí přímé použití. """ url = f"{base_url.rstrip('/')}/rpc/Switch.Set" # Gen2 RPC parsuje query parametry jako JSON — bool musí být 'true'/'false'. params = {"id": int(switch_id), "on": "true" if on else "false"} if client is not None: resp = await client.get(url, params=params) else: async with httpx.AsyncClient(timeout=timeout) as c: resp = await c.get(url, params=params) resp.raise_for_status() data = resp.json() was_on = data.get("was_on") if isinstance(data, dict) else None return bool(was_on) if was_on is not None else None