From 9a5c1fbaedb7d8b20015c65037fb01b7d4c3e06f Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Fri, 4 Oct 2019 11:41:47 -0400 Subject: [PATCH] Add SecurityPanelController for alarm_control_panel to alexa (#27081) * Implemented Alexa.SecurityPanelController Interface for alarm_control_panel https://developer.amazon.com/docs/device-apis/alexa-securitypanelcontroller.html * Implemented Tests for Alexa.SecurityPanelController Interface for alarm_control_panel * Added additional AuthorizationRequired error handling * Removed optional exitDelayInSeconds * Updating elif to if to please pylint * Adding self to code owners. * Adding self to code owners. * Added AlexaEndpointHealth Interface to alarm_control_panel entities. * Added additional entity tests. * Code reformatted with Black. * Updated alexa alarm_control_panel tests for more coverage. * Updated alexa alarm_control_panel tests for more coverage. Fixed Test. * Adding self to code owners. --- CODEOWNERS | 2 +- .../components/alexa/capabilities.py | 67 ++++++++++ homeassistant/components/alexa/entities.py | 17 +++ homeassistant/components/alexa/errors.py | 14 ++ homeassistant/components/alexa/handlers.py | 75 +++++++++++ homeassistant/components/alexa/manifest.json | 5 +- tests/components/alexa/test_capabilities.py | 35 +++++ tests/components/alexa/test_smart_home.py | 124 ++++++++++++++++++ 8 files changed, 337 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8073020712d..935d68033e3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -17,7 +17,7 @@ homeassistant/components/adguard/* @frenck homeassistant/components/airly/* @bieniu homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell -homeassistant/components/alexa/* @home-assistant/cloud +homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index fca63adab0e..7be3188fea1 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -5,6 +5,10 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_LOCKED, STATE_OFF, STATE_ON, @@ -13,6 +17,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) import homeassistant.components.climate.const as climate +from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER from homeassistant.components import light, fan, cover import homeassistant.util.color as color_util import homeassistant.util.dt as dt_util @@ -79,6 +84,11 @@ class AlexaCapibility: """Applicable only to scenes.""" return None + @staticmethod + def configuration(): + """Applicable only to security control panel.""" + return [] + def serialize_discovery(self): """Serialize according to the Discovery API.""" result = { @@ -96,6 +106,11 @@ class AlexaCapibility: supports_deactivation = self.supports_deactivation() if supports_deactivation is not None: result["supportsDeactivation"] = supports_deactivation + + configuration = self.configuration() + if configuration: + result["configuration"] = configuration + return result def serialize_properties(self): @@ -649,3 +664,55 @@ class AlexaPowerLevelController(AlexaCapibility): return PERCENTAGE_FAN_MAP.get(speed, None) return None + + +class AlexaSecurityPanelController(AlexaCapibility): + """Implements Alexa.SecurityPanelController. + + https://developer.amazon.com/docs/device-apis/alexa-securitypanelcontroller.html + """ + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SecurityPanelController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "armState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "armState": + raise UnsupportedProperty(name) + + arm_state = self.entity.state + if arm_state == STATE_ALARM_ARMED_HOME: + return "ARMED_STAY" + if arm_state == STATE_ALARM_ARMED_AWAY: + return "ARMED_AWAY" + if arm_state == STATE_ALARM_ARMED_NIGHT: + return "ARMED_NIGHT" + if arm_state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + return "ARMED_STAY" + return "DISARMED" + + def configuration(self): + """Return supported authorization types.""" + code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) + + if code_format == FORMAT_NUMBER: + return {"supportedAuthorizationTypes": [{"type": "FOUR_DIGIT_PIN"}]} + return [] diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 63231f71447..0f07e525fa9 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -14,6 +14,7 @@ from homeassistant.const import ( from homeassistant.util.decorator import Registry from homeassistant.components.climate import const as climate from homeassistant.components import ( + alarm_control_panel, alert, automation, binary_sensor, @@ -45,6 +46,7 @@ from .capabilities import ( AlexaPowerController, AlexaPowerLevelController, AlexaSceneController, + AlexaSecurityPanelController, AlexaSpeaker, AlexaStepSpeaker, AlexaTemperatureSensor, @@ -487,3 +489,18 @@ class BinarySensorCapabilities(AlexaEntity): return self.TYPE_CONTACT if attrs.get(ATTR_DEVICE_CLASS) == "motion": return self.TYPE_MOTION + + +@ENTITY_ADAPTERS.register(alarm_control_panel.DOMAIN) +class AlarmControlPanelCapabilities(AlexaEntity): + """Class to represent Alarm capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SECURITY_PANEL] + + def interfaces(self): + """Yield the supported interfaces.""" + if not self.entity.attributes.get("code_arm_required"): + yield AlexaSecurityPanelController(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 8c2fa692267..8e32ed9c7ee 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -83,3 +83,17 @@ class AlexaBridgeUnreachableError(AlexaError): namespace = "Alexa" error_type = "BRIDGE_UNREACHABLE" + + +class AlexaSecurityPanelUnauthorizedError(AlexaError): + """Class to represent SecurityPanelController Unauthorized errors.""" + + namespace = "Alexa.SecurityPanelController" + error_type = "UNAUTHORIZED" + + +class AlexaSecurityPanelAuthorizationRequired(AlexaError): + """Class to represent SecurityPanelController AuthorizationRequired errors.""" + + namespace = "Alexa.SecurityPanelController" + error_type = "AUTHORIZATION_REQUIRED" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 3cb61675f92..bd07b71ca29 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -9,6 +9,11 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, + STATE_ALARM_DISARMED, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -35,6 +40,8 @@ from .const import API_TEMP_UNITS, API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, from .entities import async_get_entities from .errors import ( AlexaInvalidValueError, + AlexaSecurityPanelAuthorizationRequired, + AlexaSecurityPanelUnauthorizedError, AlexaTempRangeError, AlexaUnsupportedThermostatModeError, ) @@ -849,3 +856,71 @@ async def async_api_adjust_power_level(hass, config, directive, context): ) return directive.response() + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) +async def async_api_arm(hass, config, directive, context): + """Process a Security Panel Arm request.""" + entity = directive.entity + service = None + arm_state = directive.payload["armState"] + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.state != STATE_ALARM_DISARMED: + msg = "You must disarm the system before you can set the requested arm state." + raise AlexaSecurityPanelAuthorizationRequired(msg) + + if arm_state == "ARMED_AWAY": + service = SERVICE_ALARM_ARM_AWAY + if arm_state == "ARMED_STAY": + service = SERVICE_ALARM_ARM_HOME + if arm_state == "ARMED_NIGHT": + service = SERVICE_ALARM_ARM_NIGHT + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + response = directive.response( + name="Arm.Response", namespace="Alexa.SecurityPanelController" + ) + + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": arm_state, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Disarm")) +async def async_api_disarm(hass, config, directive, context): + """Process a Security Panel Disarm request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + payload = directive.payload + if "authorization" in payload: + value = payload["authorization"]["value"] + if payload["authorization"]["type"] == "FOUR_DIGIT_PIN": + data["code"] = value + + if not await hass.services.async_call( + entity.domain, SERVICE_ALARM_DISARM, data, blocking=True, context=context + ): + msg = "Invalid Code" + raise AlexaSecurityPanelUnauthorizedError(msg) + + response = directive.response() + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": "DISARMED", + } + ) + + return response diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json index 9db7e270e61..ad0f1c33d49 100644 --- a/homeassistant/components/alexa/manifest.json +++ b/homeassistant/components/alexa/manifest.json @@ -4,5 +4,8 @@ "documentation": "https://www.home-assistant.io/integrations/alexa", "requirements": [], "dependencies": ["http"], - "codeowners": ["@home-assistant/cloud"] + "codeowners": [ + "@home-assistant/cloud", + "@ochlocracy" + ] } diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index d53f145e6ff..280a76dc3f0 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,11 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNKNOWN, STATE_UNAVAILABLE, + STATE_ALARM_DISARMED, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, ) from homeassistant.components.climate import const as climate from homeassistant.components.alexa import smart_home @@ -527,3 +532,33 @@ async def test_temperature_sensor_climate(hass): properties.assert_equal( "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} ) + + +async def test_report_alarm_control_panel_state(hass): + """Test SecurityPanelController implements armState property.""" + hass.states.async_set("alarm_control_panel.armed_away", STATE_ALARM_ARMED_AWAY, {}) + hass.states.async_set( + "alarm_control_panel.armed_custom_bypass", STATE_ALARM_ARMED_CUSTOM_BYPASS, {} + ) + hass.states.async_set("alarm_control_panel.armed_home", STATE_ALARM_ARMED_HOME, {}) + hass.states.async_set( + "alarm_control_panel.armed_night", STATE_ALARM_ARMED_NIGHT, {} + ) + hass.states.async_set("alarm_control_panel.disarmed", STATE_ALARM_DISARMED, {}) + + properties = await reported_properties(hass, "alarm_control_panel.armed_away") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + properties = await reported_properties( + hass, "alarm_control_panel.armed_custom_bypass" + ) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + properties = await reported_properties(hass, "alarm_control_panel.armed_home") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + properties = await reported_properties(hass, "alarm_control_panel.armed_night") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_NIGHT") + + properties = await reported_properties(hass, "alarm_control_panel.disarmed") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "DISARMED") diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 78ce2963eaf..78bdd8e0908 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1306,3 +1306,127 @@ async def test_endpoint_bad_health(hass): properties.assert_equal( "Alexa.EndpointHealth", "connectivity", {"value": "UNREACHABLE"} ) + + +async def test_alarm_control_panel_disarmed(hass): + """Test alarm_control_panel discovery.""" + device = ( + "alarm_control_panel.test_1", + "disarmed", + { + "friendly_name": "Test Alarm Control Panel 1", + "code_arm_required": False, + "code_format": "number", + "code": "1234", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_1" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 1" + capabilities = assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth" + ) + security_panel_capability = get_capability( + capabilities, "Alexa.SecurityPanelController" + ) + assert security_panel_capability is not None + configuration = security_panel_capability["configuration"] + assert {"type": "FOUR_DIGIT_PIN"} in configuration["supportedAuthorizationTypes"] + + properties = await reported_properties(hass, "alarm_control_panel#test_1") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "DISARMED") + + call, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_1", + "alarm_control_panel.alarm_arm_home", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_STAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + call, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_1", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + call, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_1", + "alarm_control_panel.alarm_arm_night", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_NIGHT"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_NIGHT") + + +async def test_alarm_control_panel_armed(hass): + """Test alarm_control_panel discovery.""" + device = ( + "alarm_control_panel.test_2", + "armed_away", + { + "friendly_name": "Test Alarm Control Panel 2", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_2" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 2" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_2") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + call, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Disarm", + "alarm_control_panel#test_2", + "alarm_control_panel.alarm_disarm", + hass, + payload={"authorization": {"type": "FOUR_DIGIT_PIN", "value": "1234"}}, + ) + assert call.data["code"] == "1234" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "DISARMED") + + msg = await assert_request_fails( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_2", + "alarm_control_panel.alarm_arm_home", + hass, + payload={"armState": "ARMED_STAY"}, + ) + assert msg["event"]["payload"]["type"] == "AUTHORIZATION_REQUIRED" + + +async def test_alarm_control_panel_code_arm_required(hass): + """Test alarm_control_panel with code_arm_required discovery.""" + device = ( + "alarm_control_panel.test_3", + "disarmed", + {"friendly_name": "Test Alarm Control Panel 3", "code_arm_required": True}, + ) + await discovery_test(device, hass, expected_endpoints=0)