diff --git a/CHANGELOG.md b/CHANGELOG.md index a47c036..8bcddf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/custom_components/adtpulse/__init__.py b/custom_components/adtpulse/__init__.py index 81f305c..ade21c2 100644 --- a/custom_components/adtpulse/__init__.py +++ b/custom_components/adtpulse/__init__.py @@ -21,6 +21,7 @@ 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 ( @@ -42,6 +43,8 @@ SUPPORTED_PLATFORMS = ["alarm_control_panel", "binary_sensor"] +CONFIG_SCHEMA = config_entry_only_config_schema(ADTPULSE_DOMAIN) + async def async_setup( hass: HomeAssistant, config: ConfigType # pylint: disable=unused-argument @@ -133,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 diff --git a/custom_components/adtpulse/alarm_control_panel.py b/custom_components/adtpulse/alarm_control_panel.py index 29d77d4..ef5e815 100644 --- a/custom_components/adtpulse/alarm_control_panel.py +++ b/custom_components/adtpulse/alarm_control_panel.py @@ -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, @@ -34,14 +36,14 @@ ) from pyadtpulse.site import ADTPulseSite -from .const import ADTPULSE_DATA_ATTRIBUTION, ADTPULSE_DOMAIN +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, ) LOG = getLogger(__name__) @@ -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 @@ -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) @@ -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.""" @@ -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 @@ -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 @@ -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( @@ -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: @@ -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( diff --git a/custom_components/adtpulse/base_entity.py b/custom_components/adtpulse/base_entity.py index 59d3e25..64b16ab 100644 --- a/custom_components/adtpulse/base_entity.py +++ b/custom_components/adtpulse/base_entity.py @@ -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.""" @@ -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: @@ -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() diff --git a/custom_components/adtpulse/binary_sensor.py b/custom_components/adtpulse/binary_sensor.py index a96f410..8c7444f 100644 --- a/custom_components/adtpulse/binary_sensor.py +++ b/custom_components/adtpulse/binary_sensor.py @@ -20,19 +20,19 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import as_local from pyadtpulse.site import ADTPulseSite from pyadtpulse.zones import ADTPulseZoneData -from .const import ADTPULSE_DATA_ATTRIBUTION, ADTPULSE_DOMAIN +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, + zone_is_in_trouble, + zone_is_open, ) LOG = getLogger(__name__) @@ -103,9 +103,7 @@ async def async_setup_entry( async_add_entities(entities) -class ADTPulseZoneSensor( - CoordinatorEntity[ADTPulseDataUpdateCoordinator], BinarySensorEntity -): +class ADTPulseZoneSensor(ADTPulseEntity, BinarySensorEntity): """HASS zone binary sensor implementation for ADT Pulse.""" # zone = {'id': 'sensor-12', 'name': 'South Office Motion', @@ -167,7 +165,6 @@ def __init__( ) else: LOG.debug("%s: adding zone sensor for site %s", ADTPULSE_DOMAIN, site.id) - self._site = site self._zone_id = zone_id self._is_trouble_indicator = trouble_indicator self._my_zone = self._get_my_zone(site, zone_id) @@ -187,10 +184,6 @@ def name(self) -> str | None: return "Trouble" return None - @property - def has_entity_name(self) -> bool: - return True - @property def unique_id(self) -> str: """Return HA unique id.""" @@ -219,8 +212,8 @@ def is_on(self) -> bool: """Return True if the binary sensor is on.""" # sensor is considered tripped if the state is anything but OK if self._is_trouble_indicator: - return zone_trouble(self._my_zone) - return zone_open(self._my_zone) + return zone_is_in_trouble(self._my_zone) + return zone_is_open(self._my_zone) @property def device_class(self) -> BinarySensorDeviceClass: @@ -255,11 +248,6 @@ def device_info(self) -> DeviceInfo: manufacturer="ADT", ) - @property - def attribution(self) -> str: - """Return API data attribution.""" - return ADTPULSE_DATA_ATTRIBUTION - @callback def _handle_coordinator_update(self) -> None: LOG.debug( @@ -271,9 +259,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class ADTPulseGatewaySensor( - CoordinatorEntity[ADTPulseDataUpdateCoordinator], BinarySensorEntity -): +class ADTPulseGatewaySensor(ADTPulseEntity, BinarySensorEntity): """HASS Gateway Online Binary Sensor.""" def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, site: ADTPulseSite): @@ -287,10 +273,8 @@ def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, site: ADTPulseSit LOG.debug( "%s: adding gateway status sensor for site %s", ADTPULSE_DOMAIN, site.name ) - self._gateway = site.gateway - self._site = site self._device_class = BinarySensorDeviceClass.CONNECTIVITY - self._name = f"ADT Pulse Gateway Status - Site: {self._site.name}" + self._name = f"ADT Pulse Gateway Status - Site: {site.name}" super().__init__(coordinator, self._name) @property @@ -298,14 +282,6 @@ def is_on(self) -> bool: """Return if gateway is online.""" return self._gateway.is_online - @property - def name(self) -> str | None: - return None - - @property - def has_entity_name(self) -> bool: - return True - # FIXME: Gateways only support one site? @property def unique_id(self) -> str: @@ -318,11 +294,6 @@ def icon(self) -> str: return "mdi:lan-connect" return "mdi:lan-disconnect" - @property - def attribution(self) -> str | None: - """Return API data attribution.""" - return ADTPULSE_DATA_ATTRIBUTION - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the device state attributes.""" diff --git a/custom_components/adtpulse/manifest.json b/custom_components/adtpulse/manifest.json index 516e06a..b92e4c3 100644 --- a/custom_components/adtpulse/manifest.json +++ b/custom_components/adtpulse/manifest.json @@ -8,7 +8,7 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/rlippmann/hass-adtpulse/issues", "requirements": [ - "pyadtpulse>=1.1.2" + "pyadtpulse>=1.1.3" ], - "version": "0.3.2" + "version": "0.3.3" } diff --git a/custom_components/adtpulse/services.yaml b/custom_components/adtpulse/services.yaml index e69de29..912ace7 100644 --- a/custom_components/adtpulse/services.yaml +++ b/custom_components/adtpulse/services.yaml @@ -0,0 +1,13 @@ +force_stay: + target: + entity: + domain: alarm_control_panel + +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 05aee81..102930f 100644 --- a/custom_components/adtpulse/strings.json +++ b/custom_components/adtpulse/strings.json @@ -38,5 +38,19 @@ "min_relogin":"Pulse re-login Interval must be greater than 20 minutes", "max_keepalive":"Pulse keepalive Interval must be less than 15 minutes" } + }, + "services": { + "force_stay": { + "name": "Alarm Force Stay", + "description": "Force arm Pulse in stay/home mode" + }, + "force_away": { + "name": "Alarm Force Away", + "description": "Force arm Pulse in away mode. This is the same as arming custom bypass" + }, + "quick_relogin": { + "name": "Relogin to Pulse", + "description": "Performs a re-login to Pulse" + } } } diff --git a/custom_components/adtpulse/translations/en.json b/custom_components/adtpulse/translations/en.json index 05aee81..102930f 100644 --- a/custom_components/adtpulse/translations/en.json +++ b/custom_components/adtpulse/translations/en.json @@ -38,5 +38,19 @@ "min_relogin":"Pulse re-login Interval must be greater than 20 minutes", "max_keepalive":"Pulse keepalive Interval must be less than 15 minutes" } + }, + "services": { + "force_stay": { + "name": "Alarm Force Stay", + "description": "Force arm Pulse in stay/home mode" + }, + "force_away": { + "name": "Alarm Force Away", + "description": "Force arm Pulse in away mode. This is the same as arming custom bypass" + }, + "quick_relogin": { + "name": "Relogin to Pulse", + "description": "Performs a re-login to Pulse" + } } } diff --git a/custom_components/adtpulse/utils.py b/custom_components/adtpulse/utils.py index 513f8e8..6530bda 100644 --- a/custom_components/adtpulse/utils.py +++ b/custom_components/adtpulse/utils.py @@ -2,7 +2,8 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify from pyadtpulse.const import STATE_OK, STATE_ONLINE from pyadtpulse.site import ADTPulseSite from pyadtpulse.zones import ADTPulseZoneData @@ -14,7 +15,7 @@ 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) + registry = er.async_get(hass) if registry is None: return # this seems backwards @@ -44,14 +45,14 @@ def get_alarm_unique_id(site: ADTPulseSite) -> str: return f"adt_pulse_alarm_{site.id}" -def zone_open(zone: ADTPulseZoneData) -> bool: +def zone_is_open(zone: ADTPulseZoneData) -> bool: """Determine if a zone is opened.""" - return not zone.state == STATE_OK + return zone.state != STATE_OK -def zone_trouble(zone: ADTPulseZoneData) -> bool: +def zone_is_in_trouble(zone: ADTPulseZoneData) -> bool: """Determine if a zone is in trouble state.""" - return not zone.status == STATE_ONLINE + return zone.status != STATE_ONLINE def system_can_be_armed(site: ADTPulseSite) -> bool: @@ -59,7 +60,7 @@ def system_can_be_armed(site: ADTPulseSite) -> bool: zones = site.zones_as_dict if zones is None: return False - for zone in zones: - if zone_open(zone) or zone_trouble(zone): + for zone in zones.values(): + if zone_is_open(zone) or zone_is_in_trouble(zone): return False return True