Skip to content

Commit f24c7c7

Browse files
author
Maksym Shtembuliak
committed
fix: harden collector runtime sessions
1 parent f6fa995 commit f24c7c7

20 files changed

Lines changed: 636 additions & 66 deletions

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ the GitHub release body should be rendered from the matching version section her
2727

2828
### Changed
2929

30+
- Marked the documented Anenji `0x8401` Protocol 4 control surface as tested for
31+
this exact fingerprint after device-owner validation included working OP2
32+
controls that are not exposed by the vendor apps. The irreversible
33+
generation-data clear and user-parameter reset actions remain blocked by the
34+
independent destructive-action safety policy even though the profile is
35+
treated as compatible.
3036
- Unknown inverter models that report documented Anenji Communication Protocol
3137
3, 4, 5, or 6 now select the matching protocol-specific telemetry map
3238
automatically instead of requiring a new exact-model catalog entry. Their
@@ -55,6 +61,15 @@ the GitHub release body should be rendered from the matching version section her
5561

5662
### Fixed
5763

64+
- Fixed a framed-transport desynchronization where one unexpected unwrapped
65+
Modbus RTU response could be combined with the next EyeBond header, consume
66+
subsequent valid frames, and stall telemetry until the socket was replaced.
67+
Malformed, oversized, or incomplete frames now close only the affected
68+
session with a typed diagnostic reason, after which a fresh callback can
69+
resume normally.
70+
- Preload the SmartESS semantic catalog with the other JSON metadata caches in
71+
Home Assistant's executor, avoiding a first-use blocking file read from the
72+
event loop when diagnostics or cloud evidence first resolves a field.
5873
- Restored exact Anenji ANJ-11KW-48V-WIFI-P identification for the captured
5974
Protocol 4 fingerprint `layout=4`, `model=0x8401`. Unlike the broad legacy
6075
heuristic, the new catalog entry affects only this proven fingerprint; it

‎catalog/inverter_models/models/anenji_anj_11kw_48v_wifi_p.json‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,21 @@
4040
"notes": [
4141
"Device-specific runtime profile is user-confirmed.",
4242
"Communication Protocol No. 3-10 documentation defines the protocol-4 telemetry projection used by this model.",
43-
"The captured 0x8401 hardware fingerprint uses the documented Protocol 4 telemetry surface; its write controls remain untested and require explicit Full Control.",
44-
"Register 602 secondary-output priority is documented and hardware-read-correlated; local write behavior remains conditional pending a device retest."
43+
"The captured 0x8401 hardware fingerprint uses the documented Protocol 4 telemetry and control surface.",
44+
"The 0x8401 owner confirmed local writes and resulting device behavior for OP2 enable plus start-hour and end-hour timer controls, including controls absent from the vendor apps.",
45+
"The documented Protocol 4 control surface is treated as tested for this exact fingerprint; irreversible generation-data clear and user-parameter reset actions remain blocked by the independent safety policy."
4546
]
4647
},
4748
"known_limitations": [
48-
"Secondary Output Priority writes are available only in Full Control until register 602 is write-tested on hardware."
49+
"Irreversible generation-data clear and user-parameter reset actions remain blocked even though the ordinary Protocol 4 control surface is enabled."
4950
],
5051
"knowledge_summary": "Anenji 11kW unit with a model-specific control profile over the shared protocol-4 register projection, backed by donor captures, user-confirmed controls, and the Protocol No. 3-10 documentation.",
5152
"source_keys": [
5253
"corpus_anenji_anj_11kw",
5354
"private_message_anenji_anj_11kw_validation",
5455
"documentation_anenji_protocol_3_10",
5556
"support_archive_anenji_anj_11kw_secondary_controls_20260707",
56-
"github_issue_13_anenji_8401_beta4"
57+
"github_issue_13_anenji_8401_beta4",
58+
"github_issue_38_anenji_8401_op2_controls"
5759
]
5860
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"schema_version": 1,
3+
"source_key": "github_issue_38_anenji_8401_op2_controls",
4+
"kind": "github_issue",
5+
"title": "Anenji 0x8401 OP2 hardware control validation",
6+
"reference": "project-issue:38#issuecomment-5482296845",
7+
"captured_at": "2026-08-31",
8+
"visibility": "public",
9+
"summary": "The owner of the captured layout 4, model 0x8401 Protocol 4 inverter confirmed successful local writes and resulting behavior for OP2 output enable, OP2 start hour, and OP2 end hour. The overload-alarm setting was visible but was not explicitly reported as write-tested.",
10+
"assertions": [
11+
"hardware_test",
12+
"write_validation",
13+
"control_behavior"
14+
]
15+
}

‎custom_components/eybond_local/collector/transport/common.py‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -162,21 +162,7 @@ def _looks_like_eybond_frame_start(chunk: bytes) -> bool:
162162
header = decode_header(chunk)
163163
except Exception:
164164
return False
165-
if header.total_len < HEADER_SIZE:
166-
return False
167-
if header.total_len > 4096:
168-
return False
169-
if header.fcode not in {
170-
FC_HEARTBEAT,
171-
FC_QUERY_COLLECTOR,
172-
FC_SET_COLLECTOR,
173-
FC_FORWARD_TO_DEVICE,
174-
FC_SET_DEVICE_REG,
175-
FC_TRIGGER_QUERY_REAL_TIME,
176-
FC_TRIGGER_QUERY_HISTORY,
177-
}:
178-
return False
179-
return True
165+
return _runtime_eybond_header_error(header) == ""
180166

181167

182168
def _classify_initial_protocol_shape(chunk: bytes) -> str:
@@ -242,6 +228,26 @@ def _short_ascii(value: bytes, *, limit: int = 160) -> str:
242228
}
243229

244230

231+
def _runtime_eybond_header_error(header: EybondHeader) -> str:
232+
"""Return a typed reason when a runtime EyeBond header is implausible.
233+
234+
``decode_header`` deliberately stays a mechanical wire decoder because the
235+
transparent cloud tooling understands more function codes than the local
236+
runtime. The runtime reader, protocol sniffing, and the mixed AT reader do
237+
need one shared fail-closed definition, however: accepting an arbitrary
238+
eight-byte sequence as a header can turn one raw RTU response into a large
239+
pseudo-frame and consume every valid frame that follows it.
240+
"""
241+
242+
if header.payload_len < 0:
243+
return "collector_frame_length_invalid"
244+
if header.payload_len > _AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN:
245+
return "collector_frame_payload_too_large"
246+
if header.fcode not in _AT_TEXT_MIXED_FRAME_FCODES:
247+
return "collector_frame_function_invalid"
248+
return ""
249+
250+
245251
def _mask_identity_token(value: str) -> str:
246252
token = str(value or "").strip()
247253
if len(token) <= 6:

‎custom_components/eybond_local/collector/transport/connections.py‎

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@
2727
parse_heartbeat_pn,
2828
)
2929
from .common import (
30-
_AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN,
31-
_AT_TEXT_MIXED_FRAME_FCODES,
3230
_AT_TEXT_MIXED_FRAME_READ_TIMEOUT,
3331
_PrefixedAsyncReader,
3432
_cancel_and_join_task,
@@ -38,12 +36,21 @@
3836
_looks_like_plain_raw_response_start,
3937
_looks_like_uart_passthrough_value,
4038
_parse_fc2_collector_pn,
39+
_runtime_eybond_header_error,
4140
_short_ascii,
4241
)
4342

4443
logger = logging.getLogger(__name__)
4544

4645

46+
# An idle connection may remain silent indefinitely. Once the collector has
47+
# started a frame, however, every remaining structural part is bounded. This
48+
# prevents a truncated header or a false payload length from pinning the one
49+
# reader task and swallowing later valid responses on the same TCP socket.
50+
_FRAMED_HEADER_COMPLETION_TIMEOUT = 2.0
51+
_FRAMED_PAYLOAD_COMPLETION_TIMEOUT = 5.0
52+
53+
4754
def _modbus_rtu_response_length(prefix: bytes) -> int | None:
4855
"""Return the complete RTU response length for a three-byte prefix.
4956
@@ -422,17 +429,82 @@ async def _read_loop(
422429
reader = _PrefixedAsyncReader(reader)
423430
try:
424431
while True:
425-
prefix = await reader.readexactly(3)
432+
first = await reader.readexactly(1)
433+
try:
434+
prefix = first + await asyncio.wait_for(
435+
reader.readexactly(2),
436+
timeout=_FRAMED_HEADER_COMPLETION_TIMEOUT,
437+
)
438+
except asyncio.TimeoutError:
439+
self._collector.last_disconnect_reason = (
440+
"collector_frame_header_timeout"
441+
)
442+
logger.warning(
443+
"Closing collector session after partial frame prefix "
444+
"remote=%s prefix=%s",
445+
self._collector.remote_ip,
446+
first.hex(),
447+
)
448+
return
426449
if prefix == b"AT+":
427450
line = prefix + await reader.read_at_response()
428451
self._handle_at_response(line)
429452
continue
430453

431-
header_bytes = prefix + await reader.readexactly(HEADER_SIZE - len(prefix))
454+
try:
455+
header_bytes = prefix + await asyncio.wait_for(
456+
reader.readexactly(HEADER_SIZE - len(prefix)),
457+
timeout=_FRAMED_HEADER_COMPLETION_TIMEOUT,
458+
)
459+
except asyncio.TimeoutError:
460+
self._collector.last_disconnect_reason = (
461+
"collector_frame_header_timeout"
462+
)
463+
logger.warning(
464+
"Closing collector session after incomplete frame header "
465+
"remote=%s prefix=%s",
466+
self._collector.remote_ip,
467+
prefix.hex(),
468+
)
469+
return
432470
header = decode_header(header_bytes)
471+
header_error = _runtime_eybond_header_error(header)
472+
if header_error:
473+
self._collector.last_disconnect_reason = header_error
474+
logger.warning(
475+
"Closing collector session after malformed frame header "
476+
"remote=%s reason=%s header=%s tid=%d devcode=0x%04X "
477+
"devaddr=0x%02X fc=%d payload=%d",
478+
self._collector.remote_ip,
479+
header_error,
480+
header_bytes.hex(),
481+
header.tid,
482+
header.devcode,
483+
header.devaddr,
484+
header.fcode,
485+
header.payload_len,
486+
)
487+
return
433488
payload = b""
434489
if header.payload_len > 0:
435-
payload = await reader.readexactly(header.payload_len)
490+
try:
491+
payload = await asyncio.wait_for(
492+
reader.readexactly(header.payload_len),
493+
timeout=_FRAMED_PAYLOAD_COMPLETION_TIMEOUT,
494+
)
495+
except asyncio.TimeoutError:
496+
self._collector.last_disconnect_reason = (
497+
"collector_frame_payload_timeout"
498+
)
499+
logger.warning(
500+
"Closing collector session after incomplete frame payload "
501+
"remote=%s tid=%d fc=%d expected=%d",
502+
self._collector.remote_ip,
503+
header.tid,
504+
header.fcode,
505+
header.payload_len,
506+
)
507+
return
436508

437509
self._collector.last_devcode = header.devcode
438510
logger.debug(
@@ -1218,13 +1290,7 @@ async def _read_mixed_frame_tail(
12181290
return None
12191291

12201292
def _looks_like_mixed_frame_header(self, header: EybondHeader) -> bool:
1221-
if header.payload_len < 0:
1222-
return False
1223-
if header.payload_len > _AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN:
1224-
return False
1225-
if header.fcode not in _AT_TEXT_MIXED_FRAME_FCODES:
1226-
return False
1227-
return True
1293+
return _runtime_eybond_header_error(header) == ""
12281294

12291295
def _record_unhandled_raw_fragment(self, payload: bytes, *, parser: str) -> None:
12301296
self._collector.raw_unhandled_line_count += 1

‎custom_components/eybond_local/collector/transport/listener.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class _CollectorSessionInventoryEntry:
8181
collector_identity_source: str = ""
8282
collector_identity_sources: set[str] = field(default_factory=set)
8383
observed_protocol_shapes: set[str] = field(default_factory=set)
84+
close_reason: str = ""
8485

8586
def diagnostics(self) -> dict[str, object]:
8687
result: dict[str, object] = {
@@ -106,6 +107,8 @@ def diagnostics(self) -> dict[str, object]:
106107
result["observed_protocol_shapes"] = sorted(
107108
self.observed_protocol_shapes
108109
)
110+
if self.close_reason:
111+
result["close_reason"] = self.close_reason
109112
return result
110113

111114

@@ -922,6 +925,15 @@ def _mark_socket_session_closed(self, session_id: str, connection: object) -> No
922925
):
923926
if mapping.get(normalized) is connection:
924927
mapping.pop(normalized, None)
928+
entry = self._session_inventory.get(normalized)
929+
if entry is not None:
930+
try:
931+
collector = connection.collector_info # type: ignore[attr-defined]
932+
except Exception:
933+
collector = None
934+
entry.close_reason = str(
935+
getattr(collector, "last_disconnect_reason", "") or ""
936+
).strip()
925937
self._mark_session_state(normalized, "closed_disconnected")
926938
logger.info(
927939
"Collector socket session closed listener_port=%s session=%s",
@@ -1091,6 +1103,8 @@ async def pop_pending_socket_for_route(
10911103
pending = self._select_pending_socket_by_session_id(normalized_session_id)
10921104
if pending is None:
10931105
return None
1106+
if self._reserved_for_transparent_route(pending):
1107+
return None
10941108
await self._pause_pending_sniff(pending)
10951109
if not self._pending_socket_still_registered(pending):
10961110
return None
@@ -1101,13 +1115,17 @@ async def pop_pending_socket_for_route(
11011115
pending = self._select_pending_socket(collector_ip)
11021116
if pending is None:
11031117
return None
1118+
if self._reserved_for_transparent_route(pending):
1119+
return None
11041120
await self._pause_pending_sniff(pending)
11051121
if not self._pending_socket_still_registered(pending):
11061122
return None
11071123
return self._claim_pending_socket(pending)
11081124

11091125
matched = self._select_pending_socket_by_collector_pn(normalized_pn)
11101126
if matched is not None:
1127+
if self._reserved_for_transparent_route(matched):
1128+
return None
11111129
return self._claim_pending_socket(matched)
11121130

11131131
candidates = self._route_identity_candidates(collector_ip)
@@ -1117,6 +1135,14 @@ async def pop_pending_socket_for_route(
11171135
for pending in candidates:
11181136
if not self._pending_socket_still_registered(pending):
11191137
continue
1138+
# A causally-new socket reserved by a byte-transparent cloud
1139+
# route must never be identity-probed or claimed by the normal
1140+
# runtime. The transparent route owns the first read so the
1141+
# real collector/cloud handshake remains unmodified. Baseline
1142+
# sessions and fresh sockets whose observed wire cannot belong
1143+
# to that route are intentionally unaffected.
1144+
if self._reserved_for_transparent_route(pending):
1145+
continue
11201146
# Ownership safety: when a PN is present, never probe or claim a
11211147
# socket already identified as a DIFFERENT collector (e.g. a
11221148
# second collector behind the same NAT/public IP). Only our-PN or

‎custom_components/eybond_local/drivers/registry.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ..metadata.profile_loader import load_driver_profile
2222
from ..metadata.register_schema_loader import load_register_schema
2323
from ..metadata.smartess_protocol_catalog_loader import load_smartess_protocol_catalog
24+
from ..metadata.smartess_semantic_catalog_loader import load_smartess_semantic_catalog
2425
from ..models import (
2526
BinarySensorDescription,
2627
CapabilityGroup,
@@ -393,6 +394,7 @@ def _prime_catalog_driven_metadata() -> None:
393394
load_device_catalog()
394395
compiled_catalog = load_compiled_detection_catalog()
395396
load_collector_cloud_profile_catalog()
397+
load_smartess_semantic_catalog()
396398

397399
for surface in compiled_catalog.surfaces.values():
398400
if surface.profile_name:

‎custom_components/eybond_local/metadata/profile_loader.py‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -780,10 +780,6 @@ def _validate_profile(profile: DriverProfileMetadata) -> None:
780780
f"profile:{profile.key}:unsupported_support_tier:"
781781
f"{capability.key}:{capability.support_tier}"
782782
)
783-
if capability.support_tier == "blocked" and capability.tested:
784-
raise ValueError(
785-
f"profile:{profile.key}:blocked_capability_cannot_be_tested:{capability.key}"
786-
)
787783
_unique_or_raise(
788784
items=(str(choice.value) for choice in capability.enum_choices),
789785
kind=f"choice_value:{capability.key}",

‎custom_components/eybond_local/protocol_catalogs/inverter_catalog.json‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,17 @@
13671367
"read_only": false,
13681368
"support_capture_policy": "smg_anenji_11k"
13691369
},
1370+
{
1371+
"key": "smg_anenji_11kw_8401_full",
1372+
"protocol_key": "modbus_smg",
1373+
"driver_key": "modbus_smg",
1374+
"variant_key": "anenji_anj_11kw_48v_wifi_p_8401",
1375+
"profile_name": "modbus_smg/models/anenji_anj_11kw_48v_wifi_p_8401.json",
1376+
"register_schema_name": "modbus_smg/protocols/communication_protocol_4.json",
1377+
"tier": "full",
1378+
"read_only": false,
1379+
"support_capture_policy": "smg_anenji_11k"
1380+
},
13701381
{
13711382
"key": "smg_protocol_5_family_fallback",
13721383
"protocol_key": "modbus_smg",
@@ -1935,7 +1946,7 @@
19351946
"serial_ascii_plausible"
19361947
],
19371948
"model_name": "Anenji ANJ-11KW-48V-WIFI-P",
1938-
"surface_key": "smg_protocol_4_family_fallback",
1949+
"surface_key": "smg_anenji_11kw_8401_full",
19391950
"runtime_probe": {
19401951
"validation": [
19411952
{

0 commit comments

Comments
 (0)