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.
This commit is contained in:
ochlocracy 2019-10-04 11:41:47 -04:00 committed by Paulus Schoutsen
parent f169e84d21
commit 9a5c1fbaed
8 changed files with 337 additions and 2 deletions

View File

@ -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

View File

@ -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 []

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -4,5 +4,8 @@
"documentation": "https://www.home-assistant.io/integrations/alexa",
"requirements": [],
"dependencies": ["http"],
"codeowners": ["@home-assistant/cloud"]
"codeowners": [
"@home-assistant/cloud",
"@ochlocracy"
]
}

View File

@ -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")

View File

@ -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)