Skip to content
This repository has been archived by the owner on Feb 2, 2024. It is now read-only.

Commit

Permalink
Merge pull request rsnodgrass#45 from rlippmann/0.4.0
Browse files Browse the repository at this point in the history
0.4.0
  • Loading branch information
rsnodgrass authored Feb 2, 2024
2 parents 26f2deb + 2ea2e4c commit d47314b
Show file tree
Hide file tree
Showing 14 changed files with 424 additions and 85 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/ruff.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
64 changes: 36 additions & 28 deletions custom_components/adtpulse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,38 @@
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,
CONF_HOST,
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,
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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


Expand Down Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions custom_components/adtpulse/alarm_control_panel.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Support for ADT Pulse alarm control panels."""

from __future__ import annotations

from logging import getLogger
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 7 additions & 4 deletions custom_components/adtpulse/base_entity.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""ADT Pulse Entity Base class."""

from __future__ import annotations

from logging import getLogger
from typing import Any, Mapping

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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
13 changes: 9 additions & 4 deletions custom_components/adtpulse/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -274,15 +275,19 @@ 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
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."""
Expand All @@ -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)),
}
Expand Down
47 changes: 26 additions & 21 deletions custom_components/adtpulse/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""HASS ADT Pulse Config Flow."""

from __future__ import annotations

from logging import getLogger
Expand All @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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}"}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Loading

0 comments on commit d47314b

Please sign in to comment.