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
+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)