mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""Service for controlling smart plugs via generic REST/HTTP API."""
|
|
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import httpx
|
|
|
|
if TYPE_CHECKING:
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RESTSmartPlugService:
|
|
"""Service for controlling smart plugs via generic REST/HTTP API.
|
|
|
|
Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.).
|
|
"""
|
|
|
|
def __init__(self, timeout: float = 10.0):
|
|
self.timeout = timeout
|
|
|
|
@staticmethod
|
|
def _url_error(url: str) -> str | None:
|
|
"""Return why *url* is rejected by the LAN-service policy, else None.
|
|
|
|
Split out from ``_validate_url`` so ``test_connection`` can tell the
|
|
user which rule the URL broke instead of a single fixed sentence.
|
|
"""
|
|
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
|
|
|
try:
|
|
assert_safe_lan_service_url(url, label="REST plug URL")
|
|
except ValueError as exc:
|
|
return str(exc)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _validate_url(url: str) -> bool:
|
|
"""Apply the shared LAN-service SSRF policy to a REST plug URL.
|
|
|
|
Delegates to ``_url_safety.assert_safe_lan_service_url`` — the same
|
|
guard Spoolman, the notification providers and the LAN-service
|
|
settings use — rather than reimplementing a narrower check. The
|
|
hand-rolled version this replaces got the policy wrong in both
|
|
directions: it rejected a literal ``127.0.0.1`` (so an openHAB or
|
|
Node-RED instance on the same host could only be reached by spelling
|
|
it ``localhost``), while allowing every target the shared policy
|
|
rejects unconditionally — Alibaba/AWS-IPv6 metadata endpoints,
|
|
numeric-encoded IPs, multicast and the unspecified address — because
|
|
anything that wasn't a bare IP literal fell through to ``True``.
|
|
|
|
Loopback and RFC-1918 stay permitted on purpose: a REST-controlled
|
|
plug bridge running next to Bambuddy is the normal topology.
|
|
"""
|
|
return RESTSmartPlugService._url_error(url) is None
|
|
|
|
def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
|
|
"""Parse JSON string to dict of headers."""
|
|
if not headers_json:
|
|
return {}
|
|
try:
|
|
headers = json.loads(headers_json)
|
|
if isinstance(headers, dict):
|
|
return {str(k): str(v) for k, v in headers.items()}
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.warning("Failed to parse REST headers JSON: %s", headers_json)
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _extract_json_path(data: Any, path: str) -> Any:
|
|
"""Extract value using dot notation (e.g., 'state' or 'data.power.status')."""
|
|
if not path:
|
|
return None
|
|
|
|
parts = path.split(".")
|
|
current = data
|
|
|
|
for part in parts:
|
|
if isinstance(current, dict) and part in current:
|
|
current = current[part]
|
|
else:
|
|
return None
|
|
|
|
return current
|
|
|
|
async def _send_request(
|
|
self,
|
|
url: str,
|
|
method: str = "POST",
|
|
headers: dict[str, str] | None = None,
|
|
body: str | None = None,
|
|
) -> httpx.Response | None:
|
|
"""Send an HTTP request and return the response."""
|
|
if not self._validate_url(url):
|
|
logger.warning("Blocked REST request to invalid URL: %s", url)
|
|
return None
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
kwargs: dict[str, Any] = {"headers": headers or {}}
|
|
if body is not None:
|
|
# Try to detect if body is JSON
|
|
try:
|
|
json.loads(body)
|
|
kwargs["content"] = body
|
|
if "Content-Type" not in (headers or {}):
|
|
kwargs["headers"]["Content-Type"] = "application/json"
|
|
except (json.JSONDecodeError, TypeError):
|
|
kwargs["content"] = body
|
|
|
|
response = await client.request(method.upper(), url, **kwargs)
|
|
response.raise_for_status()
|
|
return response
|
|
except httpx.TimeoutException:
|
|
logger.warning("REST smart plug at %s timed out", url)
|
|
return None
|
|
except httpx.HTTPStatusError as e:
|
|
logger.warning("REST smart plug at %s returned error: %s", url, e)
|
|
return None
|
|
except httpx.RequestError as e:
|
|
logger.warning("Failed to connect to REST smart plug at %s: %s", url, e)
|
|
return None
|
|
except Exception as e:
|
|
logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e)
|
|
return None
|
|
|
|
async def turn_on(self, plug: "SmartPlug") -> bool:
|
|
"""Turn on the plug. Returns True if successful."""
|
|
if not plug.rest_on_url:
|
|
logger.warning("No ON URL configured for REST plug '%s'", plug.name)
|
|
return False
|
|
|
|
headers = self._parse_headers(plug.rest_headers)
|
|
method = plug.rest_method or "POST"
|
|
response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
|
|
|
|
if response is not None:
|
|
logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url)
|
|
return True
|
|
|
|
logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
|
|
return False
|
|
|
|
async def turn_off(self, plug: "SmartPlug") -> bool:
|
|
"""Turn off the plug. Returns True if successful."""
|
|
if not plug.rest_off_url:
|
|
logger.warning("No OFF URL configured for REST plug '%s'", plug.name)
|
|
return False
|
|
|
|
headers = self._parse_headers(plug.rest_headers)
|
|
method = plug.rest_method or "POST"
|
|
response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
|
|
|
|
if response is not None:
|
|
logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url)
|
|
return True
|
|
|
|
logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)
|
|
return False
|
|
|
|
async def toggle(self, plug: "SmartPlug") -> bool:
|
|
"""Toggle the plug state by checking status first."""
|
|
status = await self.get_status(plug)
|
|
if status["state"] == "ON":
|
|
return await self.turn_off(plug)
|
|
else:
|
|
return await self.turn_on(plug)
|
|
|
|
async def get_status(self, plug: "SmartPlug") -> dict:
|
|
"""Get current power state.
|
|
|
|
Returns dict with:
|
|
- state: "ON" or "OFF" or None if unreachable
|
|
- reachable: bool
|
|
- device_name: None (REST plugs don't report device names)
|
|
"""
|
|
if not plug.rest_status_url:
|
|
return {"state": None, "reachable": True, "device_name": None}
|
|
|
|
headers = self._parse_headers(plug.rest_headers)
|
|
response = await self._send_request(plug.rest_status_url, "GET", headers)
|
|
|
|
if response is None:
|
|
return {"state": None, "reachable": False, "device_name": None}
|
|
|
|
# Try to extract state from response
|
|
state = None
|
|
try:
|
|
data = response.json()
|
|
if plug.rest_status_path:
|
|
raw_value = self._extract_json_path(data, plug.rest_status_path)
|
|
if raw_value is not None:
|
|
on_value = (plug.rest_status_on_value or "ON").upper()
|
|
state = "ON" if str(raw_value).upper() == on_value else "OFF"
|
|
else:
|
|
# No path configured — try common patterns
|
|
raw_value = str(data).upper() if not isinstance(data, dict) else None
|
|
if raw_value in ("ON", "TRUE", "1"):
|
|
state = "ON"
|
|
elif raw_value in ("OFF", "FALSE", "0"):
|
|
state = "OFF"
|
|
except Exception:
|
|
# Response is not JSON — try raw text
|
|
text = response.text.strip().upper()
|
|
on_value = (plug.rest_status_on_value or "ON").upper()
|
|
state = "ON" if text == on_value else "OFF"
|
|
|
|
return {"state": state, "reachable": True, "device_name": None}
|
|
|
|
async def get_energy(self, plug: "SmartPlug") -> dict | None:
|
|
"""Get energy monitoring data.
|
|
|
|
Each value can come from its own URL or fall back to the shared status URL.
|
|
Multipliers convert units (e.g. Wh → kWh with multiplier 0.001).
|
|
|
|
Two distinct energy counters, because devices differ in which they have
|
|
(#2539):
|
|
|
|
- ``rest_energy_path`` — energy used **today**, resetting at midnight.
|
|
- ``rest_energy_total_path`` — a **lifetime** counter that never resets.
|
|
A Shelly exposes only this one (``aenergy.total``, in Wh). Reading it as
|
|
"today" is wrong all day long, and leaves Total and the hourly snapshots
|
|
— which the Statistics page's date filters run on — permanently empty.
|
|
|
|
Yesterday is not read from the device: no REST device we know of reports
|
|
it. It is derived from the lifetime counter's snapshots instead, in
|
|
``services.plug_energy_history``.
|
|
|
|
Returns dict with energy data or None if not available.
|
|
"""
|
|
if not plug.rest_power_path and not plug.rest_energy_path and not plug.rest_energy_total_path:
|
|
return None
|
|
|
|
headers = self._parse_headers(plug.rest_headers)
|
|
energy: dict[str, float | None] = {}
|
|
|
|
power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
|
|
energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
|
|
# The lifetime counter almost always rides on the same response as the
|
|
# today counter (one Shelly RPC call returns both `apower` and
|
|
# `aenergy.total`), so it shares the energy URL and the dedupe below
|
|
# collapses them into a single fetch.
|
|
total_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_total_path else None
|
|
|
|
# Fetch data — deduplicate when several resolve to the same URL
|
|
fetched: dict[str, Any] = {}
|
|
|
|
for url in {power_url, energy_url, total_url} - {None}:
|
|
fetched[url] = await self._fetch_json(url, headers)
|
|
|
|
def _read(path: str | None, url: str | None, multiplier: float | None) -> float | None:
|
|
if not path or not url or fetched.get(url) is None:
|
|
return None
|
|
raw = self._extract_json_path(fetched[url], path)
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return float(raw) * (multiplier or 1.0)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
power = _read(plug.rest_power_path, power_url, plug.rest_power_multiplier)
|
|
if power is not None:
|
|
energy["power"] = power
|
|
|
|
today = _read(plug.rest_energy_path, energy_url, plug.rest_energy_multiplier)
|
|
if today is not None:
|
|
energy["today"] = today
|
|
|
|
total = _read(plug.rest_energy_total_path, total_url, plug.rest_energy_total_multiplier)
|
|
if total is not None:
|
|
energy["total"] = total
|
|
|
|
return energy if energy else None
|
|
|
|
async def _fetch_json(self, url: str, headers: dict[str, str]) -> Any:
|
|
"""Fetch a URL and parse JSON response. Returns parsed data or None."""
|
|
response = await self._send_request(url, "GET", headers)
|
|
if response is None:
|
|
return None
|
|
try:
|
|
return response.json()
|
|
except Exception:
|
|
return None
|
|
|
|
async def test_connection(self, url: str, method: str = "GET", headers: str | None = None) -> dict:
|
|
"""Test connection to a REST endpoint.
|
|
|
|
Returns dict with:
|
|
- success: bool
|
|
- error: error message if failed
|
|
"""
|
|
url_error = self._url_error(url)
|
|
if url_error:
|
|
return {"success": False, "error": url_error}
|
|
|
|
parsed_headers = self._parse_headers(headers)
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
response = await client.request(method.upper(), url, headers=parsed_headers)
|
|
response.raise_for_status()
|
|
return {"success": True, "error": None}
|
|
except httpx.TimeoutException:
|
|
return {"success": False, "error": "Connection timed out"}
|
|
except httpx.HTTPStatusError as e:
|
|
return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.reason_phrase}"}
|
|
except httpx.RequestError as e:
|
|
return {"success": False, "error": f"Connection failed: {e}"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
# Singleton instance
|
|
rest_smart_plug_service = RESTSmartPlugService()
|