From 8dc06e612fc3e1fe5ea2b6cd9dc493f917b6d6ff Mon Sep 17 00:00:00 2001
From: Joakim Plate <elupus@ecce.se>
Date: Thu, 11 Feb 2021 21:37:53 +0100
Subject: [PATCH] Add config flow to philips_js (#45784)

* Add config flow to philips_js

* Adjust name of entry to contain serial

* Use device id in event rather than entity id

* Adjust turn on text

* Deprecate all fields

* Be somewhat more explicit in typing

* Switch to direct coordinator access

* Refactor the pluggable action

* Adjust tests a bit

* Minor adjustment

* More adjustments

* Add missing await in update coordinator

* Be more lenient to lack of system info

* Use constant for trigger type and simplify

* Apply suggestions from code review

Co-authored-by: J. Nick Koston <nick@koston.org>

Co-authored-by: J. Nick Koston <nick@koston.org>
---
 .coveragerc                                   |   1 +
 .../components/philips_js/__init__.py         | 132 +++++++++++-
 .../components/philips_js/config_flow.py      |  90 +++++++++
 homeassistant/components/philips_js/const.py  |   4 +
 .../components/philips_js/device_trigger.py   |  65 ++++++
 .../components/philips_js/manifest.json       |  11 +-
 .../components/philips_js/media_player.py     | 189 ++++++++++--------
 .../components/philips_js/strings.json        |  24 +++
 .../philips_js/translations/en.json           |  24 +++
 homeassistant/generated/config_flows.py       |   1 +
 requirements_all.txt                          |   2 +-
 requirements_test_all.txt                     |   3 +
 tests/components/philips_js/__init__.py       |  25 +++
 tests/components/philips_js/conftest.py       |  62 ++++++
 .../components/philips_js/test_config_flow.py | 105 ++++++++++
 .../philips_js/test_device_trigger.py         |  69 +++++++
 16 files changed, 721 insertions(+), 86 deletions(-)
 create mode 100644 homeassistant/components/philips_js/config_flow.py
 create mode 100644 homeassistant/components/philips_js/const.py
 create mode 100644 homeassistant/components/philips_js/device_trigger.py
 create mode 100644 homeassistant/components/philips_js/strings.json
 create mode 100644 homeassistant/components/philips_js/translations/en.json
 create mode 100644 tests/components/philips_js/__init__.py
 create mode 100644 tests/components/philips_js/conftest.py
 create mode 100644 tests/components/philips_js/test_config_flow.py
 create mode 100644 tests/components/philips_js/test_device_trigger.py

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