From 9a000a1183c0bb498fd0b7c0886d63c8e2783f51 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 26 Jul 2021 16:46:36 +0100 Subject: [PATCH] Support controlling Flowerbud spray level via homekit_controller (#53493) --- .../components/homekit_controller/const.py | 1 + .../components/homekit_controller/number.py | 100 ++++ .../test_vocolinc_flowerbud.py | 70 +++ .../homekit_controller/test_number.py | 87 ++++ .../vocolinc_flowerbud.json | 467 ++++++++++++++++++ 5 files changed, 725 insertions(+) create mode 100644 homeassistant/components/homekit_controller/number.py create mode 100644 tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py create mode 100644 tests/components/homekit_controller/test_number.py create mode 100644 tests/fixtures/homekit_controller/vocolinc_flowerbud.json diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 5ac777a3969..321113cf8df 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -47,5 +47,6 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): "sensor", } diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py new file mode 100644 index 00000000000..2e0193fa080 --- /dev/null +++ b/homeassistant/components/homekit_controller/number.py @@ -0,0 +1,100 @@ +""" +Support for Homekit number ranges. + +These are mostly used where a HomeKit accessory exposes additional non-standard +characteristics that don't map to a Home Assistant feature. +""" +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes + +from homeassistant.components.number import NumberEntity +from homeassistant.core import callback + +from . import KNOWN_DEVICES, CharacteristicEntity + +NUMBER_ENTITIES = { + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: { + "name": "Spray Quantity", + "icon": "mdi:water", + } +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit numbers.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_characteristic(char: Characteristic): + kwargs = NUMBER_ENTITIES.get(char.type) + if not kwargs: + return False + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + async_add_entities([HomeKitNumber(conn, info, char, **kwargs)], True) + return True + + conn.add_char_factory(async_add_characteristic) + + +class HomeKitNumber(CharacteristicEntity, NumberEntity): + """Representation of a Number control on a homekit accessory.""" + + def __init__( + self, + conn, + info, + char, + device_class=None, + icon=None, + name=None, + **kwargs, + ): + """Initialise a HomeKit number control.""" + self._device_class = device_class + self._icon = icon + self._name = name + self._char = char + + super().__init__(conn, info) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + def device_class(self): + """Return type of sensor.""" + return self._device_class + + @property + def icon(self): + """Return the sensor icon.""" + return self._icon + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._char.minValue + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._char.maxValue + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + return self._char.minStep + + @property + def value(self) -> float: + """Return the current characteristic value.""" + return self._char.value + + async def async_set_value(self, value: float): + """Set the characteristic to this value.""" + await self.async_put_characteristics( + { + self._char.type: value, + } + ) diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py new file mode 100644 index 00000000000..ee4713b012b --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -0,0 +1,70 @@ +"""Make sure that Vocolinc Flowerbud is enumerated properly.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_vocolinc_flowerbud_setup(hass): + """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + # Check that the switch entity is handled correctly + + entry = entity_registry.async_get("number.vocolinc_flowerbud_0d324b") + assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:30" + + helper = Helper( + hass, "number.vocolinc_flowerbud_0d324b", pairing, accessories[0], config_entry + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "VOCOlinc" + assert device.name == "VOCOlinc-Flowerbud-0d324b" + assert device.model == "Flowerbud" + assert device.sw_version == "3.121.2" + assert device.via_device_id is None + + # Assert the humidifier is detected + entry = entity_registry.async_get("humidifier.vocolinc_flowerbud_0d324b") + assert entry.unique_id == "homekit-AM01121849000327-30" + + helper = Helper( + hass, + "humidifier.vocolinc_flowerbud_0d324b", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" + + # The sensor and switch should be part of the same device + assert entry.device_id == device.id + + # Assert the light is detected + entry = entity_registry.async_get("light.vocolinc_flowerbud_0d324b") + assert entry.unique_id == "homekit-AM01121849000327-9" + + helper = Helper( + hass, + "light.vocolinc_flowerbud_0d324b", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" + + # The sensor and switch should be part of the same device + assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py new file mode 100644 index 00000000000..490b69b1a80 --- /dev/null +++ b/tests/components/homekit_controller/test_number.py @@ -0,0 +1,87 @@ +"""Basic checks for HomeKit sensor.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import Helper, setup_test_component + + +def create_switch_with_spray_level(accessory): + """Define battery level characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + spray_level = service.add_char( + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL + ) + + spray_level.value = 1 + spray_level.minStep = 1 + spray_level.minValue = 1 + spray_level.maxValue = 5 + spray_level.format = "float" + + cur_state = service.add_char(CharacteristicsTypes.ON) + cur_state.value = True + + return service + + +async def test_read_number(hass, utcnow): + """Test a switch service that has a sensor characteristic is correctly handled.""" + helper = await setup_test_component(hass, create_switch_with_spray_level) + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "number.testdevice", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + spray_level = outlet[CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL] + + state = await energy_helper.poll_and_get_state() + assert state.state == "1" + assert state.attributes["step"] == 1 + assert state.attributes["min"] == 1 + assert state.attributes["max"] == 5 + + spray_level.value = 5 + state = await energy_helper.poll_and_get_state() + assert state.state == "5" + + +async def test_write_number(hass, utcnow): + """Test a switch service that has a sensor characteristic is correctly handled.""" + helper = await setup_test_component(hass, create_switch_with_spray_level) + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "number.testdevice", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + spray_level = outlet[CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL] + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice", "value": 5}, + blocking=True, + ) + assert spray_level.value == 5 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice", "value": 3}, + blocking=True, + ) + assert spray_level.value == 3 diff --git a/tests/fixtures/homekit_controller/vocolinc_flowerbud.json b/tests/fixtures/homekit_controller/vocolinc_flowerbud.json new file mode 100644 index 00000000000..012c03471f3 --- /dev/null +++ b/tests/fixtures/homekit_controller/vocolinc_flowerbud.json @@ -0,0 +1,467 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "bool", + "iid": 2, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 3, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "VOCOlinc" + }, + { + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Flowerbud" + }, + { + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "VOCOlinc-Flowerbud-0d324b" + }, + { + "format": "string", + "iid": 6, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "AM01121849000327" + }, + { + "description": "", + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "3.121.2" + }, + { + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000053-0000-1000-8000-0026BB765291", + "value": "0.1" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "rssi_report_switch", + "format": "bool", + "iid": 81, + "perms": [ + "pr", + "pw" + ], + "type": "D9959C8A-809A-4F75-92D7-71F630AC2925", + "value": 0 + }, + { + "description": "rssi_report_value", + "format": "uint8", + "iid": 82, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "8137182C-6904-4FB9-ADCC-61CECA85CE48", + "value": 0 + } + ], + "iid": 80, + "stype": "Unknown Service: C635EF5C-5BBC-4F96-B7DA-6669069A4B32", + "type": "C635EF5C-5BBC-4F96-B7DA-6669069A4B32" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 31, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "FLOWERBUD" + }, + { + "format": "uint8", + "iid": 32, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000B0-0000-1000-8000-0026BB765291", + "value": 0 + }, + { + "format": "float", + "iid": 33, + "maxValue": 100.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "ev" + ], + "type": "00000010-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 45.0 + }, + { + "format": "uint8", + "iid": 34, + "maxValue": 2, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "000000B3-0000-1000-8000-0026BB765291", + "value": 0 + }, + { + "format": "uint8", + "iid": 35, + "maxValue": 1, + "minStep": 1, + "minValue": 1, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000B4-0000-1000-8000-0026BB765291", + "value": 1 + }, + { + "format": "uint8", + "iid": 36, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "36158AC8-5191-4AE2-9EF5-1D6722E88E3D", + "value": 1 + }, + { + "description": "spray quantity", + "format": "uint8", + "iid": 38, + "maxValue": 5, + "minStep": 1, + "minValue": 1, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "69D52519-0A4E-4898-8335-4739F9116D0A", + "value": 5 + }, + { + "format": "float", + "iid": 39, + "maxValue": 100.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000CA-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100.0 + }, + { + "description": "humidifier_timer_setting", + "format": "data", + "iid": 40, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "F84B3138-E44F-49B9-AA91-9E1736C247C0", + "value": "AA==" + }, + { + "description": "humidifier_countdown", + "format": "data", + "iid": 41, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "43CE176B-2933-4034-98A7-AD215BEEBF2F", + "value": "AA==" + } + ], + "iid": 30, + "stype": "humidifier-dehumidifier", + "type": "000000BD-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 10, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Mood Light" + }, + { + "format": "bool", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000025-0000-1000-8000-0026BB765291", + "value": true + }, + { + "format": "int", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000008-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 50 + }, + { + "format": "float", + "iid": 13, + "maxValue": 360.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000013-0000-1000-8000-0026BB765291", + "unit": "arcdegrees", + "value": 120.0 + }, + { + "format": "float", + "iid": 14, + "maxValue": 100.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "0000002F-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100.0 + }, + { + "description": "lb_timer_setting", + "format": "data", + "iid": 63, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "A30DFE91-271A-42A5-88BA-00E3FF5488AD", + "value": "AA==" + }, + { + "description": "light effect mode", + "format": "uint8", + "iid": 64, + "maxValue": 31, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "146889FC-7C42-429B-93AB-E80F79759E90", + "value": 0 + }, + { + "description": "light effect flag", + "format": "uint32", + "iid": 73, + "perms": [ + "pr" + ], + "type": "9D4B479D-9EFB-4739-98F3-B33E6543BF7B", + "value": 7 + }, + { + "description": "flashing mode", + "format": "data", + "iid": 65, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "2C42B339-6EC9-4ED5-8DBF-FFCCC721B144", + "value": "AA==" + }, + { + "description": "smoothing mode", + "format": "data", + "iid": 66, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "A3663C89-DC18-42EF-8297-910A4C0C9B61", + "value": "AA==" + }, + { + "description": "breathing mode", + "format": "data", + "iid": 67, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "6533B15C-AECB-455F-8896-20B125390F61", + "value": "AA==" + } + ], + "iid": 9, + "stype": "lightbulb", + "type": "00000043-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "time_zone", + "format": "int", + "iid": 50, + "maxValue": 1400, + "minStep": 1, + "minValue": -1200, + "perms": [ + "pr", + "pw" + ], + "type": "38396B8E-161B-4A77-AF3F-C4DAC0BE9B74", + "value": 0 + }, + { + "description": "hour_date_time", + "format": "int", + "iid": 51, + "perms": [ + "pr", + "pw" + ], + "type": "71216CD3-209E-40CC-BEA0-71A2A9458E13", + "value": 0 + } + ], + "iid": 48, + "stype": "Unknown Service: 961EBB65-A1E3-4F34-BD31-86552706FE40", + "type": "961EBB65-A1E3-4F34-BD31-86552706FE40" + }, + { + "characteristics": [ + { + "description": "fm_upgrade_status", + "format": "int", + "iid": 21, + "perms": [ + "pr", + "ev" + ], + "type": "49DDDE07-C3FA-499E-8055-58E154E04F34", + "value": 0 + }, + { + "description": "fm_upgrade_url", + "format": "string", + "iid": 22, + "maxLen": 256, + "perms": [ + "pw" + ], + "type": "4C203E30-EB25-466D-9980-C6C2E14BF6AA" + } + ], + "hidden": true, + "iid": 20, + "stype": "Unknown Service: 3138B537-E830-4F52-90A7-D6FDB000BF97", + "type": "3138B537-E830-4F52-90A7-D6FDB000BF97" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 24, + "perms": [ + "pr" + ], + "type": "00000037-0000-1000-8000-0026BB765291", + "value": "1.1.0" + } + ], + "iid": 23, + "stype": "service", + "type": "000000A2-0000-1000-8000-0026BB765291" + } + ] + } +]