diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml new file mode 100644 index 0000000..b268138 --- /dev/null +++ b/.github/workflows/ruff.yaml @@ -0,0 +1,8 @@ +name: Ruff +on: [push, pull_request] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: chartboost/ruff-action@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f222e43..2d0ac29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.4.0 (2024-02-02) + +* bump pyadtpulse to 1.2.0. This should provide more robust error handling and stability +* add connection status and next update sensors +* remove quick re-login service + ## 0.3.5 (2023-12-22) * bump pyadtpulse to 1.1.5 to fix more changes in Pulse v27 diff --git a/custom_components/adtpulse/__init__.py b/custom_components/adtpulse/__init__.py index ade21c2..f60973d 100644 --- a/custom_components/adtpulse/__init__.py +++ b/custom_components/adtpulse/__init__.py @@ -2,13 +2,13 @@ See https://github.com/rsnodgrass/hass-adtpulse """ + from __future__ import annotations from logging import getLogger from asyncio import gather from typing import Any -from aiohttp.client_exceptions import ClientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, @@ -16,19 +16,24 @@ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.config_entry_flow import FlowResult from homeassistant.helpers.config_validation import config_entry_only_config_schema from homeassistant.helpers.typing import ConfigType -from pyadtpulse import PyADTPulse from pyadtpulse.const import ( ADT_DEFAULT_KEEPALIVE_INTERVAL, ADT_DEFAULT_POLL_INTERVAL, ADT_DEFAULT_RELOGIN_INTERVAL, ) +from pyadtpulse.exceptions import ( + PulseAccountLockedError, + PulseAuthenticationError, + PulseGatewayOfflineError, + PulseServiceTemporarilyUnavailableError, +) +from pyadtpulse.pyadtpulse_async import PyADTPulseAsync from .const import ( ADTPULSE_DOMAIN, @@ -41,13 +46,14 @@ LOG = getLogger(__name__) -SUPPORTED_PLATFORMS = ["alarm_control_panel", "binary_sensor"] +SUPPORTED_PLATFORMS = ["alarm_control_panel", "binary_sensor", "sensor"] CONFIG_SCHEMA = config_entry_only_config_schema(ADTPULSE_DOMAIN) async def async_setup( - hass: HomeAssistant, config: ConfigType # pylint: disable=unused-argument + hass: HomeAssistant, + config: ConfigType, # pylint: disable=unused-argument ) -> bool: """Start up the ADT Pulse HA integration. @@ -79,9 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: username = entry.data.get(CONF_USERNAME) password = entry.data.get(CONF_PASSWORD) fingerprint = entry.data.get(CONF_FINGERPRINT) - poll_interval = entry.options.get(CONF_SCAN_INTERVAL) - keepalive = entry.options.get(CONF_KEEPALIVE_INTERVAL) - relogin = entry.options.get(CONF_RELOGIN_INTERVAL) + poll_interval = entry.options.get(CONF_SCAN_INTERVAL, ADT_DEFAULT_POLL_INTERVAL) + keepalive = entry.options.get( + CONF_KEEPALIVE_INTERVAL, ADT_DEFAULT_KEEPALIVE_INTERVAL + ) + relogin = entry.options.get(CONF_RELOGIN_INTERVAL, ADT_DEFAULT_RELOGIN_INTERVAL) # share reference to the service with other components/platforms # running within HASS @@ -90,28 +98,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOG.debug("Using ADT Pulse API host %s", host) if username is None or password is None or fingerprint is None: raise ConfigEntryAuthFailed("Null value for username, password, or fingerprint") - service = PyADTPulse( + service = PyADTPulseAsync( username, password, fingerprint, service_host=host, - do_login=False, keepalive_interval=keepalive, relogin_interval=relogin, ) hass.data[ADTPULSE_DOMAIN][entry.entry_id] = service try: - if not await service.async_login(): - LOG.error("%s could not log in as user %s", ADTPULSE_DOMAIN, username) - raise ConfigEntryAuthFailed( - f"{ADTPULSE_DOMAIN} could not login using supplied credentials" - ) - except (ClientConnectionError, TimeoutError) as ex: + await service.async_login() + except PulseAuthenticationError as ex: LOG.error("Unable to connect to ADT Pulse: %s", ex) - raise ConfigEntryNotReady( + raise ConfigEntryAuthFailed( f"{ADTPULSE_DOMAIN} could not log in due to a protocol error" ) from ex + except ( + PulseAccountLockedError, + PulseServiceTemporarilyUnavailableError, + PulseGatewayOfflineError, + ) as ex: + LOG.error("Unable to connect to ADT Pulse: %s", ex) + raise ConfigEntryNotReady( + f"{ADTPULSE_DOMAIN} could not log in due to service unavailability" + ) from ex if service.sites is None: LOG.error("%s could not retrieve any sites", ADTPULSE_DOMAIN) @@ -127,20 +139,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = ADTPulseDataUpdateCoordinator(hass, service) hass.data.setdefault(ADTPULSE_DOMAIN, {}) hass.data[ADTPULSE_DOMAIN][entry.entry_id] = coordinator - for platform in SUPPORTED_PLATFORMS: + setup_tasks = [ hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) ) - await coordinator.async_refresh() - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.stop) - ) + for platform in SUPPORTED_PLATFORMS + ] + await gather(*setup_tasks) + # entities already have their data, no need to call async_refresh() entry.async_on_unload(entry.add_update_listener(options_listener)) - - async def handle_relogin(dummy: str) -> None: # pylint: disable=unused-argument - await service.async_quick_relogin() - - hass.services.async_register(ADTPULSE_DOMAIN, "quick_relogin", handle_relogin) + await coordinator.start() return True @@ -212,7 +220,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator: ADTPulseDataUpdateCoordinator = hass.data[ADTPULSE_DOMAIN][ entry.entry_id ] - await coordinator.stop(None) + await coordinator.stop() await coordinator.adtpulse.async_logout() hass.data[ADTPULSE_DOMAIN].pop(entry.entry_id) diff --git a/custom_components/adtpulse/alarm_control_panel.py b/custom_components/adtpulse/alarm_control_panel.py index ef5e815..ab63ceb 100644 --- a/custom_components/adtpulse/alarm_control_panel.py +++ b/custom_components/adtpulse/alarm_control_panel.py @@ -1,4 +1,5 @@ """Support for ADT Pulse alarm control panels.""" + from __future__ import annotations from logging import getLogger @@ -260,6 +261,11 @@ def available(self) -> bool: """Alarm panel is always available even if gateway isn't.""" return True + @property + def name(self) -> str | None: + """Return the name of the sensor.""" + return None + @callback def _handle_coordinator_update(self) -> None: LOG.debug( diff --git a/custom_components/adtpulse/base_entity.py b/custom_components/adtpulse/base_entity.py index 64b16ab..940b740 100644 --- a/custom_components/adtpulse/base_entity.py +++ b/custom_components/adtpulse/base_entity.py @@ -1,4 +1,5 @@ """ADT Pulse Entity Base class.""" + from __future__ import annotations from logging import getLogger @@ -6,6 +7,7 @@ from homeassistant.core import callback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pyadtpulse.pyadtpulse_async import PyADTPulseAsync from .const import ADTPULSE_DATA_ATTRIBUTION from .coordinator import ADTPulseDataUpdateCoordinator @@ -25,7 +27,7 @@ def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, name: str): """ self._name = name # save references to commonly used objects - self._pulse_connection = coordinator.adtpulse + self._pulse_connection: PyADTPulseAsync = coordinator.adtpulse self._site = self._pulse_connection.site self._gateway = self._site.gateway self._alarm = self._site.alarm_control_panel @@ -46,7 +48,7 @@ def has_entity_name(self) -> bool: return True @property - def icon(self) -> str: + def icon(self) -> str | None: """Return the mdi icon. Returns: @@ -63,8 +65,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: def available(self) -> bool: """Returns whether an entity is available. - Generally false if gateway is offline.""" - return self._gateway.is_online + Generally false if gateway is offline or there was an exception + """ + return self._gateway.is_online and self.coordinator.last_exception is None @property def attribution(self) -> str: diff --git a/custom_components/adtpulse/binary_sensor.py b/custom_components/adtpulse/binary_sensor.py index 8c7444f..54230f2 100644 --- a/custom_components/adtpulse/binary_sensor.py +++ b/custom_components/adtpulse/binary_sensor.py @@ -5,6 +5,7 @@ automatically discovers the ADT sensors configured within Pulse and exposes them into HA. """ + from __future__ import annotations from logging import getLogger @@ -274,7 +275,7 @@ def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, site: ADTPulseSit "%s: adding gateway status sensor for site %s", ADTPULSE_DOMAIN, site.name ) self._device_class = BinarySensorDeviceClass.CONNECTIVITY - self._name = f"ADT Pulse Gateway Status - Site: {site.name}" + self._name = "Connection" super().__init__(coordinator, self._name) @property @@ -282,7 +283,11 @@ def is_on(self) -> bool: """Return if gateway is online.""" return self._gateway.is_online - # FIXME: Gateways only support one site? + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + @property def unique_id(self) -> str: """Return HA unique id.""" @@ -307,8 +312,8 @@ def extra_state_attributes(self) -> Mapping[str, Any]: "device_lan_ip_address": str(self._gateway.device_lan_ip_address), "router_lan_ip_address": str(self._gateway.router_lan_ip_address), "router_wan_ip_address": str(self._gateway.router_wan_ip_address), - "current_poll_interval": self._gateway.poll_interval, - "initial_poll_interval": self._gateway._initial_poll_interval, + "current_poll_interval": self._gateway.backoff.get_current_backoff_interval(), + "initial_poll_interval": self._gateway.backoff.initial_backoff_interval, "next_update": as_local(datetime.fromtimestamp(self._gateway.next_update)), "last_update": as_local(datetime.fromtimestamp(self._gateway.last_update)), } diff --git a/custom_components/adtpulse/config_flow.py b/custom_components/adtpulse/config_flow.py index c4fc025..5f051ea 100644 --- a/custom_components/adtpulse/config_flow.py +++ b/custom_components/adtpulse/config_flow.py @@ -1,4 +1,5 @@ """HASS ADT Pulse Config Flow.""" + from __future__ import annotations from logging import getLogger @@ -9,14 +10,13 @@ from homeassistant.config_entries import ( CONN_CLASS_CLOUD_PUSH, ConfigEntry, + ConfigEntryNotReady, ConfigFlow, OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError -from pyadtpulse import PyADTPulse from pyadtpulse.const import ( ADT_DEFAULT_KEEPALIVE_INTERVAL, ADT_DEFAULT_POLL_INTERVAL, @@ -26,6 +26,15 @@ API_HOST_CA, DEFAULT_API_HOST, ) +from pyadtpulse.exceptions import ( + PulseAccountLockedError, + PulseAuthenticationError, + PulseConnectionError, + PulseGatewayOfflineError, + PulseMFARequiredError, + PulseServiceTemporarilyUnavailableError, +) +from pyadtpulse.pyadtpulse_async import PyADTPulseAsync from pyadtpulse.site import ADTPulseSite from .const import ( @@ -58,24 +67,19 @@ async def validate_input(data: dict[str, str]) -> dict[str, str]: Dict[str, str | bool]: "title" : username used to validate "login result": True if login succeeded """ - result = False - adtpulse = PyADTPulse( + adtpulse = PyADTPulseAsync( data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_FINGERPRINT], service_host=data[CONF_HOSTNAME], - do_login=False, ) try: - result = await adtpulse.async_login() - if not result: - LOG.error("Could not validate login info for ADT Pulse") - raise InvalidAuth("Could not validate ADT Pulse login info") + await adtpulse.async_login() site: ADTPulseSite = adtpulse.site site_id = site.id except Exception as ex: LOG.error("ERROR VALIDATING INPUT") - raise CannotConnect from ex + raise ex finally: await adtpulse.async_logout() return {"title": f"ADT: Site {site_id}"} @@ -137,10 +141,19 @@ async def async_step_user( if user_input is not None: try: info = await self.validate_input(user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: + except PulseAuthenticationError: errors["base"] = "invalid_auth" + except PulseMFARequiredError: + errors["base"] = "mfa_required" + except ( + PulseAccountLockedError, + PulseGatewayOfflineError, + PulseServiceTemporarilyUnavailableError, + ) as ex: + errors["base"] = "service_unavailable" + raise ConfigEntryNotReady from ex + except PulseConnectionError: + errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except LOG.exception("Unexpected exception") errors["base"] = "unknown" @@ -243,11 +256,3 @@ async def async_step_init( data_schema=self._get_options_schema(user_input), errors=result, ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/custom_components/adtpulse/coordinator.py b/custom_components/adtpulse/coordinator.py index e8c2568..5b1ec2f 100644 --- a/custom_components/adtpulse/coordinator.py +++ b/custom_components/adtpulse/coordinator.py @@ -1,14 +1,21 @@ """ADT Pulse Update Coordinator.""" + from __future__ import annotations from logging import getLogger -from asyncio import Task -from typing import Any +from asyncio import CancelledError, Task from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import as_local, utc_from_timestamp +from pyadtpulse.exceptions import ( + PulseExceptionWithBackoff, + PulseExceptionWithRetry, + PulseLoginException, +) +from pyadtpulse.pyadtpulse_async import PyADTPulseAsync -from . import PyADTPulse from .const import ADTPULSE_DOMAIN LOG = getLogger(__name__) @@ -17,7 +24,7 @@ class ADTPulseDataUpdateCoordinator(DataUpdateCoordinator): """Update Coordinator for ADT Pulse entities.""" - def __init__(self, hass: HomeAssistant, pulse_service: PyADTPulse): + def __init__(self, hass: HomeAssistant, pulse_service: PyADTPulseAsync): """Initialize Pulse data update coordinator. Args: @@ -26,28 +33,89 @@ def __init__(self, hass: HomeAssistant, pulse_service: PyADTPulse): """ LOG.debug("%s: creating update coordinator", ADTPULSE_DOMAIN) self._adt_pulse = pulse_service - self._push_wait_task: Task[None] | None = None - super().__init__(hass, LOG, name=ADTPULSE_DOMAIN) + self._update_task: Task | None = None + super().__init__( + hass, + LOG, + name=ADTPULSE_DOMAIN, + ) @property - def adtpulse(self) -> PyADTPulse: + def adtpulse(self) -> PyADTPulseAsync: """Return the ADT Pulse service object.""" return self._adt_pulse - async def stop(self, _: Any) -> None: - """Stop the update coordinator.""" - if self._push_wait_task: - self._push_wait_task.cancel() + async def start(self) -> None: + """Start ADT Pulse update coordinator. - async def _async_update_data(self) -> None: - self._push_wait_task = self.hass.async_create_background_task( - self._pulse_push_task(), "ADT Pulse push wait task" - ) - return None + This doesn't really need to be async, but it is to yield the event loop. + """ + if not self._update_task: + ce = self.config_entry + if ce: + self._update_task = ce.async_create_background_task( + self.hass, self._async_update_data(), "ADT Pulse Data Update" + ) + else: + raise ConfigEntryNotReady + + async def stop(self): + """Stop ADT Pulse update coordinator.""" + if self._update_task: + if not self._update_task.cancelled(): + self._update_task.cancel() + await self._update_task + self._update_task = None - async def _pulse_push_task(self) -> None: - while True: + async def _async_update_data(self) -> None: + """Fetch data from ADT Pulse.""" + while not self._shutdown_requested and not self.hass.is_stopping: LOG.debug("%s: coordinator waiting for updates", ADTPULSE_DOMAIN) - await self._adt_pulse.wait_for_update() + update_exception: Exception | None = None + try: + await self._adt_pulse.wait_for_update() + except PulseLoginException as ex: + # this should never happen + LOG.error( + "%s: ADT Pulse login failed during coordinator update: %s", + ADTPULSE_DOMAIN, + ex, + ) + if self.config_entry: + self.config_entry.async_start_reauth(self.hass) + return + except PulseExceptionWithRetry as ex: + if ex.retry_time: + LOG.debug( + "%s: coordinator received retryable exception will retry at %s", + ADTPULSE_DOMAIN, + as_local(utc_from_timestamp(ex.retry_time)), + ) + update_exception = ex + except PulseExceptionWithBackoff as ex: + update_exception = ex + LOG.debug( + "%s: coordinator received backoff exception, backing off for %s seconds", + ADTPULSE_DOMAIN, + ex.backoff.get_current_backoff_interval(), + ) + except CancelledError: + LOG.debug("%s: coordinator received cancellation", ADTPULSE_DOMAIN) + return + except Exception as ex: + LOG.error( + "%s: coordinator received unknown exception %s, exiting...", + ADTPULSE_DOMAIN, + ex, + ) + raise LOG.debug("%s: coordinator received update notification", ADTPULSE_DOMAIN) - self.async_set_updated_data(None) + + if update_exception: + self.async_set_update_error(update_exception) + # async_set_update_error will only notify listeners on first error + if not self.last_update_success: + self.async_update_listeners() + else: + self.last_exception = None + self.async_set_updated_data(None) diff --git a/custom_components/adtpulse/manifest.json b/custom_components/adtpulse/manifest.json index 4aa832a..88cfe96 100644 --- a/custom_components/adtpulse/manifest.json +++ b/custom_components/adtpulse/manifest.json @@ -4,11 +4,11 @@ "codeowners": ["@rsnodgrass", "@rlippmann"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/rlippmann/hass-adtpulse/", + "documentation": "https://github.com/rsnodgrass/hass-adtpulse/", "iot_class": "cloud_push", - "issue_tracker": "https://github.com/rlippmann/hass-adtpulse/issues", + "issue_tracker": "https://github.com/rsnodgrass/hass-adtpulse/issues", "requirements": [ - "pyadtpulse==1.1.5" + "pyadtpulse==1.2.0" ], - "version": "0.3.5" + "version": "0.4.0" } diff --git a/custom_components/adtpulse/sensor.py b/custom_components/adtpulse/sensor.py new file mode 100644 index 0000000..4b2f9d0 --- /dev/null +++ b/custom_components/adtpulse/sensor.py @@ -0,0 +1,229 @@ +"""ADT Pulse sensors.""" + +from __future__ import annotations + +from logging import getLogger +from datetime import datetime, timedelta + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import as_timestamp, now +from pyadtpulse.exceptions import ( + PulseAccountLockedError, + PulseClientConnectionError, + PulseExceptionWithBackoff, + PulseExceptionWithRetry, + PulseGatewayOfflineError, + PulseServerConnectionError, + PulseServiceTemporarilyUnavailableError, +) + +from .base_entity import ADTPulseEntity +from .const import ADTPULSE_DOMAIN +from .coordinator import ADTPulseDataUpdateCoordinator +from .utils import get_gateway_unique_id + +LOG = getLogger(__name__) + +COORDINATOR_EXCEPTION_MAP: dict[type[Exception], tuple[str, str]] = { + PulseAccountLockedError: ("Account Locked", "mdi:account-network-off"), + PulseClientConnectionError: ("Client Connection Error", "mdi:network-off"), + PulseServerConnectionError: ("Server Connection Error", "mdi:server-network-off"), + PulseGatewayOfflineError: ("Gateway Offline", "mdi:cloud-lock"), + PulseServiceTemporarilyUnavailableError: ( + "Service Temporarily Unavailable", + "mdi:lan-pending", + ), +} +CONNECTION_STATUS_OK = ("Connection OK", "mdi:hand-okay") +CONNECTION_STATUSES = list(COORDINATOR_EXCEPTION_MAP.values()) +CONNECTION_STATUSES.append(CONNECTION_STATUS_OK) +CONNECTION_STATUS_STRINGS = [value[0] for value in CONNECTION_STATUSES] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors for an ADT Pulse installation.""" + coordinator: ADTPulseDataUpdateCoordinator = hass.data[ADTPULSE_DOMAIN][ + entry.entry_id + ] + + async_add_entities( + [ + ADTPulseConnectionStatus(coordinator), + ADTPulseNextRefresh(coordinator), + ] + ) + + +class ADTPulseConnectionStatus(SensorEntity, ADTPulseEntity): + """ADT Pulse connection status sensor.""" + + def __init__(self, coordinator: ADTPulseDataUpdateCoordinator): + """Initialize connection status sensor. + + Args: + coordinator (ADTPulseDataUpdateCoordinator): + HASS data update coordinator + """ + site_name = coordinator.adtpulse.site.id + LOG.debug( + "%s: adding connection status sensor for site %s", + ADTPULSE_DOMAIN, + site_name, + ) + + self._name = f"ADT Pulse Connection Status - Site: {site_name}" + super().__init__(coordinator, self._name) + + @property + def name(self) -> str | None: + """Return the name of the sensor.""" + return "Pulse Connection Status" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return f"{self.coordinator.adtpulse.site.id}-connection-status" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return True + + @property + def device_class(self) -> str | None: + """Return the class of this sensor.""" + return SensorDeviceClass.ENUM + + @property + def options(self) -> list[str]: + """Return the list of available options.""" + return CONNECTION_STATUS_STRINGS + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if not self.coordinator.last_exception: + return CONNECTION_STATUS_OK[0] + coordinator_exception = COORDINATOR_EXCEPTION_MAP.get( + type(self.coordinator.last_exception), ("", "") + ) + if coordinator_exception: + return coordinator_exception[0] + return None + + @property + def icon(self) -> str: + """Return the icon of this sensor.""" + if not self.coordinator.last_exception: + return CONNECTION_STATUS_OK[1] + coordinator_exception = COORDINATOR_EXCEPTION_MAP.get( + type(self.coordinator.last_exception), ("", "") + ) + if coordinator_exception: + return coordinator_exception[1] + return "mdi:alert-octogram" + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + if self._gateway.serial_number: + return DeviceInfo( + identifiers={(ADTPULSE_DOMAIN, self._gateway.serial_number)}, + ) + return DeviceInfo( + identifiers={(ADTPULSE_DOMAIN, get_gateway_unique_id(self._site))}, + ) + + @callback + def _handle_coordinator_update(self) -> None: + LOG.debug("Setting %s status to %s", self.name, self.native_value) + self.async_write_ha_state() + + +class ADTPulseNextRefresh(SensorEntity, ADTPulseEntity): + """ADT Pulse next refresh sensor.""" + + def __init__(self, coordinator: ADTPulseDataUpdateCoordinator): + """Initialize next refresh sensor. + + Args: + coordinator (ADTPulseDataUpdateCoordinator): + HASS data update coordinator + """ + site_name = coordinator.adtpulse.site.id + LOG.debug( + "%s: adding next refresh sensor for site %s", + ADTPULSE_DOMAIN, + site_name, + ) + + self._name = f"ADT Pulse Next Refresh - Site: {site_name}" + super().__init__(coordinator, self._name) + + @property + def device_class(self) -> str | None: + """Return the class of this sensor.""" + return SensorDeviceClass.TIMESTAMP + + @property + def icon(self) -> str | None: + """Return the icon of this sensor.""" + if self.native_value: + return "mdi:timer-pause" + return None + + @property + def native_value(self) -> datetime | None: + """Return the state of the sensor.""" + timediff = 0 + curr_time = now() + last_ex = self.coordinator.last_exception + if not last_ex: + return None + if isinstance(last_ex, PulseExceptionWithRetry): + if last_ex.retry_time is None: + return None + timediff = last_ex.retry_time - as_timestamp(now()) + elif isinstance(last_ex, PulseExceptionWithBackoff): + timediff = last_ex.backoff.get_current_backoff_interval() + if timediff < 60: + return None + return curr_time + timedelta(seconds=timediff) + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + if self._gateway.serial_number: + return DeviceInfo( + identifiers={(ADTPULSE_DOMAIN, self._gateway.serial_number)}, + ) + + return DeviceInfo( + identifiers={(ADTPULSE_DOMAIN, get_gateway_unique_id(self._site))}, + ) + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return f"{self.coordinator.adtpulse.site.id}-next-refresh" + + @property + def name(self) -> str | None: + """Return the name of the sensor.""" + return "Pulse Next Refresh" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_exception is not None + + @callback + def _handle_coordinator_update(self) -> None: + LOG.debug("Setting %s status to %s", self.name, self.native_value) + self.async_write_ha_state() diff --git a/custom_components/adtpulse/services.yaml b/custom_components/adtpulse/services.yaml index 912ace7..ec48cfa 100644 --- a/custom_components/adtpulse/services.yaml +++ b/custom_components/adtpulse/services.yaml @@ -7,7 +7,3 @@ force_away: target: entity: domain: alarm_control_panel - -quick_relogin: - name: "Relogin to Pulse" - description: "Performs a re-login to Pulse" diff --git a/custom_components/adtpulse/strings.json b/custom_components/adtpulse/strings.json index 102930f..f2d3b3b 100644 --- a/custom_components/adtpulse/strings.json +++ b/custom_components/adtpulse/strings.json @@ -17,6 +17,8 @@ "error": { "cannot_connect": "Cannot connect to ADT Pulse", "invalid_auth": "Cannot authorize with ADT Pulse with the provided credentials", + "mfa_required": "Multi-factor authentication is required to authorize with ADT Pulse", + "service_unavailable": "ADT Pulse service is unavailable", "unknown": "Unknown Error occurred" }, "abort": { diff --git a/custom_components/adtpulse/translations/en.json b/custom_components/adtpulse/translations/en.json index 102930f..f2d3b3b 100644 --- a/custom_components/adtpulse/translations/en.json +++ b/custom_components/adtpulse/translations/en.json @@ -17,6 +17,8 @@ "error": { "cannot_connect": "Cannot connect to ADT Pulse", "invalid_auth": "Cannot authorize with ADT Pulse with the provided credentials", + "mfa_required": "Multi-factor authentication is required to authorize with ADT Pulse", + "service_unavailable": "ADT Pulse service is unavailable", "unknown": "Unknown Error occurred" }, "abort": { diff --git a/custom_components/adtpulse/utils.py b/custom_components/adtpulse/utils.py index 6530bda..281c7a1 100644 --- a/custom_components/adtpulse/utils.py +++ b/custom_components/adtpulse/utils.py @@ -1,4 +1,5 @@ """ADT Pulse utility functions.""" + from __future__ import annotations from homeassistant.core import HomeAssistant