api.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. """API client for Solar of Things (solar.siseli.com).
  2. Authentication strategy
  3. ─────────────────────────────────────────────────────────────────────────────
  4. The Siseli portal uses a dual-token system discovered via JS bundle analysis:
  5. • POST /login/account → accessToken + refreshToken + expiry timestamps
  6. • POST /login/refresh/access/token → new accessToken (+ new refreshToken) using
  7. the current refreshToken
  8. accessToken = the "IOT-Token" HTTP header value; short-lived (hours/days)
  9. refreshToken = long-lived; used only to mint a new accessToken
  10. This class supports three auth modes, tried in priority order:
  11. 1. Email + password (recommended)
  12. • Call login() at startup → stores both tokens in memory.
  13. • _ensure_token_valid() checks expiry before every API call and proactively
  14. refreshes (TOKEN_REFRESH_LEAD_SECONDS = 5 min before expiry, matching the
  15. portal JS behaviour).
  16. • If refresh fails, raises TokenExpiredError so the HA integration can
  17. trigger a re-auth flow.
  18. 2. Token-pair (accessToken + refreshToken) without password
  19. • User pastes both tokens from DevTools.
  20. • Same proactive-refresh logic; re-auth needed when refreshToken expires.
  21. 3. Legacy IOT-token only (backwards compatibility)
  22. • No refresh possible; raises TokenExpiredError on 401 so HA can prompt
  23. the user to re-enter a fresh token.
  24. Usage in Home Assistant
  25. ─────────────────────────────────────────────────────────────────────────────
  26. api = SolarOfThingsAPI(
  27. email="user@example.com",
  28. password="secret", # or omit and pass iot_token=
  29. time_zone="Asia/Manila",
  30. on_token_refreshed=_save_tokens_callback,
  31. )
  32. await hass.async_add_executor_job(api.login)
  33. data = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
  34. """
  35. from __future__ import annotations
  36. import logging
  37. import threading
  38. from datetime import datetime, timezone, timedelta
  39. from typing import Any, Callable
  40. import requests
  41. try:
  42. from zoneinfo import ZoneInfo
  43. except Exception: # pragma: no cover
  44. ZoneInfo = None # type: ignore[assignment]
  45. from .const import (
  46. API_BASE_URL,
  47. API_LOGIN,
  48. API_REFRESH_TOKEN as API_REFRESH_TOKEN_ENDPOINT,
  49. API_TIME_SERIES,
  50. API_MONTHLY_SUMMARY,
  51. API_DEVICE_LIST,
  52. API_SETTINGS_GET,
  53. API_SETTINGS_SET,
  54. TOKEN_REFRESH_LEAD_SECONDS,
  55. )
  56. _LOGGER = logging.getLogger(__name__)
  57. _DEFAULT_TZ = "Asia/Manila"
  58. # ──────────────────────────────────────────────────────────────────────────────
  59. # Custom exceptions
  60. # ──────────────────────────────────────────────────────────────────────────────
  61. class TokenExpiredError(Exception):
  62. """Raised when the access token has expired and cannot be refreshed.
  63. The HA integration should catch this and call
  64. config_entry.async_start_reauth() so the user can re-enter credentials.
  65. """
  66. class AuthenticationError(Exception):
  67. """Raised when login credentials are rejected by the server."""
  68. # ──────────────────────────────────────────────────────────────────────────────
  69. # Helper: parse Siseli ISO expiry strings safely
  70. # ──────────────────────────────────────────────────────────────────────────────
  71. def _parse_expiry(value: str | None) -> datetime | None:
  72. """Return an aware UTC datetime from an ISO-8601 string, or None."""
  73. if not value:
  74. return None
  75. try:
  76. # Python 3.7+ fromisoformat doesn't handle trailing 'Z'
  77. cleaned = value.replace("Z", "+00:00")
  78. dt = datetime.fromisoformat(cleaned)
  79. if dt.tzinfo is None:
  80. dt = dt.replace(tzinfo=timezone.utc)
  81. return dt.astimezone(timezone.utc)
  82. except Exception:
  83. return None
  84. # ──────────────────────────────────────────────────────────────────────────────
  85. # Main API client
  86. # ──────────────────────────────────────────────────────────────────────────────
  87. class SolarOfThingsAPI:
  88. """Solar of Things API wrapper with automatic token refresh.
  89. Parameters
  90. ----------
  91. email: Siseli portal login e-mail (preferred auth method).
  92. password: Siseli portal password.
  93. iot_token: Legacy/manual IOT-Token (used when email/password absent).
  94. refresh_token: Stored refresh token (persisted between HA restarts).
  95. access_token_expires: ISO-8601 string of current access-token expiry.
  96. refresh_token_expires: ISO-8601 string of current refresh-token expiry.
  97. time_zone: IOT-Time-Zone header value.
  98. on_token_refreshed: Optional callback(access_token, refresh_token,
  99. access_expires_iso, refresh_expires_iso) called after
  100. every successful token refresh so the HA entry can
  101. persist the new tokens without restarting.
  102. """
  103. def __init__(
  104. self,
  105. *,
  106. email: str | None = None,
  107. password: str | None = None,
  108. iot_token: str | None = None,
  109. refresh_token: str | None = None,
  110. access_token_expires: str | None = None,
  111. refresh_token_expires: str | None = None,
  112. time_zone: str | None = None,
  113. on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
  114. ) -> None:
  115. self._email = email
  116. self._password = password
  117. self._time_zone = time_zone or _DEFAULT_TZ
  118. self._on_token_refreshed = on_token_refreshed
  119. # Token state
  120. self._access_token: str = iot_token or ""
  121. self._refresh_token: str = refresh_token or ""
  122. self._access_expires: datetime | None = _parse_expiry(access_token_expires)
  123. self._refresh_expires: datetime | None = _parse_expiry(refresh_token_expires)
  124. # Thread-safety for concurrent refresh calls
  125. self._refresh_lock = threading.Lock()
  126. # Determine auth mode
  127. if email and password:
  128. self._auth_mode = "password"
  129. elif iot_token and refresh_token:
  130. self._auth_mode = "token_pair"
  131. elif iot_token:
  132. self._auth_mode = "legacy"
  133. else:
  134. raise ValueError("Provide either (email + password) or iot_token.")
  135. # HTTP session (headers updated after every token refresh)
  136. self.session = requests.Session()
  137. self._apply_token_headers()
  138. # ─── Session headers ───────────────────────────────────────────────────────
  139. def _apply_token_headers(self) -> None:
  140. """Write the current access token into the session headers."""
  141. self.session.headers.update(
  142. {
  143. "Accept": "application/json",
  144. "Content-Type": "application/json; charset=utf-8",
  145. "IOT-Token": self._access_token,
  146. "IOT-Time-Zone": self._time_zone,
  147. "Origin": "https://solar.siseli.com",
  148. "Referer": "https://solar.siseli.com/",
  149. "User-Agent": (
  150. "HomeAssistant-SolarOfThings/2.2.0 "
  151. "(+https://github.com/conexocasa/solar-of-things-ha)"
  152. ),
  153. }
  154. )
  155. # ─── Public auth helpers ───────────────────────────────────────────────────
  156. def login(self) -> None:
  157. """Authenticate with email + password and store the resulting tokens.
  158. Raises AuthenticationError on bad credentials, or requests.RequestException
  159. on network failure. Safe to call from a background thread.
  160. """
  161. if self._auth_mode not in ("password",):
  162. raise RuntimeError("login() requires email + password auth mode.")
  163. _LOGGER.debug("SolarOfThings: logging in as %s", self._email)
  164. payload = {
  165. "email": self._email,
  166. "password": self._password,
  167. "loginType": "email",
  168. }
  169. # Auth endpoints must NOT carry the IOT-Token header yet
  170. resp = requests.post(
  171. f"{API_BASE_URL}{API_LOGIN}",
  172. json=payload,
  173. headers={
  174. "Accept": "application/json",
  175. "Content-Type": "application/json; charset=utf-8",
  176. "Origin": "https://solar.siseli.com",
  177. "Referer": "https://solar.siseli.com/",
  178. },
  179. timeout=30,
  180. )
  181. resp.raise_for_status()
  182. data = resp.json()
  183. if data.get("code") not in (0, None, "0"):
  184. msg = data.get("message") or data.get("msg") or str(data)
  185. raise AuthenticationError(f"Login failed: {msg}")
  186. self._store_tokens(data.get("data") or data)
  187. def refresh_access_token(self) -> None:
  188. """Use the stored refresh token to obtain a new access token.
  189. Raises TokenExpiredError if the refresh token is also expired or invalid.
  190. """
  191. if not self._refresh_token:
  192. raise TokenExpiredError("No refresh token available.")
  193. _LOGGER.debug("SolarOfThings: refreshing access token")
  194. resp = requests.post(
  195. f"{API_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
  196. json={"refreshToken": self._refresh_token},
  197. headers={
  198. "Accept": "application/json",
  199. "Content-Type": "application/json; charset=utf-8",
  200. "Origin": "https://solar.siseli.com",
  201. "Referer": "https://solar.siseli.com/",
  202. },
  203. timeout=30,
  204. )
  205. if resp.status_code in (401, 403):
  206. raise TokenExpiredError("Refresh token rejected by server (expired or invalid).")
  207. resp.raise_for_status()
  208. data = resp.json()
  209. if data.get("code") not in (0, None, "0"):
  210. raise TokenExpiredError(
  211. f"Refresh failed: code={data.get('code')} message={data.get('message')}"
  212. )
  213. self._store_tokens(data.get("data") or data)
  214. # ─── Internal token management ─────────────────────────────────────────────
  215. def _store_tokens(self, payload: dict[str, Any]) -> None:
  216. """Extract tokens from a login/refresh response payload and persist them."""
  217. access = (
  218. payload.get("accessToken")
  219. or payload.get("iotToken")
  220. or payload.get("token")
  221. or ""
  222. )
  223. refresh = payload.get("refreshToken") or ""
  224. access_exp = (
  225. payload.get("accessTokenWillExpiredAt")
  226. or payload.get("accessTokenExpiredAt")
  227. or ""
  228. )
  229. refresh_exp = (
  230. payload.get("refreshTokenWillExpiredAt")
  231. or payload.get("refreshTokenExpiredAt")
  232. or ""
  233. )
  234. if not access:
  235. raise AuthenticationError(
  236. f"Login/refresh response did not contain an access token. "
  237. f"Keys received: {list(payload.keys())}"
  238. )
  239. self._access_token = access
  240. self._refresh_token = refresh
  241. self._access_expires = _parse_expiry(access_exp)
  242. self._refresh_expires = _parse_expiry(refresh_exp)
  243. # Update session header immediately
  244. self._apply_token_headers()
  245. _LOGGER.debug(
  246. "SolarOfThings: token updated, expires=%s",
  247. self._access_expires.isoformat() if self._access_expires else "unknown",
  248. )
  249. # Notify the HA integration so it can persist the new token state
  250. if self._on_token_refreshed:
  251. try:
  252. self._on_token_refreshed(
  253. self._access_token,
  254. self._refresh_token,
  255. self._access_expires.isoformat() if self._access_expires else "",
  256. self._refresh_expires.isoformat() if self._refresh_expires else "",
  257. )
  258. except Exception as cb_err: # pragma: no cover
  259. _LOGGER.warning("Token-refresh callback raised: %s", cb_err)
  260. def _token_needs_refresh(self) -> bool:
  261. """Return True if the access token is absent or about to expire."""
  262. if not self._access_token:
  263. return True
  264. if self._access_expires is None:
  265. # Unknown expiry: only refresh if we already have a refresh token
  266. return bool(self._refresh_token)
  267. lead = timedelta(seconds=TOKEN_REFRESH_LEAD_SECONDS)
  268. return datetime.now(timezone.utc) >= (self._access_expires - lead)
  269. def _ensure_token_valid(self) -> None:
  270. """Proactively refresh the access token if needed.
  271. Thread-safe: uses a lock so parallel coordinator updates don't
  272. trigger multiple simultaneous refresh calls.
  273. Raises TokenExpiredError when all refresh strategies are exhausted.
  274. """
  275. if not self._token_needs_refresh():
  276. return
  277. with self._refresh_lock:
  278. # Double-check inside the lock (another thread may have refreshed)
  279. if not self._token_needs_refresh():
  280. return
  281. _LOGGER.info("SolarOfThings: access token expiring; attempting refresh")
  282. # Strategy 1: use refresh token
  283. if self._refresh_token:
  284. try:
  285. self.refresh_access_token()
  286. return
  287. except TokenExpiredError:
  288. _LOGGER.warning(
  289. "SolarOfThings: refresh token expired/invalid; "
  290. "attempting re-login"
  291. )
  292. except Exception as err:
  293. _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
  294. # Strategy 2: re-login with stored credentials
  295. if self._auth_mode == "password" and self._email and self._password:
  296. try:
  297. self.login()
  298. return
  299. except AuthenticationError as err:
  300. raise TokenExpiredError(
  301. f"Re-login failed (credentials rejected): {err}"
  302. ) from err
  303. except Exception as err:
  304. raise TokenExpiredError(
  305. f"Re-login failed (network error): {err}"
  306. ) from err
  307. # Strategy 3: nothing left — tell HA to trigger re-auth
  308. raise TokenExpiredError(
  309. "Access token expired and no refresh strategy succeeded. "
  310. "Please re-authenticate in Home Assistant."
  311. )
  312. # ─── Internal HTTP helper ──────────────────────────────────────────────────
  313. def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
  314. """Perform a POST, automatically refreshing the token on 401.
  315. On second 401 (after refresh) raises TokenExpiredError.
  316. """
  317. self._ensure_token_valid()
  318. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  319. if resp.status_code == 401:
  320. _LOGGER.warning("SolarOfThings: received 401; forcing token refresh")
  321. # Force an immediate refresh even if _token_needs_refresh() is False
  322. self._access_expires = None
  323. self._ensure_token_valid()
  324. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  325. resp.raise_for_status()
  326. return resp.json()
  327. # ─── Public properties (for persistence in HA config entry) ───────────────
  328. @property
  329. def access_token(self) -> str:
  330. return self._access_token
  331. @property
  332. def refresh_token(self) -> str:
  333. return self._refresh_token
  334. @property
  335. def access_token_expires_iso(self) -> str:
  336. return self._access_expires.isoformat() if self._access_expires else ""
  337. @property
  338. def refresh_token_expires_iso(self) -> str:
  339. return self._refresh_expires.isoformat() if self._refresh_expires else ""
  340. # ─── Time helpers ──────────────────────────────────────────────────────────
  341. def _now(self) -> datetime:
  342. if ZoneInfo:
  343. try:
  344. return datetime.now(tz=ZoneInfo(self._time_zone))
  345. except Exception:
  346. return datetime.now()
  347. return datetime.now()
  348. def _format_time(self, dt: datetime) -> str:
  349. if ZoneInfo:
  350. try:
  351. dt = dt.astimezone(ZoneInfo(self._time_zone))
  352. except Exception:
  353. pass
  354. return dt.replace(microsecond=0).isoformat()
  355. # ─── Station → device listing ──────────────────────────────────────────────
  356. def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
  357. """Return all devices under a station (paginated)."""
  358. devices: list[dict[str, Any]] = []
  359. page = 1
  360. total: int | None = None
  361. while True:
  362. data = self._post(
  363. API_DEVICE_LIST,
  364. {"page": page, "count": page_size, "stationId": station_id},
  365. )
  366. if data.get("code") not in (0, None):
  367. raise RuntimeError(
  368. f"Device list error code={data.get('code')} "
  369. f"message={data.get('message')}"
  370. )
  371. d = data.get("data") or {}
  372. total = d.get("total", total)
  373. batch = d.get("list") or []
  374. if not isinstance(batch, list):
  375. batch = []
  376. devices.extend(batch)
  377. if total is None:
  378. if len(batch) < page_size:
  379. break
  380. else:
  381. if len(devices) >= int(total):
  382. break
  383. if not batch:
  384. break
  385. page += 1
  386. return devices
  387. # ─── Time-series (per device) ──────────────────────────────────────────────
  388. def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
  389. """Fetch the latest readings for a device (last 1 hour)."""
  390. end_time = self._now()
  391. start_time = end_time - timedelta(hours=1)
  392. keys = [
  393. "pvInputPower",
  394. "acOutputActivePower",
  395. "batteryDischargeCurrent",
  396. "batteryChargingCurrent",
  397. "batteryVoltage",
  398. "feedInPower",
  399. "batterySOC",
  400. ]
  401. data = self._post(
  402. API_TIME_SERIES,
  403. {
  404. "deviceId": device_id,
  405. "count": 2000,
  406. "page": 1,
  407. "fromTime": self._format_time(start_time),
  408. "toTime": self._format_time(end_time),
  409. "orderByTimeAsc": True,
  410. "keys": keys,
  411. },
  412. )
  413. if data.get("code") not in (0, None):
  414. raise RuntimeError(
  415. f"Timeseries error code={data.get('code')} "
  416. f"message={data.get('message')}"
  417. )
  418. payload_data = (data.get("data") or {}).get("payload") or {}
  419. fields = payload_data.get("fields") or {}
  420. latest_values: dict[str, Any] = {}
  421. for key, arr in fields.items():
  422. if isinstance(arr, list) and arr:
  423. latest_values[key] = arr[-1]
  424. # Unit normalisation: acOutputActivePower is kW in API → W
  425. if "acOutputActivePower" in latest_values:
  426. try:
  427. latest_values["acOutputActivePower"] = (
  428. float(latest_values["acOutputActivePower"]) * 1000.0
  429. )
  430. except Exception:
  431. pass
  432. # Derived values
  433. voltage = float(latest_values.get("batteryVoltage") or 0)
  434. discharge = float(latest_values.get("batteryDischargeCurrent") or 0)
  435. charge = float(latest_values.get("batteryChargingCurrent") or 0)
  436. latest_values["batteryPower"] = (discharge - charge) * voltage
  437. pv_power = float(latest_values.get("pvInputPower") or 0)
  438. ac_output = float(latest_values.get("acOutputActivePower") or 0)
  439. feed_in = float(latest_values.get("feedInPower") or 0)
  440. battery_power = float(latest_values.get("batteryPower") or 0)
  441. latest_values["gridPower"] = max(0.0, ac_output - pv_power + battery_power + feed_in)
  442. latest_values["loadPower"] = ac_output
  443. return latest_values
  444. # ─── Monthly summary (station) ─────────────────────────────────────────────
  445. def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
  446. """Fetch monthly PV summary for the current month."""
  447. now = self._now()
  448. year = now.year
  449. month_key = f"{year}-{str(now.month).zfill(2)}"
  450. self._ensure_token_valid()
  451. resp = self.session.post(
  452. f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
  453. f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass",
  454. json={"time": str(year)},
  455. timeout=30,
  456. )
  457. resp.raise_for_status()
  458. data = resp.json()
  459. if data.get("code") not in (0, None):
  460. raise RuntimeError(
  461. f"Monthly summary error code={data.get('code')} "
  462. f"message={data.get('message')}"
  463. )
  464. props = (((data.get("data") or {}).get("properties")) or [])
  465. out: dict[str, Any] = {}
  466. for prop in props:
  467. prop_key = ((prop.get("property") or {}).get("key"))
  468. if not prop_key:
  469. continue
  470. for tp in prop.get("timePoints") or []:
  471. if tp.get("time") == month_key and tp.get("isRealValue"):
  472. out[prop_key] = tp.get("value")
  473. if "pvGeneratedEnergy" in out:
  474. return {"monthly_pv_generated": float(out.get("pvGeneratedEnergy") or 0)}
  475. return {}
  476. # ─── Settings / control (per device) ──────────────────────────────────────
  477. def fetch_settings(self, device_id: str) -> dict[str, Any]:
  478. """Fetch current device settings."""
  479. try:
  480. data = self._post(API_SETTINGS_GET, {"deviceId": device_id})
  481. settings: dict[str, Any] = {}
  482. if "data" in data:
  483. sd = data["data"]
  484. settings["batteryChargeLimit"] = sd.get("batteryChargeLimit", 100)
  485. settings["batteryDischargeLimit"] = sd.get("batteryDischargeLimit", 10)
  486. settings["gridChargeLimit"] = sd.get("gridChargeLimit", 0)
  487. settings["operatingMode"] = sd.get("operatingMode", "Self-Use")
  488. settings["batteryPriority"] = sd.get("batteryPriority", "Solar First")
  489. settings["gridChargingEnabled"] = sd.get("gridChargingEnabled", False)
  490. settings["gridFeedInEnabled"] = sd.get("gridFeedInEnabled", True)
  491. settings["backupModeEnabled"] = sd.get("backupModeEnabled", False)
  492. return settings
  493. except requests.exceptions.RequestException as err:
  494. _LOGGER.error("Error fetching settings for device %s: %s", device_id, err)
  495. return {}
  496. def _update_setting(self, device_id: str, setting_key: str, value: Any) -> bool:
  497. try:
  498. data = self._post(
  499. API_SETTINGS_SET,
  500. {"deviceId": device_id, "settings": {setting_key: value}},
  501. )
  502. if data.get("success") is True or data.get("code") in (0, None):
  503. _LOGGER.info("Updated %s=%s for device %s", setting_key, value, device_id)
  504. return True
  505. _LOGGER.error("Failed to update %s for device %s: %s", setting_key, device_id, data)
  506. return False
  507. except requests.exceptions.RequestException as err:
  508. _LOGGER.error("Error updating %s for device %s: %s", setting_key, device_id, err)
  509. return False
  510. def set_battery_charge_limit(self, device_id: str, limit: int) -> bool:
  511. return self._update_setting(device_id, "batteryChargeLimit", limit)
  512. def set_battery_discharge_limit(self, device_id: str, limit: int) -> bool:
  513. return self._update_setting(device_id, "batteryDischargeLimit", limit)
  514. def set_grid_charge_limit(self, device_id: str, limit: int) -> bool:
  515. return self._update_setting(device_id, "gridChargeLimit", limit)
  516. def set_operating_mode(self, device_id: str, mode: str) -> bool:
  517. return self._update_setting(device_id, "operatingMode", mode)
  518. def set_battery_priority(self, device_id: str, priority: str) -> bool:
  519. return self._update_setting(device_id, "batteryPriority", priority)
  520. def set_grid_charging(self, device_id: str, enabled: bool) -> bool:
  521. return self._update_setting(device_id, "gridChargingEnabled", enabled)
  522. def set_grid_feed_in(self, device_id: str, enabled: bool) -> bool:
  523. return self._update_setting(device_id, "gridFeedInEnabled", enabled)
  524. def set_backup_mode(self, device_id: str, enabled: bool) -> bool:
  525. return self._update_setting(device_id, "backupModeEnabled", enabled)
  526. # ─── Validation ───────────────────────────────────────────────────────────
  527. def test_connection(self, station_id: str) -> bool:
  528. """Validate credentials + station by listing devices and querying telemetry."""
  529. try:
  530. devices = self.list_devices(station_id)
  531. if not devices:
  532. return False
  533. device_id = str((devices[0].get("id") or ""))
  534. if not device_id:
  535. return False
  536. self.fetch_latest_data(device_id)
  537. return True
  538. except TokenExpiredError:
  539. raise
  540. except Exception as err:
  541. _LOGGER.error("Connection test failed: %s", err)
  542. return False