Skip to content

fix: EVC energy word order, unreachable protocol gates, reactive power, MIC-G2 sensors - #2178

Merged
wills106 merged 2 commits into
wills106:mainfrom
Bl00d-B0b:fix/evc-eq-total-word-order
Jul 26, 2026
Merged

fix: EVC energy word order, unreachable protocol gates, reactive power, MIC-G2 sensors#2178
wills106 merged 2 commits into
wills106:mainfrom
Bl00d-B0b:fix/evc-eq-total-word-order

Conversation

@Bl00d-B0b

@Bl00d-B0b Bl00d-B0b commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Six fixes, each verified on real hardware with raw Modbus TCP reads cross-checked against the official protocol documents (X3 EVC 11kW, X3-Hybrid-G4 15kW at protocol 100, X3-MIC-G2). No entity renames, no unique_id changes; everything else explored during this work was deliberately left at upstream state.

1. EV charger cumulative energy — wrong word order

Charge Added - Cumulative (input 0x0010 EQ_Total, u32) displayed 19,660.8 kWh on a charger with 0.3 kWh lifetime energy. 19660.8 = raw 3 × 65536: the charger sends this register high-word-first, contradicting the doc (EVC_Modbus_V3.5: 0x10=Lo/0x11=Hi) and the same counter's little-ordered holding copy at 0x0619 (which read 0.3 kWh correctly all along).

Fix: adds an optional per-sensor order32 override to BaseModbusSensorEntityDescription — the read path already honored it via getattr; only the dataclass field was missing — and sets order32="big" on charge_added_cum. → now reads 0.3 kWh, agreeing with its twin.

2. modbus_min=101 gates — unreachable on real hardware

Register 0x82 reports the protocol version (100 = V001.00), and no known device returns more than 100 — the 101 gate values conflated the protocol register with the document revision (see the version discussion on #2064). Entities gated modbus_min=101 could therefore never be created on any known inverter.

Raw register reads on a real protocol-100 X3-Hybrid-G4 confirm every affected register responds with sensible data:

Entity Register Raw @ proto 100
bias_power H 0x5D / 0x119 40
peakshaving_discharge_limit_1/2 H 0xEE/0xEF (rb 0x153/0x154) 0, 0 — see pairing note below
ev_charger_communication_state I 0xA0 1 — correctly reports the actually-connected EV charger
adapt_box_communication_state I 0xA1 0

Fix: these gates relaxed to modbus_min=100; the misleading dataclass comments ("document version, e.g. 102 for V001.02") corrected to reference the register-0x82 value. Entities keep their plugin enable/disable defaults.

Peakshaving pairing note: the discharge limits existed as complementary variant pairs — an unscaled variant gated modbus_max=100 and X1/X3-scaled variants (X1: 1 / X3: 0.1, per the V1.02 gain column) gated modbus_min=101. Since V1.02 corresponds to protocol 100, the doc-scaled variants were unreachable and protocol-100 X3 devices used the unscaled variant — a 10× write-scale error per the doc. The pairs are re-split at the documented boundary: unscaled → modbus_max=99, X1/X3-scaled → modbus_min=100 (V1.02: "Peak shaving mode discharging period power limit, W, gain X1: 1, X3: 0.1", registers 0x00EE/0x0153 and 0x00EF/0x0154).

3. Grid Reactive Power L1/L2 — merged S16 pairs + unreachable documented registers

On protocol-100 devices grid_reactive_power_l1/l2 displayed ~26 Mvar: they were read as S32 from 0xC0/0xC2, but raw register reads show protocol 100 serves independent per-phase S16 values at 0xC0..0xC3 — the S32 read merged total+L1 (and L2+L3) into one absurd number. Meanwhile the V1.02-documented registers 0xDE/0xE0 respond correctly at protocol 100 (raw-verified: 397/572 var) but were gated modbus_min=102 — unreachable, since no device returns more than 100 from register 0x82.

Fix: L1/L2 now follow the exact same layout L3 and Total already used — documented S32 (0xDE/0xE0) for protocol ≥ 100, legacy S16 (0xC1/0xC2) for ≤ 99. Live result, with the built-in consistency proof:

Entity Register Before After
L1 0xDE (was 0xC0-S32) 29,295,700 var 439 var
L2 0xE0 (was 0xC2-S32) 20,775,254 var 339 var
L3 0xD4 (unchanged) 317 var ✓ 336 var
Total 0xD2 (unchanged) 1,108 var ✓ 1,116 var

L1+L2+L3 = 1,114 ≈ Total — the phases now sum to the total.

The V1.00-derived 0xC0/0xC2 S32 definitions are removed: the protocol-100 hardware test above confirms the documentation-error hypothesis from #2099 (the registers never moved; the V1.00 doc's S32-at-0xC0 claim was the error), so they cannot be correct on any protocol version. After this PR the gate census contains only the two real-world protocol populations: modbus_max=99 (legacy firmware reporting 5/7) and modbus_min=100 (V001.00+, all current devices).

4. "PV Power Total" duplicate removed (MIC GEN2 only)

  • MIC GEN2: pv_total_power ("PV Total Power", disabled by default) computed the exact same value_function_pv_power_total as the existing pv_power_total ("PV Power Total") - users saw two entities with identical values. The redundant pv_total_power is removed.

Correction (review feedback): an earlier revision of this PR also removed the HYBRID GEN4/5/6 register-based pv_power_total (input 0x32), misreading it as a same-key duplicate of the computed variant. That was wrong - the two definitions are a complementary pair (computed fallback gated modbus_max=99, documented PvPowerSum register gated modbus_min=100) and never coexist at runtime. The definition is restored untouched; its disabled-by-default flag is being fixed separately in #2181. Verified on protocol-100 hardware that the register works: input 0x32 (U32) = 6858 W vs PV1+PV2 = 6874 W read moments apart.

5. MIC GEN2 meter phase powers — not served by the firmware (default-disabled)

measured_power_l2 displayed 149 MW on an 8 kW inverter. Two rounds of hardware evidence:

Doc analysis: the MIC-G2 V3.1 register table is internally inconsistent for the 0x70x block — the address column places FeedinPower R/S/T at 0x704-5 / 0x706-7 / 0x708-9 (the current primary sensors) while the description parentheticals point one register higher (the _alt sensors), and the PowerToEV row's parenthetical points at a different variable entirely. The doc alone cannot settle the addresses.

Meter-attached test (decisive): the DTSU meter was physically moved from the X3-Hybrid to the MIC's RS485 (auto-detected, no reconfiguration). With a live meter, the region never serves per-phase power at any offset:

The block has no address decoder at all — the firmware streams whatever internal frame is current, regardless of the requested offset or length:

  • 2-word reads at every start address 0x700 through 0x709 return the same value: the live total feed-in power (S32, matched measured_power 0x43B exactly, tracked -0.4 to -2.3 kW live).
  • Longer reads anchored anywhere (0x700, 0x701, 0x704, 0x705) return the identical frame from its first word; the anchor is ignored and the count only truncates it.
  • The frame content rotates between internal data sets across consecutive reads — one pass carries meter data (total power, an internal counter, values matching the phase voltages and grid frequency), the next carries PV/AC telemetry — and no frame contains per-phase power in any position.

Without a meter the same window echoes stale samples, which is where the giant readings came from: the observed 146,278,598 W is the real ~2.2 kW total echoed into both halves of one S32 (2232 * 65537 = 146,278,584). Function 0x03 (holding) reads of the block are rejected entirely.

Update — MIC-G2 protocol doc V4.0 (2025-03, firmware 1.33) settles both open questions:

  • The whole 0x700-0x70A block now carries the annotation "These registers are only used by EVC" — it is the inverter-to-EV-charger coordination interface (RefPowerToEV / PowerToEV / PvRef / FeedinPower phases), not a general meter-data area. That is why it serves garbage in normal operation.
  • V4.0's own PowerToEV row (address column 0x701-0x702, parenthetical claiming 0x700 = RefPowerToEV) proves the parentheticals carry a systematic -1 typo, making the address column authoritative: FeedinPower R/S/T = Int32 at 0x704-5 / 0x706-7 / 0x708-9.

Fix:

  • The _alt sensors (measured_power_l1/l2/l3_alt) are removed — they read Int32 at the typo offsets (0x705/0x707/0x709), overlapping the real register pairs; a 32-bit register cannot have a valid twin one address over, and live testing shows both alignments return the identical echoed buffer anyway.
  • The primaries keep their registers (0x704/0x706/0x708, Int32 per the authoritative address column) and, together with measured_power_2, stay entity_registry_enabled_default=False: a meter-attached hardware test shows this firmware never serves per-phase values in the block (the handler echoes one internal sample at every offset — the source of the 149 MW readings). The meter values that do work on MIC-G2 — all verified live with the meter attached — are measured_power (0x43B, doc "Feed In Power", Int32) and the grid counters (0x43D "Feed In Energy" / 0x43F "Consume Energy", Uint32 0.01 kWh). These three are now enabled by default (same reasoning as PV Power Total was disabled by default #2181: correct, useful sensors should not hide) — without a meter they read clean zeros, not garbage. All 32-bit registers in the MIC map were cross-checked against V4.0 signedness: every REGISTER_S32/REGISTER_U32 matches the documented Int32/Uint32. Whether any firmware serves the per-phase block is pending with SolaX support; if they confirm it is EVC-exclusive, the primaries can be removed in a follow-up.
  • The four block sensors are renamed to the doc's terminology (Feed-in Power L1/L2/L3, from V4.0's FeedinPower_x_phase; R/S/T mapped to the integration-wide L1/L2/L3 convention; the X1 single-phase variant becomes Feed-in Power L1) so they are no longer confusable with the working meter totals. Keys are unchanged, so existing registry entries and unique_ids are unaffected.

6. MIC GEN2 "Q Curve" select always showed unknown

The Q Curve select's readback sensor (holding 0x347) was decoded as a plain Uint16, but the MIC-G2 V3.1 doc defines this register as byte-packed: QCurve_SetMode (0–5) in the low byte, bQCurve_SetPf (gain 0.01) in the high byte. A real X3-MIC-G2 returns 25600 = 0x6400 (mode 0 = Off in the low byte, Pf 1.00 in the high byte); the full word never matches the 0–5 option map, so the select displayed unknown on every MIC-G2 regardless of the actual mode.

Fix: the readback now uses REGISTER_U8L (low byte), verified on hardware to decode to the correct mode.

@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch 2 times, most recently from e7d8fb7 to 490aa70 Compare July 9, 2026 15:57
@Bl00d-B0b Bl00d-B0b changed the title fix(evcharger): correct word order for cumulative charge energy (0x10 EQ_Total) fix: correct 32-bit register decoding (EVC EQ_Total, MIC GEN2 meter power, hybrid reactive power) Jul 9, 2026
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch 7 times, most recently from e3ef3c8 to 07eaf1b Compare July 9, 2026 18:33
@Bl00d-B0b Bl00d-B0b changed the title fix: correct 32-bit register decoding (EVC EQ_Total, MIC GEN2 meter power, hybrid reactive power) fix: EVC cumulative energy word order + unreachable modbus_min=101 gates Jul 9, 2026
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch from 07eaf1b to ffe2ea8 Compare July 9, 2026 19:59
@Bl00d-B0b Bl00d-B0b changed the title fix: EVC cumulative energy word order + unreachable modbus_min=101 gates fix: EVC energy word order, unreachable protocol gates, reactive power L1/L2 Jul 9, 2026
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch 4 times, most recently from 10ba8a0 to d92ac79 Compare July 10, 2026 10:18
@Bl00d-B0b

Bl00d-B0b commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@TCWORLD — this PR directly answers the open question you left in #2099:

"For the grid_reactive_power_l1/2/3 registers, we should probably test on a protocol 100 inverter and see if they have the correct values... I wonder if it was actually a documentation error in V1.00 compared with V1.02 rather than registers actually moving."

That test has now been done — raw Modbus TCP reads on a real protocol-100 X3-Hybrid-G4, same instant:

Register Raw read @ proto 100 Interpretation
0xC0 / 0xC1 / 0xC2 / 0xC3 1373 / 447 / 609 / 316 four independent S16s = total / L1 / L2 / L3
0xD2 (S32) 1371 total OK
0xDE (S32) 397 (matches 0xC1) L1 OK
0xE0 (S32) 572 (matches 0xC2) L2 OK
0xD4 (S32) 285 (matches 0xC3) L3 OK

Both layouts carry the same values simultaneously at protocol 100, and the V1.02 addresses (0xD2/0xD4/0xDE/0xE0) decode correctly — your documentation-error hypothesis is confirmed: the V1.00 doc's 0xC0/0xC2-as-S32 claim was the error; the registers never moved. Reading 0xC0 as S32 merges total+L1 into the ~26 Mvar values users see.

Hence this PR gates the V1.02 S32 registers at modbus_min=100 (matching the layout L3/Total already used) and removes the V1.00-derived 0xC0/0xC2 S32 definitions — since the hardware test confirms the doc error, they cannot be correct on any protocol version. The resulting gate census contains only the two real-world protocol populations: modbus_max=99 (legacy firmware reporting 5/7) and modbus_min=100 (V001.00+).

@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch from d92ac79 to 0c91471 Compare July 10, 2026 10:32
@TCWORLD

TCWORLD commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@Bl00d-B0b I figured it was most likely a documentation error. I have no problem removing the problematic entries rather than leaving them behind with a 101/101 gating. I think looking at the PR changes, they have been removed entirely which is good.

@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch from 0c91471 to 1d7f275 Compare July 10, 2026 10:37
Comment thread custom_components/solax_modbus/plugin_solax.py
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch 3 times, most recently from d71bd20 to 6eda3ac Compare July 13, 2026 11:59
@Bl00d-B0b Bl00d-B0b changed the title fix: EVC energy word order, unreachable protocol gates, reactive power L1/L2 fix: EVC energy word order, unreachable protocol gates, reactive power, MIC-G2 sensors Jul 13, 2026
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch 4 times, most recently from 741e3fe to 273bc25 Compare July 14, 2026 12:27
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch 6 times, most recently from 24b2868 to ead87ee Compare July 14, 2026 14:44
…r, MIC-G2 sensors

Seven hardware-verified fixes (raw Modbus TCP reads cross-checked
against the official protocol documents):

1. EVC Charge Added - Cumulative read 19660.8 kWh instead of 0.3 kWh:
   the charger sends EQ_Total (0x10, u32) high-word-first. Adds the
   per-sensor order32 override field (read path already honored it)
   and sets order32="big" on charge_added_cum.
2. modbus_min=101/102 gates were unreachable: no device reports more
   than 100 from register 0x82 (the values conflated the protocol
   register with the document revision). Relaxed to 100 after raw-read
   verification; misleading dataclass comments corrected; peakshaving
   complementary pairs re-split at the documented boundary
   (unscaled max=99, X1/X3-scaled min=100).
3. Grid Reactive Power L1/L2 showed ~26 Mvar: 0xC0/0xC2-as-S32 merged
   two S16 values. L1/L2 now use the documented S32 registers 0xDE/0xE0
   for protocol >=100 (raw-verified, phases sum to total) and the
   legacy S16 quad for <=99; the V1.00-derived 0xC0/0xC2 S32 defs are
   removed (doc error confirmed, resolves the open question in wills106#2099).
4. MIC pv_total_power removed: exact computed duplicate of
   pv_power_total (same value_function, same device).
5. MIC-G2 measured power: doc V4.0 marks the whole 0x700-0x70A block
   as EVC-only; live testing shows the block has no address decoder
   (any read returns the current internal frame) and no frame contains
   per-phase power. The _alt sensors, built on the doc's off-by-one
   parentheticals, are removed; the primaries are renamed to the doc's
   terminology (Feed-in Power L1/L2/L3, Int32 per the authoritative
   address column) and stay default-disabled. The verified-correct
   meter registers - Feed-in Power 0x43B (doc "Feed In Power", Int32)
   and the 0x43D/0x43F energy counters (Uint32) - are renamed per the
   doc and now enabled by default (they read clean zeros without a
   meter). All 32-bit MIC registers cross-checked for signedness.
6. MIC-G2 Q Curve select always showed unknown: readback 0x347 is
   byte-packed (mode in low byte, Pf in high byte) but was decoded as
   full u16. Now REGISTER_U8L.
7. RTC sensors (hybrid 0x85, MIC 0x318) are now proper TIMESTAMP
   entities: value_function_rtc returns a timezone-aware datetime
   (host local zone attached - the device stores host-local time,
   verified live; Sync RTC writes datetime.now()), matching the EV
   charger's RTC implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Bl00d-B0b
Bl00d-B0b force-pushed the fix/evc-eq-total-word-order branch from ead87ee to e8b4b38 Compare July 14, 2026 16:04
@wills106

Copy link
Copy Markdown
Owner

Does this PR require further work as it's still in draft?

@Bl00d-B0b
Bl00d-B0b marked this pull request as ready for review July 26, 2026 07:38
@wills106
wills106 merged commit 8ec8f76 into wills106:main Jul 26, 2026
13 checks passed
@Bl00d-B0b
Bl00d-B0b deleted the fix/evc-eq-total-word-order branch July 28, 2026 20:16
@TCWORLD

TCWORLD commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

1. EV charger cumulative energy — wrong word order

Charge Added - Cumulative (input 0x0010 EQ_Total, u32) displayed 19,660.8 kWh on a charger with 0.3 kWh lifetime energy. 19660.8 = raw 3 × 65536: the charger sends this register high-word-first, contradicting the doc (EVC_Modbus_V3.5: 0x10=Lo/0x11=Hi) and the same counter's little-ordered holding copy at 0x0619 (which read 0.3 kWh correctly all along).

This seems to be problematic, please see issue #2230 whereby this switch (which goes against the documentation) seems to have broken the setup for other users.

I wonder if there is either a firmware bug in your system, or the change was applied too generally and may only be applicable to specific chargers.

Can you confirm what model number the EVC charger you tested with is, and what firmware version it is running.

@TCWORLD

TCWORLD commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@Bl00d-B0b could you confirm your charger reports as being GEN1?

@Bl00d-B0b

Copy link
Copy Markdown
Contributor Author

@TCWORLD confirming: my charger is an X3-EVC-11kW, serial prefix C31103 — hardware Gen1, firmware ARM v1.18 (register 0x25 reads 118, well under the 7.0 threshold, so the plugin classifies it GEN1).

You're right that the change was applied too generally. The original evidence was solid but unit-specific: at the same instant, input 0x10-0x11 read [0, 3] (high-word-first) while the holding twin 0x619-0x61A read [3, 0] and decoded to the correct 0.3 kWh little-endian - so on this Gen1/ARM v1.18 unit the input copy genuinely is big-endian and the doc order gives 19,660.8 kWh from a 0.3 kWh counter. #2230's X3-HAC Gen2 (ARM v2.03) demonstrates the documented little order on the newer generation - both populations are real.

Fix in #2235: the order32=\"big\" override is now scoped to GEN1 only; GEN2 (which covers the HAC report) returns to the documented default. If #2213 turns out to be a third firmware population, the same split can be refined per firmware version - happy to run any additional reads on my unit to help map it.

Bl00d-B0b added a commit to Bl00d-B0b/homeassistant-solax-modbus that referenced this pull request Jul 30, 2026
The order32="big" override on charge_added_cum (from wills106#2178) was
verified on an X3-EVC-11kW Gen1 (serial C311*, ARM v1.18): at the same
instant the input copy 0x10-0x11 read [0, 3] (high-word-first) while
its holding twin 0x619-0x61A read [3, 0] and decoded to the correct
0.3 kWh little-endian.

Issue wills106#2230 shows the opposite population: an X3-HAC 22kW Gen2
(ARM v2.03) follows the documented little order, so the global
override inflated its lifetime total by a factor of 65536
(322.6 kWh -> 21,141,913.6).

The definition is now split per generation: GEN1 keeps order32="big"
(hardware-verified), GEN2 returns to the documented default order.

Fixes wills106#2230 (word-order part).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants