diff --git a/pyalarmdotcomajax/devices/__init__.py b/pyalarmdotcomajax/devices/__init__.py index 914f31a..e9813cb 100644 --- a/pyalarmdotcomajax/devices/__init__.py +++ b/pyalarmdotcomajax/devices/__init__.py @@ -38,6 +38,7 @@ class DeviceType(ExtendedEnumMixin): SYSTEM = "systems" THERMOSTAT = "thermostats" WATER_SENSOR = "waterSensors" + WATER_VALVE = "waterValves" # Unsupported ACCESS_CONTROL = "accessControlAccessPointDevices" @@ -56,7 +57,6 @@ class DeviceType(ExtendedEnumMixin): SWITCH = "switches" VALVE_SWITCH = "valveSwitches" WATER_METER = "waterMeters" - WATER_VALVE = "waterValves" X10_LIGHT = "x10Lights" diff --git a/pyalarmdotcomajax/devices/registry.py b/pyalarmdotcomajax/devices/registry.py index d6dba44..97c17c8 100644 --- a/pyalarmdotcomajax/devices/registry.py +++ b/pyalarmdotcomajax/devices/registry.py @@ -6,7 +6,6 @@ from dataclasses import dataclass, field from typing import TypedDict -from pyalarmdotcomajax.devices import DeviceType from pyalarmdotcomajax.devices.camera import Camera from pyalarmdotcomajax.devices.garage_door import GarageDoor from pyalarmdotcomajax.devices.gate import Gate @@ -18,9 +17,12 @@ from pyalarmdotcomajax.devices.system import System from pyalarmdotcomajax.devices.thermostat import Thermostat from pyalarmdotcomajax.devices.water_sensor import WaterSensor +from pyalarmdotcomajax.devices.water_valve import WaterValve from pyalarmdotcomajax.exceptions import UnkonwnDevice, UnsupportedDeviceType from pyalarmdotcomajax.helpers import classproperty +from . import DeviceType + log = logging.getLogger(__name__) AllDevices_t = ( @@ -35,6 +37,7 @@ | System | Thermostat | WaterSensor + | WaterValve ) AllDeviceTypes_t = ( @@ -49,6 +52,7 @@ | type[System] | type[Thermostat] | type[WaterSensor] + | type[WaterValve] ) # AllCommands_t = ( @@ -78,6 +82,7 @@ | list[System] | list[Thermostat] | list[WaterSensor] + | list[WaterValve] ) AllDevicesDicts_t = ( @@ -92,6 +97,7 @@ | dict[str, System] | dict[str, Thermostat] | dict[str, WaterSensor] + | dict[str, WaterValve] ) ATTRIBUTES: dict[DeviceType, AttributeRegistryEntry] = { @@ -164,6 +170,12 @@ "rel_id": "devices/water-sensor", "device_registry_property": "water_sensors", }, + DeviceType.WATER_VALVE: { + "endpoints": {"primary": "{}web/api/devices/waterValves/{}"}, + "class_": WaterValve, + "rel_id": "devices/water-valve", + "device_registry_property": "water_valve", + }, DeviceType.ACCESS_CONTROL: { "endpoints": {"primary": "{}web/api/devices/accessControlAccessPointDevices/{}"}, "rel_id": "devices/access-control-access-point-device", @@ -228,10 +240,6 @@ "endpoints": {"primary": "{}web/api/devices/waterMeters/{}"}, "rel_id": "devices/water-meter", }, - DeviceType.WATER_VALVE: { - "endpoints": {"primary": "{}web/api/devices/waterValves/{}"}, - "rel_id": "devices/water-valve", - }, DeviceType.X10_LIGHT: { "endpoints": {"primary": "{}web/api/devices/x10Lights/{}"}, "rel_id": "devices/x10-light", @@ -342,6 +350,11 @@ def water_sensors(self) -> dict[str, WaterSensor]: """Return water sensors.""" return {device_id: device for device_id, device in self._devices.items() if type(device) == WaterSensor} + @property + def water_valves(self) -> dict[str, WaterValve]: + """Return water sensors.""" + return {device_id: device for device_id, device in self._devices.items() if type(device) == WaterValve} + class AttributeRegistry: """Device registry.""" diff --git a/pyalarmdotcomajax/devices/thermostat.py b/pyalarmdotcomajax/devices/thermostat.py index ca3714b..4495040 100644 --- a/pyalarmdotcomajax/devices/thermostat.py +++ b/pyalarmdotcomajax/devices/thermostat.py @@ -131,7 +131,7 @@ class ThermostatAttributes(BaseDevice.DeviceAttributes): @property def models(self) -> dict: - """Return mapping of known ADC model IDs to manufacturer and model name. To be overridden by children.""" + """Return mapping of known ADC model IDs to manufacturer and model name.""" return { 4293: {"manufacturer": "Honeywell", "model": "T6 Pro"}, 10023: {"manufacturer": "ecobee", "model": "ecobee3 lite"}, diff --git a/pyalarmdotcomajax/devices/water_valve.py b/pyalarmdotcomajax/devices/water_valve.py new file mode 100644 index 0000000..631388f --- /dev/null +++ b/pyalarmdotcomajax/devices/water_valve.py @@ -0,0 +1,62 @@ +"""Alarm.com water valve.""" + +from __future__ import annotations + +import logging + +from pyalarmdotcomajax.devices import DeviceType + +from . import BaseDevice + +log = logging.getLogger(__name__) + + +class WaterValve(BaseDevice): + """Represent Alarm.com sensor element.""" + + class DeviceState(BaseDevice.DeviceState): + """Enum of water valve states.""" + + # https://www.alarm.com/web/system/assets/customer-site/enums/WaterValveStatus.js + + UNKNOWN = 0 + CLOSED = 1 + OPEN = 2 + + class Command(BaseDevice.Command): + """Commands for ADC water valves.""" + + OPEN = "open" + CLOSE = "close" + + @property + def models(self) -> dict: + """Return mapping of known ADC model IDs to manufacturer and model name.""" + return { + 9361: { + "manufacturer": "Qolsys", + "model": "IQ Water Valve", + } # OEM is Custos - Z-Wave Ball Valve Servo US/CA + } + + async def async_open(self) -> None: + """Send open command.""" + + await self.async_handle_external_desired_state_change(self.DeviceState.OPEN) + + await self._send_action( + device_type=DeviceType.WATER_VALVE, + event=self.Command.OPEN, + device_id=self.id_, + ) + + async def async_close(self) -> None: + """Send close command.""" + + await self.async_handle_external_desired_state_change(self.DeviceState.CLOSED) + + await self._send_action( + device_type=DeviceType.WATER_VALVE, + event=self.Command.CLOSE, + device_id=self.id_, + ) diff --git a/pyalarmdotcomajax/websockets/client.py b/pyalarmdotcomajax/websockets/client.py index 90931e2..d67b72d 100644 --- a/pyalarmdotcomajax/websockets/client.py +++ b/pyalarmdotcomajax/websockets/client.py @@ -21,6 +21,7 @@ from pyalarmdotcomajax.devices.sensor import Sensor from pyalarmdotcomajax.devices.thermostat import Thermostat from pyalarmdotcomajax.devices.water_sensor import WaterSensor +from pyalarmdotcomajax.devices.water_valve import WaterValve from pyalarmdotcomajax.exceptions import ( AuthenticationFailed, UnexpectedResponse, @@ -35,6 +36,9 @@ from pyalarmdotcomajax.websockets.handler.water_sensor import ( WaterSensorWebSocketHandler, ) +from pyalarmdotcomajax.websockets.handler.water_valve import ( + WaterValveWebSocketHandler, +) from pyalarmdotcomajax.websockets.messages import ( MonitoringEventMessage, process_raw_message, @@ -197,6 +201,8 @@ async def _async_handle_message(self, raw_message: dict) -> None: await ThermostatWebSocketHandler().process_message(message) case WaterSensor(): await WaterSensorWebSocketHandler().process_message(message) + case WaterValve(): + await WaterValveWebSocketHandler().process_message(message) case _: log.debug( f"WebSocket support not yet implemented for {message.device.__class__.__name__.lower()}s." diff --git a/pyalarmdotcomajax/websockets/handler/water_valve.py b/pyalarmdotcomajax/websockets/handler/water_valve.py new file mode 100644 index 0000000..b06c447 --- /dev/null +++ b/pyalarmdotcomajax/websockets/handler/water_valve.py @@ -0,0 +1,57 @@ +"""WaterValve websocket message handler.""" + +from __future__ import annotations + +import logging + +from pyalarmdotcomajax.devices.water_valve import WaterValve +from pyalarmdotcomajax.websockets.const import EventType +from pyalarmdotcomajax.websockets.handler import BaseWebSocketHandler +from pyalarmdotcomajax.websockets.messages import ( + EventMessage, + StatusChangeMessage, + WebSocketMessage, +) + +log = logging.getLogger(__name__) + +EVENT_STATE_MAP = { + EventType.Opened: WaterValve.DeviceState.OPEN, + EventType.Closed: WaterValve.DeviceState.CLOSED, +} + + +class WaterValveWebSocketHandler(BaseWebSocketHandler): + """Base class for device-type-specific websocket message handler.""" + + SUPPORTED_DEVICE_TYPE = WaterValve + + async def process_message(self, message: WebSocketMessage) -> None: + """Handle websocket message.""" + + # https://www.alarm.com/web/system/assets/customer-site/websockets/handlers/water-valves.js + + if type(message.device) != WaterValve: + return + + match message: + case StatusChangeMessage(): + if message.new_state: + await message.device.async_handle_external_dual_state_change(message.new_state) + + case EventMessage(): + match message.event_type: + case EventType.Opened | EventType.Closed: + await message.device.async_handle_external_dual_state_change( + EVENT_STATE_MAP[message.event_type] + ) + case _: + log.debug( + f"Support for event {message.event_type} ({message.event_type_id}) not yet implemented" + f" by {self.SUPPORTED_DEVICE_TYPE.__name__}." + ) + + case _: + log.debug( + f"Support for {type(message)} not yet implemented by {self.SUPPORTED_DEVICE_TYPE.__name__}." + ) diff --git a/tests/conftest.py b/tests/conftest.py index a728125..0d10adc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -166,3 +166,31 @@ def device_catalog_no_permissions(response_mocker: aioresponses, all_base_ok_res ) all_base_ok_responses_callable() + + +@pytest.fixture +def system_without_partition(response_mocker: aioresponses, all_base_ok_responses_callable: Callable) -> None: + """No permission to view devices.""" + + response_mocker.get( + url=AlarmController.ALL_DEVICES_URL_TEMPLATE.format(c.URL_BASE, "id-system"), + status=200, + body=get_http_body_json("device_catalog_no_partitions"), + repeat=True, + ) + + response_mocker.get( + url=AttributeRegistry.get_endpoints(DeviceType.SYSTEM)["primary"].format(c.URL_BASE, "id-system"), + status=200, + body=get_http_body_json("system_no_partitions"), + repeat=True, + ) + + response_mocker.get( + url=AlarmController.ALL_SYSTEMS_URL_TEMPLATE.format(c.URL_BASE), + status=200, + body=get_http_body_json("available_systems_ok"), + repeat=True, + ) + + all_base_ok_responses_callable() diff --git a/tests/responses/__init__.py b/tests/responses/__init__.py index e65ad17..fe1d2ea 100644 --- a/tests/responses/__init__.py +++ b/tests/responses/__init__.py @@ -6,10 +6,10 @@ def get_http_body_json(name: str) -> str: """Get server/client response/request body from JSON file.""" - return resources.read_text(__package__, f"{name}.json") + return resources.files(__package__).joinpath(f"{name}.json").read_text() def get_http_body_html(name: str) -> str: """Get server/client response/request body from HTML file.""" - return resources.read_text(__package__, f"{name}.html") + return resources.files(__package__).joinpath(f"{name}.html").read_text() diff --git a/tests/responses/device_catalog_no_partitions.json b/tests/responses/device_catalog_no_partitions.json new file mode 100644 index 0000000..dce5257 --- /dev/null +++ b/tests/responses/device_catalog_no_partitions.json @@ -0,0 +1,102 @@ +{ + "data": { + "id": 13276705, + "type": "settings/manage-devices/device-catalog", + "attributes": { + "availableManagedDeviceFilters": { + "2": "Detectors", + "4": "Lights", + "6": "Mobile Devices", + "8": "Security", + "10": "Thermostats", + "12": "Voice Assistants" + }, + "canAddDevices": true + }, + "relationships": { + "managedDeviceCategories": { + "data": [ + { + "id": "2", + "type": "settings/manage-devices/managed-device-category" + }, + { + "id": "4", + "type": "settings/manage-devices/managed-device-category" + }, + { + "id": "6", + "type": "settings/manage-devices/managed-device-category" + }, + { + "id": "8", + "type": "settings/manage-devices/managed-device-category" + }, + { + "id": "10", + "type": "settings/manage-devices/managed-device-category" + }, + { + "id": "12", + "type": "settings/manage-devices/managed-device-category" + } + ], + "meta": { + "count": "6" + } + } + } + }, + "included": [ + { + "id": "id-garage-detached", + "type": "devices/garage-door", + "attributes": { + "state": 2, + "desiredState": 2, + "managedDeviceType": 3, + "canAccessWebSettings": true, + "webSettings": 1003, + "hasState": true, + "canBeRenamed": true, + "canBeDeleted": true, + "canAccessAppSettings": false, + "canAccessTroubleshootingWizard": false, + "troubleshootingWizard": null, + "macAddress": "", + "manufacturer": null, + "isOAuth": false, + "isZWave": true, + "supportsCommandClassBasic": false, + "isMalfunctioning": false, + "canBeSaved": true, + "canChangeDescription": true, + "description": "Detached Garage Door", + "deviceModelId": 9999, + "canConfirmStateChange": true, + "canReceiveCommands": true, + "remoteCommandsEnabled": true, + "hasPermissionToChangeState": true, + "deviceIcon": { + "icon": 320 + }, + "batteryLevelNull": null, + "lowBattery": false, + "criticalBattery": false, + "canBeLinkedToVideoDevice": false, + "videoDeviceLinkDeviceIds": [] + }, + "relationships": { + "system": { + "data": { + "id": "id-system", + "type": "systems/system" + } + } + } + } + ], + "meta": { + "transformer_version": "2.0" + } +} diff --git a/tests/responses/device_catalog_ok.json b/tests/responses/device_catalog_ok.json index 9493fe4..0d6f498 100644 --- a/tests/responses/device_catalog_ok.json +++ b/tests/responses/device_catalog_ok.json @@ -782,6 +782,17 @@ "count": "2" } }, + "waterValves": { + "data": [ + { + "id": "id-water-valve", + "type": "devices/water-valve" + } + ], + "meta": { + "count": "1" + } + }, "stateInfo": { "data": { "id": "id-stateinfo-partition-house", @@ -1647,7 +1658,64 @@ } } } - } + }, + { + "id": "id-water-valve", + "type": "devices/water-valve", + "attributes": { + "state": 2, + "desiredState": 2, + "activeSensors": 0, + "totalSensors": 1, + "managedDeviceType": 20, + "hasState": false, + "canBeRenamed": true, + "canBeDeleted": false, + "canAccessWebSettings": false, + "canAccessAppSettings": false, + "webSettings": 1001, + "canAccessTroubleshootingWizard": false, + "troubleshootingWizard": null, + "addDeviceResource": 0, + "canBeAssociatedToVideoDevice": false, + "associatedCameraDeviceIds": {}, + "macAddress": "", + "manufacturer": "Custos", + "isAssignedToCareReceiver": false, + "isOAuth": false, + "isZWave": false, + "supportsCommandClassBasic": false, + "isMalfunctioning": false, + "canBeSaved": true, + "canChangeDescription": true, + "description": "Water Valve", + "deviceModelId": 9361, + "canConfirmStateChange": true, + "canReceiveCommands": true, + "remoteCommandsEnabled": true, + "hasPermissionToChangeState": true, + "deviceIcon": { + "icon": 21 + }, + "batteryLevelNull": null, + "lowBattery": false, + "criticalBattery": false + }, + "relationships": { + "system": { + "data": { + "id": "id-system", + "type": "systems/system" + } + }, + "stateInfo": { + "data": { + "id": "id-stateinfo-water-valve", + "type": "devices/state-info" + } + } + } + } ], "meta": { "transformer_version": "2.0" diff --git a/tests/responses/system_no_partitions.json b/tests/responses/system_no_partitions.json new file mode 100644 index 0000000..269b11d --- /dev/null +++ b/tests/responses/system_no_partitions.json @@ -0,0 +1,200 @@ +{ + "data": + { + "id": "id-system", + "type": "systems/system", + "attributes": { + "description": "My Alarm System", + "hasSnapShotCameras": false, + "supportsSecureArming": true, + "remainingImageQuota": 399, + "systemGroupName": "", + "unitId": 555555555, + "accessControlCurrentSystemMode": 0, + "isInPartialLockdown": false, + "icon": "property-single" + }, + "relationships": { + "partitions": { + "data": [], + "meta": { + "count": "0" + } + }, + "locks": { + "data": [], + "meta": { + "count": "2" + } + }, + "accessControlAccessPointDevices": { + "data": [], + "meta": { + "count": "0" + } + }, + "cameras": { + "data": [], + "meta": { + "count": "1" + } + }, + "sdCardCameras": { + "data": [], + "meta": { + "count": "0" + } + }, + "garageDoors": { + "data": [ + { + "id": "id-garage-detached", + "type": "devices/garage-door" + } + ], + "meta": { + "count": "1" + } + }, + "waterValves": { + "data": [], + "meta": { + "count": "0" + } + }, + "scenes": { + "data": [], + "meta": { + "count": "0" + } + }, + "sensors": { + "data": [], + "meta": { + "count": "0" + } + }, + "waterSensors": { + "data": [], + "meta": { + "count": "0" + } + }, + "sumpPumps": { + "data": [], + "meta": { + "count": "0" + } + }, + "waterMeters": { + "data": [], + "meta": { + "count": "0" + } + }, + "lights": { + "data": [], + "meta": { + "count": "0" + } + }, + "x10Lights": { + "data": [], + "meta": { + "count": "0" + } + }, + "smartChimeDevices": { + "data": [], + "meta": { + "count": "0" + } + }, + "thermostats": { + "data": [], + "meta": { + "count": "0" + } + }, + "remoteTemperatureSensors": { + "data": [], + "meta": { + "count": "0" + } + }, + "commercialTemperatureSensors": { + "data": [], + "meta": { + "count": "0" + } + }, + "valveSwitches": { + "data": [], + "meta": { + "count": "0" + } + }, + "boilerControlSystem": { + "data": null + }, + "geoDevices": { + "data": [], + "meta": { + "count": "0" + } + }, + "fences": { + "data": [], + "meta": { + "count": "0" + } + }, + "imageSensors": { + "data": [], + "meta": { + "count": "0" + } + }, + "configuration": { + "data": { + "id": "id-system", + "type": "systems/configuration" + } + }, + "shades": { + "data": [], + "meta": { + "count": "0" + } + }, + "gates": { + "data": [], + "meta": { + "count": "0" + } + }, + "switches": { + "data": [], + "meta": { + "count": "0" + } + }, + "iqRouters": { + "data": [], + "meta": { + "count": "0" + } + }, + "noiseSensors": { + "data": [], + "meta": { + "count": "0" + } + } + } + }, + "included": [], + "meta": { + "transformer_version": "1.1" + } +} diff --git a/tests/responses/system_ok.json b/tests/responses/system_ok.json index 5bd8976..cb68be8 100644 --- a/tests/responses/system_ok.json +++ b/tests/responses/system_ok.json @@ -76,16 +76,24 @@ }, { "id": "id-garage-left", "type": "devices/garage-door" + }, { + "id": "id-garage-detached", + "type": "devices/garage-door" } ], "meta": { - "count": "1" + "count": "3" } }, "waterValves": { - "data": [], + "data": [ + { + "id": "id-water-valve", + "type": "devices/water-valve" + } + ], "meta": { - "count": "0" + "count": "1" } }, "scenes": { diff --git a/tests/responses/systems_ok.json b/tests/responses/unused__systems_ok.json similarity index 100% rename from tests/responses/systems_ok.json rename to tests/responses/unused__systems_ok.json diff --git a/tests/test_controller.py b/tests/test_controller.py index 2273d15..664d709 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -41,6 +41,7 @@ async def test__device_storage( assert adc_client.devices.lights.values() assert adc_client.devices.thermostats.values() assert adc_client.devices.water_sensors.values() + assert adc_client.devices.water_valves.values() @pytest.mark.asyncio @@ -63,3 +64,13 @@ async def test__async_has_image_sensors( """Test for function that fetches image sensor images.""" await adc_client.async_update() + + +@pytest.mark.asyncio +async def test__async_no_partitions( + system_without_partition: str, + adc_client: AlarmController, +) -> None: + """Test that we can handle systems without partitions.""" + + await adc_client.async_update()