Add incremental REG3 frame extractor
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Extracteur incrémental des trames REG3 observées sur le flux TCP.
|
||||
|
||||
Ce module ne décode aucune valeur métier et ne dépend ni du réseau ni de Home
|
||||
Assistant. Les signatures, tailles et cohérences d'en-tête sont limitées à ce
|
||||
qui est observé dans ``docs/frame_protocol_analysis.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
FRAME_SIGNATURE: Final = b"\x55\x00"
|
||||
HEADER_MIN_SIZE: Final = 12
|
||||
HEADER_LENGTH_OFFSET: Final = 10
|
||||
HEADER_SIZE_ADJUSTMENT: Final = 15
|
||||
FRAME_TYPE_OFFSET: Final = 8
|
||||
|
||||
FRAME_SIZES_BY_TYPE: Final[dict[int, int]] = {
|
||||
0x0C: 95,
|
||||
0x0A: 163,
|
||||
0x0B: 227,
|
||||
}
|
||||
KNOWN_FRAME_SIZES: Final[frozenset[int]] = frozenset(FRAME_SIZES_BY_TYPE.values())
|
||||
|
||||
|
||||
class FrameExtractor:
|
||||
"""Assemble des trames complètes depuis des fragments TCP arbitraires."""
|
||||
|
||||
def __init__(self, *, max_buffer_size: int = 4096) -> None:
|
||||
if max_buffer_size < HEADER_MIN_SIZE:
|
||||
raise ValueError(
|
||||
f"max_buffer_size doit être supérieur ou égal à {HEADER_MIN_SIZE}"
|
||||
)
|
||||
self._max_buffer_size = max_buffer_size
|
||||
self._buffer = bytearray()
|
||||
|
||||
@property
|
||||
def buffered_bytes(self) -> bytes:
|
||||
"""Retourne une copie immuable des octets en attente."""
|
||||
|
||||
return bytes(self._buffer)
|
||||
|
||||
@property
|
||||
def buffered_size(self) -> int:
|
||||
"""Retourne la taille des octets en attente."""
|
||||
|
||||
return len(self._buffer)
|
||||
|
||||
@property
|
||||
def max_buffer_size(self) -> int:
|
||||
"""Retourne la limite configurable du tampon persistant."""
|
||||
|
||||
return self._max_buffer_size
|
||||
|
||||
def feed(self, data: bytes) -> list[bytes]:
|
||||
"""Ajoute un fragment TCP et retourne les trames complètes extraites.
|
||||
|
||||
Les données entrantes sont ajoutées par blocs bornés afin que le tampon
|
||||
persistant ne dépasse jamais ``max_buffer_size``.
|
||||
"""
|
||||
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError("data doit être de type bytes")
|
||||
|
||||
frames = self._extract_available()
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
available = self._max_buffer_size - len(self._buffer)
|
||||
if available == 0:
|
||||
before = len(self._buffer)
|
||||
frames.extend(self._extract_available())
|
||||
if len(self._buffer) == before:
|
||||
# Cette branche ne doit être atteinte qu'avec un en-tête
|
||||
# incomplet ou invalide ; avancer garantit la progression.
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
end = min(offset + available, len(data))
|
||||
self._buffer.extend(data[offset:end])
|
||||
offset = end
|
||||
frames.extend(self._extract_available())
|
||||
|
||||
return frames
|
||||
|
||||
def _extract_available(self) -> list[bytes]:
|
||||
frames: list[bytes] = []
|
||||
|
||||
while True:
|
||||
if len(self._buffer) < len(FRAME_SIGNATURE):
|
||||
return frames
|
||||
|
||||
signature_offset = self._buffer.find(FRAME_SIGNATURE)
|
||||
if signature_offset < 0:
|
||||
self._preserve_possible_split_signature()
|
||||
return frames
|
||||
|
||||
if signature_offset > 0:
|
||||
del self._buffer[:signature_offset]
|
||||
|
||||
if len(self._buffer) < HEADER_MIN_SIZE:
|
||||
return frames
|
||||
|
||||
frame_size = (
|
||||
int.from_bytes(
|
||||
self._buffer[
|
||||
HEADER_LENGTH_OFFSET : HEADER_LENGTH_OFFSET + 2
|
||||
],
|
||||
"little",
|
||||
)
|
||||
+ HEADER_SIZE_ADJUSTMENT
|
||||
)
|
||||
frame_type = self._buffer[FRAME_TYPE_OFFSET]
|
||||
expected_size = FRAME_SIZES_BY_TYPE.get(frame_type)
|
||||
|
||||
if (
|
||||
frame_size not in KNOWN_FRAME_SIZES
|
||||
or frame_size > self._max_buffer_size
|
||||
or expected_size != frame_size
|
||||
):
|
||||
# Ne supprimer qu'un octet : une nouvelle signature peut
|
||||
# commencer à l'octet suivant du flux reçu.
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
if len(self._buffer) < frame_size:
|
||||
return frames
|
||||
|
||||
frames.append(bytes(self._buffer[:frame_size]))
|
||||
del self._buffer[:frame_size]
|
||||
|
||||
def _preserve_possible_split_signature(self) -> None:
|
||||
"""Conserve seulement un dernier 0x55 en l'absence de signature."""
|
||||
|
||||
if self._buffer[-1] == FRAME_SIGNATURE[0]:
|
||||
self._buffer[:] = FRAME_SIGNATURE[:1]
|
||||
else:
|
||||
self._buffer.clear()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests de l'extracteur incrémental REG3, indépendants de Home Assistant."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.arkteos.frame_extractor import FrameExtractor
|
||||
|
||||
|
||||
FIXTURES_PATH = Path(__file__).with_name("fixtures")
|
||||
|
||||
|
||||
def _load_frames() -> dict[str, bytes]:
|
||||
return {
|
||||
"metadata": (FIXTURES_PATH / "metadata_95.bin").read_bytes(),
|
||||
"frigo": (FIXTURES_PATH / "frigo_163.bin").read_bytes(),
|
||||
"regulation": (FIXTURES_PATH / "regulation_227.bin").read_bytes(),
|
||||
}
|
||||
|
||||
|
||||
def test_no_bytes() -> None:
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(b"") == []
|
||||
assert extractor.buffered_bytes == b""
|
||||
|
||||
|
||||
def test_complete_metadata_frame() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
assert FrameExtractor().feed(metadata) == [metadata]
|
||||
|
||||
|
||||
def test_complete_frigo_frame() -> None:
|
||||
frigo = _load_frames()["frigo"]
|
||||
assert FrameExtractor().feed(frigo) == [frigo]
|
||||
|
||||
|
||||
def test_complete_regulation_frame() -> None:
|
||||
regulation = _load_frames()["regulation"]
|
||||
assert FrameExtractor().feed(regulation) == [regulation]
|
||||
|
||||
|
||||
def test_frame_split_in_two_calls() -> None:
|
||||
frigo = _load_frames()["frigo"]
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(frigo[:80]) == []
|
||||
assert extractor.feed(frigo[80:]) == [frigo]
|
||||
|
||||
|
||||
def test_frame_provided_byte_by_byte() -> None:
|
||||
regulation = _load_frames()["regulation"]
|
||||
extractor = FrameExtractor()
|
||||
extracted = [frame for byte in regulation for frame in extractor.feed(bytes([byte]))]
|
||||
assert extracted == [regulation]
|
||||
|
||||
|
||||
def test_multiple_concatenated_frames() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["metadata"] + frames["frigo"] + frames["regulation"]
|
||||
assert FrameExtractor().feed(sequence) == [
|
||||
frames["metadata"],
|
||||
frames["frigo"],
|
||||
frames["regulation"],
|
||||
]
|
||||
|
||||
|
||||
def test_order_different_from_observed_cycle() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["frigo"] + frames["metadata"] + frames["regulation"]
|
||||
assert FrameExtractor().feed(sequence) == [
|
||||
frames["frigo"],
|
||||
frames["metadata"],
|
||||
frames["regulation"],
|
||||
]
|
||||
|
||||
|
||||
def test_noise_before_signature() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
assert FrameExtractor().feed(b"parasites" + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_signature_split_between_calls() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(b"parasites\x55") == []
|
||||
assert extractor.buffered_bytes == b"\x55"
|
||||
assert extractor.feed(metadata[1:]) == [metadata]
|
||||
|
||||
|
||||
def test_incoherent_type_header_resynchronizes() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
invalid = bytearray(metadata)
|
||||
invalid[8] = 0x0A
|
||||
assert FrameExtractor().feed(bytes(invalid) + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_unknown_length_header_resynchronizes() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
invalid = bytearray(metadata)
|
||||
invalid[10:12] = (81).to_bytes(2, "little")
|
||||
assert FrameExtractor().feed(bytes(invalid) + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_announced_size_above_limit_resynchronizes() -> None:
|
||||
frames = _load_frames()
|
||||
extractor = FrameExtractor(max_buffer_size=200)
|
||||
assert extractor.feed(frames["regulation"] + frames["metadata"]) == [
|
||||
frames["metadata"]
|
||||
]
|
||||
|
||||
|
||||
def test_false_signature_in_noise() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
false_header = b"\x55\x00" + b"\x00" * 10
|
||||
assert FrameExtractor().feed(b"\x10" + false_header + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_complete_frame_followed_by_incomplete_frame() -> None:
|
||||
frames = _load_frames()
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(frames["metadata"] + frames["frigo"][:20]) == [
|
||||
frames["metadata"]
|
||||
]
|
||||
assert extractor.buffered_bytes == frames["frigo"][:20]
|
||||
assert extractor.feed(frames["frigo"][20:]) == [frames["frigo"]]
|
||||
|
||||
|
||||
def test_variable_size_fragments() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["frigo"] + frames["metadata"] + frames["regulation"]
|
||||
extractor = FrameExtractor()
|
||||
extracted: list[bytes] = []
|
||||
fragment_sizes = (1, 7, 31, 2, 83, 19, 11, 131)
|
||||
offset = 0
|
||||
for size in fragment_sizes:
|
||||
extracted.extend(extractor.feed(sequence[offset : offset + size]))
|
||||
offset += size
|
||||
extracted.extend(extractor.feed(sequence[offset:]))
|
||||
assert extracted == [frames["frigo"], frames["metadata"], frames["regulation"]]
|
||||
|
||||
|
||||
def test_buffer_is_empty_after_complete_extraction() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
extractor = FrameExtractor()
|
||||
extractor.feed(metadata)
|
||||
assert extractor.buffered_size == 0
|
||||
|
||||
|
||||
def test_incomplete_buffer_is_preserved() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(metadata[:10]) == []
|
||||
assert extractor.buffered_bytes == metadata[:10]
|
||||
|
||||
|
||||
def test_repeated_invalid_data_terminates() -> None:
|
||||
invalid_header = b"\x55\x00" + b"\xff" * 10
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(invalid_header * 100) == []
|
||||
assert extractor.buffered_size == 0
|
||||
|
||||
|
||||
def test_sequence_with_all_real_fixtures() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["regulation"] + frames["frigo"] + frames["metadata"]
|
||||
assert FrameExtractor().feed(sequence) == [
|
||||
frames["regulation"],
|
||||
frames["frigo"],
|
||||
frames["metadata"],
|
||||
]
|
||||
Reference in New Issue
Block a user