api.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. """API client for Solar of Things (solar.siseli.com).
  2. Authentication strategy
  3. ─────────────────────────────────────────────────────────────────────────────
  4. The Siseli portal uses a dual-token system. Login requires the IOT Open
  5. Platform signing scheme discovered via JS bundle analysis (umi.js):
  6. Endpoint : POST https://test.solar.siseli.com/apis/login/account
  7. Signed : yes — IOT-Open-AppID, IOT-Open-Nonce, IOT-Open-Body-Hash,
  8. IOT-Open-Sign headers
  9. Signing algorithm (reverse-engineered from portal umi.js):
  10. 1. Build a dict of signing headers:
  11. {"IOT-Open-AppID": appId,
  12. "IOT-Open-Body-Hash": sha256(body_bytes).lower(),
  13. "IOT-Open-Nonce": random_32_char_hex}
  14. 2. Sort keys alphabetically, join as k1=v1&k2=v2 (no URL-encoding).
  15. 3. base64-encode the resulting string.
  16. 4. HMAC-SHA256(b64_str, decrypted_app_secret) [bytes result]
  17. 5. MD5(hmac_bytes).hexdigest() → IOT-Open-Sign value
  18. App secret decryption (qe() in umi.js):
  19. key = MD5(appId).lower()[:16] treated as ASCII bytes (16 bytes = AES-128)
  20. iv = MD5(appId).lower()[16:] treated as ASCII bytes (16 bytes)
  21. AES-128-CBC-ZeroPadding decrypt of base64(encrypted_secret)
  22. After successful login the server returns an accessToken (used as
  23. IOT-Token header for data requests) and a refreshToken.
  24. This class supports three auth modes, tried in priority order:
  25. 1. User-ID + password (recommended)
  26. • Call login() at startup → stores both tokens in memory.
  27. • _ensure_token_valid() checks expiry before every API call and
  28. proactively refreshes (TOKEN_REFRESH_LEAD_SECONDS = 5 min before
  29. expiry, mirroring the portal JS behaviour).
  30. • If refresh fails, raises TokenExpiredError so the HA integration
  31. can trigger a re-auth flow.
  32. 2. Token-pair (accessToken + refreshToken) without password
  33. • User pastes both tokens from DevTools.
  34. • Same proactive-refresh logic; re-auth needed when refreshToken
  35. expires.
  36. 3. Legacy IOT-token only (backwards compatibility)
  37. • No refresh possible; raises TokenExpiredError on 401 so HA can
  38. prompt the user to re-enter a fresh token.
  39. Usage in Home Assistant
  40. ─────────────────────────────────────────────────────────────────────────────
  41. api = SolarOfThingsAPI(
  42. user_id="myaccount",
  43. password="secret", # or omit and pass iot_token=
  44. time_zone="Asia/Manila",
  45. on_token_refreshed=_save_tokens_callback,
  46. )
  47. await hass.async_add_executor_job(api.login)
  48. data = await hass.async_add_executor_job(api.fetch_latest_data, device_id)
  49. """
  50. from __future__ import annotations
  51. import base64
  52. import hashlib
  53. import hmac as _hmac
  54. import logging
  55. import os
  56. import threading
  57. from datetime import datetime, timezone, timedelta
  58. from typing import Any, Callable
  59. import requests
  60. try:
  61. from Crypto.Cipher import AES as _AES
  62. _CRYPTO_AVAILABLE = True
  63. except ImportError:
  64. _CRYPTO_AVAILABLE = False
  65. try:
  66. from zoneinfo import ZoneInfo
  67. except Exception: # pragma: no cover
  68. ZoneInfo = None # type: ignore[assignment]
  69. from .const import (
  70. API_BASE_URL,
  71. API_AUTH_BASE_URL,
  72. API_LOGIN,
  73. API_REFRESH_TOKEN as API_REFRESH_TOKEN_ENDPOINT,
  74. API_TIME_SERIES,
  75. API_MONTHLY_SUMMARY,
  76. API_DEVICE_LIST,
  77. API_SETTINGS_GET,
  78. API_SETTINGS_SET,
  79. IOT_APP_ID,
  80. IOT_APP_SECRET_ENC,
  81. TOKEN_REFRESH_LEAD_SECONDS,
  82. )
  83. _LOGGER = logging.getLogger(__name__)
  84. _DEFAULT_TZ = "Asia/Manila"
  85. # ──────────────────────────────────────────────────────────────────────────────
  86. # Custom exceptions
  87. # ──────────────────────────────────────────────────────────────────────────────
  88. class TokenExpiredError(Exception):
  89. """Raised when the access token has expired and cannot be refreshed.
  90. The HA integration should catch this and call
  91. config_entry.async_start_reauth() so the user can re-enter credentials.
  92. """
  93. class AuthenticationError(Exception):
  94. """Raised when login credentials are rejected by the server."""
  95. # ──────────────────────────────────────────────────────────────────────────────
  96. # Signing helpers (reverse-engineered from portal umi.js)
  97. # ──────────────────────────────────────────────────────────────────────────────
  98. def _decrypt_app_secret(app_id: str, encrypted_b64: str) -> str:
  99. """AES-128-CBC decrypt the embedded app secret.
  100. Key derivation mirrors the portal qe() function:
  101. key = MD5(app_id).lower()[:16] as ASCII bytes
  102. iv = MD5(app_id).lower()[16:] as ASCII bytes
  103. The ciphertext is the base64-decoded encrypted_b64 value.
  104. """
  105. if not _CRYPTO_AVAILABLE:
  106. raise RuntimeError(
  107. "pycryptodome is not installed. "
  108. "Add 'pycryptodome' to the integration requirements."
  109. )
  110. md5_hex = hashlib.md5(app_id.encode("utf-8")).hexdigest()
  111. key = md5_hex[:16].encode("ascii") # 16 bytes — AES-128
  112. iv = md5_hex[16:].encode("ascii") # 16 bytes — CBC IV
  113. ciphertext = base64.b64decode(encrypted_b64)
  114. cipher = _AES.new(key, _AES.MODE_CBC, iv)
  115. plaintext = cipher.decrypt(ciphertext).rstrip(b"\x00")
  116. return plaintext.decode("utf-8")
  117. def _compute_iot_sign(app_id: str, nonce: str, body_hash: str, secret: str) -> str:
  118. """Compute the IOT-Open-Sign header value.
  119. Algorithm (Ye() in portal umi.js):
  120. 1. Sort signing headers alphabetically by key.
  121. 2. qs.stringify → "IOT-Open-AppID=X&IOT-Open-Body-Hash=Y&IOT-Open-Nonce=Z"
  122. 3. Base64-encode the qs string.
  123. 4. HMAC-SHA256(b64_qs, secret) → raw bytes.
  124. 5. MD5(hmac_bytes).hexdigest() → sign value.
  125. """
  126. sign_headers = {
  127. "IOT-Open-AppID": app_id,
  128. "IOT-Open-Body-Hash": body_hash,
  129. "IOT-Open-Nonce": nonce,
  130. }
  131. qs_str = "&".join(f"{k}={sign_headers[k]}" for k in sorted(sign_headers.keys()))
  132. b64_qs = base64.b64encode(qs_str.encode("utf-8")).decode("ascii")
  133. hmac_bytes = _hmac.new(secret.encode("utf-8"), b64_qs.encode("utf-8"), hashlib.sha256).digest()
  134. return hashlib.md5(hmac_bytes).hexdigest()
  135. def _make_signed_headers(body_bytes: bytes, extra: dict[str, str] | None = None) -> dict[str, str]:
  136. """Build the complete set of IOT Open Platform signed request headers.
  137. Returns headers suitable for POST to API_AUTH_BASE_URL endpoints.
  138. """
  139. secret = _decrypt_app_secret(IOT_APP_ID, IOT_APP_SECRET_ENC)
  140. nonce = os.urandom(16).hex() # 32-char hex nonce
  141. body_hash = hashlib.sha256(body_bytes).hexdigest()
  142. sign = _compute_iot_sign(IOT_APP_ID, nonce, body_hash, secret)
  143. headers: dict[str, str] = {
  144. "Accept": "application/json",
  145. "Content-Type": "application/json; charset=utf-8",
  146. "Origin": "https://solar.siseli.com",
  147. "Referer": "https://solar.siseli.com/",
  148. "IOT-Open-AppID": IOT_APP_ID,
  149. "IOT-Open-Nonce": nonce,
  150. "IOT-Open-Body-Hash": body_hash,
  151. "IOT-Open-Sign": sign,
  152. }
  153. if extra:
  154. headers.update(extra)
  155. return headers
  156. # ──────────────────────────────────────────────────────────────────────────────
  157. # Helper: parse Siseli ISO expiry strings safely
  158. # ──────────────────────────────────────────────────────────────────────────────
  159. def _parse_expiry(value: str | None) -> datetime | None:
  160. """Return an aware UTC datetime from an ISO-8601 string, or None."""
  161. if not value:
  162. return None
  163. try:
  164. # Python 3.7+ fromisoformat doesn't handle trailing 'Z'
  165. cleaned = value.replace("Z", "+00:00")
  166. dt = datetime.fromisoformat(cleaned)
  167. if dt.tzinfo is None:
  168. dt = dt.replace(tzinfo=timezone.utc)
  169. return dt.astimezone(timezone.utc)
  170. except Exception:
  171. return None
  172. # ──────────────────────────────────────────────────────────────────────────────
  173. # Main API client
  174. # ──────────────────────────────────────────────────────────────────────────────
  175. class SolarOfThingsAPI:
  176. """Solar of Things API wrapper with automatic token refresh.
  177. Parameters
  178. ----------
  179. user_id: Siseli portal login account / user-ID (preferred auth method).
  180. password: Siseli portal password.
  181. iot_token: Legacy/manual IOT-Token (used when user_id/password absent).
  182. refresh_token: Stored refresh token (persisted between HA restarts).
  183. access_token_expires: ISO-8601 string of current access-token expiry.
  184. refresh_token_expires: ISO-8601 string of current refresh-token expiry.
  185. time_zone: IOT-Time-Zone header value.
  186. on_token_refreshed: Optional callback(access_token, refresh_token,
  187. access_expires_iso, refresh_expires_iso) called after
  188. every successful token refresh so the HA entry can
  189. persist the new tokens without restarting.
  190. """
  191. def __init__(
  192. self,
  193. *,
  194. user_id: str | None = None,
  195. password: str | None = None,
  196. iot_token: str | None = None,
  197. refresh_token: str | None = None,
  198. access_token_expires: str | None = None,
  199. refresh_token_expires: str | None = None,
  200. time_zone: str | None = None,
  201. on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
  202. ) -> None:
  203. self._user_id = user_id
  204. self._password = password
  205. self._time_zone = time_zone or _DEFAULT_TZ
  206. self._on_token_refreshed = on_token_refreshed
  207. # Token state
  208. self._access_token: str = iot_token or ""
  209. self._refresh_token: str = refresh_token or ""
  210. self._access_expires: datetime | None = _parse_expiry(access_token_expires)
  211. self._refresh_expires: datetime | None = _parse_expiry(refresh_token_expires)
  212. # Thread-safety for concurrent refresh calls
  213. self._refresh_lock = threading.Lock()
  214. # Determine auth mode
  215. if user_id and password:
  216. self._auth_mode = "password"
  217. elif iot_token and refresh_token:
  218. self._auth_mode = "token_pair"
  219. elif iot_token:
  220. self._auth_mode = "legacy"
  221. else:
  222. raise ValueError("Provide either (user_id + password) or iot_token.")
  223. # HTTP session (headers updated after every token refresh)
  224. self.session = requests.Session()
  225. self._apply_token_headers()
  226. # ─── Session headers ───────────────────────────────────────────────────────
  227. def _apply_token_headers(self) -> None:
  228. """Write the current access token into the session headers."""
  229. self.session.headers.update(
  230. {
  231. "Accept": "application/json",
  232. "Content-Type": "application/json; charset=utf-8",
  233. "IOT-Token": self._access_token,
  234. "IOT-Time-Zone": self._time_zone,
  235. "Origin": "https://solar.siseli.com",
  236. "Referer": "https://solar.siseli.com/",
  237. "User-Agent": (
  238. "HomeAssistant-SolarOfThings/2.3.0 "
  239. "(+https://github.com/conexocasa/solar-of-things-ha)"
  240. ),
  241. }
  242. )
  243. # ─── Public auth helpers ───────────────────────────────────────────────────
  244. def login(self) -> None:
  245. """Authenticate with user-ID + password and store the resulting tokens.
  246. Uses the IOT Open Platform signed request format discovered from the
  247. portal JS bundle. The login endpoint is:
  248. POST https://solar.siseli.com/apis/login/account
  249. The password is sent as MD5(plaintext_password) lowercase hex — this
  250. is how the portal processes it before transmitting.
  251. Raises AuthenticationError on bad credentials, or requests.RequestException
  252. on network failure. Safe to call from a background thread.
  253. """
  254. if self._auth_mode not in ("password",):
  255. raise RuntimeError("login() requires user_id + password auth mode.")
  256. _LOGGER.debug("SolarOfThings: logging in as %s", self._user_id)
  257. import json as _json
  258. # The portal sends the password as MD5(plaintext_password) lowercase hex.
  259. # Sending plaintext returns code 7 "password error" even with valid creds.
  260. password_md5 = hashlib.md5(self._password.encode("utf-8")).hexdigest()
  261. payload = {
  262. "account": self._user_id,
  263. "password": password_md5,
  264. }
  265. body_bytes = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
  266. headers = _make_signed_headers(body_bytes)
  267. resp = requests.post(
  268. f"{API_AUTH_BASE_URL}{API_LOGIN}",
  269. data=body_bytes,
  270. headers=headers,
  271. timeout=30,
  272. )
  273. resp.raise_for_status()
  274. data = resp.json()
  275. if data.get("code") not in (0, None, "0"):
  276. msg = data.get("message") or data.get("msg") or str(data)
  277. raise AuthenticationError(f"Login failed: {msg}")
  278. self._store_tokens(data.get("data") or data)
  279. def refresh_access_token(self) -> None:
  280. """Use the stored refresh token to obtain a new access token.
  281. Raises TokenExpiredError if the refresh token is also expired or invalid.
  282. """
  283. if not self._refresh_token:
  284. raise TokenExpiredError("No refresh token available.")
  285. _LOGGER.debug("SolarOfThings: refreshing access token")
  286. resp = requests.post(
  287. f"{API_AUTH_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
  288. json={"refreshToken": self._refresh_token},
  289. headers={
  290. "Accept": "application/json",
  291. "Content-Type": "application/json; charset=utf-8",
  292. "Origin": "https://solar.siseli.com",
  293. "Referer": "https://solar.siseli.com/",
  294. },
  295. timeout=30,
  296. )
  297. if resp.status_code in (401, 403):
  298. raise TokenExpiredError("Refresh token rejected by server (expired or invalid).")
  299. resp.raise_for_status()
  300. data = resp.json()
  301. if data.get("code") not in (0, None, "0"):
  302. raise TokenExpiredError(
  303. f"Refresh failed: code={data.get('code')} message={data.get('message')}"
  304. )
  305. self._store_tokens(data.get("data") or data)
  306. # ─── Internal token management ─────────────────────────────────────────────
  307. def _store_tokens(self, payload: dict[str, Any]) -> None:
  308. """Extract tokens from a login/refresh response payload and persist them."""
  309. access = (
  310. payload.get("accessToken")
  311. or payload.get("iotToken")
  312. or payload.get("token")
  313. or ""
  314. )
  315. refresh = payload.get("refreshToken") or ""
  316. access_exp = (
  317. payload.get("accessTokenWillExpiredAt")
  318. or payload.get("accessTokenExpiredAt")
  319. or ""
  320. )
  321. refresh_exp = (
  322. payload.get("refreshTokenWillExpiredAt")
  323. or payload.get("refreshTokenExpiredAt")
  324. or ""
  325. )
  326. if not access:
  327. raise AuthenticationError(
  328. f"Login/refresh response did not contain an access token. "
  329. f"Keys received: {list(payload.keys())}"
  330. )
  331. self._access_token = access
  332. self._refresh_token = refresh
  333. self._access_expires = _parse_expiry(access_exp)
  334. self._refresh_expires = _parse_expiry(refresh_exp)
  335. # Update session header immediately
  336. self._apply_token_headers()
  337. _LOGGER.debug(
  338. "SolarOfThings: token updated, expires=%s",
  339. self._access_expires.isoformat() if self._access_expires else "unknown",
  340. )
  341. # Notify the HA integration so it can persist the new token state
  342. if self._on_token_refreshed:
  343. try:
  344. self._on_token_refreshed(
  345. self._access_token,
  346. self._refresh_token,
  347. self._access_expires.isoformat() if self._access_expires else "",
  348. self._refresh_expires.isoformat() if self._refresh_expires else "",
  349. )
  350. except Exception as cb_err: # pragma: no cover
  351. _LOGGER.warning("Token-refresh callback raised: %s", cb_err)
  352. def _token_needs_refresh(self) -> bool:
  353. """Return True if the access token is absent or about to expire."""
  354. if not self._access_token:
  355. return True
  356. if self._access_expires is None:
  357. # Unknown expiry: only refresh if we already have a refresh token
  358. return bool(self._refresh_token)
  359. lead = timedelta(seconds=TOKEN_REFRESH_LEAD_SECONDS)
  360. return datetime.now(timezone.utc) >= (self._access_expires - lead)
  361. def _ensure_token_valid(self) -> None:
  362. """Proactively refresh the access token if needed.
  363. Thread-safe: uses a lock so parallel coordinator updates don't
  364. trigger multiple simultaneous refresh calls.
  365. Raises TokenExpiredError when all refresh strategies are exhausted.
  366. """
  367. if not self._token_needs_refresh():
  368. return
  369. with self._refresh_lock:
  370. # Double-check inside the lock (another thread may have refreshed)
  371. if not self._token_needs_refresh():
  372. return
  373. _LOGGER.info("SolarOfThings: access token expiring; attempting refresh")
  374. # Strategy 1: use refresh token
  375. if self._refresh_token:
  376. try:
  377. self.refresh_access_token()
  378. return
  379. except TokenExpiredError:
  380. _LOGGER.warning(
  381. "SolarOfThings: refresh token expired/invalid; "
  382. "attempting re-login"
  383. )
  384. except Exception as err:
  385. _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
  386. # Strategy 2: re-login with stored credentials
  387. if self._auth_mode == "password" and self._user_id and self._password:
  388. try:
  389. self.login()
  390. return
  391. except AuthenticationError as err:
  392. raise TokenExpiredError(
  393. f"Re-login failed (credentials rejected): {err}"
  394. ) from err
  395. except Exception as err:
  396. raise TokenExpiredError(
  397. f"Re-login failed (network error): {err}"
  398. ) from err
  399. # Strategy 3: nothing left — tell HA to trigger re-auth
  400. raise TokenExpiredError(
  401. "Access token expired and no refresh strategy succeeded. "
  402. "Please re-authenticate in Home Assistant."
  403. )
  404. # ─── Internal HTTP helper ──────────────────────────────────────────────────
  405. def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
  406. """Perform a POST to a data endpoint, automatically refreshing the token on 401.
  407. On second 401 (after refresh) raises TokenExpiredError.
  408. Uses API_BASE_URL (solar.siseli.com) — not the auth base URL.
  409. """
  410. self._ensure_token_valid()
  411. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  412. if resp.status_code == 401:
  413. _LOGGER.warning("SolarOfThings: received 401; forcing token refresh")
  414. # Force an immediate refresh even if _token_needs_refresh() is False
  415. self._access_expires = None
  416. self._ensure_token_valid()
  417. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  418. resp.raise_for_status()
  419. return resp.json()
  420. # ─── Public properties (for persistence in HA config entry) ───────────────
  421. @property
  422. def access_token(self) -> str:
  423. return self._access_token
  424. @property
  425. def refresh_token(self) -> str:
  426. return self._refresh_token
  427. @property
  428. def access_token_expires_iso(self) -> str:
  429. return self._access_expires.isoformat() if self._access_expires else ""
  430. @property
  431. def refresh_token_expires_iso(self) -> str:
  432. return self._refresh_expires.isoformat() if self._refresh_expires else ""
  433. # ─── Time helpers ──────────────────────────────────────────────────────────
  434. def _now(self) -> datetime:
  435. if ZoneInfo:
  436. try:
  437. return datetime.now(tz=ZoneInfo(self._time_zone))
  438. except Exception:
  439. return datetime.now()
  440. return datetime.now()
  441. def _format_time(self, dt: datetime) -> str:
  442. if ZoneInfo:
  443. try:
  444. dt = dt.astimezone(ZoneInfo(self._time_zone))
  445. except Exception:
  446. pass
  447. return dt.replace(microsecond=0).isoformat()
  448. # ─── Station → device listing ──────────────────────────────────────────────
  449. def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
  450. """Return all devices under a station (paginated)."""
  451. devices: list[dict[str, Any]] = []
  452. page = 1
  453. total: int | None = None
  454. while True:
  455. data = self._post(
  456. API_DEVICE_LIST,
  457. {"page": page, "count": page_size, "stationId": station_id},
  458. )
  459. if data.get("code") not in (0, None):
  460. raise RuntimeError(
  461. f"Device list error code={data.get('code')} "
  462. f"message={data.get('message')}"
  463. )
  464. d = data.get("data") or {}
  465. total = d.get("total", total)
  466. batch = d.get("list") or []
  467. if not isinstance(batch, list):
  468. batch = []
  469. devices.extend(batch)
  470. if total is None:
  471. if len(batch) < page_size:
  472. break
  473. else:
  474. if len(devices) >= int(total):
  475. break
  476. if not batch:
  477. break
  478. page += 1
  479. return devices
  480. # ─── Time-series (per device) ──────────────────────────────────────────────
  481. def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
  482. """Fetch the latest readings for a device (last 1 hour)."""
  483. end_time = self._now()
  484. start_time = end_time - timedelta(hours=1)
  485. keys = [
  486. "pvInputPower",
  487. "acOutputActivePower",
  488. "batteryDischargeCurrent",
  489. "batteryChargingCurrent",
  490. "batteryVoltage",
  491. "feedInPower",
  492. "batterySOC",
  493. ]
  494. data = self._post(
  495. API_TIME_SERIES,
  496. {
  497. "deviceId": device_id,
  498. "count": 2000,
  499. "page": 1,
  500. "fromTime": self._format_time(start_time),
  501. "toTime": self._format_time(end_time),
  502. "orderByTimeAsc": True,
  503. "keys": keys,
  504. },
  505. )
  506. if data.get("code") not in (0, None):
  507. raise RuntimeError(
  508. f"Timeseries error code={data.get('code')} "
  509. f"message={data.get('message')}"
  510. )
  511. payload_data = (data.get("data") or {}).get("payload") or {}
  512. fields = payload_data.get("fields") or {}
  513. latest_values: dict[str, Any] = {}
  514. for key, arr in fields.items():
  515. if isinstance(arr, list) and arr:
  516. latest_values[key] = arr[-1]
  517. # Unit normalisation: acOutputActivePower is kW in API → W
  518. if "acOutputActivePower" in latest_values:
  519. try:
  520. latest_values["acOutputActivePower"] = (
  521. float(latest_values["acOutputActivePower"]) * 1000.0
  522. )
  523. except Exception:
  524. pass
  525. # Derived values
  526. voltage = float(latest_values.get("batteryVoltage") or 0)
  527. discharge = float(latest_values.get("batteryDischargeCurrent") or 0)
  528. charge = float(latest_values.get("batteryChargingCurrent") or 0)
  529. latest_values["batteryPower"] = (discharge - charge) * voltage
  530. pv_power = float(latest_values.get("pvInputPower") or 0)
  531. ac_output = float(latest_values.get("acOutputActivePower") or 0)
  532. feed_in = float(latest_values.get("feedInPower") or 0)
  533. battery_power = float(latest_values.get("batteryPower") or 0)
  534. latest_values["gridPower"] = max(0.0, ac_output - pv_power + battery_power + feed_in)
  535. latest_values["loadPower"] = ac_output
  536. return latest_values
  537. # ─── Monthly summary (station) ─────────────────────────────────────────────
  538. def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
  539. """Fetch monthly PV summary for the current month."""
  540. now = self._now()
  541. year = now.year
  542. month_key = f"{year}-{str(now.month).zfill(2)}"
  543. self._ensure_token_valid()
  544. resp = self.session.post(
  545. f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
  546. f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass",
  547. json={"time": str(year)},
  548. timeout=30,
  549. )
  550. resp.raise_for_status()
  551. data = resp.json()
  552. if data.get("code") not in (0, None):
  553. raise RuntimeError(
  554. f"Monthly summary error code={data.get('code')} "
  555. f"message={data.get('message')}"
  556. )
  557. props = (((data.get("data") or {}).get("properties")) or
  558. (data.get("data") or {}).get("list") or
  559. [])
  560. result: dict[str, Any] = {}
  561. for item in props if isinstance(props, list) else []:
  562. k = item.get("key") or item.get("name")
  563. v = item.get("value")
  564. if k and v is not None:
  565. result[k] = v
  566. # Extract monthly totals (fallback: look for known keys)
  567. monthly: dict[str, Any] = {}
  568. pv_total = result.get(month_key) or result.get("pvTotal") or result.get("pv") or 0
  569. monthly["monthly_pv_generated"] = float(pv_total or 0)
  570. grid_import = result.get("gridImport") or result.get("buy") or 0
  571. monthly["monthly_grid_import"] = float(grid_import or 0)
  572. total_consumption = result.get("totalConsumption") or result.get("load") or 0
  573. monthly["monthly_total_consumption"] = float(total_consumption or 0)
  574. if monthly["monthly_total_consumption"] > 0:
  575. monthly["monthly_solar_percentage"] = round(
  576. 100.0 * monthly["monthly_pv_generated"] / monthly["monthly_total_consumption"], 1
  577. )
  578. else:
  579. monthly["monthly_solar_percentage"] = 0.0
  580. return monthly
  581. # ─── Device settings ───────────────────────────────────────────────────────
  582. def get_device_settings(self, device_id: str) -> dict[str, Any]:
  583. """Fetch current device settings."""
  584. data = self._post(API_SETTINGS_GET, {"deviceId": device_id})
  585. if data.get("code") not in (0, None):
  586. raise RuntimeError(
  587. f"Settings fetch error code={data.get('code')} "
  588. f"message={data.get('message')}"
  589. )
  590. return data.get("data") or {}
  591. # Alias used by the coordinator in __init__.py
  592. fetch_settings = get_device_settings
  593. def update_device_settings(self, device_id: str, settings: dict[str, Any]) -> None:
  594. """Update one or more device settings."""
  595. payload = {"deviceId": device_id, **settings}
  596. data = self._post(API_SETTINGS_SET, payload)
  597. if data.get("code") not in (0, None):
  598. raise RuntimeError(
  599. f"Settings update error code={data.get('code')} "
  600. f"message={data.get('message')}"
  601. )
  602. # ─── Convenience control helpers (called by select.py / switch.py) ─────────
  603. def set_operating_mode(self, device_id: str, mode: str) -> None:
  604. """Set the inverter operating mode (e.g. 'Self-Use', 'Backup')."""
  605. self.update_device_settings(device_id, {"operatingMode": mode})
  606. def set_battery_priority(self, device_id: str, mode: str) -> None:
  607. """Set battery charging priority (e.g. 'Solar First', 'Grid First')."""
  608. self.update_device_settings(device_id, {"batteryPriority": mode})
  609. def set_grid_charging(self, device_id: str, enabled: bool) -> None:
  610. """Enable or disable grid charging."""
  611. self.update_device_settings(device_id, {"gridChargingEnabled": enabled})
  612. def set_grid_feed_in(self, device_id: str, enabled: bool) -> None:
  613. """Enable or disable grid feed-in (export)."""
  614. self.update_device_settings(device_id, {"gridFeedInEnabled": enabled})
  615. def set_backup_mode(self, device_id: str, enabled: bool) -> None:
  616. """Enable or disable backup (reserve) mode."""
  617. self.update_device_settings(device_id, {"backupModeEnabled": enabled})
  618. def test_connection(self, station_id: str) -> bool:
  619. """Return True if we can reach the device-list endpoint successfully."""
  620. try:
  621. devices = self.list_devices(station_id, page_size=1)
  622. return True
  623. except Exception as err:
  624. _LOGGER.error("SolarOfThings: connection test failed: %s", err)
  625. return False