Skip to content

Commit 6d3df73

Browse files
author
Maksym Shtembuliak
committed
refactor: isolate bounded collector binary framing
Preserve the existing runtime header contract and add an internal checksum-validating binary codec with fixed deadlines, explicit grammar and terminal ambiguity handling. Cover fragmentation, late-frame overlap, suffix preservation and cancellation. No AABB session admission or new inverter driver is enabled. Refs #45. Transport foundation only; no release or deployment.
1 parent 5ea6ce8 commit 6d3df73

5 files changed

Lines changed: 666 additions & 17 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Bounded binary framing, independent of inverter metadata and live waiters.
2+
3+
This codec is an internal building block for the short-ASCII auxiliary channel.
4+
It does not negotiate a collector protocol or enable that channel in runtime.
5+
The session owner supplies the admitted grammar, never a future's done() state.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
from dataclasses import dataclass
12+
from enum import Enum
13+
import math
14+
from typing import Protocol
15+
16+
from ..protocol import (
17+
HEADER_SIZE, EybondHeader, decode_header,
18+
FC_HEARTBEAT, FC_QUERY_COLLECTOR, FC_SET_COLLECTOR, FC_FORWARD_TO_DEVICE,
19+
FC_TRIGGER_QUERY_REAL_TIME, FC_SET_DEVICE_REG, FC_TRIGGER_QUERY_HISTORY,
20+
)
21+
22+
23+
AABB_MAGIC = b"\xaa\xbb"
24+
AABB_FRAME_SIZE = 21
25+
AABB_SUBTYPES = frozenset({b"\x02\x00", b"\x02\x02"})
26+
AABB_PREFIXES = frozenset(AABB_MAGIC + subtype for subtype in AABB_SUBTYPES)
27+
MAX_EYBOND_PAYLOAD_SIZE = 4096
28+
MAX_BINARY_FRAME_SIZE = HEADER_SIZE + MAX_EYBOND_PAYLOAD_SIZE
29+
RUNTIME_EYBOND_FCODES = frozenset({
30+
FC_HEARTBEAT, FC_QUERY_COLLECTOR, FC_SET_COLLECTOR, FC_FORWARD_TO_DEVICE,
31+
FC_TRIGGER_QUERY_REAL_TIME, FC_SET_DEVICE_REG, FC_TRIGGER_QUERY_HISTORY,
32+
})
33+
34+
35+
def runtime_eybond_header_error(header: EybondHeader) -> str:
36+
"""The existing runtime header contract, shared by all binary readers.
37+
38+
Mechanical decode_header stays permissive for transparent cloud tooling.
39+
The supported function set, size limits and failure reasons are unchanged.
40+
"""
41+
42+
if header.payload_len < 0:
43+
return "collector_frame_length_invalid"
44+
if header.payload_len > MAX_EYBOND_PAYLOAD_SIZE:
45+
return "collector_frame_payload_too_large"
46+
if header.fcode not in RUNTIME_EYBOND_FCODES:
47+
return "collector_frame_function_invalid"
48+
return ""
49+
50+
51+
class BinaryGrammar(Enum):
52+
"""A session's allowed binary grammars, NOT a request-waiter preference."""
53+
54+
EYBOND = "eybond"
55+
AABB = "aabb"
56+
MIXED = "mixed"
57+
58+
59+
class BinaryFramingError(ValueError):
60+
"""An invalid, incomplete or ambiguous boundary requires session recovery."""
61+
62+
63+
@dataclass(frozen=True, slots=True)
64+
class BinaryFrame:
65+
"""One structural frame; interpreting payload values is a separate concern."""
66+
67+
grammar: BinaryGrammar
68+
wire: bytes
69+
header: EybondHeader | None = None
70+
71+
72+
def validate_aabb_frame(wire: bytes) -> None:
73+
"""Validate one exact 21-byte MPPT reply, including its additive checksum.
74+
75+
The checksum covers bytes2..19; the final byte is the sum modulo256.
76+
Both observed reply subtypes share framing, not necessarily field semantics.
77+
"""
78+
79+
if len(wire) != AABB_FRAME_SIZE:
80+
raise BinaryFramingError("aabb_length_invalid")
81+
if not wire.startswith(AABB_MAGIC):
82+
raise BinaryFramingError("aabb_magic_invalid")
83+
if wire[2:4] not in AABB_SUBTYPES:
84+
raise BinaryFramingError("aabb_subtype_unsupported")
85+
if sum(wire[2:-1]) & 0xFF != wire[-1]:
86+
raise BinaryFramingError("aabb_checksum_invalid")
87+
88+
89+
class BinaryFrameDecoder:
90+
"""Incrementally decode ONE frame with a fixed deadline and bounded buffer.
91+
92+
``feed`` returns bytes consumed from this chunk. Any suffix belongs to the
93+
caller and must be processed independently (possibly as an AT line).
94+
The buffer, grammar and deadline never reset on fragmentation or retry.
95+
An error is terminal: the caller must not guess a boundary and continue.
96+
97+
MIXED deliberately refuses overlaps, even with a valid checksum. Selecting
98+
AABB instead requires an independently established wire contract; merely
99+
waiting for an AABB response is NOT such evidence. The codec cannot invent
100+
this contract or distinguish two identical, valid representations.
101+
"""
102+
103+
def __init__(self, grammar: BinaryGrammar, *, started_at: float, timeout: float) -> None:
104+
if not isinstance(grammar, BinaryGrammar):
105+
raise ValueError("binary_grammar_invalid")
106+
if not math.isfinite(started_at) or not math.isfinite(timeout) or timeout <= 0:
107+
raise ValueError("binary_deadline_invalid")
108+
self._grammar = grammar
109+
self._last_now = started_at
110+
self._deadline = started_at + timeout
111+
if not math.isfinite(self._deadline):
112+
raise ValueError("binary_deadline_invalid")
113+
self._buffer = bytearray()
114+
self._header: EybondHeader | None = None
115+
self._size = 0
116+
self._kind: BinaryGrammar | None = None
117+
self._error = ""
118+
self._result: BinaryFrame | None = None
119+
120+
@property
121+
def buffered_size(self) -> int:
122+
return len(self._buffer)
123+
124+
@property
125+
def frame(self) -> BinaryFrame | None:
126+
return self._result
127+
128+
@property
129+
def deadline(self) -> float:
130+
return self._deadline
131+
132+
@property
133+
def bytes_needed(self) -> int:
134+
"""Exact next read size: never speculate beyond this frame boundary."""
135+
136+
if self._error or self._result is not None:
137+
return 0
138+
return (self._size or HEADER_SIZE) - len(self._buffer)
139+
140+
def _fail(self, reason: str) -> None:
141+
self._error = reason
142+
raise BinaryFramingError(reason)
143+
144+
def _check(self, now: float) -> None:
145+
if self._error:
146+
raise BinaryFramingError(self._error)
147+
if not math.isfinite(now):
148+
self._fail("binary_clock_invalid")
149+
if now < self._last_now:
150+
self._fail("binary_clock_regressed")
151+
self._last_now = now
152+
if self._result is None and now >= self._deadline:
153+
self._fail("binary_frame_timeout")
154+
155+
def _select_boundary(self) -> None:
156+
wire = bytes(self._buffer)
157+
header = decode_header(wire)
158+
header_error = runtime_eybond_header_error(header)
159+
auxiliary_prefix = wire[:4] in AABB_PREFIXES
160+
161+
if self._grammar is BinaryGrammar.EYBOND:
162+
if header_error:
163+
self._fail(header_error)
164+
self._kind = BinaryGrammar.EYBOND
165+
self._header = header
166+
self._size = header.total_len
167+
elif self._grammar is BinaryGrammar.AABB:
168+
# Integrity is checked once the entire bounded21-byte reply arrives.
169+
self._kind = BinaryGrammar.AABB
170+
self._size = AABB_FRAME_SIZE
171+
elif auxiliary_prefix and not header_error:
172+
# No checksum or future state can prove which of these two binary
173+
# grammars the peer intended. Don't consume a speculative tail.
174+
self._fail("binary_frame_ambiguous")
175+
elif not header_error:
176+
self._kind = BinaryGrammar.EYBOND
177+
self._header = header
178+
self._size = header.total_len
179+
elif wire.startswith(AABB_MAGIC):
180+
self._kind = BinaryGrammar.AABB
181+
self._size = AABB_FRAME_SIZE
182+
else:
183+
self._fail(header_error)
184+
185+
def feed(self, data: bytes, *, now: float) -> int:
186+
"""Consume only this frame's bytes, or fail without publishing a frame."""
187+
188+
self._check(now)
189+
if self._result is not None:
190+
return 0
191+
consumed = 0
192+
while consumed < len(data):
193+
target = self._size or HEADER_SIZE
194+
take = min(target - len(self._buffer), len(data) - consumed)
195+
self._buffer.extend(data[consumed:consumed + take])
196+
consumed += take
197+
if not self._size and len(self._buffer) == HEADER_SIZE:
198+
self._select_boundary()
199+
if self._size and len(self._buffer) == self._size:
200+
wire = bytes(self._buffer)
201+
if self._kind is BinaryGrammar.AABB:
202+
try:
203+
validate_aabb_frame(wire)
204+
except BinaryFramingError as exc:
205+
self._fail(str(exc))
206+
assert self._kind is not None
207+
self._result = BinaryFrame(self._kind, wire, self._header)
208+
break
209+
return consumed
210+
211+
def finish(self, *, now: float) -> BinaryFrame:
212+
"""Finish at EOF; partial data never becomes a shorter successful frame."""
213+
214+
self._check(now)
215+
if self._result is None:
216+
self._fail("binary_frame_truncated")
217+
assert self._result is not None
218+
return self._result
219+
220+
221+
class BinaryFrameReader(Protocol):
222+
"""The socket owner remains the only reader; no background tasks are added."""
223+
224+
async def readexactly(self, size: int) -> bytes:
225+
...
226+
227+
228+
async def async_read_binary_frame(
229+
reader: BinaryFrameReader,
230+
*,
231+
prefix: bytes,
232+
grammar: BinaryGrammar,
233+
started_at: float,
234+
timeout: float,
235+
) -> BinaryFrame:
236+
"""Read the rest of a frame under its ORIGINAL first-byte deadline.
237+
238+
Supply at most the fixed8-byte header as prefix. Cancellation is propagated
239+
to the owner: this function never retries or reuses a partly consumed stream.
240+
It must not be called by competing readers or after a guessed grammar switch.
241+
"""
242+
243+
if not 0 < len(prefix) <= HEADER_SIZE:
244+
raise ValueError("binary_prefix_length_invalid")
245+
loop = asyncio.get_running_loop()
246+
decoder = BinaryFrameDecoder(grammar, started_at=started_at, timeout=timeout)
247+
decoder.feed(prefix, now=loop.time())
248+
while decoder.frame is None:
249+
now = loop.time()
250+
decoder.feed(b"", now=now)
251+
try:
252+
tail = await asyncio.wait_for(
253+
reader.readexactly(decoder.bytes_needed),
254+
timeout=decoder.deadline - now,
255+
)
256+
except asyncio.TimeoutError as exc:
257+
raise BinaryFramingError("binary_frame_timeout") from exc
258+
except asyncio.IncompleteReadError as exc:
259+
decoder.feed(exc.partial, now=loop.time())
260+
return decoder.finish(now=loop.time())
261+
decoder.feed(tail, now=loop.time())
262+
return decoder.finish(now=loop.time())

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

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
decode_header,
4040
parse_heartbeat_pn,
4141
)
42+
from .binary_framing import (
43+
MAX_EYBOND_PAYLOAD_SIZE,
44+
RUNTIME_EYBOND_FCODES,
45+
runtime_eybond_header_error,
46+
)
4247

4348
logger = logging.getLogger(__name__)
4449

@@ -216,16 +221,8 @@ def _short_ascii(value: bytes, *, limit: int = 160) -> str:
216221

217222
_AT_TEXT_MIXED_FRAME_READ_TIMEOUT = 0.05
218223
_AT_TEXT_RESPONSE_IDLE_TIMEOUT = 0.2
219-
_AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN = 4096
220-
_AT_TEXT_MIXED_FRAME_FCODES = {
221-
FC_HEARTBEAT,
222-
FC_QUERY_COLLECTOR,
223-
FC_SET_COLLECTOR,
224-
FC_FORWARD_TO_DEVICE,
225-
FC_TRIGGER_QUERY_REAL_TIME,
226-
FC_SET_DEVICE_REG,
227-
FC_TRIGGER_QUERY_HISTORY,
228-
}
224+
_AT_TEXT_MAX_MIXED_FRAME_PAYLOAD_LEN = MAX_EYBOND_PAYLOAD_SIZE
225+
_AT_TEXT_MIXED_FRAME_FCODES = RUNTIME_EYBOND_FCODES
229226

230227

231228
def _runtime_eybond_header_error(header: EybondHeader) -> str:
@@ -239,13 +236,7 @@ def _runtime_eybond_header_error(header: EybondHeader) -> str:
239236
pseudo-frame and consume every valid frame that follows it.
240237
"""
241238

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 ""
239+
return runtime_eybond_header_error(header)
249240

250241

251242
def _mask_identity_token(value: str) -> str:

0 commit comments

Comments
 (0)