| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- """Constants for the Solar of Things integration."""
- DOMAIN = "solar_of_things"
- # ─── Configuration keys ────────────────────────────────────────────────────────
- CONF_IOT_TOKEN = "iot_token" # legacy / advanced manual entry
- CONF_STATION_ID = "station_id"
- CONF_DEVICE_ID = "device_id"
- CONF_TIME_ZONE = "time_zone"
- # Credential-based auth (preferred)
- CONF_USER_ID = "user_id" # Siseli account / user-ID login (not email)
- CONF_PASSWORD = "password"
- # Fields that are typed or copy-pasted by hand and routinely arrive with stray
- # leading/trailing whitespace. The upstream API treats " 4235…" as a different
- # (invalid) value, which surfaces to the user as an unhelpful "cannot connect".
- # Consumed by normalise_config_fields() in util.py, which is applied both when
- # the config flow accepts input and when an entry is read back at setup, so a
- # single list keeps the two paths from drifting.
- # CONF_PASSWORD is deliberately absent: whitespace in a password may be
- # significant, so it is never trimmed.
- WHITESPACE_SENSITIVE_FIELDS = (
- CONF_USER_ID,
- CONF_STATION_ID,
- CONF_DEVICE_ID,
- CONF_IOT_TOKEN,
- CONF_TIME_ZONE,
- )
- # Runtime-stored token state (written back to config entry)
- CONF_REFRESH_TOKEN = "refresh_token"
- CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires" # ISO-8601 string
- CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires" # ISO-8601 string
- # ─── API bases ─────────────────────────────────────────────────────────────────
- # Both auth and data endpoints live on the production server solar.siseli.com.
- # The portal JS bundle embeds both test/prod AppIDs; AppID rBrTRfAPXz is the
- # one accepted by solar.siseli.com (confirmed by live API testing 2026-03-07).
- API_BASE_URL = "https://solar.siseli.com" # data endpoints
- API_AUTH_BASE_URL = "https://solar.siseli.com" # auth / login endpoints
- # ─── Auth endpoints (discovered from portal JS bundle) ─────────────────────────
- # The login endpoint requires IOT-Open-AppID signing (see api.py _sign_request).
- API_LOGIN = "/apis/login/account" # POST + signed headers
- API_REFRESH_TOKEN = "/apis/login/refresh/access/token" # POST, no token needed
- # ─── IOT Open Platform app credentials (embedded in portal umi.js) ────────────
- # rBrTRfAPXz is the production AppID accepted by solar.siseli.com.
- # JO4DAiNeys is the test AppID (accepted only by test.solar.siseli.com).
- IOT_APP_ID = "rBrTRfAPXz"
- IOT_APP_SECRET_ENC = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
- # ─── Data endpoints ────────────────────────────────────────────────────────────
- API_TIME_SERIES = "/apis/deviceState/simple/attribute/keys/history/v1"
- API_MONTHLY_SUMMARY = "/apis/stationOverView/stateAttributeSummary/category/yearly"
- # Remote device config endpoints (discovered 2026-03-07 from live API testing).
- # These accept a plain IOT-Token header (no IOT-Open-Sign) and use the device ID
- # as a query parameter. Write sends one setting key+value per call.
- API_SETTINGS_GET = "/apis/remote/device/configs/cache/get" # ?deviceId=<id>
- API_SETTINGS_SET = "/apis/remote/device/config/write" # ?deviceId=<id>
- API_DEVICE_LIST = "/apis/device/list"
- # Live "energy flow" endpoint. GET with ?deviceId=<id>&dataSource=1; values are
- # returned under data.deviceAttributeState.fields. Used as a fallback when the
- # historical time-series endpoint yields nothing (see ENERGY_FLOW_RULES below).
- API_ENERGY_FLOW = "/apis/deviceState/simple/energy/flow/v1"
- # ─── Token refresh window ──────────────────────────────────────────────────────
- # Refresh the access token this many seconds *before* its stated expiry.
- # Mirrors the portal JS which refreshes when ≤300 s remain.
- TOKEN_REFRESH_LEAD_SECONDS = 300 # 5 minutes
- # ─── Sensor keys ───────────────────────────────────────────────────────────────
- SENSOR_KEYS = [
- "pvInputPower",
- "acOutputActivePower",
- "batteryDischargeCurrent",
- "batteryChargingCurrent",
- "batteryVoltage",
- "feedInPower",
- "batteryPower",
- "batterySOC",
- "gridPower",
- "loadPower",
- ]
- # ─── Energy-flow fallback mapping ──────────────────────────────────────────────
- # Several inverter / WiFi-dongle firmware families never populate the historical
- # time-series endpoint (API_TIME_SERIES) that this integration reads by default,
- # so every realtime sensor stays "unknown" while the portal shows live data.
- # Reported for UWB1, RWB1-0x, JC-62xx, DatouBoss DT-series and EASUN units in
- # https://github.com/Conexo-Casa/solar-of-things-ha/issues/7 (and #3, #8, #11,
- # #14, #15). Those devices serve live values from API_ENERGY_FLOW instead,
- # under a different set of field names.
- #
- # Each canonical sensor key maps to an ordered list of rules. The first rule
- # that produces a usable number wins. A rule is (mode, source_fields, scale):
- # "first" – use the first source field that is present
- # "sum" – add every source field that is present (multi-string PV inputs)
- # `scale` converts the source value into the unit declared in
- # SENSOR_DEFINITIONS.
- #
- # ONLY mappings whose unit AND (where relevant) sign are confirmed by an
- # observed value are enabled here — no rule in this table can be 1000x wrong
- # or have charge/discharge backwards. Two capture rounds confirmed the table
- # below:
- # Night (0 W, settles nothing but the always-zero fields):
- # bmsBatteryVoltage / positiveTerminalBatteryVoltage 26.6 V
- # batteryPercentage / bmsSOC 100 %
- # batteryPower 9 W
- # pv1Power / pv2Power W (labelled; 0 at night)
- # Daylight, four states — AC/no-AC x charging/discharging (issue #7,
- # 2026-09-15, hidemichixt-creator's four-file capture set) — settled the
- # rest via the API's own per-field "unit" tag plus arithmetic cross-checks:
- # load_power "unit": "kW" in the payload itself (not guessed).
- # AC Output Power / Load Power.
- # aPhaseMainsPower/b/c "unit": "W" in the payload itself. Sign confirmed
- # by conservation of power: load_power + charging
- # batteryPower − pv1Power reproduces
- # |aPhaseMainsPower| to within rounding in both
- # AC-connected samples (e.g. 614+720−234=1100 W).
- # Negative = importing from mains; positive would
- # be feed-in by the same physical symmetry (not
- # yet directly observed — no sample went positive).
- # positiveTerminalBatteryCurrent Sign flips consistently across all four
- # states: negative while charging (-17.6, -27 A),
- # positive while discharging (+25.5, +17.6 A).
- # negativeTerminalBatteryCurrent stayed 0 in every
- # sample on this device/firmware and is still
- # unused — see ENERGY_FLOW_UNVERIFIED.
- # Rule modes: "first" (first present source wins), "sum" (add every present
- # source), "clamp_pos" (sum, then max(0, total) — the positive/export half of
- # a signed field), "clamp_neg" (sum, then max(0, -total) — the negative/import
- # half). `scale` converts the source value into the unit declared in
- # SENSOR_DEFINITIONS.
- ENERGY_FLOW_RULES: dict[str, list[tuple[str, tuple[str, ...], float]]] = {
- "pvInputPower": [
- ("sum", ("pv1Power", "pv2Power", "pv3Power", "pv4Power"), 1.0),
- ],
- "batteryVoltage": [
- ("first", ("bmsBatteryVoltage", "positiveTerminalBatteryVoltage"), 1.0),
- ],
- "batterySOC": [
- ("first", ("batteryPercentage", "bmsSOC"), 1.0),
- ],
- "batteryPower": [
- ("first", ("batteryPower",), 1.0),
- ],
- "acOutputActivePower": [
- ("sum", ("load_power",), 1000.0),
- ],
- "gridPower": [
- ("clamp_neg", ("aPhaseMainsPower", "bPhaseMainsPower", "cPhaseMainsPower"), 1.0),
- ],
- "feedInPower": [
- ("clamp_pos", ("aPhaseMainsPower", "bPhaseMainsPower", "cPhaseMainsPower"), 1.0),
- ],
- "batteryChargingCurrent": [
- ("clamp_neg", ("positiveTerminalBatteryCurrent",), 1.0),
- ],
- "batteryDischargeCurrent": [
- ("clamp_pos", ("positiveTerminalBatteryCurrent",), 1.0),
- ],
- }
- # Fields observed in issue #7 payloads that are still deliberately NOT mapped.
- # Publishing a wrong value is worse than leaving a sensor "unknown": a 1000x
- # scaling error feeds the HA Energy dashboard and long-term statistics, and
- # statistics cannot be un-poisoned by a later fix.
- ENERGY_FLOW_UNVERIFIED: tuple[str, ...] = (
- "generationPower", # kW aggregate matching pv1Power+pv2Power exactly on
- # every sample so far, but redundant with the
- # per-string sum above — no reason to add a second,
- # less precise path for the same number.
- "loadPower", # camelCase alternate of the confirmed load_power
- # key; no capture has shown a device that reports
- # this spelling instead, so its unit is unconfirmed.
- "negativeTerminalBatteryCurrent", # stayed 0 in every capture on this
- # device/firmware; positiveTerminalBatteryCurrent's
- # sign already covers both directions, so this
- # field has no confirmed use yet.
- )
- # Canonical keys that indicate the time-series endpoint returned usable realtime
- # data. If none of these are present the energy-flow fallback is attempted.
- REALTIME_PROBE_KEYS: tuple[str, ...] = (
- "pvInputPower",
- "acOutputActivePower",
- "batteryVoltage",
- "batterySOC",
- "batteryChargingCurrent",
- "batteryDischargeCurrent",
- "feedInPower",
- )
- SENSOR_DEFINITIONS = {
- "pvInputPower": {
- "name": "PV Input Power",
- "unit": "W",
- "device_class": "power",
- "icon": "mdi:solar-power",
- },
- "acOutputActivePower": {
- "name": "AC Output Power",
- "unit": "W",
- "device_class": "power",
- "icon": "mdi:power-plug",
- },
- "batteryDischargeCurrent": {
- "name": "Battery Discharge Current",
- "unit": "A",
- "device_class": "current",
- "icon": "mdi:battery-arrow-down",
- },
- "batteryChargingCurrent": {
- "name": "Battery Charging Current",
- "unit": "A",
- "device_class": "current",
- "icon": "mdi:battery-arrow-up",
- },
- "batteryVoltage": {
- "name": "Battery Voltage",
- "unit": "V",
- "device_class": "voltage",
- "icon": "mdi:battery",
- },
- "batteryPower": {
- "name": "Battery Power",
- "unit": "W",
- "device_class": "power",
- "icon": "mdi:battery-charging",
- },
- "batterySOC": {
- "name": "Battery State of Charge",
- "unit": "%",
- "device_class": "battery",
- "icon": "mdi:battery",
- },
- "feedInPower": {
- "name": "Grid Feed-in Power",
- "unit": "W",
- "device_class": "power",
- "icon": "mdi:transmission-tower-export",
- },
- "gridPower": {
- "name": "Grid Import Power",
- "unit": "W",
- "device_class": "power",
- "icon": "mdi:transmission-tower-import",
- },
- "loadPower": {
- "name": "Load Power",
- "unit": "W",
- "device_class": "power",
- "icon": "mdi:home-lightning-bolt",
- },
- # Monthly summary sensors
- "monthly_pv_generated": {
- "name": "Monthly PV Generated",
- "unit": "kWh",
- "device_class": "energy",
- "icon": "mdi:solar-power",
- },
- "monthly_grid_import": {
- "name": "Monthly Grid Import",
- "unit": "kWh",
- "device_class": "energy",
- "icon": "mdi:transmission-tower-import",
- },
- "monthly_total_consumption": {
- "name": "Monthly Total Consumption",
- "unit": "kWh",
- "device_class": "energy",
- "icon": "mdi:home-lightning-bolt",
- },
- "monthly_solar_percentage": {
- "name": "Monthly Solar Coverage",
- "unit": "%",
- "icon": "mdi:percent",
- },
- }
|