diff --git a/.coveragerc b/.coveragerc index e2aa4243a46..fde7cb637f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -531,7 +531,6 @@ omit = homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/panasonic_bluray/media_player.py - homeassistant/components/panasonic_viera/__init__.py homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py homeassistant/components/pcal9535a/* diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 60261712f4d..ebc1c20a1fa 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -1,13 +1,29 @@ """The Panasonic Viera integration.""" import asyncio +from functools import partial +import logging +from urllib.request import URLError +from panasonic_viera import EncryptionRequired, Keys, RemoteControl, SOAPError import voluptuous as vol +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import Script -from .const import CONF_ON_ACTION, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import ( + ATTR_REMOTE, + CONF_APP_ID, + CONF_ENCRYPTION_KEY, + CONF_ON_ACTION, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { @@ -28,7 +44,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["media_player"] +PLATFORMS = [MEDIA_PLAYER_DOMAIN] async def async_setup(hass, config): @@ -49,6 +65,27 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up Panasonic Viera from a config entry.""" + panasonic_viera_data = hass.data.setdefault(DOMAIN, {}) + + config = config_entry.data + + host = config[CONF_HOST] + port = config[CONF_PORT] + + on_action = config[CONF_ON_ACTION] + if on_action is not None: + on_action = Script(hass, on_action) + + params = {} + if CONF_APP_ID in config and CONF_ENCRYPTION_KEY in config: + params["app_id"] = config[CONF_APP_ID] + params["encryption_key"] = config[CONF_ENCRYPTION_KEY] + + remote = Remote(hass, host, port, on_action, **params) + await remote.async_create_remote_control(during_setup=True) + + panasonic_viera_data[config_entry.entry_id] = {ATTR_REMOTE: remote} + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, component) @@ -59,7 +96,7 @@ async def async_setup_entry(hass, config_entry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - return all( + unload_ok = all( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(config_entry, component) @@ -67,3 +104,135 @@ async def async_unload_entry(hass, config_entry): ] ) ) + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class Remote: + """The Remote class. It stores the TV properties and the remote control connection itself.""" + + def __init__( + self, hass, host, port, on_action=None, app_id=None, encryption_key=None, + ): + """Initialize the Remote class.""" + self._hass = hass + + self._host = host + self._port = port + + self._on_action = on_action + + self._app_id = app_id + self._encryption_key = encryption_key + + self.state = None + self.available = False + self.volume = 0 + self.muted = False + self.playing = True + + self._control = None + + async def async_create_remote_control(self, during_setup=False): + """Create remote control.""" + control_existed = self._control is not None + try: + params = {} + if self._app_id and self._encryption_key: + params["app_id"] = self._app_id + params["encryption_key"] = self._encryption_key + + self._control = await self._hass.async_add_executor_job( + partial(RemoteControl, self._host, self._port, **params) + ) + + self.state = STATE_ON + self.available = True + except (TimeoutError, URLError, SOAPError, OSError) as err: + if control_existed or during_setup: + _LOGGER.debug("Could not establish remote connection: %s", err) + + self._control = None + self.state = STATE_OFF + self.available = self._on_action is not None + except Exception as err: # pylint: disable=broad-except + if control_existed or during_setup: + _LOGGER.exception("An unknown error occurred: %s", err) + self._control = None + self.state = STATE_OFF + self.available = self._on_action is not None + + async def async_update(self): + """Update device data.""" + if self._control is None: + await self.async_create_remote_control() + return + + await self._handle_errors(self._update) + + def _update(self): + """Retrieve the latest data.""" + self.muted = self._control.get_mute() + self.volume = self._control.get_volume() / 100 + + self.state = STATE_ON + self.available = True + + async def async_send_key(self, key): + """Send a key to the TV and handle exceptions.""" + try: + key = getattr(Keys, key) + except (AttributeError, TypeError): + key = getattr(key, "value", key) + + await self._handle_errors(self._control.send_key, key) + + async def async_turn_on(self): + """Turn on the TV.""" + if self._on_action is not None: + await self._on_action.async_run() + self.state = STATE_ON + elif self.state != STATE_ON: + await self.async_send_key(Keys.power) + self.state = STATE_ON + + async def async_turn_off(self): + """Turn off the TV.""" + if self.state != STATE_OFF: + await self.async_send_key(Keys.power) + self.state = STATE_OFF + await self.async_update() + + async def async_set_mute(self, enable): + """Set mute based on 'enable'.""" + await self._handle_errors(self._control.set_mute, enable) + + async def async_set_volume(self, volume): + """Set volume level, range 0..1.""" + volume = int(volume * 100) + await self._handle_errors(self._control.set_volume, volume) + + async def async_play_media(self, media_type, media_id): + """Play media.""" + _LOGGER.debug("Play media: %s (%s)", media_id, media_type) + await self._handle_errors(self._control.open_webpage, media_id) + + async def _handle_errors(self, func, *args): + """Handle errors from func, set available and reconnect if needed.""" + try: + return await self._hass.async_add_executor_job(func, *args) + except EncryptionRequired: + _LOGGER.error( + "The connection couldn't be encrypted. Please reconfigure your TV" + ) + except (TimeoutError, URLError, SOAPError, OSError): + self.state = STATE_OFF + self.available = self._on_action is not None + await self.async_create_remote_control() + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("An unknown error occurred: %s", err) + self.state = STATE_OFF + self.available = self._on_action is not None diff --git a/homeassistant/components/panasonic_viera/const.py b/homeassistant/components/panasonic_viera/const.py index 434d2d3d7c4..529de4ebe67 100644 --- a/homeassistant/components/panasonic_viera/const.py +++ b/homeassistant/components/panasonic_viera/const.py @@ -10,6 +10,8 @@ CONF_ENCRYPTION_KEY = "encryption_key" DEFAULT_NAME = "Panasonic Viera TV" DEFAULT_PORT = 55000 +ATTR_REMOTE = "remote" + ERROR_NOT_CONNECTED = "not_connected" ERROR_INVALID_PIN_CODE = "invalid_pin_code" diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index ce1d3762693..7260b519bf4 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -1,9 +1,7 @@ -"""Support for interface with a Panasonic Viera TV.""" -from functools import partial +"""Media player support for Panasonic Viera TV.""" import logging -from urllib.request import URLError -from panasonic_viera import EncryptionRequired, Keys, RemoteControl, SOAPError +from panasonic_viera import Keys from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( @@ -20,10 +18,9 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON -from homeassistant.helpers.script import Script +from homeassistant.const import CONF_NAME -from .const import CONF_APP_ID, CONF_ENCRYPTION_KEY, CONF_ON_ACTION +from .const import ATTR_REMOTE, DOMAIN SUPPORT_VIERATV = ( SUPPORT_PAUSE @@ -47,42 +44,25 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config = config_entry.data - host = config[CONF_HOST] - port = config[CONF_PORT] + remote = hass.data[DOMAIN][config_entry.entry_id][ATTR_REMOTE] name = config[CONF_NAME] - on_action = config[CONF_ON_ACTION] - if on_action is not None: - on_action = Script(hass, on_action) - - params = {} - if CONF_APP_ID in config and CONF_ENCRYPTION_KEY in config: - params["app_id"] = config[CONF_APP_ID] - params["encryption_key"] = config[CONF_ENCRYPTION_KEY] - - remote = Remote(hass, host, port, on_action, **params) - await remote.async_create_remote_control(during_setup=True) - - tv_device = PanasonicVieraTVDevice(remote, name) - + tv_device = PanasonicVieraTVEntity(remote, name) async_add_entities([tv_device]) -class PanasonicVieraTVDevice(MediaPlayerEntity): +class PanasonicVieraTVEntity(MediaPlayerEntity): """Representation of a Panasonic Viera TV.""" - def __init__( - self, remote, name, uuid=None, - ): - """Initialize the Panasonic device.""" - # Save a reference to the imported class + def __init__(self, remote, name, uuid=None): + """Initialize the entity.""" self._remote = remote self._name = name self._uuid = uuid @property - def unique_id(self) -> str: - """Return the unique ID of this Viera TV.""" + def unique_id(self): + """Return the unique ID of the device.""" return self._uuid @property @@ -97,7 +77,7 @@ class PanasonicVieraTVDevice(MediaPlayerEntity): @property def available(self): - """Return if True the device is available.""" + """Return True if the device is available.""" return self._remote.available @property @@ -176,125 +156,8 @@ class PanasonicVieraTVDevice(MediaPlayerEntity): async def async_play_media(self, media_type, media_id, **kwargs): """Play media.""" - await self._remote.async_play_media(media_type, media_id) - - -class Remote: - """The Remote class. It stores the TV properties and the remote control connection itself.""" - - def __init__( - self, hass, host, port, on_action=None, app_id=None, encryption_key=None, - ): - """Initialize the Remote class.""" - self._hass = hass - - self._host = host - self._port = port - - self._on_action = on_action - - self._app_id = app_id - self._encryption_key = encryption_key - - self.state = None - self.available = False - self.volume = 0 - self.muted = False - self.playing = True - - self._control = None - - async def async_create_remote_control(self, during_setup=False): - """Create remote control.""" - control_existed = self._control is not None - try: - params = {} - if self._app_id and self._encryption_key: - params["app_id"] = self._app_id - params["encryption_key"] = self._encryption_key - - self._control = await self._hass.async_add_executor_job( - partial(RemoteControl, self._host, self._port, **params) - ) - - self.state = STATE_ON - self.available = True - except (TimeoutError, URLError, SOAPError, OSError) as err: - if control_existed or during_setup: - _LOGGER.error("Could not establish remote connection: %s", err) - - self._control = None - self.state = STATE_OFF - self.available = self._on_action is not None - except Exception as err: # pylint: disable=broad-except - if control_existed or during_setup: - _LOGGER.exception("An unknown error occurred: %s", err) - self._control = None - self.state = STATE_OFF - self.available = self._on_action is not None - - async def async_update(self): - """Update device data.""" - if self._control is None: - await self.async_create_remote_control() - return - - await self._handle_errors(self._update) - - async def _update(self): - """Retrieve the latest data.""" - self.muted = self._control.get_mute() - self.volume = self._control.get_volume() / 100 - - self.state = STATE_ON - self.available = True - - async def async_send_key(self, key): - """Send a key to the TV and handle exceptions.""" - await self._handle_errors(self._control.send_key, key) - - async def async_turn_on(self): - """Turn on the TV.""" - if self._on_action is not None: - await self._on_action.async_run() - self.state = STATE_ON - elif self.state != STATE_ON: - await self.async_send_key(Keys.power) - self.state = STATE_ON - - async def async_turn_off(self): - """Turn off the TV.""" - if self.state != STATE_OFF: - await self.async_send_key(Keys.power) - self.state = STATE_OFF - await self.async_update() - - async def async_set_mute(self, enable): - """Set mute based on 'enable'.""" - await self._handle_errors(self._control.set_mute, enable) - - async def async_set_volume(self, volume): - """Set volume level, range 0..1.""" - volume = int(volume * 100) - await self._handle_errors(self._control.set_volume, volume) - - async def async_play_media(self, media_type, media_id): - """Play media.""" - _LOGGER.debug("Play media: %s (%s)", media_id, media_type) - if media_type != MEDIA_TYPE_URL: _LOGGER.warning("Unsupported media_type: %s", media_type) return - await self._handle_errors(self._control.open_webpage, media_id) - - async def _handle_errors(self, func, *args): - """Handle errors from func, set available and reconnect if needed.""" - try: - await self._hass.async_add_executor_job(func, *args) - except EncryptionRequired: - _LOGGER.error("The connection couldn't be encrypted") - except (TimeoutError, URLError, SOAPError, OSError): - self.state = STATE_OFF - self.available = self._on_action is not None - await self.async_create_remote_control() + await self._remote.async_play_media(media_type, media_id) diff --git a/tests/components/panasonic_viera/test_init.py b/tests/components/panasonic_viera/test_init.py new file mode 100644 index 00000000000..3e02ac3703a --- /dev/null +++ b/tests/components/panasonic_viera/test_init.py @@ -0,0 +1,123 @@ +"""Test the Panasonic Viera setup process.""" +from unittest.mock import Mock + +from asynctest import patch + +from homeassistant.components.panasonic_viera.const import ( + CONF_APP_ID, + CONF_ENCRYPTION_KEY, + CONF_ON_ACTION, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.config_entries import ENTRY_STATE_NOT_LOADED +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + CONF_HOST: "0.0.0.0", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: None, +} + +MOCK_ENCRYPTION_DATA = { + CONF_APP_ID: "mock-app-id", + CONF_ENCRYPTION_KEY: "mock-encryption-key", +} + + +def get_mock_remote(): + """Return a mock remote.""" + mock_remote = Mock() + + async def async_create_remote_control(during_setup=False): + return + + mock_remote.async_create_remote_control = async_create_remote_control + + return mock_remote + + +async def test_setup_entry_encrypted(hass): + """Test setup with encrypted config entry.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_CONFIG_DATA[CONF_HOST], + data={**MOCK_CONFIG_DATA, **MOCK_ENCRYPTION_DATA}, + ) + + mock_entry.add_to_hass(hass) + + mock_remote = get_mock_remote() + + with patch( + "homeassistant.components.panasonic_viera.Remote", return_value=mock_remote, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("media_player.panasonic_viera_tv") + + assert state + assert state.name == DEFAULT_NAME + + +async def test_setup_entry_unencrypted(hass): + """Test setup with unencrypted config entry.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, unique_id=MOCK_CONFIG_DATA[CONF_HOST], data=MOCK_CONFIG_DATA, + ) + + mock_entry.add_to_hass(hass) + + mock_remote = get_mock_remote() + + with patch( + "homeassistant.components.panasonic_viera.Remote", return_value=mock_remote, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("media_player.panasonic_viera_tv") + + assert state + assert state.name == DEFAULT_NAME + + +async def test_setup_config_flow_initiated(hass): + """Test if config flow is initiated in setup.""" + assert ( + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_HOST: "0.0.0.0"}},) + is True + ) + + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_setup_unload_entry(hass): + """Test if config entry is unloaded.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, unique_id=MOCK_CONFIG_DATA[CONF_HOST], data=MOCK_CONFIG_DATA + ) + + mock_entry.add_to_hass(hass) + + mock_remote = get_mock_remote() + + with patch( + "homeassistant.components.panasonic_viera.Remote", return_value=mock_remote, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + await hass.config_entries.async_unload(mock_entry.entry_id) + + assert mock_entry.state == ENTRY_STATE_NOT_LOADED + + state = hass.states.get("media_player.panasonic_viera_tv") + + assert state is None