Add support for v6 features to philips js integration (#46422)

This commit is contained in:
Joakim Plate 2021-02-26 18:34:40 +01:00 committed by GitHub
parent 7ab2d91bf0
commit e12eba1989
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 702 additions and 138 deletions

View File

@ -8,8 +8,13 @@ from haphilipsjs import ConnectionFailure, PhilipsTV
from homeassistant.components.automation import AutomationActionType from homeassistant.components.automation import AutomationActionType
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_VERSION, CONF_HOST from homeassistant.const import (
from homeassistant.core import Context, HassJob, HomeAssistant, callback CONF_API_VERSION,
CONF_HOST,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import CALLBACK_TYPE, Context, HassJob, HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@ -30,7 +35,12 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Philips TV from a config entry.""" """Set up Philips TV from a config entry."""
tvapi = PhilipsTV(entry.data[CONF_HOST], entry.data[CONF_API_VERSION]) tvapi = PhilipsTV(
entry.data[CONF_HOST],
entry.data[CONF_API_VERSION],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
)
coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi) coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi)
@ -103,7 +113,9 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]):
def __init__(self, hass, api: PhilipsTV) -> None: def __init__(self, hass, api: PhilipsTV) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""
self.api = api self.api = api
self._notify_future: Optional[asyncio.Task] = None
@callback
def _update_listeners(): def _update_listeners():
for update_callback in self._listeners: for update_callback in self._listeners:
update_callback() update_callback()
@ -120,9 +132,43 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]):
), ),
) )
async def _notify_task(self):
while self.api.on and self.api.notify_change_supported:
if await self.api.notifyChange(130):
self.async_set_updated_data(None)
@callback
def _async_notify_stop(self):
if self._notify_future:
self._notify_future.cancel()
self._notify_future = None
@callback
def _async_notify_schedule(self):
if (
(self._notify_future is None or self._notify_future.done())
and self.api.on
and self.api.notify_change_supported
):
self._notify_future = self.hass.loop.create_task(self._notify_task())
@callback
def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None:
"""Remove data update."""
super().async_remove_listener(update_callback)
if not self._listeners:
self._async_notify_stop()
@callback
def _async_stop_refresh(self, event: asyncio.Event) -> None:
super()._async_stop_refresh(event)
self._async_notify_stop()
@callback
async def _async_update_data(self): async def _async_update_data(self):
"""Fetch the latest data from the source.""" """Fetch the latest data from the source."""
try: try:
await self.hass.async_add_executor_job(self.api.update) await self.api.update()
self._async_notify_schedule()
except ConnectionFailure: except ConnectionFailure:
pass pass

View File

@ -1,35 +1,47 @@
"""Config flow for Philips TV integration.""" """Config flow for Philips TV integration."""
import logging import platform
from typing import Any, Dict, Optional, TypedDict from typing import Any, Dict, Optional, Tuple, TypedDict
from haphilipsjs import ConnectionFailure, PhilipsTV from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core from homeassistant import config_entries, core
from homeassistant.const import CONF_API_VERSION, CONF_HOST from homeassistant.const import (
CONF_API_VERSION,
CONF_HOST,
CONF_PASSWORD,
CONF_PIN,
CONF_USERNAME,
)
from .const import DOMAIN # pylint:disable=unused-import from . import LOGGER
from .const import ( # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__) CONF_SYSTEM,
CONST_APP_ID,
CONST_APP_NAME,
DOMAIN,
)
class FlowUserDict(TypedDict): class FlowUserDict(TypedDict):
"""Data for user step.""" """Data for user step."""
host: str host: str
api_version: int
async def validate_input(hass: core.HomeAssistant, data: FlowUserDict): async def validate_input(
hass: core.HomeAssistant, host: str, api_version: int
) -> Tuple[Dict, PhilipsTV]:
"""Validate the user input allows us to connect.""" """Validate the user input allows us to connect."""
hub = PhilipsTV(data[CONF_HOST], data[CONF_API_VERSION]) hub = PhilipsTV(host, api_version)
await hass.async_add_executor_job(hub.getSystem) await hub.getSystem()
await hub.setTransport(hub.secured_transport)
if hub.system is None: if not hub.system:
raise ConnectionFailure raise ConnectionFailure("System data is empty")
return hub.system return hub
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -38,7 +50,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
_default = {} _current = {}
_hub: PhilipsTV
_pair_state: Any
async def async_step_import(self, conf: Dict[str, Any]): async def async_step_import(self, conf: Dict[str, Any]):
"""Import a configuration from config.yaml.""" """Import a configuration from config.yaml."""
@ -53,34 +67,99 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
} }
) )
async def _async_create_current(self):
system = self._current[CONF_SYSTEM]
return self.async_create_entry(
title=f"{system['name']} ({system['serialnumber']})",
data=self._current,
)
async def async_step_pair(self, user_input: Optional[Dict] = None):
"""Attempt to pair with device."""
assert self._hub
errors = {}
schema = vol.Schema(
{
vol.Required(CONF_PIN): str,
}
)
if not user_input:
try:
self._pair_state = await self._hub.pairRequest(
CONST_APP_ID,
CONST_APP_NAME,
platform.node(),
platform.system(),
"native",
)
except PairingFailure as exc:
LOGGER.debug(str(exc))
return self.async_abort(
reason="pairing_failure",
description_placeholders={"error_id": exc.data.get("error_id")},
)
return self.async_show_form(
step_id="pair", data_schema=schema, errors=errors
)
try:
username, password = await self._hub.pairGrant(
self._pair_state, user_input[CONF_PIN]
)
except PairingFailure as exc:
LOGGER.debug(str(exc))
if exc.data.get("error_id") == "INVALID_PIN":
errors[CONF_PIN] = "invalid_pin"
return self.async_show_form(
step_id="pair", data_schema=schema, errors=errors
)
return self.async_abort(
reason="pairing_failure",
description_placeholders={"error_id": exc.data.get("error_id")},
)
self._current[CONF_USERNAME] = username
self._current[CONF_PASSWORD] = password
return await self._async_create_current()
async def async_step_user(self, user_input: Optional[FlowUserDict] = None): async def async_step_user(self, user_input: Optional[FlowUserDict] = None):
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors = {}
if user_input: if user_input:
self._default = user_input self._current = user_input
try: try:
system = await validate_input(self.hass, user_input) hub = await validate_input(
except ConnectionFailure: self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION]
)
except ConnectionFailure as exc:
LOGGER.error(str(exc))
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(system["serialnumber"])
self._abort_if_unique_id_configured(updates=user_input)
data = {**user_input, "system": system} await self.async_set_unique_id(hub.system["serialnumber"])
self._abort_if_unique_id_configured()
return self.async_create_entry( self._current[CONF_SYSTEM] = hub.system
title=f"{system['name']} ({system['serialnumber']})", data=data self._current[CONF_API_VERSION] = hub.api_version
) self._hub = hub
if hub.pairing_type == "digest_auth_pairing":
return await self.async_step_pair()
return await self._async_create_current()
schema = vol.Schema( schema = vol.Schema(
{ {
vol.Required(CONF_HOST, default=self._default.get(CONF_HOST)): str, vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str,
vol.Required( vol.Required(
CONF_API_VERSION, default=self._default.get(CONF_API_VERSION) CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1)
): vol.In([1, 6]), ): vol.In([1, 5, 6]),
} }
) )
return self.async_show_form(step_id="user", data_schema=schema, errors=errors) return self.async_show_form(step_id="user", data_schema=schema, errors=errors)

View File

@ -2,3 +2,6 @@
DOMAIN = "philips_js" DOMAIN = "philips_js"
CONF_SYSTEM = "system" CONF_SYSTEM = "system"
CONST_APP_ID = "homeassistant.io"
CONST_APP_NAME = "Home Assistant"

View File

@ -3,7 +3,7 @@
"name": "Philips TV", "name": "Philips TV",
"documentation": "https://www.home-assistant.io/integrations/philips_js", "documentation": "https://www.home-assistant.io/integrations/philips_js",
"requirements": [ "requirements": [
"ha-philipsjs==0.1.0" "ha-philipsjs==2.3.0"
], ],
"codeowners": [ "codeowners": [
"@elupus" "@elupus"

View File

@ -1,6 +1,7 @@
"""Media Player component to integrate TVs exposing the Joint Space API.""" """Media Player component to integrate TVs exposing the Joint Space API."""
from typing import Any, Dict from typing import Any, Dict, Optional
from haphilipsjs import ConnectionFailure
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -11,15 +12,21 @@ from homeassistant.components.media_player import (
MediaPlayerEntity, MediaPlayerEntity,
) )
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
MEDIA_CLASS_APP,
MEDIA_CLASS_CHANNEL, MEDIA_CLASS_CHANNEL,
MEDIA_CLASS_DIRECTORY, MEDIA_CLASS_DIRECTORY,
MEDIA_TYPE_APP,
MEDIA_TYPE_APPS,
MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_CHANNELS, MEDIA_TYPE_CHANNELS,
SUPPORT_BROWSE_MEDIA, SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK, SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA, SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK, SUPPORT_PREVIOUS_TRACK,
SUPPORT_SELECT_SOURCE, SUPPORT_SELECT_SOURCE,
SUPPORT_STOP,
SUPPORT_TURN_OFF, SUPPORT_TURN_OFF,
SUPPORT_TURN_ON, SUPPORT_TURN_ON,
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_MUTE,
@ -27,7 +34,6 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_STEP,
) )
from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.philips_js import PhilipsTVDataUpdateCoordinator
from homeassistant.const import ( from homeassistant.const import (
CONF_API_VERSION, CONF_API_VERSION,
CONF_HOST, CONF_HOST,
@ -40,7 +46,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LOGGER as _LOGGER from . import LOGGER as _LOGGER, PhilipsTVDataUpdateCoordinator
from .const import CONF_SYSTEM, DOMAIN from .const import CONF_SYSTEM, DOMAIN
SUPPORT_PHILIPS_JS = ( SUPPORT_PHILIPS_JS = (
@ -53,16 +59,15 @@ SUPPORT_PHILIPS_JS = (
| SUPPORT_PREVIOUS_TRACK | SUPPORT_PREVIOUS_TRACK
| SUPPORT_PLAY_MEDIA | SUPPORT_PLAY_MEDIA
| SUPPORT_BROWSE_MEDIA | SUPPORT_BROWSE_MEDIA
| SUPPORT_PLAY
| SUPPORT_PAUSE
| SUPPORT_STOP
) )
CONF_ON_ACTION = "turn_on_action" CONF_ON_ACTION = "turn_on_action"
DEFAULT_API_VERSION = 1 DEFAULT_API_VERSION = 1
PREFIX_SEPARATOR = ": "
PREFIX_SOURCE = "Input"
PREFIX_CHANNEL = "Channel"
PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA = vol.All(
cv.deprecated(CONF_HOST), cv.deprecated(CONF_HOST),
cv.deprecated(CONF_NAME), cv.deprecated(CONF_NAME),
@ -131,12 +136,19 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
self._supports = SUPPORT_PHILIPS_JS self._supports = SUPPORT_PHILIPS_JS
self._system = system self._system = system
self._unique_id = unique_id self._unique_id = unique_id
self._state = STATE_OFF
self._media_content_type: Optional[str] = None
self._media_content_id: Optional[str] = None
self._media_title: Optional[str] = None
self._media_channel: Optional[str] = None
super().__init__(coordinator) super().__init__(coordinator)
self._update_from_coordinator() self._update_from_coordinator()
def _update_soon(self): async def _async_update_soon(self):
"""Reschedule update task.""" """Reschedule update task."""
self.hass.add_job(self.coordinator.async_request_refresh) self.async_write_ha_state()
await self.coordinator.async_request_refresh()
@property @property
def name(self): def name(self):
@ -147,7 +159,9 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
def supported_features(self): def supported_features(self):
"""Flag media player features that are supported.""" """Flag media player features that are supported."""
supports = self._supports supports = self._supports
if self._coordinator.turn_on: if self._coordinator.turn_on or (
self._tv.on and self._tv.powerstate is not None
):
supports |= SUPPORT_TURN_ON supports |= SUPPORT_TURN_ON
return supports return supports
@ -155,6 +169,7 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
def state(self): def state(self):
"""Get the device state. An exception means OFF state.""" """Get the device state. An exception means OFF state."""
if self._tv.on: if self._tv.on:
if self._tv.powerstate == "On" or self._tv.powerstate is None:
return STATE_ON return STATE_ON
return STATE_OFF return STATE_OFF
@ -168,22 +183,12 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
"""List of available input sources.""" """List of available input sources."""
return list(self._sources.values()) return list(self._sources.values())
def select_source(self, source): async def async_select_source(self, source):
"""Set the input source.""" """Set the input source."""
data = source.split(PREFIX_SEPARATOR, 1)
if data[0] == PREFIX_SOURCE: # Legacy way to set source
source_id = _inverted(self._sources).get(data[1])
if source_id:
self._tv.setSource(source_id)
elif data[0] == PREFIX_CHANNEL: # Legacy way to set channel
channel_id = _inverted(self._channels).get(data[1])
if channel_id:
self._tv.setChannel(channel_id)
else:
source_id = _inverted(self._sources).get(source) source_id = _inverted(self._sources).get(source)
if source_id: if source_id:
self._tv.setSource(source_id) await self._tv.setSource(source_id)
self._update_soon() await self._async_update_soon()
@property @property
def volume_level(self): def volume_level(self):
@ -197,78 +202,118 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
async def async_turn_on(self): async def async_turn_on(self):
"""Turn on the device.""" """Turn on the device."""
if self._tv.on and self._tv.powerstate:
await self._tv.setPowerState("On")
self._state = STATE_ON
else:
await self._coordinator.turn_on.async_run(self.hass, self._context) await self._coordinator.turn_on.async_run(self.hass, self._context)
await self._async_update_soon()
def turn_off(self): async def async_turn_off(self):
"""Turn off the device.""" """Turn off the device."""
self._tv.sendKey("Standby") await self._tv.sendKey("Standby")
self._tv.on = False self._state = STATE_OFF
self._update_soon() await self._async_update_soon()
def volume_up(self): async def async_volume_up(self):
"""Send volume up command.""" """Send volume up command."""
self._tv.sendKey("VolumeUp") await self._tv.sendKey("VolumeUp")
self._update_soon() await self._async_update_soon()
def volume_down(self): async def async_volume_down(self):
"""Send volume down command.""" """Send volume down command."""
self._tv.sendKey("VolumeDown") await self._tv.sendKey("VolumeDown")
self._update_soon() await self._async_update_soon()
def mute_volume(self, mute): async def async_mute_volume(self, mute):
"""Send mute command.""" """Send mute command."""
self._tv.setVolume(None, mute) if self._tv.muted != mute:
self._update_soon() await self._tv.sendKey("Mute")
await self._async_update_soon()
else:
_LOGGER.debug("Ignoring request when already in expected state")
def set_volume_level(self, volume): async def async_set_volume_level(self, volume):
"""Set volume level, range 0..1.""" """Set volume level, range 0..1."""
self._tv.setVolume(volume, self._tv.muted) await self._tv.setVolume(volume, self._tv.muted)
self._update_soon() await self._async_update_soon()
def media_previous_track(self): async def async_media_previous_track(self):
"""Send rewind command.""" """Send rewind command."""
self._tv.sendKey("Previous") await self._tv.sendKey("Previous")
self._update_soon() await self._async_update_soon()
def media_next_track(self): async def async_media_next_track(self):
"""Send fast forward command.""" """Send fast forward command."""
self._tv.sendKey("Next") await self._tv.sendKey("Next")
self._update_soon() await self._async_update_soon()
async def async_media_play_pause(self):
"""Send pause command to media player."""
if self._tv.quirk_playpause_spacebar:
await self._tv.sendUnicode(" ")
else:
await self._tv.sendKey("PlayPause")
await self._async_update_soon()
async def async_media_play(self):
"""Send pause command to media player."""
await self._tv.sendKey("Play")
await self._async_update_soon()
async def async_media_pause(self):
"""Send play command to media player."""
await self._tv.sendKey("Pause")
await self._async_update_soon()
async def async_media_stop(self):
"""Send play command to media player."""
await self._tv.sendKey("Stop")
await self._async_update_soon()
@property @property
def media_channel(self): def media_channel(self):
"""Get current channel if it's a channel.""" """Get current channel if it's a channel."""
if self.media_content_type == MEDIA_TYPE_CHANNEL: return self._media_channel
return self._channels.get(self._tv.channel_id)
return None
@property @property
def media_title(self): def media_title(self):
"""Title of current playing media.""" """Title of current playing media."""
if self.media_content_type == MEDIA_TYPE_CHANNEL: return self._media_title
return self._channels.get(self._tv.channel_id)
return self._sources.get(self._tv.source_id)
@property @property
def media_content_type(self): def media_content_type(self):
"""Return content type of playing media.""" """Return content type of playing media."""
if self._tv.source_id == "tv" or self._tv.source_id == "11": return self._media_content_type
return MEDIA_TYPE_CHANNEL
if self._tv.source_id is None and self._tv.channels:
return MEDIA_TYPE_CHANNEL
return None
@property @property
def media_content_id(self): def media_content_id(self):
"""Content type of current playing media.""" """Content type of current playing media."""
if self.media_content_type == MEDIA_TYPE_CHANNEL: return self._media_content_id
return self._channels.get(self._tv.channel_id)
@property
def media_image_url(self):
"""Image url of current playing media."""
if self._media_content_id and self._media_content_type in (
MEDIA_CLASS_APP,
MEDIA_CLASS_CHANNEL,
):
return self.get_browse_image_url(
self._media_content_type, self._media_content_id, media_image_id=None
)
return None return None
@property @property
def device_state_attributes(self): def app_id(self):
"""Return the state attributes.""" """ID of the current running app."""
return {"channel_list": list(self._channels.values())} return self._tv.application_id
@property
def app_name(self):
"""Name of the current running app."""
app = self._tv.applications.get(self._tv.application_id)
if app:
return app.get("label")
@property @property
def device_class(self): def device_class(self):
@ -293,57 +338,243 @@ class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
"sw_version": self._system.get("softwareversion"), "sw_version": self._system.get("softwareversion"),
} }
def play_media(self, media_type, media_id, **kwargs): async def async_play_media(self, media_type, media_id, **kwargs):
"""Play a piece of media.""" """Play a piece of media."""
_LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id)
if media_type == MEDIA_TYPE_CHANNEL: if media_type == MEDIA_TYPE_CHANNEL:
channel_id = _inverted(self._channels).get(media_id) list_id, _, channel_id = media_id.partition("/")
if channel_id: if channel_id:
self._tv.setChannel(channel_id) await self._tv.setChannel(channel_id, list_id)
self._update_soon() await self._async_update_soon()
else: else:
_LOGGER.error("Unable to find channel <%s>", media_id) _LOGGER.error("Unable to find channel <%s>", media_id)
elif media_type == MEDIA_TYPE_APP:
app = self._tv.applications.get(media_id)
if app:
await self._tv.setApplication(app["intent"])
await self._async_update_soon()
else:
_LOGGER.error("Unable to find application <%s>", media_id)
else: else:
_LOGGER.error("Unsupported media type <%s>", media_type) _LOGGER.error("Unsupported media type <%s>", media_type)
async def async_browse_media(self, media_content_type=None, media_content_id=None): async def async_browse_media_channels(self, expanded):
"""Implement the websocket media browsing helper.""" """Return channel media objects."""
if media_content_id not in (None, ""): if expanded:
raise BrowseError( children = [
f"Media not found: {media_content_type} / {media_content_id}" BrowseMedia(
title=channel.get("name", f"Channel: {channel_id}"),
media_class=MEDIA_CLASS_CHANNEL,
media_content_id=f"alltv/{channel_id}",
media_content_type=MEDIA_TYPE_CHANNEL,
can_play=True,
can_expand=False,
thumbnail=self.get_browse_image_url(
MEDIA_TYPE_APP, channel_id, media_image_id=None
),
) )
for channel_id, channel in self._tv.channels.items()
]
else:
children = None
return BrowseMedia( return BrowseMedia(
title="Channels", title="Channels",
media_class=MEDIA_CLASS_DIRECTORY, media_class=MEDIA_CLASS_DIRECTORY,
media_content_id="", media_content_id="channels",
media_content_type=MEDIA_TYPE_CHANNELS, media_content_type=MEDIA_TYPE_CHANNELS,
children_media_class=MEDIA_TYPE_CHANNEL,
can_play=False, can_play=False,
can_expand=True, can_expand=True,
children=[ children=children,
)
async def async_browse_media_favorites(self, list_id, expanded):
"""Return channel media objects."""
if expanded:
favorites = await self._tv.getFavoriteList(list_id)
if favorites:
def get_name(channel):
channel_data = self._tv.channels.get(str(channel["ccid"]))
if channel_data:
return channel_data["name"]
return f"Channel: {channel['ccid']}"
children = [
BrowseMedia( BrowseMedia(
title=channel, title=get_name(channel),
media_class=MEDIA_CLASS_CHANNEL, media_class=MEDIA_CLASS_CHANNEL,
media_content_id=channel, media_content_id=f"{list_id}/{channel['ccid']}",
media_content_type=MEDIA_TYPE_CHANNEL, media_content_type=MEDIA_TYPE_CHANNEL,
can_play=True, can_play=True,
can_expand=False, can_expand=False,
thumbnail=self.get_browse_image_url(
MEDIA_TYPE_APP, channel, media_image_id=None
),
) )
for channel in self._channels.values() for channel in favorites
]
else:
children = None
else:
children = None
favorite = self._tv.favorite_lists[list_id]
return BrowseMedia(
title=favorite.get("name", f"Favorites {list_id}"),
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id=f"favorites/{list_id}",
media_content_type=MEDIA_TYPE_CHANNELS,
children_media_class=MEDIA_TYPE_CHANNEL,
can_play=False,
can_expand=True,
children=children,
)
async def async_browse_media_applications(self, expanded):
"""Return application media objects."""
if expanded:
children = [
BrowseMedia(
title=application["label"],
media_class=MEDIA_CLASS_APP,
media_content_id=application_id,
media_content_type=MEDIA_TYPE_APP,
can_play=True,
can_expand=False,
thumbnail=self.get_browse_image_url(
MEDIA_TYPE_APP, application_id, media_image_id=None
),
)
for application_id, application in self._tv.applications.items()
]
else:
children = None
return BrowseMedia(
title="Applications",
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id="applications",
media_content_type=MEDIA_TYPE_APPS,
children_media_class=MEDIA_TYPE_APP,
can_play=False,
can_expand=True,
children=children,
)
async def async_browse_media_favorite_lists(self, expanded):
"""Return favorite media objects."""
if self._tv.favorite_lists and expanded:
children = [
await self.async_browse_media_favorites(list_id, False)
for list_id in self._tv.favorite_lists
]
else:
children = None
return BrowseMedia(
title="Favorites",
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id="favorite_lists",
media_content_type=MEDIA_TYPE_CHANNELS,
children_media_class=MEDIA_TYPE_CHANNEL,
can_play=False,
can_expand=True,
children=children,
)
async def async_browse_media_root(self):
"""Return root media objects."""
return BrowseMedia(
title="Library",
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id="",
media_content_type="",
can_play=False,
can_expand=True,
children=[
await self.async_browse_media_channels(False),
await self.async_browse_media_applications(False),
await self.async_browse_media_favorite_lists(False),
], ],
) )
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Implement the websocket media browsing helper."""
if not self._tv.on:
raise BrowseError("Can't browse when tv is turned off")
if media_content_id in (None, ""):
return await self.async_browse_media_root()
path = media_content_id.partition("/")
if path[0] == "channels":
return await self.async_browse_media_channels(True)
if path[0] == "applications":
return await self.async_browse_media_applications(True)
if path[0] == "favorite_lists":
return await self.async_browse_media_favorite_lists(True)
if path[0] == "favorites":
return await self.async_browse_media_favorites(path[2], True)
raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
async def async_get_browse_image(
self, media_content_type, media_content_id, media_image_id=None
):
"""Serve album art. Returns (content, content_type)."""
try:
if media_content_type == MEDIA_TYPE_APP and media_content_id:
return await self._tv.getApplicationIcon(media_content_id)
if media_content_type == MEDIA_TYPE_CHANNEL and media_content_id:
return await self._tv.getChannelLogo(media_content_id)
except ConnectionFailure:
_LOGGER.warning("Failed to fetch image")
return None, None
async def async_get_media_image(self):
"""Serve album art. Returns (content, content_type)."""
return await self.async_get_browse_image(
self.media_content_type, self.media_content_id, None
)
@callback
def _update_from_coordinator(self): def _update_from_coordinator(self):
if self._tv.on:
if self._tv.powerstate in ("Standby", "StandbyKeep"):
self._state = STATE_OFF
else:
self._state = STATE_ON
else:
self._state = STATE_OFF
self._sources = { self._sources = {
srcid: source.get("name") or f"Source {srcid}" srcid: source.get("name") or f"Source {srcid}"
for srcid, source in (self._tv.sources or {}).items() for srcid, source in (self._tv.sources or {}).items()
} }
self._channels = { if self._tv.channel_active:
chid: channel.get("name") or f"Channel {chid}" self._media_content_type = MEDIA_TYPE_CHANNEL
for chid, channel in (self._tv.channels or {}).items() self._media_content_id = f"all/{self._tv.channel_id}"
} self._media_title = self._tv.channels.get(self._tv.channel_id, {}).get(
"name"
)
self._media_channel = self._media_title
elif self._tv.application_id:
self._media_content_type = MEDIA_TYPE_APP
self._media_content_id = self._tv.application_id
self._media_title = self._tv.applications.get(
self._tv.application_id, {}
).get("label")
self._media_channel = None
else:
self._media_content_type = None
self._media_content_id = None
self._media_title = self._sources.get(self._tv.source_id)
self._media_channel = None
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:

View File

@ -10,8 +10,10 @@
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]",
}, "pairing_failure": "Unable to pair: {error_id}",
"invalid_pin": "Invalid PIN"
},
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }

View File

@ -5,7 +5,9 @@
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"unknown": "Unexpected error" "unknown": "Unexpected error",
"pairing_failure": "Unable to pair: {error_id}",
"invalid_pin": "Invalid PIN"
}, },
"step": { "step": {
"user": { "user": {

View File

@ -721,7 +721,7 @@ guppy3==3.1.0
ha-ffmpeg==3.0.2 ha-ffmpeg==3.0.2
# homeassistant.components.philips_js # homeassistant.components.philips_js
ha-philipsjs==0.1.0 ha-philipsjs==2.3.0
# homeassistant.components.habitica # homeassistant.components.habitica
habitipy==0.2.0 habitipy==0.2.0

View File

@ -382,7 +382,7 @@ guppy3==3.1.0
ha-ffmpeg==3.0.2 ha-ffmpeg==3.0.2
# homeassistant.components.philips_js # homeassistant.components.philips_js
ha-philipsjs==0.1.0 ha-philipsjs==2.3.0
# homeassistant.components.habitica # homeassistant.components.habitica
habitipy==0.2.0 habitipy==0.2.0

View File

@ -3,6 +3,9 @@
MOCK_SERIAL_NO = "1234567890" MOCK_SERIAL_NO = "1234567890"
MOCK_NAME = "Philips TV" MOCK_NAME = "Philips TV"
MOCK_USERNAME = "mock_user"
MOCK_PASSWORD = "mock_password"
MOCK_SYSTEM = { MOCK_SYSTEM = {
"menulanguage": "English", "menulanguage": "English",
"name": MOCK_NAME, "name": MOCK_NAME,
@ -12,14 +15,63 @@ MOCK_SYSTEM = {
"model": "modelname", "model": "modelname",
} }
MOCK_USERINPUT = { MOCK_SYSTEM_UNPAIRED = {
"host": "1.1.1.1", "menulanguage": "Dutch",
"api_version": 1, "name": "55PUS7181/12",
"country": "Netherlands",
"serialnumber": "ABCDEFGHIJKLF",
"softwareversion": "TPM191E_R.101.001.208.001",
"model": "65OLED855/12",
"deviceid": "1234567890",
"nettvversion": "6.0.2",
"epgsource": "one",
"api_version": {"Major": 6, "Minor": 2, "Patch": 0},
"featuring": {
"jsonfeatures": {
"editfavorites": ["TVChannels", "SatChannels"],
"recordings": ["List", "Schedule", "Manage"],
"ambilight": ["LoungeLight", "Hue", "Ambilight"],
"menuitems": ["Setup_Menu"],
"textentry": [
"context_based",
"initial_string_available",
"editor_info_available",
],
"applications": ["TV_Apps", "TV_Games", "TV_Settings"],
"pointer": ["not_available"],
"inputkey": ["key"],
"activities": ["intent"],
"channels": ["preset_string"],
"mappings": ["server_mapping"],
},
"systemfeatures": {
"tvtype": "consumer",
"content": ["dmr", "dms_tad"],
"tvsearch": "intent",
"pairing_type": "digest_auth_pairing",
"secured_transport": "True",
},
},
} }
MOCK_USERINPUT = {
"host": "1.1.1.1",
}
MOCK_IMPORT = {"host": "1.1.1.1", "api_version": 6}
MOCK_CONFIG = { MOCK_CONFIG = {
**MOCK_USERINPUT, "host": "1.1.1.1",
"api_version": 1,
"system": MOCK_SYSTEM, "system": MOCK_SYSTEM,
} }
MOCK_CONFIG_PAIRED = {
"host": "1.1.1.1",
"api_version": 6,
"username": MOCK_USERNAME,
"password": MOCK_PASSWORD,
"system": MOCK_SYSTEM_UNPAIRED,
}
MOCK_ENTITY_ID = "media_player.philips_tv" MOCK_ENTITY_ID = "media_player.philips_tv"

View File

@ -1,6 +1,7 @@
"""Standard setup for tests.""" """Standard setup for tests."""
from unittest.mock import Mock, patch from unittest.mock import create_autospec, patch
from haphilipsjs import PhilipsTV
from pytest import fixture from pytest import fixture
from homeassistant import setup from homeassistant import setup
@ -20,10 +21,18 @@ async def setup_notification(hass):
@fixture(autouse=True) @fixture(autouse=True)
def mock_tv(): def mock_tv():
"""Disable component actual use.""" """Disable component actual use."""
tv = Mock(autospec="philips_js.PhilipsTV") tv = create_autospec(PhilipsTV)
tv.sources = {} tv.sources = {}
tv.channels = {} tv.channels = {}
tv.application = None
tv.applications = {}
tv.system = MOCK_SYSTEM tv.system = MOCK_SYSTEM
tv.api_version = 1
tv.api_version_detected = None
tv.on = True
tv.notify_change_supported = False
tv.pairing_type = None
tv.powerstate = None
with patch( with patch(
"homeassistant.components.philips_js.config_flow.PhilipsTV", return_value=tv "homeassistant.components.philips_js.config_flow.PhilipsTV", return_value=tv

View File

@ -1,12 +1,21 @@
"""Test the Philips TV config flow.""" """Test the Philips TV config flow."""
from unittest.mock import patch from unittest.mock import ANY, patch
from haphilipsjs import PairingFailure
from pytest import fixture from pytest import fixture
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.philips_js.const import DOMAIN from homeassistant.components.philips_js.const import DOMAIN
from . import MOCK_CONFIG, MOCK_USERINPUT from . import (
MOCK_CONFIG,
MOCK_CONFIG_PAIRED,
MOCK_IMPORT,
MOCK_PASSWORD,
MOCK_SYSTEM_UNPAIRED,
MOCK_USERINPUT,
MOCK_USERNAME,
)
@fixture(autouse=True) @fixture(autouse=True)
@ -27,12 +36,26 @@ def mock_setup_entry():
yield mock_setup_entry yield mock_setup_entry
@fixture
async def mock_tv_pairable(mock_tv):
"""Return a mock tv that is pariable."""
mock_tv.system = MOCK_SYSTEM_UNPAIRED
mock_tv.pairing_type = "digest_auth_pairing"
mock_tv.api_version = 6
mock_tv.api_version_detected = 6
mock_tv.secured_transport = True
mock_tv.pairRequest.return_value = {}
mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD
return mock_tv
async def test_import(hass, mock_setup, mock_setup_entry): async def test_import(hass, mock_setup, mock_setup_entry):
"""Test we get an item on import.""" """Test we get an item on import."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_IMPORT}, context={"source": config_entries.SOURCE_IMPORT},
data=MOCK_USERINPUT, data=MOCK_IMPORT,
) )
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
@ -47,7 +70,7 @@ async def test_import_exist(hass, mock_config_entry):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_IMPORT}, context={"source": config_entries.SOURCE_IMPORT},
data=MOCK_USERINPUT, data=MOCK_IMPORT,
) )
assert result["type"] == "abort" assert result["type"] == "abort"
@ -103,3 +126,116 @@ async def test_form_unexpected_error(hass, mock_tv):
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "unknown"} assert result["errors"] == {"base": "unknown"}
async def test_pairing(hass, mock_tv_pairable, mock_setup, mock_setup_entry):
"""Test we get the form."""
mock_tv = mock_tv_pairable
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USERINPUT,
)
assert result["type"] == "form"
assert result["errors"] == {}
mock_tv.setTransport.assert_called_with(True)
mock_tv.pairRequest.assert_called()
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": "1234"}
)
assert result == {
"flow_id": ANY,
"type": "create_entry",
"description": None,
"description_placeholders": None,
"handler": "philips_js",
"result": ANY,
"title": "55PUS7181/12 (ABCDEFGHIJKLF)",
"data": MOCK_CONFIG_PAIRED,
"version": 1,
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_pair_request_failed(
hass, mock_tv_pairable, mock_setup, mock_setup_entry
):
"""Test we get the form."""
mock_tv = mock_tv_pairable
mock_tv.pairRequest.side_effect = PairingFailure({})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USERINPUT,
)
assert result == {
"flow_id": ANY,
"description_placeholders": {"error_id": None},
"handler": "philips_js",
"reason": "pairing_failure",
"type": "abort",
}
async def test_pair_grant_failed(hass, mock_tv_pairable, mock_setup, mock_setup_entry):
"""Test we get the form."""
mock_tv = mock_tv_pairable
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_USERINPUT,
)
assert result["type"] == "form"
assert result["errors"] == {}
mock_tv.setTransport.assert_called_with(True)
mock_tv.pairRequest.assert_called()
# Test with invalid pin
mock_tv.pairGrant.side_effect = PairingFailure({"error_id": "INVALID_PIN"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": "1234"}
)
assert result["type"] == "form"
assert result["errors"] == {"pin": "invalid_pin"}
# Test with unexpected failure
mock_tv.pairGrant.side_effect = PairingFailure({})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": "1234"}
)
assert result == {
"flow_id": ANY,
"description_placeholders": {"error_id": None},
"handler": "philips_js",
"reason": "pairing_failure",
"type": "abort",
}

View File

@ -33,9 +33,13 @@ async def test_get_triggers(hass, mock_device):
assert_lists_same(triggers, expected_triggers) assert_lists_same(triggers, expected_triggers)
async def test_if_fires_on_turn_on_request(hass, calls, mock_entity, mock_device): async def test_if_fires_on_turn_on_request(
hass, calls, mock_tv, mock_entity, mock_device
):
"""Test for turn_on and turn_off triggers firing.""" """Test for turn_on and turn_off triggers firing."""
mock_tv.on = False
assert await async_setup_component( assert await async_setup_component(
hass, hass,
automation.DOMAIN, automation.DOMAIN,