diff --git a/CODEOWNERS b/CODEOWNERS
index 3fa8a7a366dffbe8fea07257fd9725614ccdb071..40584db7479f6dea1216b30cd242802e31ad9fdf 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -193,6 +193,7 @@ homeassistant/components/humidifier/* @home-assistant/core @Shulyaka
 homeassistant/components/hunterdouglas_powerview/* @bdraco
 homeassistant/components/hvv_departures/* @vigonotion
 homeassistant/components/hydrawise/* @ptcryan
+homeassistant/components/hyperion/* @dermotduffy
 homeassistant/components/iammeter/* @lewei50
 homeassistant/components/iaqualink/* @flz
 homeassistant/components/icloud/* @Quentame
diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py
index d1baec315bf32645411513c2bd5c98886276bba7..db34a21dadab225e523d7643da9a9912e5971596 100644
--- a/homeassistant/components/hyperion/light.py
+++ b/homeassistant/components/hyperion/light.py
@@ -1,8 +1,7 @@
-"""Support for Hyperion remotes."""
-import json
+"""Support for Hyperion-NG remotes."""
 import logging
-import socket
 
+from hyperion import client, const
 import voluptuous as vol
 
 from homeassistant.components.light import (
@@ -16,6 +15,7 @@ from homeassistant.components.light import (
     LightEntity,
 )
 from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.exceptions import PlatformNotReady
 import homeassistant.helpers.config_validation as cv
 import homeassistant.util.color as color_util
 
@@ -26,103 +26,91 @@ CONF_PRIORITY = "priority"
 CONF_HDMI_PRIORITY = "hdmi_priority"
 CONF_EFFECT_LIST = "effect_list"
 
+# As we want to preserve brightness control for effects (e.g. to reduce the
+# brightness for V4L), we need to persist the effect that is in flight, so
+# subsequent calls to turn_on will know the keep the effect enabled.
+# Unfortunately the Home Assistant UI does not easily expose a way to remove a
+# selected effect (there is no 'No Effect' option by default). Instead, we
+# create a new fake effect ("Solid") that is always selected by default for
+# showing a solid color. This is the same method used by WLED.
+KEY_EFFECT_SOLID = "Solid"
+
 DEFAULT_COLOR = [255, 255, 255]
+DEFAULT_BRIGHTNESS = 255
+DEFAULT_EFFECT = KEY_EFFECT_SOLID
 DEFAULT_NAME = "Hyperion"
+DEFAULT_ORIGIN = "Home Assistant"
 DEFAULT_PORT = 19444
 DEFAULT_PRIORITY = 128
 DEFAULT_HDMI_PRIORITY = 880
-DEFAULT_EFFECT_LIST = [
-    "HDMI",
-    "Cinema brighten lights",
-    "Cinema dim lights",
-    "Knight rider",
-    "Blue mood blobs",
-    "Cold mood blobs",
-    "Full color mood blobs",
-    "Green mood blobs",
-    "Red mood blobs",
-    "Warm mood blobs",
-    "Police Lights Single",
-    "Police Lights Solid",
-    "Rainbow mood",
-    "Rainbow swirl fast",
-    "Rainbow swirl",
-    "Random",
-    "Running dots",
-    "System Shutdown",
-    "Snake",
-    "Sparks Color",
-    "Sparks",
-    "Strobe blue",
-    "Strobe Raspbmc",
-    "Strobe white",
-    "Color traces",
-    "UDP multicast listener",
-    "UDP listener",
-    "X-Mas",
-]
+DEFAULT_EFFECT_LIST = []
 
 SUPPORT_HYPERION = SUPPORT_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT
 
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
-    {
-        vol.Required(CONF_HOST): cv.string,
-        vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
-        vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR): vol.All(
-            list,
-            vol.Length(min=3, max=3),
-            [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))],
-        ),
-        vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-        vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int,
-        vol.Optional(
-            CONF_HDMI_PRIORITY, default=DEFAULT_HDMI_PRIORITY
-        ): cv.positive_int,
-        vol.Optional(CONF_EFFECT_LIST, default=DEFAULT_EFFECT_LIST): vol.All(
-            cv.ensure_list, [cv.string]
-        ),
-    }
+PLATFORM_SCHEMA = vol.All(
+    cv.deprecated(CONF_HDMI_PRIORITY, invalidation_version="0.118"),
+    cv.deprecated(CONF_DEFAULT_COLOR, invalidation_version="0.118"),
+    cv.deprecated(CONF_EFFECT_LIST, invalidation_version="0.118"),
+    PLATFORM_SCHEMA.extend(
+        {
+            vol.Required(CONF_HOST): cv.string,
+            vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
+            vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR): vol.All(
+                list,
+                vol.Length(min=3, max=3),
+                [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))],
+            ),
+            vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+            vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int,
+            vol.Optional(
+                CONF_HDMI_PRIORITY, default=DEFAULT_HDMI_PRIORITY
+            ): cv.positive_int,
+            vol.Optional(CONF_EFFECT_LIST, default=DEFAULT_EFFECT_LIST): vol.All(
+                cv.ensure_list, [cv.string]
+            ),
+        }
+    ),
 )
 
+ICON_LIGHTBULB = "mdi:lightbulb"
+ICON_EFFECT = "mdi:lava-lamp"
+ICON_EXTERNAL_SOURCE = "mdi:video-input-hdmi"
+
 
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
     """Set up a Hyperion server remote."""
     name = config[CONF_NAME]
     host = config[CONF_HOST]
     port = config[CONF_PORT]
     priority = config[CONF_PRIORITY]
-    hdmi_priority = config[CONF_HDMI_PRIORITY]
-    default_color = config[CONF_DEFAULT_COLOR]
-    effect_list = config[CONF_EFFECT_LIST]
 
-    device = Hyperion(
-        name, host, port, priority, default_color, hdmi_priority, effect_list
-    )
+    hyperion_client = client.HyperionClient(host, port)
+
+    if not await hyperion_client.async_client_connect():
+        raise PlatformNotReady
 
-    if device.setup():
-        add_entities([device])
+    async_add_entities([Hyperion(name, priority, hyperion_client)])
 
 
 class Hyperion(LightEntity):
     """Representation of a Hyperion remote."""
 
-    def __init__(
-        self, name, host, port, priority, default_color, hdmi_priority, effect_list
-    ):
+    def __init__(self, name, priority, hyperion_client):
         """Initialize the light."""
-        self._host = host
-        self._port = port
         self._name = name
         self._priority = priority
-        self._hdmi_priority = hdmi_priority
-        self._default_color = default_color
-        self._rgb_color = [0, 0, 0]
-        self._rgb_mem = [0, 0, 0]
-        self._brightness = 255
-        self._icon = "mdi:lightbulb"
-        self._effect_list = effect_list
-        self._effect = None
-        self._skip_update = False
+        self._client = hyperion_client
+
+        # Active state representing the Hyperion instance.
+        self._set_internal_state(
+            brightness=255, rgb_color=DEFAULT_COLOR, effect=KEY_EFFECT_SOLID
+        )
+        self._effect_list = []
+
+    @property
+    def should_poll(self):
+        """Return whether or not this entity should be polled."""
+        return False
 
     @property
     def name(self):
@@ -142,7 +130,7 @@ class Hyperion(LightEntity):
     @property
     def is_on(self):
         """Return true if not black."""
-        return self._rgb_color != [0, 0, 0]
+        return self._client.is_on()
 
     @property
     def icon(self):
@@ -157,158 +145,233 @@ class Hyperion(LightEntity):
     @property
     def effect_list(self):
         """Return the list of supported effects."""
-        return self._effect_list
+        return (
+            self._effect_list
+            + const.KEY_COMPONENTID_EXTERNAL_SOURCES
+            + [KEY_EFFECT_SOLID]
+        )
 
     @property
     def supported_features(self):
         """Flag supported features."""
         return SUPPORT_HYPERION
 
-    def turn_on(self, **kwargs):
+    @property
+    def available(self):
+        """Return server availability."""
+        return self._client.has_loaded_state
+
+    @property
+    def unique_id(self):
+        """Return a unique id for this instance."""
+        return self._client.id
+
+    async def async_turn_on(self, **kwargs):
         """Turn the lights on."""
+        # == Turn device on ==
+        # Turn on both ALL (Hyperion itself) and LEDDEVICE. It would be
+        # preferable to enable LEDDEVICE after the settings (e.g. brightness,
+        # color, effect), but this is not possible due to:
+        # https://github.com/hyperion-project/hyperion.ng/issues/967
+        if not self.is_on:
+            if not await self._client.async_send_set_component(
+                **{
+                    const.KEY_COMPONENTSTATE: {
+                        const.KEY_COMPONENT: const.KEY_COMPONENTID_ALL,
+                        const.KEY_STATE: True,
+                    }
+                }
+            ):
+                return
+
+            if not await self._client.async_send_set_component(
+                **{
+                    const.KEY_COMPONENTSTATE: {
+                        const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE,
+                        const.KEY_STATE: True,
+                    }
+                }
+            ):
+                return
+
+        # == Get key parameters ==
+        brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness)
+        effect = kwargs.get(ATTR_EFFECT, self._effect)
         if ATTR_HS_COLOR in kwargs:
             rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
-        elif self._rgb_mem == [0, 0, 0]:
-            rgb_color = self._default_color
         else:
-            rgb_color = self._rgb_mem
+            rgb_color = self._rgb_color
+
+        # == Set brightness ==
+        if self._brightness != brightness:
+            if not await self._client.async_send_set_adjustment(
+                **{
+                    const.KEY_ADJUSTMENT: {
+                        const.KEY_BRIGHTNESS: int(
+                            round((float(brightness) * 100) / 255)
+                        )
+                    }
+                }
+            ):
+                return
 
-        brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness)
+        # == Set an external source
+        if effect and effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES:
 
-        if ATTR_EFFECT in kwargs:
-            self._skip_update = True
-            self._effect = kwargs[ATTR_EFFECT]
-            if self._effect == "HDMI":
-                self.json_request({"command": "clearall"})
-                self._icon = "mdi:video-input-hdmi"
-                self._brightness = 255
-                self._rgb_color = [125, 125, 125]
-            else:
-                self.json_request(
-                    {
-                        "command": "effect",
-                        "priority": self._priority,
-                        "effect": {"name": self._effect},
+            # Clear any color/effect.
+            if not await self._client.async_send_clear(
+                **{const.KEY_PRIORITY: self._priority}
+            ):
+                return
+
+            # Turn off all external sources, except the intended.
+            for key in const.KEY_COMPONENTID_EXTERNAL_SOURCES:
+                if not await self._client.async_send_set_component(
+                    **{
+                        const.KEY_COMPONENTSTATE: {
+                            const.KEY_COMPONENT: key,
+                            const.KEY_STATE: effect == key,
+                        }
                     }
-                )
-                self._icon = "mdi:lava-lamp"
-                self._rgb_color = [175, 0, 255]
-            return
+                ):
+                    return
 
-        cal_color = [int(round(x * float(brightness) / 255)) for x in rgb_color]
-        self.json_request(
-            {"command": "color", "priority": self._priority, "color": cal_color}
-        )
+        # == Set an effect
+        elif effect and effect != KEY_EFFECT_SOLID:
+            # This call should not be necessary, but without it there is no priorities-update issued:
+            # https://github.com/hyperion-project/hyperion.ng/issues/992
+            if not await self._client.async_send_clear(
+                **{const.KEY_PRIORITY: self._priority}
+            ):
+                return
 
-    def turn_off(self, **kwargs):
-        """Disconnect all remotes."""
-        self.json_request({"command": "clearall"})
-        self.json_request(
-            {"command": "color", "priority": self._priority, "color": [0, 0, 0]}
-        )
+            if not await self._client.async_send_set_effect(
+                **{
+                    const.KEY_PRIORITY: self._priority,
+                    const.KEY_EFFECT: {const.KEY_NAME: effect},
+                    const.KEY_ORIGIN: DEFAULT_ORIGIN,
+                }
+            ):
+                return
+        # == Set a color
+        else:
+            if not await self._client.async_send_set_color(
+                **{
+                    const.KEY_PRIORITY: self._priority,
+                    const.KEY_COLOR: rgb_color,
+                    const.KEY_ORIGIN: DEFAULT_ORIGIN,
+                }
+            ):
+                return
 
-    def update(self):
-        """Get the lights status."""
-        # postpone the immediate state check for changes that take time
-        if self._skip_update:
-            self._skip_update = False
+    async def async_turn_off(self, **kwargs):
+        """Disable the LED output component."""
+        if not await self._client.async_send_set_component(
+            **{
+                const.KEY_COMPONENTSTATE: {
+                    const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE,
+                    const.KEY_STATE: False,
+                }
+            }
+        ):
             return
-        response = self.json_request({"command": "serverinfo"})
-        if response:
-            # workaround for outdated Hyperion
-            if "activeLedColor" not in response["info"]:
-                self._rgb_color = self._default_color
-                self._rgb_mem = self._default_color
-                self._brightness = 255
-                self._icon = "mdi:lightbulb"
-                self._effect = None
-                return
-            # Check if Hyperion is in ambilight mode trough an HDMI grabber
-            try:
-                active_priority = response["info"]["priorities"][0]["priority"]
-                if active_priority == self._hdmi_priority:
-                    self._brightness = 255
-                    self._rgb_color = [125, 125, 125]
-                    self._icon = "mdi:video-input-hdmi"
-                    self._effect = "HDMI"
-                    return
-            except (KeyError, IndexError):
-                pass
-
-            led_color = response["info"]["activeLedColor"]
-            if not led_color or led_color[0]["RGB Value"] == [0, 0, 0]:
-                # Get the active effect
-                if response["info"].get("activeEffects"):
-                    self._rgb_color = [175, 0, 255]
-                    self._icon = "mdi:lava-lamp"
-                    try:
-                        s_name = response["info"]["activeEffects"][0]["script"]
-                        s_name = s_name.split("/")[-1][:-3].split("-")[0]
-                        self._effect = [
-                            x for x in self._effect_list if s_name.lower() in x.lower()
-                        ][0]
-                    except (KeyError, IndexError):
-                        self._effect = None
-                # Bulb off state
-                else:
-                    self._rgb_color = [0, 0, 0]
-                    self._icon = "mdi:lightbulb"
-                    self._effect = None
-            else:
-                # Get the RGB color
-                self._rgb_color = led_color[0]["RGB Value"]
-                self._brightness = max(self._rgb_color)
-                self._rgb_mem = [
-                    int(round(float(x) * 255 / self._brightness))
-                    for x in self._rgb_color
-                ]
-                self._icon = "mdi:lightbulb"
-                self._effect = None
-
-    def setup(self):
-        """Get the hostname of the remote."""
-        response = self.json_request({"command": "serverinfo"})
-        if response:
-            if self._name == self._host:
-                self._name = response["info"]["hostname"]
-            return True
-        return False
 
-    def json_request(self, request, wait_for_response=False):
-        """Communicate with the JSON server."""
-        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-        sock.settimeout(5)
-
-        try:
-            sock.connect((self._host, self._port))
-        except OSError:
-            sock.close()
-            return False
-
-        sock.send(bytearray(f"{json.dumps(request)}\n", "utf-8"))
-        try:
-            buf = sock.recv(4096)
-        except socket.timeout:
-            # Something is wrong, assume it's offline
-            sock.close()
-            return False
-
-        # Read until a newline or timeout
-        buffering = True
-        while buffering:
-            if "\n" in str(buf, "utf-8"):
-                response = str(buf, "utf-8").split("\n")[0]
-                buffering = False
+    def _set_internal_state(self, brightness=None, rgb_color=None, effect=None):
+        """Set the internal state."""
+        if brightness is not None:
+            self._brightness = brightness
+        if rgb_color is not None:
+            self._rgb_color = rgb_color
+        if effect is not None:
+            self._effect = effect
+            if effect == KEY_EFFECT_SOLID:
+                self._icon = ICON_LIGHTBULB
+            elif effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES:
+                self._icon = ICON_EXTERNAL_SOURCE
             else:
-                try:
-                    more = sock.recv(4096)
-                except socket.timeout:
-                    more = None
-                if not more:
-                    buffering = False
-                    response = str(buf, "utf-8")
-                else:
-                    buf += more
-
-        sock.close()
-        return json.loads(response)
+                self._icon = ICON_EFFECT
+
+    def _update_components(self, _=None):
+        """Update Hyperion components."""
+        self.async_write_ha_state()
+
+    def _update_adjustment(self, _=None):
+        """Update Hyperion adjustments."""
+        if self._client.adjustment:
+            brightness_pct = self._client.adjustment[0].get(
+                const.KEY_BRIGHTNESS, DEFAULT_BRIGHTNESS
+            )
+            if brightness_pct < 0 or brightness_pct > 100:
+                return
+            self._set_internal_state(
+                brightness=int(round((brightness_pct * 255) / float(100)))
+            )
+            self.async_write_ha_state()
+
+    def _update_priorities(self, _=None):
+        """Update Hyperion priorities."""
+        visible_priority = self._client.visible_priority
+        if visible_priority:
+            componentid = visible_priority.get(const.KEY_COMPONENTID)
+            if componentid in const.KEY_COMPONENTID_EXTERNAL_SOURCES:
+                self._set_internal_state(rgb_color=DEFAULT_COLOR, effect=componentid)
+            elif componentid == const.KEY_COMPONENTID_EFFECT:
+                # Owner is the effect name.
+                # See: https://docs.hyperion-project.org/en/json/ServerInfo.html#priorities
+                self._set_internal_state(
+                    rgb_color=DEFAULT_COLOR, effect=visible_priority[const.KEY_OWNER]
+                )
+            elif componentid == const.KEY_COMPONENTID_COLOR:
+                self._set_internal_state(
+                    rgb_color=visible_priority[const.KEY_VALUE][const.KEY_RGB],
+                    effect=KEY_EFFECT_SOLID,
+                )
+            self.async_write_ha_state()
+
+    def _update_effect_list(self, _=None):
+        """Update Hyperion effects."""
+        if not self._client.effects:
+            return
+        effect_list = []
+        for effect in self._client.effects or []:
+            if const.KEY_NAME in effect:
+                effect_list.append(effect[const.KEY_NAME])
+        if effect_list:
+            self._effect_list = effect_list
+            self.async_write_ha_state()
+
+    def _update_full_state(self):
+        """Update full Hyperion state."""
+        self._update_adjustment()
+        self._update_priorities()
+        self._update_effect_list()
+
+        _LOGGER.debug(
+            "Hyperion full state update: On=%s,Brightness=%i,Effect=%s "
+            "(%i effects total),Color=%s",
+            self.is_on,
+            self._brightness,
+            self._effect,
+            len(self._effect_list),
+            self._rgb_color,
+        )
+
+    def _update_client(self, json):
+        """Update client connection state."""
+        self.async_write_ha_state()
+
+    async def async_added_to_hass(self):
+        """Register callbacks when entity added to hass."""
+        self._client.set_callbacks(
+            {
+                f"{const.KEY_ADJUSTMENT}-{const.KEY_UPDATE}": self._update_adjustment,
+                f"{const.KEY_COMPONENTS}-{const.KEY_UPDATE}": self._update_components,
+                f"{const.KEY_EFFECTS}-{const.KEY_UPDATE}": self._update_effect_list,
+                f"{const.KEY_PRIORITIES}-{const.KEY_UPDATE}": self._update_priorities,
+                f"{const.KEY_CLIENT}-{const.KEY_UPDATE}": self._update_client,
+            }
+        )
+
+        # Load initial state.
+        self._update_full_state()
+        return True
diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json
index 6d9d0ae4d9def4da742272b3f37f0d546dadba1a..4a9bf2ada8c930908d54976c17918c9075f45f75 100644
--- a/homeassistant/components/hyperion/manifest.json
+++ b/homeassistant/components/hyperion/manifest.json
@@ -2,5 +2,6 @@
   "domain": "hyperion",
   "name": "Hyperion",
   "documentation": "https://www.home-assistant.io/integrations/hyperion",
-  "codeowners": []
+  "requirements": ["hyperion-py==0.3.0"],
+  "codeowners": ["@dermotduffy"]
 }
diff --git a/requirements_all.txt b/requirements_all.txt
index 5f5e82f43a88c9b2bb524665f7cf358b8dec1e1a..6b7a8a7feee3c907e57e0787b85d73c7545a3d92 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -774,6 +774,9 @@ huawei-lte-api==1.4.12
 # homeassistant.components.hydrawise
 hydrawiser==0.2
 
+# homeassistant.components.hyperion
+hyperion-py==0.3.0
+
 # homeassistant.components.bh1750
 # homeassistant.components.bme280
 # homeassistant.components.htu21d
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index dd28f3496fd01cb1d86b42494aa9b7c33d2dfc27..2846c38d69c92a77c0c8e65dc4946c569aa0fb0f 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -391,6 +391,9 @@ httplib2==0.10.3
 # homeassistant.components.huawei_lte
 huawei-lte-api==1.4.12
 
+# homeassistant.components.hyperion
+hyperion-py==0.3.0
+
 # homeassistant.components.iaqualink
 iaqualink==0.3.4
 
diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4c1ee67efa702742d9332da1760fe1dff171a9e
--- /dev/null
+++ b/tests/components/hyperion/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Hyperion component."""
diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py
new file mode 100644
index 0000000000000000000000000000000000000000..c400e34db51b51acc25987304ca05a769dfb022a
--- /dev/null
+++ b/tests/components/hyperion/test_light.py
@@ -0,0 +1,430 @@
+"""Tests for the Hyperion integration."""
+# from tests.async_mock import AsyncMock, MagicMock, patch
+from asynctest import CoroutineMock, Mock, call, patch
+from hyperion import const
+
+from homeassistant.components.hyperion import light as hyperion_light
+from homeassistant.components.light import (
+    ATTR_BRIGHTNESS,
+    ATTR_EFFECT,
+    ATTR_HS_COLOR,
+    DOMAIN,
+)
+from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
+from homeassistant.setup import async_setup_component
+
+TEST_HOST = "test-hyperion-host"
+TEST_PORT = const.DEFAULT_PORT
+TEST_NAME = "test_hyperion_name"
+TEST_PRIORITY = 128
+TEST_ENTITY_ID = f"{DOMAIN}.{TEST_NAME}"
+
+
+def create_mock_client():
+    """Create a mock Hyperion client."""
+    mock_client = Mock()
+    mock_client.async_client_connect = CoroutineMock(return_value=True)
+    mock_client.adjustment = None
+    mock_client.effects = None
+    mock_client.id = "%s:%i" % (TEST_HOST, TEST_PORT)
+    return mock_client
+
+
+def call_registered_callback(client, key, *args, **kwargs):
+    """Call a Hyperion entity callback that was registered with the client."""
+    return client.set_callbacks.call_args[0][0][key](*args, **kwargs)
+
+
+async def setup_entity(hass, client=None):
+    """Add a test Hyperion entity to hass."""
+    client = client or create_mock_client()
+    with patch("hyperion.client.HyperionClient", return_value=client):
+        assert await async_setup_component(
+            hass,
+            DOMAIN,
+            {
+                DOMAIN: {
+                    "platform": "hyperion",
+                    "name": TEST_NAME,
+                    "host": TEST_HOST,
+                    "port": const.DEFAULT_PORT,
+                    "priority": TEST_PRIORITY,
+                }
+            },
+        )
+    await hass.async_block_till_done()
+
+
+async def test_setup_platform(hass):
+    """Test setting up the platform."""
+    client = create_mock_client()
+    await setup_entity(hass, client=client)
+    assert hass.states.get(TEST_ENTITY_ID) is not None
+
+
+async def test_setup_platform_not_ready(hass):
+    """Test the platform not being ready."""
+    client = create_mock_client()
+    client.async_client_connect = CoroutineMock(return_value=False)
+
+    await setup_entity(hass, client=client)
+    assert hass.states.get(TEST_ENTITY_ID) is None
+
+
+async def test_light_basic_properies(hass):
+    """Test the basic properties."""
+    client = create_mock_client()
+    await setup_entity(hass, client=client)
+
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.state == "on"
+    assert entity_state.attributes["brightness"] == 255
+    assert entity_state.attributes["hs_color"] == (0.0, 0.0)
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB
+    assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID
+
+    # By default the effect list is the 3 external sources + 'Solid'.
+    assert len(entity_state.attributes["effect_list"]) == 4
+
+    assert (
+        entity_state.attributes["supported_features"] == hyperion_light.SUPPORT_HYPERION
+    )
+
+
+async def test_light_async_turn_on(hass):
+    """Test turning the light on."""
+    client = create_mock_client()
+    await setup_entity(hass, client=client)
+
+    # On (=), 100% (=), solid (=), [255,255,255] (=)
+    client.async_send_set_color = CoroutineMock(return_value=True)
+    await hass.services.async_call(
+        DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True
+    )
+
+    assert client.async_send_set_color.call_args == call(
+        **{
+            const.KEY_PRIORITY: TEST_PRIORITY,
+            const.KEY_COLOR: [255, 255, 255],
+            const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN,
+        }
+    )
+
+    # On (=), 50% (!), solid (=), [255,255,255] (=)
+    # ===
+    brightness = 128
+    client.async_send_set_color = CoroutineMock(return_value=True)
+    client.async_send_set_adjustment = CoroutineMock(return_value=True)
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_TURN_ON,
+        {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_BRIGHTNESS: brightness},
+        blocking=True,
+    )
+
+    assert client.async_send_set_adjustment.call_args == call(
+        **{const.KEY_ADJUSTMENT: {const.KEY_BRIGHTNESS: 50}}
+    )
+    assert client.async_send_set_color.call_args == call(
+        **{
+            const.KEY_PRIORITY: TEST_PRIORITY,
+            const.KEY_COLOR: [255, 255, 255],
+            const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN,
+        }
+    )
+
+    # Simulate a state callback from Hyperion.
+    client.adjustment = [{const.KEY_BRIGHTNESS: 50}]
+    call_registered_callback(client, "adjustment-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.state == "on"
+    assert entity_state.attributes["brightness"] == brightness
+
+    # On (=), 50% (=), solid (=), [0,255,255] (!)
+    hs_color = (180.0, 100.0)
+    client.async_send_set_color = CoroutineMock(return_value=True)
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_TURN_ON,
+        {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_HS_COLOR: hs_color},
+        blocking=True,
+    )
+
+    assert client.async_send_set_color.call_args == call(
+        **{
+            const.KEY_PRIORITY: TEST_PRIORITY,
+            const.KEY_COLOR: (0, 255, 255),
+            const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN,
+        }
+    )
+
+    # Simulate a state callback from Hyperion.
+    client.visible_priority = {
+        const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR,
+        const.KEY_VALUE: {const.KEY_RGB: (0, 255, 255)},
+    }
+
+    call_registered_callback(client, "priorities-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["hs_color"] == hs_color
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB
+
+    # On (=), 100% (!), solid, [0,255,255] (=)
+    brightness = 255
+    client.async_send_set_color = CoroutineMock(return_value=True)
+    client.async_send_set_adjustment = CoroutineMock(return_value=True)
+
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_TURN_ON,
+        {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_BRIGHTNESS: brightness},
+        blocking=True,
+    )
+
+    assert client.async_send_set_adjustment.call_args == call(
+        **{const.KEY_ADJUSTMENT: {const.KEY_BRIGHTNESS: 100}}
+    )
+    assert client.async_send_set_color.call_args == call(
+        **{
+            const.KEY_PRIORITY: TEST_PRIORITY,
+            const.KEY_COLOR: (0, 255, 255),
+            const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN,
+        }
+    )
+    client.adjustment = [{const.KEY_BRIGHTNESS: 100}]
+    call_registered_callback(client, "adjustment-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["brightness"] == brightness
+
+    # On (=), 100% (=), V4L (!), [0,255,255] (=)
+    effect = const.KEY_COMPONENTID_EXTERNAL_SOURCES[2]  # V4L
+    client.async_send_clear = CoroutineMock(return_value=True)
+    client.async_send_set_component = CoroutineMock(return_value=True)
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_TURN_ON,
+        {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_EFFECT: effect},
+        blocking=True,
+    )
+
+    assert client.async_send_clear.call_args == call(
+        **{const.KEY_PRIORITY: TEST_PRIORITY}
+    )
+    assert client.async_send_set_component.call_args_list == [
+        call(
+            **{
+                const.KEY_COMPONENTSTATE: {
+                    const.KEY_COMPONENT: const.KEY_COMPONENTID_EXTERNAL_SOURCES[0],
+                    const.KEY_STATE: False,
+                }
+            }
+        ),
+        call(
+            **{
+                const.KEY_COMPONENTSTATE: {
+                    const.KEY_COMPONENT: const.KEY_COMPONENTID_EXTERNAL_SOURCES[1],
+                    const.KEY_STATE: False,
+                }
+            }
+        ),
+        call(
+            **{
+                const.KEY_COMPONENTSTATE: {
+                    const.KEY_COMPONENT: const.KEY_COMPONENTID_EXTERNAL_SOURCES[2],
+                    const.KEY_STATE: True,
+                }
+            }
+        ),
+    ]
+    client.visible_priority = {const.KEY_COMPONENTID: effect}
+    call_registered_callback(client, "priorities-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_EXTERNAL_SOURCE
+    assert entity_state.attributes["effect"] == effect
+
+    # On (=), 100% (=), "Warm Blobs" (!), [0,255,255] (=)
+    effect = "Warm Blobs"
+    client.async_send_clear = CoroutineMock(return_value=True)
+    client.async_send_set_effect = CoroutineMock(return_value=True)
+
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_TURN_ON,
+        {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_EFFECT: effect},
+        blocking=True,
+    )
+
+    assert client.async_send_clear.call_args == call(
+        **{const.KEY_PRIORITY: TEST_PRIORITY}
+    )
+    assert client.async_send_set_effect.call_args == call(
+        **{
+            const.KEY_PRIORITY: TEST_PRIORITY,
+            const.KEY_EFFECT: {const.KEY_NAME: effect},
+            const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN,
+        }
+    )
+    client.visible_priority = {
+        const.KEY_COMPONENTID: const.KEY_COMPONENTID_EFFECT,
+        const.KEY_OWNER: effect,
+    }
+    call_registered_callback(client, "priorities-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_EFFECT
+    assert entity_state.attributes["effect"] == effect
+
+    # No calls if disconnected.
+    client.has_loaded_state = False
+    call_registered_callback(client, "client-update", {"loaded-state": False})
+    client.async_send_clear = CoroutineMock(return_value=True)
+    client.async_send_set_effect = CoroutineMock(return_value=True)
+
+    await hass.services.async_call(
+        DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True
+    )
+
+    assert not client.async_send_clear.called
+    assert not client.async_send_set_effect.called
+
+
+async def test_light_async_turn_off(hass):
+    """Test turning the light off."""
+    client = create_mock_client()
+    await setup_entity(hass, client=client)
+
+    client.async_send_set_component = CoroutineMock(return_value=True)
+    await hass.services.async_call(
+        DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True
+    )
+
+    assert client.async_send_set_component.call_args == call(
+        **{
+            const.KEY_COMPONENTSTATE: {
+                const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE,
+                const.KEY_STATE: False,
+            }
+        }
+    )
+
+    # No calls if no state loaded.
+    client.has_loaded_state = False
+    client.async_send_set_component = CoroutineMock(return_value=True)
+    call_registered_callback(client, "client-update", {"loaded-state": False})
+
+    await hass.services.async_call(
+        DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True
+    )
+
+    assert not client.async_send_set_component.called
+
+
+async def test_light_async_updates_from_hyperion_client(hass):
+    """Test receiving a variety of Hyperion client callbacks."""
+    client = create_mock_client()
+    await setup_entity(hass, client=client)
+
+    # Bright change gets accepted.
+    brightness = 10
+    client.adjustment = [{const.KEY_BRIGHTNESS: brightness}]
+    call_registered_callback(client, "adjustment-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0))
+
+    # Broken brightness value is ignored.
+    bad_brightness = -200
+    client.adjustment = [{const.KEY_BRIGHTNESS: bad_brightness}]
+    call_registered_callback(client, "adjustment-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0))
+
+    # Update components.
+    client.is_on.return_value = True
+    call_registered_callback(client, "components-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.state == "on"
+
+    client.is_on.return_value = False
+    call_registered_callback(client, "components-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.state == "off"
+
+    # Update priorities (V4L)
+    client.is_on.return_value = True
+    client.visible_priority = {const.KEY_COMPONENTID: const.KEY_COMPONENTID_V4L}
+    call_registered_callback(client, "priorities-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_EXTERNAL_SOURCE
+    assert entity_state.attributes["hs_color"] == (0.0, 0.0)
+    assert entity_state.attributes["effect"] == const.KEY_COMPONENTID_V4L
+
+    # Update priorities (Effect)
+    effect = "foo"
+    client.visible_priority = {
+        const.KEY_COMPONENTID: const.KEY_COMPONENTID_EFFECT,
+        const.KEY_OWNER: effect,
+    }
+
+    call_registered_callback(client, "priorities-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["effect"] == effect
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_EFFECT
+    assert entity_state.attributes["hs_color"] == (0.0, 0.0)
+
+    # Update priorities (Color)
+    rgb = (0, 100, 100)
+    client.visible_priority = {
+        const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR,
+        const.KEY_VALUE: {const.KEY_RGB: rgb},
+    }
+
+    call_registered_callback(client, "priorities-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB
+    assert entity_state.attributes["hs_color"] == (180.0, 100.0)
+
+    # Update effect list
+    effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}]
+    client.effects = effects
+    call_registered_callback(client, "effects-update")
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.attributes["effect_list"] == [
+        effect[const.KEY_NAME] for effect in effects
+    ] + const.KEY_COMPONENTID_EXTERNAL_SOURCES + [hyperion_light.KEY_EFFECT_SOLID]
+
+    # Update connection status (e.g. disconnection).
+
+    # Turn on late, check state, disconnect, ensure it cannot be turned off.
+    client.has_loaded_state = False
+    call_registered_callback(client, "client-update", {"loaded-state": False})
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.state == "unavailable"
+
+    # Update connection status (e.g. re-connection)
+    client.has_loaded_state = True
+    call_registered_callback(client, "client-update", {"loaded-state": True})
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+    assert entity_state.state == "on"
+
+
+async def test_full_state_loaded_on_start(hass):
+    """Test receiving a variety of Hyperion client callbacks."""
+    client = create_mock_client()
+
+    # Update full state (should call all update methods).
+    brightness = 25
+    client.adjustment = [{const.KEY_BRIGHTNESS: brightness}]
+    client.visible_priority = {
+        const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR,
+        const.KEY_VALUE: {const.KEY_RGB: (0, 100, 100)},
+    }
+    client.effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}]
+
+    await setup_entity(hass, client=client)
+
+    entity_state = hass.states.get(TEST_ENTITY_ID)
+
+    assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0))
+    assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID
+    assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB
+    assert entity_state.attributes["hs_color"] == (180.0, 100.0)