Add humidifier support for Alexa (#81329)

This commit is contained in:
Jan Bouwhuis 2022-11-28 20:55:22 +01:00 committed by GitHub
parent 35e81cf982
commit 5d4c4a1293
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 417 additions and 15 deletions

View File

@ -8,6 +8,7 @@ from homeassistant.components import (
climate,
cover,
fan,
humidifier,
image_processing,
input_button,
input_number,
@ -398,6 +399,8 @@ class AlexaPowerController(AlexaCapability):
is_on = self.entity.state != climate.HVACMode.OFF
elif self.entity.domain == fan.DOMAIN:
is_on = self.entity.state == fan.STATE_ON
elif self.entity.domain == humidifier.DOMAIN:
is_on = self.entity.state == humidifier.STATE_ON
elif self.entity.domain == vacuum.DOMAIN:
is_on = self.entity.state == vacuum.STATE_CLEANING
elif self.entity.domain == timer.DOMAIN:
@ -1403,6 +1406,12 @@ class AlexaModeController(AlexaCapability):
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None):
return f"{fan.ATTR_PRESET_MODE}.{mode}"
# Humidifier mode
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
mode = self.entity.attributes.get(humidifier.ATTR_MODE, None)
if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []):
return f"{humidifier.ATTR_MODE}.{mode}"
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
# Return state instead of position when using ModeController.
@ -1459,6 +1468,20 @@ class AlexaModeController(AlexaCapability):
)
return self._resource.serialize_capability_resources()
# Humidifier modes
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}":
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, [])
for mode in modes:
self._resource.add_mode(f"{humidifier.ATTR_MODE}.{mode}", [mode])
# Humidifiers or Fans with a single mode completely break Alexa discovery, add a
# fake preset (see issue #53832).
if len(modes) == 1:
self._resource.add_mode(
f"{humidifier.ATTR_MODE}.{PRESET_MODE_NA}", [PRESET_MODE_NA]
)
return self._resource.serialize_capability_resources()
# Cover Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaModeResource(
@ -1600,6 +1623,12 @@ class AlexaRangeController(AlexaCapability):
return self.entity.attributes.get(fan.ATTR_PERCENTAGE)
return 100 if self.entity.state == fan.STATE_ON else 0
# Humidifier target humidity
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
# If the humidifier is turned off the target humidity attribute is not set.
# We return 0 to make clear we do not know the current value.
return self.entity.attributes.get(humidifier.ATTR_HUMIDITY, 0)
# Input Number Value
if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}":
return float(self.entity.state)
@ -1640,6 +1669,17 @@ class AlexaRangeController(AlexaCapability):
)
return self._resource.serialize_capability_resources()
# Humidifier Target Humidity Resources
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
self._resource = AlexaPresetResource(
labels=["Humidity", "Percentage", "Target humidity"],
min_value=self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10),
max_value=self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90),
precision=1,
unit=AlexaGlobalCatalog.UNIT_PERCENT,
)
return self._resource.serialize_capability_resources()
# Cover Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaPresetResource(
@ -1764,6 +1804,22 @@ class AlexaRangeController(AlexaCapability):
)
return self._semantics.serialize_semantics()
# Target Humidity Percentage
if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics()
min_value = self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10)
max_value = self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90)
self._semantics.add_action_to_directive(
lower_labels, "SetRangeValue", {"rangeValue": min_value}
)
self._semantics.add_action_to_directive(
raise_labels, "SetRangeValue", {"rangeValue": max_value}
)
return self._semantics.serialize_semantics()
return None

View File

@ -83,7 +83,8 @@ API_THERMOSTAT_MODES_CUSTOM = {
}
API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"}
# AlexaModeController does not like a single mode for the fan preset, we add PRESET_MODE_NA if a fan has only one preset_mode
# AlexaModeController does not like a single mode for the fan preset or humidifier mode,
# we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode
PRESET_MODE_NA = "-"

View File

@ -15,6 +15,7 @@ from homeassistant.components import (
cover,
fan,
group,
humidifier,
image_processing,
input_boolean,
input_button,
@ -100,6 +101,9 @@ class DisplayCategory:
# to HDMI1. Applies to Scenes
ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER"
# Indicates a device that cools the air in interior spaces.
AIR_CONDITIONER = "AIR_CONDITIONER"
# Indicates a device that emits pleasant odors and masks unpleasant odors in interior spaces.
AIR_FRESHENER = "AIR_FRESHENER"
@ -583,6 +587,30 @@ class FanCapabilities(AlexaEntity):
yield Alexa(self.hass)
@ENTITY_ADAPTERS.register(humidifier.DOMAIN)
class HumidifierCapabilities(AlexaEntity):
"""Class to represent Humidifier capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & humidifier.HumidifierEntityFeature.MODES:
yield AlexaModeController(
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
)
yield AlexaRangeController(
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}"
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.hass)
@ENTITY_ADAPTERS.register(lock.DOMAIN)
class LockCapabilities(AlexaEntity):
"""Class to represent Lock capabilities."""

View File

@ -14,6 +14,7 @@ from homeassistant.components import (
cover,
fan,
group,
humidifier,
input_button,
input_number,
light,
@ -154,6 +155,8 @@ async def async_api_turn_on(
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 (
@ -201,6 +204,8 @@ async def async_api_turn_off(
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 (
@ -448,20 +453,31 @@ async def async_api_set_percentage(
"""Process a set percentage request."""
entity = directive.entity
if entity.domain != fan.DOMAIN:
if entity.domain == fan.DOMAIN:
percentage = int(directive.payload["percentage"])
service = fan.SERVICE_SET_PERCENTAGE
data = {
ATTR_ENTITY_ID: entity.entity_id,
fan.ATTR_PERCENTAGE: percentage,
}
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context
)
elif entity.domain == humidifier.DOMAIN:
percentage = int(directive.payload["percentage"])
service = humidifier.SERVICE_SET_HUMIDITY
data = {
ATTR_ENTITY_ID: entity.entity_id,
humidifier.ATTR_HUMIDITY: percentage,
}
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context
)
else:
raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED)
percentage = int(directive.payload["percentage"])
service = fan.SERVICE_SET_PERCENTAGE
data = {
ATTR_ENTITY_ID: entity.entity_id,
fan.ATTR_PERCENTAGE: percentage,
}
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context
)
return directive.response()
@ -1130,6 +1146,18 @@ async def async_api_set_mode(
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]
@ -1306,6 +1334,12 @@ async def async_api_set_range(
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)
@ -1414,6 +1448,26 @@ async def async_api_adjust_range(
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)

View File

@ -411,6 +411,72 @@ async def test_report_fan_speed_state(hass):
properties.assert_equal("Alexa.RangeController", "rangeValue", 0)
async def test_report_humidifier_humidity_state(hass):
"""Test PercentageController, PowerLevelController reports humidifier humidity correctly."""
hass.states.async_set(
"humidifier.dry",
"on",
{
"friendly_name": "Humidifier dry",
"supported_features": 0,
"humidity": 25,
"min_humidity": 20,
"max_humidity": 90,
},
)
hass.states.async_set(
"humidifier.wet",
"on",
{
"friendly_name": "Humidifier wet",
"supported_features": 0,
"humidity": 80,
"min_humidity": 20,
"max_humidity": 90,
},
)
properties = await reported_properties(hass, "humidifier.dry")
properties.assert_equal("Alexa.RangeController", "rangeValue", 25)
properties = await reported_properties(hass, "humidifier.wet")
properties.assert_equal("Alexa.RangeController", "rangeValue", 80)
async def test_report_humidifier_mode(hass):
"""Test ModeController reports humidifier mode correctly."""
hass.states.async_set(
"humidifier.auto",
"on",
{
"friendly_name": "Humidifier auto",
"supported_features": 1,
"humidity": 50,
"mode": "Auto",
"available_modes": ["Auto", "Low", "Medium", "High"],
"min_humidity": 20,
"max_humidity": 90,
},
)
properties = await reported_properties(hass, "humidifier.auto")
properties.assert_equal("Alexa.ModeController", "mode", "mode.Auto")
hass.states.async_set(
"humidifier.medium",
"on",
{
"friendly_name": "Humidifier auto",
"supported_features": 1,
"humidity": 60,
"mode": "Medium",
"available_modes": ["Auto", "Low", "Medium", "High"],
"min_humidity": 20,
"max_humidity": 90,
},
)
properties = await reported_properties(hass, "humidifier.medium")
properties.assert_equal("Alexa.ModeController", "mode", "mode.Medium")
async def test_report_fan_preset_mode(hass):
"""Test ModeController reports fan preset_mode correctly."""
hass.states.async_set(

View File

@ -950,6 +950,145 @@ async def test_single_preset_mode_fan(hass, caplog):
caplog.clear()
@freeze_time("2022-04-19 07:53:05")
async def test_humidifier(hass, caplog):
"""Test humidifier controller."""
device = (
"humidifier.test_1",
"on",
{
"friendly_name": "Humidifier test 1",
"humidity": 66,
"supported_features": 1,
"mode": "Auto",
"available_modes": ["Auto", "Low", "Medium", "High"],
"min_humidity": 20,
"max_humidity": 90,
},
)
await discovery_test(device, hass)
await assert_power_controller_works(
"humidifier#test_1",
"humidifier.turn_on",
"humidifier.turn_off",
hass,
"2022-04-19T07:53:05Z",
)
call, _ = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"humidifier#test_1",
"humidifier.set_mode",
hass,
payload={"mode": "mode.Auto"},
instance="humidifier.mode",
)
assert call.data["mode"] == "Auto"
with pytest.raises(AssertionError):
await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"humidifier#test_1",
"humidifier.set_mode",
hass,
payload={"mode": "mode.-"},
instance="humidifier.mode",
)
assert "Entity 'humidifier.test_1' does not support Mode '-'" in caplog.text
caplog.clear()
call, _ = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"humidifier#test_1",
"humidifier.set_humidity",
hass,
payload={"rangeValue": "67"},
instance="humidifier.humidity",
)
assert call.data["humidity"] == 67
call, _ = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"humidifier#test_1",
"humidifier.set_humidity",
hass,
payload={"rangeValue": "33"},
instance="humidifier.humidity",
)
assert call.data["humidity"] == 33
async def test_humidifier_without_modes(hass):
"""Test humidifier discovery without modes."""
device = (
"humidifier.test_2",
"on",
{
"friendly_name": "Humidifier test 2",
"humidity": 33,
"supported_features": 0,
"min_humidity": 20,
"max_humidity": 90,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "humidifier#test_2"
assert appliance["displayCategories"][0] == "OTHER"
assert appliance["friendlyName"] == "Humidifier test 2"
capabilities = assert_endpoint_capabilities(
appliance,
"Alexa.RangeController",
"Alexa.PowerController",
"Alexa.EndpointHealth",
"Alexa",
)
power_capability = get_capability(capabilities, "Alexa.PowerController")
assert "capabilityResources" not in power_capability
assert "configuration" not in power_capability
async def test_humidifier_with_modes(hass):
"""Test humidifier discovery with modes."""
device = (
"humidifier.test_1",
"on",
{
"friendly_name": "Humidifier test 1",
"humidity": 66,
"supported_features": 1,
"mode": "Auto",
"available_modes": ["Auto", "Low", "Medium", "High"],
"min_humidity": 20,
"max_humidity": 90,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "humidifier#test_1"
assert appliance["displayCategories"][0] == "OTHER"
assert appliance["friendlyName"] == "Humidifier test 1"
capabilities = assert_endpoint_capabilities(
appliance,
"Alexa.ModeController",
"Alexa.RangeController",
"Alexa.PowerController",
"Alexa.EndpointHealth",
"Alexa",
)
power_capability = get_capability(capabilities, "Alexa.PowerController")
assert "capabilityResources" not in power_capability
assert "configuration" not in power_capability
async def test_lock(hass):
"""Test lock discovery."""
device = ("lock.test", "off", {"friendly_name": "Test lock"})

View File

@ -209,8 +209,8 @@ async def test_report_state_unsets_authorized_on_access_token_error(
config._store.set_authorized.assert_called_once_with(False)
async def test_report_state_instance(hass, aioclient_mock):
"""Test proactive state reports with instance."""
async def test_report_state_fan(hass, aioclient_mock):
"""Test proactive state reports with fan instance."""
aioclient_mock.post(TEST_URL, text="", status=202)
hass.states.async_set(
@ -275,6 +275,64 @@ async def test_report_state_instance(hass, aioclient_mock):
assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan"
async def test_report_state_humidifier(hass, aioclient_mock):
"""Test proactive state reports with humidifier instance."""
aioclient_mock.post(TEST_URL, text="", status=202)
hass.states.async_set(
"humidifier.test_humidifier",
"off",
{
"friendly_name": "Test humidifier",
"supported_features": 1,
"mode": None,
"available_modes": ["auto", "smart"],
},
)
await state_report.async_enable_proactive_mode(hass, get_default_config(hass))
hass.states.async_set(
"humidifier.test_humidifier",
"on",
{
"friendly_name": "Test humidifier",
"supported_features": 1,
"mode": "smart",
"available_modes": ["auto", "smart"],
"humidity": 55,
},
)
# To trigger event listener
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["event"]["header"]["namespace"] == "Alexa"
assert call_json["event"]["header"]["name"] == "ChangeReport"
change_reports = call_json["event"]["payload"]["change"]["properties"]
checks = 0
for report in change_reports:
if report["name"] == "mode":
assert report["value"] == "mode.smart"
assert report["instance"] == "humidifier.mode"
assert report["namespace"] == "Alexa.ModeController"
checks += 1
if report["name"] == "rangeValue":
assert report["value"] == 55
assert report["instance"] == "humidifier.humidity"
assert report["namespace"] == "Alexa.RangeController"
checks += 1
assert checks == 2
assert call_json["event"]["endpoint"]["endpointId"] == "humidifier#test_humidifier"
async def test_send_add_or_update_message(hass, aioclient_mock):
"""Test sending an AddOrUpdateReport message."""
aioclient_mock.post(TEST_URL, text="")