api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """API client for Solar of Things (solar.siseli.com)."""
  2. from __future__ import annotations
  3. import logging
  4. from datetime import datetime, timedelta
  5. from typing import Any
  6. import requests
  7. try:
  8. # Python 3.9+
  9. from zoneinfo import ZoneInfo
  10. except Exception: # pragma: no cover
  11. ZoneInfo = None
  12. from .const import (
  13. API_BASE_URL,
  14. API_TIME_SERIES,
  15. API_MONTHLY_SUMMARY,
  16. API_DEVICE_LIST,
  17. API_SETTINGS_GET,
  18. API_SETTINGS_SET,
  19. )
  20. _LOGGER = logging.getLogger(__name__)
  21. _DEFAULT_TZ = "Asia/Manila"
  22. class SolarOfThingsAPI:
  23. """Solar of Things API wrapper.
  24. Design note:
  25. - token + timezone live on this object
  26. - stationId/deviceId are passed per call (so one integration can handle many devices)
  27. """
  28. def __init__(self, iot_token: str, time_zone: str | None = None) -> None:
  29. self.iot_token = iot_token
  30. self.time_zone = time_zone or _DEFAULT_TZ
  31. self.session = requests.Session()
  32. self.session.headers.update(
  33. {
  34. "Accept": "application/json",
  35. "Content-Type": "application/json; charset=utf-8",
  36. "IOT-Token": self.iot_token,
  37. "IOT-Time-Zone": self.time_zone,
  38. "Origin": "https://solar.siseli.com",
  39. "Referer": "https://solar.siseli.com/",
  40. "User-Agent": "HomeAssistant-SolarOfThings/2.1.0 (+https://www.home-assistant.io)",
  41. }
  42. )
  43. def _now(self) -> datetime:
  44. if ZoneInfo:
  45. try:
  46. return datetime.now(tz=ZoneInfo(self.time_zone))
  47. except Exception:
  48. return datetime.now()
  49. return datetime.now()
  50. def _format_time(self, dt: datetime) -> str:
  51. """Format timestamp like the Siseli web client expects."""
  52. if ZoneInfo:
  53. try:
  54. dt = dt.astimezone(ZoneInfo(self.time_zone))
  55. except Exception:
  56. pass
  57. return dt.replace(microsecond=0).isoformat()
  58. # -------------------------
  59. # Station -> device listing
  60. # -------------------------
  61. def list_devices(self, station_id: str, page_size: int = 50) -> list[dict[str, Any]]:
  62. """Return all devices under a station.
  63. Uses endpoint discovered from the portal JS bundle: /apis/device/list.
  64. Expected payload: {page, count, stationId}
  65. """
  66. devices: list[dict[str, Any]] = []
  67. page = 1
  68. total = None
  69. while True:
  70. payload = {"page": page, "count": page_size, "stationId": station_id}
  71. resp = self.session.post(f"{API_BASE_URL}{API_DEVICE_LIST}", json=payload, timeout=30)
  72. resp.raise_for_status()
  73. data = resp.json()
  74. if data.get("code") not in (0, None):
  75. raise RuntimeError(f"Device list error code={data.get('code')} message={data.get('message')}")
  76. d = data.get("data") or {}
  77. total = d.get("total", total)
  78. batch = d.get("list") or []
  79. if not isinstance(batch, list):
  80. batch = []
  81. devices.extend(batch)
  82. if total is None:
  83. # no pagination info; stop when returned less than requested
  84. if len(batch) < page_size:
  85. break
  86. else:
  87. if len(devices) >= int(total):
  88. break
  89. if not batch:
  90. break
  91. page += 1
  92. return devices
  93. # -------------------------
  94. # Time-series (per device)
  95. # -------------------------
  96. def fetch_latest_data(self, device_id: str) -> dict[str, Any]:
  97. """Fetch the latest readings for a device."""
  98. end_time = self._now()
  99. start_time = end_time - timedelta(hours=1)
  100. keys = [
  101. "pvInputPower",
  102. "acOutputActivePower",
  103. "batteryDischargeCurrent",
  104. "batteryChargingCurrent",
  105. "batteryVoltage",
  106. "feedInPower",
  107. "batterySOC",
  108. ]
  109. payload = {
  110. "deviceId": device_id,
  111. "count": 2000,
  112. "page": 1,
  113. "fromTime": self._format_time(start_time),
  114. "toTime": self._format_time(end_time),
  115. "orderByTimeAsc": True,
  116. "keys": keys,
  117. }
  118. resp = self.session.post(f"{API_BASE_URL}{API_TIME_SERIES}", json=payload, timeout=30)
  119. resp.raise_for_status()
  120. data = resp.json()
  121. if data.get("code") not in (0, None):
  122. raise RuntimeError(f"Timeseries error code={data.get('code')} message={data.get('message')}")
  123. payload_data = (data.get("data") or {}).get("payload") or {}
  124. fields = payload_data.get("fields") or {}
  125. latest_values: dict[str, Any] = {}
  126. for key, arr in fields.items():
  127. if isinstance(arr, list) and arr:
  128. latest_values[key] = arr[-1]
  129. # Unit normalization:
  130. # - pvInputPower appears to be W
  131. # - acOutputActivePower is kW in API; normalize to W
  132. if "acOutputActivePower" in latest_values:
  133. try:
  134. latest_values["acOutputActivePower"] = float(latest_values["acOutputActivePower"]) * 1000.0
  135. except Exception:
  136. pass
  137. # Derived values
  138. voltage = float(latest_values.get("batteryVoltage") or 0)
  139. discharge = float(latest_values.get("batteryDischargeCurrent") or 0)
  140. charge = float(latest_values.get("batteryChargingCurrent") or 0)
  141. latest_values["batteryPower"] = (discharge - charge) * voltage
  142. pv_power = float(latest_values.get("pvInputPower") or 0)
  143. ac_output = float(latest_values.get("acOutputActivePower") or 0)
  144. feed_in = float(latest_values.get("feedInPower") or 0)
  145. battery_power = float(latest_values.get("batteryPower") or 0)
  146. latest_values["gridPower"] = max(0.0, ac_output - pv_power + battery_power + feed_in)
  147. latest_values["loadPower"] = ac_output
  148. return latest_values
  149. # -------------------------
  150. # Monthly summary (station)
  151. # -------------------------
  152. def fetch_monthly_summary(self, station_id: str) -> dict[str, Any]:
  153. """Fetch monthly PV summary for current month."""
  154. now = self._now()
  155. year = now.year
  156. month_key = f"{year}-{str(now.month).zfill(2)}"
  157. url = (
  158. f"{API_BASE_URL}{API_MONTHLY_SUMMARY}"
  159. f"?stationId={station_id}&summaryCategoryKey=pvInverterElectricityQuantityClass"
  160. )
  161. resp = self.session.post(url, json={"time": str(year)}, timeout=30)
  162. resp.raise_for_status()
  163. data = resp.json()
  164. if data.get("code") not in (0, None):
  165. raise RuntimeError(f"Monthly summary error code={data.get('code')} message={data.get('message')}")
  166. props = (((data.get("data") or {}).get("properties")) or [])
  167. out: dict[str, Any] = {}
  168. for prop in props:
  169. prop_key = ((prop.get("property") or {}).get("key"))
  170. if not prop_key:
  171. continue
  172. for tp in prop.get("timePoints") or []:
  173. if tp.get("time") == month_key and tp.get("isRealValue"):
  174. out[prop_key] = tp.get("value")
  175. if "pvGeneratedEnergy" in out:
  176. return {"monthly_pv_generated": float(out.get("pvGeneratedEnergy") or 0)}
  177. return {}
  178. # -------------------------
  179. # Settings / control (per device)
  180. # -------------------------
  181. def fetch_settings(self, device_id: str) -> dict[str, Any]:
  182. """Fetch current device settings.
  183. NOTE: These endpoints may vary by account/device firmware. If your portal uses
  184. different endpoints, update const.py accordingly.
  185. """
  186. payload = {"deviceId": device_id}
  187. try:
  188. response = self.session.post(
  189. f"{API_BASE_URL}{API_SETTINGS_GET}",
  190. json=payload,
  191. timeout=30,
  192. )
  193. response.raise_for_status()
  194. data = response.json()
  195. settings: dict[str, Any] = {}
  196. if "data" in data:
  197. sd = data["data"]
  198. settings["batteryChargeLimit"] = sd.get("batteryChargeLimit", 100)
  199. settings["batteryDischargeLimit"] = sd.get("batteryDischargeLimit", 10)
  200. settings["gridChargeLimit"] = sd.get("gridChargeLimit", 0)
  201. settings["operatingMode"] = sd.get("operatingMode", "Self-Use")
  202. settings["batteryPriority"] = sd.get("batteryPriority", "Solar First")
  203. settings["gridChargingEnabled"] = sd.get("gridChargingEnabled", False)
  204. settings["gridFeedInEnabled"] = sd.get("gridFeedInEnabled", True)
  205. settings["backupModeEnabled"] = sd.get("backupModeEnabled", False)
  206. return settings
  207. except requests.exceptions.RequestException as err:
  208. _LOGGER.error("Error fetching settings for device %s: %s", device_id, err)
  209. return {}
  210. def _update_setting(self, device_id: str, setting_key: str, value: Any) -> bool:
  211. payload = {"deviceId": device_id, "settings": {setting_key: value}}
  212. try:
  213. response = self.session.post(
  214. f"{API_BASE_URL}{API_SETTINGS_SET}",
  215. json=payload,
  216. timeout=30,
  217. )
  218. response.raise_for_status()
  219. data = response.json()
  220. if data.get("success") is True or data.get("code") in (0, None):
  221. _LOGGER.info("Updated %s=%s for device %s", setting_key, value, device_id)
  222. return True
  223. _LOGGER.error("Failed to update %s for device %s: %s", setting_key, device_id, data)
  224. return False
  225. except requests.exceptions.RequestException as err:
  226. _LOGGER.error("Error updating %s for device %s: %s", setting_key, device_id, err)
  227. return False
  228. def set_battery_charge_limit(self, device_id: str, limit: int) -> bool:
  229. return self._update_setting(device_id, "batteryChargeLimit", limit)
  230. def set_battery_discharge_limit(self, device_id: str, limit: int) -> bool:
  231. return self._update_setting(device_id, "batteryDischargeLimit", limit)
  232. def set_grid_charge_limit(self, device_id: str, limit: int) -> bool:
  233. return self._update_setting(device_id, "gridChargeLimit", limit)
  234. def set_operating_mode(self, device_id: str, mode: str) -> bool:
  235. return self._update_setting(device_id, "operatingMode", mode)
  236. def set_battery_priority(self, device_id: str, priority: str) -> bool:
  237. return self._update_setting(device_id, "batteryPriority", priority)
  238. def set_grid_charging(self, device_id: str, enabled: bool) -> bool:
  239. return self._update_setting(device_id, "gridChargingEnabled", enabled)
  240. def set_grid_feed_in(self, device_id: str, enabled: bool) -> bool:
  241. return self._update_setting(device_id, "gridFeedInEnabled", enabled)
  242. def set_backup_mode(self, device_id: str, enabled: bool) -> bool:
  243. return self._update_setting(device_id, "backupModeEnabled", enabled)
  244. # -------------------------
  245. # Validation
  246. # -------------------------
  247. def test_connection(self, station_id: str) -> bool:
  248. """Validate token + station by listing devices and calling timeseries on first."""
  249. try:
  250. devices = self.list_devices(station_id)
  251. if not devices:
  252. return False
  253. first = devices[0]
  254. device_id = str(first.get("id") or "")
  255. if not device_id:
  256. return False
  257. _ = self.fetch_latest_data(device_id)
  258. return True
  259. except Exception as err:
  260. _LOGGER.error("Connection test failed: %s", err)
  261. return False