diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e33ea6..fba78d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Support for RadioRA 3. +- Support for RadioRA 2 InLineDimmer and InLineSwitch. + ## [0.14.0] - 2022-06-18 ### Added diff --git a/pylutron_caseta/__init__.py b/pylutron_caseta/__init__.py index 87c76c3..a2e3f75 100644 --- a/pylutron_caseta/__init__.py +++ b/pylutron_caseta/__init__.py @@ -5,15 +5,29 @@ from .messages import Response, ResponseStatus _LEAP_DEVICE_TYPES = { - "light": ["WallDimmer", "PlugInDimmer"], + "light": [ + "WallDimmer", + "PlugInDimmer", + "InLineDimmer", + "SunnataDimmer", + "TempInWallPaddleDimmer", + "WallDimmerWithPreset", + "Dimmed", + ], "switch": [ "WallSwitch", "OutdoorPlugInSwitch", "PlugInSwitch", + "InLineSwitch", + "PowPakSwitch", + "SunnataSwitch", + "TempInWallPaddleSwitch", + "Switched", ], "fan": [ "CasetaFanSpeedController", "MaestroFanSpeedController", + "FanSpeed", ], "cover": [ "SerenaHoneycombShade", @@ -21,6 +35,10 @@ "TriathlonHoneycombShade", "TriathlonRollerShade", "QsWirelessShade", + "QsWirelessHorizontalSheerBlind", + "QsWirelessWoodBlind", + "RightDrawDrape", + "Shade", "SerenaTiltOnlyWoodBlind", ], "sensor": [ @@ -34,6 +52,18 @@ "Pico4ButtonZone", "Pico4Button2Group", "FourGroupRemote", + "SeeTouchTabletopKeypad", + "SunnataKeypad", + "SunnataKeypad_2Button", + "SunnataKeypad_3ButtonRaiseLower", + "SunnataKeypad_4Button", + "SeeTouchHybridKeypad", + "SeeTouchInternational", + "SeeTouchKeypad", + "HomeownerKeypad", + "GrafikTHybridKeypad", + "AlisseKeypad", + "PalladiomKeypad", ], } diff --git a/pylutron_caseta/assets.py b/pylutron_caseta/assets.py index d1cba0e..f804a85 100644 --- a/pylutron_caseta/assets.py +++ b/pylutron_caseta/assets.py @@ -85,3 +85,51 @@ uHnNjMTXCVxNy4tkARwLRwI+1aV5PMzFSi+HyuWmBaWOe19uz3SFbYs= -----END RSA PRIVATE KEY----- """ + +LUTRON_ROOT_CA_PEM = """ +-----BEGIN CERTIFICATE----- +MIIH5DCCBMygAwIBAgIJAKk++JqaJetSMA0GCSqGSIb3DQEBCwUAMH8xCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJQQTELMAkGA1UEBwwCQ0IxHzAdBgNVBAoMFkx1dHJv +biBFbGVjdHJvbmljcyBJbmMxHzAdBgNVBAsMFkx1dHJvbiBFbGVjdHJvbmljcyBJ +bmMxFDASBgNVBAMMC2x1dHJvbi1yb290MB4XDTE2MDkyODE5NTk0MVoXDTM2MDky +MzE5NTk0MVowfzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAlBBMQswCQYDVQQHDAJD +QjEfMB0GA1UECgwWTHV0cm9uIEVsZWN0cm9uaWNzIEluYzEfMB0GA1UECwwWTHV0 +cm9uIEVsZWN0cm9uaWNzIEluYzEUMBIGA1UEAwwLbHV0cm9uLXJvb3QwggMiMA0G +CSqGSIb3DQEBAQUAA4IDDwAwggMKAoIDAQDBZbMODMzm+qpsOF5hhQ272GUlOaKz +n5b5YxokSAoxY4TqQApb9/uRHIBuuGLntq0QhR0Y3b0lXBeJWzWC6zscZJrheUKW ++2aHVvU4ugPAAXK/WVI68adBSY1UP0BcO1paYrXONcuXQgdy2/GV1mo1b+bmjNFT +zeDopkUoBxivBDZZ7B5vFfbJSgSF47Xsz8cspCEUIaV1rZbaDYBzsimdvrKusJfZ +Pci+Cx71sZuKunGTCgwHduYFsBfYRgTG1ihNEASi2++Er67AcabUGaqVQr/kIrUD +sS9jB6uaqPgMajjwXiZPDm82tTHobbKSav7aq+kSBNIFyvhK5y+vAWoGeZr5WK7n +9EekO3x7LXc6XSCASuhzK6zquAGUBSQNEO3c7sZ1rIdNs1lBSkCSxs+Bl8eEHO8k +O20TqKzKF9bQtccNkFWtRKIhVLFxQt234P+XJtWvWKVOlkLCAo0QgDivFJQVnNKM +Hr2/CIsOLC+ZSWAYl0lZEJaszt7wjR9cc7DRizq9aoKcGlPRvxzobFoQ4H0Z8vIR +DQRUQWFaTTOGiEk7JKxqjXX8xuGZpoXWw8VX0gz3Y0Bz8sU58ZZbugmVjvnKKYzd +ueZ/9+FsaYX6CKdJDANEJf+fqfkGXwQGt8Ns7SeG0JyCdJ4K2ECoOURYS4P1vSY1 +40L9OldDjsW2qhpSBPHppfJ4rPRUu5J9Ux3AX4Mz+ibl6MS3wRpP1Rg+9TLITK5o +3AYrJO6oMsYrQkQvc+k20ocD7Iq0522iyw62/DpKMsPZXHNTT/rqzIihkaZaR8aa +ZOgAKi5o398mcfsuv42f8DriYc0Gr+3btiDU7rINqM935YNIABBDtVT9Ybc5uPHa +wXLmAIx2yLjqYaRDhr01Sql6WGy8Y0HcI5lM1pw4Vpx+VKWG/QdORGtZgKySGZ0+ +9bY9cRN9IBFz4J60xoqx0MsM5o6FqVDypDCB32KaobVZSAnHifwEGtJJimNIzHpY +jGCTzBHSpuZcvV2dVAuPTHzck37ifpNTUFUCAwEAAaNjMGEwHQYDVR0OBBYEFErb +2SmGkh+4kYe2twSie5+xaqRsMB8GA1UdIwQYMBaAFErb2SmGkh+4kYe2twSie5+x +aqRsMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IDAQAP9t0STrn0xENQEDNwRMQUBYTA/r7BmzXX6b0Ip6HW7QNmTkFc5pUb +KT86v6g8cJ7ls96JwdTu1kRQt4Qpbyvzp2p5YlRFnm3NTVVdcffcZNo95x9Z/1Cv +5xZgw0OKODwPBJLCyq5ET4W6WrIZucRVBs035YXIN+z3EzCxBj6O1lcjOTTHSFFR +jI3t3AGdkFo7tCBu5TFlNEFfmaqjse140vfGWJpRKyOT4ahzXLcVxzfg/SKRID3Q +2Rop4KqLNCddzz+UM+IiwyFkOfjrXWStW46cLUzM1k5GRrl0aBg5oqMCBY4/Eeh3 +W0ZATsxxfg2Ly4FIO5d7/xiZqARFuurYe/2PSzVSPIKQrPVjDEekD+qQ0bRRQGvL +KBiMhYZHwnz/OEQl21PNp5rksuFjKG4/PimQ8V96jpbzzsOZuic3aScszgNUPbdI +0LYjCQ8xCOFPpFC4x1+rGubRjKEuGCvvYErVkX9rQlFRGPOp+k8bYTlIUKZeNsuL +KiZ4VH5+ZUAIf94DHayoo/SvBsQ5Qizb17KVRKil+vidUkMtndrNtjr3GWmH+nkn +PhRXBlekUy3dgRvTE8RFDOG9TYAN1Bs/uMgNc8Sg5Yz0SG96SLXVer2zsjmQ7tf9 +6s+UVvrr+wlL7jSJCfJo6gaUQh1sD3umPXDS+Fq+J7tiRwOvP3cejo8dLyhesDun +FGIHlKmUCIwS/3Kzvd9OtAJMsmV9Q2B1dXudJloj6ADaAmVvhI/eMUncL9sXMJZH +3CCorh2OSZt0vtdA59osgDSMrsQZSMrtovrKgeFmP1Z0ENvo90Zenm7Bjn6Hw3Y/ +GebIgSgoc149ElxjN4nagIqSJJHRrYq85sjTUSESvQUL1oi4R/VU+qMIRSHju/ZM +bkqONDohUc7/pg5rnLTZnnaQ09KvdF0yySx3hYph7L7MZWV/tF7O7yj1egRKh7lT +rgZOI7EiN4DPfTTpXoWVmIpiB/ouKp6uZ/Zrq00WthT8lUaBsFYaC3FDkkcxwdkk +lJ+cvdbUdsU= +-----END CERTIFICATE----- +""" diff --git a/pylutron_caseta/pairing.py b/pylutron_caseta/pairing.py index d0d6fe7..6f29d36 100644 --- a/pylutron_caseta/pairing.py +++ b/pylutron_caseta/pairing.py @@ -15,7 +15,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID # type: ignore -from .assets import LAP_CA_PEM, LAP_CERT_PEM, LAP_KEY_PEM +from .assets import LAP_CA_PEM, LAP_CERT_PEM, LAP_KEY_PEM, LUTRON_ROOT_CA_PEM LOGGER = logging.getLogger(__name__) @@ -77,9 +77,20 @@ async def async_pair( None, _generate_csr_with_ssl_context ) - cert_pem, ca_pem = await _async_generate_certificate( - server_addr, ssl_context, csr, ready - ) + try: + cert_pem, ca_pem = await _async_generate_certificate( + server_addr, ssl_context, csr, ready + ) + except ssl.SSLCertVerificationError: + # SSL certificate verification error - might be an RA3 processor, + # try to connect using the lutron-root certificate instead of LAP_CA + ssl_context.load_verify_locations(cadata=LUTRON_ROOT_CA_PEM) + cert_pem, ca_pem = await _async_generate_certificate( + server_addr, ssl_context, csr, ready + ) + # Generate certificates worked with RA3 lutron-root so bridge is RA3. + # Discard the ca_pem for caseta and replace with LUTRON_ROOT_CA_PEM + ca_pem = LUTRON_ROOT_CA_PEM signed_ssl_context = await loop.run_in_executor( None, _generate_signed_ssl_context, key_bytes_pem, cert_pem, ca_pem @@ -117,6 +128,7 @@ async def _async_generate_certificate( ), timeout=SOCKET_TIMEOUT, ) + json_socket = JsonSocket(reader, writer) LOGGER.info("Press the small black button on the back of the Caseta bridge...") if ready is not None: diff --git a/pylutron_caseta/smartbridge.py b/pylutron_caseta/smartbridge.py index d0ad7a3..879ef59 100644 --- a/pylutron_caseta/smartbridge.py +++ b/pylutron_caseta/smartbridge.py @@ -1,4 +1,4 @@ -"""Provides an API to interact with the Lutron Caseta Smart Bridge.""" +"""Provides an API to interact with the Lutron Caseta Smart Bridge & RA3 Processor.""" import asyncio from datetime import timedelta @@ -497,11 +497,13 @@ async def _monitor_once(self): self._leap = None def _handle_one_zone_status(self, response: Response): + _LOG.debug("Handling single zone status: %s", response) body = response.Body if body is None: return + self._handle_zone_status(body["ZoneStatus"]) - status = body["ZoneStatus"] + def _handle_zone_status(self, status): zone = id_from_href(status["Zone"]["href"]) level = status.get("Level", -1) fan_speed = status.get("FanSpeed", None) @@ -529,6 +531,15 @@ def _handle_button_status(self, response: Response): if button_id in self._button_subscribers: self._button_subscribers[button_id](button_event) + def _handle_multi_zone_status(self, response: Response): + _LOG.debug("Handling multi zone status: %s", response) + + if response.Body is None: + return + + for zonestatus in response.Body["ZoneStatuses"]: + self._handle_zone_status(zonestatus) + def _handle_occupancy_group_status(self, response: Response): _LOG.debug("Handling occupancy group status: %s", response) @@ -564,22 +575,40 @@ def _handle_unsolicited(self, response: Response): async def _login(self): """Connect and login to the Smart Bridge LEAP server using SSL.""" try: - await self._load_devices() - await self._load_buttons() - await self._load_lip_devices() - await self._load_scenes() await self._load_areas() - await self._load_occupancy_groups() - await self._subscribe_to_occupancy_groups() - await self._subscribe_to_button_status() - - for device in self.devices.values(): - if device.get("zone") is not None: - _LOG.debug("Requesting zone information from %s", device) - response = await self._request( - "ReadRequest", f"/zone/{device['zone']}/status" - ) - self._handle_one_zone_status(response) + + # Read /project to determine bridge type + project_json = await self._request("ReadRequest", "/project") + project = project_json.Body["Project"] + + if project["ProductType"] == "Lutron RadioRA 3 Project": + # RadioRa3 Bridge (processor) Device detected + _LOG.debug("RA3 bridge detected") + + # Load processor as devices[1] for compatibility with lutron_caseta HA + # integration + await self._load_ra3_processor() + await self._load_ra3_devices() + await self._subscribe_to_button_status() + else: + # Caseta Bridge Device detected + _LOG.debug("Caseta bridge detected") + + await self._load_devices() + await self._load_buttons() + await self._load_lip_devices() + await self._load_scenes() + await self._load_occupancy_groups() + await self._subscribe_to_occupancy_groups() + await self._subscribe_to_button_status() + + for device in self.devices.values(): + if device.get("zone") is not None: + _LOG.debug("Requesting zone information from %s", device) + response = await self._request( + "ReadRequest", f"/zone/{device['zone']}/status" + ) + self._handle_one_zone_status(response) if not self._login_completed.done(): self._login_completed.set_result(None) @@ -609,6 +638,11 @@ async def _load_devices(self): """Load the device list from the SSL LEAP server interface.""" _LOG.debug("Loading devices") device_json = await self._request("ReadRequest", "/device") + + # If /device has no body, this probably isn't Caseta + if device_json.Body is None: + return + for device in device_json.Body["Devices"]: _LOG.debug(device) device_id = id_from_href(device["href"]) @@ -646,6 +680,173 @@ async def _load_devices(self): serial=device["SerialNumber"], ) + async def _load_ra3_devices(self): + + for area in self.areas.values(): + await self._load_ra3_control_stations(area) + await self._load_ra3_zones(area) + + # caseta does this by default, but we need to do it manually for RA3 + await self._subscribe_to_multi_zone_status() + + async def _load_ra3_processor(self): + # Load processor as devices[1] for compatibility with lutron_caseta HA + # integration + + processor_json = await self._request( + "ReadRequest", "/device?where=IsThisDevice:true" + ) + if processor_json.Body is None: + return + + processor = processor_json.Body["Devices"][0] + processor_area = self.areas[processor["AssociatedArea"]["href"].split("/")[2]][ + "name" + ] + + level = -1 + device_id = "1" + fan_speed = None + zone_type = None + self.devices.setdefault( + device_id, + {"device_id": device_id, "current_state": level, "fan_speed": fan_speed}, + ).update( + zone=device_id, + name="_".join((processor_area, processor["Name"], processor["DeviceType"])), + button_groups=None, + type=zone_type, + model=processor["ModelNumber"], + serial=processor["SerialNumber"], + ) + + async def _load_ra3_control_stations(self, area): + # For each area, process the control stations. + # Find button devices with buttons, ignore all other devices + area_id = area["id"] + area_name = area["name"] + station_json = await self._request( + "ReadRequest", f"/area/{area_id}/associatedcontrolstation" + ) + if station_json.Body is None: + return + station_json = station_json.Body["ControlStations"] + for station in station_json: + station_name = station["Name"] + ganged_devices_json = station["AssociatedGangedDevices"] + + combined_name = "_".join((area_name, station_name)) + + for device_json in ganged_devices_json: + await self._load_ra3_station_device(combined_name, device_json) + + async def _load_ra3_station_device(self, name, device_json): + device_id = id_from_href(device_json["Device"]["href"]) + device_type = device_json["Device"]["DeviceType"] + + # ignore non-button devices + if device_type not in _LEAP_DEVICE_TYPES.get("sensor"): + return + + button_group_json = await self._request( + "ReadRequest", f"/device/{device_id}/buttongroup/expanded" + ) + + # ignore button devices without buttons + if button_group_json.Body is None: + return + + device_json = await self._request("ReadRequest", f"/device/{device_id}") + device_name = device_json.Body["Device"]["Name"] + device_model = device_json.Body["Device"]["ModelNumber"] + + if "SerialNumber" in device_json.Body["Device"]: + device_serial = device_json.Body["Device"]["SerialNumber"] + else: + device_serial = None + + button_groups = [ + id_from_href(group["href"]) + for group in button_group_json.Body["ButtonGroupsExpanded"] + ] + + self.devices.setdefault( + device_id, + { + "device_id": device_id, + "current_state": -1, + "fan_speed": None, + }, + ).update( + zone=None, + name="_".join((name, device_name, device_type)), + button_groups=button_groups, + type=device_type, + model=device_model, + serial=device_serial, + ) + + for button_expanded_json in button_group_json.Body["ButtonGroupsExpanded"]: + for button_json in button_expanded_json["Buttons"]: + self._load_ra3_button(button_json, self.devices[device_id]) + + def _load_ra3_button(self, button_json, device): + button_id = id_from_href(button_json["href"]) + button_number = button_json["ButtonNumber"] + button_engraving = button_json.get("Engraving", None) + parent_id = id_from_href(button_json["Parent"]["href"]) + button_led = None + if button_engraving is not None: + button_name = button_engraving["Text"].replace("\n", " ") + else: + button_name = button_json["Name"] + button_led_obj = button_json.get("AssociatedLED", None) + if button_led_obj is not None: + button_led = id_from_href(button_led_obj["href"]) + self.buttons.setdefault( + button_id, + { + "device_id": button_id, + "current_state": BUTTON_STATUS_RELEASED, + "button_number": button_number, + "button_group": parent_id, + }, + ).update( + name=device["name"], + type=device["type"], + model=device["model"], + serial=device["serial"], + button_name=button_name, + button_led=button_led, + ) + + async def _load_ra3_zones(self, area): + # For each area, process zones. They will masquerade as devices + area_id = area["id"] + zone_json = await self._request( + "ReadRequest", f"/area/{area_id}/associatedzone" + ) + if zone_json.Body is None: + return + zone_json = zone_json.Body["Zones"] + for zone in zone_json: + level = zone.get("Level", -1) + zone_id = id_from_href(zone["href"]) + fan_speed = zone.get("FanSpeed", None) + zone_name = zone["Name"] + zone_type = zone["ControlType"] + self.devices.setdefault( + zone_id, + {"device_id": zone_id, "current_state": level, "fan_speed": fan_speed}, + ).update( + zone=zone_id, + name="_".join((area["name"], zone_name)), + button_groups=None, + type=zone_type, + model=None, + serial=None, + ) + async def _load_lip_devices(self): """Load the LIP device list from the SSL LEAP server interface.""" _LOG.debug("Loading LIP devices") @@ -723,10 +924,12 @@ async def _load_areas(self): """Load the areas from the Smart Bridge.""" _LOG.debug("Loading areas from the Smart Bridge") area_json = await self._request("ReadRequest", "/area") + # We only need leaf nodes in RA3 for area in area_json.Body["Areas"]: - area_id = id_from_href(area["href"]) - # We currently only need the name, so just load that - self.areas.setdefault(area_id, dict(name=area["Name"])) + if area.get("IsLeaf", True): + area_id = id_from_href(area["href"]) + # We currently only need the name, so just load that + self.areas.setdefault(area_id, dict(id=area_id, name=area["Name"])) async def _load_occupancy_groups(self): """Load the occupancy groups from the Smart Bridge.""" @@ -814,6 +1017,19 @@ async def _subscribe_to_occupancy_groups(self): return self._handle_occupancy_group_status(response) + async def _subscribe_to_multi_zone_status(self): + """Subscribe to multi-zone status updates - RA3.""" + _LOG.debug("Subscribing to multi-zone status updates") + try: + response, _ = await self._subscribe( + "/zone/status", self._handle_multi_zone_status + ) + _LOG.debug("Subscribed to zone status") + except BridgeResponseError as ex: + _LOG.error("Failed zone subscription: %s", ex.response) + return + self._handle_multi_zone_status(response) + async def close(self): """Disconnect from the bridge.""" _LOG.info("Processing Smartbridge.close() call") diff --git a/tests/responses/project.json b/tests/responses/project.json new file mode 100755 index 0000000..bca9958 --- /dev/null +++ b/tests/responses/project.json @@ -0,0 +1,29 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneProjectDefinition", + "StatusCode": "200 OK", + "Url": "/project" + }, + "Body": { + "Project": { + "href": "/project", + "Name": "Smart Bridge Project", + "ProductType": "Lutron Smart Bridge Project", + "MasterDeviceList": { + "Devices": [ + { + "href": "/device/1" + } + ] + }, + "GUID": "0000000000000000000000000000000000000000", + "TimeclockEventRules": { + "href": "/project/timeclockeventrules" + }, + "DeviceRules": { + "href": "/project/devicerule/1" + } + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/2796/associatedzone.json b/tests/responses/ra3/area/2796/associatedzone.json new file mode 100644 index 0000000..97b5b8a --- /dev/null +++ b/tests/responses/ra3/area/2796/associatedzone.json @@ -0,0 +1,25 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleZoneDefinition", + "StatusCode": "200 OK", + "Url": "/area/2796/associatedzone" + }, + "Body": { + "Zones": [ + { + "href": "/zone/2010", + "Name": "Porch", + "ControlType": "Dimmed", + "Category": { + "Type": "", + "IsLight": true + }, + "AssociatedArea": { + "href": "/area/2796" + }, + "SortOrder": 0 + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/2796/controlstation.json b/tests/responses/ra3/area/2796/controlstation.json new file mode 100644 index 0000000..4a7ced0 --- /dev/null +++ b/tests/responses/ra3/area/2796/controlstation.json @@ -0,0 +1,7 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "StatusCode": "204 NoContent", + "Url": "/area/2796/associatedcontrolstation" + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/2796/ra3-area2796.json b/tests/responses/ra3/area/2796/ra3-area2796.json new file mode 100644 index 0000000..b386b0b --- /dev/null +++ b/tests/responses/ra3/area/2796/ra3-area2796.json @@ -0,0 +1,22 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneAreaDefinition", + "StatusCode": "200 OK", + "Url": "/area/2796" + }, + "Body": { + "Area": { + "href": "/area/2796", + "Name": "Porch", + "Parent": { + "href": "/area/3" + }, + "AssociatedZones": [ + { + "href": "/zone/2010" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/547/associatedzone.json b/tests/responses/ra3/area/547/associatedzone.json new file mode 100644 index 0000000..dcfd4bf --- /dev/null +++ b/tests/responses/ra3/area/547/associatedzone.json @@ -0,0 +1,51 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleZoneDefinition", + "StatusCode": "200 OK", + "Url": "/area/547/associatedzone" + }, + "Body": { + "Zones": [ + { + "href": "/zone/1361", + "Name": "Vanities", + "ControlType": "Dimmed", + "Category": { + "Type": "", + "IsLight": true + }, + "AssociatedArea": { + "href": "/area/547" + }, + "SortOrder": 0 + }, + { + "href": "/zone/1377", + "Name": "Shower \u0026 Tub", + "ControlType": "Dimmed", + "Category": { + "Type": "", + "IsLight": true + }, + "AssociatedArea": { + "href": "/area/547" + }, + "SortOrder": 1 + }, + { + "href": "/zone/1393", + "Name": "Vent", + "ControlType": "Switched", + "Category": { + "Type": "ExhaustFan", + "IsLight": false + }, + "AssociatedArea": { + "href": "/area/547" + }, + "SortOrder": 2 + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/547/controlstation.json b/tests/responses/ra3/area/547/controlstation.json new file mode 100644 index 0000000..60c91c6 --- /dev/null +++ b/tests/responses/ra3/area/547/controlstation.json @@ -0,0 +1,72 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleControlStationDefinition", + "StatusCode": "200 OK", + "Url": "/area/547/associatedcontrolstation" + }, + "Body": { + "ControlStations": [ + { + "href": "/controlstation/1352", + "Name": "Entry", + "AssociatedArea": { + "href": "/area/547" + }, + "SortOrder": 0, + "AssociatedGangedDevices": [ + { + "Device": { + "href": "/device/1354", + "DeviceType": "SunnataDimmer", + "AddressedState": "Unaddressed" + }, + "GangPosition": 0 + }, + { + "Device": { + "href": "/device/1370", + "DeviceType": "SunnataDimmer", + "AddressedState": "Unaddressed" + }, + "GangPosition": 1 + }, + { + "Device": { + "href": "/device/1386", + "DeviceType": "SunnataSwitch", + "AddressedState": "Unaddressed" + }, + "GangPosition": 2 + }, + { + "Device": { + "href": "/device/1488", + "DeviceType": "Pico3ButtonRaiseLower", + "AddressedState": "Unaddressed" + }, + "GangPosition": 3 + } + ] + }, + { + "href": "/controlstation/2937", + "Name": "Vanity", + "AssociatedArea": { + "href": "/area/547" + }, + "SortOrder": 1, + "AssociatedGangedDevices": [ + { + "Device": { + "href": "/device/2939", + "DeviceType": "Pico3ButtonRaiseLower", + "AddressedState": "Unaddressed" + }, + "GangPosition": 0 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/547/ra3-area547.json b/tests/responses/ra3/area/547/ra3-area547.json new file mode 100644 index 0000000..eba9086 --- /dev/null +++ b/tests/responses/ra3/area/547/ra3-area547.json @@ -0,0 +1,36 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneAreaDefinition", + "StatusCode": "200 OK", + "Url": "/area/547" + }, + "Body": { + "Area": { + "href": "/area/547", + "Name": "Primary Bath", + "Parent": { + "href": "/area/3" + }, + "AssociatedZones": [ + { + "href": "/zone/1361" + }, + { + "href": "/zone/1377" + }, + { + "href": "/zone/1393" + } + ], + "AssociatedControlStations": [ + { + "href": "/controlstation/1352" + }, + { + "href": "/controlstation/2937" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/766/associatedzone.json b/tests/responses/ra3/area/766/associatedzone.json new file mode 100644 index 0000000..e28d47c --- /dev/null +++ b/tests/responses/ra3/area/766/associatedzone.json @@ -0,0 +1,38 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleZoneDefinition", + "StatusCode": "200 OK", + "Url": "/area/766/associatedzone" + }, + "Body": { + "Zones": [ + { + "href": "/zone/2091", + "Name": "Overhead", + "ControlType": "Dimmed", + "Category": { + "Type": "", + "IsLight": true + }, + "AssociatedArea": { + "href": "/area/766" + }, + "SortOrder": 0 + }, + { + "href": "/zone/2107", + "Name": "Landscape", + "ControlType": "Dimmed", + "Category": { + "Type": "", + "IsLight": true + }, + "AssociatedArea": { + "href": "/area/766" + }, + "SortOrder": 1 + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/766/controlstation.json b/tests/responses/ra3/area/766/controlstation.json new file mode 100644 index 0000000..4bc54cf --- /dev/null +++ b/tests/responses/ra3/area/766/controlstation.json @@ -0,0 +1,80 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleControlStationDefinition", + "StatusCode": "200 OK", + "Url": "/area/766/associatedcontrolstation" + }, + "Body": { + "ControlStations": [ + { + "href": "/controlstation/2001", + "Name": "Entry by Door", + "AssociatedArea": { + "href": "/area/766" + }, + "SortOrder": 0, + "AssociatedGangedDevices": [ + { + "Device": { + "href": "/device/2003", + "DeviceType": "SunnataDimmer", + "AddressedState": "Unaddressed" + }, + "GangPosition": 1 + }, + { + "Device": { + "href": "/device/2084", + "DeviceType": "SunnataDimmer", + "AddressedState": "Unaddressed" + }, + "GangPosition": 2 + }, + { + "Device": { + "href": "/device/2100", + "DeviceType": "SunnataDimmer", + "AddressedState": "Unaddressed" + }, + "GangPosition": 0 + } + ] + }, + { + "href": "/controlstation/2132", + "Name": "Entry by Living Room", + "AssociatedArea": { + "href": "/area/766" + }, + "SortOrder": 1, + "AssociatedGangedDevices": [ + { + "Device": { + "href": "/device/2139", + "DeviceType": "SunnataKeypad", + "AddressedState": "Unaddressed" + }, + "GangPosition": 1 + }, + { + "Device": { + "href": "/device/2171", + "DeviceType": "SunnataKeypad", + "AddressedState": "Unaddressed" + }, + "GangPosition": 2 + }, + { + "Device": { + "href": "/device/2203", + "DeviceType": "SunnataDimmer", + "AddressedState": "Unaddressed" + }, + "GangPosition": 3 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/766/ra3-area766.json b/tests/responses/ra3/area/766/ra3-area766.json new file mode 100644 index 0000000..bf6c9aa --- /dev/null +++ b/tests/responses/ra3/area/766/ra3-area766.json @@ -0,0 +1,33 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneAreaDefinition", + "StatusCode": "200 OK", + "Url": "/area/766" + }, + "Body": { + "Area": { + "href": "/area/766", + "Name": "Entry", + "Parent": { + "href": "/area/3" + }, + "AssociatedZones": [ + { + "href": "/zone/2091" + }, + { + "href": "/zone/2107" + } + ], + "AssociatedControlStations": [ + { + "href": "/controlstation/2001" + }, + { + "href": "/controlstation/2132" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/83/associatedzone.json b/tests/responses/ra3/area/83/associatedzone.json new file mode 100644 index 0000000..8244b01 --- /dev/null +++ b/tests/responses/ra3/area/83/associatedzone.json @@ -0,0 +1,25 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleZoneDefinition", + "StatusCode": "200 OK", + "Url": "/area/83/associatedzone" + }, + "Body": { + "Zones": [ + { + "href": "/zone/536", + "Name": "Overhead", + "ControlType": "Switched", + "Category": { + "Type": "OtherAmbient", + "IsLight": true + }, + "AssociatedArea": { + "href": "/area/83" + }, + "SortOrder": 0 + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/83/controlstation.json b/tests/responses/ra3/area/83/controlstation.json new file mode 100644 index 0000000..e3d8fb5 --- /dev/null +++ b/tests/responses/ra3/area/83/controlstation.json @@ -0,0 +1,48 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleControlStationDefinition", + "StatusCode": "200 OK", + "Url": "/area/83/associatedcontrolstation" + }, + "Body": { + "ControlStations": [ + { + "href": "/controlstation/527", + "Name": "Entry", + "AssociatedArea": { + "href": "/area/83" + }, + "SortOrder": 0, + "AssociatedGangedDevices": [ + { + "Device": { + "href": "/device/529", + "DeviceType": "SunnataSwitch", + "AddressedState": "Unaddressed" + }, + "GangPosition": 0 + } + ] + }, + { + "href": "/controlstation/5339", + "Name": "TestingPico", + "AssociatedArea": { + "href": "/area/83" + }, + "SortOrder": 1, + "AssociatedGangedDevices": [ + { + "Device": { + "href": "/device/5341", + "DeviceType": "Pico3ButtonRaiseLower", + "AddressedState": "Addressed" + }, + "GangPosition": 0 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/area/83/ra3-area83.json b/tests/responses/ra3/area/83/ra3-area83.json new file mode 100644 index 0000000..2b6bf4c --- /dev/null +++ b/tests/responses/ra3/area/83/ra3-area83.json @@ -0,0 +1,27 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneAreaDefinition", + "StatusCode": "200 OK", + "Url": "/area/83" + }, + "Body": { + "Area": { + "href": "/area/83", + "Name": "Equipment Room", + "Parent": { + "href": "/area/3" + }, + "AssociatedZones": [ + { + "href": "/zone/536" + } + ], + "AssociatedControlStations": [ + { + "href": "/controlstation/527" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/areas.json b/tests/responses/ra3/areas.json new file mode 100644 index 0000000..0d69de1 --- /dev/null +++ b/tests/responses/ra3/areas.json @@ -0,0 +1,54 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleAreaDefinition", + "StatusCode": "200 OK", + "Url": "/area" + }, + "Body": { + "Areas": [ + { + "href": "/area/3", + "Name": "Home", + "SortOrder": 0, + "IsLeaf": false + }, + { + "href": "/area/83", + "Name": "Equipment Room", + "Parent": { + "href": "/area/3" + }, + "SortOrder": 0, + "IsLeaf": true + }, + { + "href": "/area/547", + "Name": "Primary Bath", + "Parent": { + "href": "/area/3" + }, + "SortOrder": 2, + "IsLeaf": true + }, + { + "href": "/area/766", + "Name": "Entry", + "Parent": { + "href": "/area/3" + }, + "SortOrder": 15, + "IsLeaf": true + }, + { + "href": "/area/2796", + "Name": "Porch", + "Parent": { + "href": "/area/3" + }, + "SortOrder": 28, + "IsLeaf": true + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/1488/buttongroup.json b/tests/responses/ra3/device/1488/buttongroup.json new file mode 100644 index 0000000..61ee0ee --- /dev/null +++ b/tests/responses/ra3/device/1488/buttongroup.json @@ -0,0 +1,100 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleButtonGroupExpandedDefinition", + "StatusCode": "200 OK", + "Url": "/device/1488/buttongroup/expanded" + }, + "Body": { + "ButtonGroupsExpanded": [ + { + "href": "/buttongroup/1491", + "Parent": { + "href": "/device/1488" + }, + "SortOrder": 0, + "Category": { + "Type": "Audio" + }, + "ProgrammingType": "Freeform", + "Buttons": [ + { + "href": "/button/1492", + "ButtonNumber": 0, + "ProgrammingModel": { + "href": "/programmingmodel/1493", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/1491" + }, + "Name": "Button 0", + "Engraving": { + "Text": "Play/Pause" + } + }, + { + "href": "/button/1495", + "ButtonNumber": 1, + "ProgrammingModel": { + "href": "/programmingmodel/1496", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/1491" + }, + "Name": "Button 1", + "Engraving": { + "Text": "Favorite" + } + }, + { + "href": "/button/1498", + "ButtonNumber": 2, + "ProgrammingModel": { + "href": "/programmingmodel/1499", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/1491" + }, + "Name": "Button 2", + "Engraving": { + "Text": "Next Track" + } + }, + { + "href": "/button/1501", + "ButtonNumber": 3, + "ProgrammingModel": { + "href": "/programmingmodel/1502", + "ProgrammingModelType": "SingleSceneRaiseProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/1491" + }, + "Name": "Button 3", + "Engraving": { + "Text": "Volume Up" + } + }, + { + "href": "/button/1504", + "ButtonNumber": 4, + "ProgrammingModel": { + "href": "/programmingmodel/1505", + "ProgrammingModelType": "SingleSceneLowerProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/1491" + }, + "Name": "Button 4", + "Engraving": { + "Text": "Volume Down" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/1488/device.json b/tests/responses/ra3/device/1488/device.json new file mode 100644 index 0000000..848dd14 --- /dev/null +++ b/tests/responses/ra3/device/1488/device.json @@ -0,0 +1,31 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneDeviceDefinition", + "StatusCode": "200 OK", + "Url": "/device/1488" + }, + "Body": { + "Device": { + "href": "/device/1488", + "Name": "Audio Pico", + "Parent": { + "href": "/project" + }, + "ModelNumber": "PJ2-3BRL-XXX-A02", + "DeviceType": "Pico3ButtonRaiseLower", + "AssociatedArea": { + "href": "/area/547" + }, + "LinkNodes": [ + { + "href": "/device/1488/linknode/1490" + } + ], + "DeviceClass": { + "HexadecimalEncoding": "1070101" + }, + "AddressedState": "Unaddressed" + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/2139/buttongroup.json b/tests/responses/ra3/device/2139/buttongroup.json new file mode 100644 index 0000000..3575cae --- /dev/null +++ b/tests/responses/ra3/device/2139/buttongroup.json @@ -0,0 +1,94 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleButtonGroupExpandedDefinition", + "StatusCode": "200 OK", + "Url": "/device/2139/buttongroup/expanded" + }, + "Body": { + "ButtonGroupsExpanded": [ + { + "href": "/buttongroup/2148", + "Parent": { + "href": "/device/2139" + }, + "SortOrder": 0, + "ProgrammingType": "Freeform", + "Buttons": [ + { + "href": "/button/2149", + "ButtonNumber": 1, + "ProgrammingModel": { + "href": "/programmingmodel/4398", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2148" + }, + "Name": "Button 1", + "Engraving": { + "Text": "Bright" + }, + "AssociatedLED": { + "href": "/led/2144" + } + }, + { + "href": "/button/2153", + "ButtonNumber": 2, + "ProgrammingModel": { + "href": "/programmingmodel/4400", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2148" + }, + "Name": "Button 2", + "Engraving": { + "Text": "Entertain" + }, + "AssociatedLED": { + "href": "/led/2145" + } + }, + { + "href": "/button/2157", + "ButtonNumber": 3, + "ProgrammingModel": { + "href": "/programmingmodel/4402", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2148" + }, + "Name": "Button 3", + "Engraving": { + "Text": "Dining" + }, + "AssociatedLED": { + "href": "/led/2146" + } + }, + { + "href": "/button/2161", + "ButtonNumber": 4, + "ProgrammingModel": { + "href": "/programmingmodel/4404", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2148" + }, + "Name": "Button 4", + "Engraving": { + "Text": "Off" + }, + "AssociatedLED": { + "href": "/led/2147" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/2139/device.json b/tests/responses/ra3/device/2139/device.json new file mode 100644 index 0000000..7d26b03 --- /dev/null +++ b/tests/responses/ra3/device/2139/device.json @@ -0,0 +1,34 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneDeviceDefinition", + "StatusCode": "200 OK", + "Url": "/device/2139" + }, + "Body": { + "Device": { + "href": "/device/2139", + "Name": "Scene Keypad", + "Parent": { + "href": "/project" + }, + "ModelNumber": "RRST-W4B-XX", + "DeviceType": "SunnataKeypad", + "AssociatedArea": { + "href": "/area/766" + }, + "LinkNodes": [ + { + "href": "/device/2139/linknode/2141" + } + ], + "FirmwareImage": { + "href": "/firmwareimage/2139" + }, + "DeviceClass": { + "HexadecimalEncoding": "1270101" + }, + "AddressedState": "Unaddressed" + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/2171/buttongroup.json b/tests/responses/ra3/device/2171/buttongroup.json new file mode 100644 index 0000000..e0b3285 --- /dev/null +++ b/tests/responses/ra3/device/2171/buttongroup.json @@ -0,0 +1,94 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleButtonGroupExpandedDefinition", + "StatusCode": "200 OK", + "Url": "/device/2171/buttongroup/expanded" + }, + "Body": { + "ButtonGroupsExpanded": [ + { + "href": "/buttongroup/2180", + "Parent": { + "href": "/device/2171" + }, + "SortOrder": 0, + "ProgrammingType": "Freeform", + "Buttons": [ + { + "href": "/button/2181", + "ButtonNumber": 1, + "ProgrammingModel": { + "href": "/programmingmodel/4406", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2180" + }, + "Name": "Button 1", + "Engraving": { + "Text": "Fan High" + }, + "AssociatedLED": { + "href": "/led/2176" + } + }, + { + "href": "/button/2185", + "ButtonNumber": 2, + "ProgrammingModel": { + "href": "/programmingmodel/4408", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2180" + }, + "Name": "Button 2", + "Engraving": { + "Text": "Medium" + }, + "AssociatedLED": { + "href": "/led/2177" + } + }, + { + "href": "/button/2189", + "ButtonNumber": 3, + "ProgrammingModel": { + "href": "/programmingmodel/4410", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2180" + }, + "Name": "Button 3", + "Engraving": { + "Text": "Low" + }, + "AssociatedLED": { + "href": "/led/2178" + } + }, + { + "href": "/button/2193", + "ButtonNumber": 4, + "ProgrammingModel": { + "href": "/programmingmodel/4412", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2180" + }, + "Name": "Button 4", + "Engraving": { + "Text": "Off" + }, + "AssociatedLED": { + "href": "/led/2179" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/2171/device.json b/tests/responses/ra3/device/2171/device.json new file mode 100644 index 0000000..eeee5c8 --- /dev/null +++ b/tests/responses/ra3/device/2171/device.json @@ -0,0 +1,34 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneDeviceDefinition", + "StatusCode": "200 OK", + "Url": "/device/2171" + }, + "Body": { + "Device": { + "href": "/device/2171", + "Name": "Fan Keypad", + "Parent": { + "href": "/project" + }, + "ModelNumber": "RRST-W4B-XX", + "DeviceType": "SunnataKeypad", + "AssociatedArea": { + "href": "/area/766" + }, + "LinkNodes": [ + { + "href": "/device/2171/linknode/2173" + } + ], + "FirmwareImage": { + "href": "/firmwareimage/2171" + }, + "DeviceClass": { + "HexadecimalEncoding": "1270101" + }, + "AddressedState": "Unaddressed" + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/2939/buttongroup.json b/tests/responses/ra3/device/2939/buttongroup.json new file mode 100644 index 0000000..fea968a --- /dev/null +++ b/tests/responses/ra3/device/2939/buttongroup.json @@ -0,0 +1,100 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleButtonGroupExpandedDefinition", + "StatusCode": "200 OK", + "Url": "/device/2939/buttongroup/expanded" + }, + "Body": { + "ButtonGroupsExpanded": [ + { + "href": "/buttongroup/2942", + "Parent": { + "href": "/device/2939" + }, + "SortOrder": 0, + "Category": { + "Type": "Audio" + }, + "ProgrammingType": "Freeform", + "Buttons": [ + { + "href": "/button/2943", + "ButtonNumber": 0, + "ProgrammingModel": { + "href": "/programmingmodel/2944", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2942" + }, + "Name": "Button 0", + "Engraving": { + "Text": "Play/Pause" + } + }, + { + "href": "/button/2946", + "ButtonNumber": 1, + "ProgrammingModel": { + "href": "/programmingmodel/2947", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2942" + }, + "Name": "Button 1", + "Engraving": { + "Text": "Favorite" + } + }, + { + "href": "/button/2949", + "ButtonNumber": 2, + "ProgrammingModel": { + "href": "/programmingmodel/2950", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2942" + }, + "Name": "Button 2", + "Engraving": { + "Text": "Next Track" + } + }, + { + "href": "/button/2952", + "ButtonNumber": 3, + "ProgrammingModel": { + "href": "/programmingmodel/2953", + "ProgrammingModelType": "SingleSceneRaiseProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2942" + }, + "Name": "Button 3", + "Engraving": { + "Text": "Volume Up" + } + }, + { + "href": "/button/2955", + "ButtonNumber": 4, + "ProgrammingModel": { + "href": "/programmingmodel/2956", + "ProgrammingModelType": "SingleSceneLowerProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/2942" + }, + "Name": "Button 4", + "Engraving": { + "Text": "Volume Down" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/2939/device.json b/tests/responses/ra3/device/2939/device.json new file mode 100644 index 0000000..b1fa467 --- /dev/null +++ b/tests/responses/ra3/device/2939/device.json @@ -0,0 +1,31 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneDeviceDefinition", + "StatusCode": "200 OK", + "Url": "/device/2939" + }, + "Body": { + "Device": { + "href": "/device/2939", + "Name": "Audio Pico", + "Parent": { + "href": "/project" + }, + "ModelNumber": "PJ2-3BRL-XXX-A02", + "DeviceType": "Pico3ButtonRaiseLower", + "AssociatedArea": { + "href": "/area/547" + }, + "LinkNodes": [ + { + "href": "/device/2939/linknode/2941" + } + ], + "DeviceClass": { + "HexadecimalEncoding": "1070101" + }, + "AddressedState": "Unaddressed" + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/5341/buttongroup.json b/tests/responses/ra3/device/5341/buttongroup.json new file mode 100644 index 0000000..705d959 --- /dev/null +++ b/tests/responses/ra3/device/5341/buttongroup.json @@ -0,0 +1,100 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleButtonGroupExpandedDefinition", + "StatusCode": "200 OK", + "Url": "/device/5341/buttongroup/expanded" + }, + "Body": { + "ButtonGroupsExpanded": [ + { + "href": "/buttongroup/5344", + "Parent": { + "href": "/device/5341" + }, + "SortOrder": 0, + "Category": { + "Type": "Lights" + }, + "ProgrammingType": "Freeform", + "Buttons": [ + { + "href": "/button/5345", + "ButtonNumber": 0, + "ProgrammingModel": { + "href": "/programmingmodel/5346", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/5344" + }, + "Name": "Button 0", + "Engraving": { + "Text": "On" + } + }, + { + "href": "/button/5348", + "ButtonNumber": 1, + "ProgrammingModel": { + "href": "/programmingmodel/5349", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/5344" + }, + "Name": "Button 1", + "Engraving": { + "Text": "Favorite" + } + }, + { + "href": "/button/5351", + "ButtonNumber": 2, + "ProgrammingModel": { + "href": "/programmingmodel/5352", + "ProgrammingModelType": "SingleActionProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/5344" + }, + "Name": "Button 2", + "Engraving": { + "Text": "Off" + } + }, + { + "href": "/button/5354", + "ButtonNumber": 3, + "ProgrammingModel": { + "href": "/programmingmodel/5355", + "ProgrammingModelType": "SingleSceneRaiseProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/5344" + }, + "Name": "Button 3", + "Engraving": { + "Text": "Raise" + } + }, + { + "href": "/button/5357", + "ButtonNumber": 4, + "ProgrammingModel": { + "href": "/programmingmodel/5358", + "ProgrammingModelType": "SingleSceneLowerProgrammingModel" + }, + "Parent": { + "href": "/buttongroup/5344" + }, + "Name": "Button 4", + "Engraving": { + "Text": "Lower" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/device/5341/device.json b/tests/responses/ra3/device/5341/device.json new file mode 100644 index 0000000..bb27bd8 --- /dev/null +++ b/tests/responses/ra3/device/5341/device.json @@ -0,0 +1,32 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneDeviceDefinition", + "StatusCode": "200 OK", + "Url": "/device/5341" + }, + "Body": { + "Device": { + "href": "/device/5341", + "Name": "TestingPicoDev", + "Parent": { + "href": "/project" + }, + "SerialNumber": 68130838, + "ModelNumber": "PJ2-3BRL-XXX-L01", + "DeviceType": "Pico3ButtonRaiseLower", + "AssociatedArea": { + "href": "/area/83" + }, + "LinkNodes": [ + { + "href": "/device/5341/linknode/5343" + } + ], + "DeviceClass": { + "HexadecimalEncoding": "1070201" + }, + "AddressedState": "Addressed" + } + } +} \ No newline at end of file diff --git a/tests/responses/ra3/devices.json b/tests/responses/ra3/devices.json new file mode 100644 index 0000000..22aa148 --- /dev/null +++ b/tests/responses/ra3/devices.json @@ -0,0 +1,7 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "StatusCode": "204 NoContent", + "Url": "/device" + } +} \ No newline at end of file diff --git a/tests/responses/ra3/processor.json b/tests/responses/ra3/processor.json new file mode 100755 index 0000000..9a7ae69 --- /dev/null +++ b/tests/responses/ra3/processor.json @@ -0,0 +1,49 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "MultipleDeviceDefinition", + "StatusCode": "200 OK", + "Url": "/device?where=IsThisDevice:true" + }, + "Body": { + "Devices": [ + { + "href": "/device/104", + "Name": "Enclosure Device 001", + "Parent": { "href": "/project" }, + "SerialNumber": 11111111, + "ModelNumber": "JanusProcRA3", + "DeviceType": "RadioRa3Processor", + "AssociatedArea": { "href": "/area/83" }, + "OwnedLinks": [ + { "href": "/link/108", "LinkType": "RF" }, + { "href": "/link/106", "LinkType": "ClearConnectTypeX" } + ], + "LinkNodes": [ + { "href": "/device/104/linknode/107" }, + { "href": "/device/104/linknode/105" } + ], + "FirmwareImage": { + "Firmware": { "DisplayName": "21.07.18f000" }, + "Installed": { + "Year": 2022, + "Month": 1, + "Day": 1, + "Hour": 0, + "Minute": 0, + "Second": 0, + "Utc": "-5:00:00" + } + }, + "DeviceFirmwarePackage": { + "Package": { "DisplayName": "001.016.000r000" } + }, + "NetworkInterfaces": [{ "MACAddress": "00:00:00:00:00:00" }], + "Databases": [{ "href": "/database/@Project", "Type": "Project" }], + "DeviceClass": { "HexadecimalEncoding": "81b0101" }, + "AddressedState": "Addressed", + "IsThisDevice": true + } + ] + } +} \ No newline at end of file diff --git a/tests/responses/ra3/project.json b/tests/responses/ra3/project.json new file mode 100644 index 0000000..3ed6104 --- /dev/null +++ b/tests/responses/ra3/project.json @@ -0,0 +1,39 @@ +{ + "CommuniqueType": "ReadResponse", + "Header": { + "MessageBodyType": "OneProjectDefinition", + "StatusCode": "200 OK", + "Url": "/project" + }, + "Body": { + "Project": { + "href": "/project", + "Name": "Home", + "ProductType": "Lutron RadioRA 3 Project", + "MasterDeviceList": { + "Devices": [ + { + "href": "/device/96" + } + ] + }, + "Contacts": [ + { + "href": "/contactinfo/81" + } + ], + "TimeclockEventRules": { + "href": "/project/timeclockeventrules" + }, + "ProjectModifiedTimestamp": { + "Year": 2022, + "Month": 2, + "Day": 8, + "Hour": 0, + "Minute": 39, + "Second": 20, + "Utc": "0" + } + } + } +} diff --git a/tests/responses/ra3/zonestatus.json b/tests/responses/ra3/zonestatus.json new file mode 100644 index 0000000..915762f --- /dev/null +++ b/tests/responses/ra3/zonestatus.json @@ -0,0 +1,70 @@ +{ + "CommuniqueType": "SubscribeResponse", + "Header": { + "MessageBodyType": "MultipleZoneStatus", + "StatusCode": "200 OK", + "Url": "/zone/status" + }, + "Body": { + "ZoneStatuses": [ + { + "href": "/zone/536/status", + "Level": 0, + "SwitchedLevel": "Off", + "Zone": { + "href": "/zone/536" + }, + "StatusAccuracy": "Good" + }, + { + "href": "/zone/1361/status", + "Level": 0, + "Zone": { + "href": "/zone/1361" + }, + "StatusAccuracy": "Good" + }, + { + "href": "/zone/1377/status", + "Level": 0, + "Zone": { + "href": "/zone/1377" + }, + "StatusAccuracy": "Good" + }, + { + "href": "/zone/1393/status", + "Level": 0, + "SwitchedLevel": "Off", + "Zone": { + "href": "/zone/1393" + }, + "StatusAccuracy": "Good" + }, + { + "href": "/zone/2091/status", + "Level": 0, + "Zone": { + "href": "/zone/2091" + }, + "StatusAccuracy": "Good" + }, + { + "href": "/zone/2107/status", + "Level": 0, + "Zone": { + "href": "/zone/2107" + }, + "StatusAccuracy": "Good" + }, + { + "href": "/zone/2010/status", + "Level": 0, + "Zone": { + "href": "/zone/2010" + }, + "StatusAccuracy": "Good" + } + ] + } +} \ No newline at end of file diff --git a/tests/test_smartbridge.py b/tests/test_smartbridge.py index bcf4212..8cd9594 100644 --- a/tests/test_smartbridge.py +++ b/tests/test_smartbridge.py @@ -20,8 +20,10 @@ import pytest +from pylutron_caseta.leap import id_from_href from pylutron_caseta.messages import Response, ResponseHeader, ResponseStatus from pylutron_caseta import ( + _LEAP_DEVICE_TYPES, FAN_MEDIUM, OCCUPANCY_GROUP_OCCUPIED, OCCUPANCY_GROUP_UNOCCUPIED, @@ -33,6 +35,9 @@ logging.getLogger().setLevel(logging.DEBUG) _LOG = logging.getLogger(__name__) +CASETA_PROCESSOR = "Caseta" +RA3_PROCESSOR = "RA3" + def response_from_json_file(filename: str) -> Response: """Fetch a response from a saved JSON file.""" @@ -145,6 +150,7 @@ def __init__(self): self.button_subscription_data_result = response_from_json_file( "buttonsubscribe.json" ) + self.ra3_button_list = [] async def fake_connect(): """Open a fake LEAP connection for the test.""" @@ -154,7 +160,7 @@ async def fake_connect(): self.target = smartbridge.Smartbridge(fake_connect) - async def initialize(self): + async def initialize(self, processor=CASETA_PROCESSOR): """Perform the initial connection with SmartBridge.""" connect_task = asyncio.get_running_loop().create_task(self.target.connect()) fake_leap = await self.connections.get() @@ -172,7 +178,11 @@ async def wait(coro: Awaitable[T]) -> T: result = await task return result - await self._accept_connection(fake_leap, wait) + if processor == "Caseta": + await self._accept_connection(fake_leap, wait) + elif processor == "RA3": + await self._accept_connection_ra3(fake_leap, wait) + await connect_task self.leap = fake_leap @@ -180,43 +190,49 @@ async def wait(coro: Awaitable[T]) -> T: async def _accept_connection(self, leap, wait): """Accept a connection from SmartBridge (implementation).""" - # First message should be read request on /device + # Read request on /areas + request, response = await wait(leap.requests.get()) + assert request == Request(communique_type="ReadRequest", url="/area") + response.set_result(response_from_json_file("areas.json")) + leap.requests.task_done() + + # Read request on /project + request, response = await wait(leap.requests.get()) + assert request == Request(communique_type="ReadRequest", url="/project") + response.set_result(response_from_json_file("project.json")) + leap.requests.task_done() + + # Read request on /device request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/device") response.set_result(response_from_json_file("devices.json")) leap.requests.task_done() - # Second message should be read request on /button + # Read request on /button request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/button") response.set_result(self.button_list_result) leap.requests.task_done() - # Third message should be read request on /server/2/id + # Read request on /server/2/id request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/server/2/id") response.set_result(response_from_json_file("lip.json")) leap.requests.task_done() - # Fourth message should be read request on /virtualbutton + # Read request on /virtualbutton request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/virtualbutton") response.set_result(response_from_json_file("scenes.json")) leap.requests.task_done() - # Fifth message should be read request on /areas - request, response = await wait(leap.requests.get()) - assert request == Request(communique_type="ReadRequest", url="/area") - response.set_result(response_from_json_file("areas.json")) - leap.requests.task_done() - - # Sixth message should be read request on /occupancygroup + # Read request on /occupancygroup request, response = await wait(leap.requests.get()) assert request == Request(communique_type="ReadRequest", url="/occupancygroup") response.set_result(self.occupancy_group_list_result) leap.requests.task_done() - # Seventh message should be subscribe request on /occupancygroup/status + # Subscribe request on /occupancygroup/status request, response = await wait(leap.requests.get()) assert request == Request( communique_type="SubscribeRequest", url="/occupancygroup/status" @@ -224,7 +240,7 @@ async def _accept_connection(self, leap, wait): response.set_result(self.occupancy_group_subscription_data_result) leap.requests.task_done() - # 8-10th message should be subscribe request on /button/{button}/status/event + # Subscribe request on /button/{button}/status/event for button in ( re.sub(r".*/", "", button["href"]) for button in self.button_list_result.Body.get("Buttons", []) @@ -236,7 +252,7 @@ async def _accept_connection(self, leap, wait): response.set_result(self.button_subscription_data_result) leap.requests.task_done() - # Finally, we should check the zone status on each zone + # Check the zone status on each zone requested_zones = [] for _ in range(0, 4): request, response = await wait(leap.requests.get()) @@ -269,6 +285,114 @@ async def _accept_connection(self, leap, wait): "/zone/6/status", ] + async def _process_station(self, result, leap, wait): + if result.Body is None: + return + + for station in result.Body.get("ControlStations", []): + for device in station.get("AssociatedGangedDevices", []): + device_type = device["Device"]["DeviceType"] + if device_type not in _LEAP_DEVICE_TYPES.get("sensor"): + continue + + device_id = re.sub(r".*/", "", device["Device"]["href"]) + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="ReadRequest", + url=f"/device/{device_id}/buttongroup/expanded", + ) + button_group_result = response_from_json_file( + f"ra3/device/{device_id}/buttongroup.json" + ) + response.set_result(button_group_result) + leap.requests.task_done() + + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="ReadRequest", url=f"/device/{device_id}" + ) + response.set_result( + response_from_json_file(f"ra3/device/{device_id}/device.json") + ) + leap.requests.task_done() + + # collect every button for upcoming subscribe tests + self.ra3_button_list.extend( + [ + id_from_href(button["href"]) + for group in button_group_result.Body["ButtonGroupsExpanded"] + for button in group["Buttons"] + ] + ) + + async def _accept_connection_ra3(self, leap, wait): + """Accept a connection from SmartBridge (implementation).""" + # Read request on /areas + ra3_area_list_result = response_from_json_file("ra3/areas.json") + request, response = await wait(leap.requests.get()) + assert request == Request(communique_type="ReadRequest", url="/area") + response.set_result(ra3_area_list_result) + leap.requests.task_done() + + # Read request on /project + request, response = await wait(leap.requests.get()) + assert request == Request(communique_type="ReadRequest", url="/project") + response.set_result(response_from_json_file("ra3/project.json")) + leap.requests.task_done() + + # Read request on /device?where=IsThisDevice:true + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="ReadRequest", url="/device?where=IsThisDevice:true" + ) + response.set_result(response_from_json_file("ra3/processor.json")) + leap.requests.task_done() + + # Read request on each area's control stations & zones + for area_id in ( + re.sub(r".*/", "", area["href"]) + for area in ra3_area_list_result.Body.get("Areas", []) + if area["IsLeaf"] + ): + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="ReadRequest", + url=f"/area/{area_id}/associatedcontrolstation", + ) + station_result = response_from_json_file( + f"ra3/area/{area_id}/controlstation.json" + ) + response.set_result(station_result) + leap.requests.task_done() + await self._process_station(station_result, leap, wait) + + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="ReadRequest", url=f"/area/{area_id}/associatedzone" + ) + zone_result = response_from_json_file( + f"ra3/area/{area_id}/associatedzone.json" + ) + response.set_result(zone_result) + leap.requests.task_done() + + # Read request on /zone/status + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="SubscribeRequest", url="/zone/status" + ) + response.set_result(response_from_json_file("ra3/zonestatus.json")) + leap.requests.task_done() + + # Subscribe request on /button/{button}/status/event + for button in self.ra3_button_list: + request, response = await wait(leap.requests.get()) + assert request == Request( + communique_type="SubscribeRequest", url=f"/button/{button}/status/event" + ) + response.set_result(self.button_subscription_data_result) + leap.requests.task_done() + def disconnect(self, exception=None): """Disconnect SmartBridge.""" if exception is None: @@ -309,7 +433,15 @@ async def fixture_bridge_uninit() -> AsyncGenerator[Bridge, None]: @pytest.fixture(name="bridge") async def fixture_bridge(bridge_uninit) -> AsyncGenerator[Bridge, None]: """Create a bridge attached to a fake reader and writer.""" - await bridge_uninit.initialize() + await bridge_uninit.initialize(CASETA_PROCESSOR) + + yield bridge_uninit + + +@pytest.fixture(name="ra3_bridge") +async def fixture_bridge_ra3(bridge_uninit) -> AsyncGenerator[Bridge, None]: + """Create a RA3 bridge attached to a fake reader and writer.""" + await bridge_uninit.initialize(RA3_PROCESSOR) yield bridge_uninit @@ -600,10 +732,10 @@ def connect(): async def test_area_list(bridge: Bridge): """Test the list of areas loaded by the bridge.""" expected_areas = { - "1": {"name": "root"}, - "2": {"name": "Hallway"}, - "3": {"name": "Living Room"}, - "4": {"name": "Master Bathroom"}, + "1": {"id": "1", "name": "root"}, + "2": {"id": "2", "name": "Hallway"}, + "3": {"id": "3", "name": "Living Room"}, + "4": {"id": "4", "name": "Master Bathroom"}, } assert bridge.target.areas == expected_areas @@ -1187,3 +1319,468 @@ async def test_reconnect_timeout(event_loop): task.cancel() await bridge.target.close() + + +@pytest.mark.asyncio +async def test_is_ra3_connected(ra3_bridge: Bridge): + """Test the is_connected method returns connection state.""" + assert ra3_bridge.target.is_connected() is True + + def connect(): + raise NotImplementedError() + + other = smartbridge.Smartbridge(connect) + assert other.is_connected() is False + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_notifications(ra3_bridge: Bridge): + """Test notifications are sent to subscribers.""" + notified = False + + def callback(): + nonlocal notified + notified = True + + ra3_bridge.target.add_subscriber("1377", callback) + ra3_bridge.leap.send_unsolicited( + Response( + CommuniqueType="ReadResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(200, "OK"), + Url="/zone/1337/status", + ), + Body={"ZoneStatus": {"Level": 100, "Zone": {"href": "/zone/1377"}}}, + ) + ) + await asyncio.wait_for(ra3_bridge.leap.requests.join(), 10) + assert notified + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_device_list(ra3_bridge: Bridge): + """Test methods getting devices.""" + devices = ra3_bridge.target.get_devices() + expected_devices = { + "1": { + "button_groups": None, + "current_state": -1, + "device_id": "1", + "fan_speed": None, + "model": "JanusProcRA3", + "name": "Equipment Room_Enclosure Device 001_RadioRa3Processor", + "serial": 11111111, + "type": None, + "zone": "1", + }, + "1361": { + "button_groups": None, + "current_state": 0, + "device_id": "1361", + "fan_speed": None, + "model": None, + "name": "Primary Bath_Vanities", + "serial": None, + "tilt": None, + "type": "Dimmed", + "zone": "1361", + }, + "1377": { + "button_groups": None, + "current_state": 0, + "device_id": "1377", + "fan_speed": None, + "model": None, + "name": "Primary Bath_Shower & Tub", + "serial": None, + "tilt": None, + "type": "Dimmed", + "zone": "1377", + }, + "1393": { + "button_groups": None, + "current_state": 0, + "device_id": "1393", + "fan_speed": None, + "model": None, + "name": "Primary Bath_Vent", + "serial": None, + "tilt": None, + "type": "Switched", + "zone": "1393", + }, + "1488": { + "button_groups": ["1491"], + "current_state": -1, + "device_id": "1488", + "fan_speed": None, + "model": "PJ2-3BRL-XXX-A02", + "name": "Primary Bath_Entry_Audio Pico_Pico3ButtonRaiseLower", + "serial": None, + "type": "Pico3ButtonRaiseLower", + "zone": None, + }, + "2010": { + "button_groups": None, + "current_state": 0, + "device_id": "2010", + "fan_speed": None, + "model": None, + "name": "Porch_Porch", + "serial": None, + "tilt": None, + "type": "Dimmed", + "zone": "2010", + }, + "2091": { + "button_groups": None, + "current_state": 0, + "device_id": "2091", + "fan_speed": None, + "model": None, + "name": "Entry_Overhead", + "serial": None, + "tilt": None, + "type": "Dimmed", + "zone": "2091", + }, + "2107": { + "button_groups": None, + "current_state": 0, + "device_id": "2107", + "fan_speed": None, + "model": None, + "name": "Entry_Landscape", + "serial": None, + "tilt": None, + "type": "Dimmed", + "zone": "2107", + }, + "2139": { + "button_groups": ["2148"], + "current_state": -1, + "device_id": "2139", + "fan_speed": None, + "model": "RRST-W4B-XX", + "name": "Entry_Entry by Living Room_Scene Keypad_SunnataKeypad", + "serial": None, + "type": "SunnataKeypad", + "zone": None, + }, + "2171": { + "button_groups": ["2180"], + "current_state": -1, + "device_id": "2171", + "fan_speed": None, + "model": "RRST-W4B-XX", + "name": "Entry_Entry by Living Room_Fan Keypad_SunnataKeypad", + "serial": None, + "type": "SunnataKeypad", + "zone": None, + }, + "2939": { + "button_groups": ["2942"], + "current_state": -1, + "device_id": "2939", + "fan_speed": None, + "model": "PJ2-3BRL-XXX-A02", + "name": "Primary Bath_Vanity_Audio Pico_Pico3ButtonRaiseLower", + "serial": None, + "type": "Pico3ButtonRaiseLower", + "zone": None, + }, + "5341": { + "button_groups": ["5344"], + "current_state": -1, + "device_id": "5341", + "fan_speed": None, + "model": "PJ2-3BRL-XXX-L01", + "name": "Equipment Room_TestingPico_TestingPicoDev_Pico3ButtonRaiseLower", + "serial": 68130838, + "type": "Pico3ButtonRaiseLower", + "zone": None, + }, + "536": { + "button_groups": None, + "current_state": 0, + "device_id": "536", + "fan_speed": None, + "model": None, + "name": "Equipment Room_Overhead", + "serial": None, + "tilt": None, + "type": "Switched", + "zone": "536", + }, + } + + assert devices == expected_devices + + ra3_bridge.leap.send_unsolicited( + Response( + CommuniqueType="ReadResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(200, "OK"), + Url="/zone/1377/status", + ), + Body={"ZoneStatus": {"Level": 100, "Zone": {"href": "/zone/1377"}}}, + ) + ) + + devices = ra3_bridge.target.get_devices() + assert devices["1377"]["current_state"] == 100 + assert devices["1488"]["current_state"] == -1 + + devices = ra3_bridge.target.get_devices_by_domain("light") + assert len(devices) == 5 + assert devices[0]["device_id"] == "1361" + + devices = ra3_bridge.target.get_devices_by_type("Dimmed") + assert len(devices) == 5 + assert devices[0]["device_id"] == "1361" + + devices = ra3_bridge.target.get_devices_by_types( + ("Pico3ButtonRaiseLower", "Dimmed") + ) + assert len(devices) == 8 + + device = ra3_bridge.target.get_device_by_id("2939") + assert device["device_id"] == "2939" + + devices = ra3_bridge.target.get_devices_by_domain("fan") + assert len(devices) == 0 + + devices = ra3_bridge.target.get_devices_by_type("CasetaFanSpeedController") + assert len(devices) == 0 + + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_area_list(ra3_bridge: Bridge): + """Test the list of areas loaded by the bridge.""" + expected_areas = { + "2796": {"id": "2796", "name": "Porch"}, + "547": {"id": "547", "name": "Primary Bath"}, + "766": {"id": "766", "name": "Entry"}, + "83": {"id": "83", "name": "Equipment Room"}, + } + + assert ra3_bridge.target.areas == expected_areas + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_button_status_change(ra3_bridge: Bridge): + """Test that the status is updated when Pico button is pressed.""" + ra3_bridge.leap.send_to_subscribers( + Response( + CommuniqueType="ReadResponse", + Header=ResponseHeader( + MessageBodyType="OneButtonStatusEvent", + StatusCode=ResponseStatus(200, "OK"), + Url="/button/2946/status/event", + ), + Body={ + "ButtonStatus": { + "Button": {"href": "/button/2946"}, + "ButtonEvent": {"EventType": "Press"}, + } + }, + ) + ) + new_status = ra3_bridge.target.buttons["2946"]["current_state"] + assert new_status == BUTTON_STATUS_PRESSED + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_button_status_change_notification(ra3_bridge: Bridge): + """Test that button status changes send notifications.""" + notified = False + + def notify(status): + assert status == BUTTON_STATUS_PRESSED + nonlocal notified + notified = True + + ra3_bridge.target.add_button_subscriber("2946", notify) + ra3_bridge.leap.send_to_subscribers( + Response( + CommuniqueType="ReadResponse", + Header=ResponseHeader( + MessageBodyType="OneButtonStatusEvent", + StatusCode=ResponseStatus(200, "OK"), + Url="/button/2946/status/event", + ), + Body={ + "ButtonStatus": { + "Button": {"href": "/button/2946"}, + "ButtonEvent": {"EventType": "Press"}, + } + }, + ) + ) + assert notified + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_is_on(ra3_bridge: Bridge): + """Test the is_on method returns device state.""" + ra3_bridge.leap.send_unsolicited( + Response( + CommuniqueType="ReadResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(200, "OK"), + Url="/zone/2107/status", + ), + Body={"ZoneStatus": {"Level": 50, "Zone": {"href": "/zone/2107"}}}, + ) + ) + + assert ra3_bridge.target.is_on("2107") is True + + ra3_bridge.leap.send_unsolicited( + Response( + CommuniqueType="ReadResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(200, "OK"), + Url="/zone/2107/status", + ), + Body={"ZoneStatus": {"Level": 0, "Zone": {"href": "/zone/2107"}}}, + ) + ) + + assert ra3_bridge.target.is_on("2107") is False + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_set_value(ra3_bridge: Bridge, event_loop): + """Test that setting values produces the right commands.""" + print("BORE1") + task = event_loop.create_task(ra3_bridge.target.set_value("2107", 50)) + command, response = await ra3_bridge.leap.requests.get() + assert command == Request( + communique_type="CreateRequest", + url="/zone/2107/commandprocessor", + body={ + "Command": { + "CommandType": "GoToLevel", + "Parameter": [{"Type": "Level", "Value": 50}], + } + }, + ) + response.set_result( + Response( + CommuniqueType="CreateResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(201, "Created"), + Url="/zone/2107/commandprocessor", + ), + Body={ + "ZoneStatus": { + "href": "/zone/2107/status", + "Level": 50, + "Zone": {"href": "/zone/2107"}, + } + }, + ) + ) + ra3_bridge.leap.requests.task_done() + await task + + task = event_loop.create_task(ra3_bridge.target.turn_on("2107")) + command, response = await ra3_bridge.leap.requests.get() + assert command == Request( + communique_type="CreateRequest", + url="/zone/2107/commandprocessor", + body={ + "Command": { + "CommandType": "GoToLevel", + "Parameter": [{"Type": "Level", "Value": 100}], + } + }, + ) + response.set_result( + Response( + CommuniqueType="CreateResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(201, "Created"), + Url="/zone/2107/commandprocessor", + ), + Body={ + "ZoneStatus": { + "href": "/zone/2107/status", + "Level": 100, + "Zone": {"href": "/zone/2107"}, + } + }, + ), + ) + ra3_bridge.leap.requests.task_done() + await task + + task = event_loop.create_task(ra3_bridge.target.turn_off("2107")) + command, response = await ra3_bridge.leap.requests.get() + assert command == Request( + communique_type="CreateRequest", + url="/zone/2107/commandprocessor", + body={ + "Command": { + "CommandType": "GoToLevel", + "Parameter": [{"Type": "Level", "Value": 0}], + } + }, + ) + response.set_result( + Response( + CommuniqueType="CreateResponse", + Header=ResponseHeader( + MessageBodyType="OneZoneStatus", + StatusCode=ResponseStatus(201, "Created"), + Url="/zone/2107/commandprocessor", + ), + Body={ + "ZoneStatus": { + "href": "/zone/2107/status", + "Level": 0, + "Zone": {"href": "/zone/2107"}, + } + }, + ), + ) + ra3_bridge.leap.requests.task_done() + await task + await ra3_bridge.target.close() + + +@pytest.mark.asyncio +async def test_ra3_set_value_with_fade(ra3_bridge: Bridge, event_loop): + """Test that setting values with fade_time produces the right commands.""" + task = event_loop.create_task( + ra3_bridge.target.set_value("2107", 50, fade_time=timedelta(seconds=4)) + ) + command, _ = await ra3_bridge.leap.requests.get() + assert command == Request( + communique_type="CreateRequest", + url="/zone/2107/commandprocessor", + body={ + "Command": { + "CommandType": "GoToDimmedLevel", + "DimmedLevelParameters": {"Level": 50, "FadeTime": "00:00:04"}, + } + }, + ) + ra3_bridge.leap.requests.task_done() + task.cancel() + await ra3_bridge.target.close()