From 42b6ec2fb545264d2b5923042c37f2f8f6e27807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gabriel?= Date: Sat, 18 Apr 2020 00:19:01 -0300 Subject: [PATCH] Add config flow to panasonic_viera component (#33829) * Updating the panasonic_viera component * Updating .coveragerc * Removing testplatform * Updating strings.json * Commit before rebase * Commit before rebase * Commit before rebase * Commit before rebase * Commit before rebase * Adding tests and stuff * Fixing permission issues * Ignoring Pylint warnings * Fixing one more Pylint warning * Refactoring * Commiting changes - part 1 * Commiting changes: part 2 * Turning unknown error logs into exception logs * Update strings.json * Rebasing * Updating the panasonic_viera component * Removing testplatform * Updating strings.json * Commit before rebase * Commit before rebase * Commit before rebase * Commit before rebase * Commit before rebase * Adding tests and stuff * Fixing permission issues * Ignoring Pylint warnings * Fixing one more Pylint warning * Refactoring * Commiting changes - part 1 * Commiting changes: part 2 * Turning unknown error logs into exception logs * Adding pt-BR translation * Removing Brazilian Portugues translations * Modifying error handling * Adding SOAPError to except handling * Updating translation * Refactoring async_step_import * Fixing indentation * Fixing requirements after rebase * Fixing translations * Fixing issues after rebase * Routing import step to user step * Adding myself as a codeowner --- .coveragerc | 1 + CODEOWNERS | 1 + .../panasonic_viera/.translations/en.json | 31 + .../components/panasonic_viera/__init__.py | 70 ++- .../components/panasonic_viera/config_flow.py | 152 +++++ .../components/panasonic_viera/const.py | 17 + .../components/panasonic_viera/manifest.json | 7 +- .../panasonic_viera/media_player.py | 360 ++++++----- .../components/panasonic_viera/strings.json | 31 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 +- requirements_test_all.txt | 4 +- tests/components/panasonic_viera/__init__.py | 1 + .../panasonic_viera/test_config_flow.py | 587 ++++++++++++++++++ 14 files changed, 1106 insertions(+), 160 deletions(-) create mode 100644 homeassistant/components/panasonic_viera/.translations/en.json create mode 100644 homeassistant/components/panasonic_viera/config_flow.py create mode 100644 homeassistant/components/panasonic_viera/const.py create mode 100644 homeassistant/components/panasonic_viera/strings.json create mode 100644 tests/components/panasonic_viera/__init__.py create mode 100644 tests/components/panasonic_viera/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 3e0e523f95f..b677f4fd840 100644 --- a/.coveragerc +++ b/.coveragerc @@ -523,6 +523,7 @@ 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/CODEOWNERS b/CODEOWNERS index 070aafd42db..2c4c3c4c70f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -279,6 +279,7 @@ homeassistant/components/openweathermap/* @fabaff homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu +homeassistant/components/panasonic_viera/* @joogps homeassistant/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/pcal9535a/* @Shulyaka diff --git a/homeassistant/components/panasonic_viera/.translations/en.json b/homeassistant/components/panasonic_viera/.translations/en.json new file mode 100644 index 00000000000..2af3ea71e45 --- /dev/null +++ b/homeassistant/components/panasonic_viera/.translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "This Panasonic Viera TV is already configured.", + "not_connected": "The remote connection with your Panasonic Viera TV was lost. Check the logs for more information.", + "unknown": "An unknown error occured. Check the logs for more information." + }, + "error": { + "invalid_pin_code": "The PIN code you entered was invalid", + "not_connected": "Could not establish a remote connection with your Panasonic Viera TV" + }, + "step": { + "pairing": { + "data": { + "pin": "PIN" + }, + "description": "Enter the PIN displayed on your TV", + "title": "Pairing" + }, + "user": { + "data": { + "host": "IP address", + "name": "Name" + }, + "description": "Enter your Panasonic Viera TV's IP address", + "title": "Setup your TV" + } + } + }, + "title": "Panasonic Viera" +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index bb63c98079e..60261712f4d 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -1 +1,69 @@ -"""The panasonic_viera component.""" +"""The Panasonic Viera integration.""" +import asyncio + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +import homeassistant.helpers.config_validation as cv + +from .const import CONF_ON_ACTION, DEFAULT_NAME, DEFAULT_PORT, DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["media_player"] + + +async def async_setup(hass, config): + """Set up Panasonic Viera from configuration.yaml.""" + if DOMAIN not in config: + return True + + for conf in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Panasonic Viera from a config entry.""" + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + return all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py new file mode 100644 index 00000000000..bcaa3bb090d --- /dev/null +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for Panasonic Viera TV integration.""" +from functools import partial +import logging +from urllib.request import URLError + +from panasonic_viera import TV_TYPE_ENCRYPTED, RemoteControl, SOAPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT + +from .const import ( # pylint: disable=unused-import + CONF_APP_ID, + CONF_ENCRYPTION_KEY, + CONF_ON_ACTION, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + ERROR_INVALID_PIN_CODE, + ERROR_NOT_CONNECTED, + REASON_NOT_CONNECTED, + REASON_UNKNOWN, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Panasonic Viera.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the Panasonic Viera config flow.""" + self._data = { + CONF_HOST: None, + CONF_NAME: None, + CONF_PORT: None, + CONF_ON_ACTION: None, + } + + self._remote = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + await self.async_load_data(user_input) + try: + self._remote = await self.hass.async_add_executor_job( + partial(RemoteControl, self._data[CONF_HOST], self._data[CONF_PORT]) + ) + except (TimeoutError, URLError, SOAPError, OSError) as err: + _LOGGER.error("Could not establish remote connection: %s", err) + errors["base"] = ERROR_NOT_CONNECTED + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("An unknown error occurred: %s", err) + return self.async_abort(reason=REASON_UNKNOWN) + + if "base" not in errors: + if self._remote.type == TV_TYPE_ENCRYPTED: + return await self.async_step_pairing() + + return self.async_create_entry( + title=self._data[CONF_NAME], data=self._data, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=self._data[CONF_HOST] + if self._data[CONF_HOST] is not None + else "", + ): str, + vol.Optional( + CONF_NAME, + default=self._data[CONF_NAME] + if self._data[CONF_NAME] is not None + else DEFAULT_NAME, + ): str, + } + ), + errors=errors, + ) + + async def async_step_pairing(self, user_input=None): + """Handle the pairing step.""" + errors = {} + + if user_input is not None: + pin = user_input[CONF_PIN] + try: + self._remote.authorize_pin_code(pincode=pin) + except SOAPError as err: + _LOGGER.error("Invalid PIN code: %s", err) + errors["base"] = ERROR_INVALID_PIN_CODE + except (TimeoutError, URLError, OSError) as err: + _LOGGER.error("The remote connection was lost: %s", err) + return self.async_abort(reason=REASON_NOT_CONNECTED) + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unknown error: %s", err) + return self.async_abort(reason=REASON_UNKNOWN) + + if "base" not in errors: + encryption_data = { + CONF_APP_ID: self._remote.app_id, + CONF_ENCRYPTION_KEY: self._remote.enc_key, + } + + self._data = {**self._data, **encryption_data} + + return self.async_create_entry( + title=self._data[CONF_NAME], data=self._data, + ) + + try: + self._remote.request_pin_code(name="Home Assistant") + except (TimeoutError, URLError, SOAPError, OSError) as err: + _LOGGER.error("The remote connection was lost: %s", err) + return self.async_abort(reason=REASON_NOT_CONNECTED) + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unknown error: %s", err) + return self.async_abort(reason=REASON_UNKNOWN) + + return self.async_show_form( + step_id="pairing", + data_schema=vol.Schema({vol.Required(CONF_PIN): str}), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(user_input=import_config) + + async def async_load_data(self, config): + """Load the data.""" + self._data = config + + self._data[CONF_PORT] = ( + self._data[CONF_PORT] if CONF_PORT in self._data else DEFAULT_PORT + ) + self._data[CONF_ON_ACTION] = ( + self._data[CONF_ON_ACTION] if CONF_ON_ACTION in self._data else None + ) + + await self.async_set_unique_id(self._data[CONF_HOST]) + self._abort_if_unique_id_configured() diff --git a/homeassistant/components/panasonic_viera/const.py b/homeassistant/components/panasonic_viera/const.py new file mode 100644 index 00000000000..434d2d3d7c4 --- /dev/null +++ b/homeassistant/components/panasonic_viera/const.py @@ -0,0 +1,17 @@ +"""Constants for the Panasonic Viera integration.""" +DOMAIN = "panasonic_viera" + +DEVICE_MANUFACTURER = "Panasonic" + +CONF_ON_ACTION = "turn_on_action" +CONF_APP_ID = "app_id" +CONF_ENCRYPTION_KEY = "encryption_key" + +DEFAULT_NAME = "Panasonic Viera TV" +DEFAULT_PORT = 55000 + +ERROR_NOT_CONNECTED = "not_connected" +ERROR_INVALID_PIN_CODE = "invalid_pin_code" + +REASON_NOT_CONNECTED = "not_connected" +REASON_UNKNOWN = "unknown" diff --git a/homeassistant/components/panasonic_viera/manifest.json b/homeassistant/components/panasonic_viera/manifest.json index 674c720ec6a..d046d742e93 100644 --- a/homeassistant/components/panasonic_viera/manifest.json +++ b/homeassistant/components/panasonic_viera/manifest.json @@ -1,7 +1,8 @@ { "domain": "panasonic_viera", - "name": "Panasonic Viera TV", + "name": "Panasonic Viera", "documentation": "https://www.home-assistant.io/integrations/panasonic_viera", - "requirements": ["panasonic_viera==0.3.2", "wakeonlan==1.1.6"], - "codeowners": [] + "requirements": ["panasonic_viera==0.3.5"], + "codeowners": ["@joogps"], + "config_flow": true } diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index d0615edfc33..abfb10e11f3 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -1,11 +1,11 @@ """Support for interface with a Panasonic Viera TV.""" +from functools import partial import logging +from urllib.request import URLError -from panasonic_viera import RemoteControl -import voluptuous as vol -import wakeonlan +from panasonic_viera import EncryptionRequired, Keys, RemoteControl, SOAPError -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( MEDIA_TYPE_URL, SUPPORT_NEXT_TRACK, @@ -20,25 +20,10 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) -from homeassistant.const import ( - CONF_BROADCAST_ADDRESS, - CONF_HOST, - CONF_MAC, - CONF_NAME, - CONF_PORT, - STATE_OFF, - STATE_ON, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON +from homeassistant.helpers.script import Script -_LOGGER = logging.getLogger(__name__) - -CONF_APP_POWER = "app_power" - -DEFAULT_NAME = "Panasonic Viera TV" -DEFAULT_PORT = 55000 -DEFAULT_BROADCAST_ADDRESS = "255.255.255.255" -DEFAULT_APP_POWER = False +from .const import CONF_APP_ID, CONF_ENCRYPTION_KEY, CONF_ON_ACTION SUPPORT_VIERATV = ( SUPPORT_PAUSE @@ -48,99 +33,58 @@ SUPPORT_VIERATV = ( | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA | SUPPORT_STOP ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MAC): cv.string, - vol.Optional( - CONF_BROADCAST_ADDRESS, default=DEFAULT_BROADCAST_ADDRESS - ): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_APP_POWER, default=DEFAULT_APP_POWER): cv.boolean, - } -) +_LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Panasonic Viera TV platform.""" - mac = config.get(CONF_MAC) - broadcast = config.get(CONF_BROADCAST_ADDRESS) - name = config.get(CONF_NAME) - port = config.get(CONF_PORT) - app_power = config.get(CONF_APP_POWER) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Panasonic Viera TV from a config entry.""" - if discovery_info: - _LOGGER.debug("%s", discovery_info) - name = discovery_info.get("name") - host = discovery_info.get("host") - port = discovery_info.get("port") - udn = discovery_info.get("udn") - if udn and udn.startswith("uuid:"): - uuid = udn[len("uuid:") :] - else: - uuid = None - remote = RemoteControl(host, port) - add_entities([PanasonicVieraTVDevice(mac, name, remote, host, app_power, uuid)]) - return True + config = config_entry.data - host = config.get(CONF_HOST) - remote = RemoteControl(host, port) + host = config[CONF_HOST] + port = config[CONF_PORT] + name = config[CONF_NAME] - add_entities( - [PanasonicVieraTVDevice(mac, name, remote, host, broadcast, app_power)] - ) - return True + 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) + + async_add_entities([tv_device]) class PanasonicVieraTVDevice(MediaPlayerDevice): """Representation of a Panasonic Viera TV.""" - def __init__(self, mac, name, remote, host, broadcast, app_power, uuid=None): + def __init__( + self, remote, name, uuid=None, + ): """Initialize the Panasonic device.""" # Save a reference to the imported class - self._wol = wakeonlan - self._mac = mac + self._remote = remote self._name = name self._uuid = uuid - self._muted = False - self._playing = True - self._state = None - self._remote = remote - self._host = host - self._broadcast = broadcast - self._volume = 0 - self._app_power = app_power @property def unique_id(self) -> str: """Return the unique ID of this Viera TV.""" return self._uuid - def update(self): - """Retrieve the latest data.""" - try: - self._muted = self._remote.get_mute() - self._volume = self._remote.get_volume() / 100 - self._state = STATE_ON - except OSError: - self._state = STATE_OFF - - def send_key(self, key): - """Send a key to the tv and handles exceptions.""" - try: - self._remote.send_key(key) - self._state = STATE_ON - except OSError: - self._state = STATE_OFF - return False - return True - @property def name(self): """Return the name of the device.""" @@ -149,98 +93,208 @@ class PanasonicVieraTVDevice(MediaPlayerDevice): @property def state(self): """Return the state of the device.""" - return self._state + return self._remote.state + + @property + def available(self): + """Return if True the device is available.""" + return self._remote.available @property def volume_level(self): """Volume level of the media player (0..1).""" - return self._volume + return self._remote.volume @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - return self._muted + return self._remote.muted @property def supported_features(self): """Flag media player features that are supported.""" - if self._mac or self._app_power: - return SUPPORT_VIERATV | SUPPORT_TURN_ON return SUPPORT_VIERATV - def turn_on(self): + async def async_update(self): + """Retrieve the latest data.""" + await self._remote.async_update() + + async def async_turn_on(self): """Turn on the media player.""" - if self._mac: - self._wol.send_magic_packet(self._mac, ip_address=self._broadcast) - self._state = STATE_ON - elif self._app_power: - self._remote.turn_on() - self._state = STATE_ON + await self._remote.async_turn_on() - def turn_off(self): + async def async_turn_off(self): """Turn off media player.""" - if self._state != STATE_OFF: - self._remote.turn_off() - self._state = STATE_OFF + await self._remote.async_turn_off() - def volume_up(self): + async def async_volume_up(self): """Volume up the media player.""" - self._remote.volume_up() + await self._remote.async_send_key(Keys.volume_up) - def volume_down(self): + async def async_volume_down(self): """Volume down media player.""" - self._remote.volume_down() + await self._remote.async_send_key(Keys.volume_down) - def mute_volume(self, mute): + async def async_mute_volume(self, mute): """Send mute command.""" - self._remote.set_mute(mute) + await self._remote.async_set_mute(mute) - def set_volume_level(self, volume): + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._remote.async_set_volume(volume) + + async def async_media_play_pause(self): + """Simulate play pause media player.""" + if self._remote.playing: + await self._remote.async_send_key(Keys.pause) + self._remote.playing = False + else: + await self._remote.async_send_key(Keys.play) + self._remote.playing = True + + async def async_media_play(self): + """Send play command.""" + await self._remote.async_send_key(Keys.play) + self._remote.playing = True + + async def async_media_pause(self): + """Send pause command.""" + await self._remote.async_send_key(Keys.pause) + self._remote.playing = False + + async def async_media_stop(self): + """Stop playback.""" + await self._remote.async_send_key(Keys.stop) + + async def async_media_next_track(self): + """Send the fast forward command.""" + await self._remote.async_send_key(Keys.fast_forward) + + async def async_media_previous_track(self): + """Send the rewind command.""" + await self._remote.async_send_key(Keys.rewind) + + 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) - try: - self._remote.set_volume(volume) - self._state = STATE_ON - except OSError: - self._state = STATE_OFF + await self._handle_errors(self._control.set_volume, volume) - def media_play_pause(self): - """Simulate play pause media player.""" - if self._playing: - self.media_pause() - else: - self.media_play() - - def media_play(self): - """Send play command.""" - self._playing = True - self._remote.media_play() - - def media_pause(self): - """Send media pause command to media player.""" - self._playing = False - self._remote.media_pause() - - def media_next_track(self): - """Send next track command.""" - self._remote.media_next_track() - - def media_previous_track(self): - """Send the previous track command.""" - self._remote.media_previous_track() - - def play_media(self, media_type, media_id, **kwargs): + 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: - try: - self._remote.open_webpage(media_id) - except (TimeoutError, OSError): - self._state = STATE_OFF - else: + if media_type != MEDIA_TYPE_URL: _LOGGER.warning("Unsupported media_type: %s", media_type) + return - def media_stop(self): - """Stop playback.""" - self.send_key("NRC_CANCEL-ONOFF") + 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() diff --git a/homeassistant/components/panasonic_viera/strings.json b/homeassistant/components/panasonic_viera/strings.json new file mode 100644 index 00000000000..9835d1b5d93 --- /dev/null +++ b/homeassistant/components/panasonic_viera/strings.json @@ -0,0 +1,31 @@ +{ + "title": "Panasonic Viera", + "config": { + "step": { + "user": { + "title": "Setup your TV", + "description": "Enter your Panasonic Viera TV's IP address", + "data": { + "host": "IP address", + "name": "Name" + } + }, + "pairing": { + "title": "Pairing", + "description": "Enter the PIN displayed on your TV", + "data": { + "pin": "PIN" + } + } + }, + "error": { + "not_connected": "Could not establish a remote connection with your Panasonic Viera TV", + "invalid_pin_code": "The PIN code you entered was invalid" + }, + "abort": { + "already_configured": "This Panasonic Viera TV is already configured.", + "not_connected": "The remote connection with your Panasonic Viera TV was lost. Check the logs for more information.", + "unknown": "An unknown error occured. Check the logs for more information." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 975b62b3e99..ca997c05fb5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -89,6 +89,7 @@ FLOWS = [ "opentherm_gw", "openuv", "owntracks", + "panasonic_viera", "plaato", "plex", "point", diff --git a/requirements_all.txt b/requirements_all.txt index 8bf58231c6d..370d2eabbd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1000,7 +1000,7 @@ paho-mqtt==1.5.0 panacotta==0.1 # homeassistant.components.panasonic_viera -panasonic_viera==0.3.2 +panasonic_viera==0.3.5 # homeassistant.components.pcal9535a pcal9535a==0.7 @@ -2105,7 +2105,6 @@ vtjp==0.1.14 # homeassistant.components.vultr vultr==0.1.2 -# homeassistant.components.panasonic_viera # homeassistant.components.wake_on_lan wakeonlan==1.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5022d7b6ad..b4df6aff07b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,6 +383,9 @@ openerz-api==0.1.0 # homeassistant.components.shiftr paho-mqtt==1.5.0 +# homeassistant.components.panasonic_viera +panasonic_viera==0.3.5 + # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora @@ -790,7 +793,6 @@ vsure==1.5.4 # homeassistant.components.vultr vultr==0.1.2 -# homeassistant.components.panasonic_viera # homeassistant.components.wake_on_lan wakeonlan==1.1.6 diff --git a/tests/components/panasonic_viera/__init__.py b/tests/components/panasonic_viera/__init__.py new file mode 100644 index 00000000000..55762f794fd --- /dev/null +++ b/tests/components/panasonic_viera/__init__.py @@ -0,0 +1 @@ +"""Tests for the Panasonic Viera component.""" diff --git a/tests/components/panasonic_viera/test_config_flow.py b/tests/components/panasonic_viera/test_config_flow.py new file mode 100644 index 00000000000..fd2738508ea --- /dev/null +++ b/tests/components/panasonic_viera/test_config_flow.py @@ -0,0 +1,587 @@ +"""Test the Panasonic Viera config flow.""" +from unittest.mock import Mock + +from asynctest import patch +from panasonic_viera import TV_TYPE_ENCRYPTED, TV_TYPE_NONENCRYPTED, SOAPError +import pytest + +from homeassistant import config_entries +from homeassistant.components.panasonic_viera.const import ( + CONF_APP_ID, + CONF_ENCRYPTION_KEY, + CONF_ON_ACTION, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + ERROR_INVALID_PIN_CODE, + ERROR_NOT_CONNECTED, + REASON_NOT_CONNECTED, + REASON_UNKNOWN, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="panasonic_viera_setup", autouse=True) +def panasonic_viera_setup_fixture(): + """Mock panasonic_viera setup.""" + with patch( + "homeassistant.components.panasonic_viera.async_setup", return_value=True + ), patch( + "homeassistant.components.panasonic_viera.async_setup_entry", return_value=True, + ): + yield + + +def get_mock_remote( + host="1.2.3.4", + authorize_error=None, + encrypted=False, + app_id=None, + encryption_key=None, +): + """Return a mock remote.""" + mock_remote = Mock() + + mock_remote.type = TV_TYPE_ENCRYPTED if encrypted else TV_TYPE_NONENCRYPTED + mock_remote.app_id = app_id + mock_remote.enc_key = encryption_key + + def request_pin_code(name=None): + return + + mock_remote.request_pin_code = request_pin_code + + def authorize_pin_code(pincode): + if pincode == "1234": + return + + if authorize_error is not None: + raise authorize_error + + mock_remote.authorize_pin_code = authorize_pin_code + + return mock_remote + + +async def test_flow_non_encrypted(hass): + """Test flow without encryption.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_remote = get_mock_remote(encrypted=False) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: None, + } + + +async def test_flow_not_connected_error(hass): + """Test flow with connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + side_effect=TimeoutError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": ERROR_NOT_CONNECTED} + + +async def test_flow_unknown_abort(hass): + """Test flow with unknown error abortion.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "abort" + assert result["reason"] == REASON_UNKNOWN + + +async def test_flow_encrypted_valid_pin_code(hass): + """Test flow with encryption and valid PIN code.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_remote = get_mock_remote( + encrypted=True, app_id="test-app-id", encryption_key="test-encryption-key", + ) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "1234"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: None, + CONF_APP_ID: "test-app-id", + CONF_ENCRYPTION_KEY: "test-encryption-key", + } + + +async def test_flow_encrypted_invalid_pin_code_error(hass): + """Test flow with encryption and invalid PIN code error during pairing step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_remote = get_mock_remote(encrypted=True, authorize_error=SOAPError) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "0000"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + assert result["errors"] == {"base": ERROR_INVALID_PIN_CODE} + + +async def test_flow_encrypted_not_connected_abort(hass): + """Test flow with encryption and PIN code connection error abortion during pairing step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_remote = get_mock_remote(encrypted=True, authorize_error=TimeoutError) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "0000"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == REASON_NOT_CONNECTED + + +async def test_flow_encrypted_unknown_abort(hass): + """Test flow with encryption and PIN code unknown error abortion during pairing step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_remote = get_mock_remote(encrypted=True, authorize_error=Exception) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "0000"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == REASON_UNKNOWN + + +async def test_flow_non_encrypted_already_configured_abort(hass): + """Test flow without encryption and existing config entry abortion.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id="1.2.3.4", + data={CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME, CONF_PORT: DEFAULT_PORT}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_flow_encrypted_already_configured_abort(hass): + """Test flow with encryption and existing config entry abortion.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id="1.2.3.4", + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_APP_ID: "test-app-id", + CONF_ENCRYPTION_KEY: "test-encryption-key", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_imported_flow_non_encrypted(hass): + """Test imported flow without encryption.""" + + mock_remote = get_mock_remote(encrypted=False) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + } + + +async def test_imported_flow_encrypted_valid_pin_code(hass): + """Test imported flow with encryption and valid PIN code.""" + + mock_remote = get_mock_remote( + encrypted=True, app_id="test-app-id", encryption_key="test-encryption-key", + ) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "1234"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + CONF_APP_ID: "test-app-id", + CONF_ENCRYPTION_KEY: "test-encryption-key", + } + + +async def test_imported_flow_encrypted_invalid_pin_code_error(hass): + """Test imported flow with encryption and invalid PIN code error during pairing step.""" + + mock_remote = get_mock_remote(encrypted=True, authorize_error=SOAPError) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "0000"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + assert result["errors"] == {"base": ERROR_INVALID_PIN_CODE} + + +async def test_imported_flow_encrypted_not_connected_abort(hass): + """Test imported flow with encryption and PIN code connection error abortion during pairing step.""" + + mock_remote = get_mock_remote(encrypted=True, authorize_error=TimeoutError) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "0000"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == REASON_NOT_CONNECTED + + +async def test_imported_flow_encrypted_unknown_abort(hass): + """Test imported flow with encryption and PIN code unknown error abortion during pairing step.""" + + mock_remote = get_mock_remote(encrypted=True, authorize_error=Exception) + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + return_value=mock_remote, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PIN: "0000"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == REASON_UNKNOWN + + +async def test_imported_flow_not_connected_error(hass): + """Test imported flow with connection error abortion.""" + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + side_effect=TimeoutError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": ERROR_NOT_CONNECTED} + + +async def test_imported_flow_unknown_abort(hass): + """Test imported flow with unknown error abortion.""" + + with patch( + "homeassistant.components.panasonic_viera.config_flow.RemoteControl", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == REASON_UNKNOWN + + +async def test_imported_flow_non_encrypted_already_configured_abort(hass): + """Test imported flow without encryption and existing config entry abortion.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id="1.2.3.4", + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_imported_flow_encrypted_already_configured_abort(hass): + """Test imported flow with encryption and existing config entry abortion.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id="1.2.3.4", + data={ + CONF_HOST: "1.2.3.4", + CONF_NAME: DEFAULT_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_ON_ACTION: "test-on-action", + CONF_APP_ID: "test-app-id", + CONF_ENCRYPTION_KEY: "test-encryption-key", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured"