Compare commits
10
Commits
a774e3a955
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5b6240a5 | ||
|
|
a743b0008f | ||
|
|
fea8809fb9 | ||
|
|
9947b9c3ad | ||
|
|
8bae450bc3 | ||
|
|
b76e8f3896 | ||
|
|
c85637e26d | ||
|
|
1b6af397e5 | ||
|
|
ce4bba673c | ||
|
|
ef7a480180 |
+15
@@ -1,3 +1,18 @@
|
||||
# Artefacts Python générés localement
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
|
||||
# Captures potentiellement sensibles
|
||||
captures/
|
||||
|
||||
# Caches et rapports de tests
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
|
||||
# Fichiers d’éditeur et système
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Configuration depuis l’interface Home Assistant.
|
||||
- Connexion locale au proxy Arkteos sur le port 9641.
|
||||
- Client TCP asynchrone strictement en lecture seule.
|
||||
- Extraction incrémentale des trames.
|
||||
- Prise en charge des trames metadata, frigo et regulation.
|
||||
- Capteur de connectivité.
|
||||
- Capteurs natifs Home Assistant.
|
||||
- Diagnostics avec masquage de l’hôte.
|
||||
- Prise en charge de la reconfiguration.
|
||||
- Tests hors ligne avec fixtures réelles.
|
||||
- Préparation HACS.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Protocole non officiellement documenté.
|
||||
- Absence de checksum démontré.
|
||||
- Aucune commande ou écriture vers la PAC.
|
||||
- Compatibilité réelle encore à valider en parallèle de Node-RED.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Raph666
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,45 +1,108 @@
|
||||
# Arkteos — développement
|
||||
# Arkteos pour Home Assistant
|
||||
|
||||
Ce dépôt prépare une intégration custom Home Assistant en lecture seule pour
|
||||
une PAC Arkteos Zuran 4.
|
||||
## Présentation
|
||||
|
||||
L'intégration devra lire exclusivement le flux du proxy TCP fourni par l'addon
|
||||
Home Assistant séparé, sur le port de référence `9641`. Elle ne doit jamais se
|
||||
connecter directement à la PAC ni écrire sur le proxy.
|
||||
Arkteos est une intégration Home Assistant personnalisée pour une PAC Arkteos
|
||||
Zuran 4. Elle lit localement les données du proxy Arkteos, selon un protocole
|
||||
REG3 observé à partir de captures réelles et du flow Node-RED de référence.
|
||||
|
||||
## Références disponibles
|
||||
L’intégration est strictement en lecture seule : elle n’envoie aucune commande
|
||||
à la PAC et ne contacte jamais directement celle-ci. Elle ne dépend ni de MQTT,
|
||||
ni de Node-RED une fois la migration terminée.
|
||||
|
||||
- `references/arkteos_nodered_flow.json` : flow Node-RED fonctionnel en
|
||||
production et seule référence actuelle pour le parsing.
|
||||
- `references/proxy_protocol_notes.md` : notes de contexte sur le proxy et les
|
||||
tailles de trames rapportées.
|
||||
## État du projet
|
||||
|
||||
## État du dépôt
|
||||
- Version actuelle : `0.1.0`.
|
||||
- Statut : expérimental.
|
||||
- Testée avec les captures d’une Arkteos Zuran 4.
|
||||
- Le protocole n’est pas officiellement documenté.
|
||||
- Conserve Node-RED en parallèle pendant la validation sur l’installation
|
||||
réelle.
|
||||
|
||||
L'intégration Home Assistant n'est pas encore écrite. Aucun comportement de
|
||||
framing TCP ne doit être supposé sans captures ou tests démonstratifs.
|
||||
## Prérequis
|
||||
|
||||
## Arborescence cible proposée
|
||||
- Home Assistant.
|
||||
- HACS pour l’installation recommandée.
|
||||
- Un proxy Arkteos accessible sur le réseau.
|
||||
- Le port par défaut du proxy est `9641`.
|
||||
- La PAC ne doit pas être contactée directement.
|
||||
- Le proxy conserve la connexion unique vers la PAC et accepte les clients.
|
||||
|
||||
```text
|
||||
custom_components/
|
||||
arkteos/
|
||||
__init__.py
|
||||
manifest.json
|
||||
config_flow.py
|
||||
const.py
|
||||
coordinator.py
|
||||
parser.py
|
||||
sensor.py
|
||||
binary_sensor.py
|
||||
strings.json
|
||||
translations/
|
||||
fr.json
|
||||
tests/
|
||||
components/
|
||||
arkteos/
|
||||
references/
|
||||
## Installation avec HACS
|
||||
|
||||
1. Ouvre HACS.
|
||||
2. Va dans **Intégrations**.
|
||||
3. Ouvre le menu **Dépôts personnalisés**.
|
||||
4. Ajoute l’URL du miroir GitHub :
|
||||
`https://github.com/raph666/home-assistant-arkteos`.
|
||||
5. Choisis la catégorie **Intégration**.
|
||||
6. Installe **Arkteos**.
|
||||
7. Redémarre Home Assistant.
|
||||
8. Va dans **Paramètres > Appareils et services**.
|
||||
9. Ajoute **Arkteos**.
|
||||
10. Saisis l’hôte du proxy et le port `9641`.
|
||||
|
||||
Le miroir GitHub sert à l’installation HACS.
|
||||
|
||||
## Configuration
|
||||
|
||||
La configuration demande l’hôte et le port du proxy. Elle est validée par une
|
||||
connexion au proxy et la réception d’une trame valide. La reconfiguration est
|
||||
possible depuis Home Assistant.
|
||||
|
||||
## Entités exposées
|
||||
|
||||
L’intégration expose des entités natives Home Assistant pour :
|
||||
|
||||
- la connectivité ;
|
||||
- le groupe frigorifique ;
|
||||
- la régulation ;
|
||||
- le circuit primaire ;
|
||||
- l’eau chaude sanitaire ;
|
||||
- les statuts et diagnostics.
|
||||
|
||||
Certaines entités de diagnostic sont désactivées par défaut.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Les diagnostics sont téléchargeables depuis Home Assistant. L’hôte est masqué
|
||||
et aucune trame brute, socket ou donnée interne sensible n’est exposée.
|
||||
|
||||
## Validation parallèle avec Node-RED
|
||||
|
||||
Le flow Node-RED doit rester actif au début. Compare les anciennes entités MQTT
|
||||
avec les nouvelles entités natives, puis retire Node-RED uniquement après une
|
||||
validation des valeurs en conditions réelles.
|
||||
|
||||
## Limites connues
|
||||
|
||||
- Le protocole est déduit de captures.
|
||||
- Aucun checksum n’est démontré.
|
||||
- Seules les trames observées de 95, 163 et 227 octets sont reconnues.
|
||||
- L’intégration est en lecture seule et ne permet aucune commande de pilotage.
|
||||
- La compatibilité est confirmée uniquement avec la configuration testée.
|
||||
- Arkteos ne fournit aucun support officiel pour cette intégration.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install -r requirements_test.txt
|
||||
python3 -m pytest -v
|
||||
python3 -m compileall custom_components/arkteos
|
||||
```
|
||||
|
||||
Cette arborescence est une proposition : elle ne crée encore aucun de ces
|
||||
fichiers.
|
||||
## Licence
|
||||
|
||||
MIT — Copyright (c) 2026 Raph666
|
||||
|
||||
## Support
|
||||
|
||||
Tant que le dépôt principal reste sur Gitea, utilise ces URLs pour le code et
|
||||
les tickets :
|
||||
|
||||
- Dépôt : https://gitea.i-host.fr/raph666/home-assistant-arkteos
|
||||
- Tickets : https://gitea.i-host.fr/raph666/home-assistant-arkteos/issues
|
||||
|
||||
Le miroir GitHub sert à l’installation HACS.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Paquet des composants personnalisés Arkteos."""
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Paquet de l'intégration Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .client import ArkteosClient
|
||||
from .const import CONF_HOST, CONF_PORT, DOMAIN, PLATFORMS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def _get_client(hass: HomeAssistant, entry: ConfigEntry) -> ArkteosClient | None:
|
||||
"""Retourne le client partagé, depuis runtime_data ou le repli structuré."""
|
||||
|
||||
client = getattr(entry, "runtime_data", None)
|
||||
if isinstance(client, ArkteosClient):
|
||||
return client
|
||||
return hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||
|
||||
|
||||
def _store_client(hass: HomeAssistant, entry: ConfigEntry, client: ArkteosClient) -> None:
|
||||
"""Stocke le client une fois par config entry."""
|
||||
|
||||
if hasattr(entry, "runtime_data"):
|
||||
entry.runtime_data = client
|
||||
return
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
|
||||
|
||||
|
||||
def _remove_client(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Supprime les données d'exécution de la config entry."""
|
||||
|
||||
if hasattr(entry, "runtime_data"):
|
||||
entry.runtime_data = None
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
if domain_data is not None:
|
||||
domain_data.pop(entry.entry_id, None)
|
||||
if not domain_data:
|
||||
hass.data.pop(DOMAIN, None)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Crée et partage le client Arkteos pour cette config entry."""
|
||||
|
||||
if _get_client(hass, entry) is not None:
|
||||
return True
|
||||
|
||||
client = ArkteosClient(entry.data[CONF_HOST], entry.data[CONF_PORT])
|
||||
_store_client(hass, entry, client)
|
||||
await client.start()
|
||||
try:
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except Exception:
|
||||
await client.stop()
|
||||
_remove_client(hass, entry)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Décharge les plateformes et arrête le client partagé."""
|
||||
|
||||
client = _get_client(hass, entry)
|
||||
if client is None:
|
||||
return True
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if not unload_ok:
|
||||
return False
|
||||
await client.stop()
|
||||
_remove_client(hass, entry)
|
||||
return True
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Binary sensor de disponibilité du proxy Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import _get_client
|
||||
from .const import DOMAIN
|
||||
from .entity import ArkteosEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Ajoute l'unique entité de connexion de cette config entry."""
|
||||
|
||||
client = _get_client(hass, entry)
|
||||
if client is None:
|
||||
return
|
||||
async_add_entities([ArkteosConnectionBinarySensor(client)])
|
||||
|
||||
|
||||
class ArkteosConnectionBinarySensor(ArkteosEntity, BinarySensorEntity):
|
||||
"""Expose la disponibilité du flux lu depuis le proxy."""
|
||||
|
||||
_attr_translation_key = "connection"
|
||||
_attr_unique_id = "arkteos_zuran4_connection"
|
||||
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||
_attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, "arkteos_zuran4")},
|
||||
manufacturer="Arkteos",
|
||||
model="Zuran 4",
|
||||
name="PAC Arkteos Zuran 4",
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Reflète la disponibilité du client sans effectuer de lecture réseau."""
|
||||
|
||||
return self._client.available
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Client TCP asynchrone, strictement en lecture seule, pour le proxy Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
from .frame_extractor import FrameExtractor
|
||||
from .parser import FilterResult, FrameError, ParsedFrame, filter_values, parse_frame
|
||||
|
||||
|
||||
DEFAULT_PROXY_PORT: Final = 9641
|
||||
DEFAULT_RECONNECT_DELAYS: Final[tuple[float, ...]] = (1.0, 2.0, 5.0, 10.0, 30.0)
|
||||
|
||||
DataCallback: TypeAlias = Callable[
|
||||
[str, dict[str, int | float | str], dict[str, int | float]], object
|
||||
]
|
||||
AvailabilityCallback: TypeAlias = Callable[[bool], object]
|
||||
SleepCallable: TypeAlias = Callable[[float], Awaitable[None]]
|
||||
|
||||
|
||||
class ArkteosClient:
|
||||
"""Lit le proxy Arkteos, extrait les trames et diffuse les données filtrées."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int = DEFAULT_PROXY_PORT,
|
||||
*,
|
||||
read_size: int = 1024,
|
||||
frame_timeout: float = 30.0,
|
||||
reconnect_delays: Sequence[float] = DEFAULT_RECONNECT_DELAYS,
|
||||
max_buffer_size: int = 4096,
|
||||
sleep: SleepCallable = asyncio.sleep,
|
||||
) -> None:
|
||||
if not host:
|
||||
raise ValueError("host ne peut pas être vide")
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port doit être compris entre 1 et 65535")
|
||||
if read_size < 1:
|
||||
raise ValueError("read_size doit être positif")
|
||||
if frame_timeout <= 0:
|
||||
raise ValueError("frame_timeout doit être positif")
|
||||
if not reconnect_delays or any(delay < 0 for delay in reconnect_delays):
|
||||
raise ValueError("reconnect_delays doit contenir des délais positifs ou nuls")
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.read_size = read_size
|
||||
self.frame_timeout = frame_timeout
|
||||
self.reconnect_delays = tuple(reconnect_delays)
|
||||
|
||||
self.running = False
|
||||
self.connected = False
|
||||
self.available = False
|
||||
self.last_valid_frame: ParsedFrame | None = None
|
||||
self.last_frame_type: str | None = None
|
||||
self.latest_frigo_data: dict[str, int | float | str] | None = None
|
||||
self.latest_regulation_data: dict[str, int | float | str] | None = None
|
||||
self.latest_metadata: dict[str, int | float | str] | None = None
|
||||
self.frames_received = 0
|
||||
self.frames_rejected = 0
|
||||
self.bytes_received = 0
|
||||
self.connection_attempts = 0
|
||||
self.reconnect_count = 0
|
||||
self.last_error: Exception | None = None
|
||||
|
||||
self._extractor = FrameExtractor(max_buffer_size=max_buffer_size)
|
||||
self._sleep = sleep
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._connected_event = asyncio.Event()
|
||||
self._data_callbacks: list[DataCallback] = []
|
||||
self._availability_callbacks: list[AvailabilityCallback] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Démarre la tâche de connexion si le client n'est pas déjà actif."""
|
||||
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self._task = asyncio.create_task(self._run(), name="arkteos-client")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Arrête le client, ferme le flux et attend toutes ses tâches."""
|
||||
|
||||
self.running = False
|
||||
self._set_connected(False)
|
||||
await self._set_available(False)
|
||||
await self._close_writer()
|
||||
|
||||
task = self._task
|
||||
self._task = None
|
||||
if task is not None and task is not asyncio.current_task() and not task.done():
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
async def wait_until_connected(self, timeout: float | None = None) -> bool:
|
||||
"""Attend une connexion établie, avec délai optionnel."""
|
||||
|
||||
if self.connected:
|
||||
return True
|
||||
try:
|
||||
if timeout is None:
|
||||
await self._connected_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(self._connected_event.wait(), timeout)
|
||||
except TimeoutError:
|
||||
return False
|
||||
return self.connected
|
||||
|
||||
def add_data_callback(self, callback: DataCallback) -> None:
|
||||
"""Ajoute un callback de données sans le dupliquer."""
|
||||
|
||||
if callback not in self._data_callbacks:
|
||||
self._data_callbacks.append(callback)
|
||||
|
||||
def remove_data_callback(self, callback: DataCallback) -> None:
|
||||
"""Retire un callback de données s'il est enregistré."""
|
||||
|
||||
with suppress(ValueError):
|
||||
self._data_callbacks.remove(callback)
|
||||
|
||||
def add_availability_callback(self, callback: AvailabilityCallback) -> None:
|
||||
"""Ajoute un callback de disponibilité sans le dupliquer."""
|
||||
|
||||
if callback not in self._availability_callbacks:
|
||||
self._availability_callbacks.append(callback)
|
||||
|
||||
def remove_availability_callback(self, callback: AvailabilityCallback) -> None:
|
||||
"""Retire un callback de disponibilité s'il est enregistré."""
|
||||
|
||||
with suppress(ValueError):
|
||||
self._availability_callbacks.remove(callback)
|
||||
|
||||
async def _run(self) -> None:
|
||||
delay_index = 0
|
||||
try:
|
||||
while self.running:
|
||||
self.connection_attempts += 1
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(self.host, self.port)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.last_error = error
|
||||
self._set_connected(False)
|
||||
await self._set_available(False)
|
||||
if not await self._wait_before_reconnect(delay_index):
|
||||
break
|
||||
delay_index = min(delay_index + 1, len(self.reconnect_delays) - 1)
|
||||
continue
|
||||
|
||||
self._writer = writer
|
||||
self._set_connected(True)
|
||||
received_valid_frame = await self._read_connection(reader)
|
||||
if received_valid_frame:
|
||||
delay_index = 0
|
||||
|
||||
self._set_connected(False)
|
||||
await self._set_available(False)
|
||||
await self._close_writer()
|
||||
if not self.running:
|
||||
break
|
||||
if not await self._wait_before_reconnect(delay_index):
|
||||
break
|
||||
delay_index = min(delay_index + 1, len(self.reconnect_delays) - 1)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
finally:
|
||||
self._set_connected(False)
|
||||
await self._set_available(False)
|
||||
await self._close_writer()
|
||||
|
||||
async def _wait_before_reconnect(self, delay_index: int) -> bool:
|
||||
if not self.running:
|
||||
return False
|
||||
self.reconnect_count += 1
|
||||
await self._sleep(self.reconnect_delays[delay_index])
|
||||
return self.running
|
||||
|
||||
async def _read_connection(self, reader: asyncio.StreamReader) -> bool:
|
||||
received_valid_frame = False
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self.frame_timeout
|
||||
|
||||
while self.running:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
await self._set_available(False)
|
||||
deadline = loop.time() + self.frame_timeout
|
||||
remaining = self.frame_timeout
|
||||
|
||||
try:
|
||||
data = await asyncio.wait_for(reader.read(self.read_size), remaining)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError:
|
||||
await self._set_available(False)
|
||||
deadline = loop.time() + self.frame_timeout
|
||||
continue
|
||||
except Exception as error:
|
||||
self.last_error = error
|
||||
return received_valid_frame
|
||||
|
||||
if not data:
|
||||
return received_valid_frame
|
||||
|
||||
self.bytes_received += len(data)
|
||||
try:
|
||||
raw_frames = self._extractor.feed(data)
|
||||
except Exception as error:
|
||||
self.frames_rejected += 1
|
||||
self.last_error = error
|
||||
continue
|
||||
|
||||
for raw_frame in raw_frames:
|
||||
try:
|
||||
frame = parse_frame(raw_frame)
|
||||
filtered = filter_values(frame)
|
||||
except FrameError as error:
|
||||
self.frames_rejected += 1
|
||||
self.last_error = error
|
||||
continue
|
||||
|
||||
received_valid_frame = True
|
||||
deadline = loop.time() + self.frame_timeout
|
||||
await self._accept_frame(frame, filtered)
|
||||
|
||||
return received_valid_frame
|
||||
|
||||
async def _accept_frame(self, frame: ParsedFrame, filtered: FilterResult) -> None:
|
||||
accepted = dict(filtered.accepted)
|
||||
rejected = dict(filtered.rejected)
|
||||
frame_type = accepted["frame_type"]
|
||||
assert isinstance(frame_type, str)
|
||||
|
||||
self.frames_received += 1
|
||||
self.last_valid_frame = frame
|
||||
self.last_frame_type = frame_type
|
||||
if frame_type == "frigo":
|
||||
self.latest_frigo_data = accepted
|
||||
elif frame_type == "regulation":
|
||||
self.latest_regulation_data = accepted
|
||||
else:
|
||||
self.latest_metadata = accepted
|
||||
|
||||
await self._set_available(True)
|
||||
for callback in tuple(self._data_callbacks):
|
||||
await self._invoke_callback(callback, frame_type, dict(accepted), dict(rejected))
|
||||
|
||||
async def _set_available(self, value: bool) -> None:
|
||||
if self.available == value:
|
||||
return
|
||||
self.available = value
|
||||
for callback in tuple(self._availability_callbacks):
|
||||
await self._invoke_callback(callback, value)
|
||||
|
||||
async def _invoke_callback(self, callback: Callable[..., object], *args: object) -> None:
|
||||
try:
|
||||
result = callback(*args)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except Exception as error:
|
||||
self.last_error = error
|
||||
|
||||
def _set_connected(self, value: bool) -> None:
|
||||
self.connected = value
|
||||
if value:
|
||||
self._connected_event.set()
|
||||
else:
|
||||
self._connected_event.clear()
|
||||
|
||||
async def _close_writer(self) -> None:
|
||||
writer = self._writer
|
||||
self._writer = None
|
||||
if writer is None:
|
||||
return
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception as error:
|
||||
self.last_error = error
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Flux de configuration par interface utilisateur de l'intégration Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .client import ArkteosClient
|
||||
from .const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
|
||||
|
||||
|
||||
VALIDATION_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""La connexion au proxy a échoué."""
|
||||
|
||||
|
||||
class ValidationTimeout(Exception):
|
||||
"""Aucune trame valide n'a été reçue dans le délai imparti."""
|
||||
|
||||
|
||||
class ArkteosConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Gère la création et la reconfiguration des config entries Arkteos."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
||||
"""Affiche et traite le formulaire initial."""
|
||||
|
||||
if user_input is not None:
|
||||
data, errors = await self._async_validate_input(user_input)
|
||||
if not errors:
|
||||
if self._entry_exists(data[CONF_HOST], data[CONF_PORT]):
|
||||
return self.async_abort(reason="already_configured")
|
||||
await self.async_set_unique_id(self._unique_id(data))
|
||||
self._abort_if_unique_id_configured()
|
||||
try:
|
||||
await self._async_validate_proxy(data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ValidationTimeout:
|
||||
errors["base"] = "timeout"
|
||||
except Exception:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(title=self._title(data), data=data)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=self._schema(data if "data" in locals() else None), errors=errors
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=self._schema())
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Permet la modification de l'hôte et du port d'une entrée existante."""
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
data, errors = await self._async_validate_input(user_input)
|
||||
if not errors:
|
||||
if self._entry_exists(data[CONF_HOST], data[CONF_PORT], exclude_entry_id=entry.entry_id):
|
||||
errors["base"] = "already_configured"
|
||||
else:
|
||||
try:
|
||||
await self._async_validate_proxy(data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ValidationTimeout:
|
||||
errors["base"] = "timeout"
|
||||
except Exception:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
entry, data_updates=data, title=self._title(data)
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=self._schema(data if "data" in locals() else entry.data), errors=errors
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="reconfigure", data_schema=self._schema(entry.data))
|
||||
|
||||
async def _async_validate_input(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
errors: dict[str, str] = {}
|
||||
host = str(user_input.get(CONF_HOST, "")).strip()
|
||||
if not host:
|
||||
errors[CONF_HOST] = "invalid_host"
|
||||
try:
|
||||
port = int(user_input.get(CONF_PORT, DEFAULT_PORT))
|
||||
except (TypeError, ValueError):
|
||||
errors[CONF_PORT] = "invalid_port"
|
||||
port = DEFAULT_PORT
|
||||
if not 1 <= port <= 65535:
|
||||
errors[CONF_PORT] = "invalid_port"
|
||||
return {CONF_HOST: host, CONF_PORT: port}, errors
|
||||
|
||||
async def _async_validate_proxy(self, data: dict[str, Any]) -> None:
|
||||
client = ArkteosClient(data[CONF_HOST], data[CONF_PORT])
|
||||
try:
|
||||
await client.start()
|
||||
if not await client.wait_until_connected(VALIDATION_TIMEOUT):
|
||||
raise CannotConnect
|
||||
deadline = asyncio.get_running_loop().time() + VALIDATION_TIMEOUT
|
||||
while client.last_valid_frame is None:
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise ValidationTimeout
|
||||
if client.last_error is not None and not client.connected:
|
||||
raise CannotConnect
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
def _entry_exists(self, host: str, port: int, exclude_entry_id: str | None = None) -> bool:
|
||||
return any(
|
||||
entry.entry_id != exclude_entry_id
|
||||
and entry.data.get(CONF_HOST) == host
|
||||
and entry.data.get(CONF_PORT) == port
|
||||
for entry in self._async_current_entries()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _title(data: dict[str, Any]) -> str:
|
||||
return f"Arkteos {data[CONF_HOST]}:{data[CONF_PORT]}"
|
||||
|
||||
@staticmethod
|
||||
def _unique_id(data: dict[str, Any]) -> str:
|
||||
return f"{data[CONF_HOST]}:{data[CONF_PORT]}"
|
||||
|
||||
@staticmethod
|
||||
def _schema(defaults: dict[str, Any] | None = None) -> vol.Schema:
|
||||
defaults = defaults or {}
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=defaults.get(CONF_HOST, "")): str,
|
||||
vol.Required(CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Constantes de l'intégration Arkteos."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
|
||||
DOMAIN = "arkteos"
|
||||
DEFAULT_PORT = 9641
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
RUNTIME_DATA = "runtime_data"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Diagnostics sans accès réseau pour l'intégration Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
|
||||
from . import _get_client
|
||||
from .const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
_SENSITIVE_KEYS = frozenset({"host", "password", "token"})
|
||||
_CLIENT_STATE_ATTRIBUTES = (
|
||||
"available",
|
||||
"running",
|
||||
"connected",
|
||||
"bytes_received",
|
||||
"frames_received",
|
||||
"frames_rejected",
|
||||
"reconnect_count",
|
||||
"last_frame_type",
|
||||
)
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _manifest_version() -> str | None:
|
||||
"""Retourne la version déclarée sans contacter de ressource externe."""
|
||||
|
||||
try:
|
||||
manifest = json.loads(Path(__file__).with_name("manifest.json").read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
version = manifest.get("version")
|
||||
return version if isinstance(version, str) else None
|
||||
|
||||
|
||||
def _simple_values(data: object) -> dict[str, str | int | float | bool | None]:
|
||||
"""Conserve uniquement les valeurs décodées, simples et non sensibles."""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
result: dict[str, str | int | float | bool | None] = {}
|
||||
for key, value in data.items():
|
||||
if not isinstance(key, str) or key.lower() in _SENSITIVE_KEYS:
|
||||
continue
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _received_data(client: object | None, attribute: str) -> dict[str, Any]:
|
||||
"""Prépare une section de données sans bytes, buffer ni objet asyncio."""
|
||||
|
||||
data = getattr(client, attribute, None) if client is not None else None
|
||||
values = _simple_values(data)
|
||||
return {"received": data is not None, "data": values}
|
||||
|
||||
|
||||
def _entry_state(entry: ConfigEntry) -> str | None:
|
||||
"""Convertit l'état optionnel de l'entrée en valeur sérialisable."""
|
||||
|
||||
state = getattr(entry, "state", None)
|
||||
if state is None:
|
||||
return None
|
||||
value = getattr(state, "value", state)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Retourne des diagnostics locaux, redacted et uniquement sérialisables.
|
||||
|
||||
Le client conserve uniquement ``frames_rejected`` de manière agrégée : les
|
||||
valeurs rejetées par champ ne sont donc pas inventées dans ce résultat.
|
||||
"""
|
||||
|
||||
entry_data = getattr(entry, "data", {})
|
||||
if not isinstance(entry_data, Mapping):
|
||||
entry_data = {}
|
||||
host = entry_data.get(CONF_HOST)
|
||||
title = getattr(entry, "title", None)
|
||||
if isinstance(title, str) and isinstance(host, str):
|
||||
title = title.replace(host, "**REDACTED**")
|
||||
|
||||
configured = async_redact_data(
|
||||
{
|
||||
CONF_HOST: host,
|
||||
CONF_PORT: entry_data.get(CONF_PORT, DEFAULT_PORT),
|
||||
},
|
||||
[CONF_HOST],
|
||||
)
|
||||
client = _get_client(hass, entry)
|
||||
client_state: dict[str, str | int | float | bool | None] = {}
|
||||
if client is not None:
|
||||
for attribute in _CLIENT_STATE_ATTRIBUTES:
|
||||
value = getattr(client, attribute, _MISSING)
|
||||
if value is _MISSING:
|
||||
continue
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
client_state[attribute] = value
|
||||
|
||||
return {
|
||||
"integration": {
|
||||
"domain": DOMAIN,
|
||||
"version": _manifest_version(),
|
||||
"title": title if isinstance(title, str) else None,
|
||||
"config_entry_state": _entry_state(entry),
|
||||
"configuration": configured,
|
||||
},
|
||||
"client": client_state,
|
||||
"received_data": {
|
||||
"metadata": _received_data(client, "latest_metadata"),
|
||||
"frigo": _received_data(client, "latest_frigo_data"),
|
||||
"regulation": _received_data(client, "latest_regulation_data"),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Entités Home Assistant partageant le client Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .client import ArkteosClient
|
||||
|
||||
|
||||
class ArkteosEntity(Entity):
|
||||
"""Base d'entité sans lecture réseau dans ses propriétés."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, client: ArkteosClient) -> None:
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Reflète la disponibilité déterminée par le client partagé."""
|
||||
|
||||
return self._client.available
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Abonne l'entité aux changements de disponibilité."""
|
||||
|
||||
await super().async_added_to_hass()
|
||||
self._client.add_availability_callback(self._handle_availability)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Retire le callback avant la destruction de l'entité."""
|
||||
|
||||
self._client.remove_availability_callback(self._handle_availability)
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
def _handle_availability(self, _available: bool) -> None:
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Extracteur incrémental des trames REG3 observées sur le flux TCP.
|
||||
|
||||
Ce module ne décode aucune valeur métier et ne dépend ni du réseau ni de Home
|
||||
Assistant. Les signatures, tailles et cohérences d'en-tête sont limitées à ce
|
||||
qui est observé dans ``docs/frame_protocol_analysis.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
FRAME_SIGNATURE: Final = b"\x55\x00"
|
||||
HEADER_MIN_SIZE: Final = 12
|
||||
HEADER_LENGTH_OFFSET: Final = 10
|
||||
HEADER_SIZE_ADJUSTMENT: Final = 15
|
||||
FRAME_TYPE_OFFSET: Final = 8
|
||||
|
||||
FRAME_SIZES_BY_TYPE: Final[dict[int, int]] = {
|
||||
0x0C: 95,
|
||||
0x0A: 163,
|
||||
0x0B: 227,
|
||||
}
|
||||
KNOWN_FRAME_SIZES: Final[frozenset[int]] = frozenset(FRAME_SIZES_BY_TYPE.values())
|
||||
|
||||
|
||||
class FrameExtractor:
|
||||
"""Assemble des trames complètes depuis des fragments TCP arbitraires."""
|
||||
|
||||
def __init__(self, *, max_buffer_size: int = 4096) -> None:
|
||||
if max_buffer_size < HEADER_MIN_SIZE:
|
||||
raise ValueError(
|
||||
f"max_buffer_size doit être supérieur ou égal à {HEADER_MIN_SIZE}"
|
||||
)
|
||||
self._max_buffer_size = max_buffer_size
|
||||
self._buffer = bytearray()
|
||||
|
||||
@property
|
||||
def buffered_bytes(self) -> bytes:
|
||||
"""Retourne une copie immuable des octets en attente."""
|
||||
|
||||
return bytes(self._buffer)
|
||||
|
||||
@property
|
||||
def buffered_size(self) -> int:
|
||||
"""Retourne la taille des octets en attente."""
|
||||
|
||||
return len(self._buffer)
|
||||
|
||||
@property
|
||||
def max_buffer_size(self) -> int:
|
||||
"""Retourne la limite configurable du tampon persistant."""
|
||||
|
||||
return self._max_buffer_size
|
||||
|
||||
def feed(self, data: bytes) -> list[bytes]:
|
||||
"""Ajoute un fragment TCP et retourne les trames complètes extraites.
|
||||
|
||||
Les données entrantes sont ajoutées par blocs bornés afin que le tampon
|
||||
persistant ne dépasse jamais ``max_buffer_size``.
|
||||
"""
|
||||
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError("data doit être de type bytes")
|
||||
|
||||
frames = self._extract_available()
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
available = self._max_buffer_size - len(self._buffer)
|
||||
if available == 0:
|
||||
before = len(self._buffer)
|
||||
frames.extend(self._extract_available())
|
||||
if len(self._buffer) == before:
|
||||
# Cette branche ne doit être atteinte qu'avec un en-tête
|
||||
# incomplet ou invalide ; avancer garantit la progression.
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
end = min(offset + available, len(data))
|
||||
self._buffer.extend(data[offset:end])
|
||||
offset = end
|
||||
frames.extend(self._extract_available())
|
||||
|
||||
return frames
|
||||
|
||||
def _extract_available(self) -> list[bytes]:
|
||||
frames: list[bytes] = []
|
||||
|
||||
while True:
|
||||
if len(self._buffer) < len(FRAME_SIGNATURE):
|
||||
return frames
|
||||
|
||||
signature_offset = self._buffer.find(FRAME_SIGNATURE)
|
||||
if signature_offset < 0:
|
||||
self._preserve_possible_split_signature()
|
||||
return frames
|
||||
|
||||
if signature_offset > 0:
|
||||
del self._buffer[:signature_offset]
|
||||
|
||||
if len(self._buffer) < HEADER_MIN_SIZE:
|
||||
return frames
|
||||
|
||||
frame_size = (
|
||||
int.from_bytes(
|
||||
self._buffer[
|
||||
HEADER_LENGTH_OFFSET : HEADER_LENGTH_OFFSET + 2
|
||||
],
|
||||
"little",
|
||||
)
|
||||
+ HEADER_SIZE_ADJUSTMENT
|
||||
)
|
||||
frame_type = self._buffer[FRAME_TYPE_OFFSET]
|
||||
expected_size = FRAME_SIZES_BY_TYPE.get(frame_type)
|
||||
|
||||
if (
|
||||
frame_size not in KNOWN_FRAME_SIZES
|
||||
or frame_size > self._max_buffer_size
|
||||
or expected_size != frame_size
|
||||
):
|
||||
# Ne supprimer qu'un octet : une nouvelle signature peut
|
||||
# commencer à l'octet suivant du flux reçu.
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
if len(self._buffer) < frame_size:
|
||||
return frames
|
||||
|
||||
frames.append(bytes(self._buffer[:frame_size]))
|
||||
del self._buffer[:frame_size]
|
||||
|
||||
def _preserve_possible_split_signature(self) -> None:
|
||||
"""Conserve seulement un dernier 0x55 en l'absence de signature."""
|
||||
|
||||
if self._buffer[-1] == FRAME_SIGNATURE[0]:
|
||||
self._buffer[:] = FRAME_SIGNATURE[:1]
|
||||
else:
|
||||
self._buffer.clear()
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "arkteos",
|
||||
"name": "Arkteos",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"version": "0.1.0",
|
||||
"requirements": [],
|
||||
"documentation": "https://gitea.i-host.fr/raph666/home-assistant-arkteos",
|
||||
"issue_tracker": "https://gitea.i-host.fr/raph666/home-assistant-arkteos/issues",
|
||||
"codeowners": []
|
||||
}
|
||||
@@ -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,396 @@
|
||||
"""Capteurs natifs en lecture seule pour les données Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
EntityCategory,
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import _get_client
|
||||
from .client import ArkteosClient
|
||||
from .const import DOMAIN
|
||||
from .entity import ArkteosEntity
|
||||
|
||||
|
||||
FRIGO_FRAME_TYPE: Final = "frigo"
|
||||
REGULATION_FRAME_TYPE: Final = "regulation"
|
||||
|
||||
FRIGO_STATUS_OPTIONS: Final = (
|
||||
"Arret",
|
||||
"Refroidissement",
|
||||
"Chauffage",
|
||||
"Degivrage",
|
||||
"Inconnu",
|
||||
)
|
||||
PAC_STATUS_OPTIONS: Final = (
|
||||
"Arret",
|
||||
"Attente",
|
||||
"Chaud",
|
||||
"Froid",
|
||||
"Hors Gel",
|
||||
"Ext Chaud",
|
||||
"Ext Froid",
|
||||
"Chaud Froid",
|
||||
"ECS",
|
||||
"Piscine",
|
||||
"Inconnu",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArkteosSensorDescription(SensorEntityDescription):
|
||||
"""Description d'un capteur et du type de trame qui l'alimente."""
|
||||
|
||||
frame_type: Literal["frigo", "regulation"]
|
||||
|
||||
|
||||
FRIGO_SENSORS: Final[tuple[ArkteosSensorDescription, ...]] = (
|
||||
ArkteosSensorDescription(
|
||||
key="exterieur_temp",
|
||||
translation_key="exterieur_temp",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="nb_degivrages",
|
||||
translation_key="nb_degivrages",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="temps_compresseur",
|
||||
translation_key="temps_compresseur",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="nb_cycles_compresseur",
|
||||
translation_key="nb_cycles_compresseur",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="freq_comp_actuelle",
|
||||
translation_key="freq_comp_actuelle",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="freq_comp_cible",
|
||||
translation_key="freq_comp_cible",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="fan_speed_evaporator_1",
|
||||
translation_key="fan_speed_evaporator_1",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="dc_voltage",
|
||||
translation_key="dc_voltage",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="statut_frigo_s",
|
||||
translation_key="statut_frigo_s",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=FRIGO_STATUS_OPTIONS,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="statut_frigo",
|
||||
translation_key="statut_frigo",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="active_error_fri",
|
||||
translation_key="active_error_fri",
|
||||
frame_type=FRIGO_FRAME_TYPE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
REGULATION_SENSORS: Final[tuple[ArkteosSensorDescription, ...]] = (
|
||||
ArkteosSensorDescription(
|
||||
key="puissance_inst_produite",
|
||||
translation_key="puissance_inst_produite",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="puissance_inst_consommee",
|
||||
translation_key="puissance_inst_consommee",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="temps_mise_sous_tension",
|
||||
translation_key="temps_mise_sous_tension",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.HOURS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="modele_pac_s",
|
||||
translation_key="modele_pac_s",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="modele_pac",
|
||||
translation_key="modele_pac",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="primaire_temp_eau_aller_consigne",
|
||||
translation_key="primaire_temp_eau_aller_consigne",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="primaire_temp_eau_aller",
|
||||
translation_key="primaire_temp_eau_aller",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="primaire_temp_eau_retour",
|
||||
translation_key="primaire_temp_eau_retour",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="primaire_debit_eau",
|
||||
translation_key="primaire_debit_eau",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement="L/h",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="primaire_pression",
|
||||
translation_key="primaire_pression",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.BAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="primaire_circulateur_consigne",
|
||||
translation_key="primaire_circulateur_consigne",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="zone1_temp_interieur",
|
||||
translation_key="zone1_temp_interieur",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="zone1_consigne",
|
||||
translation_key="zone1_consigne",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="ecs_temp_eau_milieu",
|
||||
translation_key="ecs_temp_eau_milieu",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="ecs_temp_eau_bas",
|
||||
translation_key="ecs_temp_eau_bas",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="ecs_consigne",
|
||||
translation_key="ecs_consigne",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="nb_cycles_compresseur_reg",
|
||||
translation_key="nb_cycles_compresseur_reg",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="statut_pac_s",
|
||||
translation_key="statut_pac_s",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=PAC_STATUS_OPTIONS,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="statut_pac",
|
||||
translation_key="statut_pac",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="active_error_reg",
|
||||
translation_key="active_error_reg",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
ArkteosSensorDescription(
|
||||
key="signal_rf_sonde_1",
|
||||
translation_key="signal_rf_sonde_1",
|
||||
frame_type=REGULATION_FRAME_TYPE,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
SENSOR_DESCRIPTIONS: Final = FRIGO_SENSORS + REGULATION_SENSORS
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Ajoute les capteurs alimentés par le client partagé de l'entrée."""
|
||||
|
||||
client = _get_client(hass, entry)
|
||||
if client is None:
|
||||
return
|
||||
async_add_entities(ArkteosSensor(client, description) for description in SENSOR_DESCRIPTIONS)
|
||||
|
||||
|
||||
class ArkteosSensor(ArkteosEntity, SensorEntity):
|
||||
"""Expose une valeur validée issue d'un seul type de trame Arkteos."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, client: ArkteosClient, description: ArkteosSensorDescription) -> None:
|
||||
super().__init__(client)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"arkteos_zuran4_{description.key}"
|
||||
latest_data = self._latest_data()
|
||||
self._has_received_frame = latest_data is not None
|
||||
self._last_value = latest_data.get(description.key) if latest_data is not None else None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""N'est disponible qu'après la première trame de son propre type."""
|
||||
|
||||
return self._client.available and self._has_received_frame
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Retourne la dernière valeur acceptée, sans accès réseau."""
|
||||
|
||||
return self._last_value
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Regroupe tous les capteurs sous l'unique PAC Arkteos."""
|
||||
|
||||
regulation_data = self._client.latest_regulation_data
|
||||
model = "Zuran 4"
|
||||
if regulation_data is not None:
|
||||
detected_model = regulation_data.get("modele_pac_s")
|
||||
if isinstance(detected_model, str):
|
||||
model = detected_model
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, "arkteos_zuran4")},
|
||||
manufacturer="Arkteos",
|
||||
model=model,
|
||||
name="PAC Arkteos Zuran 4",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Abonne le capteur aux seules données transmises par le client."""
|
||||
|
||||
await super().async_added_to_hass()
|
||||
self._client.add_data_callback(self._handle_data)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Désabonne le callback de données avant la destruction."""
|
||||
|
||||
self._client.remove_data_callback(self._handle_data)
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
def _latest_data(self) -> dict[str, int | float | str] | None:
|
||||
if self.entity_description.frame_type == FRIGO_FRAME_TYPE:
|
||||
return self._client.latest_frigo_data
|
||||
return self._client.latest_regulation_data
|
||||
|
||||
def _handle_data(
|
||||
self,
|
||||
frame_type: str,
|
||||
data: dict[str, int | float | str],
|
||||
_rejected: dict[str, int | float],
|
||||
) -> None:
|
||||
if frame_type != self.entity_description.frame_type:
|
||||
return
|
||||
self._has_received_frame = True
|
||||
if self.entity_description.key in data:
|
||||
self._last_value = data[self.entity_description.key]
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"title": "Arkteos",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configurer Arkteos",
|
||||
"description": "Renseignez le proxy TCP Arkteos.",
|
||||
"data": {
|
||||
"host": "Hôte du proxy",
|
||||
"port": "Port du proxy"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigurer Arkteos",
|
||||
"description": "Modifiez l'hôte ou le port du proxy.",
|
||||
"data": {
|
||||
"host": "Hôte du proxy",
|
||||
"port": "Port du proxy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Impossible de se connecter au proxy.",
|
||||
"invalid_host": "L'hôte est invalide.",
|
||||
"invalid_port": "Le port doit être compris entre 1 et 65535.",
|
||||
"timeout": "Aucune trame valide n'a été reçue avant expiration du délai.",
|
||||
"unknown": "Erreur inattendue."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Ce proxy est déjà configuré.",
|
||||
"reconfigure_successful": "Arkteos a été reconfiguré."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"connection": {
|
||||
"name": "PAC connectée"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"exterieur_temp": {"name": "Température extérieure"},
|
||||
"nb_degivrages": {"name": "Nombre de dégivrages"},
|
||||
"temps_compresseur": {"name": "Temps fonctionnement compresseur"},
|
||||
"nb_cycles_compresseur": {"name": "Nombre de cycles compresseur"},
|
||||
"freq_comp_actuelle": {"name": "Fréquence compresseur actuelle"},
|
||||
"freq_comp_cible": {"name": "Fréquence compresseur cible"},
|
||||
"fan_speed_evaporator_1": {"name": "Vitesse ventilateur groupe frigo"},
|
||||
"dc_voltage": {"name": "Tension DC compresseur"},
|
||||
"statut_frigo_s": {"name": "Statut frigorifique"},
|
||||
"statut_frigo": {"name": "Code statut frigorifique"},
|
||||
"active_error_fri": {"name": "Erreur active frigorifique"},
|
||||
"puissance_inst_produite": {"name": "Puissance instantanée produite"},
|
||||
"puissance_inst_consommee": {"name": "Puissance instantanée consommée"},
|
||||
"temps_mise_sous_tension": {"name": "Temps mise sous tension"},
|
||||
"modele_pac_s": {"name": "Modèle PAC"},
|
||||
"modele_pac": {"name": "Code modèle PAC"},
|
||||
"primaire_temp_eau_aller_consigne": {"name": "Consigne départ eau primaire"},
|
||||
"primaire_temp_eau_aller": {"name": "Température eau primaire aller"},
|
||||
"primaire_temp_eau_retour": {"name": "Température eau primaire retour"},
|
||||
"primaire_debit_eau": {"name": "Débit eau primaire"},
|
||||
"primaire_pression": {"name": "Pression eau primaire"},
|
||||
"primaire_circulateur_consigne": {"name": "Circulateur primaire"},
|
||||
"zone1_temp_interieur": {"name": "Température intérieure zone 1"},
|
||||
"zone1_consigne": {"name": "Consigne température zone 1"},
|
||||
"ecs_temp_eau_milieu": {"name": "Température ballon ECS milieu"},
|
||||
"ecs_temp_eau_bas": {"name": "Température ballon ECS bas"},
|
||||
"ecs_consigne": {"name": "Consigne ECS"},
|
||||
"nb_cycles_compresseur_reg": {"name": "Cycles compresseur régulation"},
|
||||
"statut_pac_s": {"name": "Statut PAC"},
|
||||
"statut_pac": {"name": "Code statut PAC"},
|
||||
"active_error_reg": {"name": "Erreur active régulation"},
|
||||
"signal_rf_sonde_1": {"name": "Signal RF sonde zone 1"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"title": "Arkteos",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configurer Arkteos",
|
||||
"description": "Renseignez le proxy TCP Arkteos.",
|
||||
"data": {
|
||||
"host": "Hôte du proxy",
|
||||
"port": "Port du proxy"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigurer Arkteos",
|
||||
"description": "Modifiez l'hôte ou le port du proxy.",
|
||||
"data": {
|
||||
"host": "Hôte du proxy",
|
||||
"port": "Port du proxy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Impossible de se connecter au proxy.",
|
||||
"invalid_host": "L'hôte est invalide.",
|
||||
"invalid_port": "Le port doit être compris entre 1 et 65535.",
|
||||
"timeout": "Aucune trame valide n'a été reçue avant expiration du délai.",
|
||||
"unknown": "Erreur inattendue."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Ce proxy est déjà configuré.",
|
||||
"reconfigure_successful": "Arkteos a été reconfiguré."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"connection": {
|
||||
"name": "PAC connectée"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"exterieur_temp": {"name": "Température extérieure"},
|
||||
"nb_degivrages": {"name": "Nombre de dégivrages"},
|
||||
"temps_compresseur": {"name": "Temps fonctionnement compresseur"},
|
||||
"nb_cycles_compresseur": {"name": "Nombre de cycles compresseur"},
|
||||
"freq_comp_actuelle": {"name": "Fréquence compresseur actuelle"},
|
||||
"freq_comp_cible": {"name": "Fréquence compresseur cible"},
|
||||
"fan_speed_evaporator_1": {"name": "Vitesse ventilateur groupe frigo"},
|
||||
"dc_voltage": {"name": "Tension DC compresseur"},
|
||||
"statut_frigo_s": {"name": "Statut frigorifique"},
|
||||
"statut_frigo": {"name": "Code statut frigorifique"},
|
||||
"active_error_fri": {"name": "Erreur active frigorifique"},
|
||||
"puissance_inst_produite": {"name": "Puissance instantanée produite"},
|
||||
"puissance_inst_consommee": {"name": "Puissance instantanée consommée"},
|
||||
"temps_mise_sous_tension": {"name": "Temps mise sous tension"},
|
||||
"modele_pac_s": {"name": "Modèle PAC"},
|
||||
"modele_pac": {"name": "Code modèle PAC"},
|
||||
"primaire_temp_eau_aller_consigne": {"name": "Consigne départ eau primaire"},
|
||||
"primaire_temp_eau_aller": {"name": "Température eau primaire aller"},
|
||||
"primaire_temp_eau_retour": {"name": "Température eau primaire retour"},
|
||||
"primaire_debit_eau": {"name": "Débit eau primaire"},
|
||||
"primaire_pression": {"name": "Pression eau primaire"},
|
||||
"primaire_circulateur_consigne": {"name": "Circulateur primaire"},
|
||||
"zone1_temp_interieur": {"name": "Température intérieure zone 1"},
|
||||
"zone1_consigne": {"name": "Consigne température zone 1"},
|
||||
"ecs_temp_eau_milieu": {"name": "Température ballon ECS milieu"},
|
||||
"ecs_temp_eau_bas": {"name": "Température ballon ECS bas"},
|
||||
"ecs_consigne": {"name": "Consigne ECS"},
|
||||
"nb_cycles_compresseur_reg": {"name": "Cycles compresseur régulation"},
|
||||
"statut_pac_s": {"name": "Statut PAC"},
|
||||
"statut_pac": {"name": "Code statut PAC"},
|
||||
"active_error_reg": {"name": "Erreur active régulation"},
|
||||
"signal_rf_sonde_1": {"name": "Signal RF sonde zone 1"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
@@ -0,0 +1 @@
|
||||
pytest-homeassistant-custom-component==0.13.205
|
||||
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,69 @@
|
||||
"""Tests de l'unique binary sensor Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from custom_components.arkteos.binary_sensor import ArkteosConnectionBinarySensor
|
||||
from custom_components.arkteos.const import DOMAIN
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, available: bool = False) -> None:
|
||||
self.available = available
|
||||
self.callbacks: list[object] = []
|
||||
|
||||
def add_availability_callback(self, callback) -> None:
|
||||
self.callbacks.append(callback)
|
||||
|
||||
def remove_availability_callback(self, callback) -> None:
|
||||
self.callbacks.remove(callback)
|
||||
|
||||
|
||||
def test_binary_sensor_is_created() -> None:
|
||||
assert ArkteosConnectionBinarySensor(FakeClient()) is not None
|
||||
|
||||
|
||||
def test_unique_id_is_stable() -> None:
|
||||
assert ArkteosConnectionBinarySensor(FakeClient()).unique_id == "arkteos_zuran4_connection"
|
||||
|
||||
|
||||
def test_device_info_is_correct() -> None:
|
||||
info = ArkteosConnectionBinarySensor(FakeClient()).device_info
|
||||
assert info["identifiers"] == {(DOMAIN, "arkteos_zuran4")}
|
||||
assert info["manufacturer"] == "Arkteos"
|
||||
assert info["model"] == "Zuran 4"
|
||||
|
||||
|
||||
def test_is_on_when_client_is_available() -> None:
|
||||
assert ArkteosConnectionBinarySensor(FakeClient(True)).is_on
|
||||
|
||||
|
||||
def test_is_off_when_client_is_unavailable() -> None:
|
||||
assert not ArkteosConnectionBinarySensor(FakeClient(False)).is_on
|
||||
|
||||
|
||||
def test_availability_callback_writes_state() -> None:
|
||||
client = FakeClient()
|
||||
entity = ArkteosConnectionBinarySensor(client)
|
||||
entity.async_write_ha_state = Mock()
|
||||
entity._handle_availability(True)
|
||||
entity.async_write_ha_state.assert_called_once()
|
||||
|
||||
|
||||
def test_callback_is_removed_before_entity_removal() -> None:
|
||||
client = FakeClient()
|
||||
entity = ArkteosConnectionBinarySensor(client)
|
||||
client.add_availability_callback(entity._handle_availability)
|
||||
client.remove_availability_callback(entity._handle_availability)
|
||||
assert client.callbacks == []
|
||||
|
||||
|
||||
def test_no_polling_method_is_declared() -> None:
|
||||
entity = ArkteosConnectionBinarySensor(FakeClient())
|
||||
assert not hasattr(entity, "async_update")
|
||||
|
||||
|
||||
def test_binary_sensor_is_read_only() -> None:
|
||||
source = ArkteosConnectionBinarySensor.__module__
|
||||
assert source == "custom_components.arkteos.binary_sensor"
|
||||
@@ -0,0 +1,523 @@
|
||||
"""Tests sans réseau réel du client TCP asynchrone Arkteos."""
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Deque
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.arkteos.client import ArkteosClient, DEFAULT_PROXY_PORT
|
||||
|
||||
|
||||
FIXTURES_PATH = Path(__file__).with_name("fixtures")
|
||||
|
||||
|
||||
class FakeWriter:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.wait_closed_calls = 0
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
self.wait_closed_calls += 1
|
||||
|
||||
|
||||
class ControlledReader:
|
||||
def __init__(self, chunks: list[bytes] | None = None) -> None:
|
||||
self._chunks: Deque[bytes] = deque(chunks or [])
|
||||
self._data_ready = asyncio.Event()
|
||||
if self._chunks:
|
||||
self._data_ready.set()
|
||||
|
||||
def push(self, data: bytes) -> None:
|
||||
self._chunks.append(data)
|
||||
self._data_ready.set()
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
while not self._chunks:
|
||||
await self._data_ready.wait()
|
||||
self._data_ready.clear()
|
||||
return self._chunks.popleft()
|
||||
|
||||
|
||||
def _load_frames() -> dict[str, bytes]:
|
||||
return {
|
||||
"metadata": (FIXTURES_PATH / "metadata_95.bin").read_bytes(),
|
||||
"frigo": (FIXTURES_PATH / "frigo_163.bin").read_bytes(),
|
||||
"regulation": (FIXTURES_PATH / "regulation_227.bin").read_bytes(),
|
||||
}
|
||||
|
||||
|
||||
async def _wait_for(predicate: object, attempts: int = 200) -> None:
|
||||
for _ in range(attempts):
|
||||
if callable(predicate) and predicate():
|
||||
return
|
||||
await asyncio.sleep(0.001)
|
||||
raise AssertionError("condition asynchrone non atteinte")
|
||||
|
||||
|
||||
def _connection_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
outcomes: list[tuple[ControlledReader, FakeWriter] | Exception],
|
||||
) -> None:
|
||||
pending = deque(outcomes)
|
||||
|
||||
async def open_connection(_host: str, _port: int) -> tuple[ControlledReader, FakeWriter]:
|
||||
if not pending:
|
||||
raise ConnectionRefusedError("aucune connexion factice disponible")
|
||||
outcome = pending.popleft()
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_connection", open_connection)
|
||||
|
||||
|
||||
def test_default_port() -> None:
|
||||
assert ArkteosClient("proxy.example").port == DEFAULT_PROXY_PORT
|
||||
|
||||
|
||||
def test_successful_connection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
reader = ControlledReader()
|
||||
writer = FakeWriter()
|
||||
_connection_factory(monkeypatch, [(reader, writer)])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
assert await client.wait_until_connected(0.1)
|
||||
await client.stop()
|
||||
assert writer.closed
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_connection_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
_connection_factory(monkeypatch, [ConnectionRefusedError("refus")])
|
||||
|
||||
async def pause(_delay: float) -> None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client = ArkteosClient("proxy.example", reconnect_delays=(1,), sleep=pause)
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.connection_attempts >= 1)
|
||||
await client.stop()
|
||||
assert not client.connected
|
||||
assert isinstance(client.last_error, ConnectionRefusedError)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_progressive_reconnection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
_connection_factory(
|
||||
monkeypatch,
|
||||
[ConnectionRefusedError("1"), ConnectionRefusedError("2"), ConnectionRefusedError("3")],
|
||||
)
|
||||
delays: list[float] = []
|
||||
|
||||
async def pause(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client = ArkteosClient(
|
||||
"proxy.example", reconnect_delays=(1, 2, 5), sleep=pause
|
||||
)
|
||||
await client.start()
|
||||
await _wait_for(lambda: len(delays) >= 3)
|
||||
await client.stop()
|
||||
assert delays[:3] == [1, 2, 5]
|
||||
assert client.reconnect_count >= 3
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_complete_frame_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
reader = ControlledReader([metadata])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 1)
|
||||
assert client.last_frame_type == "metadata"
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_fragmented_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
frigo = _load_frames()["frigo"]
|
||||
reader = ControlledReader([frigo[:70], frigo[70:]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 1)
|
||||
assert client.latest_frigo_data is not None
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_multiple_frames_in_one_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
frames = _load_frames()
|
||||
reader = ControlledReader([frames["metadata"] + frames["frigo"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 2)
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_metadata_data(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
reader = ControlledReader([_load_frames()["metadata"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.latest_metadata is not None)
|
||||
assert client.latest_metadata == {"frame_type": "metadata"}
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_frigo_data(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
reader = ControlledReader([_load_frames()["frigo"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.latest_frigo_data is not None)
|
||||
assert client.latest_frigo_data["frame_type"] == "frigo"
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_regulation_data(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
reader = ControlledReader([_load_frames()["regulation"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.latest_regulation_data is not None)
|
||||
assert client.latest_regulation_data["frame_type"] == "regulation"
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_invalid_frame_is_rejected_without_stopping(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class InvalidExtractor:
|
||||
def feed(self, _data: bytes) -> list[bytes]:
|
||||
return [b"invalid"]
|
||||
|
||||
async def scenario() -> None:
|
||||
reader = ControlledReader([b"source"])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
client._extractor = InvalidExtractor() # type: ignore[assignment]
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_rejected == 1)
|
||||
assert client.running
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_bytes_received_counter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
reader = ControlledReader([metadata])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.bytes_received == len(metadata))
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_frames_received_counter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
frames = _load_frames()
|
||||
reader = ControlledReader([frames["frigo"] + frames["regulation"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 2)
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_frames_rejected_counter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class RaisingExtractor:
|
||||
def feed(self, _data: bytes) -> list[bytes]:
|
||||
raise ValueError("rejet")
|
||||
|
||||
async def scenario() -> None:
|
||||
reader = ControlledReader([b"source"])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
client._extractor = RaisingExtractor() # type: ignore[assignment]
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_rejected == 1)
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_latest_data_updates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
frames = _load_frames()
|
||||
reader = ControlledReader(
|
||||
[frames["metadata"] + frames["frigo"] + frames["regulation"]]
|
||||
)
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 3)
|
||||
assert client.last_frame_type == "regulation"
|
||||
assert client.latest_metadata is not None
|
||||
assert client.latest_frigo_data is not None
|
||||
assert client.latest_regulation_data is not None
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_synchronous_data_callback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
received: list[str] = []
|
||||
reader = ControlledReader([_load_frames()["frigo"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
client.add_data_callback(lambda frame_type, _accepted, _rejected: received.append(frame_type))
|
||||
await client.start()
|
||||
await _wait_for(lambda: received == ["frigo"])
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_asynchronous_data_callback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
received: list[str] = []
|
||||
|
||||
async def callback(frame_type: str, _accepted: dict[str, object], _rejected: dict[str, object]) -> None:
|
||||
received.append(frame_type)
|
||||
|
||||
reader = ControlledReader([_load_frames()["regulation"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
client.add_data_callback(callback)
|
||||
await client.start()
|
||||
await _wait_for(lambda: received == ["regulation"])
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_callback_exception_does_not_stop_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
def callback(_frame_type: str, _accepted: object, _rejected: object) -> None:
|
||||
raise RuntimeError("callback")
|
||||
|
||||
reader = ControlledReader([_load_frames()["metadata"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
client.add_data_callback(callback)
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 1)
|
||||
assert client.running
|
||||
assert isinstance(client.last_error, RuntimeError)
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_availability_true_after_valid_frame(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
states: list[bool] = []
|
||||
reader = ControlledReader([_load_frames()["metadata"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
client.add_availability_callback(states.append)
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.available)
|
||||
assert states == [True]
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_availability_false_after_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
states: list[bool] = []
|
||||
reader = ControlledReader([_load_frames()["metadata"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example", frame_timeout=0.01)
|
||||
client.add_availability_callback(states.append)
|
||||
await client.start()
|
||||
await _wait_for(lambda: states == [True, False])
|
||||
assert not client.available
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_availability_restored_after_new_frame(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
states: list[bool] = []
|
||||
frames = _load_frames()
|
||||
reader = ControlledReader([frames["metadata"]])
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example", frame_timeout=0.01)
|
||||
client.add_availability_callback(states.append)
|
||||
await client.start()
|
||||
await _wait_for(lambda: states == [True, False])
|
||||
reader.push(frames["frigo"])
|
||||
await _wait_for(lambda: states == [True, False, True])
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_remote_disconnection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
writer = FakeWriter()
|
||||
reader = ControlledReader([b""])
|
||||
_connection_factory(monkeypatch, [(reader, writer), ConnectionRefusedError("fin")])
|
||||
|
||||
async def pause(_delay: float) -> None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client = ArkteosClient("proxy.example", reconnect_delays=(1,), sleep=pause)
|
||||
await client.start()
|
||||
await _wait_for(lambda: writer.closed)
|
||||
assert not client.connected
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_writer_is_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
writer = FakeWriter()
|
||||
_connection_factory(monkeypatch, [(ControlledReader(), writer)])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await client.wait_until_connected(0.1)
|
||||
await client.stop()
|
||||
assert writer.closed
|
||||
assert writer.wait_closed_calls == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stop_during_reconnect_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
_connection_factory(monkeypatch, [ConnectionRefusedError("refus")])
|
||||
sleeping = asyncio.Event()
|
||||
|
||||
async def pause(_delay: float) -> None:
|
||||
sleeping.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
client = ArkteosClient("proxy.example", sleep=pause)
|
||||
await client.start()
|
||||
await _wait_for(sleeping.is_set)
|
||||
await client.stop()
|
||||
assert not client.running
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_stop_during_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
writer = FakeWriter()
|
||||
_connection_factory(monkeypatch, [(ControlledReader(), writer)])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await client.wait_until_connected(0.1)
|
||||
await client.stop()
|
||||
assert writer.closed
|
||||
assert not client.connected
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_double_start_does_not_create_two_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
_connection_factory(monkeypatch, [(ControlledReader(), FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
task = client._task
|
||||
await client.start()
|
||||
assert client._task is task
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_double_stop_is_safe(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
_connection_factory(monkeypatch, [(ControlledReader(), FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await client.stop()
|
||||
await client.stop()
|
||||
assert not client.running
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_client_has_no_socket_write_operations(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
writer = FakeWriter()
|
||||
_connection_factory(monkeypatch, [(ControlledReader([_load_frames()["metadata"]]), writer)])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 1)
|
||||
await client.stop()
|
||||
assert not hasattr(writer, "write")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_no_client_task_remains_after_stop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
_connection_factory(monkeypatch, [(ControlledReader(), FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
task = client._task
|
||||
await client.stop()
|
||||
assert client._task is None
|
||||
assert task is not None and task.done()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_all_real_fixtures(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def scenario() -> None:
|
||||
frames = _load_frames()
|
||||
reader = ControlledReader(
|
||||
[frames["metadata"] + frames["frigo"] + frames["regulation"]]
|
||||
)
|
||||
_connection_factory(monkeypatch, [(reader, FakeWriter())])
|
||||
client = ArkteosClient("proxy.example")
|
||||
await client.start()
|
||||
await _wait_for(lambda: client.frames_received == 3)
|
||||
assert client.last_frame_type == "regulation"
|
||||
await client.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests hors ligne du flux de configuration Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.arkteos.const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
|
||||
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("enable_custom_integrations")
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Client factice pour valider le flux sans ouvrir de socket."""
|
||||
|
||||
connected_result = True
|
||||
valid_frame = True
|
||||
instances: list["FakeClient"] = []
|
||||
|
||||
def __init__(self, _host: str, _port: int) -> None:
|
||||
self.connected = False
|
||||
self.last_valid_frame = None
|
||||
self.last_error = None
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
self.connected = self.__class__.connected_result
|
||||
if self.__class__.valid_frame:
|
||||
self.last_valid_frame = object()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
async def wait_until_connected(self, _timeout: float | None = None) -> bool:
|
||||
return self.__class__.connected_result
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from custom_components.arkteos import config_flow
|
||||
|
||||
FakeClient.connected_result = True
|
||||
FakeClient.valid_frame = True
|
||||
FakeClient.instances.clear()
|
||||
monkeypatch.setattr(config_flow, "ArkteosClient", FakeClient)
|
||||
|
||||
|
||||
async def _start_user_flow(hass):
|
||||
return await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
||||
|
||||
|
||||
async def test_user_form_is_shown(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
async def test_default_port_is_9641(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
assert result["data_schema"]({CONF_HOST: "proxy.local"})[CONF_PORT] == DEFAULT_PORT
|
||||
|
||||
|
||||
async def test_empty_host_is_refused(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "", CONF_PORT: DEFAULT_PORT}
|
||||
)
|
||||
assert result["errors"][CONF_HOST] == "invalid_host"
|
||||
|
||||
|
||||
async def test_port_zero_is_refused(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: 0}
|
||||
)
|
||||
assert result["errors"][CONF_PORT] == "invalid_port"
|
||||
|
||||
|
||||
async def test_port_above_range_is_refused(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: 65536}
|
||||
)
|
||||
assert result["errors"][CONF_PORT] == "invalid_port"
|
||||
|
||||
|
||||
async def test_cannot_connect_is_reported(hass) -> None:
|
||||
FakeClient.connected_result = False
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: DEFAULT_PORT}
|
||||
)
|
||||
assert result["errors"]["base"] == "cannot_connect"
|
||||
assert FakeClient.instances[-1].stopped
|
||||
|
||||
|
||||
async def test_validation_timeout_is_reported(hass, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from custom_components.arkteos import config_flow
|
||||
|
||||
FakeClient.valid_frame = False
|
||||
monkeypatch.setattr(config_flow, "VALIDATION_TIMEOUT", 0.001)
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: DEFAULT_PORT}
|
||||
)
|
||||
assert result["errors"]["base"] == "timeout"
|
||||
assert FakeClient.instances[-1].stopped
|
||||
|
||||
|
||||
async def test_connection_and_valid_frame_create_entry(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: DEFAULT_PORT}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Arkteos proxy.local:9641"
|
||||
|
||||
|
||||
async def test_created_entry_contains_host_and_port(hass) -> None:
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: 9999}
|
||||
)
|
||||
assert result["data"] == {CONF_HOST: "proxy.local", CONF_PORT: 9999}
|
||||
|
||||
|
||||
async def test_duplicate_host_and_port_are_refused(hass) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "proxy.local", CONF_PORT: DEFAULT_PORT})
|
||||
entry.add_to_hass(hass)
|
||||
result = await _start_user_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "proxy.local", CONF_PORT: DEFAULT_PORT}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_reconfigure_succeeds(hass, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from custom_components.arkteos import config_flow
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "old.local", CONF_PORT: DEFAULT_PORT})
|
||||
entry.add_to_hass(hass)
|
||||
update_entry = Mock(wraps=hass.config_entries.async_update_entry)
|
||||
reload_entry = AsyncMock(return_value=True)
|
||||
|
||||
def create_fake_client(host: str, port: int) -> FakeClient:
|
||||
client = FakeClient(host, port)
|
||||
client.wait_until_connected = AsyncMock(wraps=client.wait_until_connected)
|
||||
return client
|
||||
|
||||
client_factory = Mock(side_effect=create_fake_client)
|
||||
monkeypatch.setattr(hass.config_entries, "async_update_entry", update_entry)
|
||||
monkeypatch.setattr(hass.config_entries, "async_reload", reload_entry)
|
||||
monkeypatch.setattr(config_flow, "ArkteosClient", client_factory)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "new.local", CONF_PORT: 9999}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert update_entry.call_count == 1
|
||||
_, update_kwargs = update_entry.call_args
|
||||
assert update_kwargs["entry"] is entry
|
||||
assert update_kwargs["data"] == {CONF_HOST: "new.local", CONF_PORT: 9999}
|
||||
assert update_kwargs["title"] == "Arkteos new.local:9999"
|
||||
reload_entry.assert_awaited_once_with(entry.entry_id)
|
||||
client_factory.assert_called_once_with("new.local", 9999)
|
||||
assert len(FakeClient.instances) == 1
|
||||
client = FakeClient.instances[0]
|
||||
assert client.started
|
||||
client.wait_until_connected.assert_awaited_once()
|
||||
assert client.last_valid_frame is not None
|
||||
assert client.stopped
|
||||
assert not any(
|
||||
task.get_name() == "arkteos-client" and not task.done()
|
||||
for task in __import__("asyncio").all_tasks()
|
||||
)
|
||||
|
||||
|
||||
async def test_invalid_reconfigure_is_refused(hass) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "old.local", CONF_PORT: DEFAULT_PORT})
|
||||
entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "", CONF_PORT: DEFAULT_PORT}
|
||||
)
|
||||
assert result["errors"][CONF_HOST] == "invalid_host"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests hors ligne des diagnostics Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.arkteos.client import ArkteosClient
|
||||
from custom_components.arkteos.const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
|
||||
from custom_components.arkteos.diagnostics import async_get_config_entry_diagnostics
|
||||
from custom_components.arkteos.parser import filter_values, parse_frame
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
HOST = "arkteos.local"
|
||||
|
||||
|
||||
def _entry() -> MockConfigEntry:
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Arkteos arkteos.local:9641",
|
||||
data={CONF_HOST: "arkteos.local", CONF_PORT: DEFAULT_PORT},
|
||||
)
|
||||
|
||||
|
||||
def _client() -> ArkteosClient:
|
||||
return ArkteosClient(HOST, DEFAULT_PORT)
|
||||
|
||||
|
||||
def _values(name: str) -> dict[str, int | float | str]:
|
||||
return filter_values(parse_frame((FIXTURES / name).read_bytes())).accepted
|
||||
|
||||
|
||||
async def test_diagnostics_are_redacted_and_serializable(hass) -> None:
|
||||
entry = _entry()
|
||||
client = _client()
|
||||
client.available = True
|
||||
client.running = True
|
||||
client.connected = True
|
||||
client.bytes_received = 123
|
||||
client.frames_received = 4
|
||||
client.frames_rejected = 2
|
||||
client.reconnect_count = 1
|
||||
entry.runtime_data = client
|
||||
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert isinstance(diagnostics, dict)
|
||||
assert diagnostics["integration"]["domain"] == DOMAIN
|
||||
assert diagnostics["integration"]["version"] == "0.1.0"
|
||||
assert diagnostics["integration"]["configuration"][CONF_PORT] == DEFAULT_PORT
|
||||
assert HOST not in json.dumps(diagnostics)
|
||||
assert diagnostics["client"] == {
|
||||
"available": True,
|
||||
"running": True,
|
||||
"connected": True,
|
||||
"bytes_received": 123,
|
||||
"frames_received": 4,
|
||||
"frames_rejected": 2,
|
||||
"reconnect_count": 1,
|
||||
"last_frame_type": None,
|
||||
}
|
||||
assert diagnostics["received_data"] == {
|
||||
"metadata": {"received": False, "data": {}},
|
||||
"frigo": {"received": False, "data": {}},
|
||||
"regulation": {"received": False, "data": {}},
|
||||
}
|
||||
json.dumps(diagnostics)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attribute", "fixture_name", "section"),
|
||||
(
|
||||
("latest_metadata", "metadata_95.bin", "metadata"),
|
||||
("latest_frigo_data", "frigo_163.bin", "frigo"),
|
||||
("latest_regulation_data", "regulation_227.bin", "regulation"),
|
||||
),
|
||||
)
|
||||
async def test_decoded_data_is_separated_without_raw_frames(
|
||||
hass, attribute: str, fixture_name: str, section: str
|
||||
) -> None:
|
||||
entry = _entry()
|
||||
client = _client()
|
||||
setattr(client, attribute, _values(fixture_name))
|
||||
entry.runtime_data = client
|
||||
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert diagnostics["received_data"][section]["received"]
|
||||
assert diagnostics["received_data"][section]["data"] == getattr(client, attribute)
|
||||
assert all(
|
||||
not values["received"]
|
||||
for name, values in diagnostics["received_data"].items()
|
||||
if name != section
|
||||
)
|
||||
assert (FIXTURES / fixture_name).read_bytes() not in json.dumps(diagnostics).encode()
|
||||
|
||||
|
||||
async def test_fallback_hass_data_and_missing_optional_attributes(hass) -> None:
|
||||
entry = SimpleNamespace(
|
||||
entry_id="fallback-entry",
|
||||
title=f"Arkteos {HOST}:9641",
|
||||
data={CONF_HOST: HOST},
|
||||
)
|
||||
client = _client()
|
||||
del client.connected
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
|
||||
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert diagnostics["client"]["available"] is False
|
||||
assert "connected" not in diagnostics["client"]
|
||||
assert diagnostics["integration"]["configuration"][CONF_PORT] == DEFAULT_PORT
|
||||
assert HOST not in json.dumps(diagnostics)
|
||||
|
||||
|
||||
async def test_sensitive_and_nonserializable_values_are_not_exposed(hass) -> None:
|
||||
entry = _entry()
|
||||
client = _client()
|
||||
client.latest_frigo_data = {
|
||||
"frame_type": "frigo",
|
||||
"exterieur_temp": 12.3,
|
||||
"token": "secret-token",
|
||||
"password": "secret-password",
|
||||
"raw": b"raw-frame",
|
||||
"reader": object(),
|
||||
}
|
||||
client._writer = object()
|
||||
entry.runtime_data = client
|
||||
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
serialized = json.dumps(diagnostics)
|
||||
assert "secret-token" not in serialized
|
||||
assert "secret-password" not in serialized
|
||||
assert "raw-frame" not in serialized
|
||||
assert "reader" not in diagnostics["received_data"]["frigo"]["data"]
|
||||
assert "writer" not in serialized
|
||||
assert "_task" not in serialized
|
||||
|
||||
|
||||
async def test_diagnostics_do_not_start_stop_or_connect_client(hass, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
entry = _entry()
|
||||
client = _client()
|
||||
entry.runtime_data = client
|
||||
|
||||
async def forbidden(*_args, **_kwargs) -> None:
|
||||
raise AssertionError("accès réseau ou cycle de vie interdit")
|
||||
|
||||
monkeypatch.setattr(client, "start", forbidden)
|
||||
monkeypatch.setattr(client, "stop", forbidden)
|
||||
diagnostics = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert diagnostics["client"]["frames_rejected"] == 0
|
||||
assert "rejected_values" not in diagnostics
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests de la base d'entité Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from custom_components.arkteos.entity import ArkteosEntity
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, available: bool = False) -> None:
|
||||
self.available = available
|
||||
self.callbacks: list[object] = []
|
||||
|
||||
def add_availability_callback(self, callback) -> None:
|
||||
self.callbacks.append(callback)
|
||||
|
||||
def remove_availability_callback(self, callback) -> None:
|
||||
self.callbacks.remove(callback)
|
||||
|
||||
|
||||
def test_base_entity_availability_reflects_client() -> None:
|
||||
assert ArkteosEntity(FakeClient(True)).available
|
||||
assert not ArkteosEntity(FakeClient(False)).available
|
||||
|
||||
|
||||
def test_availability_callback_writes_state() -> None:
|
||||
entity = ArkteosEntity(FakeClient())
|
||||
entity.async_write_ha_state = Mock()
|
||||
entity._handle_availability(True)
|
||||
entity.async_write_ha_state.assert_called_once()
|
||||
|
||||
|
||||
def test_base_entity_has_no_polling_method() -> None:
|
||||
assert not hasattr(ArkteosEntity(FakeClient()), "async_update")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests de l'extracteur incrémental REG3, indépendants de Home Assistant."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.arkteos.frame_extractor import FrameExtractor
|
||||
|
||||
|
||||
FIXTURES_PATH = Path(__file__).with_name("fixtures")
|
||||
|
||||
|
||||
def _load_frames() -> dict[str, bytes]:
|
||||
return {
|
||||
"metadata": (FIXTURES_PATH / "metadata_95.bin").read_bytes(),
|
||||
"frigo": (FIXTURES_PATH / "frigo_163.bin").read_bytes(),
|
||||
"regulation": (FIXTURES_PATH / "regulation_227.bin").read_bytes(),
|
||||
}
|
||||
|
||||
|
||||
def test_no_bytes() -> None:
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(b"") == []
|
||||
assert extractor.buffered_bytes == b""
|
||||
|
||||
|
||||
def test_complete_metadata_frame() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
assert FrameExtractor().feed(metadata) == [metadata]
|
||||
|
||||
|
||||
def test_complete_frigo_frame() -> None:
|
||||
frigo = _load_frames()["frigo"]
|
||||
assert FrameExtractor().feed(frigo) == [frigo]
|
||||
|
||||
|
||||
def test_complete_regulation_frame() -> None:
|
||||
regulation = _load_frames()["regulation"]
|
||||
assert FrameExtractor().feed(regulation) == [regulation]
|
||||
|
||||
|
||||
def test_frame_split_in_two_calls() -> None:
|
||||
frigo = _load_frames()["frigo"]
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(frigo[:80]) == []
|
||||
assert extractor.feed(frigo[80:]) == [frigo]
|
||||
|
||||
|
||||
def test_frame_provided_byte_by_byte() -> None:
|
||||
regulation = _load_frames()["regulation"]
|
||||
extractor = FrameExtractor()
|
||||
extracted = [frame for byte in regulation for frame in extractor.feed(bytes([byte]))]
|
||||
assert extracted == [regulation]
|
||||
|
||||
|
||||
def test_multiple_concatenated_frames() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["metadata"] + frames["frigo"] + frames["regulation"]
|
||||
assert FrameExtractor().feed(sequence) == [
|
||||
frames["metadata"],
|
||||
frames["frigo"],
|
||||
frames["regulation"],
|
||||
]
|
||||
|
||||
|
||||
def test_order_different_from_observed_cycle() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["frigo"] + frames["metadata"] + frames["regulation"]
|
||||
assert FrameExtractor().feed(sequence) == [
|
||||
frames["frigo"],
|
||||
frames["metadata"],
|
||||
frames["regulation"],
|
||||
]
|
||||
|
||||
|
||||
def test_noise_before_signature() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
assert FrameExtractor().feed(b"parasites" + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_signature_split_between_calls() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(b"parasites\x55") == []
|
||||
assert extractor.buffered_bytes == b"\x55"
|
||||
assert extractor.feed(metadata[1:]) == [metadata]
|
||||
|
||||
|
||||
def test_incoherent_type_header_resynchronizes() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
invalid = bytearray(metadata)
|
||||
invalid[8] = 0x0A
|
||||
assert FrameExtractor().feed(bytes(invalid) + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_unknown_length_header_resynchronizes() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
invalid = bytearray(metadata)
|
||||
invalid[10:12] = (81).to_bytes(2, "little")
|
||||
assert FrameExtractor().feed(bytes(invalid) + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_announced_size_above_limit_resynchronizes() -> None:
|
||||
frames = _load_frames()
|
||||
extractor = FrameExtractor(max_buffer_size=200)
|
||||
assert extractor.feed(frames["regulation"] + frames["metadata"]) == [
|
||||
frames["metadata"]
|
||||
]
|
||||
|
||||
|
||||
def test_false_signature_in_noise() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
false_header = b"\x55\x00" + b"\x00" * 10
|
||||
assert FrameExtractor().feed(b"\x10" + false_header + metadata) == [metadata]
|
||||
|
||||
|
||||
def test_complete_frame_followed_by_incomplete_frame() -> None:
|
||||
frames = _load_frames()
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(frames["metadata"] + frames["frigo"][:20]) == [
|
||||
frames["metadata"]
|
||||
]
|
||||
assert extractor.buffered_bytes == frames["frigo"][:20]
|
||||
assert extractor.feed(frames["frigo"][20:]) == [frames["frigo"]]
|
||||
|
||||
|
||||
def test_variable_size_fragments() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["frigo"] + frames["metadata"] + frames["regulation"]
|
||||
extractor = FrameExtractor()
|
||||
extracted: list[bytes] = []
|
||||
fragment_sizes = (1, 7, 31, 2, 83, 19, 11, 131)
|
||||
offset = 0
|
||||
for size in fragment_sizes:
|
||||
extracted.extend(extractor.feed(sequence[offset : offset + size]))
|
||||
offset += size
|
||||
extracted.extend(extractor.feed(sequence[offset:]))
|
||||
assert extracted == [frames["frigo"], frames["metadata"], frames["regulation"]]
|
||||
|
||||
|
||||
def test_buffer_is_empty_after_complete_extraction() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
extractor = FrameExtractor()
|
||||
extractor.feed(metadata)
|
||||
assert extractor.buffered_size == 0
|
||||
|
||||
|
||||
def test_incomplete_buffer_is_preserved() -> None:
|
||||
metadata = _load_frames()["metadata"]
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(metadata[:10]) == []
|
||||
assert extractor.buffered_bytes == metadata[:10]
|
||||
|
||||
|
||||
def test_repeated_invalid_data_terminates() -> None:
|
||||
invalid_header = b"\x55\x00" + b"\xff" * 10
|
||||
extractor = FrameExtractor()
|
||||
assert extractor.feed(invalid_header * 100) == []
|
||||
assert extractor.buffered_size == 0
|
||||
|
||||
|
||||
def test_sequence_with_all_real_fixtures() -> None:
|
||||
frames = _load_frames()
|
||||
sequence = frames["regulation"] + frames["frigo"] + frames["metadata"]
|
||||
assert FrameExtractor().feed(sequence) == [
|
||||
frames["regulation"],
|
||||
frames["frigo"],
|
||||
frames["metadata"],
|
||||
]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests du cycle de vie de la config entry Arkteos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.arkteos.const import CONF_HOST, CONF_PORT, DOMAIN
|
||||
|
||||
|
||||
class FakeClient:
|
||||
instances: list["FakeClient"] = []
|
||||
|
||||
def __init__(self, _host: str, _port: int) -> None:
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.available = False
|
||||
self._callbacks: list[object] = []
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
def add_availability_callback(self, callback) -> None:
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_availability_callback(self, callback) -> None:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
FakeClient.instances.clear()
|
||||
monkeypatch.setattr(integration, "ArkteosClient", FakeClient)
|
||||
|
||||
|
||||
def _entry() -> MockConfigEntry:
|
||||
return MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "proxy.local", CONF_PORT: 9641})
|
||||
|
||||
|
||||
async def test_setup_creates_one_client(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
entry = _entry()
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock()
|
||||
assert await integration.async_setup_entry(hass, entry)
|
||||
assert len(FakeClient.instances) == 1
|
||||
|
||||
|
||||
async def test_setup_starts_client(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock()
|
||||
assert await integration.async_setup_entry(hass, _entry())
|
||||
assert FakeClient.instances[0].started
|
||||
|
||||
|
||||
async def test_unload_stops_client(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
entry = _entry()
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock()
|
||||
hass.config_entries.async_unload_platforms = AsyncMock(return_value=True)
|
||||
await integration.async_setup_entry(hass, entry)
|
||||
assert await integration.async_unload_entry(hass, entry)
|
||||
assert FakeClient.instances[0].stopped
|
||||
|
||||
|
||||
async def test_unload_removes_runtime_data(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
entry = _entry()
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock()
|
||||
hass.config_entries.async_unload_platforms = AsyncMock(return_value=True)
|
||||
await integration.async_setup_entry(hass, entry)
|
||||
await integration.async_unload_entry(hass, entry)
|
||||
assert integration._get_client(hass, entry) is None
|
||||
|
||||
|
||||
async def test_platform_failure_stops_client(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock(side_effect=RuntimeError("platform"))
|
||||
assert not await integration.async_setup_entry(hass, _entry())
|
||||
assert FakeClient.instances[0].stopped
|
||||
|
||||
|
||||
async def test_double_setup_keeps_single_client(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
entry = _entry()
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock()
|
||||
await integration.async_setup_entry(hass, entry)
|
||||
await integration.async_setup_entry(hass, entry)
|
||||
assert len(FakeClient.instances) == 1
|
||||
|
||||
|
||||
async def test_double_unload_is_safe(hass) -> None:
|
||||
import custom_components.arkteos as integration
|
||||
|
||||
entry = _entry()
|
||||
hass.config_entries.async_forward_entry_setups = AsyncMock()
|
||||
hass.config_entries.async_unload_platforms = AsyncMock(return_value=True)
|
||||
await integration.async_setup_entry(hass, entry)
|
||||
assert await integration.async_unload_entry(hass, entry)
|
||||
assert await integration.async_unload_entry(hass, entry)
|
||||
|
||||
|
||||
def test_no_polling_platform_is_declared() -> None:
|
||||
from custom_components.arkteos.const import PLATFORMS
|
||||
|
||||
assert len(PLATFORMS) == 2
|
||||
assert {platform.value for platform in PLATFORMS} == {"binary_sensor", "sensor"}
|
||||
|
||||
|
||||
def test_no_mqtt_dependency_in_manifest() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
manifest = (Path(__file__).parents[1] / "custom_components" / "arkteos" / "manifest.json").read_text()
|
||||
assert "mqtt" not in manifest.lower()
|
||||
@@ -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 == {}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Tests hors ligne des capteurs Arkteos alimentés par callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.arkteos.const import DOMAIN
|
||||
from custom_components.arkteos.parser import filter_values, parse_frame
|
||||
from custom_components.arkteos.sensor import (
|
||||
FRIGO_SENSORS,
|
||||
FRIGO_STATUS_OPTIONS,
|
||||
PAC_STATUS_OPTIONS,
|
||||
REGULATION_SENSORS,
|
||||
SENSOR_DESCRIPTIONS,
|
||||
ArkteosSensor,
|
||||
async_setup_entry,
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Client en mémoire, sans réseau, pour les capteurs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.available = False
|
||||
self.latest_frigo_data: dict[str, int | float | str] | None = None
|
||||
self.latest_regulation_data: dict[str, int | float | str] | None = None
|
||||
self.data_callbacks: list[object] = []
|
||||
self.availability_callbacks: list[object] = []
|
||||
|
||||
def add_data_callback(self, callback) -> None:
|
||||
self.data_callbacks.append(callback)
|
||||
|
||||
def remove_data_callback(self, callback) -> None:
|
||||
self.data_callbacks.remove(callback)
|
||||
|
||||
def add_availability_callback(self, callback) -> None:
|
||||
self.availability_callbacks.append(callback)
|
||||
|
||||
def remove_availability_callback(self, callback) -> None:
|
||||
self.availability_callbacks.remove(callback)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
frame_type: str,
|
||||
data: dict[str, int | float | str],
|
||||
rejected: dict[str, int | float] | None = None,
|
||||
) -> None:
|
||||
if frame_type == "frigo":
|
||||
self.latest_frigo_data = dict(data)
|
||||
elif frame_type == "regulation":
|
||||
self.latest_regulation_data = dict(data)
|
||||
availability_changed = not self.available
|
||||
self.available = True
|
||||
if availability_changed:
|
||||
for callback in tuple(self.availability_callbacks):
|
||||
callback(True)
|
||||
for callback in tuple(self.data_callbacks):
|
||||
callback(frame_type, dict(data), rejected or {})
|
||||
|
||||
|
||||
def _frame_values(name: str) -> dict[str, int | float | str]:
|
||||
filtered = filter_values(parse_frame((FIXTURES / name).read_bytes()))
|
||||
return filtered.accepted
|
||||
|
||||
|
||||
def _sensors(client: FakeClient) -> list[ArkteosSensor]:
|
||||
return [ArkteosSensor(client, description) for description in SENSOR_DESCRIPTIONS]
|
||||
|
||||
|
||||
async def _add_sensors_to_hass(sensors: list[ArkteosSensor]) -> None:
|
||||
"""Exécute le cycle d'ajout qui inscrit les callbacks du client."""
|
||||
|
||||
for sensor in sensors:
|
||||
sensor.async_write_ha_state = Mock()
|
||||
await sensor.async_added_to_hass()
|
||||
|
||||
|
||||
def _sensor(sensors: list[ArkteosSensor], key: str) -> ArkteosSensor:
|
||||
return next(sensor for sensor in sensors if sensor.entity_description.key == key)
|
||||
|
||||
|
||||
def test_total_sensor_count_and_unique_ids() -> None:
|
||||
sensors = _sensors(FakeClient())
|
||||
assert len(sensors) == 32
|
||||
assert len({sensor.unique_id for sensor in sensors}) == len(sensors)
|
||||
|
||||
|
||||
def test_all_node_red_keys_are_described() -> None:
|
||||
assert {description.key for description in FRIGO_SENSORS} == {
|
||||
"exterieur_temp",
|
||||
"nb_degivrages",
|
||||
"temps_compresseur",
|
||||
"nb_cycles_compresseur",
|
||||
"freq_comp_actuelle",
|
||||
"freq_comp_cible",
|
||||
"fan_speed_evaporator_1",
|
||||
"dc_voltage",
|
||||
"statut_frigo_s",
|
||||
"statut_frigo",
|
||||
"active_error_fri",
|
||||
}
|
||||
assert {description.key for description in REGULATION_SENSORS} == {
|
||||
"puissance_inst_produite",
|
||||
"puissance_inst_consommee",
|
||||
"temps_mise_sous_tension",
|
||||
"modele_pac_s",
|
||||
"modele_pac",
|
||||
"primaire_temp_eau_aller_consigne",
|
||||
"primaire_temp_eau_aller",
|
||||
"primaire_temp_eau_retour",
|
||||
"primaire_debit_eau",
|
||||
"primaire_pression",
|
||||
"primaire_circulateur_consigne",
|
||||
"zone1_temp_interieur",
|
||||
"zone1_consigne",
|
||||
"ecs_temp_eau_milieu",
|
||||
"ecs_temp_eau_bas",
|
||||
"ecs_consigne",
|
||||
"nb_cycles_compresseur_reg",
|
||||
"statut_pac_s",
|
||||
"statut_pac",
|
||||
"active_error_reg",
|
||||
"signal_rf_sonde_1",
|
||||
}
|
||||
|
||||
|
||||
def test_initial_sensor_state_is_unavailable() -> None:
|
||||
for sensor in _sensors(FakeClient()):
|
||||
assert not sensor.available
|
||||
assert sensor.native_value is None
|
||||
|
||||
|
||||
def test_device_info_is_shared_by_all_sensors() -> None:
|
||||
infos = [sensor.device_info for sensor in _sensors(FakeClient())]
|
||||
assert all(info["identifiers"] == {(DOMAIN, "arkteos_zuran4")} for info in infos)
|
||||
assert all(info["name"] == "PAC Arkteos Zuran 4" for info in infos)
|
||||
assert all(info["manufacturer"] == "Arkteos" for info in infos)
|
||||
|
||||
|
||||
async def test_frigo_frame_only_makes_frigo_sensors_available() -> None:
|
||||
client = FakeClient()
|
||||
sensors = _sensors(client)
|
||||
await _add_sensors_to_hass(sensors)
|
||||
client.publish("frigo", _frame_values("frigo_163.bin"))
|
||||
assert all(sensor.available for sensor in sensors if sensor.entity_description.frame_type == "frigo")
|
||||
assert not any(sensor.available for sensor in sensors if sensor.entity_description.frame_type == "regulation")
|
||||
|
||||
|
||||
async def test_regulation_frame_keeps_frigo_available_and_enables_regulation() -> None:
|
||||
client = FakeClient()
|
||||
sensors = _sensors(client)
|
||||
await _add_sensors_to_hass(sensors)
|
||||
client.publish("frigo", _frame_values("frigo_163.bin"))
|
||||
client.publish("regulation", _frame_values("regulation_227.bin"))
|
||||
assert all(sensor.available for sensor in sensors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "fixture_name"),
|
||||
(
|
||||
("exterieur_temp", "frigo_163.bin"),
|
||||
("primaire_temp_eau_aller", "regulation_227.bin"),
|
||||
("primaire_temp_eau_retour", "regulation_227.bin"),
|
||||
("puissance_inst_consommee", "regulation_227.bin"),
|
||||
("puissance_inst_produite", "regulation_227.bin"),
|
||||
("primaire_pression", "regulation_227.bin"),
|
||||
("ecs_temp_eau_milieu", "regulation_227.bin"),
|
||||
("ecs_temp_eau_bas", "regulation_227.bin"),
|
||||
("statut_frigo_s", "frigo_163.bin"),
|
||||
("statut_pac_s", "regulation_227.bin"),
|
||||
),
|
||||
)
|
||||
async def test_fixture_values_match_parser(key: str, fixture_name: str) -> None:
|
||||
client = FakeClient()
|
||||
sensor = _sensor(_sensors(client), key)
|
||||
await _add_sensors_to_hass([sensor])
|
||||
values = _frame_values(fixture_name)
|
||||
client.publish(values["frame_type"], values)
|
||||
assert sensor.native_value == values[key]
|
||||
|
||||
|
||||
def test_status_enum_options_are_exact() -> None:
|
||||
descriptions = {description.key: description for description in SENSOR_DESCRIPTIONS}
|
||||
assert descriptions["statut_frigo_s"].options == FRIGO_STATUS_OPTIONS
|
||||
assert descriptions["statut_pac_s"].options == PAC_STATUS_OPTIONS
|
||||
|
||||
|
||||
def test_diagnostic_and_disabled_descriptions() -> None:
|
||||
descriptions = {description.key: description for description in SENSOR_DESCRIPTIONS}
|
||||
for key in ("statut_frigo", "statut_pac", "modele_pac", "zone1_temp_interieur", "zone1_consigne"):
|
||||
assert not descriptions[key].entity_registry_enabled_default
|
||||
for key in ("active_error_fri", "active_error_reg", "signal_rf_sonde_1"):
|
||||
assert descriptions[key].entity_category.value == "diagnostic"
|
||||
|
||||
|
||||
async def test_rejected_value_does_not_replace_last_valid_value() -> None:
|
||||
client = FakeClient()
|
||||
sensor = _sensor(_sensors(client), "exterieur_temp")
|
||||
await _add_sensors_to_hass([sensor])
|
||||
valid = _frame_values("frigo_163.bin")
|
||||
client.publish("frigo", valid)
|
||||
previous_value = sensor.native_value
|
||||
client.publish("frigo", {"frame_type": "frigo"}, {"exterieur_temp": 151})
|
||||
assert sensor.native_value == previous_value
|
||||
|
||||
|
||||
async def test_entity_added_after_frame_uses_latest_data() -> None:
|
||||
client = FakeClient()
|
||||
values = _frame_values("frigo_163.bin")
|
||||
client.publish("frigo", values)
|
||||
sensor = _sensor(_sensors(client), "exterieur_temp")
|
||||
await _add_sensors_to_hass([sensor])
|
||||
assert sensor.available
|
||||
assert sensor.native_value == values["exterieur_temp"]
|
||||
|
||||
|
||||
def test_callbacks_only_update_their_own_frame_type() -> None:
|
||||
client = FakeClient()
|
||||
sensors = _sensors(client)
|
||||
frigo_sensor = _sensor(sensors, "exterieur_temp")
|
||||
regulation_sensor = _sensor(sensors, "primaire_temp_eau_aller")
|
||||
frigo_sensor.async_write_ha_state = Mock()
|
||||
regulation_sensor.async_write_ha_state = Mock()
|
||||
client.add_data_callback(frigo_sensor._handle_data)
|
||||
client.add_data_callback(regulation_sensor._handle_data)
|
||||
client.publish("frigo", _frame_values("frigo_163.bin"))
|
||||
frigo_sensor.async_write_ha_state.assert_called_once()
|
||||
regulation_sensor.async_write_ha_state.assert_not_called()
|
||||
|
||||
|
||||
async def test_sensor_callback_is_removed_when_entity_is_removed() -> None:
|
||||
client = FakeClient()
|
||||
sensor = _sensor(_sensors(client), "exterieur_temp")
|
||||
client.add_data_callback(sensor._handle_data)
|
||||
client.add_availability_callback(sensor._handle_availability)
|
||||
await sensor.async_will_remove_from_hass()
|
||||
assert sensor._handle_data not in client.data_callbacks
|
||||
assert sensor._handle_availability not in client.availability_callbacks
|
||||
|
||||
|
||||
async def test_sensor_platform_creates_all_descriptions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import custom_components.arkteos.sensor as sensor_platform
|
||||
|
||||
client = FakeClient()
|
||||
created: list[ArkteosSensor] = []
|
||||
monkeypatch.setattr(sensor_platform, "_get_client", lambda _hass, _entry: client)
|
||||
await async_setup_entry(None, object(), lambda entities: created.extend(entities))
|
||||
assert len(created) == len(SENSOR_DESCRIPTIONS)
|
||||
|
||||
|
||||
def test_sensor_entities_do_not_poll_or_use_mqtt() -> None:
|
||||
assert all(sensor._attr_should_poll is False for sensor in _sensors(FakeClient()))
|
||||
assert "mqtt" not in Path(__file__).parents[1].joinpath("custom_components/arkteos/sensor.py").read_text().lower()
|
||||
Reference in New Issue
Block a user