diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 8f959369b7b..4fd66a20085 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -7,19 +7,25 @@ from typing import Dict, Optional import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_EDIT from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.const import CONF_ENTITY_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback, split_entity_id +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.discovery import (async_listen_platform, + async_load_platform) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import EventType, HomeAssistantType +from homeassistant.helpers.typing import (ContextType, EventType, + HomeAssistantType, ServiceCallType) +from homeassistant.loader import bind_hass _LOGGER = getLogger(__name__) DOMAIN = 'switcher_kis' +CONF_AUTO_OFF = 'auto_off' CONF_DEVICE_ID = 'device_id' CONF_DEVICE_PASSWORD = 'device_password' CONF_PHONE_ID = 'phone_id' @@ -40,6 +46,32 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) +SERVICE_SET_AUTO_OFF_NAME = 'set_auto_off' +SERVICE_SET_AUTO_OFF_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_AUTO_OFF): cv.time_period_str +}) + + +@bind_hass +async def _validate_edit_permission( + hass: HomeAssistantType, context: ContextType, + entity_id: str) -> None: + """Use for validating user control permissions.""" + splited = split_entity_id(entity_id) + if splited[0] != SWITCH_DOMAIN or not splited[1].startswith(DOMAIN): + raise Unauthorized( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + + user = await hass.auth.async_get_user(context.user_id) + if user is None: + raise UnknownUser( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + + if not user.permissions.check_entity(entity_id, POLICY_EDIT): + raise Unauthorized( + context=context, entity_id=entity_id, permission=(POLICY_EDIT, )) + async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: """Set up the switcher component.""" @@ -58,13 +90,12 @@ async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: """On homeassistant stop, gracefully stop the bridge if running.""" await v2bridge.stop() - hass.async_add_job(hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_stop_bridge)) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_bridge) try: device_data = await wait_for(v2bridge.queue.get(), timeout=10.0) except (Asyncio_TimeoutError, RuntimeError): - _LOGGER.exception("failed to get response from device") + _LOGGER.exception("Failed to get response from device") await v2bridge.stop() return False @@ -72,8 +103,33 @@ async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: DATA_DEVICE: device_data } + async def async_switch_platform_discovered( + platform: str, discovery_info: Optional[Dict]) -> None: + """Use for registering services after switch platform is discoverd.""" + if platform != DOMAIN: + return + + async def async_set_auto_off_service(service: ServiceCallType) -> None: + """Use for handling setting device auto-off service calls.""" + from aioswitcher.api import SwitcherV2Api + + await _validate_edit_permission( + hass, service.context, service.data[CONF_ENTITY_ID]) + + async with SwitcherV2Api(hass.loop, device_data.ip_addr, phone_id, + device_id, device_password) as swapi: + await swapi.set_auto_shutdown(service.data[CONF_AUTO_OFF]) + + hass.services.async_register( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + async_set_auto_off_service, + schema=SERVICE_SET_AUTO_OFF_SCHEMA) + + async_listen_platform( + hass, SWITCH_DOMAIN, async_switch_platform_discovered) + hass.async_create_task(async_load_platform( - hass, SWITCH_DOMAIN, DOMAIN, None, config)) + hass, SWITCH_DOMAIN, DOMAIN, {}, config)) @callback def device_updates(timestamp: Optional[datetime]) -> None: diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 140caf51936..2f3b3b6e84a 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -6,7 +6,7 @@ "@tomerfi" ], "requirements": [ - "aioswitcher==2019.3.21" + "aioswitcher==2019.4.26" ], "dependencies": [] } diff --git a/homeassistant/components/switcher_kis/services.yaml b/homeassistant/components/switcher_kis/services.yaml new file mode 100644 index 00000000000..5408a204990 --- /dev/null +++ b/homeassistant/components/switcher_kis/services.yaml @@ -0,0 +1,9 @@ +set_auto_off: + description: 'Update Switcher device auto off setting.' + fields: + entity_id: + description: "Name of the entity id associated with the integration, used for permission validation." + example: "switch.switcher_kis_boiler" + auto_off: + description: 'Time period string containing hours and minutes.' + example: '"02:30"' diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index c66c6b52e0c..a6da7aad4b9 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -30,7 +30,8 @@ async def async_setup_platform(hass: HomeAssistantType, config: Dict, async_add_entities: Callable, discovery_info: Dict) -> None: """Set up the switcher platform for the switch component.""" - assert DOMAIN in hass.data + if discovery_info is None: + return async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index e9a8d0749b0..f084c5fddbe 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -7,8 +7,10 @@ import homeassistant.core GPSType = Tuple[float, float] ConfigType = Dict[str, Any] +ContextType = homeassistant.core.Context EventType = homeassistant.core.Event HomeAssistantType = homeassistant.core.HomeAssistant +ServiceCallType = homeassistant.core.ServiceCall ServiceDataType = Dict[str, Any] TemplateVarsType = Optional[Dict[str, Any]] diff --git a/requirements_all.txt b/requirements_all.txt index 2c4920a2469..426060f990a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,7 +160,7 @@ aiolifx_effects==0.2.2 aiopvapi==1.6.14 # homeassistant.components.switcher_kis -aioswitcher==2019.3.21 +aioswitcher==2019.4.26 # homeassistant.components.unifi aiounifi==6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4dd74fc63b0..b40047493ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -58,7 +58,7 @@ aiohttp_cors==0.7.0 aiohue==1.9.1 # homeassistant.components.switcher_kis -aioswitcher==2019.3.21 +aioswitcher==2019.4.26 # homeassistant.components.unifi aiounifi==6 diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 9f961f72401..d820f11cea6 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -98,7 +98,9 @@ def mock_bridge_fixture() -> Generator[None, Any, None]: patchers = [ patch('aioswitcher.bridge.SwitcherV2Bridge.start', new=mock_bridge), patch('aioswitcher.bridge.SwitcherV2Bridge.stop', new=mock_bridge), - patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue) + patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue), + patch('aioswitcher.bridge.SwitcherV2Bridge.running', + return_value=True) ] for patcher in patchers: @@ -130,3 +132,22 @@ def mock_failed_bridge_fixture() -> Generator[None, Any, None]: for patcher in patchers: patcher.stop() + + +@fixture(name='mock_api') +def mock_api_fixture() -> Generator[CoroutineMock, Any, None]: + """Fixture for mocking aioswitcher.api.SwitcherV2Api.""" + mock_api = CoroutineMock() + + patchers = [ + patch('aioswitcher.api.SwitcherV2Api.connect', new=mock_api), + patch('aioswitcher.api.SwitcherV2Api.disconnect', new=mock_api) + ] + + for patcher in patchers: + patcher.start() + + yield + + for patcher in patchers: + patcher.stop() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index 47efe8d03c9..852f5e521f7 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -17,6 +17,9 @@ DUMMY_PHONE_ID = '1234' DUMMY_POWER_CONSUMPTION = 2780 DUMMY_REMAINING_TIME = '01:29:32' +# Adjust if any modification were made to DUMMY_DEVICE_NAME +SWITCH_ENTITY_ID = "switch.switcher_kis_device_name" + MANDATORY_CONFIGURATION = { DOMAIN: { CONF_PHONE_ID: DUMMY_PHONE_ID, diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 33d24903f94..b0d98dd6267 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -1,16 +1,32 @@ """Test cases for the switcher_kis component.""" -from typing import Any, Generator +from datetime import timedelta +from typing import Any, Generator, TYPE_CHECKING -from homeassistant.components.switcher_kis import (DOMAIN, DATA_DEVICE) +from pytest import raises + +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.components.switcher_kis import ( + CONF_AUTO_OFF, DOMAIN, DATA_DEVICE, SERVICE_SET_AUTO_OFF_NAME, + SERVICE_SET_AUTO_OFF_SCHEMA, SIGNAL_SWITCHER_DEVICE_UPDATE) +from homeassistant.core import callback, Context +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import async_mock_service, async_fire_time_changed from .consts import ( DUMMY_AUTO_OFF_SET, DUMMY_DEVICE_ID, DUMMY_DEVICE_NAME, DUMMY_DEVICE_STATE, DUMMY_ELECTRIC_CURRENT, DUMMY_IP_ADDRESS, DUMMY_MAC_ADDRESS, DUMMY_PHONE_ID, DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION) + DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION, SWITCH_ENTITY_ID) + +if TYPE_CHECKING: + from tests.common import MockUser + from aioswitcher.devices import SwitcherV2Device async def test_failed_config( @@ -49,3 +65,81 @@ async def test_discovery_data_bucket( assert device.power_consumption == DUMMY_POWER_CONSUMPTION assert device.electric_current == DUMMY_ELECTRIC_CURRENT assert device.phone_id == DUMMY_PHONE_ID + + +async def test_set_auto_off_service( + hass: HomeAssistantType, mock_bridge: Generator[None, Any, None], + mock_api: Generator[None, Any, None], hass_owner_user: 'MockUser', + hass_read_only_user: 'MockUser') -> None: + """Test the set_auto_off service.""" + assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) + + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_SET_AUTO_OFF_NAME) + + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_owner_user.id)) + + with raises(Unauthorized) as unauthorized_read_only_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_read_only_user.id)) + + assert unauthorized_read_only_exc.type is Unauthorized + + with raises(Unauthorized) as unauthorized_wrong_entity_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: "light.not_related_entity", + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id=hass_owner_user.id)) + + assert unauthorized_wrong_entity_exc.type is Unauthorized + + with raises(UnknownUser) as unknown_user_exc: + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, context=Context(user_id="not_real_user")) + + assert unknown_user_exc.type is UnknownUser + + service_calls = async_mock_service( + hass, DOMAIN, SERVICE_SET_AUTO_OFF_NAME, SERVICE_SET_AUTO_OFF_SCHEMA) + + await hass.services.async_call( + DOMAIN, SERVICE_SET_AUTO_OFF_NAME, + {CONF_ENTITY_ID: SWITCH_ENTITY_ID, + CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}) + + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert str(service_calls[0].data[CONF_AUTO_OFF]) \ + == DUMMY_AUTO_OFF_SET.lstrip('0') + + +async def test_signal_dispatcher( + hass: HomeAssistantType, + mock_bridge: Generator[None, Any, None]) -> None: + """Test signal dispatcher dispatching device updates every 4 seconds.""" + assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) + + await hass.async_block_till_done() + + @callback + def verify_update_data(device: 'SwitcherV2Device') -> None: + """Use as callback for signal dispatcher.""" + pass + + async_dispatcher_connect( + hass, SIGNAL_SWITCHER_DEVICE_UPDATE, verify_update_data) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=5))