Add REG3 frame parser and fixtures

This commit is contained in:
2026-07-18 16:52:18 +02:00
parent ef7a480180
commit ce4bba673c
9 changed files with 531 additions and 0 deletions
+1
View File
@@ -1,3 +1,4 @@
# Artefacts Python générés localement # Artefacts Python générés localement
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/
+1
View File
@@ -0,0 +1 @@
"""Paquet des composants personnalisés Arkteos."""
+1
View File
@@ -0,0 +1 @@
"""Paquet de l'intégration Arkteos."""
+353
View File
@@ -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)
+12
View File
@@ -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.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+163
View File
@@ -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 == {}