Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions homeassistant/components/google_assistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
sensor,
switch,
vacuum,
alarm_control_panel,
)

DOMAIN = "google_assistant"
Expand Down Expand Up @@ -48,6 +49,7 @@
"lock",
"binary_sensor",
"sensor",
"alarm_control_panel",
]

PREFIX_TYPES = "action.devices.types."
Expand All @@ -66,6 +68,7 @@
TYPE_DOOR = PREFIX_TYPES + "DOOR"
TYPE_TV = PREFIX_TYPES + "TV"
TYPE_SPEAKER = PREFIX_TYPES + "SPEAKER"
TYPE_ALARM = PREFIX_TYPES + "SECURITYSYSTEM"

SERVICE_REQUEST_SYNC = "request_sync"
HOMEGRAPH_URL = "https://homegraph.googleapis.com/"
Expand All @@ -81,6 +84,9 @@
ERR_UNKNOWN_ERROR = "unknownError"
ERR_FUNCTION_NOT_SUPPORTED = "functionNotSupported"

ERR_ALREADY_DISARMED = "alreadyDisarmed"
ERR_ALREADY_ARMED = "alreadyArmed"

ERR_CHALLENGE_NEEDED = "challengeNeeded"
ERR_CHALLENGE_NOT_SETUP = "challengeFailedNotSetup"
ERR_TOO_MANY_FAILED_ATTEMPTS = "tooManyFailedAttempts"
Expand All @@ -106,6 +112,7 @@
script.DOMAIN: TYPE_SCENE,
switch.DOMAIN: TYPE_SWITCH,
vacuum.DOMAIN: TYPE_VACUUM,
alarm_control_panel.DOMAIN: TYPE_ALARM,
}

DEVICE_CLASS_TO_GOOGLE_TYPES = {
Expand Down
112 changes: 111 additions & 1 deletion homeassistant/components/google_assistant/trait.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
sensor,
switch,
vacuum,
alarm_control_panel,
)
from homeassistant.components.climate import const as climate
from homeassistant.const import (
Expand All @@ -31,6 +32,20 @@
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
ATTR_ASSUMED_STATE,
SERVICE_ALARM_DISARM,
SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_ARM_AWAY,
SERVICE_ALARM_ARM_NIGHT,
SERVICE_ALARM_ARM_CUSTOM_BYPASS,
SERVICE_ALARM_TRIGGER,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
STATE_ALARM_PENDING,
ATTR_CODE,
STATE_UNKNOWN,
)
from homeassistant.core import DOMAIN as HA_DOMAIN
Expand All @@ -43,6 +58,8 @@
CHALLENGE_ACK_NEEDED,
CHALLENGE_PIN_NEEDED,
CHALLENGE_FAILED_PIN_NEEDED,
ERR_ALREADY_DISARMED,
ERR_ALREADY_ARMED,
)
from .error import SmartHomeError, ChallengeNeeded

Expand All @@ -62,6 +79,7 @@
TRAIT_MODES = PREFIX_TRAITS + "Modes"
TRAIT_OPENCLOSE = PREFIX_TRAITS + "OpenClose"
TRAIT_VOLUME = PREFIX_TRAITS + "Volume"
TRAIT_ARMDISARM = PREFIX_TRAITS + "ArmDisarm"

PREFIX_COMMANDS = "action.devices.commands."
COMMAND_ONOFF = PREFIX_COMMANDS + "OnOff"
Expand All @@ -85,6 +103,7 @@
COMMAND_OPENCLOSE = PREFIX_COMMANDS + "OpenClose"
COMMAND_SET_VOLUME = PREFIX_COMMANDS + "setVolume"
COMMAND_VOLUME_RELATIVE = PREFIX_COMMANDS + "volumeRelative"
COMMAND_ARMDISARM = PREFIX_COMMANDS + "ArmDisarm"

TRAITS = []

Expand Down Expand Up @@ -873,6 +892,98 @@ async def execute(self, command, data, params, challenge):
)


@register_trait
class ArmDisArmTrait(_Trait):
"""Trait to Arm or Disarm a Security System.

https://developers.google.com/actions/smarthome/traits/armdisarm
"""

name = TRAIT_ARMDISARM
commands = [COMMAND_ARMDISARM]

state_to_service = {
STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME,
STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY,
STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT,
STATE_ALARM_ARMED_CUSTOM_BYPASS: SERVICE_ALARM_ARM_CUSTOM_BYPASS,
STATE_ALARM_TRIGGERED: SERVICE_ALARM_TRIGGER,
}

@staticmethod
def supported(domain, features, device_class):
"""Test if state is supported."""
return domain == alarm_control_panel.DOMAIN

@staticmethod
def might_2fa(domain, features, device_class):
"""Return if the trait might ask for 2FA."""
return True

def sync_attributes(self):
"""Return ArmDisarm attributes for a sync request."""
response = {}
levels = []
for state in self.state_to_service:
# level synonyms are generated from state names
# 'armed_away' becomes 'armed away' or 'away'
level_synonym = [state.replace("_", " ")]
if state != STATE_ALARM_TRIGGERED:
level_synonym.append(state.split("_")[1])

level = {
"level_name": state,
"level_values": [{"level_synonym": level_synonym, "lang": "en"}],
}
levels.append(level)
response["availableArmLevels"] = {"levels": levels, "ordered": False}
return response

def query_attributes(self):
"""Return ArmDisarm query attributes."""
if "post_pending_state" in self.state.attributes:
armed_state = self.state.attributes["post_pending_state"]
else:
armed_state = self.state.state
response = {"isArmed": armed_state in self.state_to_service}
if response["isArmed"]:
response.update({"currentArmLevel": armed_state})
return response

async def execute(self, command, data, params, challenge):
"""Execute an ArmDisarm command."""
if params["arm"] and not params.get("cancel"):
if self.state.state == params["armLevel"]:
raise SmartHomeError(ERR_ALREADY_ARMED, "System is already armed")
if self.state.attributes["code_arm_required"]:
_verify_pin_challenge(data, self.state, challenge)
service = self.state_to_service[params["armLevel"]]
# disarm the system without asking for code when
# 'cancel' arming action is received while current status is pending
elif (
params["arm"]
and params.get("cancel")
and self.state.state == STATE_ALARM_PENDING
):
service = SERVICE_ALARM_DISARM
else:
if self.state.state == STATE_ALARM_DISARMED:
raise SmartHomeError(ERR_ALREADY_DISARMED, "System is already disarmed")
_verify_pin_challenge(data, self.state, challenge)
service = SERVICE_ALARM_DISARM

await self.hass.services.async_call(
alarm_control_panel.DOMAIN,
service,
{
ATTR_ENTITY_ID: self.state.entity_id,
ATTR_CODE: data.config.secure_devices_pin,
},
blocking=True,
context=data.context,
)


@register_trait
class FanSpeedTrait(_Trait):
"""Trait to control speed of Fan.
Expand Down Expand Up @@ -1343,7 +1454,6 @@ def _verify_pin_challenge(data, state, challenge):
"""Verify a pin challenge."""
if not data.config.should_2fa(state):
return

if not data.config.secure_devices_pin:
raise SmartHomeError(ERR_CHALLENGE_NOT_SETUP, "Challenge is not set up")

Expand Down
7 changes: 7 additions & 0 deletions tests/components/google_assistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,11 @@ def should_expose(self, state):
"type": "action.devices.types.LOCK",
"willReportState": False,
},
{
"id": "alarm_control_panel.alarm",
"name": {"name": "Alarm"},
"traits": ["action.devices.traits.ArmDisarm"],
"type": "action.devices.types.SECURITYSYSTEM",
"willReportState": False,
},
]
18 changes: 17 additions & 1 deletion tests/components/google_assistant/test_google_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
import pytest

from homeassistant import core, const, setup
from homeassistant.components import fan, cover, light, switch, lock, media_player
from homeassistant.components import (
fan,
cover,
light,
switch,
lock,
media_player,
alarm_control_panel,
)
from homeassistant.components.climate import const as climate
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.components import google_assistant as ga
Expand Down Expand Up @@ -98,6 +106,14 @@ def hass_fixture(loop, hass):
setup.async_setup_component(hass, lock.DOMAIN, {"lock": [{"platform": "demo"}]})
)

loop.run_until_complete(
setup.async_setup_component(
hass,
alarm_control_panel.DOMAIN,
{"alarm_control_panel": [{"platform": "demo"}]},
)
)

return hass


Expand Down
Loading