api.py 47 KB

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