From 8dc06e612fc3e1fe5ea2b6cd9dc493f917b6d6ff Mon Sep 17 00:00:00 2001 From: Joakim Plate <elupus@ecce.se> Date: Thu, 11 Feb 2021 21:37:53 +0100 Subject: [PATCH] Add config flow to philips_js (#45784) * Add config flow to philips_js * Adjust name of entry to contain serial * Use device id in event rather than entity id * Adjust turn on text * Deprecate all fields * Be somewhat more explicit in typing * Switch to direct coordinator access * Refactor the pluggable action * Adjust tests a bit * Minor adjustment * More adjustments * Add missing await in update coordinator * Be more lenient to lack of system info * Use constant for trigger type and simplify * Apply suggestions from code review Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: J. Nick Koston <nick@koston.org> --- .coveragerc | 1 + .../components/philips_js/__init__.py | 132 +++++++++++- .../components/philips_js/config_flow.py | 90 +++++++++ homeassistant/components/philips_js/const.py | 4 + .../components/philips_js/device_trigger.py | 65 ++++++ .../components/philips_js/manifest.json | 11 +- .../components/philips_js/media_player.py | 189 ++++++++++-------- .../components/philips_js/strings.json | 24 +++ .../philips_js/translations/en.json | 24 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/philips_js/__init__.py | 25 +++ tests/components/philips_js/conftest.py | 62 ++++++ .../components/philips_js/test_config_flow.py | 105 ++++++++++ .../philips_js/test_device_trigger.py | 69 +++++++ 16 files changed, 721 insertions(+), 86 deletions(-) create mode 100644 homeassistant/components/philips_js/config_flow.py create mode 100644 homeassistant/components/philips_js/const.py create mode 100644 homeassistant/components/philips_js/device_trigger.py create mode 100644 homeassistant/components/philips_js/strings.json create mode 100644 homeassistant/components/philips_js/translations/en.json create mode 100644 tests/components/philips_js/__init__.py create mode 100644 tests/components/philips_js/conftest.py create mode 100644 tests/components/philips_js/test_config_flow.py create mode 100644 tests/components/philips_js/test_device_trigger.py diff --git a/.coveragerc b/.coveragerc index cd1d6a9f6d3..c17f3d1057d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -704,6 +704,7 @@ omit = homeassistant/components/pandora/media_player.py homeassistant/components/pcal9535a/* homeassistant/components/pencom/switch.py + homeassistant/components/philips_js/__init__.py homeassistant/components/philips_js/media_player.py homeassistant/components/pi_hole/sensor.py homeassistant/components/pi4ioe5v9xxxx/binary_sensor.py diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 4b011c9f207..bfb99898ab2 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -1 +1,131 @@ -"""The philips_js component.""" +"""The Philips TV integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Callable, Dict, Optional + +from haphilipsjs import ConnectionFailure, PhilipsTV + +from homeassistant.components.automation import AutomationActionType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_VERSION, CONF_HOST +from homeassistant.core import Context, HassJob, HomeAssistant, callback +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS = ["media_player"] + +LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Philips TV component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Philips TV from a config entry.""" + + tvapi = PhilipsTV(entry.data[CONF_HOST], entry.data[CONF_API_VERSION]) + + coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi) + + await coordinator.async_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class PluggableAction: + """A pluggable action handler.""" + + _actions: Dict[Any, AutomationActionType] = {} + + def __init__(self, update: Callable[[], None]): + """Initialize.""" + self._update = update + + def __bool__(self): + """Return if we have something attached.""" + return bool(self._actions) + + @callback + def async_attach(self, action: AutomationActionType, variables: Dict[str, Any]): + """Attach a device trigger for turn on.""" + + @callback + def _remove(): + del self._actions[_remove] + self._update() + + job = HassJob(action) + + self._actions[_remove] = (job, variables) + self._update() + + return _remove + + async def async_run( + self, hass: HomeAssistantType, context: Optional[Context] = None + ): + """Run all turn on triggers.""" + for job, variables in self._actions.values(): + hass.async_run_hass_job(job, variables, context) + + +class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator to update data.""" + + api: PhilipsTV + + def __init__(self, hass, api: PhilipsTV) -> None: + """Set up the coordinator.""" + self.api = api + + def _update_listeners(): + for update_callback in self._listeners: + update_callback() + + self.turn_on = PluggableAction(_update_listeners) + + async def _async_update(): + try: + await self.hass.async_add_executor_job(self.api.update) + except ConnectionFailure: + pass + + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_method=_async_update, + update_interval=timedelta(seconds=30), + request_refresh_debouncer=Debouncer( + hass, LOGGER, cooldown=2.0, immediate=False + ), + ) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py new file mode 100644 index 00000000000..71bc34688b4 --- /dev/null +++ b/homeassistant/components/philips_js/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Philips TV integration.""" +import logging +from typing import Any, Dict, Optional, TypedDict + +from haphilipsjs import ConnectionFailure, PhilipsTV +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_API_VERSION, CONF_HOST + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class FlowUserDict(TypedDict): + """Data for user step.""" + + host: str + api_version: int + + +async def validate_input(hass: core.HomeAssistant, data: FlowUserDict): + """Validate the user input allows us to connect.""" + hub = PhilipsTV(data[CONF_HOST], data[CONF_API_VERSION]) + + await hass.async_add_executor_job(hub.getSystem) + + if hub.system is None: + raise ConnectionFailure + + return hub.system + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Philips TV.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + _default = {} + + async def async_step_import(self, conf: Dict[str, Any]): + """Import a configuration from config.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == conf[CONF_HOST]: + return self.async_abort(reason="already_configured") + + return await self.async_step_user( + { + CONF_HOST: conf[CONF_HOST], + CONF_API_VERSION: conf[CONF_API_VERSION], + } + ) + + async def async_step_user(self, user_input: Optional[FlowUserDict] = None): + """Handle the initial step.""" + errors = {} + if user_input: + self._default = user_input + try: + system = await validate_input(self.hass, user_input) + except ConnectionFailure: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(system["serialnumber"]) + self._abort_if_unique_id_configured(updates=user_input) + + data = {**user_input, "system": system} + + return self.async_create_entry( + title=f"{system['name']} ({system['serialnumber']})", data=data + ) + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=self._default.get(CONF_HOST)): str, + vol.Required( + CONF_API_VERSION, default=self._default.get(CONF_API_VERSION) + ): vol.In([1, 6]), + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/philips_js/const.py b/homeassistant/components/philips_js/const.py new file mode 100644 index 00000000000..893766b0083 --- /dev/null +++ b/homeassistant/components/philips_js/const.py @@ -0,0 +1,4 @@ +"""The Philips TV constants.""" + +DOMAIN = "philips_js" +CONF_SYSTEM = "system" diff --git a/homeassistant/components/philips_js/device_trigger.py b/homeassistant/components/philips_js/device_trigger.py new file mode 100644 index 00000000000..ec1da0635db --- /dev/null +++ b/homeassistant/components/philips_js/device_trigger.py @@ -0,0 +1,65 @@ +"""Provides device automations for control of device.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry, async_get_registry +from homeassistant.helpers.typing import ConfigType + +from . import PhilipsTVDataUpdateCoordinator +from .const import DOMAIN + +TRIGGER_TYPE_TURN_ON = "turn_on" + +TRIGGER_TYPES = {TRIGGER_TYPE_TURN_ON} +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for device.""" + triggers = [] + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: TRIGGER_TYPE_TURN_ON, + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + registry: DeviceRegistry = await async_get_registry(hass) + if config[CONF_TYPE] == TRIGGER_TYPE_TURN_ON: + variables = { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": config[CONF_DEVICE_ID], + "description": f"philips_js '{config[CONF_TYPE]}' event", + } + } + + device = registry.async_get(config[CONF_DEVICE_ID]) + for config_entry_id in device.config_entries: + coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN].get( + config_entry_id + ) + if coordinator: + return coordinator.turn_on.async_attach(action, variables) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 74473827424..e41aa348732 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -2,6 +2,11 @@ "domain": "philips_js", "name": "Philips TV", "documentation": "https://www.home-assistant.io/integrations/philips_js", - "requirements": ["ha-philipsjs==0.0.8"], - "codeowners": ["@elupus"] -} + "requirements": [ + "ha-philipsjs==0.1.0" + ], + "codeowners": [ + "@elupus" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 7ccec14406a..2e263ea2891 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -1,11 +1,11 @@ """Media Player component to integrate TVs exposing the Joint Space API.""" -from datetime import timedelta -import logging +from typing import Any, Dict -from haphilipsjs import PhilipsTV import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.media_player import ( + DEVICE_CLASS_TV, PLATFORM_SCHEMA, BrowseMedia, MediaPlayerEntity, @@ -27,6 +27,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_STEP, ) from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.philips_js import PhilipsTVDataUpdateCoordinator from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, @@ -34,11 +35,13 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import call_later, track_time_interval -from homeassistant.helpers.script import Script +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) +from . import LOGGER as _LOGGER +from .const import CONF_SYSTEM, DOMAIN SUPPORT_PHILIPS_JS = ( SUPPORT_TURN_OFF @@ -54,24 +57,25 @@ SUPPORT_PHILIPS_JS = ( CONF_ON_ACTION = "turn_on_action" -DEFAULT_NAME = "Philips TV" DEFAULT_API_VERSION = "1" -DEFAULT_SCAN_INTERVAL = 30 - -DELAY_ACTION_DEFAULT = 2.0 -DELAY_ACTION_ON = 10.0 PREFIX_SEPARATOR = ": " PREFIX_SOURCE = "Input" PREFIX_CHANNEL = "Channel" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): cv.string, - vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_HOST), + cv.deprecated(CONF_NAME), + cv.deprecated(CONF_API_VERSION), + cv.deprecated(CONF_ON_ACTION), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Remove(CONF_NAME): cv.string, + vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): cv.string, + vol.Remove(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } + ), ) @@ -81,70 +85,69 @@ def _inverted(data): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Philips TV platform.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - api_version = config.get(CONF_API_VERSION) - turn_on_action = config.get(CONF_ON_ACTION) - - tvapi = PhilipsTV(host, api_version) - domain = __name__.split(".")[-2] - on_script = Script(hass, turn_on_action, name, domain) if turn_on_action else None - - add_entities([PhilipsTVMediaPlayer(tvapi, name, on_script)]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, + async_add_entities, +): + """Set up the configuration entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + PhilipsTVMediaPlayer( + coordinator, + config_entry.data[CONF_SYSTEM], + config_entry.unique_id or config_entry.entry_id, + ) + ] + ) -class PhilipsTVMediaPlayer(MediaPlayerEntity): +class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): """Representation of a Philips TV exposing the JointSpace API.""" - def __init__(self, tv: PhilipsTV, name: str, on_script: Script): + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + system: Dict[str, Any], + unique_id: str, + ): """Initialize the Philips TV.""" - self._tv = tv - self._name = name + self._tv = coordinator.api + self._coordinator = coordinator self._sources = {} self._channels = {} - self._on_script = on_script self._supports = SUPPORT_PHILIPS_JS - if self._on_script: - self._supports |= SUPPORT_TURN_ON - self._update_task = None + self._system = system + self._unique_id = unique_id + super().__init__(coordinator) + self._update_from_coordinator() - def _update_soon(self, delay): + def _update_soon(self): """Reschedule update task.""" - if self._update_task: - self._update_task() - self._update_task = None - - self.schedule_update_ha_state(force_refresh=False) - - def update_forced(event_time): - self.schedule_update_ha_state(force_refresh=True) - - def update_and_restart(event_time): - update_forced(event_time) - self._update_task = track_time_interval( - self.hass, update_forced, timedelta(seconds=DEFAULT_SCAN_INTERVAL) - ) - - call_later(self.hass, delay, update_and_restart) - - async def async_added_to_hass(self): - """Start running updates once we are added to hass.""" - await self.hass.async_add_executor_job(self._update_soon, 0) + self.hass.add_job(self.coordinator.async_request_refresh) @property def name(self): """Return the device name.""" - return self._name - - @property - def should_poll(self): - """Device should be polled.""" - return False + return self._system["name"] @property def supported_features(self): """Flag media player features that are supported.""" - return self._supports + supports = self._supports + if self._coordinator.turn_on: + supports |= SUPPORT_TURN_ON + return supports @property def state(self): @@ -178,7 +181,7 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): source_id = _inverted(self._sources).get(source) if source_id: self._tv.setSource(source_id) - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() @property def volume_level(self): @@ -190,47 +193,45 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): """Boolean if volume is currently muted.""" return self._tv.muted - def turn_on(self): + async def async_turn_on(self): """Turn on the device.""" - if self._on_script: - self._on_script.run(context=self._context) - self._update_soon(DELAY_ACTION_ON) + await self._coordinator.turn_on.async_run(self.hass, self._context) def turn_off(self): """Turn off the device.""" self._tv.sendKey("Standby") self._tv.on = False - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() def volume_up(self): """Send volume up command.""" self._tv.sendKey("VolumeUp") - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() def volume_down(self): """Send volume down command.""" self._tv.sendKey("VolumeDown") - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() def mute_volume(self, mute): """Send mute command.""" self._tv.setVolume(None, mute) - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() def set_volume_level(self, volume): """Set volume level, range 0..1.""" self._tv.setVolume(volume, self._tv.muted) - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() def media_previous_track(self): """Send rewind command.""" self._tv.sendKey("Previous") - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() def media_next_track(self): """Send fast forward command.""" self._tv.sendKey("Next") - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() @property def media_channel(self): @@ -267,6 +268,29 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): """Return the state attributes.""" return {"channel_list": list(self._channels.values())} + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TV + + @property + def unique_id(self): + """Return unique identifier if known.""" + return self._unique_id + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "name": self._system["name"], + "identifiers": { + (DOMAIN, self._unique_id), + }, + "model": self._system.get("model"), + "manufacturer": "Philips", + "sw_version": self._system.get("softwareversion"), + } + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) @@ -275,7 +299,7 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): channel_id = _inverted(self._channels).get(media_id) if channel_id: self._tv.setChannel(channel_id) - self._update_soon(DELAY_ACTION_DEFAULT) + self._update_soon() else: _LOGGER.error("Unable to find channel <%s>", media_id) else: @@ -308,10 +332,7 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): ], ) - def update(self): - """Get the latest data and update device state.""" - self._tv.update() - + def _update_from_coordinator(self): self._sources = { srcid: source.get("name") or f"Source {srcid}" for srcid, source in (self._tv.sources or {}).items() @@ -321,3 +342,9 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): chid: channel.get("name") or f"Channel {chid}" for chid, channel in (self._tv.channels or {}).items() } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_from_coordinator() + super()._handle_coordinator_update() diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json new file mode 100644 index 00000000000..2267315501f --- /dev/null +++ b/homeassistant/components/philips_js/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "api_version": "API Version" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Device is requested to turn on" + } + } +} diff --git a/homeassistant/components/philips_js/translations/en.json b/homeassistant/components/philips_js/translations/en.json new file mode 100644 index 00000000000..ca580159dab --- /dev/null +++ b/homeassistant/components/philips_js/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "api_version": "API Version" + } + } + } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Device is requested to turn on" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6366a3eb887..06e2516633e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -159,6 +159,7 @@ FLOWS = [ "owntracks", "ozw", "panasonic_viera", + "philips_js", "pi_hole", "plaato", "plex", diff --git a/requirements_all.txt b/requirements_all.txt index 12ade1a446b..4dbee8b0a65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -720,7 +720,7 @@ guppy3==3.1.0 ha-ffmpeg==3.0.2 # homeassistant.components.philips_js -ha-philipsjs==0.0.8 +ha-philipsjs==0.1.0 # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5a629a771f..88e8dffff1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -380,6 +380,9 @@ guppy3==3.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 +# homeassistant.components.philips_js +ha-philipsjs==0.1.0 + # homeassistant.components.hangouts hangups==0.4.11 diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py new file mode 100644 index 00000000000..1c96a6d4e55 --- /dev/null +++ b/tests/components/philips_js/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the Philips TV integration.""" + +MOCK_SERIAL_NO = "1234567890" +MOCK_NAME = "Philips TV" + +MOCK_SYSTEM = { + "menulanguage": "English", + "name": MOCK_NAME, + "country": "Sweden", + "serialnumber": MOCK_SERIAL_NO, + "softwareversion": "abcd", + "model": "modelname", +} + +MOCK_USERINPUT = { + "host": "1.1.1.1", + "api_version": 1, +} + +MOCK_CONFIG = { + **MOCK_USERINPUT, + "system": MOCK_SYSTEM, +} + +MOCK_ENTITY_ID = "media_player.philips_tv" diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py new file mode 100644 index 00000000000..1f20cd821a5 --- /dev/null +++ b/tests/components/philips_js/conftest.py @@ -0,0 +1,62 @@ +"""Standard setup for tests.""" +from unittest.mock import Mock, patch + +from pytest import fixture + +from homeassistant import setup +from homeassistant.components.philips_js.const import DOMAIN + +from . import MOCK_CONFIG, MOCK_ENTITY_ID, MOCK_NAME, MOCK_SERIAL_NO, MOCK_SYSTEM + +from tests.common import MockConfigEntry, mock_device_registry + + +@fixture(autouse=True) +async def setup_notification(hass): + """Configure notification system.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + +@fixture(autouse=True) +def mock_tv(): + """Disable component actual use.""" + tv = Mock(autospec="philips_js.PhilipsTV") + tv.sources = {} + tv.channels = {} + tv.system = MOCK_SYSTEM + + with patch( + "homeassistant.components.philips_js.config_flow.PhilipsTV", return_value=tv + ), patch("homeassistant.components.philips_js.PhilipsTV", return_value=tv): + yield tv + + +@fixture +async def mock_config_entry(hass): + """Get standard player.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, title=MOCK_NAME) + config_entry.add_to_hass(hass) + return config_entry + + +@fixture +def mock_device_reg(hass): + """Get standard device.""" + return mock_device_registry(hass) + + +@fixture +async def mock_entity(hass, mock_device_reg, mock_config_entry): + """Get standard player.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield MOCK_ENTITY_ID + + +@fixture +def mock_device(hass, mock_device_reg, mock_entity, mock_config_entry): + """Get standard device.""" + return mock_device_reg.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, MOCK_SERIAL_NO)}, + ) diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py new file mode 100644 index 00000000000..75caff78891 --- /dev/null +++ b/tests/components/philips_js/test_config_flow.py @@ -0,0 +1,105 @@ +"""Test the Philips TV config flow.""" +from unittest.mock import patch + +from pytest import fixture + +from homeassistant import config_entries +from homeassistant.components.philips_js.const import DOMAIN + +from . import MOCK_CONFIG, MOCK_USERINPUT + + +@fixture(autouse=True) +def mock_setup(): + """Disable component setup.""" + with patch( + "homeassistant.components.philips_js.async_setup", return_value=True + ) as mock_setup: + yield mock_setup + + +@fixture(autouse=True) +def mock_setup_entry(): + """Disable component setup.""" + with patch( + "homeassistant.components.philips_js.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_import(hass, mock_setup, mock_setup_entry): + """Test we get an item on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_USERINPUT, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "Philips TV (1234567890)" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_exist(hass, mock_config_entry): + """Test we get an item on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_USERINPUT, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_form(hass, mock_setup, mock_setup_entry): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Philips TV (1234567890)" + assert result2["data"] == MOCK_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass, mock_tv): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_tv.system = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USERINPUT + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected_error(hass, mock_tv): + """Test we handle unexpected exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_tv.getSystem.side_effect = Exception("Unexpected exception") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USERINPUT + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py new file mode 100644 index 00000000000..43c7c424cf9 --- /dev/null +++ b/tests/components/philips_js/test_device_trigger.py @@ -0,0 +1,69 @@ +"""The tests for Philips TV device triggers.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.philips_js.const import DOMAIN +from homeassistant.setup import async_setup_component + +from tests.common import ( + assert_lists_same, + async_get_device_automations, + async_mock_service, +) +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers(hass, mock_device): + """Test we get the expected triggers.""" + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "turn_on", + "device_id": mock_device.id, + }, + ] + triggers = await async_get_device_automations(hass, "trigger", mock_device.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_turn_on_request(hass, calls, mock_entity, mock_device): + """Test for turn_on and turn_off triggers firing.""" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": mock_device.id, + "type": "turn_on", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "{{ trigger.device_id }}"}, + }, + } + ] + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": mock_entity}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == mock_device.id -- GitLab