8 Commits
Author SHA1 Message Date
raph666 5f5b6240a5 Expand development file ignores 2026-07-21 13:12:01 +02:00
raph666 a743b0008f Prepare 0.1.0 release notes 2026-07-21 13:05:17 +02:00
raph666 fea8809fb9 Prepare HACS distribution 2026-07-21 12:45:15 +02:00
raph666 9947b9c3ad Add Arkteos diagnostics 2026-07-21 12:25:44 +02:00
raph666 8bae450bc3 Add native Arkteos sensors 2026-07-21 12:05:30 +02:00
raph666 b76e8f3896 Add Home Assistant integration scaffold 2026-07-21 10:58:42 +02:00
raph666 c85637e26d Add async read-only proxy client 2026-07-18 17:05:26 +02:00
raph666 1b6af397e5 Add incremental REG3 frame extractor 2026-07-18 16:58:19 +02:00
27 changed files with 3120 additions and 36 deletions
+14
View File
@@ -2,3 +2,17 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/ .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
+24
View File
@@ -0,0 +1,24 @@
# Changelog
## [0.1.0] - 2026-07-21
### Added
- Configuration depuis linterface 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 lhô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.
+21
View File
@@ -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.
+98 -35
View File
@@ -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 ## Présentation
une PAC Arkteos Zuran 4.
L'intégration devra lire exclusivement le flux du proxy TCP fourni par l'addon Arkteos est une intégration Home Assistant personnalisée pour une PAC Arkteos
Home Assistant séparé, sur le port de référence `9641`. Elle ne doit jamais se Zuran 4. Elle lit localement les données du proxy Arkteos, selon un protocole
connecter directement à la PAC ni écrire sur le proxy. REG3 observé à partir de captures réelles et du flow Node-RED de référence.
## Références disponibles Lintégration est strictement en lecture seule : elle nenvoie 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 ## État du projet
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 dépôt - Version actuelle : `0.1.0`.
- Statut : expérimental.
- Testée avec les captures dune Arkteos Zuran 4.
- Le protocole nest pas officiellement documenté.
- Conserve Node-RED en parallèle pendant la validation sur linstallation
réelle.
L'intégration Home Assistant n'est pas encore écrite. Aucun comportement de ## Prérequis
framing TCP ne doit être supposé sans captures ou tests démonstratifs.
## Arborescence cible proposée - Home Assistant.
- HACS pour linstallation 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 ## Installation avec HACS
custom_components/
arkteos/ 1. Ouvre HACS.
__init__.py 2. Va dans **Intégrations**.
manifest.json 3. Ouvre le menu **Dépôts personnalisés**.
config_flow.py 4. Ajoute lURL du miroir GitHub :
const.py `https://github.com/raph666/home-assistant-arkteos`.
coordinator.py 5. Choisis la catégorie **Intégration**.
parser.py 6. Installe **Arkteos**.
sensor.py 7. Redémarre Home Assistant.
binary_sensor.py 8. Va dans **Paramètres > Appareils et services**.
strings.json 9. Ajoute **Arkteos**.
translations/ 10. Saisis lhôte du proxy et le port `9641`.
fr.json
tests/ Le miroir GitHub sert à linstallation HACS.
components/
arkteos/ ## Configuration
references/
La configuration demande lhôte et le port du proxy. Elle est validée par une
connexion au proxy et la réception dune trame valide. La reconfiguration est
possible depuis Home Assistant.
## Entités exposées
Lintégration expose des entités natives Home Assistant pour :
- la connectivité ;
- le groupe frigorifique ;
- la régulation ;
- le circuit primaire ;
- leau 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. Lhôte est masqué
et aucune trame brute, socket ou donnée interne sensible nest 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 nest démontré.
- Seules les trames observées de 95, 163 et 227 octets sont reconnues.
- Linté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 ## Licence
fichiers.
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 à linstallation HACS.
+74
View File
@@ -1 +1,75 @@
"""Paquet de l'intégration Arkteos.""" """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
+288
View File
@@ -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
+145
View File
@@ -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),
}
)
+11
View File
@@ -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"
+126
View File
@@ -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"),
},
}
+37
View File
@@ -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()
+11
View File
@@ -0,0 +1,11 @@
{
"domain": "arkteos",
"name": "Arkteos",
"config_flow": true,
"iot_class": "local_push",
"version": "0.1.0",
"requirements": [],
"documentation": "https://gitea.i-host.fr/raph666/home-assistant-arkteos",
"issue_tracker": "https://gitea.i-host.fr/raph666/home-assistant-arkteos/issues",
"codeowners": []
}
+396
View File
@@ -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()
+75
View File
@@ -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"}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "Arkteos",
"render_readme": true
}
+3
View File
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+1
View File
@@ -0,0 +1 @@
pytest-homeassistant-custom-component==0.13.205
+69
View File
@@ -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"
+523
View File
@@ -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())
+199
View File
@@ -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"
+159
View File
@@ -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
+35
View File
@@ -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")
+167
View File
@@ -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"],
]
+127
View File
@@ -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()
+259
View File
@@ -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()