2424from ...link_models import EybondLinkRoute , LinkRoute , RawSerialLinkRoute
2525from ...link_transport import PayloadLinkTransport
2626from ...models import CollectorInfo
27+ from ...payload .short_ascii import AABB_WIRE
2728from ..profile import apply_collector_profile
2829from ..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+
210347def _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