api.py 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  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 in ("sum", "clamp_pos", "clamp_neg"):
  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. total = sum(values) * scale
  229. if mode == "clamp_pos":
  230. # Positive/export half of a signed field (e.g. mains
  231. # power going positive = feeding back to the grid).
  232. total = max(0.0, total)
  233. elif mode == "clamp_neg":
  234. # Negative/import half of the same signed field,
  235. # flipped positive for display (e.g. mains power
  236. # going negative = drawing from the grid).
  237. total = max(0.0, -total)
  238. mapped[canonical] = total
  239. else: # "first" — first present source field wins
  240. for source in sources:
  241. number = _coerce_number(fields.get(source))
  242. if number is not None:
  243. mapped[canonical] = number * scale
  244. break
  245. if canonical in mapped:
  246. break
  247. return mapped
  248. def has_realtime_values(values: Any) -> bool:
  249. """Return True if any canonical realtime key carries a usable number."""
  250. if not isinstance(values, dict):
  251. return False
  252. return any(
  253. _coerce_number(values.get(key)) is not None for key in REALTIME_PROBE_KEYS
  254. )
  255. # ──────────────────────────────────────────────────────────────────────────────
  256. # Main API client
  257. # ──────────────────────────────────────────────────────────────────────────────
  258. class SolarOfThingsAPI:
  259. """Solar of Things API wrapper with automatic token refresh.
  260. Parameters
  261. ----------
  262. user_id: Siseli portal login account / user-ID (preferred auth method).
  263. password: Siseli portal password.
  264. iot_token: Legacy/manual IOT-Token (used when user_id/password absent).
  265. refresh_token: Stored refresh token (persisted between HA restarts).
  266. access_token_expires: ISO-8601 string of current access-token expiry.
  267. refresh_token_expires: ISO-8601 string of current refresh-token expiry.
  268. time_zone: IOT-Time-Zone header value.
  269. on_token_refreshed: Optional callback(access_token, refresh_token,
  270. access_expires_iso, refresh_expires_iso) called after
  271. every successful token refresh so the HA entry can
  272. persist the new tokens without restarting.
  273. """
  274. def __init__(
  275. self,
  276. *,
  277. user_id: str | None = None,
  278. password: str | None = None,
  279. iot_token: str | None = None,
  280. refresh_token: str | None = None,
  281. access_token_expires: str | None = None,
  282. refresh_token_expires: str | None = None,
  283. time_zone: str | None = None,
  284. on_token_refreshed: Callable[[str, str, str, str], None] | None = None,
  285. ) -> None:
  286. self._user_id = user_id
  287. self._password = password
  288. self._time_zone = time_zone or _DEFAULT_TZ
  289. self._on_token_refreshed = on_token_refreshed
  290. # Token state
  291. self._access_token: str = iot_token or ""
  292. self._refresh_token: str = refresh_token or ""
  293. self._access_expires: datetime | None = _parse_expiry(access_token_expires)
  294. self._refresh_expires: datetime | None = _parse_expiry(refresh_token_expires)
  295. # Thread-safety for concurrent refresh calls
  296. self._refresh_lock = threading.Lock()
  297. # Determine auth mode
  298. if user_id and password:
  299. self._auth_mode = "password"
  300. elif iot_token and refresh_token:
  301. self._auth_mode = "token_pair"
  302. elif iot_token:
  303. self._auth_mode = "legacy"
  304. else:
  305. raise ValueError("Provide either (user_id + password) or iot_token.")
  306. # HTTP session (headers updated after every token refresh)
  307. self.session = requests.Session()
  308. self._apply_token_headers()
  309. # ─── Session headers ───────────────────────────────────────────────────────
  310. def _apply_token_headers(self) -> None:
  311. """Write the current access token into the session headers."""
  312. self.session.headers.update(
  313. {
  314. "Accept": "application/json",
  315. "Content-Type": "application/json; charset=utf-8",
  316. "IOT-Token": self._access_token,
  317. "IOT-Time-Zone": self._time_zone,
  318. "Origin": "https://solar.siseli.com",
  319. "Referer": "https://solar.siseli.com/",
  320. # Keep this version in sync with manifest.json on each release.
  321. "User-Agent": (
  322. "HomeAssistant-SolarOfThings/2.5.0 "
  323. "(+https://github.com/Conexo-Casa/solar-of-things-ha)"
  324. ),
  325. }
  326. )
  327. # ─── Public auth helpers ───────────────────────────────────────────────────
  328. def login(self) -> None:
  329. """Authenticate with user-ID + password and store the resulting tokens.
  330. Uses the IOT Open Platform signed request format discovered from the
  331. portal JS bundle. The login endpoint is:
  332. POST https://solar.siseli.com/apis/login/account
  333. The password is sent as MD5(plaintext_password) lowercase hex — this
  334. is how the portal processes it before transmitting.
  335. Raises AuthenticationError on bad credentials, or requests.RequestException
  336. on network failure. Safe to call from a background thread.
  337. """
  338. if self._auth_mode not in ("password",):
  339. raise RuntimeError("login() requires user_id + password auth mode.")
  340. _LOGGER.debug("SolarOfThings: logging in as %s", self._user_id)
  341. import json as _json
  342. # The Siseli portal transmits the password as MD5(plaintext) lowercase hex —
  343. # the server rejects plaintext with error code 7. This is a protocol
  344. # requirement of the upstream API, not a choice we can change. The hash
  345. # is sent over HTTPS, so the transport layer provides confidentiality.
  346. # CodeQL alert suppressed: MD5 use here is non-cryptographic (protocol-mandated
  347. # pre-hashing by the upstream service), not used for storage or key derivation.
  348. password_md5 = hashlib.md5(self._password.encode("utf-8")).hexdigest() # noqa: S324
  349. payload = {
  350. "account": self._user_id,
  351. "password": password_md5,
  352. }
  353. body_bytes = _json.dumps(payload, separators=(",", ":")).encode("utf-8")
  354. headers = _make_signed_headers(body_bytes)
  355. resp = requests.post(
  356. f"{API_AUTH_BASE_URL}{API_LOGIN}",
  357. data=body_bytes,
  358. headers=headers,
  359. timeout=30,
  360. )
  361. resp.raise_for_status()
  362. data = resp.json()
  363. if data.get("code") not in (0, None, "0"):
  364. msg = data.get("message") or data.get("msg") or str(data)
  365. raise AuthenticationError(f"Login failed: {msg}")
  366. self._store_tokens(data.get("data") or data)
  367. def refresh_access_token(self) -> None:
  368. """Use the stored refresh token to obtain a new access token.
  369. Raises TokenExpiredError if the refresh token is also expired or invalid.
  370. """
  371. if not self._refresh_token:
  372. raise TokenExpiredError("No refresh token available.")
  373. _LOGGER.debug("SolarOfThings: refreshing access token")
  374. resp = requests.post(
  375. f"{API_AUTH_BASE_URL}{API_REFRESH_TOKEN_ENDPOINT}",
  376. json={"refreshToken": self._refresh_token},
  377. headers={
  378. "Accept": "application/json",
  379. "Content-Type": "application/json; charset=utf-8",
  380. "Origin": "https://solar.siseli.com",
  381. "Referer": "https://solar.siseli.com/",
  382. },
  383. timeout=30,
  384. )
  385. if resp.status_code in (401, 403):
  386. raise TokenExpiredError("Refresh token rejected by server (expired or invalid).")
  387. resp.raise_for_status()
  388. data = resp.json()
  389. if data.get("code") not in (0, None, "0"):
  390. raise TokenExpiredError(
  391. f"Refresh failed: code={data.get('code')} message={data.get('message')}"
  392. )
  393. self._store_tokens(data.get("data") or data)
  394. # ─── Internal token management ─────────────────────────────────────────────
  395. def _store_tokens(self, payload: dict[str, Any]) -> None:
  396. """Extract tokens from a login/refresh response payload and persist them."""
  397. access = (
  398. payload.get("accessToken")
  399. or payload.get("iotToken")
  400. or payload.get("token")
  401. or ""
  402. )
  403. refresh = payload.get("refreshToken") or ""
  404. access_exp = (
  405. payload.get("accessTokenWillExpiredAt")
  406. or payload.get("accessTokenExpiredAt")
  407. or ""
  408. )
  409. refresh_exp = (
  410. payload.get("refreshTokenWillExpiredAt")
  411. or payload.get("refreshTokenExpiredAt")
  412. or ""
  413. )
  414. if not access:
  415. raise AuthenticationError(
  416. f"Login/refresh response did not contain an access token. "
  417. f"Keys received: {list(payload.keys())}"
  418. )
  419. self._access_token = access
  420. self._refresh_token = refresh
  421. self._access_expires = _parse_expiry(access_exp)
  422. self._refresh_expires = _parse_expiry(refresh_exp)
  423. # Update session header immediately
  424. self._apply_token_headers()
  425. _LOGGER.debug(
  426. "SolarOfThings: token updated, expires=%s",
  427. self._access_expires.isoformat() if self._access_expires else "unknown",
  428. )
  429. # Notify the HA integration so it can persist the new token state
  430. if self._on_token_refreshed:
  431. try:
  432. self._on_token_refreshed(
  433. self._access_token,
  434. self._refresh_token,
  435. self._access_expires.isoformat() if self._access_expires else "",
  436. self._refresh_expires.isoformat() if self._refresh_expires else "",
  437. )
  438. except Exception as cb_err: # pragma: no cover
  439. _LOGGER.warning("Token-refresh callback raised: %s", cb_err)
  440. def _token_needs_refresh(self) -> bool:
  441. """Return True if the access token is absent or about to expire."""
  442. if not self._access_token:
  443. return True
  444. if self._access_expires is None:
  445. # Unknown expiry: only refresh if we already have a refresh token
  446. return bool(self._refresh_token)
  447. lead = timedelta(seconds=TOKEN_REFRESH_LEAD_SECONDS)
  448. return datetime.now(timezone.utc) >= (self._access_expires - lead)
  449. def _ensure_token_valid(self) -> None:
  450. """Proactively refresh the access token if needed.
  451. Thread-safe: uses a lock so parallel coordinator updates don't
  452. trigger multiple simultaneous refresh calls.
  453. Raises TokenExpiredError when all refresh strategies are exhausted.
  454. """
  455. if not self._token_needs_refresh():
  456. return
  457. with self._refresh_lock:
  458. # Double-check inside the lock (another thread may have refreshed)
  459. if not self._token_needs_refresh():
  460. return
  461. _LOGGER.info("SolarOfThings: access token expiring; attempting refresh")
  462. # Strategy 1: use refresh token
  463. if self._refresh_token:
  464. try:
  465. self.refresh_access_token()
  466. return
  467. except TokenExpiredError:
  468. _LOGGER.warning(
  469. "SolarOfThings: refresh token expired/invalid; "
  470. "attempting re-login"
  471. )
  472. except Exception as err:
  473. _LOGGER.error("SolarOfThings: token refresh request failed: %s", err)
  474. # Strategy 2: re-login with stored credentials
  475. if self._auth_mode == "password" and self._user_id and self._password:
  476. try:
  477. self.login()
  478. return
  479. except AuthenticationError as err:
  480. raise TokenExpiredError(
  481. f"Re-login failed (credentials rejected): {err}"
  482. ) from err
  483. except Exception as err:
  484. raise TokenExpiredError(
  485. f"Re-login failed (network error): {err}"
  486. ) from err
  487. # Strategy 3: nothing left — tell HA to trigger re-auth
  488. raise TokenExpiredError(
  489. "Access token expired and no refresh strategy succeeded. "
  490. "Please re-authenticate in Home Assistant."
  491. )
  492. # ─── Internal HTTP helper ──────────────────────────────────────────────────
  493. def _post(self, path: str, payload: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
  494. """Perform a POST to a data endpoint, automatically refreshing the token on 401.
  495. On second 401 (after refresh) raises TokenExpiredError.
  496. Uses API_BASE_URL (solar.siseli.com) — not the auth base URL.
  497. """
  498. self._ensure_token_valid()
  499. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  500. if resp.status_code == 401:
  501. _LOGGER.warning("SolarOfThings: received 401; forcing token refresh")
  502. # Force an immediate refresh even if _token_needs_refresh() is False
  503. self._access_expires = None
  504. self._ensure_token_valid()
  505. resp = self.session.post(f"{API_BASE_URL}{path}", json=payload, timeout=timeout)
  506. resp.raise_for_status()
  507. return resp.json()
  508. def _get(self, path: str, params: dict[str, Any], *, timeout: int = 30) -> dict[str, Any]:
  509. """Perform a GET to a data endpoint, refreshing the token once on 401.
  510. Mirrors _post's retry behaviour: on a second 401 the nested
  511. _ensure_token_valid raises TokenExpiredError, which the coordinator
  512. turns into a re-auth flow.
  513. """
  514. self._ensure_token_valid()
  515. url = f"{API_BASE_URL}{path}"
  516. resp = self.session.get(url, params=params, timeout=timeout)
  517. if resp.status_code == 401:
  518. _LOGGER.warning("SolarOfThings: received 401 on GET; forcing token refresh")
  519. # Force an immediate refresh even if _token_needs_refresh() is False
  520. self._access_expires = None
  521. self._ensure_token_valid()
  522. resp = self.session.get(url, params=params, timeout=timeout)
  523. resp.raise_for_status()
  524. return resp.json()
  525. # ─── Public properties (for persistence in HA config entry) ───────────────
  526. @property
  527. def access_token(self) -> str:
  528. return self._access_token
  529. @property
  530. def refresh_token(self) -> str:
  531. return self._refresh_token
  532. @property
  533. def access_token_expires_iso(self) -> str:
  534. return self._access_expires.isoformat() if self._access_expires else ""
  535. @property
  536. def refresh_token_expires_iso(self) -> str:
  537. return self._refresh_expires.isoformat() if self._refresh_expires else ""
  538. # ─── Time helpers ──────────────────────────────────────────────────────────
  539. def _now(self) -> datetime:
  540. if ZoneInfo:
  541. try:
  542. return datetime.now(tz=ZoneInfo(self._time_zone))
  543. except Exception:
  544. return datetime.now()
  545. return datetime.now()
  546. def _format_time(self, dt: datetime) -> str:
  547. if ZoneInfo:
  548. try:
  549. dt = dt.astimezone(ZoneInfo(self._time_zone))
  550. except Exception:
  551. pass
  552. return dt.replace(microsecond=0).isoformat()
  553. # ─── Station → device listing ──────────────────────────────────────────────
  554. def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
  555. """Return all devices under a station (paginated)."""
  556. devices: list[dict[str, Any]] = []
  557. page = 1
  558. total: int | None = None
  559. while True:
  560. data = self._post(
  561. API_DEVICE_LIST,
  562. {"page": page, "count": page_size, "stationId": station_id},
  563. )
  564. if data.get("code") not in (0, None):
  565. raise RuntimeError(
  566. f"Device list error code={data.get('code')} "
  567. f"message={data.get('message')}"
  568. )
  569. d = data.get("data") or {}
  570. total = d.get("total", total)
  571. batch = d.get("list") or []
  572. if not isinstance(batch, list):
  573. batch = []
  574. devices.extend(batch)
  575. if total is None:
  576. if len(batch) < page_size:
  577. break
  578. else:
  579. if len(devices) >= int(total):
  580. break
  581. if not batch:
  582. break
  583. page += 1
  584. return devices
  585. # ─── Time-series (per device) ──────────────────────────────────────────────
  586. def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
  587. """Fetch the latest readings for a device (last 1 hour).
  588. The historical time-series endpoint is the primary source. Several
  589. inverter / WiFi-dongle firmware families never populate it, which leaves
  590. every realtime entity "unknown" even though the portal shows live data
  591. (issue #7). For those devices we fall back to the live energy-flow
  592. endpoint and translate its field names onto the canonical sensor keys.
  593. """
  594. latest_values = self._fetch_time_series_values(device_id)
  595. if not has_realtime_values(latest_values):
  596. _LOGGER.debug(
  597. "SolarOfThings device %s: time-series returned no realtime "
  598. "values; trying energy-flow fallback",
  599. device_id,
  600. )
  601. try:
  602. fields = self.fetch_energy_flow(device_id)
  603. except TokenExpiredError:
  604. # Must reach the coordinator so it can start the re-auth flow.
  605. raise
  606. except Exception as err:
  607. _LOGGER.debug(
  608. "SolarOfThings device %s: energy-flow fallback unavailable: %s",
  609. device_id,
  610. err,
  611. )
  612. else:
  613. mapped = map_energy_flow_fields(fields)
  614. if mapped:
  615. _LOGGER.debug(
  616. "SolarOfThings device %s: energy-flow fallback resolved %s",
  617. device_id,
  618. sorted(mapped),
  619. )
  620. # Only fill gaps — never clobber a time-series reading.
  621. for key, value in mapped.items():
  622. latest_values.setdefault(key, value)
  623. elif fields:
  624. # Unknown firmware variant: surface the field names so they
  625. # can be added to ENERGY_FLOW_RULES from a bug report.
  626. _LOGGER.warning(
  627. "SolarOfThings device %s: energy-flow returned %d field(s) "
  628. "but none matched a known mapping. Please open an issue "
  629. "with these key names: %s",
  630. device_id,
  631. len(fields),
  632. sorted(fields),
  633. )
  634. self._apply_derived_values(latest_values)
  635. return latest_values
  636. def _fetch_time_series_values(self, device_id: str) -> dict[str, Any]:
  637. """Return the latest value per key from the historical time-series API."""
  638. end_time = self._now()
  639. start_time = end_time - timedelta(hours=1)
  640. # Some inverter models (e.g. Siseli HPVINV02 / "Inverter Top One"
  641. # gather protocol) report these three metrics under different key
  642. # names than the vendor's documented API. Request both the
  643. # documented key and the known alternate, and prefer whichever the
  644. # device actually populates — keeps this working for devices that
  645. # use either naming instead of hardcoding one over the other.
  646. ALIAS_GROUPS = [
  647. ("pvInputPower", "pvPower"),
  648. ("acOutputActivePower", "outputActivePower"),
  649. ("batterySOC", "batteryCapacity"),
  650. ]
  651. keys = [
  652. "pvInputPower",
  653. "pvPower",
  654. "acOutputActivePower",
  655. "outputActivePower",
  656. "batteryDischargeCurrent",
  657. "batteryChargingCurrent",
  658. "batteryVoltage",
  659. "feedInPower",
  660. "batterySOC",
  661. "batteryCapacity",
  662. ]
  663. request_body = {
  664. "deviceId": device_id,
  665. "count": 2000,
  666. "page": 1,
  667. "fromTime": self._format_time(start_time),
  668. "toTime": self._format_time(end_time),
  669. "orderByTimeAsc": True,
  670. "keys": keys,
  671. }
  672. data = self._post(API_TIME_SERIES, request_body)
  673. if data.get("code") not in (0, None):
  674. raise RuntimeError(
  675. f"Timeseries error code={data.get('code')} "
  676. f"message={data.get('message')}"
  677. )
  678. payload_data = (data.get("data") or {}).get("payload") or {}
  679. fields = payload_data.get("fields") or {}
  680. latest_values: dict[str, Any] = {}
  681. for key, arr in fields.items():
  682. if isinstance(arr, list) and arr:
  683. latest_values[key] = arr[-1]
  684. # Some inverter models (e.g. Siseli HPVINV02) report pvInputPower,
  685. # acOutputActivePower and batterySOC under an alternate key name
  686. # instead of the documented one. Prefer the canonical key when the
  687. # device populates it; fall back to the alternate otherwise.
  688. aliased_keys: set[str] = set()
  689. for canonical, alternate in ALIAS_GROUPS:
  690. if latest_values.get(canonical) is None and latest_values.get(alternate) is not None:
  691. latest_values[canonical] = latest_values[alternate]
  692. aliased_keys.add(canonical)
  693. latest_values.pop(alternate, None)
  694. # Unit normalisation: acOutputActivePower is always kW in API → W.
  695. # pvInputPower is W under the documented key on every device this
  696. # integration already supports (pinned by
  697. # test_working_device_is_unaffected) but kW when it arrived via the
  698. # pvPower alias (confirmed on HPVINV02) — convert only in that case,
  699. # so devices that were already working are unaffected.
  700. if "acOutputActivePower" in latest_values:
  701. converted = _coerce_number(latest_values["acOutputActivePower"])
  702. if converted is not None:
  703. latest_values["acOutputActivePower"] = converted * 1000.0
  704. if "pvInputPower" in aliased_keys:
  705. converted = _coerce_number(latest_values["pvInputPower"])
  706. if converted is not None:
  707. latest_values["pvInputPower"] = converted * 1000.0
  708. return latest_values
  709. def fetch_energy_flow(self, device_id: str) -> dict[str, Any]:
  710. """Return the live energy-flow ``fields`` mapping for a device.
  711. Fallback source for firmware that never populates the time-series
  712. endpoint (issue #7). Returns an empty dict when the endpoint responds
  713. without field data, so "no data" and "unsupported" behave identically.
  714. """
  715. data = self._get(API_ENERGY_FLOW, {"deviceId": device_id, "dataSource": 1})
  716. if data.get("code") not in (0, None, "0"):
  717. raise RuntimeError(
  718. f"Energy-flow error code={data.get('code')} "
  719. f"message={data.get('message') or data.get('msg')}"
  720. )
  721. payload = data.get("data") or {}
  722. state = payload.get("deviceAttributeState") or {}
  723. fields = state.get("fields")
  724. if not isinstance(fields, dict):
  725. # Tolerate the values being nested directly under data.fields.
  726. fields = payload.get("fields")
  727. return fields if isinstance(fields, dict) else {}
  728. @staticmethod
  729. def _apply_derived_values(latest_values: dict[str, Any]) -> None:
  730. """Compute values the API does not report directly.
  731. Gap-filling only: when a source such as the energy-flow fallback already
  732. supplied a real measurement, it is kept rather than overwritten with an
  733. estimate. On the time-series path none of these keys are ever returned
  734. by the API, so every one of them is still derived exactly as before.
  735. """
  736. if latest_values.get("batteryPower") is None:
  737. voltage = _coerce_number(latest_values.get("batteryVoltage")) or 0.0
  738. discharge = _coerce_number(latest_values.get("batteryDischargeCurrent")) or 0.0
  739. charge = _coerce_number(latest_values.get("batteryChargingCurrent")) or 0.0
  740. latest_values["batteryPower"] = (discharge - charge) * voltage
  741. ac_output = _coerce_number(latest_values.get("acOutputActivePower")) or 0.0
  742. if latest_values.get("loadPower") is None:
  743. latest_values["loadPower"] = ac_output
  744. if latest_values.get("gridPower") is None:
  745. pv_power = _coerce_number(latest_values.get("pvInputPower")) or 0.0
  746. feed_in = _coerce_number(latest_values.get("feedInPower")) or 0.0
  747. battery_power = _coerce_number(latest_values.get("batteryPower")) or 0.0
  748. latest_values["gridPower"] = max(
  749. 0.0, ac_output - pv_power + battery_power + feed_in
  750. )
  751. # ─── Monthly summary (station) ─────────────────────────────────────────────
  752. def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
  753. """Fetch monthly PV summary for the current month."""
  754. now = self._now()
  755. year = now.year
  756. month_key = f"{year}-{str(now.month).zfill(2)}"
  757. self._ensure_token_valid()
  758. resp = self.session.post(
  759. f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
  760. f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass",
  761. json={"time": str(year)},
  762. timeout=30,
  763. )
  764. resp.raise_for_status()
  765. data = resp.json()
  766. if data.get("code") not in (0, None):
  767. raise RuntimeError(
  768. f"Monthly summary error code={data.get('code')} "
  769. f"message={data.get('message')}"
  770. )
  771. props = (((data.get("data") or {}).get("properties")) or
  772. (data.get("data") or {}).get("list") or
  773. [])
  774. result: dict[str, Any] = {}
  775. for item in props if isinstance(props, list) else []:
  776. k = item.get("key") or item.get("name")
  777. v = item.get("value")
  778. if k and v is not None:
  779. result[k] = v
  780. # Extract monthly totals (fallback: look for known keys)
  781. monthly: dict[str, Any] = {}
  782. pv_total = result.get(month_key) or result.get("pvTotal") or result.get("pv") or 0
  783. monthly["monthly_pv_generated"] = float(pv_total or 0)
  784. grid_import = result.get("gridImport") or result.get("buy") or 0
  785. monthly["monthly_grid_import"] = float(grid_import or 0)
  786. total_consumption = result.get("totalConsumption") or result.get("load") or 0
  787. monthly["monthly_total_consumption"] = float(total_consumption or 0)
  788. if monthly["monthly_total_consumption"] > 0:
  789. monthly["monthly_solar_percentage"] = round(
  790. 100.0 * monthly["monthly_pv_generated"] / monthly["monthly_total_consumption"], 1
  791. )
  792. else:
  793. monthly["monthly_solar_percentage"] = 0.0
  794. return monthly
  795. # ─── Device settings ───────────────────────────────────────────────────────
  796. # The remote config endpoints require only a plain IOT-Token header (which the
  797. # session already carries) and pass deviceId as a URL query parameter rather
  798. # than in the JSON body.
  799. def _write_setting(self, device_id: str, key: str, value: Any) -> None:
  800. """Write a single device setting key=value via the remote config write API."""
  801. self._ensure_token_valid()
  802. url = f"{API_BASE_URL}{API_SETTINGS_SET}?deviceId={device_id}"
  803. payload = {"deviceId": device_id, "key": key, "value": value}
  804. resp = self.session.post(url, json=payload, timeout=30)
  805. resp.raise_for_status()
  806. data = resp.json()
  807. if data.get("code") not in (0, None):
  808. raise RuntimeError(
  809. f"Settings write error code={data.get('code')} "
  810. f"message={data.get('message')} (key={key})"
  811. )
  812. def get_device_settings(self, device_id: str) -> dict[str, Any]:
  813. """Fetch the cached device settings from the remote config API.
  814. Returns a flat dict of {settingKey: settingObject} where each value
  815. contains at least 'key', 'value', and 'valueDisplay' fields.
  816. The endpoint accepts a plain IOT-Token header (no IOT-Open-Sign).
  817. """
  818. self._ensure_token_valid()
  819. url = f"{API_BASE_URL}{API_SETTINGS_GET}?deviceId={device_id}"
  820. resp = self.session.post(url, json={}, timeout=30)
  821. resp.raise_for_status()
  822. data = resp.json()
  823. if data.get("code") not in (0, None):
  824. raise RuntimeError(
  825. f"Settings fetch error code={data.get('code')} "
  826. f"message={data.get('message')}"
  827. )
  828. return data.get("data") or {}
  829. # Alias used by the coordinator in __init__.py
  830. fetch_settings = get_device_settings
  831. def update_device_settings(self, device_id: str, settings: dict[str, Any]) -> None:
  832. """Write multiple settings (one API call per key)."""
  833. for key, value in settings.items():
  834. self._write_setting(device_id, key, value)
  835. # ─── Convenience control helpers (called by select.py / switch.py) ─────────
  836. # Key names are the real device attribute keys returned by get_device_settings.
  837. # Output Source Priority: USO=0, SUB=1, SBU=2
  838. # Charger Source Priority: CSO=0, SNU=1, OSO=2
  839. # batteryPowerLimitingSetting: 0=OFF, 1=ON (GRID switch)
  840. # acInputRangeSetting: 0=Appliance, 1=UPS
  841. # Operating-mode select maps HA option strings to integer values
  842. _OUTPUT_MODE_MAP: dict[str, int] = {
  843. "Utility First (USO)": 0,
  844. "Solar First (SUB)": 1,
  845. "Solar+Battery First (SBU)": 2,
  846. }
  847. _OUTPUT_MODE_REVERSE: dict[int, str] = {v: k for k, v in _OUTPUT_MODE_MAP.items()}
  848. # Charger-priority select
  849. _CHARGER_PRIORITY_MAP: dict[str, int] = {
  850. "Solar + Utility (CSO)": 0,
  851. "Solar First (SNU)": 1,
  852. "Solar Only (OSO)": 2,
  853. }
  854. _CHARGER_PRIORITY_REVERSE: dict[int, str] = {v: k for k, v in _CHARGER_PRIORITY_MAP.items()}
  855. def set_operating_mode(self, device_id: str, mode: str) -> None:
  856. """Set Output Source Priority. mode is one of _OUTPUT_MODE_MAP keys."""
  857. value = self._OUTPUT_MODE_MAP.get(mode)
  858. if value is None:
  859. raise ValueError(f"Unknown operating mode: {mode!r}. "
  860. f"Valid options: {list(self._OUTPUT_MODE_MAP)!r}")
  861. self._write_setting(device_id, "outputSourcePrioritySetting", value)
  862. def set_battery_priority(self, device_id: str, mode: str) -> None:
  863. """Set Charger Source Priority. mode is one of _CHARGER_PRIORITY_MAP keys."""
  864. value = self._CHARGER_PRIORITY_MAP.get(mode)
  865. if value is None:
  866. raise ValueError(f"Unknown battery priority: {mode!r}. "
  867. f"Valid options: {list(self._CHARGER_PRIORITY_MAP)!r}")
  868. self._write_setting(device_id, "chargerSourcePrioritySetting", value)
  869. def set_grid_charging(self, device_id: str, enabled: bool) -> None:
  870. """Set AC Input Range: Appliance (0, grid charging allowed) / UPS (1, bypass)."""
  871. self._write_setting(device_id, "acInputRangeSetting", 0 if enabled else 1)
  872. def set_grid_feed_in(self, device_id: str, enabled: bool) -> None:
  873. """Enable or disable the GRID grid switch (batteryPowerLimitingSetting)."""
  874. self._write_setting(device_id, "batteryPowerLimitingSetting", 1 if enabled else 0)
  875. def set_backup_mode(self, device_id: str, enabled: bool) -> None:
  876. """Set Output Source Priority to SBU (backup/off-grid priority) when True,
  877. or SUB (solar-first, grid-supplemented) when False."""
  878. value = 2 if enabled else 1 # SBU=2 (battery before grid), SUB=1
  879. self._write_setting(device_id, "outputSourcePrioritySetting", value)
  880. def set_battery_charge_limit(self, device_id: str, percent: int) -> None:
  881. """Set battery charge limit (0–100 %)."""
  882. self._write_setting(device_id, "batteryChargeLimit", percent)
  883. def set_battery_discharge_limit(self, device_id: str, percent: int) -> None:
  884. """Set battery discharge limit / minimum SOC (0–100 %)."""
  885. self._write_setting(device_id, "batteryDischargeLimit", percent)
  886. def set_grid_charge_limit(self, device_id: str, watts: int) -> None:
  887. """Set maximum grid charge power (0–5000 W)."""
  888. self._write_setting(device_id, "gridChargeLimit", watts)
  889. def test_connection(self, station_id: str) -> bool:
  890. """Return True if we can reach the device-list endpoint successfully."""
  891. try:
  892. devices = self.list_devices(station_id, page_size=1)
  893. return True
  894. except Exception as err:
  895. _LOGGER.error("SolarOfThings: connection test failed: %s", err)
  896. return False