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