api.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  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. API_ENERGY_FLOW,
  80. ENERGY_FLOW_RULES,
  81. REALTIME_PROBE_KEYS,
  82. IOT_APP_ID,
  83. IOT_APP_SECRET_ENC,
  84. TOKEN_REFRESH_LEAD_SECONDS,
  85. )
  86. _LOGGER = logging.getLogger(__name__)
  87. _DEFAULT_TZ = "Asia/Manila"
  88. # ──────────────────────────────────────────────────────────────────────────────
  89. # Custom exceptions
  90. # ──────────────────────────────────────────────────────────────────────────────
  91. class TokenExpiredError(Exception):
  92. """Raised when the access token has expired and cannot be refreshed.
  93. The HA integration should catch this and call
  94. config_entry.async_start_reauth() so the user can re-enter credentials.
  95. """
  96. class AuthenticationError(Exception):
  97. """Raised when login credentials are rejected by the server."""
  98. # ──────────────────────────────────────────────────────────────────────────────
  99. # Signing helpers (reverse-engineered from portal umi.js)
  100. # ──────────────────────────────────────────────────────────────────────────────
  101. def _decrypt_app_secret(app_id: str, encrypted_b64: str) -> str:
  102. """AES-128-CBC decrypt the embedded app secret.
  103. Key derivation mirrors the portal qe() function:
  104. key = MD5(app_id).lower()[:16] as ASCII bytes
  105. iv = MD5(app_id).lower()[16:] as ASCII bytes
  106. The ciphertext is the base64-decoded encrypted_b64 value.
  107. """
  108. if not _CRYPTO_AVAILABLE:
  109. raise RuntimeError(
  110. "pycryptodome is not installed. "
  111. "Add 'pycryptodome' to the integration requirements."
  112. )
  113. md5_hex = hashlib.md5(app_id.encode("utf-8")).hexdigest()
  114. key = md5_hex[:16].encode("ascii") # 16 bytes — AES-128
  115. iv = md5_hex[16:].encode("ascii") # 16 bytes — CBC IV
  116. ciphertext = base64.b64decode(encrypted_b64)
  117. cipher = _AES.new(key, _AES.MODE_CBC, iv)
  118. plaintext = cipher.decrypt(ciphertext).rstrip(b"\x00")
  119. return plaintext.decode("utf-8")
  120. def _compute_iot_sign(app_id: str, nonce: str, body_hash: str, secret: str) -> str:
  121. """Compute the IOT-Open-Sign header value.
  122. Algorithm (Ye() in portal umi.js):
  123. 1. Sort signing headers alphabetically by key.
  124. 2. qs.stringify → "IOT-Open-AppID=X&IOT-Open-Body-Hash=Y&IOT-Open-Nonce=Z"
  125. 3. Base64-encode the qs string.
  126. 4. HMAC-SHA256(b64_qs, secret) → raw bytes.
  127. 5. MD5(hmac_bytes).hexdigest() → sign value.
  128. """
  129. sign_headers = {
  130. "IOT-Open-AppID": app_id,
  131. "IOT-Open-Body-Hash": body_hash,
  132. "IOT-Open-Nonce": nonce,
  133. }
  134. qs_str = "&".join(f"{k}={sign_headers[k]}" for k in sorted(sign_headers.keys()))
  135. b64_qs = base64.b64encode(qs_str.encode("utf-8")).decode("ascii")
  136. hmac_bytes = _hmac.new(secret.encode("utf-8"), b64_qs.encode("utf-8"), hashlib.sha256).digest()
  137. return hashlib.md5(hmac_bytes).hexdigest()
  138. def _make_signed_headers(body_bytes: bytes, extra: dict[str, str] | None = None) -> dict[str, str]:
  139. """Build the complete set of IOT Open Platform signed request headers.
  140. Returns headers suitable for POST to API_AUTH_BASE_URL endpoints.
  141. """
  142. secret = _decrypt_app_secret(IOT_APP_ID, IOT_APP_SECRET_ENC)
  143. nonce = os.urandom(16).hex() # 32-char hex nonce
  144. body_hash = hashlib.sha256(body_bytes).hexdigest()
  145. sign = _compute_iot_sign(IOT_APP_ID, nonce, body_hash, secret)
  146. headers: dict[str, str] = {
  147. "Accept": "application/json",
  148. "Content-Type": "application/json; charset=utf-8",
  149. "Origin": "https://solar.siseli.com",
  150. "Referer": "https://solar.siseli.com/",
  151. "IOT-Open-AppID": IOT_APP_ID,
  152. "IOT-Open-Nonce": nonce,
  153. "IOT-Open-Body-Hash": body_hash,
  154. "IOT-Open-Sign": sign,
  155. }
  156. if extra:
  157. headers.update(extra)
  158. return headers
  159. # ──────────────────────────────────────────────────────────────────────────────
  160. # Helper: parse Siseli ISO expiry strings safely
  161. # ──────────────────────────────────────────────────────────────────────────────
  162. def _parse_expiry(value: str | None) -> datetime | None:
  163. """Return an aware UTC datetime from an ISO-8601 string, or None."""
  164. if not value:
  165. return None
  166. try:
  167. # Python 3.7+ fromisoformat doesn't handle trailing 'Z'
  168. cleaned = value.replace("Z", "+00:00")
  169. dt = datetime.fromisoformat(cleaned)
  170. if dt.tzinfo is None:
  171. dt = dt.replace(tzinfo=timezone.utc)
  172. return dt.astimezone(timezone.utc)
  173. except Exception:
  174. return None
  175. # ──────────────────────────────────────────────────────────────────────────────
  176. # Energy-flow fallback mapping
  177. # ──────────────────────────────────────────────────────────────────────────────
  178. def _coerce_number(value: Any) -> float | None:
  179. """Best-effort conversion of an API field value to a float.
  180. The energy-flow payload has only been observed second-hand (issue #7), so
  181. accept every shape this portal is known to use elsewhere: a bare number, a
  182. numeric string, a latest-wins list (as the time-series endpoint returns), or
  183. a {"value": ...} wrapper. Anything unrecognised yields None so the caller
  184. leaves the sensor untouched instead of publishing a garbage reading.
  185. """
  186. if value is None or isinstance(value, bool):
  187. return None
  188. if isinstance(value, (int, float)):
  189. return float(value)
  190. if isinstance(value, str):
  191. text = value.strip()
  192. if not text:
  193. return None
  194. try:
  195. return float(text)
  196. except ValueError:
  197. return None
  198. if isinstance(value, list):
  199. for item in reversed(value):
  200. number = _coerce_number(item)
  201. if number is not None:
  202. return number
  203. return None
  204. if isinstance(value, dict):
  205. for key in ("value", "val", "latest"):
  206. if key in value:
  207. return _coerce_number(value[key])
  208. return None
  209. def map_energy_flow_fields(fields: Any) -> dict[str, float]:
  210. """Translate an energy-flow ``fields`` mapping into canonical sensor keys.
  211. Pure function: no network access and no instance state, so it can be tested
  212. directly. Applies ENERGY_FLOW_RULES in declaration order and returns only
  213. the keys it could actually resolve — the caller merges the result without
  214. clobbering better data from the time-series endpoint.
  215. """
  216. if not isinstance(fields, dict) or not fields:
  217. return {}
  218. mapped: dict[str, float] = {}
  219. for canonical, rules in ENERGY_FLOW_RULES.items():
  220. for mode, sources, scale in rules:
  221. if mode == "sum":
  222. values = [
  223. number
  224. for source in sources
  225. if (number := _coerce_number(fields.get(source))) is not None
  226. ]
  227. if values:
  228. mapped[canonical] = sum(values) * scale
  229. else: # "first" — first present source field wins
  230. for source in sources:
  231. number = _coerce_number(fields.get(source))
  232. if number is not None:
  233. mapped[canonical] = number * scale
  234. break
  235. if canonical in mapped:
  236. break
  237. return mapped
  238. def has_realtime_values(values: Any) -> bool:
  239. """Return True if any canonical realtime key carries a usable number."""
  240. if not isinstance(values, dict):
  241. return False
  242. return any(
  243. _coerce_number(values.get(key)) is not None for key in REALTIME_PROBE_KEYS
  244. )
  245. # ──────────────────────────────────────────────────────────────────────────────
  246. # Main API client
  247. # ──────────────────────────────────────────────────────────────────────────────
  248. class SolarOfThingsAPI:
  249. """Solar of Things API wrapper with automatic token refresh.
  250. Parameters
  251. ----------
  252. user_id: Siseli portal login account / user-ID (preferred auth method).
  253. password: Siseli portal password.
  254. iot_token: Legacy/manual IOT-Token (used when user_id/password absent).
  255. refresh_token: Stored refresh token (persisted between HA restarts).
  256. access_token_expires: ISO-8601 string of current access-token expiry.
  257. refresh_token_expires: ISO-8601 string of current refresh-token expiry.
  258. time_zone: IOT-Time-Zone header value.
  259. on_token_refreshed: Optional callback(access_token, refresh_token,
  260. access_expires_iso, refresh_expires_iso) called after
  261. every successful token refresh so the HA entry can
  262. persist the new tokens without restarting.
  263. """
  264. def __init__(
  265. self,
  266. *,
  267. user_id: str | None = None,
  268. password: str | None = None,
  269. iot_token: str | None = None,
  270. refresh_token: str | None = None,
  271. access_token_expires: str | None = None,
  272. refresh_token_expires: str | None = None,
  273. time_zone: str | None = None,
  274. on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
  275. ) -> None:
  276. self._user_id = user_id
  277. self._password = password
  278. self._time_zone = time_zone or _DEFAULT_TZ
  279. self._on_token_refreshed = on_token_refreshed
  280. # Token state
  281. self._access_token: str = iot_token or ""
  282. self._refresh_token: str = refresh_token or ""
  283. self._access_expires: datetime | None = _parse_expiry(access_token_expires)
  284. self._refresh_expires: datetime | None = _parse_expiry(refresh_token_expires)
  285. # Thread-safety for concurrent refresh calls
  286. self._refresh_lock = threading.Lock()
  287. # Determine auth mode
  288. if user_id and password:
  289. self._auth_mode = "password"
  290. elif iot_token and refresh_token:
  291. self._auth_mode = "token_pair"
  292. elif iot_token:
  293. self._auth_mode = "legacy"
  294. else:
  295. raise ValueError("Provide either (user_id + password) or iot_token.")
  296. # HTTP session (headers updated after every token refresh)
  297. self.session = requests.Session()
  298. self._apply_token_headers()
  299. # ─── Session headers ───────────────────────────────────────────────────────
  300. def _apply_token_headers(self) -> None:
  301. """Write the current access token into the session headers."""
  302. self.session.headers.update(
  303. {
  304. "Accept": "application/json",
  305. "Content-Type": "application/json; charset=utf-8",
  306. "IOT-Token": self._access_token,
  307. "IOT-Time-Zone": self._time_zone,
  308. "Origin": "https://solar.siseli.com",
  309. "Referer": "https://solar.siseli.com/",
  310. # Keep this version in sync with manifest.json on each release.
  311. "User-Agent": (
  312. "HomeAssistant-SolarOfThings/2.5.0 "
  313. "(+https://github.com/Conexo-Casa/solar-of-things-ha)"
  314. ),
  315. }
  316. )
  317. # ─── Public auth helpers ───────────────────────────────────────────────────
  318. def login(self) -> None:
  319. """Authenticate with user-ID + password and store the resulting tokens.
  320. Uses the IOT Open Platform signed request format discovered from the
  321. portal JS bundle. The login endpoint is:
  322. POST https://solar.siseli.com/apis/login/account
  323. The password is sent as MD5(plaintext_password) lowercase hex — this
  324. is how the portal processes it before transmitting.
  325. Raises AuthenticationError on bad credentials, or requests.RequestException
  326. on network failure. Safe to call from a background thread.
  327. """
  328. if self._auth_mode not in ("password",):
  329. raise RuntimeError("login() requires user_id + password auth mode.")
  330. _LOGGER.debug("SolarOfThings: logging in as %s", self._user_id)
  331. import json as _json
  332. # The Siseli portal transmits the password as MD5(plaintext) lowercase hex —
  333. # the server rejects plaintext with error code 7. This is a protocol
  334. # requirement of the upstream API, not a choice we can change. The hash
  335. # is sent over HTTPS, so the transport layer provides confidentiality.
  336. # CodeQL alert suppressed: MD5 use here is non-cryptographic (protocol-mandated
  337. # pre-hashing by the upstream service), not used for storage or key derivation.
  338. password_md5 = hashlib.md5(self._password.encode("utf-8")).hexdigest() # noqa: S324
  339. payload = {
  340. "account": self._user_id,
  341. "password": password_md5,
  342. }
  343. body_bytes = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
  344. headers = _make_signed_headers(body_bytes)
  345. resp = requests.post(
  346. f"{API_AUTH_BASE_URL}{API_LOGIN}",
  347. data=body_bytes,
  348. headers=headers,
  349. timeout=30,
  350. )
  351. resp.raise_for_status()
  352. data = resp.json()
  353. if data.get("code") not in (0, None, "0"):
  354. msg = data.get("message") or data.get("msg") or str(data)
  355. raise AuthenticationError(f"Login failed: {msg}")
  356. self._store_tokens(data.get("data") or data)
  357. def refresh_access_token(self) -> None:
  358. """Use the stored refresh token to obtain a new access token.
  359. Raises TokenExpiredError if the refresh token is also expired or invalid.
  360. """
  361. if not self._refresh_token:
  362. raise TokenExpiredError("No refresh token available.")
  363. _LOGGER.debug("SolarOfThings: refreshing access token")
  364. resp = requests.post(
  365. f"{API_AUTH_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
  366. json={"refreshToken": self._refresh_token},
  367. headers={
  368. "Accept": "application/json",
  369. "Content-Type": "application/json; charset=utf-8",
  370. "Origin": "https://solar.siseli.com",
  371. "Referer": "https://solar.siseli.com/",
  372. },
  373. timeout=30,
  374. )
  375. if resp.status_code in (401, 403):
  376. raise TokenExpiredError("Refresh token rejected by server (expired or invalid).")
  377. resp.raise_for_status()
  378. data = resp.json()
  379. if data.get("code") not in (0, None, "0"):
  380. raise TokenExpiredError(
  381. f"Refresh failed: code={data.get('code')} message={data.get('message')}"
  382. )
  383. self._store_tokens(data.get("data") or data)
  384. # ─── Internal token management ─────────────────────────────────────────────
  385. def _store_tokens(self, payload: dict[str, Any]) -> None:
  386. """Extract tokens from a login/refresh response payload and persist them."""
  387. access = (
  388. payload.get("accessToken")
  389. or payload.get("iotToken")
  390. or payload.get("token")
  391. or ""
  392. )
  393. refresh = payload.get("refreshToken") or ""
  394. access_exp = (
  395. payload.get("accessTokenWillExpiredAt")
  396. or payload.get("accessTokenExpiredAt")
  397. or ""
  398. )
  399. refresh_exp = (
  400. payload.get("refreshTokenWillExpiredAt")
  401. or payload.get("refreshTokenExpiredAt")
  402. or ""
  403. )
  404. if not access:
  405. raise AuthenticationError(
  406. f"Login/refresh response did not contain an access token. "
  407. f"Keys received: {list(payload.keys())}"
  408. )
  409. self._access_token = access
  410. self._refresh_token = refresh
  411. self._access_expires = _parse_expiry(access_exp)
  412. self._refresh_expires = _parse_expiry(refresh_exp)
  413. # Update session header immediately
  414. self._apply_token_headers()
  415. _LOGGER.debug(
  416. "SolarOfThings: token updated, expires=%s",
  417. self._access_expires.isoformat() if self._access_expires else "unknown",
  418. )
  419. # Notify the HA integration so it can persist the new token state
  420. if self._on_token_refreshed:
  421. try:
  422. self._on_token_refreshed(
  423. self._access_token,
  424. self._refresh_token,
  425. self._access_expires.isoformat() if self._access_expires else "",
  426. self._refresh_expires.isoformat() if self._refresh_expires else "",
  427. )
  428. except Exception as cb_err: # pragma: no cover
  429. _LOGGER.warning("Token-refresh callback raised: %s", cb_err)
  430. def _token_needs_refresh(self) -> bool:
  431. """Return True if the access token is absent or about to expire."""
  432. if not self._access_token:
  433. return True
  434. if self._access_expires is None:
  435. # Unknown expiry: only refresh if we already have a refresh token
  436. return bool(self._refresh_token)
  437. lead = timedelta(seconds=TOKEN_REFRESH_LEAD_SECONDS)
  438. return datetime.now(timezone.utc) >= (self._access_expires - lead)
  439. def _ensure_token_valid(self) -> None:
  440. """Proactively refresh the access token if needed.
  441. Thread-safe: uses a lock so parallel coordinator updates don't
  442. trigger multiple simultaneous refresh calls.
  443. Raises TokenExpiredError when all refresh strategies are exhausted.
  444. """
  445. if not self._token_needs_refresh():
  446. return
  447. with self._refresh_lock:
  448. # Double-check inside the lock (another thread may have refreshed)
  449. if not self._token_needs_refresh():
  450. return
  451. _LOGGER.info("SolarOfThings: access token expiring; attempting refresh")
  452. # Strategy 1: use refresh token
  453. if self._refresh_token:
  454. try:
  455. self.refresh_access_token()
  456. return
  457. except TokenExpiredError:
  458. _LOGGER.warning(
  459. "SolarOfThings: refresh token expired/invalid; "
  460. "attempting re-login"
  461. )
  462. except Exception as err:
  463. _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
  464. # Strategy 2: re-login with stored credentials
  465. if self._auth_mode == "password" and self._user_id and self._password:
  466. try:
  467. self.login()
  468. return
  469. except AuthenticationError as err:
  470. raise TokenExpiredError(
  471. f"Re-login failed (credentials rejected): {err}"
  472. ) from err
  473. except Exception as err:
  474. raise TokenExpiredError(
  475. f"Re-login failed (network error): {err}"
  476. ) from err
  477. # Strategy 3: nothing left — tell HA to trigger re-auth
  478. raise TokenExpiredError(
  479. "Access token expired and no refresh strategy succeeded. "
  480. "Please re-authenticate in Home Assistant."
  481. )
  482. # ─── Internal HTTP helper ──────────────────────────────────────────────────
  483. def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
  484. """Perform a POST to a data endpoint, automatically refreshing the token on 401.
  485. On second 401 (after refresh) raises TokenExpiredError.
  486. Uses API_BASE_URL (solar.siseli.com) — not the auth base URL.
  487. """
  488. self._ensure_token_valid()
  489. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  490. if resp.status_code == 401:
  491. _LOGGER.warning("SolarOfThings: received 401; forcing token refresh")
  492. # Force an immediate refresh even if _token_needs_refresh() is False
  493. self._access_expires = None
  494. self._ensure_token_valid()
  495. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  496. resp.raise_for_status()
  497. return resp.json()
  498. def _get(self, path: str, params: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
  499. """Perform a GET to a data endpoint, refreshing the token once on 401.
  500. Mirrors _post's retry behaviour: on a second 401 the nested
  501. _ensure_token_valid raises TokenExpiredError, which the coordinator
  502. turns into a re-auth flow.
  503. """
  504. self._ensure_token_valid()
  505. url = f"{API_BASE_URL}{path}"
  506. resp = self.session.get(url, params=params, timeout=timeout)
  507. if resp.status_code == 401:
  508. _LOGGER.warning("SolarOfThings: received 401 on GET; forcing token refresh")
  509. # Force an immediate refresh even if _token_needs_refresh() is False
  510. self._access_expires = None
  511. self._ensure_token_valid()
  512. resp = self.session.get(url, params=params, timeout=timeout)
  513. resp.raise_for_status()
  514. return resp.json()
  515. # ─── Public properties (for persistence in HA config entry) ───────────────
  516. @property
  517. def access_token(self) -> str:
  518. return self._access_token
  519. @property
  520. def refresh_token(self) -> str:
  521. return self._refresh_token
  522. @property
  523. def access_token_expires_iso(self) -> str:
  524. return self._access_expires.isoformat() if self._access_expires else ""
  525. @property
  526. def refresh_token_expires_iso(self) -> str:
  527. return self._refresh_expires.isoformat() if self._refresh_expires else ""
  528. # ─── Time helpers ──────────────────────────────────────────────────────────
  529. def _now(self) -> datetime:
  530. if ZoneInfo:
  531. try:
  532. return datetime.now(tz=ZoneInfo(self._time_zone))
  533. except Exception:
  534. return datetime.now()
  535. return datetime.now()
  536. def _format_time(self, dt: datetime) -> str:
  537. if ZoneInfo:
  538. try:
  539. dt = dt.astimezone(ZoneInfo(self._time_zone))
  540. except Exception:
  541. pass
  542. return dt.replace(microsecond=0).isoformat()
  543. # ─── Station → device listing ──────────────────────────────────────────────
  544. def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
  545. """Return all devices under a station (paginated)."""
  546. devices: list[dict[str, Any]] = []
  547. page = 1
  548. total: int | None = None
  549. while True:
  550. data = self._post(
  551. API_DEVICE_LIST,
  552. {"page": page, "count": page_size, "stationId": station_id},
  553. )
  554. if data.get("code") not in (0, None):
  555. raise RuntimeError(
  556. f"Device list error code={data.get('code')} "
  557. f"message={data.get('message')}"
  558. )
  559. d = data.get("data") or {}
  560. total = d.get("total", total)
  561. batch = d.get("list") or []
  562. if not isinstance(batch, list):
  563. batch = []
  564. devices.extend(batch)
  565. if total is None:
  566. if len(batch) < page_size:
  567. break
  568. else:
  569. if len(devices) >= int(total):
  570. break
  571. if not batch:
  572. break
  573. page += 1
  574. return devices
  575. # ─── Time-series (per device) ──────────────────────────────────────────────
  576. def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
  577. """Fetch the latest readings for a device (last 1 hour).
  578. The historical time-series endpoint is the primary source. Several
  579. inverter / WiFi-dongle firmware families never populate it, which leaves
  580. every realtime entity "unknown" even though the portal shows live data
  581. (issue #7). For those devices we fall back to the live energy-flow
  582. endpoint and translate its field names onto the canonical sensor keys.
  583. """
  584. latest_values = self._fetch_time_series_values(device_id)
  585. if not has_realtime_values(latest_values):
  586. _LOGGER.debug(
  587. "SolarOfThings device %s: time-series returned no realtime "
  588. "values; trying energy-flow fallback",
  589. device_id,
  590. )
  591. try:
  592. fields = self.fetch_energy_flow(device_id)
  593. except TokenExpiredError:
  594. # Must reach the coordinator so it can start the re-auth flow.
  595. raise
  596. except Exception as err:
  597. _LOGGER.debug(
  598. "SolarOfThings device %s: energy-flow fallback unavailable: %s",
  599. device_id,
  600. err,
  601. )
  602. else:
  603. mapped = map_energy_flow_fields(fields)
  604. if mapped:
  605. _LOGGER.debug(
  606. "SolarOfThings device %s: energy-flow fallback resolved %s",
  607. device_id,
  608. sorted(mapped),
  609. )
  610. # Only fill gaps — never clobber a time-series reading.
  611. for key, value in mapped.items():
  612. latest_values.setdefault(key, value)
  613. elif fields:
  614. # Unknown firmware variant: surface the field names so they
  615. # can be added to ENERGY_FLOW_RULES from a bug report.
  616. _LOGGER.warning(
  617. "SolarOfThings device %s: energy-flow returned %d field(s) "
  618. "but none matched a known mapping. Please open an issue "
  619. "with these key names: %s",
  620. device_id,
  621. len(fields),
  622. sorted(fields),
  623. )
  624. self._apply_derived_values(latest_values)
  625. return latest_values
  626. def _fetch_time_series_values(self, device_id: str) -> dict[str, Any]:
  627. """Return the latest value per key from the historical time-series API."""
  628. end_time = self._now()
  629. start_time = end_time - timedelta(hours=1)
  630. keys = [
  631. "pvInputPower",
  632. "acOutputActivePower",
  633. "batteryDischargeCurrent",
  634. "batteryChargingCurrent",
  635. "batteryVoltage",
  636. "feedInPower",
  637. "batterySOC",
  638. ]
  639. data = self._post(
  640. API_TIME_SERIES,
  641. {
  642. "deviceId": device_id,
  643. "count": 2000,
  644. "page": 1,
  645. "fromTime": self._format_time(start_time),
  646. "toTime": self._format_time(end_time),
  647. "orderByTimeAsc": True,
  648. "keys": keys,
  649. },
  650. )
  651. if data.get("code") not in (0, None):
  652. raise RuntimeError(
  653. f"Timeseries error code={data.get('code')} "
  654. f"message={data.get('message')}"
  655. )
  656. payload_data = (data.get("data") or {}).get("payload") or {}
  657. fields = payload_data.get("fields") or {}
  658. latest_values: dict[str, Any] = {}
  659. for key, arr in fields.items():
  660. if isinstance(arr, list) and arr:
  661. latest_values[key] = arr[-1]
  662. # Unit normalisation: acOutputActivePower is kW in API → W
  663. if "acOutputActivePower" in latest_values:
  664. converted = _coerce_number(latest_values["acOutputActivePower"])
  665. if converted is not None:
  666. latest_values["acOutputActivePower"] = converted * 1000.0
  667. return latest_values
  668. def fetch_energy_flow(self, device_id: str) -> dict[str, Any]:
  669. """Return the live energy-flow ``fields`` mapping for a device.
  670. Fallback source for firmware that never populates the time-series
  671. endpoint (issue #7). Returns an empty dict when the endpoint responds
  672. without field data, so "no data" and "unsupported" behave identically.
  673. """
  674. data = self._get(API_ENERGY_FLOW, {"deviceId": device_id, "dataSource": 1})
  675. if data.get("code") not in (0, None, "0"):
  676. raise RuntimeError(
  677. f"Energy-flow error code={data.get('code')} "
  678. f"message={data.get('message') or data.get('msg')}"
  679. )
  680. payload = data.get("data") or {}
  681. state = payload.get("deviceAttributeState") or {}
  682. fields = state.get("fields")
  683. if not isinstance(fields, dict):
  684. # Tolerate the values being nested directly under data.fields.
  685. fields = payload.get("fields")
  686. return fields if isinstance(fields, dict) else {}
  687. @staticmethod
  688. def _apply_derived_values(latest_values: dict[str, Any]) -> None:
  689. """Compute values the API does not report directly.
  690. Gap-filling only: when a source such as the energy-flow fallback already
  691. supplied a real measurement, it is kept rather than overwritten with an
  692. estimate. On the time-series path none of these keys are ever returned
  693. by the API, so every one of them is still derived exactly as before.
  694. """
  695. if latest_values.get("batteryPower") is None:
  696. voltage = _coerce_number(latest_values.get("batteryVoltage")) or 0.0
  697. discharge = _coerce_number(latest_values.get("batteryDischargeCurrent")) or 0.0
  698. charge = _coerce_number(latest_values.get("batteryChargingCurrent")) or 0.0
  699. latest_values["batteryPower"] = (discharge - charge) * voltage
  700. ac_output = _coerce_number(latest_values.get("acOutputActivePower")) or 0.0
  701. if latest_values.get("loadPower") is None:
  702. latest_values["loadPower"] = ac_output
  703. if latest_values.get("gridPower") is None:
  704. pv_power = _coerce_number(latest_values.get("pvInputPower")) or 0.0
  705. feed_in = _coerce_number(latest_values.get("feedInPower")) or 0.0
  706. battery_power = _coerce_number(latest_values.get("batteryPower")) or 0.0
  707. latest_values["gridPower"] = max(
  708. 0.0, ac_output - pv_power + battery_power + feed_in
  709. )
  710. # ─── Monthly summary (station) ─────────────────────────────────────────────
  711. def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
  712. """Fetch monthly PV summary for the current month."""
  713. now = self._now()
  714. year = now.year
  715. month_key = f"{year}-{str(now.month).zfill(2)}"
  716. self._ensure_token_valid()
  717. resp = self.session.post(
  718. f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
  719. f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass",
  720. json={"time": str(year)},
  721. timeout=30,
  722. )
  723. resp.raise_for_status()
  724. data = resp.json()
  725. if data.get("code") not in (0, None):
  726. raise RuntimeError(
  727. f"Monthly summary error code={data.get('code')} "
  728. f"message={data.get('message')}"
  729. )
  730. props = (((data.get("data") or {}).get("properties")) or
  731. (data.get("data") or {}).get("list") or
  732. [])
  733. result: dict[str, Any] = {}
  734. for item in props if isinstance(props, list) else []:
  735. k = item.get("key") or item.get("name")
  736. v = item.get("value")
  737. if k and v is not None:
  738. result[k] = v
  739. # Extract monthly totals (fallback: look for known keys)
  740. monthly: dict[str, Any] = {}
  741. pv_total = result.get(month_key) or result.get("pvTotal") or result.get("pv") or 0
  742. monthly["monthly_pv_generated"] = float(pv_total or 0)
  743. grid_import = result.get("gridImport") or result.get("buy") or 0
  744. monthly["monthly_grid_import"] = float(grid_import or 0)
  745. total_consumption = result.get("totalConsumption") or result.get("load") or 0
  746. monthly["monthly_total_consumption"] = float(total_consumption or 0)
  747. if monthly["monthly_total_consumption"] > 0:
  748. monthly["monthly_solar_percentage"] = round(
  749. 100.0 * monthly["monthly_pv_generated"] / monthly["monthly_total_consumption"], 1
  750. )
  751. else:
  752. monthly["monthly_solar_percentage"] = 0.0
  753. return monthly
  754. # ─── Device settings ───────────────────────────────────────────────────────
  755. # The remote config endpoints require only a plain IOT-Token header (which the
  756. # session already carries) and pass deviceId as a URL query parameter rather
  757. # than in the JSON body.
  758. def _write_setting(self, device_id: str, key: str, value: Any) -> None:
  759. """Write a single device setting key=value via the remote config write API."""
  760. self._ensure_token_valid()
  761. url = f"{API_BASE_URL}{API_SETTINGS_SET}?deviceId={device_id}"
  762. payload = {"deviceId": device_id, "key": key, "value": value}
  763. resp = self.session.post(url, json=payload, timeout=30)
  764. resp.raise_for_status()
  765. data = resp.json()
  766. if data.get("code") not in (0, None):
  767. raise RuntimeError(
  768. f"Settings write error code={data.get('code')} "
  769. f"message={data.get('message')} (key={key})"
  770. )
  771. def get_device_settings(self, device_id: str) -> dict[str, Any]:
  772. """Fetch the cached device settings from the remote config API.
  773. Returns a flat dict of {settingKey: settingObject} where each value
  774. contains at least 'key', 'value', and 'valueDisplay' fields.
  775. The endpoint accepts a plain IOT-Token header (no IOT-Open-Sign).
  776. """
  777. self._ensure_token_valid()
  778. url = f"{API_BASE_URL}{API_SETTINGS_GET}?deviceId={device_id}"
  779. resp = self.session.post(url, json={}, timeout=30)
  780. resp.raise_for_status()
  781. data = resp.json()
  782. if data.get("code") not in (0, None):
  783. raise RuntimeError(
  784. f"Settings fetch error code={data.get('code')} "
  785. f"message={data.get('message')}"
  786. )
  787. return data.get("data") or {}
  788. # Alias used by the coordinator in __init__.py
  789. fetch_settings = get_device_settings
  790. def update_device_settings(self, device_id: str, settings: dict[str, Any]) -> None:
  791. """Write multiple settings (one API call per key)."""
  792. for key, value in settings.items():
  793. self._write_setting(device_id, key, value)
  794. # ─── Convenience control helpers (called by select.py / switch.py) ─────────
  795. # Key names are the real device attribute keys returned by get_device_settings.
  796. # Output Source Priority: USO=0, SUB=1, SBU=2
  797. # Charger Source Priority: CSO=0, SNU=1, OSO=2
  798. # batteryPowerLimitingSetting: 0=OFF, 1=ON (GRID switch)
  799. # acInputRangeSetting: 0=Appliance, 1=UPS
  800. # Operating-mode select maps HA option strings to integer values
  801. _OUTPUT_MODE_MAP: dict[str, int] = {
  802. "Utility First (USO)": 0,
  803. "Solar First (SUB)": 1,
  804. "Solar+Battery First (SBU)": 2,
  805. }
  806. _OUTPUT_MODE_REVERSE: dict[int, str] = {v: k for k, v in _OUTPUT_MODE_MAP.items()}
  807. # Charger-priority select
  808. _CHARGER_PRIORITY_MAP: dict[str, int] = {
  809. "Solar + Utility (CSO)": 0,
  810. "Solar First (SNU)": 1,
  811. "Solar Only (OSO)": 2,
  812. }
  813. _CHARGER_PRIORITY_REVERSE: dict[int, str] = {v: k for k, v in _CHARGER_PRIORITY_MAP.items()}
  814. def set_operating_mode(self, device_id: str, mode: str) -> None:
  815. """Set Output Source Priority. mode is one of _OUTPUT_MODE_MAP keys."""
  816. value = self._OUTPUT_MODE_MAP.get(mode)
  817. if value is None:
  818. raise ValueError(f"Unknown operating mode: {mode!r}. "
  819. f"Valid options: {list(self._OUTPUT_MODE_MAP)!r}")
  820. self._write_setting(device_id, "outputSourcePrioritySetting", value)
  821. def set_battery_priority(self, device_id: str, mode: str) -> None:
  822. """Set Charger Source Priority. mode is one of _CHARGER_PRIORITY_MAP keys."""
  823. value = self._CHARGER_PRIORITY_MAP.get(mode)
  824. if value is None:
  825. raise ValueError(f"Unknown battery priority: {mode!r}. "
  826. f"Valid options: {list(self._CHARGER_PRIORITY_MAP)!r}")
  827. self._write_setting(device_id, "chargerSourcePrioritySetting", value)
  828. def set_grid_charging(self, device_id: str, enabled: bool) -> None:
  829. """Set AC Input Range: Appliance (0, grid charging allowed) / UPS (1, bypass)."""
  830. self._write_setting(device_id, "acInputRangeSetting", 0 if enabled else 1)
  831. def set_grid_feed_in(self, device_id: str, enabled: bool) -> None:
  832. """Enable or disable the GRID grid switch (batteryPowerLimitingSetting)."""
  833. self._write_setting(device_id, "batteryPowerLimitingSetting", 1 if enabled else 0)
  834. def set_backup_mode(self, device_id: str, enabled: bool) -> None:
  835. """Set Output Source Priority to SBU (backup/off-grid priority) when True,
  836. or SUB (solar-first, grid-supplemented) when False."""
  837. value = 2 if enabled else 1 # SBU=2 (battery before grid), SUB=1
  838. self._write_setting(device_id, "outputSourcePrioritySetting", value)
  839. def set_battery_charge_limit(self, device_id: str, percent: int) -> None:
  840. """Set battery charge limit (0–100 %)."""
  841. self._write_setting(device_id, "batteryChargeLimit", percent)
  842. def set_battery_discharge_limit(self, device_id: str, percent: int) -> None:
  843. """Set battery discharge limit / minimum SOC (0–100 %)."""
  844. self._write_setting(device_id, "batteryDischargeLimit", percent)
  845. def set_grid_charge_limit(self, device_id: str, watts: int) -> None:
  846. """Set maximum grid charge power (0–5000 W)."""
  847. self._write_setting(device_id, "gridChargeLimit", watts)
  848. def test_connection(self, station_id: str) -> bool:
  849. """Return True if we can reach the device-list endpoint successfully."""
  850. try:
  851. devices = self.list_devices(station_id, page_size=1)
  852. return True
  853. except Exception as err:
  854. _LOGGER.error("SolarOfThings: connection test failed: %s", err)
  855. return False