Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4bba673c | ||
|
|
ef7a480180 |
@@ -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/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Paquet des composants personnalisés Arkteos."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Paquet de l'intégration Arkteos."""
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Analyse observable du flux Arkteos REG3
|
||||||
|
|
||||||
|
## Périmètre et méthode
|
||||||
|
|
||||||
|
Cette analyse porte exclusivement sur :
|
||||||
|
|
||||||
|
- `captures/stream_normal.bin` ;
|
||||||
|
- `references/arkteos_nodered_flow.json` ;
|
||||||
|
- `AGENTS.md`.
|
||||||
|
|
||||||
|
Les offsets sont relatifs au début d'une trame présumée, indexés à zéro, et
|
||||||
|
exprimés en décimal puis en hexadécimal. Les conclusions portant sur le
|
||||||
|
découpage sont établies à partir des octets de la capture, pas à partir de
|
||||||
|
limites de lectures TCP.
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
| Élément | Observation |
|
||||||
|
|---|---:|
|
||||||
|
| Taille totale de la capture | 43 165 octets (`0xA89D`) |
|
||||||
|
| Occurrences de `55 00` | 267 |
|
||||||
|
| Occurrences de chaque taille | 95 : 89 ; 163 : 89 ; 227 : 89 |
|
||||||
|
| Cycles complets observés | 89 |
|
||||||
|
| Ordre | `227 → 163 → 95`, sans exception dans cette capture |
|
||||||
|
|
||||||
|
La capture commence par `55 00` à l'offset global 0 (`0x0000`). Toutes les
|
||||||
|
occurrences suivantes sont séparées de la précédente par 227, 163 ou 95
|
||||||
|
octets. La dernière occurrence commence à l'offset global 43 070 (`0xA83E`) ;
|
||||||
|
les 95 octets restants terminent exactement la capture. Ces positions
|
||||||
|
partitionnent donc entièrement cette capture en 267 segments.
|
||||||
|
|
||||||
|
## Types et tailles observés
|
||||||
|
|
||||||
|
| Taille totale | Taille hex. | Occurrences | Nom dans le flow | Valeur observée à 8 (`0x08`) |
|
||||||
|
|---:|---:|---:|---|---:|
|
||||||
|
| 95 | `0x005F` | 89 | aucune branche de parsing | `0x0C` |
|
||||||
|
| 163 | `0x00A3` | 89 | `frigo` | `0x0A` |
|
||||||
|
| 227 | `0x00E3` | 89 | `regulation` | `0x0B` |
|
||||||
|
|
||||||
|
Le flow identifie `frigo` et `regulation` uniquement à partir de la taille du
|
||||||
|
buffer reçu (163 ou 227). Il ne contient pas de parsing pour 95 octets. Le nom
|
||||||
|
fonctionnel du type de 95 octets n'est donc pas déterminé par les trois fichiers
|
||||||
|
analysés.
|
||||||
|
|
||||||
|
## Signatures et en-têtes
|
||||||
|
|
||||||
|
Les 16 premiers octets sont invariants pour les 89 occurrences de chaque taille
|
||||||
|
dans cette capture :
|
||||||
|
|
||||||
|
| Taille | Octets 0–15 (`0x00–0x0F`) |
|
||||||
|
|---:|---|
|
||||||
|
| 95 | `55 00 58 FF A7 40 02 02 0C 00 50 00 00 00 00 00` |
|
||||||
|
| 163 | `55 00 9C FF 63 40 02 04 0A 00 94 00 00 00 00 00` |
|
||||||
|
| 227 | `55 00 DC FF 23 40 02 04 0B 00 D4 00 01 00 00 00` |
|
||||||
|
|
||||||
|
### Différences observables dans l'en-tête
|
||||||
|
|
||||||
|
| Offset déc. | Offset hex. | 95 octets | 163 octets | 227 octets | Observation |
|
||||||
|
|---:|---:|---:|---:|---:|---|
|
||||||
|
| 0 | `0x00` | `55` | `55` | `55` | commun |
|
||||||
|
| 1 | `0x01` | `00` | `00` | `00` | commun |
|
||||||
|
| 2 | `0x02` | `58` | `9C` | `DC` | distingue les trois tailles dans cette capture |
|
||||||
|
| 3 | `0x03` | `FF` | `FF` | `FF` | commun |
|
||||||
|
| 4 | `0x04` | `A7` | `63` | `23` | distingue les trois tailles dans cette capture |
|
||||||
|
| 5 | `0x05` | `40` | `40` | `40` | commun |
|
||||||
|
| 6 | `0x06` | `02` | `02` | `02` | commun |
|
||||||
|
| 7 | `0x07` | `02` | `04` | `04` | 95 distinct de 163 et 227 |
|
||||||
|
| 8 | `0x08` | `0C` | `0A` | `0B` | valeur distincte par taille dans cette capture |
|
||||||
|
| 9 | `0x09` | `00` | `00` | `00` | commun |
|
||||||
|
| 10–11 | `0x0A–0x0B` | `50 00` | `94 00` | `D4 00` | entier little-endian corrélé à la taille |
|
||||||
|
| 12 | `0x0C` | `00` | `00` | `01` | 227 distinct dans cette capture |
|
||||||
|
| 13–15 | `0x0D–0x0F` | `00 00 00` | `00 00 00` | `00 00 00` | commun |
|
||||||
|
|
||||||
|
Les octets 2 (`0x02`) et 4 (`0x04`) ont aussi la relation observée
|
||||||
|
`octet[2] + octet[4] = 0xFF` pour les trois signatures. Cette relation ne
|
||||||
|
démontre ni checksum ni signification fonctionnelle.
|
||||||
|
|
||||||
|
## Hypothèse de détection de longueur
|
||||||
|
|
||||||
|
L'entier non signé little-endian aux offsets 10–11 (`0x0A–0x0B`) a les valeurs
|
||||||
|
suivantes pour toutes les occurrences de chaque type :
|
||||||
|
|
||||||
|
| Taille totale observée | Valeur de `octets[10:12]` LE | Écart |
|
||||||
|
|---:|---:|---:|
|
||||||
|
| 95 | 80 (`0x0050`) | 15 (`0x000F`) |
|
||||||
|
| 163 | 148 (`0x0094`) | 15 (`0x000F`) |
|
||||||
|
| 227 | 212 (`0x00D4`) | 15 (`0x000F`) |
|
||||||
|
|
||||||
|
**Hypothèse probable :** dans cette capture, après lecture de 12 octets à
|
||||||
|
partir de `55 00`, la taille totale peut être calculée comme
|
||||||
|
`little_endian_u16(offset 10) + 15`.
|
||||||
|
|
||||||
|
Cette corrélation est vérifiée sur les 267 segments observés. Elle n'établit
|
||||||
|
pas le nom ou la sémantique de ce champ, ni sa validité pour d'autres versions,
|
||||||
|
types ou états de la PAC. Elle ne constitue pas une preuve d'un champ longueur
|
||||||
|
défini par le protocole.
|
||||||
|
|
||||||
|
La valeur à l'offset 8 (`0x08`) est une seconde signature probable de type :
|
||||||
|
`0x0A → 163`, `0x0B → 227`, `0x0C → 95` dans cette capture. Elle n'est pas une
|
||||||
|
preuve qu'il s'agit d'un identifiant de trame documenté.
|
||||||
|
|
||||||
|
## Séquence observée
|
||||||
|
|
||||||
|
Les 267 segments suivent exactement le motif répété :
|
||||||
|
|
||||||
|
```text
|
||||||
|
227, 163, 95, 227, 163, 95, … (89 répétitions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Il n'y a ni répétition, ni inversion, ni omission dans la capture. Cela rend la
|
||||||
|
séquence régulière **pour cet échantillon**. Il n'est pas déterminé si elle est
|
||||||
|
obligatoire dans le protocole, si elle peut varier avec l'état de la PAC, ou si
|
||||||
|
des trames supplémentaires peuvent être intercalées.
|
||||||
|
|
||||||
|
## Ce qui est observé, probable et non déterminé
|
||||||
|
|
||||||
|
### Observé
|
||||||
|
|
||||||
|
- `55 00` apparaît 267 fois et permet une partition complète de cette capture.
|
||||||
|
- Les trois distances entre signatures sont exclusivement 95, 163 et 227.
|
||||||
|
- Chaque taille apparaît 89 fois, dans l'ordre cyclique indiqué.
|
||||||
|
- Les 16 octets d'en-tête listés sont constants pour chaque taille dans cet
|
||||||
|
échantillon.
|
||||||
|
- Le flow décode des champs aux offsets connus pour les segments de 163 et 227
|
||||||
|
octets ; il écarte les autres tailles.
|
||||||
|
|
||||||
|
### Probable, mais non spécifié
|
||||||
|
|
||||||
|
- `55 00` est une signature de début de segment dans ce flux.
|
||||||
|
- L'entier little-endian aux offsets 10–11 permet de calculer la taille totale
|
||||||
|
observée par addition de 15.
|
||||||
|
- L'octet à l'offset 8 distingue les trois catégories observées.
|
||||||
|
|
||||||
|
### Non déterminé
|
||||||
|
|
||||||
|
- La signification des octets d'en-tête, y compris 2, 4, 7, 8, 10–12.
|
||||||
|
- L'existence, la position ou l'algorithme d'un checksum.
|
||||||
|
- L'existence d'un champ longueur officiellement défini.
|
||||||
|
- La sémantique du segment de 95 octets.
|
||||||
|
- La stabilité des signatures, tailles et de l'ordre avec d'autres PAC,
|
||||||
|
versions logicielles ou états de fonctionnement.
|
||||||
|
- La relation entre les limites de cette capture et les lectures effectuées par
|
||||||
|
une socket TCP.
|
||||||
|
|
||||||
|
## Conséquences pour un extracteur TCP incrémental
|
||||||
|
|
||||||
|
TCP fournit un flux d'octets : une lecture peut contenir une fraction de
|
||||||
|
segment, un segment complet ou plusieurs segments. Le nœud Node-RED est réglé
|
||||||
|
en mode `stream`, mais son parser teste directement la taille de chaque message
|
||||||
|
reçu ; cette capture ne prouve pas que ce comportement soit sûr pour toutes les
|
||||||
|
segmentations TCP.
|
||||||
|
|
||||||
|
Un extracteur doit donc :
|
||||||
|
|
||||||
|
1. conserver les octets reçus dans un tampon persistant ;
|
||||||
|
2. rechercher `55 00` sans présumer que le début du tampon est une frontière ;
|
||||||
|
3. attendre au moins 12 octets avant d'évaluer l'hypothèse de taille ;
|
||||||
|
4. n'extraire un segment que lorsque la taille attendue est entièrement présente
|
||||||
|
dans le tampon ;
|
||||||
|
5. traiter les signatures ou tailles non observées comme non déterminées, sans
|
||||||
|
les faire passer au parser connu ;
|
||||||
|
6. prévoir une resynchronisation prudente si l'en-tête observé ne correspond
|
||||||
|
pas aux signatures documentées ici.
|
||||||
|
|
||||||
|
L'extracteur ne doit pas dépendre du cycle `227 → 163 → 95` pour délimiter les
|
||||||
|
segments : cette régularité est observée, mais non démontrée comme une règle de
|
||||||
|
protocole.
|
||||||
Vendored
+12
@@ -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.
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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 == {}
|
||||||
Reference in New Issue
Block a user