nasWebio ed1366f463
Add NASweb integration (#98118)
* Add NASweb integration

* Fix DeviceInfo import

* Remove commented out code

* Change class name for uniquness

* Drop CoordinatorEntity inheritance

* Rename class Output to more descriptive: RelaySwitch

* Update required webio-api version

* Implement on-the-fly addition/removal of entities

* Set coordinator name matching device name

* Set entities with too old status as unavailable

* Drop Optional in favor of modern typing

* Fix spelling of a variable

* Rename commons to more fitting name: helper

* Remove redundant code

* Let unload fail when there is no coordinator

* Fix bad docstring

* Rename cord to coordinator for clarity

* Remove default value for pop and let it raise exception

* Drop workaround and use get_url from helper.network

* Use webhook to send data from device

* Deinitialize coordinator when no longer needed

* Use Python formattable string

* Use dataclass to store integration data in hass.data

* Raise ConfigEntryNotReady when appropriate

* Refactor NASwebData class

* Move RelaySwitch to switch.py

* Fix ConfigFlow tests

* Create issues when entry fails to load

* Respond when correctly received status update

* Depend on webhook instead of http

* Create issue when status is not received during entry set up

* Make issue_id unique across integration entries

* Remove unnecessary initializations

* Inherit CoordinatorEntity to avoid code duplication

* Optimize property access via assignment in __init__

* Use preexisting mechanism to fill schema with user input

* Fix translation strings

* Handle unavailable or unreachable internal url

* Implement custom coordinator for push driven data updates

* Move module-specific constants to respective modules

* Fix requirements_all.txt

* Fix CODEOWNERS file

* Raise ConfigEntryError instead of issue creation

* Fix entity registry import

* Use HassKey as key in hass.data

* Use typed ConfigEntry

* Store runtime data in config entry

* Rewrite to be more Pythonic

* Move add/remove of switch entities to switch.py

* Skip unnecessary check

* Remove unnecessary type hints

* Remove unnecessary nonlocal

* Use a more descriptive docstring

* Add docstrings to NASwebCoordinator

* Fix formatting

* Use correct return type

* Fix tests to align with changed code

* Remove commented code

* Use serial number as config entry id

* Catch AbortFlow exception

* Update tests to check ConfigEntry Unique ID

* Remove unnecessary form abort
2024-11-08 12:03:32 +01:00

192 lines
7.7 KiB
Python

"""Message routing coordinators for handling NASweb push notifications."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
import time
from typing import Any
from aiohttp.web import Request, Response
from webio_api import WebioAPI
from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import event
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
from .const import STATUS_UPDATE_MAX_TIME_INTERVAL
_LOGGER = logging.getLogger(__name__)
class NotificationCoordinator:
"""Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator."""
def __init__(self) -> None:
"""Initialize coordinator."""
self._coordinators: dict[str, NASwebCoordinator] = {}
def add_coordinator(self, serial: str, coordinator: NASwebCoordinator) -> None:
"""Add NASwebCoordinator to possible notification targets."""
self._coordinators[serial] = coordinator
_LOGGER.debug("Added NASwebCoordinator for NASweb[%s]", serial)
def remove_coordinator(self, serial: str) -> None:
"""Remove NASwebCoordinator from possible notification targets."""
self._coordinators.pop(serial)
_LOGGER.debug("Removed NASwebCoordinator for NASweb[%s]", serial)
def has_coordinators(self) -> bool:
"""Check if there is any registered coordinator for push notifications."""
return len(self._coordinators) > 0
async def check_connection(self, serial: str) -> bool:
"""Wait for first status update to confirm connection with NASweb."""
nasweb_coordinator = self._coordinators.get(serial)
if nasweb_coordinator is None:
_LOGGER.error("Cannot check connection. No device match serial number")
return False
for counter in range(10):
_LOGGER.debug("Checking connection with: %s (%s)", serial, counter)
if nasweb_coordinator.is_connection_confirmed():
return True
await asyncio.sleep(1)
return False
async def handle_webhook_request(
self, hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
"""Handle webhook request from Push API."""
if not self.has_coordinators():
return None
notification = await request.json()
serial = notification.get(KEY_DEVICE_SERIAL, None)
_LOGGER.debug("Received push: %s", notification)
if serial is None:
_LOGGER.warning("Received notification without nasweb identifier")
return None
nasweb_coordinator = self._coordinators.get(serial)
if nasweb_coordinator is None:
_LOGGER.warning("Received notification for not registered nasweb")
return None
await nasweb_coordinator.handle_push_notification(notification)
return Response(body='{"response": "ok"}', content_type="application/json")
class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Coordinator managing status of single NASweb device.
Since status updates are managed through push notifications, this class schedules
periodic checks to ensure that devices are marked unavailable if updates
haven't been received for a prolonged period.
"""
def __init__(
self, hass: HomeAssistant, webio_api: WebioAPI, name: str = "NASweb[default]"
) -> None:
"""Initialize NASweb coordinator."""
self._hass = hass
self.name = name
self.webio_api = webio_api
self._last_update: float | None = None
job_name = f"NASwebCoordinator[{name}]"
self._job = HassJob(self._handle_max_update_interval, job_name)
self._unsub_last_update_check: CALLBACK_TYPE | None = None
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
data: dict[str, Any] = {}
data[KEY_OUTPUTS] = self.webio_api.outputs
self.async_set_updated_data(data)
def is_connection_confirmed(self) -> bool:
"""Check whether coordinator received status update from NASweb."""
return self._last_update is not None
@callback
def async_add_listener(
self, update_callback: CALLBACK_TYPE, context: Any = None
) -> Callable[[], None]:
"""Listen for data updates."""
schedule_update_check = not self._listeners
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._listeners.pop(remove_listener)
if not self._listeners:
self._async_unsub_last_update_check()
self._listeners[remove_listener] = (update_callback, context)
# This is the first listener, set up interval.
if schedule_update_check:
self._schedule_last_update_check()
return remove_listener
@callback
def async_set_updated_data(self, data: dict[str, Any]) -> None:
"""Update data and notify listeners."""
self.data = data
self.last_update = self._hass.loop.time()
_LOGGER.debug("Updated %s data", self.name)
if self._listeners:
self._schedule_last_update_check()
self.async_update_listeners()
@callback
def async_update_listeners(self) -> None:
"""Update all registered listeners."""
for update_callback, _ in list(self._listeners.values()):
update_callback()
async def _handle_max_update_interval(self, now: datetime) -> None:
"""Handle max update interval occurrence.
This method is called when `STATUS_UPDATE_MAX_TIME_INTERVAL` has passed without
receiving a status update. It only needs to trigger state update of entities
which then change their state accordingly.
"""
self._unsub_last_update_check = None
if self._listeners:
self.async_update_listeners()
def _schedule_last_update_check(self) -> None:
"""Schedule a task to trigger entities state update after `STATUS_UPDATE_MAX_TIME_INTERVAL`.
This method schedules a task (`_handle_max_update_interval`) to be executed after
`STATUS_UPDATE_MAX_TIME_INTERVAL` seconds without status update, which enables entities
to change their state to unavailable. After each status update this task is rescheduled.
"""
self._async_unsub_last_update_check()
now = self._hass.loop.time()
next_check = (
now + timedelta(seconds=STATUS_UPDATE_MAX_TIME_INTERVAL).total_seconds()
)
self._unsub_last_update_check = event.async_call_at(
self._hass,
self._job,
next_check,
)
def _async_unsub_last_update_check(self) -> None:
"""Cancel any scheduled update check call."""
if self._unsub_last_update_check:
self._unsub_last_update_check()
self._unsub_last_update_check = None
async def handle_push_notification(self, notification: dict) -> None:
"""Handle incoming push notification from NASweb."""
msg_type = notification.get(KEY_TYPE)
_LOGGER.debug("Received push notification: %s", msg_type)
if msg_type == TYPE_STATUS_UPDATE:
await self.process_status_update(notification)
self._last_update = time.time()
async def process_status_update(self, new_status: dict) -> None:
"""Process status update from NASweb."""
self.webio_api.update_device_status(new_status)
new_data = {KEY_OUTPUTS: self.webio_api.outputs}
self.async_set_updated_data(new_data)