Compare commits
12
Commits
ce4bba673c
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6a6ad044 | ||
|
|
6bac70e981 | ||
|
|
a3af4d8c68 | ||
|
|
5054c44916 | ||
|
|
5f5b6240a5 | ||
|
|
a743b0008f | ||
|
|
fea8809fb9 | ||
|
|
9947b9c3ad | ||
|
|
8bae450bc3 | ||
|
|
b76e8f3896 | ||
|
|
c85637e26d | ||
|
|
1b6af397e5 |
+14
@@ -2,3 +2,17 @@
|
||||
__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,36 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.2] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Assets de branding HACS à la racine du dépôt.
|
||||
|
||||
## [0.1.1] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Assets visuels de l’intégration Arkteos.
|
||||
|
||||
## [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.2`.
|
||||
- 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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -1 +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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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.2",
|
||||
"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,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,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
@@ -0,0 +1 @@
|
||||
pytest-homeassistant-custom-component==0.13.205
|
||||
@@ -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,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