Skip to content

Commit

Permalink
Merge pull request #39 from rlippmann:0.3.3
Browse files Browse the repository at this point in the history
0.3.3
  • Loading branch information
rlippmann authored Oct 12, 2023
2 parents a4363c0 + a7eb94f commit 6f4e754
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 136 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## 0.3.3 (2023-10-12)

* bump pyadtpulse to 1.1.3. This should fix alarm not updating issue
* add force stay and force away services
* add relogin service
* refactor code to use base entity. This should cause most entities to become unavailable if the gateway goes offline
* disallow invalid alarm state changes
* revert alarm card functionality. All states will be available, but exceptions will be thrown if an invalid state is requested.

## 0.3.2 (2023-10-08)

Alarm control panel updates:
Expand Down
63 changes: 11 additions & 52 deletions custom_components/adtpulse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,17 @@
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import entity_registry
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 homeassistant.util import slugify
from pyadtpulse import PyADTPulse
from pyadtpulse.const import (
ADT_DEFAULT_KEEPALIVE_INTERVAL,
ADT_DEFAULT_POLL_INTERVAL,
ADT_DEFAULT_RELOGIN_INTERVAL,
STATE_OK,
STATE_ONLINE,
)
from pyadtpulse.site import ADTPulseSite
from pyadtpulse.zones import ADTPulseZoneData

from .const import (
ADTPULSE_DOMAIN,
Expand All @@ -48,53 +43,12 @@

SUPPORTED_PLATFORMS = ["alarm_control_panel", "binary_sensor"]

CONFIG_SCHEMA = config_entry_only_config_schema(ADTPULSE_DOMAIN)

def get_gateway_unique_id(site: ADTPulseSite) -> str:
"""Get unique ID for gateway."""
return f"adt_pulse_gateway_{site.id}"


def get_alarm_unique_id(site: ADTPulseSite) -> str:
"""Get unique ID for alarm."""
return f"adt_pulse_alarm_{site.id}"


def zone_open(zone: ADTPulseZoneData) -> bool:
"""Determine if a zone is opened."""
return not zone.state == STATE_OK


def zone_trouble(zone: ADTPulseZoneData) -> bool:
"""Determine if a zone is in trouble state."""
return not zone.status == STATE_ONLINE


@callback
def migrate_entity_name(
hass: HomeAssistant, site: ADTPulseSite, platform_name: str, entity_uid: str
) -> None:
"""Migrate old entity names."""
registry = entity_registry.async_get(hass)
if registry is None:
return
# this seems backwards
entity_id = registry.async_get_entity_id(
platform_name,
ADTPULSE_DOMAIN,
entity_uid,
)
if entity_id is not None:
# change has_entity_name to True and set name to None for devices
registry.async_update_entity(entity_id, has_entity_name=True, name=None)
# rename site name to site id for entities which have site name
slugified_site_name = slugify(site.name)
if slugified_site_name in entity_id:
registry.async_update_entity(
entity_id, new_entity_id=entity_id.replace(slugified_site_name, site.id)
)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(
hass: HomeAssistant, config: ConfigType # pylint: disable=unused-argument
) -> bool:
"""Start up the ADT Pulse HA integration.
Args:
Expand Down Expand Up @@ -182,6 +136,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.stop)
)
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)
return True


Expand Down
86 changes: 51 additions & 35 deletions custom_components/adtpulse/alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util import as_local
from pyadtpulse.alarm_panel import (
ADT_ALARM_ARMING,
Expand All @@ -34,15 +36,15 @@
)
from pyadtpulse.site import ADTPulseSite

from . import (
from .base_entity import ADTPulseEntity
from .const import ADTPULSE_DOMAIN
from .coordinator import ADTPulseDataUpdateCoordinator
from .utils import (
get_alarm_unique_id,
get_gateway_unique_id,
migrate_entity_name,
zone_open,
zone_trouble,
system_can_be_armed,
)
from .const import ADTPULSE_DATA_ATTRIBUTION, ADTPULSE_DOMAIN
from .coordinator import ADTPulseDataUpdateCoordinator

LOG = getLogger(__name__)

Expand All @@ -64,6 +66,11 @@
ADT_ALARM_UNKNOWN: "mdi:shield-bug",
}

FORCE_ARM = "force arm"
ARM_ERROR_MESSAGE = (
f"Pulse system cannot be armed due to opened/tripped zone - use {FORCE_ARM}"
)


async def async_setup_entry(
hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
Expand All @@ -85,19 +92,22 @@ async def async_setup_entry(
alarm_devices = [ADTPulseAlarm(coordinator, site)]

async_add_entities(alarm_devices)
platform = async_get_current_platform()
platform.async_register_entity_service(
"force_stay", {}, "async_alarm_arm_force_stay"
)
platform.async_register_entity_service(
"force_away", {}, "async_alarm_arm_custom_bypass"
)


class ADTPulseAlarm(
CoordinatorEntity[ADTPulseDataUpdateCoordinator], alarm.AlarmControlPanelEntity
):
class ADTPulseAlarm(ADTPulseEntity, alarm.AlarmControlPanelEntity):
"""An alarm_control_panel implementation for ADT Pulse."""

def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, site: ADTPulseSite):
"""Initialize the alarm control panel."""
LOG.debug("%s: adding alarm control panel for %s", ADTPULSE_DOMAIN, site.id)
self._name = f"ADT Alarm Panel - Site {site.id}"
self._site = site
self._alarm = site.alarm_control_panel
self._assumed_state: str | None = None
super().__init__(coordinator, self._name)

Expand All @@ -118,11 +128,6 @@ def state(self) -> str:
def assumed_state(self) -> bool:
return self._assumed_state is None

@property
def attribution(self) -> str | None:
"""Return API data attribution."""
return ADTPULSE_DATA_ATTRIBUTION

@property
def icon(self) -> str:
"""Return the icon."""
Expand All @@ -131,16 +136,8 @@ def icon(self) -> str:
return ALARM_ICON_MAP[self._alarm.status]

@property
def supported_features(self) -> AlarmControlPanelEntityFeature | None:
def supported_features(self) -> AlarmControlPanelEntityFeature:
"""Return the list of supported features."""
if self.state != STATE_ALARM_DISARMED:
return None
retval = AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
if self._site.zones_as_dict is None:
return retval
for zone in self._site.zones_as_dict.values():
if zone_open(zone) or zone_trouble(zone):
return retval
return (
AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
Expand All @@ -166,10 +163,14 @@ async def _perform_alarm_action(
) -> None:
result = True
LOG.debug("%s: Setting Alarm to %s", ADTPULSE_DOMAIN, action)
if action != STATE_ALARM_DISARMED:
await self._check_if_system_armable(action)
if self.state == action:
LOG.warning("Attempting to set alarm to same state, ignoring")
return
if action == STATE_ALARM_DISARMED:
if not self._gateway.is_online:
self._assumed_state = action
elif action == STATE_ALARM_DISARMED:
self._assumed_state = STATE_ALARM_DISARMING
else:
self._assumed_state = STATE_ALARM_ARMING
Expand All @@ -188,6 +189,16 @@ async def async_alarm_disarm(self, code: str | None = None) -> None:
self._site.async_disarm(), STATE_ALARM_DISARMED
)

async def _check_if_system_armable(self, new_state: str) -> None:
"""Checks if we can arm the system, raises exceptions if not."""
if self.state != STATE_ALARM_DISARMED:
raise HomeAssistantError(
f"Cannot set alarm to {new_state} "
f"because currently set to {self.state}"
)
if not new_state == FORCE_ARM and not system_can_be_armed(self._site):
raise HomeAssistantError(ARM_ERROR_MESSAGE)

async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self._perform_alarm_action(
Expand All @@ -204,17 +215,17 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None:
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
"""Send force arm command."""
await self._perform_alarm_action(
self._site.async_arm_away(force_arm=True), "force arm"
self._site.async_arm_away(force_arm=True), FORCE_ARM
)

@property
def name(self) -> str | None:
"""Return the name of the alarm."""
return None
async def async_alarm_arm_force_stay(self) -> None:
"""Send force arm stay command.
@property
def has_entity_name(self) -> bool:
return True
This type of arming isn't implemented in HA, but we put it in anyway for
use as a service call."""
await self._perform_alarm_action(
self._site.async_arm_home(force_arm=True), STATE_ALARM_ARMED_HOME
)

@property
def extra_state_attributes(self) -> dict:
Expand Down Expand Up @@ -244,6 +255,11 @@ def code_format(self) -> None:
"""
return None

@property
def available(self) -> bool:
"""Alarm panel is always available even if gateway isn't."""
return True

@callback
def _handle_coordinator_update(self) -> None:
LOG.debug(
Expand Down
43 changes: 36 additions & 7 deletions custom_components/adtpulse/base_entity.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""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 .const import LOG
from .const import ADTPULSE_DATA_ATTRIBUTION
from .coordinator import ADTPulseDataUpdateCoordinator

LOG = getLogger(__name__)


class ADTPulseEntity(CoordinatorEntity[ADTPulseDataUpdateCoordinator]):
"""Base Entity class for ADT Pulse devices."""
Expand All @@ -19,14 +24,26 @@ def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, name: str):
name (str): entity name
"""
self._name = name

# save references to commonly used objects
self._pulse_connection = coordinator.adtpulse
self._site = self._pulse_connection.site
self._gateway = self._site.gateway
self._alarm = self._site.alarm_control_panel
self._attrs: dict = {}
super().__init__(coordinator)

# Base level properties that can be overridden by subclasses
@property
def name(self) -> str | None:
"""Return the display name for this sensor.
Should generally be none since using has_entity_name."""
return None

@property
def name(self) -> str:
"""Return the display name for this sensor."""
return self._name
def has_entity_name(self) -> bool:
"""Returns has_entity_name. Should generally be true."""
return True

@property
def icon(self) -> str:
Expand All @@ -38,13 +55,25 @@ def icon(self) -> str:
return "mdi:gauge"

@property
def extra_state_attributes(self) -> dict:
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the device state attributes."""
return self._attrs

@property
def available(self) -> bool:
"""Returns whether an entity is available.
Generally false if gateway is offline."""
return self._gateway.is_online

@property
def attribution(self) -> str:
"""Return API data attribution."""
return ADTPULSE_DATA_ATTRIBUTION

@callback
def _handle_coordinator_update(self) -> None:
"""Call update method."""
LOG.debug(f"Scheduling update ADT Pulse entity {self._name}")
LOG.debug("Scheduling update ADT Pulse entity %s", self._name)
# inform HASS that ADT Pulse data for this entity has been updated
self.async_write_ha_state()
Loading

0 comments on commit 6f4e754

Please sign in to comment.