diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 0447de97929..2c60dc168d0 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -1,4 +1,5 @@ """Code to support homekit_controller tests.""" +import json from datetime import timedelta from unittest import mock @@ -147,6 +148,41 @@ class FakeService(AbstractService): return char +def setup_accessories_from_file(path): + """Load an collection of accessory defs from JSON data.""" + with open(path, 'r') as accessories_data: + accessories_json = json.load(accessories_data) + + accessories = [] + + for accessory_data in accessories_json: + accessory = Accessory('Name', 'Mfr', 'Model', '0001', '0.1') + accessory.services = [] + accessory.aid = accessory_data['aid'] + for service_data in accessory_data['services']: + service = FakeService('public.hap.service.accessory-information') + service.type = service_data['type'] + service.iid = service_data['iid'] + + for char_data in service_data['characteristics']: + char = FakeCharacteristic(1, '23', None) + char.type = char_data['type'] + char.iid = char_data['iid'] + char.perms = char_data['perms'] + char.format = char_data['format'] + if 'description' in char_data: + char.description = char_data['description'] + if 'value' in char_data: + char.value = char_data['value'] + service.characteristics.append(char) + + accessory.services.append(service) + + accessories.append(accessory) + + return accessories + + async def setup_platform(hass): """Load the platform but with a fake Controller API.""" config = { @@ -161,6 +197,30 @@ async def setup_platform(hass): return fake_controller +async def setup_test_accessories(hass, accessories, capitalize=False): + """Load a fake homekit accessory based on a homekit accessory model. + + If capitalize is True, property names will be in upper case. + """ + fake_controller = await setup_platform(hass) + pairing = fake_controller.add(accessories) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + ('MD' if capitalize else 'md'): 'TestDevice', + ('ID' if capitalize else 'id'): '00:00:00:00:00:00', + ('C#' if capitalize else 'c#'): 1, + } + } + + fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) + await hass.async_block_till_done() + + return pairing + + async def setup_test_component(hass, services, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. @@ -177,24 +237,10 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None): assert domain, 'Cannot map test homekit services to homeassistant domain' - fake_controller = await setup_platform(hass) - accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') accessory.services.extend(services) - pairing = fake_controller.add([accessory]) - discovery_info = { - 'host': '127.0.0.1', - 'port': 8080, - 'properties': { - ('MD' if capitalize else 'md'): 'TestDevice', - ('ID' if capitalize else 'id'): '00:00:00:00:00:00', - ('C#' if capitalize else 'c#'): 1, - } - } - - fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) - await hass.async_block_till_done() + pairing = await setup_test_accessories(hass, [accessory], capitalize) entity = 'testdevice' if suffix is None else 'testdevice_{}'.format(suffix) return Helper(hass, '.'.join((domain, entity)), pairing, accessory) diff --git a/tests/components/homekit_controller/specific_devices/koogeek_ls1.json b/tests/components/homekit_controller/specific_devices/koogeek_ls1.json new file mode 100644 index 00000000000..9b05ce76639 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/koogeek_ls1.json @@ -0,0 +1,244 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "string", + "iid": 2, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "23", + "value": "Koogeek-LS1-20833F" + }, + { + "format": "string", + "iid": 3, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "20", + "value": "Koogeek" + }, + { + "format": "string", + "iid": 4, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "21", + "value": "LS1" + }, + { + "format": "string", + "iid": 5, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "30", + "value": "AAAA011111111111" + }, + { + "format": "bool", + "iid": 6, + "perms": [ + "pw" + ], + "type": "14" + }, + { + "format": "string", + "iid": 23, + "perms": [ + "pr" + ], + "type": "52", + "value": "2.2.15" + } + ], + "iid": 1, + "type": "3E" + }, + { + "characteristics": [ + { + "ev": false, + "format": "bool", + "iid": 8, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "25", + "value": false + }, + { + "ev": false, + "format": "float", + "iid": 9, + "maxValue": 359, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "13", + "unit": "arcdegrees", + "value": 44 + }, + { + "ev": false, + "format": "float", + "iid": 10, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "2F", + "unit": "percentage", + "value": 0 + }, + { + "ev": false, + "format": "int", + "iid": 11, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "8", + "unit": "percentage", + "value": 100 + }, + { + "format": "string", + "iid": 12, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "23", + "value": "Light Strip" + } + ], + "iid": 7, + "primary": true, + "type": "43" + }, + { + "characteristics": [ + { + "description": "TIMER_SETTINGS", + "format": "tlv8", + "iid": 14, + "perms": [ + "pr", + "pw" + ], + "type": "4aaaf942-0dec-11e5-b939-0800200c9a66", + "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "iid": 13, + "type": "4aaaf940-0dec-11e5-b939-0800200c9a66" + }, + { + "characteristics": [ + { + "description": "FW Upgrade supported types", + "format": "string", + "iid": 16, + "perms": [ + "pr", + "hd" + ], + "type": "151909D2-3802-11E4-916C-0800200C9A66", + "value": "url,data" + }, + { + "description": "FW Upgrade URL", + "format": "string", + "iid": 17, + "maxLen": 256, + "perms": [ + "pw", + "hd" + ], + "type": "151909D1-3802-11E4-916C-0800200C9A66" + }, + { + "description": "FW Upgrade Status", + "ev": false, + "format": "int", + "iid": 18, + "perms": [ + "pr", + "ev", + "hd" + ], + "type": "151909D6-3802-11E4-916C-0800200C9A66", + "value": 0 + }, + { + "description": "FW Upgrade Data", + "format": "data", + "iid": 19, + "perms": [ + "pw", + "hd" + ], + "type": "151909D7-3802-11E4-916C-0800200C9A66" + } + ], + "hidden": true, + "iid": 15, + "type": "151909D0-3802-11E4-916C-0800200C9A66" + }, + { + "characteristics": [ + { + "description": "Timezone", + "format": "int", + "iid": 21, + "perms": [ + "pr", + "pw" + ], + "type": "151909D5-3802-11E4-916C-0800200C9A66", + "value": 0 + }, + { + "description": "Time value since Epoch", + "format": "int", + "iid": 22, + "perms": [ + "pr", + "pw" + ], + "type": "151909D4-3802-11E4-916C-0800200C9A66", + "value": 1550348623 + } + ], + "iid": 20, + "type": "151909D3-3802-11E4-916C-0800200C9A66" + } + ] + } +] \ No newline at end of file diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py new file mode 100644 index 00000000000..78528c1247f --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -0,0 +1,85 @@ +"""Make sure that existing Koogeek LS1 support isn't broken.""" + +import os +from datetime import timedelta +from unittest import mock + +import pytest + +from homekit.exceptions import AccessoryDisconnectedError, EncryptionError +import homeassistant.util.dt as dt_util +from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR +from tests.common import async_fire_time_changed +from tests.components.homekit_controller.common import ( + setup_accessories_from_file, setup_test_accessories, FakePairing, Helper +) + +LIGHT_ON = ('lightbulb', 'on') + + +async def test_koogeek_ls1_setup(hass): + """Test that a Koogeek LS1 can be correctly setup in HA.""" + profile_path = os.path.join(os.path.dirname(__file__), 'koogeek_ls1.json') + accessories = setup_accessories_from_file(profile_path) + pairing = await setup_test_accessories(hass, accessories) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Assert that the entity is correctly added to the entity registry + entity = entity_registry.async_get('light.testdevice') + assert entity.unique_id == 'homekit-AAAA011111111111-7' + + helper = Helper(hass, 'light.testdevice', pairing, accessories[0]) + state = await helper.poll_and_get_state() + + # Assert that the friendly name is detected correctly + assert state.attributes['friendly_name'] == 'TestDevice' + + # Assert that all optional features the LS1 supports are detected + assert state.attributes['supported_features'] == ( + SUPPORT_BRIGHTNESS | SUPPORT_COLOR + ) + + +@pytest.mark.parametrize('failure_cls', [ + AccessoryDisconnectedError, EncryptionError +]) +async def test_recover_from_failure(hass, utcnow, failure_cls): + """ + Test that entity actually recovers from a network connection drop. + + See https://github.com/home-assistant/home-assistant/issues/18949 + """ + profile_path = os.path.join(os.path.dirname(__file__), 'koogeek_ls1.json') + accessories = setup_accessories_from_file(profile_path) + pairing = await setup_test_accessories(hass, accessories) + + helper = Helper(hass, 'light.testdevice', pairing, accessories[0]) + + # Set light state on fake device to off + helper.characteristics[LIGHT_ON].set_value(False) + + # Test that entity starts off in a known state + state = await helper.poll_and_get_state() + assert state.state == 'off' + + # Set light state on fake device to on + helper.characteristics[LIGHT_ON].set_value(True) + + # Test that entity remains in the same state if there is a network error + next_update = dt_util.utcnow() + timedelta(seconds=60) + with mock.patch.object(FakePairing, 'get_characteristics') as get_char: + get_char.side_effect = failure_cls('Disconnected') + + state = await helper.poll_and_get_state() + assert state.state == 'off' + + get_char.assert_called_with([(1, 8), (1, 9), (1, 10), (1, 11)]) + + # Test that entity changes state when network error goes away + next_update += timedelta(seconds=60) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = await helper.poll_and_get_state() + assert state.state == 'on'