api.py 50 KB

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