diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac0114..a47c036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.3.2 (2023-10-08) + +Alarm control panel updates: +* update alarm capabilities based upon existing state of alarm +* disable setting alarm to existing state +* add arming/disarming icons +* add assumed state +* remove site id from attributes +* raise HomeAssistant exception on alarm set failure +* write ha state even if alarm action fails + +## 0.3.1 (2023-10-07) + +* fix typo in manifest preventing install + ## 0.3.0 (2023-10-07) * bump pyadtpulse to 1.1.2 diff --git a/custom_components/adtpulse/__init__.py b/custom_components/adtpulse/__init__.py index fc68c5b..86a70fe 100644 --- a/custom_components/adtpulse/__init__.py +++ b/custom_components/adtpulse/__init__.py @@ -29,8 +29,11 @@ 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, @@ -56,6 +59,16 @@ def get_alarm_unique_id(site: ADTPulseSite) -> str: 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 diff --git a/custom_components/adtpulse/alarm_control_panel.py b/custom_components/adtpulse/alarm_control_panel.py index 6ced5fa..d0c1664 100644 --- a/custom_components/adtpulse/alarm_control_panel.py +++ b/custom_components/adtpulse/alarm_control_panel.py @@ -19,6 +19,7 @@ STATE_UNAVAILABLE, ) 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 @@ -33,7 +34,13 @@ ) from pyadtpulse.site import ADTPulseSite -from . import get_alarm_unique_id, get_gateway_unique_id, migrate_entity_name +from . import ( + get_alarm_unique_id, + get_gateway_unique_id, + migrate_entity_name, + zone_open, + zone_trouble, +) from .const import ADTPULSE_DATA_ATTRIBUTION, ADTPULSE_DOMAIN from .coordinator import ADTPulseDataUpdateCoordinator @@ -45,11 +52,13 @@ ADT_ALARM_DISARMING: STATE_ALARM_DISARMING, ADT_ALARM_HOME: STATE_ALARM_ARMED_HOME, ADT_ALARM_OFF: STATE_ALARM_DISARMED, - ADT_ALARM_UNKNOWN: None, + ADT_ALARM_UNKNOWN: STATE_UNAVAILABLE, } ALARM_ICON_MAP = { + ADT_ALARM_ARMING: "mdi:shield-refresh", ADT_ALARM_AWAY: "mdi:shield-lock", + ADT_ALARM_DISARMING: "mdi-shield-sync", ADT_ALARM_HOME: "mdi:shield-home", ADT_ALARM_OFF: "mdi:shield-off", ADT_ALARM_UNKNOWN: "mdi:shield-bug", @@ -89,6 +98,7 @@ def __init__(self, coordinator: ADTPulseDataUpdateCoordinator, site: ADTPulseSit 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) @property @@ -98,10 +108,16 @@ def state(self) -> str: Returns: str: the status """ + if self._assumed_state is not None: + return self._assumed_state if self._alarm.status in ALARM_MAP: return ALARM_MAP[self._alarm.status] return STATE_UNAVAILABLE + @property + def assumed_state(self) -> bool: + return self._assumed_state is None + @property def attribution(self) -> str | None: """Return API data attribution.""" @@ -115,8 +131,16 @@ def icon(self) -> str: return ALARM_ICON_MAP[self._alarm.status] @property - def supported_features(self) -> AlarmControlPanelEntityFeature: + def supported_features(self) -> AlarmControlPanelEntityFeature | None: """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 @@ -140,23 +164,41 @@ def device_info(self) -> DeviceInfo: async def _perform_alarm_action( self, arm_disarm_func: Coroutine[bool | None, None, bool], action: str ) -> None: + result = True LOG.debug("%s: Setting Alarm to %s", ADTPULSE_DOMAIN, action) - if await arm_disarm_func: - self.async_write_ha_state() + if self.state == action: + LOG.warning("Attempting to set alarm to same state, ignoring") + return + if action == STATE_ALARM_DISARMED: + self._assumed_state = STATE_ALARM_DISARMING else: + self._assumed_state = STATE_ALARM_ARMING + self.async_write_ha_state() + result = await arm_disarm_func + if not result: LOG.warning("Could not %s ADT Pulse alarm", action) + self._assumed_state = None + self.async_write_ha_state() + if not result: + raise HomeAssistantError(f"Could not set alarm status to {action}") async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._perform_alarm_action(self._site.async_disarm(), "disarm") + await self._perform_alarm_action( + self._site.async_disarm(), STATE_ALARM_DISARMED + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._perform_alarm_action(self._site.async_arm_home(), "arm home") + await self._perform_alarm_action( + self._site.async_arm_home(), STATE_ALARM_ARMED_HOME + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._perform_alarm_action(self._site.async_arm_away(), "arm away") + await self._perform_alarm_action( + self._site.async_arm_away(), STATE_ALARM_ARMED_AWAY + ) # Pulse can arm away or home with bypass async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: @@ -178,7 +220,6 @@ def has_entity_name(self) -> bool: def extra_state_attributes(self) -> dict: """Return the state attributes.""" return { - "site_id": self._site.id, "last_update_time": as_local( datetime.fromtimestamp(self._alarm.last_update) ), diff --git a/custom_components/adtpulse/binary_sensor.py b/custom_components/adtpulse/binary_sensor.py index f0e6edc..1d88478 100644 --- a/custom_components/adtpulse/binary_sensor.py +++ b/custom_components/adtpulse/binary_sensor.py @@ -22,11 +22,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import as_local -from pyadtpulse.const import STATE_OK, STATE_ONLINE from pyadtpulse.site import ADTPulseSite from pyadtpulse.zones import ADTPulseZoneData -from . import get_alarm_unique_id, get_gateway_unique_id, migrate_entity_name +from . import ( + get_alarm_unique_id, + get_gateway_unique_id, + migrate_entity_name, + zone_open, + zone_trouble, +) from .const import ADTPULSE_DATA_ATTRIBUTION, ADTPULSE_DOMAIN from .coordinator import ADTPulseDataUpdateCoordinator @@ -214,8 +219,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 not self._my_zone.status == STATE_ONLINE - return not self._my_zone.state == STATE_OK + return zone_trouble(self._my_zone) + return zone_open(self._my_zone) @property def device_class(self) -> BinarySensorDeviceClass: diff --git a/custom_components/adtpulse/manifest.json b/custom_components/adtpulse/manifest.json index 0c519af..516e06a 100644 --- a/custom_components/adtpulse/manifest.json +++ b/custom_components/adtpulse/manifest.json @@ -10,5 +10,5 @@ "requirements": [ "pyadtpulse>=1.1.2" ], - "version": "0.3.0" + "version": "0.3.2" }