Add Arkteos diagnostics
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
captures/
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Diagnostics sans accès réseau pour l'intégration Arkteos."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
|
|
||||||
|
from . import _get_client
|
||||||
|
from .const import CONF_HOST, CONF_PORT, DEFAULT_PORT, DOMAIN
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
|
_SENSITIVE_KEYS = frozenset({"host", "password", "token"})
|
||||||
|
_CLIENT_STATE_ATTRIBUTES = (
|
||||||
|
"available",
|
||||||
|
"running",
|
||||||
|
"connected",
|
||||||
|
"bytes_received",
|
||||||
|
"frames_received",
|
||||||
|
"frames_rejected",
|
||||||
|
"reconnect_count",
|
||||||
|
"last_frame_type",
|
||||||
|
)
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_version() -> str | None:
|
||||||
|
"""Retourne la version déclarée sans contacter de ressource externe."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(Path(__file__).with_name("manifest.json").read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
version = manifest.get("version")
|
||||||
|
return version if isinstance(version, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_values(data: object) -> dict[str, str | int | float | bool | None]:
|
||||||
|
"""Conserve uniquement les valeurs décodées, simples et non sensibles."""
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
result: dict[str, str | int | float | bool | None] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if not isinstance(key, str) or key.lower() in _SENSITIVE_KEYS:
|
||||||
|
continue
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _received_data(client: object | None, attribute: str) -> dict[str, Any]:
|
||||||
|
"""Prépare une section de données sans bytes, buffer ni objet asyncio."""
|
||||||
|
|
||||||
|
data = getattr(client, attribute, None) if client is not None else None
|
||||||
|
values = _simple_values(data)
|
||||||
|
return {"received": data is not None, "data": values}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_state(entry: ConfigEntry) -> str | None:
|
||||||
|
"""Convertit l'état optionnel de l'entrée en valeur sérialisable."""
|
||||||
|
|
||||||
|
state = getattr(entry, "state", None)
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
value = getattr(state, "value", state)
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retourne des diagnostics locaux, redacted et uniquement sérialisables.
|
||||||
|
|
||||||
|
Le client conserve uniquement ``frames_rejected`` de manière agrégée : les
|
||||||
|
valeurs rejetées par champ ne sont donc pas inventées dans ce résultat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
entry_data = getattr(entry, "data", {})
|
||||||
|
if not isinstance(entry_data, Mapping):
|
||||||
|
entry_data = {}
|
||||||
|
host = entry_data.get(CONF_HOST)
|
||||||
|
title = getattr(entry, "title", None)
|
||||||
|
if isinstance(title, str) and isinstance(host, str):
|
||||||
|
title = title.replace(host, "**REDACTED**")
|
||||||
|
|
||||||
|
configured = async_redact_data(
|
||||||
|
{
|
||||||
|
CONF_HOST: host,
|
||||||
|
CONF_PORT: entry_data.get(CONF_PORT, DEFAULT_PORT),
|
||||||
|
},
|
||||||
|
[CONF_HOST],
|
||||||
|
)
|
||||||
|
client = _get_client(hass, entry)
|
||||||
|
client_state: dict[str, str | int | float | bool | None] = {}
|
||||||
|
if client is not None:
|
||||||
|
for attribute in _CLIENT_STATE_ATTRIBUTES:
|
||||||
|
value = getattr(client, attribute, _MISSING)
|
||||||
|
if value is _MISSING:
|
||||||
|
continue
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
client_state[attribute] = value
|
||||||
|
|
||||||
|
return {
|
||||||
|
"integration": {
|
||||||
|
"domain": DOMAIN,
|
||||||
|
"version": _manifest_version(),
|
||||||
|
"title": title if isinstance(title, str) else None,
|
||||||
|
"config_entry_state": _entry_state(entry),
|
||||||
|
"configuration": configured,
|
||||||
|
},
|
||||||
|
"client": client_state,
|
||||||
|
"received_data": {
|
||||||
|
"metadata": _received_data(client, "latest_metadata"),
|
||||||
|
"frigo": _received_data(client, "latest_frigo_data"),
|
||||||
|
"regulation": _received_data(client, "latest_regulation_data"),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
Reference in New Issue
Block a user