Skip to content

Commit ad0b442

Browse files
committed
fix: discriminate callback AABB MPPT frames from FC4
Framed and AT collectors harvest bare aabb side-channel telemetry without flipping forward mode or stealing correlated FC4 replies.
1 parent 44e7fc2 commit ad0b442

12 files changed

Lines changed: 1833 additions & 93 deletions

File tree

‎CHANGELOG.md‎

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

1010
### Fixed
1111

12+
- Collector framed/AT transports now discriminate bare MPPT `aabb` frames from EyeBond FC4 on the callback socket, complete AABB waiters without corrupting negotiated forward mode, and project last-PV snapshots into `collector_info` for drivers that solicit `5AA5…02`.
13+
1214
- Callback identity probes no longer start a process-wide shared listener they cannot tear down. Attempts borrow an already-owned discovery listener or report `callback_listener_unavailable`, and expected onboarding outcomes (timeouts, silent callbacks, wire retries) log at debug/info instead of warning.
1315

1416
- Read-only cloud analysis now retains its results for collector-only entries

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
_acquire_shared_listener,
2424
_acquire_shared_payload_listener,
2525
_release_shared_listener,
26+
_resolve_registered_listener,
2627
)
2728
from .proxy import SharedProxyCaptureRoute
2829
from .shared_at import SharedCollectorAtTransport

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

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ...link_models import EybondLinkRoute, LinkRoute, RawSerialLinkRoute
2525
from ...link_transport import PayloadLinkTransport
2626
from ...models import CollectorInfo
27+
from ...payload.short_ascii import AABB_WIRE
2728
from ..profile import apply_collector_profile
2829
from ..protocol import (
2930
EybondHeader,
@@ -207,6 +208,142 @@ def _looks_like_plain_raw_response_start(chunk: bytes) -> bool:
207208
return 0x20 <= value <= 0x7E
208209

209210

211+
def _record_aabb_telemetry(collector: CollectorInfo, frame: bytes) -> bool:
212+
"""Harvest one fixed-length ``aabb`` PV telemetry frame onto collector info.
213+
214+
Returns True when ``frame`` was recognized as ``aabb`` (even non-PV subtypes)
215+
so the caller can consume it from the TCP stream.
216+
"""
217+
218+
if len(frame) < AABB_WIRE.frame_length or not frame.startswith(AABB_WIRE.magic):
219+
return False
220+
frame = frame[: AABB_WIRE.frame_length]
221+
subtype_bytes = frame[len(AABB_WIRE.magic) : AABB_WIRE.data_offset]
222+
subtype = subtype_bytes.hex()
223+
frame_hex = frame.hex()
224+
seen = monotonic()
225+
collector.aabb_last_any = (subtype, frame_hex, seen)
226+
# PV/controller block observed as subtype ``0200`` in the Anern proxy capture.
227+
# Subtype ``0202`` is the MPPT *settings* reply (SI20210402T2), not live PV.
228+
if subtype_bytes == AABB_WIRE.pv_subtype:
229+
collector.aabb_last_pv = (frame_hex, seen)
230+
# Compatibility mirrors describe the PV snapshot only. Keeping a
231+
# non-PV subtype out of these fields prevents projection from combining
232+
# a settings subtype with stale PV bytes and timestamp.
233+
collector.aabb_last_hex = frame_hex
234+
collector.aabb_last_monotonic = seen
235+
collector.aabb_last_subtype = subtype
236+
logger.debug(
237+
"Harvested aabb PV telemetry remote=%s subtype=%s frame=%s",
238+
collector.remote_ip,
239+
subtype,
240+
frame.hex(),
241+
)
242+
else:
243+
logger.debug(
244+
"Ignored non-PV aabb frame remote=%s subtype=%s frame=%s",
245+
collector.remote_ip,
246+
subtype,
247+
frame.hex(),
248+
)
249+
return True
250+
251+
252+
def _complete_pending_aabb_raw_response(
253+
pending: asyncio.Future[bytes] | None,
254+
*,
255+
protocol: str,
256+
frame: bytes,
257+
) -> bool:
258+
"""Complete a raw ``payload_protocol=aabb`` waiter with one harvested frame."""
259+
260+
if pending is None or pending.done():
261+
return False
262+
if str(protocol or "").strip().lower() != "aabb":
263+
return False
264+
if (
265+
len(frame) < AABB_WIRE.frame_length
266+
or not frame.startswith(AABB_WIRE.magic)
267+
or frame[len(AABB_WIRE.magic) : AABB_WIRE.data_offset]
268+
!= AABB_WIRE.pv_subtype
269+
):
270+
return False
271+
pending.set_result(frame[: AABB_WIRE.frame_length])
272+
return True
273+
274+
275+
async def _discriminate_aabb_magic(
276+
reader: "_PrefixedAsyncReader",
277+
magic: bytes,
278+
*,
279+
timeout: float,
280+
is_correlated_header: Callable[[EybondHeader], bool] | None = None,
281+
) -> tuple[str, bytes]:
282+
"""Decide whether ``AA BB…`` is unsolicited aabb PV or an FC4 header (tid ``0xAABB``).
283+
284+
Caller has already consumed the two-byte ``\\xaa\\xbb`` magic into ``magic``.
285+
286+
Returns one of:
287+
- ``(\"aabb\", frame21)`` — harvest and continue
288+
- ``(\"eybond_header\", header8)`` — process as a normal EyeBond frame header
289+
- ``(\"incomplete\", b\"\")`` — ambiguous bytes were ``push_front``'d; wait for more
290+
underlying data (or discard on stall) before retrying
291+
"""
292+
293+
if magic != AABB_WIRE.magic:
294+
raise ValueError("aabb_magic_required")
295+
296+
buf = bytearray(magic)
297+
# Always inspect the full EyeBond header before committing to AABB. A valid
298+
# FC4 response can have tid=0xAABB and devcode=0x0200, making its first four
299+
# bytes identical to the AABB magic + PV subtype. Explicit correlation with
300+
# an outstanding TID/function is stronger evidence than that subtype.
301+
while len(buf) < HEADER_SIZE:
302+
try:
303+
buf += await asyncio.wait_for(reader.readexactly(1), timeout=timeout)
304+
except asyncio.TimeoutError:
305+
reader.push_front(bytes(buf))
306+
return ("incomplete", b"")
307+
308+
header_candidate = bytes(buf[:HEADER_SIZE])
309+
plausible_header = _looks_like_eybond_frame_start(header_candidate)
310+
if plausible_header and is_correlated_header is not None:
311+
header = decode_header(header_candidate)
312+
if is_correlated_header(header):
313+
return ("eybond_header", header_candidate)
314+
315+
subtype = bytes(buf[len(AABB_WIRE.magic) : AABB_WIRE.data_offset])
316+
known_aabb_subtype = subtype in AABB_WIRE.known_subtypes
317+
target_length = AABB_WIRE.frame_length if known_aabb_subtype else HEADER_SIZE
318+
while len(buf) < target_length:
319+
try:
320+
buf += await asyncio.wait_for(reader.readexactly(1), timeout=timeout)
321+
except asyncio.TimeoutError:
322+
reader.push_front(bytes(buf))
323+
return ("incomplete", b"")
324+
325+
if known_aabb_subtype:
326+
return ("aabb", bytes(buf[: AABB_WIRE.frame_length]))
327+
328+
if plausible_header:
329+
leftover = bytes(buf[HEADER_SIZE:])
330+
if leftover:
331+
reader.push_front(leftover)
332+
return ("eybond_header", header_candidate)
333+
334+
while len(buf) < AABB_WIRE.frame_length:
335+
try:
336+
buf += await asyncio.wait_for(reader.readexactly(1), timeout=timeout)
337+
except asyncio.TimeoutError:
338+
reader.push_front(bytes(buf))
339+
return ("incomplete", b"")
340+
341+
frame = bytes(buf[: AABB_WIRE.frame_length])
342+
leftover = bytes(buf[AABB_WIRE.frame_length :])
343+
if leftover:
344+
reader.push_front(leftover)
345+
return ("aabb", frame)
346+
210347
def _short_ascii(value: bytes, *, limit: int = 160) -> str:
211348
text = "".join(chr(byte) if 0x20 <= byte <= 0x7E else "." for byte in value[:limit])
212349
if len(value) > limit:
@@ -435,6 +572,11 @@ def _copy_collector_info(collector: CollectorInfo) -> CollectorInfo:
435572
collector_virtual_bridge=collector.collector_virtual_bridge,
436573
collector_bridge_kind=collector.collector_bridge_kind,
437574
collector_bridge_version=collector.collector_bridge_version,
575+
aabb_last_any=collector.aabb_last_any,
576+
aabb_last_hex=collector.aabb_last_hex,
577+
aabb_last_monotonic=collector.aabb_last_monotonic,
578+
aabb_last_subtype=collector.aabb_last_subtype,
579+
aabb_last_pv=collector.aabb_last_pv,
438580
)
439581
)
440582

@@ -444,6 +586,33 @@ def __init__(self, reader: asyncio.StreamReader, initial_bytes: bytes = b"") ->
444586
self._reader = reader
445587
self._buffer = bytearray(initial_bytes)
446588

589+
def push_front(self, data: bytes) -> None:
590+
"""Return bytes to the front of the read buffer (unread)."""
591+
592+
if data:
593+
self._buffer[:0] = data
594+
595+
def discard_buffered(self, count: int) -> int:
596+
"""Drop up to ``count`` bytes from the front of the buffer. Return dropped."""
597+
598+
n = max(0, min(int(count), len(self._buffer)))
599+
if n:
600+
del self._buffer[:n]
601+
return n
602+
603+
def buffered_startswith(self, prefix: bytes) -> bool:
604+
return bytes(self._buffer[: len(prefix)]) == prefix
605+
606+
async def await_underlying_byte(self, timeout: float) -> bool:
607+
"""Wait for one new network byte into the buffer. False on timeout."""
608+
609+
try:
610+
byte = await asyncio.wait_for(self._reader.readexactly(1), timeout=timeout)
611+
except asyncio.TimeoutError:
612+
return False
613+
self._buffer.extend(byte)
614+
return True
615+
447616
async def readexactly(self, size: int) -> bytes:
448617
if size <= 0:
449618
return b""
@@ -458,7 +627,6 @@ async def readexactly(self, size: int) -> bytes:
458627
return data
459628
data += await self._reader.readexactly(size - len(data))
460629
return data
461-
462630
async def readuntil(self, separator: bytes = b"\n") -> bytes:
463631
index = self._buffer.find(separator)
464632
if index >= 0:

0 commit comments

Comments
 (0)