From 06ae6a7faa2b214a6e89a542b3824d5cd7c5e379 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 28 Oct 2021 16:21:58 -0600 Subject: [PATCH 1/5] Wrap listener callback and ignore exceptions as they shouldn't be allowed to break the vivintpy library --- vivintpy/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vivintpy/entity.py b/vivintpy/entity.py index 49c9e09..002102d 100644 --- a/vivintpy/entity.py +++ b/vivintpy/entity.py @@ -47,4 +47,7 @@ def unsubscribe() -> None: def emit(self, event_name: str, data: dict) -> None: """Run all callbacks for an event.""" for listener in self._listeners.get(event_name, []): - listener(data) + try: + listener(data) + except: # noqa E722 + pass From d9ae340841caf5aeac131c5e0f7c7ebb67af7a6b Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 28 Oct 2021 16:23:30 -0600 Subject: [PATCH 2/5] Handle wireless sensor subdevice grouping --- vivintpy/const.py | 2 + vivintpy/devices/__init__.py | 32 ++++++++-- vivintpy/devices/alarm_panel.py | 98 +++++++++++++++++------------ vivintpy/devices/wireless_sensor.py | 20 ++++++ vivintpy/enums.py | 10 ++- 5 files changed, 115 insertions(+), 47 deletions(-) diff --git a/vivintpy/const.py b/vivintpy/const.py index 48e98df..38b0c54 100644 --- a/vivintpy/const.py +++ b/vivintpy/const.py @@ -91,6 +91,7 @@ class VivintDeviceAttribute: CAPABILITY_CATEGORY = "caca" CURRENT_SOFTWARE_VERSION = "csv" FIRMWARE_VERSION = "fwv" + HIDDEN = "hidden" ID = "_id" LOW_BATTERY = "lb" NAME = "n" @@ -108,6 +109,7 @@ class AlarmPanelAttribute(VivintDeviceAttribute): DEVICES = "d" PARTITION_ID = "parid" + UNREGISTERED = "ureg" class CameraAttribute(VivintDeviceAttribute): diff --git a/vivintpy/devices/__init__.py b/vivintpy/devices/__init__.py index 5e36bad..d15cb81 100644 --- a/vivintpy/devices/__init__.py +++ b/vivintpy/devices/__init__.py @@ -5,7 +5,7 @@ from ..const import VivintDeviceAttribute as Attribute from ..entity import Entity -from ..enums import CapabilityCategoryType, CapabilityType, ZoneBypass +from ..enums import CapabilityCategoryType, CapabilityType, DeviceType, ZoneBypass from ..vivintskyapi import VivintSkyApi from ..zjs_device_config_db import get_zwave_device_info @@ -17,7 +17,6 @@ def get_device_class(device_type: str) -> Type[VivintDevice]: """Map a device_type string to the class that implements that device.""" - from ..enums import DeviceType from . import UnknownDevice from .camera import Camera from .door_lock import DoorLock @@ -33,6 +32,7 @@ def get_device_class(device_type: str) -> Type[VivintDevice]: DeviceType.GARAGE_DOOR: GarageDoor, DeviceType.MULTILEVEL_SWITCH: MultilevelSwitch, DeviceType.THERMOSTAT: Thermostat, + DeviceType.TOUCH_PANEL: VivintDevice, DeviceType.WIRELESS_SENSOR: WirelessSensor, } @@ -59,6 +59,7 @@ def __init__(self, data: dict, alarm_panel: AlarmPanel = None) -> None: if data.get(Attribute.CAPABILITY_CATEGORY) else None ) + self._parent: VivintDevice | None = None def __repr__(self) -> str: """Return custom __repr__ of device.""" @@ -69,6 +70,11 @@ def id(self) -> int: """Device's id.""" return self.data[Attribute.ID] + @property + def is_valid(self) -> bool: + """Return `True` if the device is valid.""" + return True + @property def name(self) -> str | None: """Device's name.""" @@ -81,6 +87,16 @@ def capabilities( """Device capabilities.""" return self._capabilities + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + return DeviceType(self.data[Attribute.TYPE]) + + @property + def is_subdevice(self) -> bool: + """Return if this device is a subdevice.""" + return self._parent is not None + @property def manufacturer(self) -> str | None: """Return the manufacturer for this device.""" @@ -100,13 +116,17 @@ def panel_id(self) -> int: """Return the id of the panel this device is associated to.""" return self.data.get(Attribute.PANEL_ID) + @property + def parent(self) -> VivintDevice | None: + """Return the parent device, if any.""" + return self._parent + @property def serial_number(self) -> str | None: """Return the serial number for this device.""" serial_number = self.data.get(Attribute.SERIAL_NUMBER_32_BIT) - serial_number = ( - serial_number if serial_number else self.data.get(Attribute.SERIAL_NUMBER) - ) + if not serial_number: + serial_number = self.data.get(Attribute.SERIAL_NUMBER) return serial_number @property @@ -190,4 +210,4 @@ class UnknownDevice(VivintDevice): def __repr__(self) -> str: """Return custom __repr__ of device.""" - return f"<{self.__class__.__name__}|{self.data[Attribute.TYPE]} {self.id}>" + return f"<{self.__class__.__name__}|{self.data[Attribute.TYPE]} {self.id}, {self.name}>" diff --git a/vivintpy/devices/alarm_panel.py b/vivintpy/devices/alarm_panel.py index 4c37814..c7205ab 100644 --- a/vivintpy/devices/alarm_panel.py +++ b/vivintpy/devices/alarm_panel.py @@ -1,15 +1,12 @@ """Module that implements the AlarmPanel class.""" from __future__ import annotations +import asyncio import logging -from typing import TYPE_CHECKING, Type - -from ..const import ( - AlarmPanelAttribute, - PubNubMessageAttribute, - PubNubOperatorAttribute, - SystemAttribute, -) +from typing import TYPE_CHECKING, Any, Type + +from ..const import AlarmPanelAttribute as Attribute +from ..const import PubNubMessageAttribute, PubNubOperatorAttribute, SystemAttribute from ..enums import ArmedState, DeviceType from ..exceptions import VivintSkyApiError from ..utils import add_async_job, first_or_none @@ -32,18 +29,16 @@ def __init__(self, data: dict, system: System): """Initialize an alarm panel.""" self.system = system super().__init__(data) - self.__panel_credentials = None + self.devices: list[VivintDevice] = [] + self.unregistered_devices: dict[int, tuple] = {} - # initialize devices - self.devices = [ - get_device_class(device_data[AlarmPanelAttribute.TYPE])(device_data, self) - for device_data in self.data[AlarmPanelAttribute.DEVICES] - ] + self.__parse_data(data=data, init=True) # store a reference to the physical panel device + self.__panel_credentials = None self.__panel = first_or_none( self.devices, - lambda device: DeviceType(device.data.get(AlarmPanelAttribute.TYPE)) + lambda device: DeviceType(device.data.get(Attribute.TYPE)) == DeviceType.TOUCH_PANEL, ) @@ -55,7 +50,7 @@ def vivintskyapi(self) -> VivintSkyApi: @property def id(self) -> int: """Panel's id.""" - return self.data[AlarmPanelAttribute.PANEL_ID] + return self.data[Attribute.PANEL_ID] @property def name(self) -> str: @@ -84,7 +79,7 @@ def software_version(self) -> str: @property def partition_id(self) -> int: """Panel's partition id.""" - return self.data[AlarmPanelAttribute.PARTITION_ID] + return self.data[Attribute.PARTITION_ID] @property def is_disarmed(self) -> bool: @@ -103,7 +98,7 @@ def is_armed_stay(self) -> bool: def get_armed_state(self): """Return the panel's arm state.""" - return self.data[AlarmPanelAttribute.STATE] + return self.data[Attribute.STATE] async def set_armed_state(self, state: int) -> None: """Set the armed state for a panel.""" @@ -146,29 +141,14 @@ def get_devices( return devices - def refresh(self, data: dict, new_device: bool = False) -> None: + def refresh(self, data: dict[str, Any], new_device: bool = False) -> None: """Refresh the alarm panel.""" if not new_device: self.update_data(data, override=True) else: - self.data[AlarmPanelAttribute.DEVICES].extend( - data[AlarmPanelAttribute.DEVICES] - ) + self.data[Attribute.DEVICES].extend(data[Attribute.DEVICES]) - # update associated devices - for device_data in data[AlarmPanelAttribute.DEVICES]: - device = first_or_none( - self.devices, - lambda device: device.id == device_data[AlarmPanelAttribute.ID], - ) - if device: - device.update_data(device_data, override=True) - else: - device = get_device_class(device_data[AlarmPanelAttribute.TYPE])( - device_data, self - ) - self.devices.append(device) - self.emit(DEVICE_DISCOVERED, device_data) + self.__parse_data(data) def handle_pubnub_message(self, message: dict) -> None: """Handle a pubnub message.""" @@ -197,6 +177,7 @@ def handle_pubnub_message(self, message: dict) -> None: continue if operation == PubNubOperatorAttribute.CREATE: + self.refresh(data=data, new_device=True) add_async_job(self.handle_new_device, device_id) else: device = first_or_none( @@ -211,15 +192,19 @@ def handle_pubnub_message(self, message: dict) -> None: # for the sake of consistency, we also need to update the panel's raw data raw_device_data = first_or_none( - self.data[AlarmPanelAttribute.DEVICES], + self.data[Attribute.DEVICES], lambda raw_device_data: raw_device_data["_id"] == device_data["_id"], ) if operation == PubNubOperatorAttribute.DELETE: self.devices.remove(device) - self.data[AlarmPanelAttribute.DEVICES].remove(raw_device_data) - self.emit(DEVICE_DELETED, raw_device_data) + self.data[Attribute.DEVICES].remove(raw_device_data) + self.unregistered_devices[device.id] = ( + device.name, + device.device_type, + ) + self.emit(DEVICE_DELETED, {"device": device}) else: device.handle_pubnub_message(device_data) raw_device_data.update(device_data) @@ -227,8 +212,43 @@ def handle_pubnub_message(self, message: dict) -> None: async def handle_new_device(self, device_id: int) -> None: """Handle a new device.""" try: + device = first_or_none(self.devices, lambda device: device.id == device_id) + while not device.is_valid: + await asyncio.sleep(1) + if device.id in self.unregistered_devices: + return resp = await self.vivintskyapi.get_device_data(self.id, device_id) data = resp[SystemAttribute.SYSTEM][SystemAttribute.PARTITION][0] self.refresh(data, new_device=True) + self.emit(DEVICE_DISCOVERED, {"device": device}) except VivintSkyApiError: _LOGGER.error("Error getting new device data for device %s", device_id) + + def __parse_data(self, data: dict[str, Any], init: bool = False) -> None: + """Parse the alarm panel data.""" + for device_data in data[Attribute.DEVICES]: + device: VivintDevice = None + if not init: + device = first_or_none( + self.devices, + lambda device: device.id == device_data[Attribute.ID], + ) + if device: + device.update_data(device_data, override=True) + else: + self.__parse_device_data(device_data=device_data) + + if data.get(Attribute.UNREGISTERED): + self.unregistered_devices: dict[int, tuple] = { + device[Attribute.ID]: ( + device[Attribute.NAME], + DeviceType(device[Attribute.TYPE]), + ) + for device in data[Attribute.UNREGISTERED] + } + + def __parse_device_data(self, device_data: dict[str, Any]) -> None: + """Parse device data and optionally emit a device discovered event.""" + device_class = get_device_class(device_data[Attribute.TYPE]) + device = device_class(device_data, self) + self.devices.append(device) diff --git a/vivintpy/devices/wireless_sensor.py b/vivintpy/devices/wireless_sensor.py index 4de9478..4cb25a6 100644 --- a/vivintpy/devices/wireless_sensor.py +++ b/vivintpy/devices/wireless_sensor.py @@ -1,8 +1,10 @@ """Module that implements the WirelessSensor class.""" import logging +from typing import Any from ..const import WirelessSensorAttribute as Attributes from ..enums import EquipmentCode, EquipmentType, SensorType +from ..utils import first_or_none from . import BypassTamperDevice, VivintDevice _LOGGER = logging.getLogger(__name__) @@ -64,6 +66,24 @@ def low_battery(self) -> bool: """Return true if battery's level is low.""" return self.data.get(Attributes.LOW_BATTERY, False) + @property + def is_valid(self) -> bool: + """Return `True` if the wireless sensor is valid.""" + return ( + self.serial_number is not None + and self.equipment_code != EquipmentCode.OTHER + and self.sensor_type != SensorType.UNUSED + ) + + def update_data(self, new_val: dict[str, Any], override: bool = False) -> None: + """Update entity's raw data.""" + super().update_data(new_val=new_val, override=override) + if self.data.get(Attributes.HIDDEN) and self._parent is None: + self._parent = first_or_none( + self.alarm_panel.devices, + lambda parent: parent.serial_number == self.serial_number, + ) + async def set_bypass(self, bypass: bool) -> None: """Bypass/unbypass the sensor.""" await self.vivintskyapi.set_sensor_state( diff --git a/vivintpy/enums.py b/vivintpy/enums.py index b33ffc9..69470d8 100644 --- a/vivintpy/enums.py +++ b/vivintpy/enums.py @@ -207,9 +207,12 @@ class EquipmentCode(IntEnum): VS_CO3_DETECTOR = 1266 VS_SMKT_SMOKE_DETECTOR = 1267 + # Handle unknown/future equipment codes + UNKNOWN = -1 + @classmethod def _missing_(cls, value): - return cls.OTHER + return cls.UNKNOWN @unique @@ -328,9 +331,12 @@ class SensorType(IntEnum): SILENT_BURGLARY = 24 UNUSED = 0 + # Handle unknown/future sensor types. + UNKNOWN = -1 + @classmethod def _missing_(cls, value): - return cls.UNUSED + return cls.UNKNOWN @unique From b110b4c7b6321163ecc2ce0bd1fb8f02e0eab652 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 28 Oct 2021 17:01:56 -0600 Subject: [PATCH 3/5] bump version --- vivintpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vivintpy/__init__.py b/vivintpy/__init__.py index 1b38f1c..c8e4b9c 100644 --- a/vivintpy/__init__.py +++ b/vivintpy/__init__.py @@ -1,2 +1,2 @@ """Provide a package for vivintpy.""" -__version__ = "2021.10.0" +__version__ = "2021.10.1" From c48aba745c52b2ee0f3cc5ed923338801b745a51 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 28 Oct 2021 17:17:02 -0600 Subject: [PATCH 4/5] Fix setting parent on init and update --- vivintpy/devices/wireless_sensor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/vivintpy/devices/wireless_sensor.py b/vivintpy/devices/wireless_sensor.py index 4cb25a6..08ced58 100644 --- a/vivintpy/devices/wireless_sensor.py +++ b/vivintpy/devices/wireless_sensor.py @@ -6,6 +6,7 @@ from ..enums import EquipmentCode, EquipmentType, SensorType from ..utils import first_or_none from . import BypassTamperDevice, VivintDevice +from .alarm_panel import AlarmPanel _LOGGER = logging.getLogger(__name__) @@ -19,6 +20,11 @@ def __repr__(self) -> str: f"<{self.__class__.__name__}|{self.equipment_type} {self.id}, {self.name}>" ) + def __init__(self, data: dict, alarm_panel: AlarmPanel = None) -> None: + """Initialize a wireless sesnor.""" + super().__init__(data=data, alarm_panel=alarm_panel) + self.__update_parent() + @property def model(self) -> str: """Return the equipment_code as the model of this sensor.""" @@ -78,6 +84,9 @@ def is_valid(self) -> bool: def update_data(self, new_val: dict[str, Any], override: bool = False) -> None: """Update entity's raw data.""" super().update_data(new_val=new_val, override=override) + self.__update_parent() + + def __update_parent(self) -> None: if self.data.get(Attributes.HIDDEN) and self._parent is None: self._parent = first_or_none( self.alarm_panel.devices, From 63435ebfc3aa433efb197ebc1fdd5282a46ef680 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 28 Oct 2021 23:50:17 -0600 Subject: [PATCH 5/5] Reorder __init__ and __repr__ on WirelessSensor --- vivintpy/devices/wireless_sensor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/vivintpy/devices/wireless_sensor.py b/vivintpy/devices/wireless_sensor.py index 08ced58..16760e2 100644 --- a/vivintpy/devices/wireless_sensor.py +++ b/vivintpy/devices/wireless_sensor.py @@ -14,17 +14,17 @@ class WirelessSensor(BypassTamperDevice, VivintDevice): """Represents a Vivint wireless sensor device.""" + def __init__(self, data: dict, alarm_panel: AlarmPanel = None) -> None: + """Initialize a wireless sesnor.""" + super().__init__(data=data, alarm_panel=alarm_panel) + self.__update_parent() + def __repr__(self) -> str: """Return custom __repr__ of wireless sensor.""" return ( f"<{self.__class__.__name__}|{self.equipment_type} {self.id}, {self.name}>" ) - def __init__(self, data: dict, alarm_panel: AlarmPanel = None) -> None: - """Initialize a wireless sesnor.""" - super().__init__(data=data, alarm_panel=alarm_panel) - self.__update_parent() - @property def model(self) -> str: """Return the equipment_code as the model of this sensor."""