* Alexa temperature adjustment handle multiple setpoint In "auto" mode with many thermostats, the thermostats expose both an upper and lower setpoint representing a range of temperatures. When a temperature delta is sent from Alexa (e.g. "lower by 2 degrees), we need to handle the case where the temperature property is not set, but instead the upper and lower setpoint properties are set. In this case, we adjust those properties via service call instead of the singular value. * Updating tests to fix coverage
1792 lines
55 KiB
Python
1792 lines
55 KiB
Python
"""Alexa message handlers."""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Coroutine
|
|
import logging
|
|
import math
|
|
from typing import Any
|
|
|
|
from homeassistant import core as ha
|
|
from homeassistant.components import (
|
|
button,
|
|
camera,
|
|
climate,
|
|
cover,
|
|
fan,
|
|
group,
|
|
humidifier,
|
|
input_button,
|
|
input_number,
|
|
light,
|
|
media_player,
|
|
number,
|
|
timer,
|
|
vacuum,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_ENTITY_ID,
|
|
ATTR_ENTITY_PICTURE,
|
|
ATTR_SUPPORTED_FEATURES,
|
|
ATTR_TEMPERATURE,
|
|
SERVICE_ALARM_ARM_AWAY,
|
|
SERVICE_ALARM_ARM_HOME,
|
|
SERVICE_ALARM_ARM_NIGHT,
|
|
SERVICE_ALARM_DISARM,
|
|
SERVICE_LOCK,
|
|
SERVICE_MEDIA_NEXT_TRACK,
|
|
SERVICE_MEDIA_PAUSE,
|
|
SERVICE_MEDIA_PLAY,
|
|
SERVICE_MEDIA_PREVIOUS_TRACK,
|
|
SERVICE_MEDIA_STOP,
|
|
SERVICE_SET_COVER_POSITION,
|
|
SERVICE_SET_COVER_TILT_POSITION,
|
|
SERVICE_TURN_OFF,
|
|
SERVICE_TURN_ON,
|
|
SERVICE_UNLOCK,
|
|
SERVICE_VOLUME_DOWN,
|
|
SERVICE_VOLUME_MUTE,
|
|
SERVICE_VOLUME_SET,
|
|
SERVICE_VOLUME_UP,
|
|
STATE_ALARM_DISARMED,
|
|
UnitOfTemperature,
|
|
)
|
|
from homeassistant.helpers import network
|
|
from homeassistant.util import color as color_util, dt as dt_util
|
|
from homeassistant.util.decorator import Registry
|
|
from homeassistant.util.unit_conversion import TemperatureConverter
|
|
|
|
from .config import AbstractConfig
|
|
from .const import (
|
|
API_TEMP_UNITS,
|
|
API_THERMOSTAT_MODES,
|
|
API_THERMOSTAT_MODES_CUSTOM,
|
|
API_THERMOSTAT_PRESETS,
|
|
DATE_FORMAT,
|
|
PRESET_MODE_NA,
|
|
Cause,
|
|
Inputs,
|
|
)
|
|
from .entities import async_get_entities
|
|
from .errors import (
|
|
AlexaInvalidDirectiveError,
|
|
AlexaInvalidValueError,
|
|
AlexaSecurityPanelAuthorizationRequired,
|
|
AlexaTempRangeError,
|
|
AlexaUnsupportedThermostatModeError,
|
|
AlexaVideoActionNotPermittedForContentError,
|
|
)
|
|
from .messages import AlexaDirective, AlexaResponse
|
|
from .state_report import async_enable_proactive_mode
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive"
|
|
HANDLERS: Registry[
|
|
tuple[str, str],
|
|
Callable[
|
|
[ha.HomeAssistant, AbstractConfig, AlexaDirective, ha.Context],
|
|
Coroutine[Any, Any, AlexaResponse],
|
|
],
|
|
] = Registry()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.Discovery", "Discover"))
|
|
async def async_api_discovery(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Create a API formatted discovery response.
|
|
|
|
Async friendly.
|
|
"""
|
|
discovery_endpoints = [
|
|
alexa_entity.serialize_discovery()
|
|
for alexa_entity in async_get_entities(hass, config)
|
|
if config.should_expose(alexa_entity.entity_id)
|
|
]
|
|
|
|
return directive.response(
|
|
name="Discover.Response",
|
|
namespace="Alexa.Discovery",
|
|
payload={"endpoints": discovery_endpoints},
|
|
)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.Authorization", "AcceptGrant"))
|
|
async def async_api_accept_grant(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Create a API formatted AcceptGrant response.
|
|
|
|
Async friendly.
|
|
"""
|
|
auth_code = directive.payload["grant"]["code"]
|
|
_LOGGER.debug("AcceptGrant code: %s", auth_code)
|
|
|
|
if config.supports_auth:
|
|
await config.async_accept_grant(auth_code)
|
|
|
|
if config.should_report_state:
|
|
await async_enable_proactive_mode(hass, config)
|
|
|
|
return directive.response(
|
|
name="AcceptGrant.Response", namespace="Alexa.Authorization", payload={}
|
|
)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PowerController", "TurnOn"))
|
|
async def async_api_turn_on(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a turn on request."""
|
|
entity = directive.entity
|
|
if (domain := entity.domain) == group.DOMAIN:
|
|
domain = ha.DOMAIN
|
|
|
|
service = SERVICE_TURN_ON
|
|
if domain == cover.DOMAIN:
|
|
service = cover.SERVICE_OPEN_COVER
|
|
elif domain == fan.DOMAIN:
|
|
service = fan.SERVICE_TURN_ON
|
|
elif domain == humidifier.DOMAIN:
|
|
service = humidifier.SERVICE_TURN_ON
|
|
elif domain == vacuum.DOMAIN:
|
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
if (
|
|
not supported & vacuum.VacuumEntityFeature.TURN_ON
|
|
and supported & vacuum.VacuumEntityFeature.START
|
|
):
|
|
service = vacuum.SERVICE_START
|
|
elif domain == timer.DOMAIN:
|
|
service = timer.SERVICE_START
|
|
elif domain == media_player.DOMAIN:
|
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
power_features = (
|
|
media_player.MediaPlayerEntityFeature.TURN_ON
|
|
| media_player.MediaPlayerEntityFeature.TURN_OFF
|
|
)
|
|
if not supported & power_features:
|
|
service = media_player.SERVICE_MEDIA_PLAY
|
|
|
|
await hass.services.async_call(
|
|
domain,
|
|
service,
|
|
{ATTR_ENTITY_ID: entity.entity_id},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PowerController", "TurnOff"))
|
|
async def async_api_turn_off(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a turn off request."""
|
|
entity = directive.entity
|
|
domain = entity.domain
|
|
if entity.domain == group.DOMAIN:
|
|
domain = ha.DOMAIN
|
|
|
|
service = SERVICE_TURN_OFF
|
|
if entity.domain == cover.DOMAIN:
|
|
service = cover.SERVICE_CLOSE_COVER
|
|
elif domain == fan.DOMAIN:
|
|
service = fan.SERVICE_TURN_OFF
|
|
elif domain == humidifier.DOMAIN:
|
|
service = humidifier.SERVICE_TURN_OFF
|
|
elif domain == vacuum.DOMAIN:
|
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
if (
|
|
not supported & vacuum.VacuumEntityFeature.TURN_OFF
|
|
and supported & vacuum.VacuumEntityFeature.RETURN_HOME
|
|
):
|
|
service = vacuum.SERVICE_RETURN_TO_BASE
|
|
elif domain == timer.DOMAIN:
|
|
service = timer.SERVICE_CANCEL
|
|
elif domain == media_player.DOMAIN:
|
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
power_features = (
|
|
media_player.MediaPlayerEntityFeature.TURN_ON
|
|
| media_player.MediaPlayerEntityFeature.TURN_OFF
|
|
)
|
|
if not supported & power_features:
|
|
service = media_player.SERVICE_MEDIA_STOP
|
|
|
|
await hass.services.async_call(
|
|
domain,
|
|
service,
|
|
{ATTR_ENTITY_ID: entity.entity_id},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.BrightnessController", "SetBrightness"))
|
|
async def async_api_set_brightness(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set brightness request."""
|
|
entity = directive.entity
|
|
brightness = int(directive.payload["brightness"])
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity.entity_id, light.ATTR_BRIGHTNESS_PCT: brightness},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.BrightnessController", "AdjustBrightness"))
|
|
async def async_api_adjust_brightness(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an adjust brightness request."""
|
|
entity = directive.entity
|
|
brightness_delta = int(directive.payload["brightnessDelta"])
|
|
|
|
# set brightness
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_TURN_ON,
|
|
{
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
light.ATTR_BRIGHTNESS_STEP_PCT: brightness_delta,
|
|
},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ColorController", "SetColor"))
|
|
async def async_api_set_color(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set color request."""
|
|
entity = directive.entity
|
|
rgb = color_util.color_hsb_to_RGB(
|
|
float(directive.payload["color"]["hue"]),
|
|
float(directive.payload["color"]["saturation"]),
|
|
float(directive.payload["color"]["brightness"]),
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity.entity_id, light.ATTR_RGB_COLOR: rgb},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ColorTemperatureController", "SetColorTemperature"))
|
|
async def async_api_set_color_temperature(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set color temperature request."""
|
|
entity = directive.entity
|
|
kelvin = int(directive.payload["colorTemperatureInKelvin"])
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity.entity_id, light.ATTR_KELVIN: kelvin},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ColorTemperatureController", "DecreaseColorTemperature"))
|
|
async def async_api_decrease_color_temp(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a decrease color temperature request."""
|
|
entity = directive.entity
|
|
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
|
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
|
|
|
|
value = min(max_mireds, current + 50)
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ColorTemperatureController", "IncreaseColorTemperature"))
|
|
async def async_api_increase_color_temp(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an increase color temperature request."""
|
|
entity = directive.entity
|
|
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
|
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
|
|
|
|
value = max(min_mireds, current - 50)
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_TURN_ON,
|
|
{ATTR_ENTITY_ID: entity.entity_id, light.ATTR_COLOR_TEMP: value},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.SceneController", "Activate"))
|
|
async def async_api_activate(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an activate request."""
|
|
entity = directive.entity
|
|
domain = entity.domain
|
|
|
|
service = SERVICE_TURN_ON
|
|
if domain == button.DOMAIN:
|
|
service = button.SERVICE_PRESS
|
|
elif domain == input_button.DOMAIN:
|
|
service = input_button.SERVICE_PRESS
|
|
|
|
await hass.services.async_call(
|
|
domain,
|
|
service,
|
|
{ATTR_ENTITY_ID: entity.entity_id},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
payload = {
|
|
"cause": {"type": Cause.VOICE_INTERACTION},
|
|
"timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
|
|
}
|
|
|
|
return directive.response(
|
|
name="ActivationStarted", namespace="Alexa.SceneController", payload=payload
|
|
)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.SceneController", "Deactivate"))
|
|
async def async_api_deactivate(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a deactivate request."""
|
|
entity = directive.entity
|
|
domain = entity.domain
|
|
|
|
await hass.services.async_call(
|
|
domain,
|
|
SERVICE_TURN_OFF,
|
|
{ATTR_ENTITY_ID: entity.entity_id},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
payload = {
|
|
"cause": {"type": Cause.VOICE_INTERACTION},
|
|
"timestamp": dt_util.utcnow().strftime(DATE_FORMAT),
|
|
}
|
|
|
|
return directive.response(
|
|
name="DeactivationStarted", namespace="Alexa.SceneController", payload=payload
|
|
)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.LockController", "Lock"))
|
|
async def async_api_lock(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a lock request."""
|
|
entity = directive.entity
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_LOCK,
|
|
{ATTR_ENTITY_ID: entity.entity_id},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{"name": "lockState", "namespace": "Alexa.LockController", "value": "LOCKED"}
|
|
)
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.LockController", "Unlock"))
|
|
async def async_api_unlock(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an unlock request."""
|
|
if config.locale not in {"de-DE", "en-US", "ja-JP"}:
|
|
msg = (
|
|
"The unlock directive is not supported for the following locales:"
|
|
f" {config.locale}"
|
|
)
|
|
raise AlexaInvalidDirectiveError(msg)
|
|
|
|
entity = directive.entity
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_UNLOCK,
|
|
{ATTR_ENTITY_ID: entity.entity_id},
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{"namespace": "Alexa.LockController", "name": "lockState", "value": "UNLOCKED"}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.Speaker", "SetVolume"))
|
|
async def async_api_set_volume(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set volume request."""
|
|
volume = round(float(directive.payload["volume"] / 100), 2)
|
|
entity = directive.entity
|
|
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.InputController", "SelectInput"))
|
|
async def async_api_select_input(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set input request."""
|
|
media_input = directive.payload["input"]
|
|
entity = directive.entity
|
|
|
|
# Attempt to map the ALL UPPERCASE payload name to a source.
|
|
# Strips trailing 1 to match single input devices.
|
|
source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST, [])
|
|
for source in source_list:
|
|
formatted_source = (
|
|
source.lower().replace("-", "").replace("_", "").replace(" ", "")
|
|
)
|
|
media_input = media_input.lower().replace(" ", "")
|
|
if (
|
|
formatted_source in Inputs.VALID_SOURCE_NAME_MAP
|
|
and formatted_source == media_input
|
|
) or (
|
|
media_input.endswith("1") and formatted_source == media_input.rstrip("1")
|
|
):
|
|
media_input = source
|
|
break
|
|
else:
|
|
msg = (
|
|
f"failed to map input {media_input} to a media source on {entity.entity_id}"
|
|
)
|
|
raise AlexaInvalidValueError(msg)
|
|
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
media_player.const.ATTR_INPUT_SOURCE: media_input,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
media_player.SERVICE_SELECT_SOURCE,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.Speaker", "AdjustVolume"))
|
|
async def async_api_adjust_volume(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an adjust volume request."""
|
|
volume_delta = int(directive.payload["volume"])
|
|
|
|
entity = directive.entity
|
|
current_level = entity.attributes.get(media_player.const.ATTR_MEDIA_VOLUME_LEVEL)
|
|
|
|
# read current state
|
|
try:
|
|
current = math.floor(int(current_level * 100))
|
|
except ZeroDivisionError:
|
|
current = 0
|
|
|
|
volume = float(max(0, volume_delta + current) / 100)
|
|
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_VOLUME_SET, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.StepSpeaker", "AdjustVolume"))
|
|
async def async_api_adjust_volume_step(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an adjust volume step request."""
|
|
# media_player volume up/down service does not support specifying steps
|
|
# each component handles it differently e.g. via config.
|
|
# This workaround will simply call the volume up/Volume down the amount of
|
|
# steps asked for. When no steps are called in the request, Alexa sends
|
|
# a default of 10 steps which for most purposes is too high. The default
|
|
# is set 1 in this case.
|
|
entity = directive.entity
|
|
volume_int = int(directive.payload["volumeSteps"])
|
|
is_default = bool(directive.payload["volumeStepsDefault"])
|
|
default_steps = 1
|
|
|
|
if volume_int < 0:
|
|
service_volume = SERVICE_VOLUME_DOWN
|
|
if is_default:
|
|
volume_int = -default_steps
|
|
else:
|
|
service_volume = SERVICE_VOLUME_UP
|
|
if is_default:
|
|
volume_int = default_steps
|
|
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
for _ in range(abs(volume_int)):
|
|
await hass.services.async_call(
|
|
entity.domain, service_volume, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.StepSpeaker", "SetMute"))
|
|
@HANDLERS.register(("Alexa.Speaker", "SetMute"))
|
|
async def async_api_set_mute(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set mute request."""
|
|
mute = bool(directive.payload["mute"])
|
|
entity = directive.entity
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_VOLUME_MUTE, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PlaybackController", "Play"))
|
|
async def async_api_play(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a play request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PlaybackController", "Pause"))
|
|
async def async_api_pause(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a pause request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PlaybackController", "Stop"))
|
|
async def async_api_stop(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a stop request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PlaybackController", "Next"))
|
|
async def async_api_next(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a next request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.PlaybackController", "Previous"))
|
|
async def async_api_previous(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a previous request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
SERVICE_MEDIA_PREVIOUS_TRACK,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
def temperature_from_object(hass, temp_obj, interval=False):
|
|
"""Get temperature from Temperature object in requested unit."""
|
|
to_unit = hass.config.units.temperature_unit
|
|
from_unit = UnitOfTemperature.CELSIUS
|
|
temp = float(temp_obj["value"])
|
|
|
|
if temp_obj["scale"] == "FAHRENHEIT":
|
|
from_unit = UnitOfTemperature.FAHRENHEIT
|
|
elif temp_obj["scale"] == "KELVIN" and not interval:
|
|
# convert to Celsius if absolute temperature
|
|
temp -= 273.15
|
|
|
|
if interval:
|
|
return TemperatureConverter.convert_interval(temp, from_unit, to_unit)
|
|
return TemperatureConverter.convert(temp, from_unit, to_unit)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ThermostatController", "SetTargetTemperature"))
|
|
async def async_api_set_target_temp(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set target temperature request."""
|
|
entity = directive.entity
|
|
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
|
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
|
unit = hass.config.units.temperature_unit
|
|
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
payload = directive.payload
|
|
response = directive.response()
|
|
if "targetSetpoint" in payload:
|
|
temp = temperature_from_object(hass, payload["targetSetpoint"])
|
|
if temp < min_temp or temp > max_temp:
|
|
raise AlexaTempRangeError(hass, temp, min_temp, max_temp)
|
|
data[ATTR_TEMPERATURE] = temp
|
|
response.add_context_property(
|
|
{
|
|
"name": "targetSetpoint",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": {"value": temp, "scale": API_TEMP_UNITS[unit]},
|
|
}
|
|
)
|
|
if "lowerSetpoint" in payload:
|
|
temp_low = temperature_from_object(hass, payload["lowerSetpoint"])
|
|
if temp_low < min_temp or temp_low > max_temp:
|
|
raise AlexaTempRangeError(hass, temp_low, min_temp, max_temp)
|
|
data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
|
|
response.add_context_property(
|
|
{
|
|
"name": "lowerSetpoint",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": {"value": temp_low, "scale": API_TEMP_UNITS[unit]},
|
|
}
|
|
)
|
|
if "upperSetpoint" in payload:
|
|
temp_high = temperature_from_object(hass, payload["upperSetpoint"])
|
|
if temp_high < min_temp or temp_high > max_temp:
|
|
raise AlexaTempRangeError(hass, temp_high, min_temp, max_temp)
|
|
data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
|
|
response.add_context_property(
|
|
{
|
|
"name": "upperSetpoint",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": {"value": temp_high, "scale": API_TEMP_UNITS[unit]},
|
|
}
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
climate.SERVICE_SET_TEMPERATURE,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ThermostatController", "AdjustTargetTemperature"))
|
|
async def async_api_adjust_target_temp(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process an adjust target temperature request."""
|
|
entity = directive.entity
|
|
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
|
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
|
unit = hass.config.units.temperature_unit
|
|
|
|
temp_delta = temperature_from_object(
|
|
hass, directive.payload["targetSetpointDelta"], interval=True
|
|
)
|
|
|
|
response = directive.response()
|
|
|
|
current_target_temp_high = entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
|
|
current_target_temp_low = entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
|
|
if current_target_temp_high and current_target_temp_low:
|
|
target_temp_high = float(current_target_temp_high) + temp_delta
|
|
if target_temp_high < min_temp or target_temp_high > max_temp:
|
|
raise AlexaTempRangeError(hass, target_temp_high, min_temp, max_temp)
|
|
|
|
target_temp_low = float(current_target_temp_low) + temp_delta
|
|
if target_temp_low < min_temp or target_temp_low > max_temp:
|
|
raise AlexaTempRangeError(hass, target_temp_low, min_temp, max_temp)
|
|
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
climate.ATTR_TARGET_TEMP_HIGH: target_temp_high,
|
|
climate.ATTR_TARGET_TEMP_LOW: target_temp_low,
|
|
}
|
|
|
|
response.add_context_property(
|
|
{
|
|
"name": "upperSetpoint",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": {"value": target_temp_high, "scale": API_TEMP_UNITS[unit]},
|
|
}
|
|
)
|
|
response.add_context_property(
|
|
{
|
|
"name": "lowerSetpoint",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": {"value": target_temp_low, "scale": API_TEMP_UNITS[unit]},
|
|
}
|
|
)
|
|
else:
|
|
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
|
|
|
|
if target_temp < min_temp or target_temp > max_temp:
|
|
raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp)
|
|
|
|
data = {ATTR_ENTITY_ID: entity.entity_id, ATTR_TEMPERATURE: target_temp}
|
|
response.add_context_property(
|
|
{
|
|
"name": "targetSetpoint",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": {"value": target_temp, "scale": API_TEMP_UNITS[unit]},
|
|
}
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
climate.SERVICE_SET_TEMPERATURE,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ThermostatController", "SetThermostatMode"))
|
|
async def async_api_set_thermostat_mode(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a set thermostat mode request."""
|
|
entity = directive.entity
|
|
mode = directive.payload["thermostatMode"]
|
|
mode = mode if isinstance(mode, str) else mode["value"]
|
|
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None)
|
|
|
|
if ha_preset:
|
|
presets = entity.attributes.get(climate.ATTR_PRESET_MODES, [])
|
|
|
|
if ha_preset not in presets:
|
|
msg = f"The requested thermostat mode {ha_preset} is not supported"
|
|
raise AlexaUnsupportedThermostatModeError(msg)
|
|
|
|
service = climate.SERVICE_SET_PRESET_MODE
|
|
data[climate.ATTR_PRESET_MODE] = ha_preset
|
|
|
|
elif mode == "CUSTOM":
|
|
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES)
|
|
custom_mode = directive.payload["thermostatMode"]["customName"]
|
|
custom_mode = next(
|
|
(k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode),
|
|
None,
|
|
)
|
|
if custom_mode not in operation_list:
|
|
msg = (
|
|
f"The requested thermostat mode {mode}: {custom_mode} is not supported"
|
|
)
|
|
raise AlexaUnsupportedThermostatModeError(msg)
|
|
|
|
service = climate.SERVICE_SET_HVAC_MODE
|
|
data[climate.ATTR_HVAC_MODE] = custom_mode
|
|
|
|
else:
|
|
operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES)
|
|
ha_modes = {k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode}
|
|
ha_mode = next(iter(set(ha_modes).intersection(operation_list)), None)
|
|
if ha_mode not in operation_list:
|
|
msg = f"The requested thermostat mode {mode} is not supported"
|
|
raise AlexaUnsupportedThermostatModeError(msg)
|
|
|
|
service = climate.SERVICE_SET_HVAC_MODE
|
|
data[climate.ATTR_HVAC_MODE] = ha_mode
|
|
|
|
response = directive.response()
|
|
await hass.services.async_call(
|
|
climate.DOMAIN, service, data, blocking=False, context=context
|
|
)
|
|
response.add_context_property(
|
|
{
|
|
"name": "thermostatMode",
|
|
"namespace": "Alexa.ThermostatController",
|
|
"value": mode,
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa", "ReportState"))
|
|
async def async_api_reportstate(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a ReportState request."""
|
|
return directive.response(name="StateReport")
|
|
|
|
|
|
@HANDLERS.register(("Alexa.SecurityPanelController", "Arm"))
|
|
async def async_api_arm(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""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
|
|
elif arm_state == "ARMED_NIGHT":
|
|
service = SERVICE_ALARM_ARM_NIGHT
|
|
elif arm_state == "ARMED_STAY":
|
|
service = SERVICE_ALARM_ARM_HOME
|
|
else:
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
# return 0 until alarm integration supports an exit delay
|
|
payload = {"exitDelayInSeconds": 0}
|
|
|
|
response = directive.response(
|
|
name="Arm.Response", namespace="Alexa.SecurityPanelController", payload=payload
|
|
)
|
|
|
|
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: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a Security Panel Disarm request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
response = directive.response()
|
|
|
|
# Per Alexa Documentation: If you receive a Disarm directive, and the
|
|
# system is already disarmed, respond with a success response,
|
|
# not an error response.
|
|
if entity.state == STATE_ALARM_DISARMED:
|
|
return response
|
|
|
|
payload = directive.payload
|
|
if "authorization" in payload:
|
|
value = payload["authorization"]["value"]
|
|
if payload["authorization"]["type"] == "FOUR_DIGIT_PIN":
|
|
data["code"] = value
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, SERVICE_ALARM_DISARM, data, blocking=True, context=context
|
|
)
|
|
|
|
response.add_context_property(
|
|
{
|
|
"name": "armState",
|
|
"namespace": "Alexa.SecurityPanelController",
|
|
"value": "DISARMED",
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ModeController", "SetMode"))
|
|
async def async_api_set_mode(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a SetMode directive."""
|
|
entity = directive.entity
|
|
instance = directive.instance
|
|
domain = entity.domain
|
|
service = None
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
mode = directive.payload["mode"]
|
|
|
|
# Fan Direction
|
|
if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
|
|
direction = mode.split(".")[1]
|
|
if direction in (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD):
|
|
service = fan.SERVICE_SET_DIRECTION
|
|
data[fan.ATTR_DIRECTION] = direction
|
|
|
|
# Fan preset_mode
|
|
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
|
|
preset_mode = mode.split(".")[1]
|
|
if preset_mode != PRESET_MODE_NA and preset_mode in entity.attributes.get(
|
|
fan.ATTR_PRESET_MODES
|
|
):
|
|
service = fan.SERVICE_SET_PRESET_MODE
|
|
data[fan.ATTR_PRESET_MODE] = preset_mode
|
|
else:
|
|
msg = f"Entity '{entity.entity_id}' does not support Preset '{preset_mode}'"
|
|
raise AlexaInvalidValueError(msg)
|
|
|
|
# Humidifier mode
|
|
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
|
|
mode = mode.split(".")[1]
|
|
if mode != PRESET_MODE_NA and mode in entity.attributes.get(
|
|
humidifier.ATTR_AVAILABLE_MODES
|
|
):
|
|
service = humidifier.SERVICE_SET_MODE
|
|
data[humidifier.ATTR_MODE] = mode
|
|
else:
|
|
msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'"
|
|
raise AlexaInvalidValueError(msg)
|
|
|
|
# Cover Position
|
|
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
|
position = mode.split(".")[1]
|
|
|
|
if position == cover.STATE_CLOSED:
|
|
service = cover.SERVICE_CLOSE_COVER
|
|
elif position == cover.STATE_OPEN:
|
|
service = cover.SERVICE_OPEN_COVER
|
|
elif position == "custom":
|
|
service = cover.SERVICE_STOP_COVER
|
|
|
|
if not service:
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
await hass.services.async_call(
|
|
domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.ModeController",
|
|
"instance": instance,
|
|
"name": "mode",
|
|
"value": mode,
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ModeController", "AdjustMode"))
|
|
async def async_api_adjust_mode(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a AdjustMode request.
|
|
|
|
Requires capabilityResources supportedModes to be ordered.
|
|
Only supportedModes with ordered=True support the adjustMode directive.
|
|
"""
|
|
|
|
# Currently no supportedModes are configured with ordered=True
|
|
# to support this request.
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ToggleController", "TurnOn"))
|
|
async def async_api_toggle_on(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a toggle on request."""
|
|
entity = directive.entity
|
|
instance = directive.instance
|
|
domain = entity.domain
|
|
|
|
# Fan Oscillating
|
|
if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
service = fan.SERVICE_OSCILLATE
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
fan.ATTR_OSCILLATING: True,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.ToggleController",
|
|
"instance": instance,
|
|
"name": "toggleState",
|
|
"value": "ON",
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ToggleController", "TurnOff"))
|
|
async def async_api_toggle_off(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a toggle off request."""
|
|
entity = directive.entity
|
|
instance = directive.instance
|
|
domain = entity.domain
|
|
|
|
# Fan Oscillating
|
|
if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
service = fan.SERVICE_OSCILLATE
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
fan.ATTR_OSCILLATING: False,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.ToggleController",
|
|
"instance": instance,
|
|
"name": "toggleState",
|
|
"value": "OFF",
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.RangeController", "SetRangeValue"))
|
|
async def async_api_set_range(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a next request."""
|
|
entity = directive.entity
|
|
instance = directive.instance
|
|
domain = entity.domain
|
|
service = None
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
range_value = directive.payload["rangeValue"]
|
|
|
|
# Cover Position
|
|
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
|
range_value = int(range_value)
|
|
if range_value == 0:
|
|
service = cover.SERVICE_CLOSE_COVER
|
|
elif range_value == 100:
|
|
service = cover.SERVICE_OPEN_COVER
|
|
else:
|
|
service = cover.SERVICE_SET_COVER_POSITION
|
|
data[cover.ATTR_POSITION] = range_value
|
|
|
|
# Cover Tilt
|
|
elif instance == f"{cover.DOMAIN}.tilt":
|
|
range_value = int(range_value)
|
|
if range_value == 0:
|
|
service = cover.SERVICE_CLOSE_COVER_TILT
|
|
elif range_value == 100:
|
|
service = cover.SERVICE_OPEN_COVER_TILT
|
|
else:
|
|
service = cover.SERVICE_SET_COVER_TILT_POSITION
|
|
data[cover.ATTR_TILT_POSITION] = range_value
|
|
|
|
# Fan Speed
|
|
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
|
|
range_value = int(range_value)
|
|
if range_value == 0:
|
|
service = fan.SERVICE_TURN_OFF
|
|
else:
|
|
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
if supported and fan.FanEntityFeature.SET_SPEED:
|
|
service = fan.SERVICE_SET_PERCENTAGE
|
|
data[fan.ATTR_PERCENTAGE] = range_value
|
|
else:
|
|
service = fan.SERVICE_TURN_ON
|
|
|
|
# Humidifier target humidity
|
|
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
|
|
range_value = int(range_value)
|
|
service = humidifier.SERVICE_SET_HUMIDITY
|
|
data[humidifier.ATTR_HUMIDITY] = range_value
|
|
|
|
# Input Number Value
|
|
elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
|
|
range_value = float(range_value)
|
|
service = input_number.SERVICE_SET_VALUE
|
|
min_value = float(entity.attributes[input_number.ATTR_MIN])
|
|
max_value = float(entity.attributes[input_number.ATTR_MAX])
|
|
data[input_number.ATTR_VALUE] = min(max_value, max(min_value, range_value))
|
|
|
|
# Input Number Value
|
|
elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}":
|
|
range_value = float(range_value)
|
|
service = number.SERVICE_SET_VALUE
|
|
min_value = float(entity.attributes[number.ATTR_MIN])
|
|
max_value = float(entity.attributes[number.ATTR_MAX])
|
|
data[number.ATTR_VALUE] = min(max_value, max(min_value, range_value))
|
|
|
|
# Vacuum Fan Speed
|
|
elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
|
|
service = vacuum.SERVICE_SET_FAN_SPEED
|
|
speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
|
|
speed = next(
|
|
(v for i, v in enumerate(speed_list) if i == int(range_value)), None
|
|
)
|
|
|
|
if not speed:
|
|
msg = "Entity does not support value"
|
|
raise AlexaInvalidValueError(msg)
|
|
|
|
data[vacuum.ATTR_FAN_SPEED] = speed
|
|
|
|
else:
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
await hass.services.async_call(
|
|
domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.RangeController",
|
|
"instance": instance,
|
|
"name": "rangeValue",
|
|
"value": range_value,
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.RangeController", "AdjustRangeValue"))
|
|
async def async_api_adjust_range(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a next request."""
|
|
entity = directive.entity
|
|
instance = directive.instance
|
|
domain = entity.domain
|
|
service = None
|
|
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
|
|
range_delta = directive.payload["rangeValueDelta"]
|
|
range_delta_default = bool(directive.payload["rangeValueDeltaDefault"])
|
|
response_value: int | None = 0
|
|
|
|
# Cover Position
|
|
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
|
|
range_delta = int(range_delta * 20) if range_delta_default else int(range_delta)
|
|
service = SERVICE_SET_COVER_POSITION
|
|
if not (current := entity.attributes.get(cover.ATTR_POSITION)):
|
|
msg = f"Unable to determine {entity.entity_id} current position"
|
|
raise AlexaInvalidValueError(msg)
|
|
position = response_value = min(100, max(0, range_delta + current))
|
|
if position == 100:
|
|
service = cover.SERVICE_OPEN_COVER
|
|
elif position == 0:
|
|
service = cover.SERVICE_CLOSE_COVER
|
|
else:
|
|
data[cover.ATTR_POSITION] = position
|
|
|
|
# Cover Tilt
|
|
elif instance == f"{cover.DOMAIN}.tilt":
|
|
range_delta = int(range_delta * 20) if range_delta_default else int(range_delta)
|
|
service = SERVICE_SET_COVER_TILT_POSITION
|
|
current = entity.attributes.get(cover.ATTR_TILT_POSITION)
|
|
if not current:
|
|
msg = f"Unable to determine {entity.entity_id} current tilt position"
|
|
raise AlexaInvalidValueError(msg)
|
|
tilt_position = response_value = min(100, max(0, range_delta + current))
|
|
if tilt_position == 100:
|
|
service = cover.SERVICE_OPEN_COVER_TILT
|
|
elif tilt_position == 0:
|
|
service = cover.SERVICE_CLOSE_COVER_TILT
|
|
else:
|
|
data[cover.ATTR_TILT_POSITION] = tilt_position
|
|
|
|
# Fan speed percentage
|
|
elif instance == f"{fan.DOMAIN}.{fan.ATTR_PERCENTAGE}":
|
|
percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20
|
|
range_delta = (
|
|
int(range_delta * percentage_step)
|
|
if range_delta_default
|
|
else int(range_delta)
|
|
)
|
|
service = fan.SERVICE_SET_PERCENTAGE
|
|
if not (current := entity.attributes.get(fan.ATTR_PERCENTAGE)):
|
|
msg = f"Unable to determine {entity.entity_id} current fan speed"
|
|
raise AlexaInvalidValueError(msg)
|
|
percentage = response_value = min(100, max(0, range_delta + current))
|
|
if percentage:
|
|
data[fan.ATTR_PERCENTAGE] = percentage
|
|
else:
|
|
service = fan.SERVICE_TURN_OFF
|
|
|
|
# Humidifier target humidity
|
|
elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
|
|
percentage_step = 5
|
|
range_delta = (
|
|
int(range_delta * percentage_step)
|
|
if range_delta_default
|
|
else int(range_delta)
|
|
)
|
|
service = humidifier.SERVICE_SET_HUMIDITY
|
|
if not (current := entity.attributes.get(humidifier.ATTR_HUMIDITY)):
|
|
msg = f"Unable to determine {entity.entity_id} current target humidity"
|
|
raise AlexaInvalidValueError(msg)
|
|
min_value = entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10)
|
|
max_value = entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90)
|
|
percentage = response_value = min(
|
|
max_value, max(min_value, range_delta + current)
|
|
)
|
|
if percentage:
|
|
data[humidifier.ATTR_HUMIDITY] = percentage
|
|
|
|
# Input Number Value
|
|
elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
|
|
range_delta = float(range_delta)
|
|
service = input_number.SERVICE_SET_VALUE
|
|
min_value = float(entity.attributes[input_number.ATTR_MIN])
|
|
max_value = float(entity.attributes[input_number.ATTR_MAX])
|
|
current = float(entity.state)
|
|
data[input_number.ATTR_VALUE] = response_value = min(
|
|
max_value, max(min_value, range_delta + current)
|
|
)
|
|
|
|
# Number Value
|
|
elif instance == f"{number.DOMAIN}.{number.ATTR_VALUE}":
|
|
range_delta = float(range_delta)
|
|
service = number.SERVICE_SET_VALUE
|
|
min_value = float(entity.attributes[number.ATTR_MIN])
|
|
max_value = float(entity.attributes[number.ATTR_MAX])
|
|
current = float(entity.state)
|
|
data[number.ATTR_VALUE] = response_value = min(
|
|
max_value, max(min_value, range_delta + current)
|
|
)
|
|
|
|
# Vacuum Fan Speed
|
|
elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}":
|
|
range_delta = int(range_delta)
|
|
service = vacuum.SERVICE_SET_FAN_SPEED
|
|
speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST]
|
|
current_speed = entity.attributes[vacuum.ATTR_FAN_SPEED]
|
|
current_speed_index = next(
|
|
(i for i, v in enumerate(speed_list) if v == current_speed), 0
|
|
)
|
|
new_speed_index = min(
|
|
len(speed_list) - 1, max(0, current_speed_index + range_delta)
|
|
)
|
|
speed = next(
|
|
(v for i, v in enumerate(speed_list) if i == new_speed_index), None
|
|
)
|
|
data[vacuum.ATTR_FAN_SPEED] = response_value = speed
|
|
|
|
else:
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
await hass.services.async_call(
|
|
domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
response = directive.response()
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.RangeController",
|
|
"instance": instance,
|
|
"name": "rangeValue",
|
|
"value": response_value,
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ChannelController", "ChangeChannel"))
|
|
async def async_api_changechannel(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a change channel request."""
|
|
channel = "0"
|
|
entity = directive.entity
|
|
channel_payload = directive.payload["channel"]
|
|
metadata_payload = directive.payload["channelMetadata"]
|
|
payload_name = "number"
|
|
|
|
if "number" in channel_payload:
|
|
channel = channel_payload["number"]
|
|
payload_name = "number"
|
|
elif "callSign" in channel_payload:
|
|
channel = channel_payload["callSign"]
|
|
payload_name = "callSign"
|
|
elif "affiliateCallSign" in channel_payload:
|
|
channel = channel_payload["affiliateCallSign"]
|
|
payload_name = "affiliateCallSign"
|
|
elif "uri" in channel_payload:
|
|
channel = channel_payload["uri"]
|
|
payload_name = "uri"
|
|
elif "name" in metadata_payload:
|
|
channel = metadata_payload["name"]
|
|
payload_name = "callSign"
|
|
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
media_player.const.ATTR_MEDIA_CONTENT_ID: channel,
|
|
media_player.const.ATTR_MEDIA_CONTENT_TYPE: (
|
|
media_player.const.MEDIA_TYPE_CHANNEL
|
|
),
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
media_player.const.SERVICE_PLAY_MEDIA,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
response = directive.response()
|
|
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.ChannelController",
|
|
"name": "channel",
|
|
"value": {payload_name: channel},
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.ChannelController", "SkipChannels"))
|
|
async def async_api_skipchannel(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a skipchannel request."""
|
|
channel = int(directive.payload["channelCount"])
|
|
entity = directive.entity
|
|
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
if channel < 0:
|
|
service_media = SERVICE_MEDIA_PREVIOUS_TRACK
|
|
else:
|
|
service_media = SERVICE_MEDIA_NEXT_TRACK
|
|
|
|
for _ in range(abs(channel)):
|
|
await hass.services.async_call(
|
|
entity.domain, service_media, data, blocking=False, context=context
|
|
)
|
|
|
|
response = directive.response()
|
|
|
|
response.add_context_property(
|
|
{
|
|
"namespace": "Alexa.ChannelController",
|
|
"name": "channel",
|
|
"value": {"number": ""},
|
|
}
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@HANDLERS.register(("Alexa.SeekController", "AdjustSeekPosition"))
|
|
async def async_api_seek(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a seek request."""
|
|
entity = directive.entity
|
|
position_delta = int(directive.payload["deltaPositionMilliseconds"])
|
|
|
|
current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION)
|
|
if not current_position:
|
|
msg = f"{entity} did not return the current media position."
|
|
raise AlexaVideoActionNotPermittedForContentError(msg)
|
|
|
|
seek_position = max(int(current_position) + int(position_delta / 1000), 0)
|
|
|
|
media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION)
|
|
if media_duration and 0 < int(media_duration) < seek_position:
|
|
seek_position = media_duration
|
|
|
|
data = {
|
|
ATTR_ENTITY_ID: entity.entity_id,
|
|
media_player.ATTR_MEDIA_SEEK_POSITION: seek_position,
|
|
}
|
|
|
|
await hass.services.async_call(
|
|
media_player.DOMAIN,
|
|
media_player.SERVICE_MEDIA_SEEK,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
# convert seconds to milliseconds for StateReport.
|
|
seek_position = int(seek_position * 1000)
|
|
|
|
payload = {"properties": [{"name": "positionMilliseconds", "value": seek_position}]}
|
|
return directive.response(
|
|
name="StateReport", namespace="Alexa.SeekController", payload=payload
|
|
)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.EqualizerController", "SetMode"))
|
|
async def async_api_set_eq_mode(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a SetMode request for EqualizerController."""
|
|
mode = directive.payload["mode"]
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST)
|
|
if sound_mode_list and mode.lower() in sound_mode_list:
|
|
data[media_player.const.ATTR_SOUND_MODE] = mode.lower()
|
|
else:
|
|
msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}"
|
|
raise AlexaInvalidValueError(msg)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain,
|
|
media_player.SERVICE_SELECT_SOUND_MODE,
|
|
data,
|
|
blocking=False,
|
|
context=context,
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.EqualizerController", "AdjustBands"))
|
|
@HANDLERS.register(("Alexa.EqualizerController", "ResetBands"))
|
|
@HANDLERS.register(("Alexa.EqualizerController", "SetBands"))
|
|
async def async_api_bands_directive(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Handle an AdjustBands, ResetBands, SetBands request.
|
|
|
|
Only mode directives are currently supported for the EqualizerController.
|
|
"""
|
|
# Currently bands directives are not supported.
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
|
|
@HANDLERS.register(("Alexa.TimeHoldController", "Hold"))
|
|
async def async_api_hold(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a TimeHoldController Hold request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
if entity.domain == timer.DOMAIN:
|
|
service = timer.SERVICE_PAUSE
|
|
|
|
elif entity.domain == vacuum.DOMAIN:
|
|
service = vacuum.SERVICE_START_PAUSE
|
|
|
|
else:
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.TimeHoldController", "Resume"))
|
|
async def async_api_resume(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a TimeHoldController Resume request."""
|
|
entity = directive.entity
|
|
data = {ATTR_ENTITY_ID: entity.entity_id}
|
|
|
|
if entity.domain == timer.DOMAIN:
|
|
service = timer.SERVICE_START
|
|
|
|
elif entity.domain == vacuum.DOMAIN:
|
|
service = vacuum.SERVICE_START_PAUSE
|
|
|
|
else:
|
|
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
|
|
|
|
await hass.services.async_call(
|
|
entity.domain, service, data, blocking=False, context=context
|
|
)
|
|
|
|
return directive.response()
|
|
|
|
|
|
@HANDLERS.register(("Alexa.CameraStreamController", "InitializeCameraStreams"))
|
|
async def async_api_initialize_camera_stream(
|
|
hass: ha.HomeAssistant,
|
|
config: AbstractConfig,
|
|
directive: AlexaDirective,
|
|
context: ha.Context,
|
|
) -> AlexaResponse:
|
|
"""Process a InitializeCameraStreams request."""
|
|
entity = directive.entity
|
|
stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls")
|
|
state = hass.states.get(entity.entity_id)
|
|
assert state
|
|
camera_image = state.attributes[ATTR_ENTITY_PICTURE]
|
|
|
|
try:
|
|
external_url = network.get_url(
|
|
hass,
|
|
allow_internal=False,
|
|
allow_ip=False,
|
|
require_ssl=True,
|
|
require_standard_port=True,
|
|
)
|
|
except network.NoURLAvailableError as err:
|
|
raise AlexaInvalidValueError(
|
|
"Failed to find suitable URL to serve to Alexa"
|
|
) from err
|
|
|
|
payload = {
|
|
"cameraStreams": [
|
|
{
|
|
"uri": f"{external_url}{stream_source}",
|
|
"protocol": "HLS",
|
|
"resolution": {"width": 1280, "height": 720},
|
|
"authorizationType": "NONE",
|
|
"videoCodec": "H264",
|
|
"audioCodec": "AAC",
|
|
}
|
|
],
|
|
"imageUri": f"{external_url}{camera_image}",
|
|
}
|
|
return directive.response(
|
|
name="Response", namespace="Alexa.CameraStreamController", payload=payload
|
|
)
|