Automatically update app list for Vizio SmartTV's (#38641)

This commit is contained in:
Raman Gupta 2020-09-02 05:55:10 -04:00 committed by GitHub
parent 9f5baa0bf7
commit 7ff633f531
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 225 additions and 47 deletions

View file

@ -1,15 +1,24 @@
"""The vizio component."""
import asyncio
from datetime import timedelta
import logging
from typing import Any, Dict, List
from pyvizio.const import APPS
from pyvizio.util import gen_apps_list_from_url
import voluptuous as vol
from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.config_entries import ENTRY_STATE_LOADED, SOURCE_IMPORT, ConfigEntry
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA
_LOGGER = logging.getLogger(__name__)
def validate_apps(config: ConfigType) -> ConfigType:
"""Validate CONF_APPS is only used when CONF_DEVICE_CLASS == DEVICE_CLASS_TV."""
@ -47,6 +56,16 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool:
"""Load the saved entities."""
hass.data.setdefault(DOMAIN, {})
if (
CONF_APPS not in hass.data[DOMAIN]
and config_entry.data[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
):
coordinator = VizioAppsDataUpdateCoordinator(hass)
await coordinator.async_refresh()
hass.data[DOMAIN][CONF_APPS] = coordinator
for platform in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, platform)
@ -68,4 +87,38 @@ async def async_unload_entry(
)
)
# Exclude this config entry because its not unloaded yet
if not any(
entry.state == ENTRY_STATE_LOADED
and entry.entry_id != config_entry.entry_id
and entry.data[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
for entry in hass.config_entries.async_entries(DOMAIN)
):
hass.data[DOMAIN].pop(CONF_APPS)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold Vizio app config data."""
def __init__(self, hass: HomeAssistantType) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(days=1),
update_method=self._async_update_data,
)
self.data = APPS
async def _async_update_data(self) -> List[Dict[str, Any]]:
"""Update data via library."""
data = await gen_apps_list_from_url(session=async_get_clientsession(self.hass))
if not data:
raise UpdateFailed
return sorted(data, key=lambda app: app["name"])

View file

@ -5,6 +5,7 @@ import socket
from typing import Any, Dict, Optional
from pyvizio import VizioAsync, async_guess_device_type
from pyvizio.const import APP_HOME
import voluptuous as vol
from homeassistant import config_entries
@ -154,7 +155,15 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow):
default=self.config_entry.options.get(CONF_APPS, {}).get(
default_include_or_exclude, []
),
): cv.multi_select(VizioAsync.get_apps_list()),
): cv.multi_select(
[
APP_HOME["name"],
*[
app["name"]
for app in self.hass.data[DOMAIN][CONF_APPS].data
],
]
),
}
)

View file

@ -1,5 +1,4 @@
"""Constants used by vizio component."""
from pyvizio import VizioAsync
from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
@ -101,10 +100,10 @@ VIZIO_SCHEMA = {
vol.Optional(CONF_APPS): vol.All(
{
vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All(
cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
cv.ensure_list, [cv.string]
),
vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All(
cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All(
cv.ensure_list,

View file

@ -2,7 +2,7 @@
"domain": "vizio",
"name": "VIZIO SmartCast",
"documentation": "https://www.home-assistant.io/integrations/vizio",
"requirements": ["pyvizio==0.1.51"],
"requirements": ["pyvizio==0.1.56"],
"codeowners": ["@raman325"],
"config_flow": true,
"zeroconf": ["_viziocast._tcp.local."],

View file

@ -5,10 +5,11 @@ from typing import Any, Callable, Dict, List, Optional, Union
from pyvizio import VizioAsync
from pyvizio.api.apps import find_app_name
from pyvizio.const import APP_HOME, APPS, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
from homeassistant.components.media_player import (
DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV,
SUPPORT_SELECT_SOUND_MODE,
MediaPlayerEntity,
)
@ -23,6 +24,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.core import callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -32,6 +34,7 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_ADDITIONAL_CONFIGS,
@ -78,6 +81,7 @@ async def async_setup_entry(
params = {}
if not config_entry.options:
params["options"] = {CONF_VOLUME_STEP: volume_step}
include_or_exclude_key = next(
(
key
@ -115,7 +119,9 @@ async def async_setup_entry(
_LOGGER.warning("Failed to connect to %s", host)
raise PlatformNotReady
entity = VizioDevice(config_entry, device, name, device_class)
apps_coordinator = hass.data[DOMAIN].get(CONF_APPS)
entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator)
async_add_entities([entity], update_before_add=True)
platform = entity_platform.current_platform.get()
@ -133,10 +139,12 @@ class VizioDevice(MediaPlayerEntity):
device: VizioAsync,
name: str,
device_class: str,
apps_coordinator: DataUpdateCoordinator,
) -> None:
"""Initialize Vizio device."""
self._config_entry = config_entry
self._async_unsub_listeners = []
self._apps_coordinator = apps_coordinator
self._name = name
self._state = None
@ -150,6 +158,7 @@ class VizioDevice(MediaPlayerEntity):
self._available_sound_modes = []
self._available_inputs = []
self._available_apps = []
self._all_apps = apps_coordinator.data if apps_coordinator else None
self._conf_apps = config_entry.options.get(CONF_APPS, {})
self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
CONF_ADDITIONAL_CONFIGS, []
@ -255,14 +264,15 @@ class VizioDevice(MediaPlayerEntity):
# Create list of available known apps from known app list after
# filtering by CONF_INCLUDE/CONF_EXCLUDE
self._available_apps = self._apps_list(self._device.get_apps_list())
self._available_apps = self._apps_list([app["name"] for app in self._all_apps])
self._current_app_config = await self._device.get_current_app_config(
log_api_exception=False
)
self._current_app = find_app_name(
self._current_app_config, [APP_HOME, *APPS, *self._additional_app_configs]
self._current_app_config,
[APP_HOME, *self._all_apps, *self._additional_app_configs],
)
if self._current_app == NO_APP_RUNNING:
@ -286,6 +296,7 @@ class VizioDevice(MediaPlayerEntity):
async def _async_update_options(self, config_entry: ConfigEntry) -> None:
"""Update options if the update signal comes from this entity."""
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
# Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports
self._conf_apps.update(config_entry.options.get(CONF_APPS, {}))
async def async_update_setting(
@ -314,6 +325,18 @@ class VizioDevice(MediaPlayerEntity):
)
)
# Register callback for app list updates if device is a TV
@callback
def apps_list_update():
"""Update list of all apps."""
self._all_apps = self._apps_coordinator.data
self.async_write_ha_state()
if self._device_class == DEVICE_CLASS_TV:
self._async_unsub_listeners.append(
self._apps_coordinator.async_add_listener(apps_list_update)
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect callbacks when entity is removed."""
for listener in self._async_unsub_listeners:
@ -479,7 +502,7 @@ class VizioDevice(MediaPlayerEntity):
)
)
elif source in self._available_apps:
await self._device.launch_app(source)
await self._device.launch_app(source, self._all_apps)
async def async_volume_up(self) -> None:
"""Increase volume of the device."""

View file

@ -2,7 +2,7 @@ update_setting:
description: Update the value of a setting on a particular Vizio media player device.
fields:
entity_id:
description: Name of an entity to send command.
description: Name of an entity to send command to.
example: "media_player.vizio_smartcast"
setting_type:
description: The type of setting to be changed. Available types are listed in the `setting_types` property.

View file

@ -1830,7 +1830,7 @@ pyversasense==0.0.6
pyvesync==1.1.0
# homeassistant.components.vizio
pyvizio==0.1.51
pyvizio==0.1.56
# homeassistant.components.velux
pyvlx==0.2.16

View file

@ -857,7 +857,7 @@ pyvera==0.3.9
pyvesync==1.1.0
# homeassistant.components.vizio
pyvizio==0.1.51
pyvizio==0.1.56
# homeassistant.components.volumio
pyvolumio==0.1.2

View file

@ -1,5 +1,6 @@
"""Configure py.test."""
import pytest
from pyvizio.api.apps import AppConfig
from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME
from .const import (
@ -57,6 +58,15 @@ def vizio_get_unique_id_fixture():
yield
@pytest.fixture(name="vizio_data_coordinator_update", autouse=True)
def vizio_data_coordinator_update_fixture():
"""Mock get data coordinator update."""
with patch(
"homeassistant.components.vizio.gen_apps_list_from_url", return_value=APP_LIST,
):
yield
@pytest.fixture(name="vizio_no_unique_id")
def vizio_no_unique_id_fixture():
"""Mock no vizio unique ID returrned."""
@ -191,15 +201,12 @@ def vizio_update_with_apps_fixture(vizio_update: pytest.fixture):
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
return_value=get_mock_inputs(INPUT_LIST_WITH_APPS),
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_apps_list",
return_value=APP_LIST,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
return_value="CAST",
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
return_value=CURRENT_APP_CONFIG,
return_value=AppConfig(**CURRENT_APP_CONFIG),
):
yield

View file

@ -72,7 +72,21 @@ INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
CURRENT_APP = "Hulu"
CURRENT_APP_CONFIG = {CONF_APP_ID: "3", CONF_NAME_SPACE: 4, CONF_MESSAGE: None}
APP_LIST = ["Hulu", "Netflix"]
APP_LIST = [
{
"name": "Hulu",
"country": ["*"],
"id": ["1"],
"config": [{"NAME_SPACE": 4, "APP_ID": "3", "MESSAGE": None}],
},
{
"name": "Netflix",
"country": ["*"],
"id": ["2"],
"config": [{"NAME_SPACE": 1, "APP_ID": "2", "MESSAGE": None}],
},
]
APP_NAME_LIST = [app["name"] for app in APP_LIST]
INPUT_LIST_WITH_APPS = INPUT_LIST + ["CAST"]
CUSTOM_CONFIG = {CONF_APP_ID: "test", CONF_MESSAGE: None, CONF_NAME_SPACE: 10}
ADDITIONAL_APP_CONFIG = {

View file

@ -109,12 +109,18 @@ async def test_user_flow_all_fields(
assert CONF_APPS not in result["data"]
async def test_speaker_options_flow(hass: HomeAssistantType) -> None:
async def test_speaker_options_flow(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture,
) -> None:
"""Test options config flow for speaker."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_SPEAKER_CONFIG)
entry.add_to_hass(hass)
assert not entry.options
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_SPEAKER_CONFIG
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
@ -131,12 +137,18 @@ async def test_speaker_options_flow(hass: HomeAssistantType) -> None:
assert CONF_APPS not in result["data"]
async def test_tv_options_flow_no_apps(hass: HomeAssistantType) -> None:
async def test_tv_options_flow_no_apps(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture,
) -> None:
"""Test options config flow for TV without providing apps option."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG)
entry.add_to_hass(hass)
assert not entry.options
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
@ -156,12 +168,18 @@ async def test_tv_options_flow_no_apps(hass: HomeAssistantType) -> None:
assert CONF_APPS not in result["data"]
async def test_tv_options_flow_with_apps(hass: HomeAssistantType) -> None:
async def test_tv_options_flow_with_apps(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture,
) -> None:
"""Test options config flow for TV with providing apps option."""
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG)
entry.add_to_hass(hass)
assert not entry.options
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
@ -182,14 +200,23 @@ async def test_tv_options_flow_with_apps(hass: HomeAssistantType) -> None:
assert result["data"][CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]}
async def test_tv_options_flow_start_with_volume(hass: HomeAssistantType) -> None:
async def test_tv_options_flow_start_with_volume(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture,
) -> None:
"""Test options config flow for TV with providing apps option after providing volume step in initial config."""
entry = MockConfigEntry(
domain=DOMAIN,
data=MOCK_USER_VALID_TV_CONFIG,
options={CONF_VOLUME_STEP: VOLUME_STEP},
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
)
entry.add_to_hass(hass)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]
result = await hass.config_entries.options.async_init(
entry.entry_id, data={CONF_VOLUME_STEP: VOLUME_STEP}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert entry.options
assert entry.options == {CONF_VOLUME_STEP: VOLUME_STEP}

View file

@ -4,6 +4,7 @@ import pytest
from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
from homeassistant.components.vizio.const import DOMAIN
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.setup import async_setup_component
from .const import MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID
@ -37,7 +38,12 @@ async def test_load_and_unload(
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1
assert DOMAIN in hass.data
assert "apps" in hass.data[DOMAIN]
assert isinstance(hass.data[DOMAIN]["apps"], DataUpdateCoordinator)
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert await config_entry.async_unload(hass)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0
assert "apps" not in hass.data.get(DOMAIN, {})
assert DOMAIN not in hass.data

View file

@ -8,6 +8,7 @@ import pytest
from pytest import raises
from pyvizio.api.apps import AppConfig
from pyvizio.const import (
APPS,
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
INPUT_APPS,
@ -51,6 +52,7 @@ from homeassistant.util import dt as dt_util
from .const import (
ADDITIONAL_APP_CONFIG,
APP_LIST,
APP_NAME_LIST,
CURRENT_APP,
CURRENT_APP_CONFIG,
CURRENT_EQ,
@ -358,7 +360,11 @@ async def test_services(
await _test_service(hass, MP_DOMAIN, "pow_on", SERVICE_TURN_ON, None)
await _test_service(hass, MP_DOMAIN, "pow_off", SERVICE_TURN_OFF, None)
await _test_service(
hass, MP_DOMAIN, "mute_on", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}
hass,
MP_DOMAIN,
"mute_on",
SERVICE_VOLUME_MUTE,
{ATTR_MEDIA_VOLUME_MUTED: True},
)
await _test_service(
hass,
@ -511,7 +517,7 @@ async def test_setup_with_apps(
hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP_CONFIG
):
attr = hass.states.get(ENTITY_ID).attributes
_assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_LIST), attr)
_assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr)
assert CURRENT_APP in attr["source_list"]
assert attr["source"] == CURRENT_APP
assert attr["app_name"] == CURRENT_APP
@ -524,6 +530,7 @@ async def test_setup_with_apps(
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: CURRENT_APP},
CURRENT_APP,
APP_LIST,
)
@ -580,13 +587,13 @@ async def test_setup_with_apps_additional_apps_config(
_assert_source_list_with_apps(
list(
INPUT_LIST_WITH_APPS
+ APP_LIST
+ APP_NAME_LIST
+ [
app["name"]
for app in MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG[CONF_APPS][
CONF_ADDITIONAL_CONFIGS
]
if app["name"] not in APP_LIST
if app["name"] not in APP_NAME_LIST
]
),
attr,
@ -603,6 +610,7 @@ async def test_setup_with_apps_additional_apps_config(
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: "Netflix"},
"Netflix",
APP_LIST,
)
await _test_service(
hass,
@ -649,7 +657,7 @@ async def test_setup_with_unknown_app_config(
hass, MOCK_USER_VALID_TV_CONFIG, UNKNOWN_APP_CONFIG
):
attr = hass.states.get(ENTITY_ID).attributes
_assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_LIST), attr)
_assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr)
assert attr["source"] == UNKNOWN_APP
assert attr["app_name"] == UNKNOWN_APP
assert attr["app_id"] == UNKNOWN_APP_CONFIG
@ -666,7 +674,7 @@ async def test_setup_with_no_running_app(
hass, MOCK_USER_VALID_TV_CONFIG, vars(AppConfig())
):
attr = hass.states.get(ENTITY_ID).attributes
_assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_LIST), attr)
_assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr)
assert attr["source"] == "CAST"
assert "app_id" not in attr
assert "app_name" not in attr
@ -694,3 +702,35 @@ async def test_setup_tv_without_mute(
_assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV)
assert "sound_mode" not in attr
assert "is_volume_muted" not in attr
async def test_apps_update(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_update_with_apps: pytest.fixture,
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps where no app is running."""
with patch(
"homeassistant.components.vizio.gen_apps_list_from_url", return_value=None,
):
async with _cm_for_test_setup_tv_with_apps(
hass, MOCK_USER_VALID_TV_CONFIG, vars(AppConfig())
):
# Check source list, remove TV inputs, and verify that the integration is
# using the default APPS list
sources = hass.states.get(ENTITY_ID).attributes["source_list"]
apps = list(set(sources) - set(INPUT_LIST))
assert len(apps) == len(APPS)
with patch(
"homeassistant.components.vizio.gen_apps_list_from_url",
return_value=APP_LIST,
):
async_fire_time_changed(hass, dt_util.now() + timedelta(days=2))
await hass.async_block_till_done()
# Check source list, remove TV inputs, and verify that the integration is
# now using the APP_LIST list
sources = hass.states.get(ENTITY_ID).attributes["source_list"]
apps = list(set(sources) - set(INPUT_LIST))
assert len(apps) == len(APP_LIST)