Просмотр исходного кода

Add API Health sensor for API sync mode

ZiB 4 месяцев назад
Родитель
Сommit
5f9a275aec

+ 38 - 1
custom_components/room_cal_ha/backends/api.py

@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
 from homeassistant.helpers.aiohttp_client import async_get_clientsession
 
 from ..const import CONF_API_BASE_URL, CONF_API_TOKEN
-from ..models import ReservationData
+from ..models import ApiStatusData, ReservationData
 from .base import BackendAuthError, BackendConnectError, ReservationBackend
 
 if TYPE_CHECKING:
@@ -66,6 +66,43 @@ class RoomCalApiBackend(ReservationBackend):
 
     return _parse_reservation(current_raw), _parse_reservation(next_raw)
 
+  async def async_fetch_status(self) -> ApiStatusData:
+    session = async_get_clientsession(self._hass)
+    try:
+      async with session.get(f"{self._base_url()}/status", headers=self._headers()) as resp:
+        if resp.status in (401, 403):
+          raise BackendAuthError(f"API returned HTTP {resp.status}")
+        resp.raise_for_status()
+        status_data: dict = await resp.json()
+    except BackendAuthError:
+      raise
+    except Exception as exc:
+      raise BackendConnectError(str(exc)) from exc
+
+    services: dict = {}
+    try:
+      async with session.get(f"{self._base_url()}/services", headers=self._headers()) as resp:
+        if resp.status == 200:
+          services = await resp.json()
+    except Exception as exc:
+      _LOGGER.warning("Failed to fetch /services: %s", exc)
+
+    checked_at = None
+    if raw_ts := status_data.get("checked_at"):
+      from datetime import datetime as _dt
+      try:
+        checked_at = _dt.fromisoformat(raw_ts)
+      except (ValueError, TypeError):
+        pass
+
+    return ApiStatusData(
+      status=status_data.get("status", "unknown"),
+      checks=status_data.get("checks", {}),
+      checked_at=checked_at,
+      stale=status_data.get("stale", False),
+      services=services,
+    )
+
 
 def _parse_reservation(data: dict | None) -> ReservationData | None:
   if data is None:

+ 10 - 1
custom_components/room_cal_ha/coordinator.py

@@ -23,7 +23,7 @@ from .const import (
   SYNC_MODE_API,
   SYNC_MODE_CALDAV,
 )
-from .models import OverrideData, ReservationData
+from .models import ApiStatusData, OverrideData, ReservationData
 from dataclasses import fields as dataclass_fields
 from .store import ReservationStore
 
@@ -37,6 +37,7 @@ class CoordinatorData:
   current_last_refresh: datetime | None
   next_last_refresh: datetime | None
   override_active: bool = False
+  api_status: ApiStatusData | None = None
 
 
 class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
@@ -67,6 +68,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     backend_current: ReservationData | None = None
     backend_next: ReservationData | None = None
     backend_fetched = False
+    api_status: ApiStatusData | None = None
 
     sync_mode = self.entry.options.get(CONF_SYNC_MODE, "")
 
@@ -92,6 +94,11 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       except BackendConnectError as exc:
         _LOGGER.warning("API fetch failed, using cached data: %s", exc)
 
+      try:
+        api_status = await api_backend.async_fetch_status()
+      except Exception as exc:
+        _LOGGER.warning("API status fetch failed: %s", exc)
+
     now = dt_util.utcnow()
 
     if backend_fetched:
@@ -114,6 +121,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         current_last_refresh=self._current_last_refresh,
         next_last_refresh=self._next_last_refresh,
         override_active=True,
+        api_status=api_status,
       )
 
     self._reschedule_mode_sync(store_current, store_next)
@@ -123,6 +131,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next=store_next,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
+      api_status=api_status,
     )
 
   def _reschedule_mode_sync(self, current: ReservationData, nxt: ReservationData) -> None:

+ 11 - 2
custom_components/room_cal_ha/models.py

@@ -1,10 +1,19 @@
 from __future__ import annotations
 
-from dataclasses import dataclass, fields
-from datetime import date, time
+from dataclasses import dataclass, field, fields
+from datetime import date, datetime, time
 from typing import Any
 
 
+@dataclass
+class ApiStatusData:
+  status: str = "unknown"
+  checks: dict = field(default_factory=dict)
+  checked_at: datetime | None = None
+  stale: bool = False
+  services: dict = field(default_factory=dict)
+
+
 @dataclass
 class OverrideData:
   current: "ReservationData"

+ 43 - 6
custom_components/room_cal_ha/sensor.py

@@ -18,6 +18,7 @@ from .const import (
   DOMAIN,
   STORE_CURRENT,
   STORE_NEXT,
+  SYNC_MODE_API,
   SYNC_MODE_MANUAL,
 )
 from .coordinator import CoordinatorData, RoomCalCoordinator
@@ -33,10 +34,18 @@ async def async_setup_entry(
 
   room_name = entry.data.get(CONF_ROOM_NAME, entry.title)
 
+  room_state_device = DeviceInfo(
+    identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
+    name=f"{room_name} Room State",
+  )
+
   entities: list[SensorEntity] = [
-    SyncSourceSensor(entry, room_name),
+    SyncSourceSensor(entry, room_name, room_state_device),
   ]
 
+  if entry.options.get(CONF_SYNC_MODE) == SYNC_MODE_API:
+    entities.append(ApiHealthSensor(coordinator, entry, room_state_device))
+
   for slot, device_name in (
     (STORE_CURRENT, f"{room_name} Current"),
     (STORE_NEXT, f"{room_name} Next"),
@@ -66,13 +75,10 @@ class SyncSourceSensor(SensorEntity):
   _attr_name = "Sync Source"
   _attr_icon = "mdi:sync"
 
-  def __init__(self, entry: ConfigEntry, room_name: str) -> None:
+  def __init__(self, entry: ConfigEntry, room_name: str, device_info: DeviceInfo) -> None:
     self._entry = entry
     self._attr_unique_id = f"{entry.entry_id}_sync_source"
-    self._attr_device_info = DeviceInfo(
-      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
-      name=f"{room_name} Room State",
-    )
+    self._attr_device_info = device_info
 
   @property
   def native_value(self) -> str:
@@ -222,6 +228,7 @@ class LastRefreshSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
   _attr_has_entity_name = True
   _attr_name = "Last Refresh"
   _attr_device_class = SensorDeviceClass.TIMESTAMP
+  _attr_icon = "mdi:timer-sync"
 
   def __init__(
     self,
@@ -243,3 +250,33 @@ class LastRefreshSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
       if self._slot == STORE_CURRENT
       else data.next_last_refresh
     )
+
+
+class ApiHealthSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
+  _attr_has_entity_name = True
+  _attr_name = "API Health"
+  _attr_icon = "mdi:api"
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_info: DeviceInfo,
+  ) -> None:
+    super().__init__(coordinator)
+    self._attr_unique_id = f"{entry.entry_id}_api_health"
+    self._attr_device_info = device_info
+
+  @property
+  def native_value(self) -> str | None:
+    api_status = self.coordinator.data.api_status
+    if api_status is None:
+      return None
+    return "OK" if api_status.status == "OK" else "Degraded"
+
+  @property
+  def extra_state_attributes(self) -> dict | None:
+    api_status = self.coordinator.data.api_status
+    if api_status is None or not api_status.services:
+      return None
+    return dict(api_status.services)

+ 55 - 1
tests/test_sensors.py

@@ -9,8 +9,9 @@ from unittest.mock import MagicMock
 import pytest
 
 from custom_components.room_cal_ha.coordinator import CoordinatorData
-from custom_components.room_cal_ha.models import ReservationData
+from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
 from custom_components.room_cal_ha.sensor import (
+  ApiHealthSensor,
   GuestNameSensor,
   LastRefreshSensor,
   ReservationDateTimeSensor,
@@ -30,6 +31,7 @@ def _coordinator(
   nxt=None,
   current_ts=None,
   next_ts=None,
+  api_status=None,
 ):
   coord = MagicMock()
   coord.data = CoordinatorData(
@@ -37,6 +39,7 @@ def _coordinator(
     next=nxt or ReservationData(),
     current_last_refresh=current_ts,
     next_last_refresh=next_ts,
+    api_status=api_status,
   )
   return coord
 
@@ -243,3 +246,54 @@ def test_last_refresh_sensor_none_before_any_fetch():
   assert s.native_value is None
 
 
+# ---------------------------------------------------------------------------
+# ApiHealthSensor
+# ---------------------------------------------------------------------------
+
+def _api_sensor(coordinator):
+  s = object.__new__(ApiHealthSensor)
+  s.coordinator = coordinator
+  return s
+
+
+def test_api_health_ok():
+  status = ApiStatusData(status="OK", services={"booking": "OK", "calendar": "OK"})
+  coord = _coordinator(api_status=status)
+  s = _api_sensor(coord)
+  assert s.native_value == "OK"
+
+
+def test_api_health_degraded_on_warn():
+  status = ApiStatusData(status="Warn", services={"booking": "Warn", "calendar": "OK"})
+  coord = _coordinator(api_status=status)
+  s = _api_sensor(coord)
+  assert s.native_value == "Degraded"
+
+
+def test_api_health_none_when_no_status():
+  coord = _coordinator(api_status=None)
+  s = _api_sensor(coord)
+  assert s.native_value is None
+
+
+def test_api_health_services_as_attributes():
+  svc = {"booking": "OK", "calendar": "Warn"}
+  status = ApiStatusData(status="Warn", services=svc)
+  coord = _coordinator(api_status=status)
+  s = _api_sensor(coord)
+  assert s.extra_state_attributes == svc
+
+
+def test_api_health_no_attrs_when_services_empty():
+  status = ApiStatusData(status="OK", services={})
+  coord = _coordinator(api_status=status)
+  s = _api_sensor(coord)
+  assert s.extra_state_attributes is None
+
+
+def test_api_health_no_attrs_when_no_status():
+  coord = _coordinator(api_status=None)
+  s = _api_sensor(coord)
+  assert s.extra_state_attributes is None
+
+