Skip to content

Commit 1d1f3d3

Browse files
author
Maksym Shtembuliak
committed
fix: drain proxy capture safely and recognize raw PI30 activity
1 parent 12c5d19 commit 1d1f3d3

11 files changed

Lines changed: 1028 additions & 154 deletions

File tree

‎CHANGELOG.md‎

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

101101
### Fixed
102102

103+
- Proxy Capture recognizes CRC-checked raw PI30 commands when checking cloud
104+
session activity. Stopping or cancelling capture drains relay tasks and pending
105+
trace writes before closing the recording file. Startup diagnostics report
106+
connection, identity and activity separately; the existing timeout and live
107+
endpoint-restoration checks remain unchanged.
108+
103109
- An initially silent collector that reveals a short framed heartbeat can
104110
confirm its full PN through FC2 on the same socket instead of being closed
105111
and retried with another dialect (#23). A short PN alone still cannot claim

‎custom_components/eybond_local/runtime/coordinator/cloud_tools.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,15 +1871,35 @@ async def _async_validate_proxy_capture_target(self, *, target_host: str, target
18711871
)
18721872

18731873
async def _async_wait_for_proxy_capture_reconnect(self, trace_path: Path) -> None:
1874-
deadline = asyncio.get_running_loop().time() + 75.0
1874+
loop = asyncio.get_running_loop()
1875+
started = loop.time()
1876+
deadline = started + 75.0
18751877
identity_verified = False
1876-
while asyncio.get_running_loop().time() < deadline:
1878+
last_progress = None
1879+
next_progress_at = started
1880+
status: dict[str, object] = {}
1881+
while loop.time() < deadline:
18771882
if not self._proxy_capture_process_running():
18781883
raise RuntimeError("proxy_capture_route_stopped")
18791884
status = await self.hass.async_add_executor_job(
18801885
lambda: inspect_proxy_capture_start_status(trace_path)
18811886
)
18821887
upstream_error = str(status.get("upstream_error") or "")
1888+
progress = (
1889+
bool(status.get("connected")),
1890+
bool(status.get("identity_verified")),
1891+
bool(status.get("operational_activity")),
1892+
bool(upstream_error),
1893+
)
1894+
now = loop.time()
1895+
if progress != last_progress or now >= next_progress_at:
1896+
logger.debug(
1897+
"Proxy capture startup entry=%s elapsed=%.1fs connected=%s "
1898+
"identity_verified=%s operational_activity=%s upstream_error=%s",
1899+
self.config_entry.entry_id, now - started, *progress,
1900+
)
1901+
last_progress = progress
1902+
next_progress_at = now + 15.0
18831903
if upstream_error:
18841904
raise RuntimeError(f"proxy_capture_upstream_connect_failed:{upstream_error}")
18851905
if status.get("identity_mismatch"):
@@ -1892,6 +1912,14 @@ async def _async_wait_for_proxy_capture_reconnect(self, trace_path: Path) -> Non
18921912
):
18931913
return
18941914
await asyncio.sleep(1.0)
1915+
logger.warning(
1916+
"Proxy capture startup timed out entry=%s elapsed=%.1fs connected=%s "
1917+
"identity_verified=%s operational_activity=%s upstream_error=%s",
1918+
self.config_entry.entry_id, loop.time() - started,
1919+
bool(status.get("connected")), identity_verified,
1920+
bool(status.get("operational_activity")),
1921+
bool(status.get("upstream_error")),
1922+
)
18951923
if identity_verified:
18961924
raise TimeoutError("proxy_capture_cloud_session_not_ready")
18971925
raise TimeoutError("proxy_capture_collector_identity_timeout")

‎custom_components/eybond_local/support/cloud_session_wire.py‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
decode_header,
1515
)
1616
from ..payload.modbus import crc16_modbus
17+
from ..payload.pi30 import build_request as build_pi30_request
1718

1819
_MODBUS_FIXED_LEN_FCODES = frozenset({3, 4, 6})
1920
_MODBUS_WRITE_MULTIPLE_FCODE = 16
@@ -30,10 +31,20 @@
3031
)
3132
_MODBUS_INCOMPLETE = object()
3233
_ASCII_INCOMPLETE = object()
34+
# One bounded raw PI30 line, including its two escaped CRC bytes and CR.
35+
# Recognition is opt-in for transparent capture, not a new shadow carrier.
36+
_PI30_MAX_FRAME_LENGTH = 512
3337

3438

35-
def consume_cloud_message(buffer: bytearray) -> tuple[str, bytes] | None:
36-
"""Consume one complete AT, inverter, or EyeBond message from ``buffer``."""
39+
def consume_cloud_message(
40+
buffer: bytearray, *, allow_pi30: bool = False,
41+
) -> tuple[str, bytes] | None:
42+
"""Consume one complete AT, inverter, or EyeBond message from ``buffer``.
43+
44+
Transparent observation may opt into CRC-verified raw PI30 (kind ``pi30``).
45+
The default framing and shadow allowlists are unchanged. Legacy no-CRC
46+
ASCII remains distinct: printable text is not proof of a valid PI30 CRC.
47+
"""
3748

3849
if not buffer:
3950
return None
@@ -54,6 +65,11 @@ def consume_cloud_message(buffer: bytearray) -> tuple[str, bytes] | None:
5465
if modbus is not None:
5566
return "modbus", modbus
5667

68+
if allow_pi30:
69+
pi30_frame = _consume_pi30_frame(buffer)
70+
if pi30_frame is not None:
71+
return "pi30", pi30_frame
72+
5773
ascii_frame = _consume_g_ascii_frame(buffer)
5874
if ascii_frame is _ASCII_INCOMPLETE:
5975
frame = _consume_eybond_frame_if_complete(buffer)
@@ -120,6 +136,31 @@ def _modbus_crc_is_valid(frame: bytes) -> bool:
120136
return crc16_modbus(frame[:-2]) == int.from_bytes(frame[-2:], "little")
121137

122138

139+
def _consume_pi30_frame(buffer: bytearray) -> bytes | None:
140+
"""Consume a bounded printable body with an exact PI30 CRC and terminator.
141+
142+
CRC bytes are binary, not part of the printable body. Rebuilding through
143+
the payload helper also validates PI30's special CRC-byte escaping, without
144+
duplicating it. No scanning past a bad line, stripping, or buffer mutation
145+
on incomplete/invalid data. Legacy no-CRC G-ASCII is handled separately.
146+
"""
147+
148+
first = buffer[0]
149+
if first != 0x28 and not 0x41 <= first <= 0x5A:
150+
return None
151+
carriage = buffer.find(b"\r", 0, _PI30_MAX_FRAME_LENGTH)
152+
if carriage < 3:
153+
return None
154+
frame = bytes(buffer[:carriage + 1])
155+
body = frame[:-3]
156+
if body.startswith(b"AT+") or any(byte < 0x20 or byte > 0x7E for byte in body):
157+
return None
158+
if build_pi30_request(body.decode("ascii")) != frame:
159+
return None
160+
del buffer[:carriage + 1]
161+
return frame
162+
163+
123164
def _consume_g_ascii_frame(buffer: bytearray) -> bytes | object | None:
124165
if not buffer:
125166
return None

0 commit comments

Comments
 (0)