From 9947b9c3ad2f84988b3c5c4c7a7484b10e99b12e Mon Sep 17 00:00:00 2001 From: raph666 Date: Tue, 21 Jul 2026 12:25:44 +0200 Subject: [PATCH] Add Arkteos diagnostics --- .gitignore | 3 +- custom_components/arkteos/diagnostics.py | 126 ++++++++++++++++++ tests/test_diagnostics.py | 159 +++++++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 custom_components/arkteos/diagnostics.py create mode 100644 tests/test_diagnostics.py diff --git a/.gitignore b/.gitignore index 7962149..c02a394 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Artefacts Python générés localement __pycache__/ *.py[cod] -.venv/ \ No newline at end of file +.venv/ +captures/ \ No newline at end of file diff --git a/custom_components/arkteos/diagnostics.py b/custom_components/arkteos/diagnostics.py new file mode 100644 index 0000000..ddaf02c --- /dev/null +++ b/custom_components/arkteos/diagnostics.py @@ -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"), + }, + } diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..11733ca --- /dev/null +++ b/tests/test_diagnostics.py @@ -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