فهرست منبع

feat: enable AC output, grid import/feed-in, and battery current sensors on energy-flow-fallback devices (#25)

hidemichixt-creator's four-state daylight capture (issue #7) confirmed what
v2.5.0 left unmapped: load_power and the mains-power fields carry their own
"unit" tag in the payload (kW/W, not guessed), and the mains-power sign
checks out against load-minus-generation arithmetic in every AC-connected
sample. The battery terminal current's sign also flips consistently with
charge direction across all four states.

Adds clamp_pos/clamp_neg rule modes to map_energy_flow_fields to split a
single signed field (mains power, battery terminal current) into two
one-directional canonical sensors.
Bob Ehlers 1 ماه پیش
والد
کامیت
48560338ed

+ 24 - 0
CHANGELOG.md

@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ---
 
+## [2.6.0] - 2026-09-15
+
+### Added
+- **AC Output Power, Grid Import Power, Grid Feed-in Power, Battery Charging
+  Current and Battery Discharge Current now populate on devices that use the
+  energy-flow fallback** (the UWB1 / RWB1-0x / JC-62xx / DatouBoss / EASUN
+  family from #7). v2.5.0 shipped only Battery Voltage, State of Charge,
+  Power and PV Input Power from this path because every field needed to
+  confirm the rest — `load_power`'s unit, the mains-power sign convention,
+  and the battery terminal current's charge/discharge direction — had only
+  been captured at night, reading zero. Four daylight captures across every
+  AC/PV/charging combination settled all three: `load_power` and the mains
+  fields carry their own `"unit"` tag in the payload (kW and W
+  respectively, not guessed), and the mains-power sign checks out against
+  simple load-minus-generation arithmetic in every AC-connected sample.
+  Thanks to @hidemichixt-creator for the four-state capture set (#7).
+
+### Notes
+- Grid Feed-in Power's positive-value (exporting) case is inferred from the
+  same signed field as Grid Import Power by physical symmetry, not yet
+  observed directly — no capture so far has shown the device actually
+  exporting. If it reads wrong on a grid-tied system, please reopen #7 with
+  a capture taken while exporting.
+
 ## [2.5.1] - 2026-09-14
 
 ### Fixed

+ 12 - 2
custom_components/solar_of_things/api.py

@@ -264,14 +264,24 @@ def map_energy_flow_fields(fields: Any) -> dict[str, float]:
 
     for canonical, rules in ENERGY_FLOW_RULES.items():
         for mode, sources, scale in rules:
-            if mode == "sum":
+            if mode in ("sum", "clamp_pos", "clamp_neg"):
                 values = [
                     number
                     for source in sources
                     if (number := _coerce_number(fields.get(source))) is not None
                 ]
                 if values:
-                    mapped[canonical] = sum(values) * scale
+                    total = sum(values) * scale
+                    if mode == "clamp_pos":
+                        # Positive/export half of a signed field (e.g. mains
+                        # power going positive = feeding back to the grid).
+                        total = max(0.0, total)
+                    elif mode == "clamp_neg":
+                        # Negative/import half of the same signed field,
+                        # flipped positive for display (e.g. mains power
+                        # going negative = drawing from the grid).
+                        total = max(0.0, -total)
+                    mapped[canonical] = total
             else:  # "first" — first present source field wins
                 for source in sources:
                     number = _coerce_number(fields.get(source))

+ 61 - 39
custom_components/solar_of_things/const.py

@@ -100,18 +100,39 @@ SENSOR_KEYS = [
 # `scale` converts the source value into the unit declared in
 # SENSOR_DEFINITIONS.
 #
-# ONLY mappings whose unit is confirmed by a NON-ZERO observed value are enabled
-# here.  Every rule below has scale 1.0 — the value is published exactly as the
-# portal reports it — so no rule in this table can be 1000x wrong.  The issue #7
-# payload was captured at night, so it pinned down the units of only these
-# fields:
-#   bmsBatteryVoltage / positiveTerminalBatteryVoltage   26.6 V
-#   batteryPercentage / bmsSOC                           100 %
-#   batteryPower                                         9 W
-#   pv1Power / pv2Power                                  W (labelled; 0 at night)
-# Anything that would need a kW→W conversion, or whose direction/sign is
-# ambiguous, is listed in ENERGY_FLOW_UNVERIFIED below and left unmapped until a
-# daytime, under-load capture confirms it.
+# 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),
@@ -125,38 +146,39 @@ ENERGY_FLOW_RULES: dict[str, list[tuple[str, tuple[str, ...], float]]] = {
     "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 the issue #7 payload that are deliberately NOT mapped.
+# 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.  Two distinct reasons:
-#
-# 1. UNIT UNCONFIRMED (kW vs W).  Every captured sample was zero because the
-#    reading was taken at night, so the scale cannot be pinned down.  The
-#    reporter labelled these kW, and this integration already applies kW→W to
-#    acOutputActivePower on the time-series path, so x1000 is *probably* right —
-#    but "probably" is not good enough for a value that lands in statistics.
-# 2. DIRECTION UNCONFIRMED.  The battery terminal currents carry the right
-#    magnitude (26.6 V x 0.4 A ~= 9 W, matching the reported batteryPower), but
-#    which terminal means charge and which means discharge is unverified — the
-#    reporter flagged it, and a swap would invert charge/discharge, which is
-#    actively misleading rather than merely absent.
-#
-# Revisit once two captures from issue #7 are available: one in daylight under
-# load (settles the units), one while the battery is actively charging (settles
-# the direction).
+# statistics cannot be un-poisoned by a later fix.
 ENERGY_FLOW_UNVERIFIED: tuple[str, ...] = (
-    # 1. unit unconfirmed (kW vs W)
-    "aPhaseMainsPower",   # candidate for gridPower (sum of the three phases)
-    "bPhaseMainsPower",
-    "cPhaseMainsPower",
-    "generationPower",    # candidate aggregate fallback for pvInputPower
-    "load_power",         # candidate for loadPower / acOutputActivePower
-    "loadPower",
-    # 2. direction unconfirmed (which terminal is charge vs discharge)
-    "positiveTerminalBatteryCurrent",
-    "negativeTerminalBatteryCurrent",
+    "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

+ 1 - 1
custom_components/solar_of_things/manifest.json

@@ -11,5 +11,5 @@
   "requirements": [
     "pycryptodome>=3.9.0"
   ],
-  "version": "2.5.1"
+  "version": "2.6.0"
 }

+ 80 - 9
tests/test_energy_flow.py

@@ -6,6 +6,10 @@ entity "unknown" while the portal shows live data (issue #7).
 
 The field names and the sample values in ``ISSUE_7_NIGHT_PAYLOAD`` are taken
 verbatim from that report, which was captured at night — hence the zeros.
+The daylight payloads (``ISSUE_7_AC_PV_CHARGING`` / ``ISSUE_7_NOAC_PV_DISCHARGING``)
+are trimmed from hidemichixt-creator's four-state capture set posted
+2026-09-15, which settled the unit and sign questions the night capture
+could not.
 """
 from __future__ import annotations
 
@@ -36,6 +40,36 @@ ISSUE_7_NIGHT_PAYLOAD = {
     "aPhaseOutputFrequency": 50,
 }
 
+# Trimmed from UPS_AC_PV_BatteryCharging.txt (issue #7, 2026-09-15): AC
+# connected, PV producing, battery charging.
+ISSUE_7_AC_PV_CHARGING = {
+    "pv1Power": 234,
+    "pv2Power": 0,
+    "load_power": 0.614,  # kW, per the payload's own "unit" field
+    "aPhaseMainsPower": -1100,  # W, per the payload's own "unit" field
+    "bPhaseMainsPower": 0,
+    "cPhaseMainsPower": 0,
+    "positiveTerminalBatteryCurrent": -27,
+    "negativeTerminalBatteryCurrent": 0,
+    "batteryPower": -720,
+    "bmsSOC": 54,
+}
+
+# Trimmed from UPS_NoAC_PV_BatteryDischarging.txt (issue #7, 2026-09-15): AC
+# disconnected, PV producing, battery discharging.
+ISSUE_7_NOAC_PV_DISCHARGING = {
+    "pv1Power": 262,
+    "pv2Power": 0,
+    "load_power": 0.635,
+    "aPhaseMainsPower": 0,
+    "bPhaseMainsPower": 0,
+    "cPhaseMainsPower": 0,
+    "positiveTerminalBatteryCurrent": 17.6,
+    "negativeTerminalBatteryCurrent": 0,
+    "batteryPower": 462,
+    "bmsSOC": 54,
+}
+
 
 # ─── Pure mapping ──────────────────────────────────────────────────────────────
 
@@ -48,16 +82,53 @@ def test_issue_7_payload_maps_to_canonical_keys() -> None:
     assert mapped["batteryVoltage"] == 26.6
     assert mapped["batterySOC"] == 100.0
     assert mapped["batteryPower"] == 9.0
+    # Confirmed by the 2026-09-15 daylight captures (see below) — 0 at night
+    # is a real reading now, not an unconfirmed guess.
+    assert mapped["acOutputActivePower"] == 0.0
+    assert mapped["batteryChargingCurrent"] == 0.0
+    assert mapped["batteryDischargeCurrent"] == 0.4  # idle trickle, per the report
+
+    # loadPower (camelCase) has no confirmed source — must stay ABSENT rather
+    # than publish a possibly-wrong value. See ENERGY_FLOW_UNVERIFIED in const.py.
+    assert "loadPower" not in mapped
+
+
+def test_ac_output_power_mapped_from_load_power_kw() -> None:
+    """load_power is confirmed kW by the payload's own "unit" tag → x1000."""
+    mapped = map_energy_flow_fields(ISSUE_7_AC_PV_CHARGING)
+    assert mapped["acOutputActivePower"] == 614.0
+
+
+def test_grid_import_and_feed_in_split_from_signed_mains_power() -> None:
+    """aPhaseMainsPower is confirmed W; negative means importing from mains.
+
+    Sign confirmed by conservation of power: load (614 W) + battery charging
+    (720 W) - PV (234 W) = 1100 W, matching |aPhaseMainsPower| exactly.
+    """
+    mapped = map_energy_flow_fields(ISSUE_7_AC_PV_CHARGING)
+    assert mapped["gridPower"] == 1100.0
+    assert mapped["feedInPower"] == 0.0  # not exporting while importing
+
+    # No AC connection at all -> the field reads 0, which is a real
+    # "no grid" measurement, not a missing one.
+    mapped_no_ac = map_energy_flow_fields(ISSUE_7_NOAC_PV_DISCHARGING)
+    assert mapped_no_ac["gridPower"] == 0.0
+    assert mapped_no_ac["feedInPower"] == 0.0
+
+
+def test_battery_charging_and_discharge_current_split_by_sign() -> None:
+    """positiveTerminalBatteryCurrent flips sign with charge direction.
+
+    Confirmed across all four states captured for issue #7: negative while
+    charging, positive while discharging.
+    """
+    charging = map_energy_flow_fields(ISSUE_7_AC_PV_CHARGING)
+    assert charging["batteryChargingCurrent"] == 27.0
+    assert charging["batteryDischargeCurrent"] == 0.0
 
-    # Unit or direction unconfirmed — must stay ABSENT rather than publish a
-    # possibly-wrong value. See ENERGY_FLOW_UNVERIFIED in const.py.
-    for unconfirmed in (
-        "loadPower",
-        "acOutputActivePower",
-        "batteryChargingCurrent",
-        "batteryDischargeCurrent",
-    ):
-        assert unconfirmed not in mapped, unconfirmed
+    discharging = map_energy_flow_fields(ISSUE_7_NOAC_PV_DISCHARGING)
+    assert discharging["batteryChargingCurrent"] == 0.0
+    assert discharging["batteryDischargeCurrent"] == 17.6
 
 
 def test_unverified_fields_are_never_mapped() -> None: