|
| 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()) |
0 commit comments