From ce4bba673c9b1a6c5edfc92c691dd3d421a29529 Mon Sep 17 00:00:00 2001 From: raph666 Date: Sat, 18 Jul 2026 16:52:18 +0200 Subject: [PATCH] Add REG3 frame parser and fixtures --- .gitignore | 1 + custom_components/__init__.py | 1 + custom_components/arkteos/__init__.py | 1 + custom_components/arkteos/parser.py | 353 ++++++++++++++++++++++++++ tests/fixtures/README.md | 12 + tests/fixtures/frigo_163.bin | Bin 0 -> 163 bytes tests/fixtures/metadata_95.bin | Bin 0 -> 95 bytes tests/fixtures/regulation_227.bin | Bin 0 -> 227 bytes tests/test_parser.py | 163 ++++++++++++ 9 files changed, 531 insertions(+) create mode 100644 custom_components/__init__.py create mode 100644 custom_components/arkteos/__init__.py create mode 100644 custom_components/arkteos/parser.py create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/frigo_163.bin create mode 100644 tests/fixtures/metadata_95.bin create mode 100644 tests/fixtures/regulation_227.bin create mode 100644 tests/test_parser.py diff --git a/.gitignore b/.gitignore index 3fd041f..7962149 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # Artefacts Python générés localement __pycache__/ *.py[cod] +.venv/ \ No newline at end of file diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..d7a3fa3 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Paquet des composants personnalisés Arkteos.""" diff --git a/custom_components/arkteos/__init__.py b/custom_components/arkteos/__init__.py new file mode 100644 index 0000000..a29e053 --- /dev/null +++ b/custom_components/arkteos/__init__.py @@ -0,0 +1 @@ +"""Paquet de l'intégration Arkteos.""" diff --git a/custom_components/arkteos/parser.py b/custom_components/arkteos/parser.py new file mode 100644 index 0000000..a07c118 --- /dev/null +++ b/custom_components/arkteos/parser.py @@ -0,0 +1,353 @@ +"""Parseur isolé des trames Arkteos REG3 observées. + +Les offsets, conversions, tables et plages proviennent exclusivement des nœuds +Node-RED « Parse frame » et « Filtre valeurs aberrantes » de référence. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Final, Literal, TypeAlias + + +METADATA_FRAME_SIZE: Final = 95 +FRIGO_FRAME_SIZE: Final = 163 +REGULATION_FRAME_SIZE: Final = 227 +FRAME_SIGNATURE: Final = b"\x55\x00" +HEADER_SIZE: Final = 15 + +FRAME_TYPES_BY_SIZE: Final[dict[int, int]] = { + METADATA_FRAME_SIZE: 0x0C, + FRIGO_FRAME_SIZE: 0x0A, + REGULATION_FRAME_SIZE: 0x0B, +} + +STATUTS_PAC: Final[dict[int, str]] = { + 0: "Arret", + 1: "Attente", + 2: "Chaud", + 3: "Froid", + 4: "Hors Gel", + 5: "Ext Chaud", + 6: "Ext Froid", + 7: "Chaud Froid", + 8: "ECS", + 9: "Piscine", +} + +STATUTS_FRIGO: Final[dict[int, str]] = { + 0: "Arret", + 1: "Refroidissement", + 2: "Chauffage", + 3: "Degivrage", +} + +MODELES_PAC: Final[dict[int, str]] = { + 0x10: "AJPAC_III", + 0x11: "BAGUIO_ZURAN_IV", + 0x12: "TIMAX_III", + 0x13: "GEOTWIN_IV", + 0x14: "CAIROX", + 0x15: "PHOENIX", + 0x16: "ARKTEA", + 0x17: "GEOINVERTER", + 0x18: "LAST_MODEL", +} + + +class FrameError(ValueError): + """Erreur contrôlée rencontrée lors de la lecture ou du parsing.""" + + +class BufferBoundsError(FrameError): + """Une lecture demandée dépasse le buffer fourni.""" + + +class FrameLengthError(FrameError): + """La longueur de la trame est inconnue ou incompatible.""" + + +class FrameHeaderError(FrameError): + """L'en-tête observé ne correspond pas à la trame attendue.""" + + +@dataclass(frozen=True, slots=True) +class MetadataFrame: + """Trame de 95 octets reconnue sans champs fonctionnels déduits.""" + + frame_type: Literal["metadata"] = field(default="metadata", init=False) + + +@dataclass(frozen=True, slots=True) +class FrigoFrame: + """Champs décodés à partir d'une trame frigo de 163 octets.""" + + exterieur_temp: float + nb_degivrages: int + temps_compresseur: float + nb_cycles_compresseur: int + freq_comp_actuelle: int + freq_comp_cible: int + fan_speed_evaporator_1: int + dc_voltage: int + statut_frigo: int + statut_frigo_s: str + active_error_fri: int + frame_type: Literal["frigo"] = field(default="frigo", init=False) + + +@dataclass(frozen=True, slots=True) +class RegulationFrame: + """Champs décodés à partir d'une trame régulation de 227 octets.""" + + puissance_inst_produite: float + puissance_inst_consommee: float + temps_mise_sous_tension: int + modele_pac: int + modele_pac_s: str + primaire_temp_eau_aller_consigne: float + primaire_temp_eau_aller: float + primaire_temp_eau_retour: float + primaire_debit_eau: float + primaire_pression: float + primaire_circulateur_consigne: int + zone1_temp_interieur: float + zone1_consigne: float + ecs_temp_eau_milieu: float + ecs_temp_eau_bas: float + ecs_consigne: float + nb_cycles_compresseur_reg: int + statut_pac: int + statut_pac_s: str + active_error_reg: int + signal_rf_sonde_1: int + frame_type: Literal["regulation"] = field(default="regulation", init=False) + + +ParsedFrame: TypeAlias = MetadataFrame | FrigoFrame | RegulationFrame +Scalar: TypeAlias = int | float | str + + +@dataclass(frozen=True, slots=True) +class FilterResult: + """Valeurs acceptées et rejetées sans modification silencieuse.""" + + accepted: dict[str, Scalar] + rejected: dict[str, int | float] + + +# Plages reprises à l'identique du nœud « Filtre valeurs aberrantes ». +RANGES: Final[dict[str, tuple[int | float, int | float]]] = { + "exterieur_temp": (-50, 150), + "nb_degivrages": (0, 99999), + "temps_compresseur": (0, 999999), + "nb_cycles_compresseur": (0, 9999999), + "freq_comp_actuelle": (0, 200), + "freq_comp_cible": (0, 200), + "fan_speed_evaporator_1": (0, 3000), + "dc_voltage": (0, 1000), + "statut_frigo": (0, 3), + "active_error_fri": (0, 65535), + "puissance_inst_produite": (0, 50000), + "puissance_inst_consommee": (0, 50000), + "temps_mise_sous_tension": (0, 999999), + "primaire_temp_eau_aller_consigne": (-10, 90), + "primaire_temp_eau_aller": (-10, 90), + "primaire_temp_eau_retour": (-10, 90), + "primaire_debit_eau": (0, 10000), + "primaire_pression": (0, 10), + "primaire_circulateur_consigne": (0, 100), + "zone1_temp_interieur": (-10, 50), + "zone1_consigne": (5, 35), + "ecs_temp_eau_milieu": (0, 95), + "ecs_temp_eau_bas": (0, 95), + "ecs_consigne": (0, 80), + "nb_cycles_compresseur_reg": (0, 999999), + "statut_pac": (0, 9), + "active_error_reg": (0, 65535), + "signal_rf_sonde_1": (-128, 127), +} + + +def _require_bytes(data: bytes, offset: int, size: int) -> None: + if offset < 0 or size < 0 or offset + size > len(data): + raise BufferBoundsError( + f"lecture hors buffer : offset={offset}, taille={size}, " + f"buffer={len(data)}" + ) + + +def read_u8(data: bytes, offset: int) -> int: + """Lit un entier non signé de 8 bits sans dépasser le buffer.""" + + _require_bytes(data, offset, 1) + return data[offset] + + +def read_i8(data: bytes, offset: int) -> int: + """Lit un entier signé de 8 bits, comme ``signExtend8`` du flow.""" + + value = read_u8(data, offset) + return (value ^ 0x80) - 0x80 + + +def read_u16_le(data: bytes, offset: int) -> int: + """Lit un entier non signé de 16 bits little-endian.""" + + _require_bytes(data, offset, 2) + return data[offset] + data[offset + 1] * 256 + + +def read_i16_le(data: bytes, offset: int) -> int: + """Lit un entier signé de 16 bits little-endian.""" + + value = read_u16_le(data, offset) + return value - 65536 if value >= 32768 else value + + +def _require_frame_size(data: bytes, expected_size: int) -> None: + if len(data) != expected_size: + raise FrameLengthError( + f"trame de longueur {len(data)} octets, {expected_size} attendus" + ) + + +def validate_frame_header(data: bytes, expected_size: int) -> None: + """Valide les signatures d'en-tête observées dans la capture de référence.""" + + _require_frame_size(data, expected_size) + if data[0:2] != FRAME_SIGNATURE: + raise FrameHeaderError( + f"signature invalide : {data[0:2].hex(' ')}, " + f"{FRAME_SIGNATURE.hex(' ')} attendue" + ) + + expected_type = FRAME_TYPES_BY_SIZE[expected_size] + observed_type = read_u8(data, 8) + if observed_type != expected_type: + raise FrameHeaderError( + f"type d'en-tête invalide : 0x{observed_type:02X}, " + f"0x{expected_type:02X} attendu pour {expected_size} octets" + ) + + observed_size = read_u16_le(data, 10) + HEADER_SIZE + if observed_size != expected_size: + raise FrameHeaderError( + f"longueur d'en-tête incohérente : {observed_size}, " + f"{expected_size} attendus" + ) + + +def parse_metadata_frame(data: bytes, *, validate_header: bool = True) -> MetadataFrame: + """Reconnaît une trame metadata de 95 octets sans inventer de champs.""" + + _require_frame_size(data, METADATA_FRAME_SIZE) + if validate_header: + validate_frame_header(data, METADATA_FRAME_SIZE) + return MetadataFrame() + + +def parse_frigo_frame(data: bytes, *, validate_header: bool = True) -> FrigoFrame: + """Parse une trame frigo de 163 octets avec les offsets du flow.""" + + _require_frame_size(data, FRIGO_FRAME_SIZE) + if validate_header: + validate_frame_header(data, FRIGO_FRAME_SIZE) + + statut_frigo_raw = read_u8(data, 36) >> 4 + active_error_fri = read_u8(data, 12) + (read_u8(data, 13) & 0x0F) * 256 + return FrigoFrame( + exterieur_temp=read_i16_le(data, 24) / 10, + nb_degivrages=read_u16_le(data, 26), + temps_compresseur=read_u16_le(data, 40) / 10, + nb_cycles_compresseur=read_u16_le(data, 42) * 100, + freq_comp_actuelle=read_u16_le(data, 52), + freq_comp_cible=read_u16_le(data, 54), + fan_speed_evaporator_1=read_u16_le(data, 56), + dc_voltage=read_u16_le(data, 62), + statut_frigo=statut_frigo_raw, + statut_frigo_s=STATUTS_FRIGO.get(statut_frigo_raw, "Inconnu"), + active_error_fri=active_error_fri, + ) + + +def parse_regulation_frame( + data: bytes, *, validate_header: bool = True +) -> RegulationFrame: + """Parse une trame régulation de 227 octets avec les offsets du flow.""" + + _require_frame_size(data, REGULATION_FRAME_SIZE) + if validate_header: + validate_frame_header(data, REGULATION_FRAME_SIZE) + + statut_pac_raw = read_u8(data, 12) & 0b11111 + modele_raw = read_u8(data, 46) + active_error_reg = read_u8(data, 30) + (read_u8(data, 31) & 0x0F) * 256 + signal_rf = read_i8(data, 193) + return RegulationFrame( + puissance_inst_produite=read_u16_le(data, 16) / 0.1, + puissance_inst_consommee=read_u16_le(data, 18) / 0.1, + temps_mise_sous_tension=read_u16_le(data, 20), + modele_pac=modele_raw, + modele_pac_s=MODELES_PAC.get(modele_raw, "Inconnu"), + primaire_temp_eau_aller_consigne=read_i16_le(data, 52) / 10, + primaire_temp_eau_aller=read_i16_le(data, 54) / 10, + primaire_temp_eau_retour=read_i16_le(data, 56) / 10, + primaire_debit_eau=read_u16_le(data, 60) / 10, + primaire_pression=read_u8(data, 62) / 10, + primaire_circulateur_consigne=read_u8(data, 64), + zone1_temp_interieur=read_i16_le(data, 68) / 10, + zone1_consigne=read_i16_le(data, 70) / 10, + ecs_temp_eau_milieu=read_i16_le(data, 108) / 10, + ecs_temp_eau_bas=read_i16_le(data, 110) / 10, + ecs_consigne=read_i16_le(data, 122) / 10, + nb_cycles_compresseur_reg=read_u16_le(data, 148), + statut_pac=statut_pac_raw, + statut_pac_s=STATUTS_PAC.get(statut_pac_raw, "Inconnu"), + active_error_reg=active_error_reg, + signal_rf_sonde_1=signal_rf, + ) + + +def parse_frame(data: bytes, *, validate_header: bool = True) -> ParsedFrame: + """Parse une trame reconnue par sa taille, sinon lève une erreur explicite.""" + + parsers = { + METADATA_FRAME_SIZE: parse_metadata_frame, + FRIGO_FRAME_SIZE: parse_frigo_frame, + REGULATION_FRAME_SIZE: parse_regulation_frame, + } + parser = parsers.get(len(data)) + if parser is None: + raise FrameLengthError( + f"longueur de trame inconnue : {len(data)} octets " + f"(attendus : {METADATA_FRAME_SIZE}, {FRIGO_FRAME_SIZE}, " + f"{REGULATION_FRAME_SIZE})" + ) + return parser(data, validate_header=validate_header) + + +def filter_values(frame: ParsedFrame) -> FilterResult: + """Applique à une trame les plages exactes du filtre Node-RED.""" + + values = asdict(frame) + accepted: dict[str, Scalar] = {"frame_type": values.pop("frame_type")} + rejected: dict[str, int | float] = {} + + for key, value in values.items(): + if isinstance(value, str): + accepted[key] = value + continue + + limits = RANGES.get(key) + if limits is None: + accepted[key] = value + continue + + minimum, maximum = limits + if minimum <= value <= maximum: + accepted[key] = value + else: + rejected[key] = value + + return FilterResult(accepted=accepted, rejected=rejected) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..a791d5b --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,12 @@ +# Fixtures REG3 + +Les fichiers binaires de ce dossier sont chacun un exemplaire réel extrait de +`captures/stream_normal.bin` : + +- `metadata_95.bin` : 95 octets ; +- `frigo_163.bin` : 163 octets ; +- `regulation_227.bin` : 227 octets. + +Ils permettent d'exécuter les tests du parseur sans versionner ni charger la +capture complète. Les limites de lectures TCP ne sont pas représentées par ces +fixtures. diff --git a/tests/fixtures/frigo_163.bin b/tests/fixtures/frigo_163.bin new file mode 100644 index 0000000000000000000000000000000000000000..9df5399fcb39bba8db892b775fb710506661cb96 GIT binary patch literal 163 zcmWG#nDal`fr*8SVG08>a9~8{F)%z}2xib?P-4(zK$dZ56Brm`nVgwG>VRS?2&D*lrri}_&SW`=*usc~EHGIqb|C>KN!A7js78=U!~l7x FRR9jT6f*z- literal 0 HcmV?d00001 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..8cd6846 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,163 @@ +"""Tests du parseur REG3 sans hypothèse sur les lectures TCP.""" + +from dataclasses import asdict, replace +from pathlib import Path + +import pytest + +from custom_components.arkteos.parser import ( + BufferBoundsError, + FrameHeaderError, + FrameLengthError, + filter_values, + parse_frame, + parse_frigo_frame, + parse_metadata_frame, + parse_regulation_frame, + read_i8, + read_i16_le, + read_u8, + read_u16_le, +) + + +FIXTURES_PATH = Path(__file__).with_name("fixtures") +FIXTURE_FILES = { + 95: "metadata_95.bin", + 163: "frigo_163.bin", + 227: "regulation_227.bin", +} + + +@pytest.fixture(scope="module") +def frames() -> dict[int, bytes]: + """Charge les exemplaires réels versionnés, indépendamment du flux complet.""" + + loaded_frames = { + size: (FIXTURES_PATH / filename).read_bytes() + for size, filename in FIXTURE_FILES.items() + } + for expected_size, frame in loaded_frames.items(): + assert len(frame) == expected_size + return loaded_frames + + +def test_read_u8() -> None: + assert read_u8(b"\x12", 0) == 18 + + +def test_read_i8_positive() -> None: + assert read_i8(b"\x7f", 0) == 127 + + +def test_read_i8_negative() -> None: + assert read_i8(b"\x80", 0) == -128 + + +def test_read_u16_little_endian() -> None: + assert read_u16_le(b"\x34\x12", 0) == 0x1234 + + +def test_read_i16_little_endian_positive() -> None: + assert read_i16_le(b"\xff\x7f", 0) == 32767 + + +def test_read_i16_little_endian_negative() -> None: + assert read_i16_le(b"\x00\x80", 0) == -32768 + + +def test_buffer_overrun_and_short_frame_are_controlled() -> None: + with pytest.raises(BufferBoundsError): + read_u16_le(b"\x00", 0) + with pytest.raises(FrameLengthError): + parse_frigo_frame(b"\x55\x00") + + +def test_bad_signature(frames: dict[int, bytes]) -> None: + frame = bytearray(frames[163]) + frame[0] = 0 + with pytest.raises(FrameHeaderError, match="signature"): + parse_frame(bytes(frame)) + + +def test_type_and_length_inconsistency(frames: dict[int, bytes]) -> None: + frame = bytearray(frames[163]) + frame[8] = 0x0B + with pytest.raises(FrameHeaderError, match="type"): + parse_frame(bytes(frame)) + + +def test_header_length_inconsistency(frames: dict[int, bytes]) -> None: + frame = bytearray(frames[227]) + frame[10:12] = (80).to_bytes(2, "little") + with pytest.raises(FrameHeaderError, match="longueur"): + parse_frame(bytes(frame)) + + +def test_unknown_frame_length() -> None: + with pytest.raises(FrameLengthError, match="inconnue"): + parse_frame(b"\x00" * 96) + + +def test_parse_real_metadata_frame(frames: dict[int, bytes]) -> None: + frame = parse_metadata_frame(frames[95]) + assert frame.frame_type == "metadata" + + +def test_parse_real_frigo_frame(frames: dict[int, bytes]) -> None: + frame = parse_frigo_frame(frames[163]) + assert frame.frame_type == "frigo" + assert frame.exterieur_temp == 32.0 + + +def test_parse_real_regulation_frame(frames: dict[int, bytes]) -> None: + frame = parse_regulation_frame(frames[227]) + assert frame.frame_type == "regulation" + assert frame.modele_pac_s == "BAGUIO_ZURAN_IV" + + +def test_values_match_node_red_calculations(frames: dict[int, bytes]) -> None: + frigo = parse_frigo_frame(frames[163]) + regulation = parse_regulation_frame(frames[227]) + assert frigo.temps_compresseur == 22.4 + assert frigo.nb_cycles_compresseur == 8300 + assert frigo.dc_voltage == 327 + assert regulation.temps_mise_sous_tension == 43 + assert regulation.primaire_temp_eau_aller == 54.9 + assert regulation.ecs_temp_eau_milieu == 60.5 + assert regulation.nb_cycles_compresseur_reg == 7827 + + +def test_filter_keeps_valid_value(frames: dict[int, bytes]) -> None: + result = filter_values(parse_frigo_frame(frames[163])) + assert result.accepted["exterieur_temp"] == 32.0 + assert result.rejected == {} + + +def test_filter_rejects_aberrant_temperature(frames: dict[int, bytes]) -> None: + frame = replace(parse_frigo_frame(frames[163]), exterieur_temp=150.1) + result = filter_values(frame) + assert "exterieur_temp" not in result.accepted + assert result.rejected["exterieur_temp"] == 150.1 + + +def test_filter_rejects_aberrant_pressure(frames: dict[int, bytes]) -> None: + frame = replace(parse_regulation_frame(frames[227]), primaire_pression=10.1) + result = filter_values(frame) + assert "primaire_pression" not in result.accepted + assert result.rejected["primaire_pression"] == 10.1 + + +def test_filter_keeps_text_statuses(frames: dict[int, bytes]) -> None: + frigo_result = filter_values(parse_frigo_frame(frames[163])) + regulation_result = filter_values(parse_regulation_frame(frames[227])) + assert frigo_result.accepted["statut_frigo_s"] == "Arret" + assert regulation_result.accepted["statut_pac_s"] == "Attente" + + +def test_metadata_has_no_invented_fields(frames: dict[int, bytes]) -> None: + frame = parse_metadata_frame(frames[95]) + result = filter_values(frame) + assert asdict(frame) == {"frame_type": "metadata"} + assert result.accepted == {"frame_type": "metadata"} + assert result.rejected == {}