Compare commits

..

No commits in common. "task/ha-piha" and "master" have entirely different histories.

5 changed files with 66 additions and 80 deletions

View file

@ -1,10 +0,0 @@
services:
ha-diag-agent:
# Pin events to the piha-specific subdirectory; overrides the ${NODE_NAME}
# variable substitution in the base compose file which requires a shell env var.
volumes:
- /opt/homelab/events/piha:/events
- /var/lib/ha-diag-agent:/data
- /opt/homelab/config/ha-diag-agent:/config:ro
mem_limit: 128m
restart: unless-stopped

View file

@ -2,14 +2,11 @@
# Copy to /opt/homelab/config/ha-diag-agent/.env on the target node # Copy to /opt/homelab/config/ha-diag-agent/.env on the target node
# Home Assistant connection (required) # Home Assistant connection (required)
# piha: HA_URL=http://localhost:8123 HA_URL=http://homeassistant.local:8123
# chelsty-infra: HA_URL=http://100.70.180.90:8123 (chelsty-ha Tailscale IP)
HA_URL=http://localhost:8123
# Obtain from HA UI: Settings → People → <diag_agent user> → Long-Lived Access Tokens
HA_TOKEN=your-long-lived-token-here HA_TOKEN=your-long-lived-token-here
HA_TIMEOUT=10.0 HA_TIMEOUT=10.0
# Node identity (must match the node's canonical name in the homelab inventory) # Node identity
NODE_NAME=piha NODE_NAME=piha
LOCATION_TAG=ken LOCATION_TAG=ken

View file

@ -22,6 +22,7 @@ dependencies = [
dev = [ dev = [
"pytest>=8.1", "pytest>=8.1",
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",
"aioresponses>=0.7",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View file

@ -68,7 +68,7 @@ async def _run_check_and_emit(
_log.warning( _log.warning(
"check_unhealthy", "check_unhealthy",
check=check.name, check=check.name,
ha_event=result.event_type, event=result.event_type,
msg=result.message, msg=result.message,
) )

View file

@ -1,9 +1,8 @@
"""Tests for HAClient using unittest.mock to avoid aioresponses/aiohttp version coupling.""" """Tests for HAClient using aioresponses to mock aiohttp."""
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from aioresponses import aioresponses
from ha_diag.ha_client import HAClient, make_session from ha_diag.ha_client import HAClient, make_session
@ -11,49 +10,34 @@ HA_URL = "http://homeassistant.test:8123"
TOKEN = "test-token" TOKEN = "test-token"
def _mock_resp(payload=None, text=None, status=200):
"""Return a mock that behaves like an aiohttp response context manager."""
resp = MagicMock()
resp.status = status
if status >= 400:
resp.raise_for_status.side_effect = Exception(f"HTTP {status}")
else:
resp.raise_for_status = MagicMock()
resp.json = AsyncMock(return_value=payload if payload is not None else {})
resp.text = AsyncMock(return_value=text or "")
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=False)
return resp
def _mock_session(get_resp=None):
session = MagicMock()
session.get.return_value = get_resp or _mock_resp()
return session
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_api_status_ok(): async def test_get_api_status_ok():
session = _mock_session(_mock_resp({"message": "API running."})) with aioresponses() as m:
client = HAClient(HA_URL, session) m.get(f"{HA_URL}/api/", payload={"message": "API running."})
result = await client.get_api_status() async with make_session(TOKEN) as session:
client = HAClient(HA_URL, session)
result = await client.get_api_status()
assert result == {"message": "API running."} assert result == {"message": "API running."}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_api_status_unauthorized(): async def test_get_api_status_unauthorized():
session = _mock_session(_mock_resp(status=401)) with aioresponses() as m:
client = HAClient(HA_URL, session) m.get(f"{HA_URL}/api/", status=401)
with pytest.raises(Exception): async with make_session(TOKEN) as session:
await client.get_api_status() client = HAClient(HA_URL, session)
with pytest.raises(Exception):
await client.get_api_status()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_states_returns_list(): async def test_get_states_returns_list():
payload = [{"entity_id": "light.living_room", "state": "on"}] payload = [{"entity_id": "light.living_room", "state": "on"}]
session = _mock_session(_mock_resp(payload)) with aioresponses() as m:
client = HAClient(HA_URL, session) m.get(f"{HA_URL}/api/states", payload=payload)
states = await client.get_states() async with make_session(TOKEN) as session:
client = HAClient(HA_URL, session)
states = await client.get_states()
assert isinstance(states, list) assert isinstance(states, list)
assert states[0]["entity_id"] == "light.living_room" assert states[0]["entity_id"] == "light.living_room"
@ -61,9 +45,11 @@ async def test_get_states_returns_list():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_config_returns_dict(): async def test_get_config_returns_dict():
payload = {"version": "2024.1.0", "location_name": "Home"} payload = {"version": "2024.1.0", "location_name": "Home"}
session = _mock_session(_mock_resp(payload)) with aioresponses() as m:
client = HAClient(HA_URL, session) m.get(f"{HA_URL}/api/config", payload=payload)
config = await client.get_config() async with make_session(TOKEN) as session:
client = HAClient(HA_URL, session)
config = await client.get_config()
assert config["version"] == "2024.1.0" assert config["version"] == "2024.1.0"
@ -73,9 +59,11 @@ async def test_get_entity_registry_returns_list():
{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}, {"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"},
{"entity_id": "sensor.temp", "platform": "mqtt", "area_id": None}, {"entity_id": "sensor.temp", "platform": "mqtt", "area_id": None},
] ]
session = _mock_session(_mock_resp(payload)) with aioresponses() as m:
client = HAClient(HA_URL, session) m.get(f"{HA_URL}/api/config/entity_registry", payload=payload)
registry = await client.get_entity_registry() async with make_session(TOKEN) as session:
client = HAClient(HA_URL, session)
registry = await client.get_entity_registry()
assert len(registry) == 2 assert len(registry) == 2
assert registry[0]["platform"] == "zha" assert registry[0]["platform"] == "zha"
@ -83,7 +71,12 @@ async def test_get_entity_registry_returns_list():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_make_session_sets_auth_header(): async def test_make_session_sets_auth_header():
"""make_session injects the Bearer token in all requests.""" """make_session injects the Bearer token in all requests."""
async with make_session("my-secret-token") as session: with aioresponses() as m:
m.get(f"{HA_URL}/api/", payload={"message": "API running."})
async with make_session("my-secret-token") as session:
client = HAClient(HA_URL, session)
await client.get_api_status()
# Verify the Authorization header was sent
assert session.headers.get("Authorization") == "Bearer my-secret-token" assert session.headers.get("Authorization") == "Bearer my-secret-token"
@ -96,42 +89,47 @@ async def test_make_session_sets_auth_header():
async def test_entity_registry_cached_on_second_call(): async def test_entity_registry_cached_on_second_call():
"""Second call within TTL returns cache, making only one HTTP request.""" """Second call within TTL returns cache, making only one HTTP request."""
payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}] payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}]
session = _mock_session(_mock_resp(payload)) with aioresponses() as m:
client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0) m.get(f"{HA_URL}/api/config/entity_registry", payload=payload)
r1 = await client.get_entity_registry() async with make_session(TOKEN) as session:
r2 = await client.get_entity_registry() # from cache client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0)
r1 = await client.get_entity_registry()
r2 = await client.get_entity_registry() # from cache — no second HTTP call
assert r1 == r2 assert r1 == r2
session.get.assert_called_once() # only one HTTP request # aioresponses would raise ConnectionError on the unmocked second request
# if caching weren't working; reaching here means it used the cache.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_entity_registry_cache_bypassed_after_ttl(): async def test_entity_registry_cache_bypassed_after_ttl(monkeypatch):
"""After TTL expiry (ttl=0), next call fetches fresh data.""" """After TTL expiry, next call fetches fresh data."""
import time
payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}] payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}]
session = _mock_session(_mock_resp(payload)) with aioresponses() as m:
# TTL=0 means every call is stale → two fetches m.get(f"{HA_URL}/api/config/entity_registry", payload=payload)
session.get.side_effect = [_mock_resp(payload), _mock_resp(payload)] m.get(f"{HA_URL}/api/config/entity_registry", payload=payload)
client = HAClient(HA_URL, session, entity_registry_cache_ttl=0.0) async with make_session(TOKEN) as session:
await client.get_entity_registry() client = HAClient(HA_URL, session, entity_registry_cache_ttl=0.0)
await client.get_entity_registry() await client.get_entity_registry() # fetches
assert session.get.call_count == 2 await client.get_entity_registry() # TTL=0 → fetches again
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalidate_registry_cache_forces_refetch(): async def test_invalidate_registry_cache_forces_refetch():
"""invalidate_registry_cache() makes the next call hit the network.""" """invalidate_registry_cache() makes the next call hit the network."""
payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": ""}] payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": ""}]
session = _mock_session(_mock_resp(payload)) with aioresponses() as m:
session.get.side_effect = [_mock_resp(payload), _mock_resp(payload)] m.get(f"{HA_URL}/api/config/entity_registry", payload=payload)
client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0) m.get(f"{HA_URL}/api/config/entity_registry", payload=payload)
await client.get_entity_registry() async with make_session(TOKEN) as session:
client.invalidate_registry_cache() client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0)
await client.get_entity_registry() await client.get_entity_registry()
assert session.get.call_count == 2 client.invalidate_registry_cache()
await client.get_entity_registry() # must hit network again
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_entity_registry_cache_default_ttl_is_300(): async def test_entity_registry_cache_default_ttl_is_300():
session = _mock_session() async with make_session(TOKEN) as session:
client = HAClient(HA_URL, session) client = HAClient(HA_URL, session)
assert client._registry_cache_ttl == 300.0 assert client._registry_cache_ttl == 300.0