diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index e703bfe182d..484c064d53d 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -27,6 +27,8 @@ MODE_HOMEKIT_TO_HASS = { # Map of hass operation modes to homekit modes MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} +DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS) + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Homekit climate.""" @@ -50,10 +52,10 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): def update_characteristics(self, characteristics): """Synchronise device state with Home Assistant.""" # pylint: disable=import-error - from homekit.models.characteristics import CharacteristicsTypes + from homekit.model.characteristics import CharacteristicsTypes for characteristic in characteristics: - ctype = characteristic['type'] + ctype = CharacteristicsTypes.get_short_uuid(characteristic['type']) if ctype == CharacteristicsTypes.HEATING_COOLING_CURRENT: self._state = MODE_HOMEKIT_TO_HASS.get( characteristic['value']) @@ -62,8 +64,11 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): self._features |= SUPPORT_OPERATION_MODE self._current_mode = MODE_HOMEKIT_TO_HASS.get( characteristic['value']) + + valid_values = characteristic.get( + 'valid-values', DEFAULT_VALID_MODES) self._valid_modes = [MODE_HOMEKIT_TO_HASS.get( - mode) for mode in characteristic['valid-values']] + mode) for mode in valid_values] elif ctype == CharacteristicsTypes.TEMPERATURE_CURRENT: self._current_temp = characteristic['value'] elif ctype == CharacteristicsTypes.TEMPERATURE_TARGET: diff --git a/requirements_all.txt b/requirements_all.txt index 637bb16d800..85e9aeab078 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -529,7 +529,7 @@ home-assistant-frontend==20190121.1 homeassistant-pyozw==0.1.2 # homeassistant.components.homekit_controller -# homekit==0.12.2 +homekit==0.12.2 # homeassistant.components.homematicip_cloud homematicip==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2571c1a460..6f16780d4c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,6 +112,9 @@ holidays==0.9.9 # homeassistant.components.frontend home-assistant-frontend==20190121.1 +# homeassistant.components.homekit_controller +homekit==0.12.2 + # homeassistant.components.homematicip_cloud homematicip==0.10.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 67702635d47..8817ee61e8f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -32,7 +32,6 @@ COMMENT_REQUIREMENTS = ( 'i2csense', 'credstash', 'bme680', - 'homekit', 'py_noaa', ) @@ -64,6 +63,7 @@ TEST_REQUIREMENTS = ( 'hdate', 'holidays', 'home-assistant-frontend', + 'homekit', 'homematicip', 'influxdb', 'jsonpath', diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py new file mode 100644 index 00000000000..f96b046b4cf --- /dev/null +++ b/tests/components/homekit_controller/common.py @@ -0,0 +1,144 @@ +"""Code to support homekit_controller tests.""" +from datetime import timedelta +from unittest import mock + +from homeassistant.components.homekit_controller import ( + DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, SERVICE_HOMEKIT) +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util +from tests.common import async_fire_time_changed, fire_service_discovered + + +class FakePairing: + """ + A test fake that pretends to be a paired HomeKit accessory. + + This only contains methods and values that exist on the upstream Pairing + class. + """ + + def __init__(self, accessory): + """Create a fake pairing from an accessory model.""" + self.accessory = accessory + self.pairing_data = { + 'accessories': self.list_accessories_and_characteristics() + } + + def list_accessories_and_characteristics(self): + """Fake implementation of list_accessories_and_characteristics.""" + return [self.accessory.to_accessory_and_service_list()] + + def get_characteristics(self, characteristics): + """Fake implementation of get_characteristics.""" + results = {} + for aid, cid in characteristics: + for service in self.accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + results[(aid, cid)] = { + 'value': char.get_value() + } + return results + + def put_characteristics(self, characteristics): + """Fake implementation of put_characteristics.""" + for _, cid, new_val in characteristics: + for service in self.accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + char.set_value(new_val) + + +class FakeController: + """ + A test fake that pretends to be a paired HomeKit accessory. + + This only contains methods and values that exist on the upstream Controller + class. + """ + + def __init__(self): + """Create a Fake controller with no pairings.""" + self.pairings = {} + + def add(self, accessory): + """Create and register a fake pairing for a simulated accessory.""" + pairing = FakePairing(accessory) + self.pairings['00:00:00:00:00:00'] = pairing + return pairing + + +class Helper: + """Helper methods for interacting with HomeKit fakes.""" + + def __init__(self, hass, entity_id, pairing, accessory): + """Create a helper for a given accessory/entity.""" + from homekit.model.services import ServicesTypes + from homekit.model.characteristics import CharacteristicsTypes + + self.hass = hass + self.entity_id = entity_id + self.pairing = pairing + self.accessory = accessory + + self.characteristics = {} + for service in self.accessory.services: + service_name = ServicesTypes.get_short(service.type) + for char in service.characteristics: + char_name = CharacteristicsTypes.get_short(char.type) + self.characteristics[(service_name, char_name)] = char + + async def poll_and_get_state(self): + """Trigger a time based poll and return the current entity state.""" + next_update = dt_util.utcnow() + timedelta(seconds=60) + async_fire_time_changed(self.hass, next_update) + await self.hass.async_block_till_done() + + state = self.hass.states.get(self.entity_id) + assert state is not None + return state + + +async def setup_test_component(hass, services): + """Load a fake homekit accessory based on a homekit accessory model.""" + from homekit.model import Accessory + from homekit.model.services import ServicesTypes + + domain = None + for service in services: + service_name = ServicesTypes.get_short(service.type) + if service_name in HOMEKIT_ACCESSORY_DISPATCH: + domain = HOMEKIT_ACCESSORY_DISPATCH[service_name] + break + + assert domain, 'Cannot map test homekit services to homeassistant domain' + + config = { + 'discovery': { + } + } + + with mock.patch('homekit.Controller') as controller: + fake_controller = controller.return_value = FakeController() + await async_setup_component(hass, DOMAIN, config) + + 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': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + } + } + + fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) + await hass.async_block_till_done() + + return Helper(hass, '.'.join((domain, 'testdevice')), pairing, accessory) diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py new file mode 100644 index 00000000000..fe6cffdc09f --- /dev/null +++ b/tests/components/homekit_controller/conftest.py @@ -0,0 +1,14 @@ +"""HomeKit controller session fixtures.""" +import datetime +from unittest import mock + +import pytest + + +@pytest.fixture +def utcnow(request): + """Freeze time at a known point.""" + start_dt = datetime.datetime(2019, 1, 1, 0, 0, 0) + with mock.patch('homeassistant.util.dt.utcnow') as dt_utcnow: + dt_utcnow.return_value = start_dt + yield dt_utcnow diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py new file mode 100644 index 00000000000..9f5cc9d8764 --- /dev/null +++ b/tests/components/homekit_controller/test_climate.py @@ -0,0 +1,77 @@ +"""Basic checks for HomeKitclimate.""" +from homeassistant.components.climate import ( + DOMAIN, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE) +from tests.components.homekit_controller.common import ( + setup_test_component) + + +HEATING_COOLING_TARGET = ('thermostat', 'heating-cooling.target') +HEATING_COOLING_CURRENT = ('thermostat', 'heating-cooling.current') +TEMPERATURE_TARGET = ('thermostat', 'temperature.target') +TEMPERATURE_CURRENT = ('thermostat', 'temperature.current') + + +async def test_climate_change_thermostat_state(hass, utcnow): + """Test that we can turn a HomeKit thermostat on and off again.""" + from homekit.model.services import ThermostatService + + helper = await setup_test_component(hass, [ThermostatService()]) + + await hass.services.async_call(DOMAIN, SERVICE_SET_OPERATION_MODE, { + 'entity_id': 'climate.testdevice', + 'operation_mode': 'heat', + }, blocking=True) + + assert helper.characteristics[HEATING_COOLING_TARGET].value == 1 + + await hass.services.async_call(DOMAIN, SERVICE_SET_OPERATION_MODE, { + 'entity_id': 'climate.testdevice', + 'operation_mode': 'cool', + }, blocking=True) + assert helper.characteristics[HEATING_COOLING_TARGET].value == 2 + + +async def test_climate_change_thermostat_temperature(hass, utcnow): + """Test that we can turn a HomeKit thermostat on and off again.""" + from homekit.model.services import ThermostatService + + helper = await setup_test_component(hass, [ThermostatService()]) + + await hass.services.async_call(DOMAIN, SERVICE_SET_TEMPERATURE, { + 'entity_id': 'climate.testdevice', + 'temperature': 21, + }, blocking=True) + assert helper.characteristics[TEMPERATURE_TARGET].value == 21 + + await hass.services.async_call(DOMAIN, SERVICE_SET_TEMPERATURE, { + 'entity_id': 'climate.testdevice', + 'temperature': 25, + }, blocking=True) + assert helper.characteristics[TEMPERATURE_TARGET].value == 25 + + +async def test_climate_read_thermostat_state(hass, utcnow): + """Test that we can read the state of a HomeKit thermostat accessory.""" + from homekit.model.services import ThermostatService + + helper = await setup_test_component(hass, [ThermostatService()]) + + # Simulate that heating is on + helper.characteristics[TEMPERATURE_CURRENT].value = 19 + helper.characteristics[TEMPERATURE_TARGET].value = 21 + helper.characteristics[HEATING_COOLING_CURRENT].value = 1 + helper.characteristics[HEATING_COOLING_TARGET].value = 1 + + state = await helper.poll_and_get_state() + assert state.state == 'heat' + assert state.attributes['current_temperature'] == 19 + + # Simulate that cooling is on + helper.characteristics[TEMPERATURE_CURRENT].value = 21 + helper.characteristics[TEMPERATURE_TARGET].value = 19 + helper.characteristics[HEATING_COOLING_CURRENT].value = 2 + helper.characteristics[HEATING_COOLING_TARGET].value = 2 + + state = await helper.poll_and_get_state() + assert state.state == 'cool' + assert state.attributes['current_temperature'] == 21 diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py new file mode 100644 index 00000000000..152940818c1 --- /dev/null +++ b/tests/components/homekit_controller/test_light.py @@ -0,0 +1,46 @@ +"""Basic checks for HomeKitSwitch.""" +from tests.components.homekit_controller.common import ( + setup_test_component) + + +async def test_switch_change_light_state(hass, utcnow): + """Test that we can turn a HomeKit light on and off again.""" + from homekit.model.services import BHSLightBulbService + + helper = await setup_test_component(hass, [BHSLightBulbService()]) + + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.testdevice', + 'brightness': 255, + 'hs_color': [4, 5], + }, blocking=True) + assert helper.characteristics[('lightbulb', 'on')].value == 1 + assert helper.characteristics[('lightbulb', 'brightness')].value == 100 + assert helper.characteristics[('lightbulb', 'hue')].value == 4 + assert helper.characteristics[('lightbulb', 'saturation')].value == 5 + + await hass.services.async_call('light', 'turn_off', { + 'entity_id': 'light.testdevice', + }, blocking=True) + assert helper.characteristics[('lightbulb', 'on')].value == 0 + + +async def test_switch_read_light_state(hass, utcnow): + """Test that we can read the state of a HomeKit light accessory.""" + from homekit.model.services import BHSLightBulbService + + helper = await setup_test_component(hass, [BHSLightBulbService()]) + + # Initial state is that the light is off + state = await helper.poll_and_get_state() + assert state.state == 'off' + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[('lightbulb', 'on')].set_value(True) + state = await helper.poll_and_get_state() + assert state.state == 'on' + + # Simulate that device switched off in the real world not via HA + helper.characteristics[('lightbulb', 'on')].set_value(False) + state = await helper.poll_and_get_state() + assert state.state == 'off' diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py new file mode 100644 index 00000000000..8915f5858cf --- /dev/null +++ b/tests/components/homekit_controller/test_switch.py @@ -0,0 +1,49 @@ +"""Basic checks for HomeKitSwitch.""" +from tests.components.homekit_controller.common import ( + setup_test_component) + + +async def test_switch_change_outlet_state(hass, utcnow): + """Test that we can turn a HomeKit outlet on and off again.""" + from homekit.model.services import OutletService + + helper = await setup_test_component(hass, [OutletService()]) + + await hass.services.async_call('switch', 'turn_on', { + 'entity_id': 'switch.testdevice', + }, blocking=True) + assert helper.characteristics[('outlet', 'on')].value == 1 + + await hass.services.async_call('switch', 'turn_off', { + 'entity_id': 'switch.testdevice', + }, blocking=True) + assert helper.characteristics[('outlet', 'on')].value == 0 + + +async def test_switch_read_outlet_state(hass, utcnow): + """Test that we can read the state of a HomeKit outlet accessory.""" + from homekit.model.services import OutletService + + helper = await setup_test_component(hass, [OutletService()]) + + # Initial state is that the switch is off and the outlet isn't in use + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'off' + assert switch_1.attributes['outlet_in_use'] is False + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[('outlet', 'on')].set_value(True) + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'on' + assert switch_1.attributes['outlet_in_use'] is False + + # Simulate that device switched off in the real world not via HA + helper.characteristics[('outlet', 'on')].set_value(False) + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'off' + + # Simulate that someone plugged something into the device + helper.characteristics[('outlet', 'outlet-in-use')].value = True + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == 'off' + assert switch_1.attributes['outlet_in_use'] is True