From 414a59ae9f8f14507603a85a5481661ad8371e6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Aug 2020 09:46:45 -0500 Subject: [PATCH] Add the ability to reload homekit from yaml (#39326) --- homeassistant/components/homekit/__init__.py | 76 ++++++++++++++----- .../components/homekit/services.yaml | 3 + homeassistant/helpers/reload.py | 13 +++- tests/components/homekit/test_homekit.py | 68 +++++++++++++++++ .../helpers/test_domain_configuration.yaml | 4 + tests/fixtures/homekit/configuration.yaml | 3 + tests/helpers/test_reload.py | 33 ++++++++ 7 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 tests/fixtures/helpers/test_domain_configuration.yaml create mode 100644 tests/fixtures/homekit/configuration.yaml diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index d1c909cf2b0..e8360b1d73b 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -30,12 +30,14 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, ) from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, Unauthorized from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.loader import async_get_integration from homeassistant.util import get_local_ip @@ -150,23 +152,7 @@ async def async_setup(hass: HomeAssistant, config: dict): entries_by_name = {entry.data[CONF_NAME]: entry for entry in current_entries} for index, conf in enumerate(config[DOMAIN]): - bridge_name = conf[CONF_NAME] - - if ( - bridge_name in entries_by_name - and entries_by_name[bridge_name].source == SOURCE_IMPORT - ): - entry = entries_by_name[bridge_name] - # If they alter the yaml config we import the changes - # since there currently is no practical way to support - # all the options in the UI at this time. - data = conf.copy() - options = {} - for key in CONFIG_OPTIONS: - options[key] = data[key] - del data[key] - - hass.config_entries.async_update_entry(entry, data=data, options=options) + if _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf): continue conf[CONF_ENTRY_INDEX] = index @@ -181,6 +167,36 @@ async def async_setup(hass: HomeAssistant, config: dict): return True +@callback +def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf): + """Update a config entry with the latest yaml. + + Returns True if a matching config entry was found + + Returns False if there is no matching config entry + """ + bridge_name = conf[CONF_NAME] + + if ( + bridge_name in entries_by_name + and entries_by_name[bridge_name].source == SOURCE_IMPORT + ): + entry = entries_by_name[bridge_name] + # If they alter the yaml config we import the changes + # since there currently is no practical way to support + # all the options in the UI at this time. + data = conf.copy() + options = {} + for key in CONFIG_OPTIONS: + options[key] = data[key] + del data[key] + + hass.config_entries.async_update_entry(entry, data=data, options=options) + return True + + return False + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up HomeKit from a config entry.""" _async_import_options_from_data_if_missing(hass, entry) @@ -349,6 +365,32 @@ def _async_register_events_and_services(hass: HomeAssistant): DOMAIN, SERVICE_HOMEKIT_START, async_handle_homekit_service_start ) + async def _handle_homekit_reload(service): + """Handle start HomeKit service call.""" + config = await async_integration_yaml_config(hass, DOMAIN) + + if not config or DOMAIN not in config: + return + + current_entries = hass.config_entries.async_entries(DOMAIN) + entries_by_name = {entry.data[CONF_NAME]: entry for entry in current_entries} + + for conf in config[DOMAIN]: + _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf) + + reload_tasks = [ + hass.config_entries.async_reload(entry.entry_id) + for entry in current_entries + ] + + await asyncio.gather(*reload_tasks) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_RELOAD, + _handle_homekit_reload, + ) + class HomeKit: """Class to handle all actions between HomeKit and Home Assistant.""" diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index b33ba642c8d..c2dde2cac6c 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -3,6 +3,9 @@ start: description: Starts the HomeKit driver. +reload: + description: Reload homekit and re-process yaml configuration. + reset_accessory: description: Reset a HomeKit accessory. This can be useful when changing a media_player’s device class to tv, linking a battery, or whenever Home Assistant adds support for new HomeKit features to existing entities. fields: diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 4ff9198f233..1ffba25ce15 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Iterable, Optional +from typing import Any, Dict, Iterable, Optional from homeassistant import config as conf_util from homeassistant.const import SERVICE_RELOAD @@ -59,6 +59,17 @@ async def async_reload_integration_platforms( await platform.async_setup(p_config) # type: ignore +async def async_integration_yaml_config( + hass: HomeAssistantType, integration_name: str +) -> Optional[Dict[Any, Any]]: + """Fetch the latest yaml configuration for an integration.""" + integration = await async_get_integration(hass, integration_name) + + return await conf_util.async_process_component_config( + hass, await conf_util.async_hass_config_yaml(hass), integration + ) + + @callback def async_get_platform( hass: HomeAssistantType, integration_name: str, integration_platform_name: str diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index c20b2a9d9fb..94c5c28ecb2 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -4,6 +4,7 @@ from typing import Dict import pytest +from homeassistant import config as hass_config from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, @@ -49,6 +50,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, STATE_ON, UNIT_PERCENTAGE, ) @@ -1181,3 +1183,69 @@ async def test_homekit_finds_linked_humidity_sensors( "linked_humidity_sensor": "sensor.humidifier_humidity_sensor", }, ) + + +async def test_reload(hass): + """Test we can reload from yaml.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IMPORT, + data={CONF_NAME: "reloadable", CONF_PORT: 12345}, + options={}, + ) + entry.add_to_hass(hass) + + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await async_setup_component( + hass, "homekit", {"homekit": {CONF_NAME: "reloadable", CONF_PORT: 12345}} + ) + await hass.async_block_till_done() + + mock_homekit.assert_any_call( + hass, + "reloadable", + 12345, + None, + ANY, + {}, + DEFAULT_SAFE_MODE, + None, + entry.entry_id, + ) + assert mock_homekit().setup.called is True + yaml_path = os.path.join( + _get_fixtures_base_path(), + "fixtures", + "homekit/configuration.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch( + f"{PATH_HOMEKIT}.HomeKit" + ) as mock_homekit2: + mock_homekit2.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + await hass.services.async_call( + "homekit", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_homekit2.assert_any_call( + hass, + "reloadable", + 45678, + None, + ANY, + {}, + DEFAULT_SAFE_MODE, + None, + entry.entry_id, + ) + assert mock_homekit2().setup.called is True + + +def _get_fixtures_base_path(): + return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/tests/fixtures/helpers/test_domain_configuration.yaml b/tests/fixtures/helpers/test_domain_configuration.yaml new file mode 100644 index 00000000000..3c9c6ed8a7b --- /dev/null +++ b/tests/fixtures/helpers/test_domain_configuration.yaml @@ -0,0 +1,4 @@ +test_domain: + - name: one + - name: two + diff --git a/tests/fixtures/homekit/configuration.yaml b/tests/fixtures/homekit/configuration.yaml new file mode 100644 index 00000000000..546c4a1a09f --- /dev/null +++ b/tests/fixtures/homekit/configuration.yaml @@ -0,0 +1,3 @@ +homekit: + - name: reloadable + port: 45678 diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 255d3cde40a..dafcbebdb6e 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -2,11 +2,14 @@ import logging from os import path +import pytest + from homeassistant import config from homeassistant.const import SERVICE_RELOAD from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.reload import ( async_get_platform, + async_integration_yaml_config, async_reload_integration_platforms, async_setup_reload_service, ) @@ -106,5 +109,35 @@ async def test_setup_reload_service(hass): assert len(setup_called) == 2 +async def test_async_integration_yaml_config(hass): + """Test loading yaml config for an integration.""" + mock_integration(hass, MockModule(DOMAIN)) + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + f"helpers/{DOMAIN}_configuration.yaml", + ) + with patch.object(config, "YAML_CONFIG_FILE", yaml_path): + processed_config = await async_integration_yaml_config(hass, DOMAIN) + + assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]} + + +async def test_async_integration_missing_yaml_config(hass): + """Test loading missing yaml config for an integration.""" + mock_integration(hass, MockModule(DOMAIN)) + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "helpers/does_not_exist_configuration.yaml", + ) + with pytest.raises(FileNotFoundError), patch.object( + config, "YAML_CONFIG_FILE", yaml_path + ): + await async_integration_yaml_config(hass, DOMAIN) + + def _get_fixtures_base_path(): return path.dirname(path.dirname(__file__))