Skip to content

Commit 4f35335

Browse files
author
Maksym Shtembuliak
committed
feat: expose optional PV3300 BMS telemetry with bounded retries
1 parent 597f946 commit 4f35335

13 files changed

Lines changed: 368 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ the GitHub release body should be rendered from the matching version section her
1313
PI30 archive and specifically reported working controls. It reuses the
1414
existing VMII profile; no new device commands are introduced.
1515

16-
- MUST PV3300 Support Archives can collect the optional documented BMS/SOC
17-
block with one bounded read. This is diagnostic evidence only; normal polling
18-
and sensor exposure are unchanged until firmware support is confirmed.
16+
- MUST PV3300 exposes documented BMS state of charge plus separate BMS voltage,
17+
signed current and temperature, backed by a successful local capture (#46).
18+
Missing/invalid BMS data is withdrawn without suppressing core telemetry;
19+
bounded reads and retry delays protect devices without the optional block.
20+
Support Archives retain a separate raw BMS read. No controls are promoted
21+
to tested, and other MUST variants keep their existing map.
1922

2023
- Advanced setup can include up to eight known collector IPs in the ordinary
2124
scan, alongside local discovery. Routed/VPN collectors no longer require a

‎catalog/inverter_models/models/must_pv3300.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
]
3434
},
3535
"known_limitations": [
36-
"SOC is not yet exposed. Support Archives can check the vendor-documented optional BMS register block, but its availability on this firmware is unconfirmed.",
36+
"SOC and separate BMS voltage/current/temperature require the optional BMS block confirmed in issue #46. Missing or invalid data stays unavailable; BMS current keeps the native signed convention.",
3737
"Learned maps tied to the older generic MUST schema are not automatically rebased; existing compatibility checks remain in force.",
3838
"Output Current and Inverter Load Current can remain zero on the reported unit; their meaning is still under investigation.",
3939
"Other PV/PH/EP models retain the common register interpretation because published MUST documents disagree on load-percentage units.",
@@ -42,6 +42,7 @@
4242
"knowledge_summary": "PV3300 keeps the common MUST map and controls, with a scoped telemetry override: whole-percent load, charge-positive battery current/power and import-positive grid power. Native AC converter power retains its vendor sign.",
4343
"source_keys": [
4444
"private_must_pv_ph18_modbus_map",
45-
"github_issue_46_pv3300"
45+
"github_issue_46_pv3300",
46+
"github_issue_46_pv3300_bms"
4647
]
4748
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"schema_version": 1,
3+
"source_key": "github_issue_46_pv3300_bms",
4+
"kind": "github_comment",
5+
"title": "PV3300 optional BMS block captured with BMS connected",
6+
"reference": "https://github.com/groove-max/ha-eybond-local/issues/46#issuecomment-5933626381",
7+
"captured_at": "2026-10-01",
8+
"visibility": "public",
9+
"summary": "Owner confirms BMS connection and LCD percentage. Local FC03 registers 109-113 return documented BMS voltage, current, temperature and SOC; SOC is 83% in the archive. No simultaneous exact LCD comparison or new control-write confirmation is claimed.",
10+
"assertions": ["telemetry_validation"]
11+
}

‎custom_components/eybond_local/drivers/must.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77

88
from typing import Any
9+
import time
910

1011
from ..metadata.compiled_detection_catalog import load_compiled_detection_catalog
1112
from ..metadata.device_catalog_loader import resolve_support_capture_policy
@@ -23,6 +24,7 @@
2324
)
2425
from .read_result import DriverReadMode, DriverReadResult
2526
from .modbus_write_error import ModbusWriteErrorMixin
27+
from .must_bms import async_read_bms
2628
from .capability_codec import (
2729
decode_capability_value,
2830
encode_capability_words,
@@ -144,6 +146,8 @@ async def async_read_values(
144146
poll_interval: float | None = None,
145147
now_monotonic: float | None = None,
146148
) -> DriverReadResult:
149+
started = time.monotonic()
150+
now = started if now_monotonic is None else float(now_monotonic)
147151
schema = load_register_schema(
148152
inverter.register_schema_name or self.register_schema_name
149153
)
@@ -160,7 +164,15 @@ async def async_read_values(
160164
) / 10
161165
if "model_prefix" in values and "model_suffix" in values:
162166
values["model_number"] = f"{values['model_prefix']}{values['model_suffix']}"
163-
return DriverReadResult(values=values, mode=DriverReadMode.FULL)
167+
diagnostics = {}
168+
if schema.source_name == "must_pv_ph18/pv3300.json":
169+
bms_values, diagnostics = await async_read_bms(
170+
session, transport, inverter, schema,
171+
runtime_state if runtime_state is not None else {},
172+
lambda: now + max(0, time.monotonic() - started),
173+
)
174+
values.update(bms_values)
175+
return DriverReadResult(values=values, mode=DriverReadMode.FULL, diagnostics=diagnostics)
164176

165177
async def async_write_capability(
166178
self,
@@ -256,9 +268,8 @@ async def async_capture_support_evidence(
256268
purpose="support_only_zero_bulk_current_single_read_comparison",
257269
)
258270
# The vendor's 6422/1916 map describes a separate optional BMS
259-
# window (voltage/current/temperature/reserved/SOC). Its presence
260-
# on this firmware is not yet confirmed. Archive-only, one bounded
261-
# read, no runtime schema/identity/fixture promotion or SOC guess.
271+
# window (voltage/current/temperature/reserved/SOC). Keep the raw
272+
# diagnostic read independently bounded, even during runtime backoff.
262273
current_status = evidence.get("current_read_diagnostics", {}).get("status", "completed")
263274
if current_status == "completed":
264275
evidence["bms_read_diagnostics"] = await capture_support_reads(
@@ -315,7 +326,9 @@ def _must_default_schema_name() -> str:
315326

316327
def _support_capture_ranges(schema_name: str) -> tuple[tuple[int, int], ...]:
317328
schema = load_register_schema(schema_name)
318-
planned = [(block.start, block.count) for block in schema.blocks]
329+
# BMS evidence has its own deadline and failure record below; never merge
330+
# it into the mandatory/core capture or read it twice during one export.
331+
planned = [(block.start, block.count) for block in schema.blocks if block.key != "bms"]
319332
planned.extend(_support_capture_policy().ranges)
320333
return _merge_capture_ranges(planned)
321334

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Optional PV3300 BMS reads: bounded, runtime-scoped and never carried forward."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from dataclasses import dataclass
7+
from typing import Callable
8+
9+
from ..payload.modbus import ModbusError
10+
from ..payload.register_decode import decode_block
11+
12+
READ_TIMEOUT = 3.0
13+
RETRY_DELAY = 60.0
14+
UNSUPPORTED_RETRY_DELAY = 300.0
15+
STATE_KEY = "must_bms_read"
16+
17+
18+
@dataclass
19+
class BmsReadState:
20+
transport: object
21+
inverter: object
22+
last_clock: float
23+
retry_at: float = 0.0
24+
status: str = "not_checked"
25+
26+
27+
def decode_bms(words: list[int], schema) -> dict[str, object]:
28+
"""Decode the vendor's 109..113 block without inventing a battery state."""
29+
if len(words) != 5 or any(type(word) is not int or not 0 <= word <= 65535 for word in words):
30+
raise ModbusError("invalid_bms_block")
31+
# The vendor decoder ignores the reserved word at 112 in its no-data test.
32+
if all(words[index] == 0 for index in (0, 1, 2, 4)):
33+
return {}
34+
values = decode_block(109, words, schema.spec_set("bms"), all_ones_unavailable=True)
35+
values = {key: value for key, value in values.items() if value is not None}
36+
# SOC is signed in protocol 6422/1916. Never clamp invalid/FFFF to 0/100.
37+
if not 0 <= values["battery_soc"] <= 100:
38+
values.pop("battery_soc")
39+
# An entirely unpopulated all-ones block is not a valid -0.1 A sample.
40+
if all(words[index] == 65535 for index in (0, 1, 2, 4)):
41+
return {}
42+
return values
43+
44+
45+
async def async_read_bms(session, transport, inverter, schema, runtime_state: dict, clock: Callable[[], float]):
46+
"""Read once after core telemetry; failure removes only optional values.
47+
48+
Successful samples are not cached: MUST returns FULL snapshots, so a failed
49+
read or negative-cache hit cannot label an old SOC as fresh. Negative facts
50+
are temporary and scoped to this runtime binding, never the shared driver.
51+
"""
52+
now = clock()
53+
state = runtime_state.get(STATE_KEY)
54+
if (
55+
type(state) is not BmsReadState
56+
or state.transport is not transport
57+
or state.inverter is not inverter
58+
or now < state.last_clock
59+
):
60+
state = BmsReadState(transport, inverter, now)
61+
runtime_state[STATE_KEY] = state
62+
values = {}
63+
if now >= state.retry_at:
64+
delay = RETRY_DELAY
65+
try:
66+
async with asyncio.timeout(READ_TIMEOUT):
67+
words = await session.read_holding(109, 5)
68+
except ConnectionError:
69+
# A positive link failure still belongs to normal runtime recovery.
70+
raise
71+
except Exception as exc:
72+
if getattr(transport, "connected", True) is False:
73+
raise ConnectionError("must_bms_connection_lost") from exc
74+
if isinstance(exc, ModbusError) and str(exc) in {"exception_code:1", "exception_code:2"}:
75+
state.status = "unsupported"
76+
delay = UNSUPPORTED_RETRY_DELAY
77+
else:
78+
state.status = "timeout" if isinstance(exc, TimeoutError) else "invalid_response"
79+
else:
80+
try:
81+
values = decode_bms(words, schema)
82+
except ModbusError:
83+
state.status = "invalid_response"
84+
else:
85+
state.status = "ok" if "battery_soc" in values else "invalid_soc" if values else "no_data"
86+
if values:
87+
delay = 0.0
88+
state.retry_at = clock() + delay
89+
state.last_clock = clock()
90+
return values, {
91+
"must_bms_status": state.status,
92+
"must_bms_retry_after_seconds": round(max(0, state.retry_at - state.last_clock), 3),
93+
}

‎custom_components/eybond_local/protocol_catalogs/register_schemas/must_pv_ph18/pv3300.json‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,27 @@
22
"extends": "must_pv_ph18/base.json",
33
"schema_key": "must_pv3300",
44
"title": "MUST PV3300 telemetry (6422 field-confirmed variant)",
5+
"blocks": [
6+
{"key": "bms", "start": 109, "count": 5}
7+
],
58
"spec_sets": {
9+
"bms": [
10+
{"key": "bms_battery_voltage", "register": 109, "divisor": 10},
11+
{"key": "bms_battery_current", "register": 110, "signed": true, "divisor": 10},
12+
{"key": "bms_battery_temperature", "register": 111},
13+
{"key": "battery_soc", "register": 113, "signed": true}
14+
],
615
"runtime": [
716
{"key": "load_percent", "divisor": 1, "decimals": 0},
817
{"key": "battery_current", "multiplier": -1},
918
{"key": "battery_power", "multiplier": -1},
1019
{"key": "grid_power", "multiplier": -1}
1120
]
12-
}
21+
},
22+
"measurement_descriptions": [
23+
{"key": "battery_soc", "name": "Battery State of Charge", "unit": "%", "device_class": "battery", "state_class": "measurement"},
24+
{"key": "bms_battery_voltage", "name": "BMS Battery Voltage", "unit": "V", "device_class": "voltage", "state_class": "measurement", "diagnostic": true},
25+
{"key": "bms_battery_current", "name": "BMS Battery Current", "unit": "A", "device_class": "current", "state_class": "measurement", "diagnostic": true},
26+
{"key": "bms_battery_temperature", "name": "BMS Battery Temperature", "unit": "°C", "device_class": "temperature", "state_class": "measurement", "diagnostic": true}
27+
]
1328
}

‎docs/generated/INVERTER_MODEL_CATALOG.generated.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,14 +416,14 @@ Runtime descriptors with no specific commercial model record. These are generic
416416
- `must_pv3300` → surface `must_pv3300_full` (driver must_pv_ph18, variant pv3300)
417417
- Protocol: must_pv_ph18 | Detection: anchors (protocol.protocol_id=MUST_PV_PH18; identity.model_number=PV3300)
418418
- Tier: full | Read-only: no | Profile: must_pv_ph18/base.json | Schema: must_pv_ph18/pv3300.json
419-
- Capabilities: 27 (untested 27); support tiers: standard 27 | Telemetry: 37 measurements, 0 binary sensors
419+
- Capabilities: 27 (untested 27); support tiers: standard 27 | Telemetry: 41 measurements, 0 binary sensors
420420
- Known limitations:
421-
- SOC is not yet exposed. Support Archives can check the vendor-documented optional BMS register block, but its availability on this firmware is unconfirmed.
421+
- SOC and separate BMS voltage/current/temperature require the optional BMS block confirmed in issue #46. Missing or invalid data stays unavailable; BMS current keeps the native signed convention.
422422
- Learned maps tied to the older generic MUST schema are not automatically rebased; existing compatibility checks remain in force.
423423
- Output Current and Inverter Load Current can remain zero on the reported unit; their meaning is still under investigation.
424424
- Other PV/PH/EP models retain the common register interpretation because published MUST documents disagree on load-percentage units.
425425
- Previously accumulated energy/history is not rewritten; daily estimated totals reset with the first valid sample of the next local day.
426-
- Evidence: 2 source(s)
426+
- Evidence: 3 source(s)
427427

428428
### PowMr — 4.2KW (`powmr_4_2kw`)
429429

‎docs/user/RUNTIME_AND_INVERTER.md‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,24 @@ and the selected control mode are not changed. No local hardware qualification
167167
is implied by the existing broad setpoint limits; use only settings appropriate
168168
to your exact model and battery.
169169

170-
There is no verified PV3300 SOC sensor yet. Its manufacturer protocol describes
171-
an optional BMS block containing SOC. Creating a Support Archive now checks that
172-
block once, read-only, with a three-second limit. Unsupported registers or a
173-
timeout are recorded in the archive; they do not add a zero-percent sensor or
174-
change normal polling. Battery capacity in Ah is a setting, not remaining charge.
170+
PV3300 devices with supported BMS communication now expose **Battery State of
171+
Charge** directly from the BMS, not an estimate from battery voltage. Separate
172+
**BMS Battery Voltage**, **BMS Battery Current** and **BMS Battery Temperature**
173+
appear under inverter diagnostics. Update the main test build and restart HA;
174+
there is no need to re-add the device or enable Full Control for these readings.
175+
176+
These BMS readings do not replace the inverter's existing battery measurements.
177+
**BMS Battery Current** keeps the manufacturer's signed value; do not assume it
178+
uses the same direction convention as **Battery Current**. Battery capacity in
179+
Ah is a setting, not remaining charge.
180+
181+
The optional BMS request has a three-second limit. Missing or invalid readings
182+
become unavailable, not zero or a frozen last value. Other inverter telemetry
183+
continues; after a failure or an empty BMS response the integration retries after
184+
one minute, or five minutes if the device explicitly rejects the command/address.
185+
A genuine 0% SOC with valid BMS data remains 0%. Other MUST models keep their
186+
existing map. A Support Archive also includes a separate bounded raw BMS read
187+
for troubleshooting.
175188

176189
## SRNE partial battery / PV readings
177190

‎docs/user/SUPPORT_ARCHIVE.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ controls. The archive retains the original readings and the extra observations
9494
separately, so a later response does not overwrite the evidence from the first.
9595

9696
For **MUST PV3300**, the archive also tries one read of the documented optional
97-
BMS block (registers 109–113), including the candidate SOC register. This adds
97+
BMS block (registers 109–113), including SOC. This adds
9898
at most three seconds and is skipped if the preceding current comparison lost
99-
communication. Raw words and read failures are retained separately; they do not
100-
create an SOC sensor, change normal polling or assume a BMS is connected.
99+
communication. Raw words and read failures are retained separately. This check
100+
does not assume a BMS is connected or change the runtime retry state. Supported PV3300
101+
devices now also read this block during normal polling; missing or invalid
102+
runtime BMS readings stay unavailable.
101103

102104
The archive helps answer questions such as:
103105

0 commit comments

Comments
 (0)