const.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. """Constants for the Solar of Things integration."""
  2. DOMAIN = "solar_of_things"
  3. # ─── Configuration keys ────────────────────────────────────────────────────────
  4. CONF_IOT_TOKEN = "iot_token" # legacy / advanced manual entry
  5. CONF_STATION_ID = "station_id"
  6. CONF_DEVICE_ID = "device_id"
  7. CONF_TIME_ZONE = "time_zone"
  8. # Credential-based auth (preferred)
  9. CONF_USER_ID = "user_id" # Siseli account / user-ID login (not email)
  10. CONF_PASSWORD = "password"
  11. # Fields that are typed or copy-pasted by hand and routinely arrive with stray
  12. # leading/trailing whitespace. The upstream API treats " 4235…" as a different
  13. # (invalid) value, which surfaces to the user as an unhelpful "cannot connect".
  14. # Consumed by normalise_config_fields() in util.py, which is applied both when
  15. # the config flow accepts input and when an entry is read back at setup, so a
  16. # single list keeps the two paths from drifting.
  17. # CONF_PASSWORD is deliberately absent: whitespace in a password may be
  18. # significant, so it is never trimmed.
  19. WHITESPACE_SENSITIVE_FIELDS = (
  20. CONF_USER_ID,
  21. CONF_STATION_ID,
  22. CONF_DEVICE_ID,
  23. CONF_IOT_TOKEN,
  24. CONF_TIME_ZONE,
  25. )
  26. # Runtime-stored token state (written back to config entry)
  27. CONF_REFRESH_TOKEN = "refresh_token"
  28. CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" # ISO-8601 string
  29. CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" # ISO-8601 string
  30. # ─── API bases ─────────────────────────────────────────────────────────────────
  31. # Both auth and data endpoints live on the production server solar.siseli.com.
  32. # The portal JS bundle embeds both test/prod AppIDs; AppID rBrTRfAPXz is the
  33. # one accepted by solar.siseli.com (confirmed by live API testing 2026-03-07).
  34. API_BASE_URL = "https://solar.siseli.com" # data endpoints
  35. API_AUTH_BASE_URL = "https://solar.siseli.com" # auth / login endpoints
  36. # ─── Auth endpoints (discovered from portal JS bundle) ─────────────────────────
  37. # The login endpoint requires IOT-Open-AppID signing (see api.py _sign_request).
  38. API_LOGIN = "/apis/login/account" # POST + signed headers
  39. API_REFRESH_TOKEN = "/apis/login/refresh/access/token" # POST, no token needed
  40. # ─── IOT Open Platform app credentials (embedded in portal umi.js) ────────────
  41. # rBrTRfAPXz is the production AppID accepted by solar.siseli.com.
  42. # JO4DAiNeys is the test AppID (accepted only by test.solar.siseli.com).
  43. IOT_APP_ID = "rBrTRfAPXz"
  44. IOT_APP_SECRET_ENC = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
  45. # ─── Data endpoints ────────────────────────────────────────────────────────────
  46. API_TIME_SERIES = "/apis/deviceState/simple/attribute/keys/history/v1"
  47. API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
  48. # Remote device config endpoints (discovered 2026-03-07 from live API testing).
  49. # These accept a plain IOT-Token header (no IOT-Open-Sign) and use the device ID
  50. # as a query parameter. Write sends one setting key+value per call.
  51. API_SETTINGS_GET = "/apis/remote/device/configs/cache/get" # ?deviceId=<id>
  52. API_SETTINGS_SET = "/apis/remote/device/config/write" # ?deviceId=<id>
  53. API_DEVICE_LIST = "/apis/device/list"
  54. # Live "energy flow" endpoint. GET with ?deviceId=<id>&dataSource=1; values are
  55. # returned under data.deviceAttributeState.fields. Used as a fallback when the
  56. # historical time-series endpoint yields nothing (see ENERGY_FLOW_RULES below).
  57. API_ENERGY_FLOW = "/apis/deviceState/simple/energy/flow/v1"
  58. # ─── Token refresh window ──────────────────────────────────────────────────────
  59. # Refresh the access token this many seconds *before* its stated expiry.
  60. # Mirrors the portal JS which refreshes when ≤300 s remain.
  61. TOKEN_REFRESH_LEAD_SECONDS = 300 # 5 minutes
  62. # ─── Sensor keys ───────────────────────────────────────────────────────────────
  63. SENSOR_KEYS = [
  64. "pvInputPower",
  65. "acOutputActivePower",
  66. "batteryDischargeCurrent",
  67. "batteryChargingCurrent",
  68. "batteryVoltage",
  69. "feedInPower",
  70. "batteryPower",
  71. "batterySOC",
  72. "gridPower",
  73. "loadPower",
  74. ]
  75. # ─── Energy-flow fallback mapping ──────────────────────────────────────────────
  76. # Several inverter / WiFi-dongle firmware families never populate the historical
  77. # time-series endpoint (API_TIME_SERIES) that this integration reads by default,
  78. # so every realtime sensor stays "unknown" while the portal shows live data.
  79. # Reported for UWB1, RWB1-0x, JC-62xx, DatouBoss DT-series and EASUN units in
  80. # https://github.com/Conexo-Casa/solar-of-things-ha/issues/7 (and #3, #8, #11,
  81. # #14, #15). Those devices serve live values from API_ENERGY_FLOW instead,
  82. # under a different set of field names.
  83. #
  84. # Each canonical sensor key maps to an ordered list of rules. The first rule
  85. # that produces a usable number wins. A rule is (mode, source_fields, scale):
  86. # "first" – use the first source field that is present
  87. # "sum" – add every source field that is present (multi-string PV inputs)
  88. # `scale` converts the source value into the unit declared in
  89. # SENSOR_DEFINITIONS.
  90. #
  91. # ONLY mappings whose unit AND (where relevant) sign are confirmed by an
  92. # observed value are enabled here — no rule in this table can be 1000x wrong
  93. # or have charge/discharge backwards. Two capture rounds confirmed the table
  94. # below:
  95. # Night (0 W, settles nothing but the always-zero fields):
  96. # bmsBatteryVoltage / positiveTerminalBatteryVoltage 26.6 V
  97. # batteryPercentage / bmsSOC 100 %
  98. # batteryPower 9 W
  99. # pv1Power / pv2Power W (labelled; 0 at night)
  100. # Daylight, four states — AC/no-AC x charging/discharging (issue #7,
  101. # 2026-09-15, hidemichixt-creator's four-file capture set) — settled the
  102. # rest via the API's own per-field "unit" tag plus arithmetic cross-checks:
  103. # load_power "unit": "kW" in the payload itself (not guessed).
  104. # AC Output Power / Load Power.
  105. # aPhaseMainsPower/b/c "unit": "W" in the payload itself. Sign confirmed
  106. # by conservation of power: load_power + charging
  107. # batteryPower − pv1Power reproduces
  108. # |aPhaseMainsPower| to within rounding in both
  109. # AC-connected samples (e.g. 614+720−234=1100 W).
  110. # Negative = importing from mains, positive =
  111. # feeding in — confirmed directly (not just by
  112. # symmetry) by a follow-up capture with the
  113. # battery full and PV surplus flowing out
  114. # (897 W PV, 0 W battery, 351 W load ->
  115. # +495 W on aPhaseMainsPower, the ~50 W gap
  116. # being ordinary inverter conversion loss).
  117. # positiveTerminalBatteryCurrent Sign flips consistently across all four
  118. # states: negative while charging (-17.6, -27 A),
  119. # positive while discharging (+25.5, +17.6 A).
  120. # negativeTerminalBatteryCurrent stayed 0 in every
  121. # sample on this device/firmware and is still
  122. # unused — see ENERGY_FLOW_UNVERIFIED.
  123. # Rule modes: "first" (first present source wins), "sum" (add every present
  124. # source), "clamp_pos" (sum, then max(0, total) — the positive/export half of
  125. # a signed field), "clamp_neg" (sum, then max(0, -total) — the negative/import
  126. # half). `scale` converts the source value into the unit declared in
  127. # SENSOR_DEFINITIONS.
  128. ENERGY_FLOW_RULES: dict[str, list[tuple[str, tuple[str, ...], float]]] = {
  129. "pvInputPower": [
  130. ("sum", ("pv1Power", "pv2Power", "pv3Power", "pv4Power"), 1.0),
  131. ],
  132. "batteryVoltage": [
  133. ("first", ("bmsBatteryVoltage", "positiveTerminalBatteryVoltage"), 1.0),
  134. ],
  135. "batterySOC": [
  136. ("first", ("batteryPercentage", "bmsSOC"), 1.0),
  137. ],
  138. "batteryPower": [
  139. ("first", ("batteryPower",), 1.0),
  140. ],
  141. "acOutputActivePower": [
  142. ("sum", ("load_power",), 1000.0),
  143. ],
  144. "gridPower": [
  145. ("clamp_neg", ("aPhaseMainsPower", "bPhaseMainsPower", "cPhaseMainsPower"), 1.0),
  146. ],
  147. "feedInPower": [
  148. ("clamp_pos", ("aPhaseMainsPower", "bPhaseMainsPower", "cPhaseMainsPower"), 1.0),
  149. ],
  150. "batteryChargingCurrent": [
  151. ("clamp_neg", ("positiveTerminalBatteryCurrent",), 1.0),
  152. ],
  153. "batteryDischargeCurrent": [
  154. ("clamp_pos", ("positiveTerminalBatteryCurrent",), 1.0),
  155. ],
  156. }
  157. # Fields observed in issue #7 payloads that are still deliberately NOT mapped.
  158. # Publishing a wrong value is worse than leaving a sensor "unknown": a 1000x
  159. # scaling error feeds the HA Energy dashboard and long-term statistics, and
  160. # statistics cannot be un-poisoned by a later fix.
  161. ENERGY_FLOW_UNVERIFIED: tuple[str, ...] = (
  162. "generationPower", # kW aggregate matching pv1Power+pv2Power exactly on
  163. # every sample so far, but redundant with the
  164. # per-string sum above — no reason to add a second,
  165. # less precise path for the same number.
  166. "loadPower", # camelCase alternate of the confirmed load_power
  167. # key; no capture has shown a device that reports
  168. # this spelling instead, so its unit is unconfirmed.
  169. "negativeTerminalBatteryCurrent", # stayed 0 in every capture on this
  170. # device/firmware; positiveTerminalBatteryCurrent's
  171. # sign already covers both directions, so this
  172. # field has no confirmed use yet.
  173. )
  174. # Canonical keys that indicate the time-series endpoint returned usable realtime
  175. # data. If none of these are present the energy-flow fallback is attempted.
  176. REALTIME_PROBE_KEYS: tuple[str, ...] = (
  177. "pvInputPower",
  178. "acOutputActivePower",
  179. "batteryVoltage",
  180. "batterySOC",
  181. "batteryChargingCurrent",
  182. "batteryDischargeCurrent",
  183. "feedInPower",
  184. )
  185. SENSOR_DEFINITIONS = {
  186. "pvInputPower": {
  187. "name": "PV Input Power",
  188. "unit": "W",
  189. "device_class": "power",
  190. "icon": "mdi:solar-power",
  191. },
  192. "acOutputActivePower": {
  193. "name": "AC Output Power",
  194. "unit": "W",
  195. "device_class": "power",
  196. "icon": "mdi:power-plug",
  197. },
  198. "batteryDischargeCurrent": {
  199. "name": "Battery Discharge Current",
  200. "unit": "A",
  201. "device_class": "current",
  202. "icon": "mdi:battery-arrow-down",
  203. },
  204. "batteryChargingCurrent": {
  205. "name": "Battery Charging Current",
  206. "unit": "A",
  207. "device_class": "current",
  208. "icon": "mdi:battery-arrow-up",
  209. },
  210. "batteryVoltage": {
  211. "name": "Battery Voltage",
  212. "unit": "V",
  213. "device_class": "voltage",
  214. "icon": "mdi:battery",
  215. },
  216. "batteryPower": {
  217. "name": "Battery Power",
  218. "unit": "W",
  219. "device_class": "power",
  220. "icon": "mdi:battery-charging",
  221. },
  222. "batterySOC": {
  223. "name": "Battery State of Charge",
  224. "unit": "%",
  225. "device_class": "battery",
  226. "icon": "mdi:battery",
  227. },
  228. "feedInPower": {
  229. "name": "Grid Feed-in Power",
  230. "unit": "W",
  231. "device_class": "power",
  232. "icon": "mdi:transmission-tower-export",
  233. },
  234. "gridPower": {
  235. "name": "Grid Import Power",
  236. "unit": "W",
  237. "device_class": "power",
  238. "icon": "mdi:transmission-tower-import",
  239. },
  240. "loadPower": {
  241. "name": "Load Power",
  242. "unit": "W",
  243. "device_class": "power",
  244. "icon": "mdi:home-lightning-bolt",
  245. },
  246. # Monthly summary sensors
  247. "monthly_pv_generated": {
  248. "name": "Monthly PV Generated",
  249. "unit": "kWh",
  250. "device_class": "energy",
  251. "icon": "mdi:solar-power",
  252. },
  253. "monthly_grid_import": {
  254. "name": "Monthly Grid Import",
  255. "unit": "kWh",
  256. "device_class": "energy",
  257. "icon": "mdi:transmission-tower-import",
  258. },
  259. "monthly_total_consumption": {
  260. "name": "Monthly Total Consumption",
  261. "unit": "kWh",
  262. "device_class": "energy",
  263. "icon": "mdi:home-lightning-bolt",
  264. },
  265. "monthly_solar_percentage": {
  266. "name": "Monthly Solar Coverage",
  267. "unit": "%",
  268. "icon": "mdi:percent",
  269. },
  270. }
  271. # ─── Device-setting key aliases (write path) ───────────────────────────────────
  272. # Several inverter firmwares expose the same logical control under a different
  273. # writable-config key name than the one this integration was written against —
  274. # the same class of bug #13 already fixed for sensor *reads*, but here for
  275. # control *writes* (and their matching read-back for current state). Confirmed
  276. # by lukaszkwapien's full writable-config dump for a FCHAO inverter (#18):
  277. # Output Source Priority: the documented `outputSourcePrioritySetting` fails
  278. # with `code=70134 "Config attribute not exists"` on this firmware, which
  279. # exposes the same control as `setOutputSourcePriority` instead.
  280. # Every other control below has no confirmed alternate name yet — its tuple is
  281. # just itself. The same dump also confirmed FCHAO does not expose
  282. # batteryChargeLimit / batteryDischargeLimit / gridChargeLimit under ANY name;
  283. # for those, resolve_setting_key() in api.py returning None is used to mark
  284. # the entity unavailable rather than let the user trigger a write that is
  285. # guaranteed to fail with the same portal error.
  286. SETTING_KEY_ALIASES: dict[str, tuple[str, ...]] = {
  287. "outputSourcePrioritySetting": ("outputSourcePrioritySetting", "setOutputSourcePriority"),
  288. "chargerSourcePrioritySetting": ("chargerSourcePrioritySetting",),
  289. "acInputRangeSetting": ("acInputRangeSetting",),
  290. "batteryPowerLimitingSetting": ("batteryPowerLimitingSetting",),
  291. "batteryChargeLimit": ("batteryChargeLimit",),
  292. "batteryDischargeLimit": ("batteryDischargeLimit",),
  293. "gridChargeLimit": ("gridChargeLimit",),
  294. }