Skip to content

Commit e3a4181

Browse files
author
Maksym Shtembuliak
committed
Fix cloud-tool preparation races and partial telemetry reads
Serialize proxy and active learning startup with polling and unload cleanup. Restore SRNE battery/PV reads and valid catalog proof, correct MUST current labels, and add bounded read-only support diagnostics for MUST and Hopewind. Add lifecycle and telemetry regressions, document behavior, and clarify manual main installation. Refs #23, #43, #44, #46, #13.
1 parent cd7834b commit e3a4181

31 files changed

Lines changed: 1133 additions & 78 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ the GitHub release body should be rendered from the matching version section her
99

1010
### Added
1111

12+
- Support archives include bounded, read-only checks of documented Hopewind
13+
0237 control addresses (#23), and individual MUST PV3300 current registers
14+
when the combined block reports all three as zero (#46). Results stay separate
15+
from normal telemetry. These checks do not write settings or enable controls.
16+
1217
- Added a read-only **Hopewind String (Protocol 0237)** profile, based on the
1318
manufacturer's map and successful local reads from a Bluesun BSM15K-B (#23).
1419
It exposes PV and AC generation, electrical readings, energy counters and
@@ -40,6 +45,23 @@ the GitHub release body should be rendered from the matching version section her
4045

4146
### Fixed
4247

48+
- Proxy capture and active device learning now wait for an in-flight poll
49+
before preparing the collector connection. New polls cannot interrupt the
50+
preparation or its rollback; unloading waits for startup cleanup before
51+
closing the link (#43). This fixes a reproduced overlap, not a confirmed
52+
cause of every reported collector disconnect.
53+
54+
- SRNE polling can recover battery and PV readings through five documented
55+
short register groups after an explicit illegal-address rejection of the
56+
combined DC block (#44). Timeouts and malformed responses do not trigger
57+
this fallback. Missing groups remain unavailable instead of becoming zero.
58+
The detected read-only family also retains the catalog proof required to
59+
recreate its sensors after a Home Assistant reload.
60+
61+
- MUST current labels now match the documented nodes: **Inverter Current**,
62+
**Grid Current**, and **Load Current** (#46). Existing entity IDs and values
63+
are unchanged; current is not estimated from power and voltage.
64+
4365
- MUST PV3300 reports whole load percent and normalizes battery/grid directions
4466
for HA energy flows (#46): positive battery power/current means charging;
4567
positive grid power means importing. These corrections are model-scoped;

‎README.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ It is a small ESP8266/ESP32-based bridge that connects directly to the inverter
131131
### Manual installation
132132

133133
1. Download the archive from the [latest EyeBond Local release](https://github.com/groove-max/ha-eybond-local/releases/latest).
134-
2. Copy `custom_components/eybond_local/` into `config/custom_components/`.
134+
2. Extract the archive and copy only its `custom_components/eybond_local/`
135+
folder into `config/custom_components/`. The final path must be
136+
`config/custom_components/eybond_local/manifest.json`, without another
137+
repository or `custom_components` folder nested inside it.
135138
3. Restart Home Assistant.
136139
4. Add **EyeBond Local** from **Settings → Devices & Services**.
137140

@@ -148,9 +151,12 @@ yet. It does not update through HACS and may change before the next release.
148151

149152
1. Back up your Home Assistant configuration.
150153
2. Download the current [`main` branch archive](https://github.com/groove-max/ha-eybond-local/archive/refs/heads/main.zip).
151-
3. Remove the existing `config/custom_components/eybond_local/` directory, then
152-
copy the complete directory from the archive into `config/custom_components/`.
153-
Do not mix files from two builds.
154+
3. Extract the archive. Inside `ha-eybond-local-main`, open `custom_components`.
155+
Replace the existing `config/custom_components/eybond_local/` directory with
156+
the complete **`eybond_local`** folder from there. Do not copy the outer
157+
`ha-eybond-local-main` folder or mix files from two builds. Check that
158+
`manifest.json` is directly inside `config/custom_components/eybond_local/`.
159+
You do not need to remove the integration or its devices from Home Assistant.
154160
4. Restart Home Assistant and check the EyeBond Local entries.
155161
5. When reporting a result, include the Git commit shown on the repository page
156162
and attach a new Support Archive.

‎custom_components/eybond_local/drivers/modbus_catalog.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
merge_capability_register_word,
5252
)
5353
from .catalog_probe import async_walk_detection_dag
54+
from .support_diagnostics import capture_support_reads
5455

5556
logger = logging.getLogger(__name__)
5657

@@ -356,13 +357,13 @@ async def async_capture_support_evidence(
356357
"words": list(values),
357358
}
358359
)
359-
return {
360+
evidence = {
360361
"capture_kind": "modbus_catalog_register_dump",
361362
"driver_key": self.key,
362363
"model_name": inverter.model_name,
363364
"serial_number": inverter.serial_number,
364365
"capture_notes": [
365-
"Support capture only reads catalog blocks; it never invokes"
366+
"Support capture only reads catalog blocks and optional support reads; it never invokes"
366367
" the driver's optional write capabilities. Blocks list their"
367368
" function codes (3 = holding, 4 = input).",
368369
],
@@ -382,6 +383,16 @@ async def async_capture_support_evidence(
382383
for item in captured_ranges
383384
],
384385
}
386+
plan = schema.support_read_plan
387+
if plan is not None:
388+
evidence["support_read_diagnostics"] = await capture_support_reads(
389+
session,
390+
tuple((block.start, block.count, block.key) for block in plan.blocks),
391+
timeout_seconds=plan.timeout_seconds,
392+
source=plan.source,
393+
purpose=plan.purpose,
394+
)
395+
return evidence
385396

386397
def local_register_read_plans(
387398
self, inverter: DetectedInverter

‎custom_components/eybond_local/drivers/must.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ..payload.modbus import ModbusError, ModbusSession
1616
from ..payload.register_decode import decode_ascii_word, read_spec_set_values
1717
from .base import InverterDriver
18+
from .support_diagnostics import capture_support_reads
1819
from .local_register_evidence import (
1920
LocalRegisterReadPlan,
2021
LocalRegisterSnapshot,
@@ -217,7 +218,7 @@ async def async_capture_support_evidence(
217218
"words": list(values),
218219
}
219220
)
220-
return {
221+
evidence = {
221222
"capture_kind": "must_pv_ph18_modbus_register_dump",
222223
"driver_key": self.key,
223224
"model_name": inverter.model_name,
@@ -238,6 +239,22 @@ async def async_capture_support_evidence(
238239
for item in captured_ranges
239240
],
240241
}
242+
if inverter.register_schema_name == "must_pv_ph18/pv3300.json":
243+
raw = {
244+
block["start"] + offset: word
245+
for block in captured_ranges
246+
for offset, word in enumerate(block["words"])
247+
}
248+
if all(raw.get(address) == 0 for address in (25210, 25211, 25212)):
249+
evidence["current_read_diagnostics"] = await capture_support_reads(
250+
session,
251+
((25210, 1, "inverter_current"), (25211, 1, "grid_current"),
252+
(25212, 1, "load_current")),
253+
timeout_seconds=6.0,
254+
source="PH/PV Modbus 1.4.3 and protocol 1916 current registers",
255+
purpose="support_only_zero_bulk_current_single_read_comparison",
256+
)
257+
return evidence
241258

242259
def local_register_read_plans(
243260
self, inverter: DetectedInverter

‎custom_components/eybond_local/drivers/srne.py‎

Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import asyncio
65
from typing import Any
76

87
from ..metadata.compiled_detection_catalog import load_compiled_detection_catalog
@@ -18,10 +17,11 @@
1817
async_capture_modbus_snapshot,
1918
)
2019
from .read_result import DriverReadMode, DriverReadResult
20+
from .support_diagnostics import capture_support_reads
2121

2222

2323
# SRNE Modbus V2.07, P01 DC Data Area (addresses in that table are hexadecimal).
24-
# Support-only diagnosis of a rejected 0x0100/18 read; never a runtime read plan.
24+
# Documented short reads after an explicit rejection of the 0x0100/18 block.
2525
# In particular, do not cross the optional/new fields or reserved 0x010D again.
2626
_DC_DIAGNOSTIC_RANGES = (
2727
(0x0100, 3, "battery"),
@@ -87,17 +87,28 @@ async def async_probe(self, transport, target: ProbeTarget) -> DetectedInverter
8787
if not _looks_like_srne_product_info(product_info):
8888
return None
8989

90-
surface = load_compiled_detection_catalog().surfaces["srne_modbus_read_only"]
90+
catalog = load_compiled_detection_catalog()
91+
evidence = {
92+
"identity.product_info": product_info,
93+
"protocol.protocol_id": "SRNE_MODBUS",
94+
}
95+
resolution = catalog.resolve_family(protocol_key=self.key, evidence=evidence)
96+
if not resolution.surface_key:
97+
return None
98+
surface = catalog.surfaces[resolution.surface_key]
9199
details = {
92100
"product_info": product_info,
93101
"protocol_id": "SRNE_MODBUS",
94102
"catalog_detection": {
95-
"resolution": "exact",
103+
"resolution": resolution.resolution,
96104
"surface_key": surface.key,
97-
"evidence": {
98-
"identity.product_info": product_info,
99-
"protocol.protocol_id": "SRNE_MODBUS",
100-
},
105+
"confidence": resolution.confidence,
106+
"candidate_keys": list(resolution.candidate_keys),
107+
"catalog_version": resolution.catalog_version,
108+
"descriptor_revisions": list(resolution.descriptor_revisions),
109+
"evidence_fingerprint": resolution.evidence_fingerprint,
110+
"evidence": evidence,
111+
"decision_path": list(resolution.decision_path),
101112
},
102113
}
103114
return DetectedInverter(
@@ -125,7 +136,14 @@ async def async_read_values(
125136
inverter.register_schema_name or self.register_schema_name
126137
)
127138
session = self._session(transport, inverter.probe_target)
128-
values = await read_spec_set_values(session, schema, ascii_style="printable")
139+
values = await read_spec_set_values(
140+
session,
141+
schema,
142+
ascii_style="printable",
143+
illegal_address_fallbacks={
144+
(3, 0x0100, 18): tuple((start, count) for start, count, _ in _DC_DIAGNOSTIC_RANGES)
145+
},
146+
)
129147
return DriverReadResult(values=values, mode=DriverReadMode.FULL)
130148

131149
async def async_write_capability(
@@ -216,15 +234,23 @@ def local_register_read_plans(
216234
schema = load_register_schema(
217235
inverter.register_schema_name or self.register_schema_name
218236
)
219-
return tuple(
220-
LocalRegisterReadPlan.for_target(
221-
inverter.probe_target,
222-
function=3,
223-
start=block.start,
224-
count=block.count,
237+
# Bounded background evidence uses the same documented DC groups, not
238+
# the unsupported parent or zero-filled holes. Ordinary polling still
239+
# prefers the compact parent when the device accepts it.
240+
plans = []
241+
for block in schema.blocks:
242+
ranges = (
243+
tuple((start, count) for start, count, _ in _DC_DIAGNOSTIC_RANGES)
244+
if (block.start, block.count) == (0x0100, 18)
245+
else ((block.start, block.count),)
225246
)
226-
for block in schema.blocks
227-
)
247+
plans.extend(
248+
LocalRegisterReadPlan.for_target(
249+
inverter.probe_target, function=3, start=start, count=count
250+
)
251+
for start, count in ranges
252+
)
253+
return tuple(plans)
228254

229255
async def async_capture_local_register_snapshot(
230256
self, transport, inverter: DetectedInverter, *, collector_pn: str
@@ -257,45 +283,14 @@ async def _capture_dc_subranges(session: ModbusSession) -> dict[str, Any]:
257283
phase probing, capability writes or mutation of the runtime schema occurs.
258284
"""
259285

260-
result: dict[str, Any] = {
261-
"source": "SRNE Modbus V2.07, P01 DC Data Area",
262-
"purpose": "support_only_rejected_dc_block",
263-
"trigger_range": {"start": 0x0100, "count": 18},
264-
"time_budget_seconds": _DC_DIAGNOSTIC_TIMEOUT,
265-
"planned_ranges": [
266-
{"start": start, "count": count, "group": group}
267-
for start, count, group in _DC_DIAGNOSTIC_RANGES
268-
],
269-
"status": "completed",
270-
"captured_ranges": [],
271-
"range_failures": [],
272-
}
273-
loop = asyncio.get_running_loop()
274-
deadline = loop.time() + _DC_DIAGNOSTIC_TIMEOUT
275-
for start, count, _group in _DC_DIAGNOSTIC_RANGES:
276-
if loop.time() >= deadline:
277-
result["status"] = "budget_exhausted"
278-
break
279-
timeout = asyncio.timeout_at(deadline)
280-
try:
281-
async with timeout:
282-
words = await session.read_holding(start, count)
283-
except Exception as exc:
284-
expired = timeout.expired()
285-
result["range_failures"].append(
286-
{
287-
"start": start,
288-
"count": count,
289-
"error": "diagnostic_budget_exhausted" if expired else str(exc),
290-
}
291-
)
292-
if _is_address_rejection(exc):
293-
continue
294-
result["status"] = "budget_exhausted" if expired else "stopped_on_error"
295-
break
296-
result["captured_ranges"].append(
297-
{"start": start, "count": count, "words": list(words)}
298-
)
286+
result = await capture_support_reads(
287+
session,
288+
_DC_DIAGNOSTIC_RANGES,
289+
timeout_seconds=_DC_DIAGNOSTIC_TIMEOUT,
290+
source="SRNE Modbus V2.07, P01 DC Data Area",
291+
purpose="support_only_rejected_dc_block",
292+
)
293+
result["trigger_range"] = {"start": 0x0100, "count": 18}
299294
return result
300295

301296

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Bounded read-only evidence; never changes runtime maps or enables writes."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import Any
7+
8+
from ..payload.modbus import ModbusError, ModbusSession
9+
10+
11+
async def capture_support_reads(
12+
session: ModbusSession,
13+
ranges: tuple[tuple[int, int, str], ...],
14+
*,
15+
timeout_seconds: float,
16+
source: str,
17+
purpose: str,
18+
) -> dict[str, Any]:
19+
"""Read a fixed driver-owned list; only illegal addresses allow continuing."""
20+
21+
result: dict[str, Any] = {
22+
"source": source,
23+
"purpose": purpose,
24+
"time_budget_seconds": timeout_seconds,
25+
"planned_ranges": [
26+
{"start": start, "count": count, "group": group}
27+
for start, count, group in ranges
28+
],
29+
"status": "completed",
30+
"captured_ranges": [],
31+
"range_failures": [],
32+
}
33+
loop = asyncio.get_running_loop()
34+
deadline = loop.time() + timeout_seconds
35+
for start, count, _group in ranges:
36+
if loop.time() >= deadline:
37+
result["status"] = "budget_exhausted"
38+
break
39+
timeout = asyncio.timeout_at(deadline)
40+
try:
41+
async with timeout:
42+
words = await session.read_holding(start, count)
43+
except Exception as exc:
44+
expired = timeout.expired()
45+
result["range_failures"].append({
46+
"start": start, "count": count,
47+
"error": "diagnostic_budget_exhausted" if expired else str(exc),
48+
})
49+
if isinstance(exc, ModbusError) and str(exc) == "exception_code:2":
50+
continue
51+
result["status"] = "budget_exhausted" if expired else "stopped_on_error"
52+
break
53+
result["captured_ranges"].append({"start": start, "count": count, "words": list(words)})
54+
return result

0 commit comments

Comments
 (0)