From c1ed584f2d917b83649f2e48a1e44e8730961a12 Mon Sep 17 00:00:00 2001
From: On Freund <onfreund@gmail.com>
Date: Fri, 21 Aug 2020 07:16:58 +0300
Subject: [PATCH] Add config flow to kodi (#38551)

* Add config flow to kodi

* Fix lint errors

* Remove entry update listener

* Create test_init.py

* Apply suggestions from code review

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update __init__.py

* fix indentation

* Apply suggestions from code review

* Apply suggestions from code review

* Update tests/components/kodi/__init__.py

* Fix init test

* Fix merge

* More review changes

* Apply suggestions from code review

* Apply suggestions from code review

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Fix black formatting

* Fix Flake8

* Don't store CONF_ID

* Fall back to entry id

* Apply suggestions from code review

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

* Update __init__.py

* Apply suggestions from code review

* Apply suggestions from code review

Co-authored-by: Chris Talkington <chris@talkingtontech.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
---
 CODEOWNERS                                    |   2 +-
 .../components/discovery/__init__.py          |   2 +-
 homeassistant/components/kodi/__init__.py     | 163 ++--
 homeassistant/components/kodi/config_flow.py  | 260 +++++++
 homeassistant/components/kodi/const.py        |  15 +
 .../components/kodi/device_trigger.py         |  89 +++
 homeassistant/components/kodi/manifest.json   |  11 +-
 homeassistant/components/kodi/media_player.py | 697 +++++++-----------
 homeassistant/components/kodi/strings.json    |  47 ++
 homeassistant/generated/config_flows.py       |   1 +
 homeassistant/generated/zeroconf.py           |   3 +
 requirements_all.txt                          |   9 +-
 requirements_test_all.txt                     |   3 +
 tests/components/kodi/__init__.py             |  41 ++
 tests/components/kodi/test_config_flow.py     | 325 ++++++++
 tests/components/kodi/test_device_trigger.py  | 129 ++++
 tests/components/kodi/test_init.py            |  25 +
 tests/components/kodi/util.py                 |  67 ++
 18 files changed, 1362 insertions(+), 527 deletions(-)
 create mode 100644 homeassistant/components/kodi/config_flow.py
 create mode 100644 homeassistant/components/kodi/device_trigger.py
 create mode 100644 homeassistant/components/kodi/strings.json
 create mode 100644 tests/components/kodi/__init__.py
 create mode 100644 tests/components/kodi/test_config_flow.py
 create mode 100644 tests/components/kodi/test_device_trigger.py
 create mode 100644 tests/components/kodi/test_init.py
 create mode 100644 tests/components/kodi/util.py

diff --git a/CODEOWNERS b/CODEOWNERS
index 4eb9b399059..6009cd55745 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -224,7 +224,7 @@ homeassistant/components/keenetic_ndms2/* @foxel
 homeassistant/components/kef/* @basnijholt
 homeassistant/components/keyboard_remote/* @bendavid
 homeassistant/components/knx/* @Julius2342
-homeassistant/components/kodi/* @armills
+homeassistant/components/kodi/* @armills @OnFreund
 homeassistant/components/konnected/* @heythisisnate @kit-klein
 homeassistant/components/lametric/* @robbiet480
 homeassistant/components/launch_library/* @ludeeus
diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py
index 921b76168ca..2879d4bfbec 100644
--- a/homeassistant/components/discovery/__init__.py
+++ b/homeassistant/components/discovery/__init__.py
@@ -70,7 +70,6 @@ SERVICE_HANDLERS = {
     "openhome": ("media_player", "openhome"),
     "bose_soundtouch": ("media_player", "soundtouch"),
     "bluesound": ("media_player", "bluesound"),
-    "kodi": ("media_player", "kodi"),
     "lg_smart_device": ("media_player", "lg_soundbar"),
     "nanoleaf_aurora": ("light", "nanoleaf"),
 }
@@ -87,6 +86,7 @@ MIGRATED_SERVICE_HANDLERS = [
     "harmony",
     "homekit",
     "ikea_tradfri",
+    "kodi",
     "philips_hue",
     "sonos",
     "songpal",
diff --git a/homeassistant/components/kodi/__init__.py b/homeassistant/components/kodi/__init__.py
index 094bdf0984b..dedb0ab09c4 100644
--- a/homeassistant/components/kodi/__init__.py
+++ b/homeassistant/components/kodi/__init__.py
@@ -1,89 +1,100 @@
 """The kodi component."""
 
 import asyncio
+import logging
 
-import voluptuous as vol
+from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection
 
-from homeassistant.components.kodi.const import DOMAIN
-from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
-from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM
-from homeassistant.helpers import config_validation as cv
-
-SERVICE_ADD_MEDIA = "add_to_playlist"
-SERVICE_CALL_METHOD = "call_method"
-
-ATTR_MEDIA_TYPE = "media_type"
-ATTR_MEDIA_NAME = "media_name"
-ATTR_MEDIA_ARTIST_NAME = "artist_name"
-ATTR_MEDIA_ID = "media_id"
-ATTR_METHOD = "method"
-
-MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids})
-
-KODI_ADD_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend(
-    {
-        vol.Required(ATTR_MEDIA_TYPE): cv.string,
-        vol.Optional(ATTR_MEDIA_ID): cv.string,
-        vol.Optional(ATTR_MEDIA_NAME): cv.string,
-        vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
-    }
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_PASSWORD,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_USERNAME,
+    EVENT_HOMEASSISTANT_STOP,
 )
-KODI_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend(
-    {vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+    CONF_WS_PORT,
+    DATA_CONNECTION,
+    DATA_KODI,
+    DATA_REMOVE_LISTENER,
+    DATA_VERSION,
+    DOMAIN,
 )
 
-SERVICE_TO_METHOD = {
-    SERVICE_ADD_MEDIA: {
-        "method": "async_add_media_to_playlist",
-        "schema": KODI_ADD_MEDIA_SCHEMA,
-    },
-    SERVICE_CALL_METHOD: {
-        "method": "async_call_method",
-        "schema": KODI_CALL_METHOD_SCHEMA,
-    },
-}
+_LOGGER = logging.getLogger(__name__)
+PLATFORMS = ["media_player"]
 
 
 async def async_setup(hass, config):
     """Set up the Kodi integration."""
-    if any((CONF_PLATFORM, DOMAIN) in cfg.items() for cfg in config.get(MP_DOMAIN, [])):
-        # Register the Kodi media_player services
-        async def async_service_handler(service):
-            """Map services to methods on MediaPlayerEntity."""
-            method = SERVICE_TO_METHOD.get(service.service)
-            if not method:
-                return
-
-            params = {
-                key: value for key, value in service.data.items() if key != "entity_id"
-            }
-            entity_ids = service.data.get("entity_id")
-            if entity_ids:
-                target_players = [
-                    player
-                    for player in hass.data[DOMAIN].values()
-                    if player.entity_id in entity_ids
-                ]
-            else:
-                target_players = hass.data[DOMAIN].values()
-
-            update_tasks = []
-            for player in target_players:
-                await getattr(player, method["method"])(**params)
-
-            for player in target_players:
-                if player.should_poll:
-                    update_coro = player.async_update_ha_state(True)
-                    update_tasks.append(update_coro)
-
-            if update_tasks:
-                await asyncio.wait(update_tasks)
-
-        for service in SERVICE_TO_METHOD:
-            schema = SERVICE_TO_METHOD[service]["schema"]
-            hass.services.async_register(
-                DOMAIN, service, async_service_handler, schema=schema
-            )
-
-    # Return boolean to indicate that initialization was successful.
+    hass.data.setdefault(DOMAIN, {})
     return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+    """Set up Kodi from a config entry."""
+    conn = get_kodi_connection(
+        entry.data[CONF_HOST],
+        entry.data[CONF_PORT],
+        entry.data[CONF_WS_PORT],
+        entry.data[CONF_USERNAME],
+        entry.data[CONF_PASSWORD],
+        entry.data[CONF_SSL],
+        session=async_get_clientsession(hass),
+    )
+    try:
+        await conn.connect()
+        kodi = Kodi(conn)
+        await kodi.ping()
+        raw_version = (await kodi.get_application_properties(["version"]))["version"]
+    except CannotConnectError as error:
+        raise ConfigEntryNotReady from error
+    except InvalidAuthError as error:
+        _LOGGER.error(
+            "Login to %s failed: [%s]", entry.data[CONF_HOST], error,
+        )
+        return False
+
+    async def _close(event):
+        await conn.close()
+
+    remove_stop_listener = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)
+
+    version = f"{raw_version['major']}.{raw_version['minor']}"
+    hass.data[DOMAIN][entry.entry_id] = {
+        DATA_CONNECTION: conn,
+        DATA_KODI: kodi,
+        DATA_REMOVE_LISTENER: remove_stop_listener,
+        DATA_VERSION: version,
+    }
+
+    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:
+        data = hass.data[DOMAIN].pop(entry.entry_id)
+        await data[DATA_CONNECTION].close()
+        data[DATA_REMOVE_LISTENER]()
+
+    return unload_ok
diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py
new file mode 100644
index 00000000000..0e1a94821e6
--- /dev/null
+++ b/homeassistant/components/kodi/config_flow.py
@@ -0,0 +1,260 @@
+"""Config flow for Kodi integration."""
+import logging
+
+from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_NAME,
+    CONF_PASSWORD,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_TIMEOUT,
+    CONF_USERNAME,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import DiscoveryInfoType, Optional
+
+from .const import (
+    CONF_WS_PORT,
+    DEFAULT_PORT,
+    DEFAULT_SSL,
+    DEFAULT_TIMEOUT,
+    DEFAULT_WS_PORT,
+)
+from .const import DOMAIN  # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def validate_http(hass: core.HomeAssistant, data):
+    """Validate the user input allows us to connect over HTTP."""
+
+    host = data[CONF_HOST]
+    port = data[CONF_PORT]
+    username = data.get(CONF_USERNAME)
+    password = data.get(CONF_PASSWORD)
+    ssl = data.get(CONF_SSL)
+    session = async_get_clientsession(hass)
+
+    _LOGGER.debug("Connecting to %s:%s over HTTP.", host, port)
+    khc = get_kodi_connection(
+        host, port, None, username, password, ssl, session=session
+    )
+    kodi = Kodi(khc)
+    try:
+        await kodi.ping()
+    except CannotConnectError as error:
+        raise CannotConnect from error
+    except InvalidAuthError as error:
+        raise InvalidAuth from error
+
+
+async def validate_ws(hass: core.HomeAssistant, data):
+    """Validate the user input allows us to connect over WS."""
+    ws_port = data.get(CONF_WS_PORT)
+    if not ws_port:
+        return
+
+    host = data[CONF_HOST]
+    port = data[CONF_PORT]
+    username = data.get(CONF_USERNAME)
+    password = data.get(CONF_PASSWORD)
+    ssl = data.get(CONF_SSL)
+
+    session = async_get_clientsession(hass)
+
+    _LOGGER.debug("Connecting to %s:%s over WebSocket.", host, ws_port)
+    kwc = get_kodi_connection(
+        host, port, ws_port, username, password, ssl, session=session
+    )
+    try:
+        await kwc.connect()
+        if not kwc.connected:
+            _LOGGER.warning("Cannot connect to %s:%s over WebSocket.", host, ws_port)
+            raise CannotConnect()
+        kodi = Kodi(kwc)
+        await kodi.ping()
+    except CannotConnectError as error:
+        raise CannotConnect from error
+
+
+class KodiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Kodi."""
+
+    VERSION = 1
+    CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+    def __init__(self):
+        """Initialize flow."""
+        self._host: Optional[str] = None
+        self._port: Optional[int] = None
+        self._ws_port: Optional[int] = None
+        self._name: Optional[str] = None
+        self._username: Optional[str] = None
+        self._password: Optional[str] = None
+        self._ssl: Optional[bool] = DEFAULT_SSL
+        self._discovery_name: Optional[str] = None
+
+    async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType):
+        """Handle zeroconf discovery."""
+        self._host = discovery_info["host"]
+        self._port = int(discovery_info["port"])
+        self._name = discovery_info["hostname"][: -len(".local.")]
+        uuid = discovery_info["properties"]["uuid"]
+        self._discovery_name = discovery_info["name"]
+
+        await self.async_set_unique_id(uuid)
+        self._abort_if_unique_id_configured(
+            updates={
+                CONF_HOST: self._host,
+                CONF_PORT: self._port,
+                CONF_NAME: self._name,
+            }
+        )
+
+        # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+        self.context.update({"title_placeholders": {CONF_NAME: self._name}})
+        return await self.async_step_discovery_confirm()
+
+    async def async_step_discovery_confirm(self, user_input=None):
+        """Handle user-confirmation of discovered node."""
+        if user_input is not None:
+            return await self.async_step_credentials()
+
+        return self.async_show_form(
+            step_id="discovery_confirm", description_placeholders={"name": self._name}
+        )
+
+    async def async_step_user(self, user_input=None):
+        """Handle the initial step."""
+        return await self.async_step_host(user_input)
+
+    async def async_step_host(self, user_input=None, errors=None):
+        """Handle host name and port input."""
+        if not errors:
+            errors = {}
+
+        if user_input is not None:
+            self._host = user_input[CONF_HOST]
+            self._port = user_input[CONF_PORT]
+            self._ssl = user_input[CONF_SSL]
+            return await self.async_step_credentials()
+
+        return self.async_show_form(
+            step_id="host", data_schema=self._host_schema(), errors=errors
+        )
+
+    async def async_step_credentials(self, user_input=None):
+        """Handle username and password input."""
+        errors = {}
+        if user_input is not None:
+            self._username = user_input.get(CONF_USERNAME)
+            self._password = user_input.get(CONF_PASSWORD)
+            try:
+                await validate_http(self.hass, self._get_data())
+                return await self.async_step_ws_port()
+            except InvalidAuth:
+                errors["base"] = "invalid_auth"
+            except CannotConnect:
+                if self._discovery_name:
+                    return self.async_abort(reason="cannot_connect")
+                return await self.async_step_host(errors={"base": "cannot_connect"})
+            except Exception:  # pylint: disable=broad-except
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+
+        return self.async_show_form(
+            step_id="credentials", data_schema=self._credentials_schema(), errors=errors
+        )
+
+    async def async_step_ws_port(self, user_input=None):
+        """Handle websocket port of discovered node."""
+        errors = {}
+        if user_input is not None:
+            self._ws_port = user_input.get(CONF_WS_PORT)
+            try:
+                await validate_ws(self.hass, self._get_data())
+                return self._create_entry()
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+            except Exception:  # pylint: disable=broad-except
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+
+        return self.async_show_form(
+            step_id="ws_port", data_schema=self._ws_port_schema(), errors=errors
+        )
+
+    async def async_step_import(self, data):
+        """Handle import from YAML."""
+        # We assume that the imported values work and just create the entry
+        return self.async_create_entry(title=data[CONF_NAME], data=data)
+
+    @callback
+    def _create_entry(self):
+        return self.async_create_entry(
+            title=self._name or self._host, data=self._get_data(),
+        )
+
+    @callback
+    def _get_data(self):
+        data = {
+            CONF_NAME: self._name,
+            CONF_HOST: self._host,
+            CONF_PORT: self._port,
+            CONF_WS_PORT: self._ws_port,
+            CONF_USERNAME: self._username,
+            CONF_PASSWORD: self._password,
+            CONF_SSL: self._ssl,
+            CONF_TIMEOUT: DEFAULT_TIMEOUT,
+        }
+
+        return data
+
+    @callback
+    def _ws_port_schema(self):
+        suggestion = self._ws_port or DEFAULT_WS_PORT
+        return vol.Schema(
+            {
+                vol.Optional(
+                    CONF_WS_PORT, description={"suggested_value": suggestion}
+                ): int
+            }
+        )
+
+    @callback
+    def _host_schema(self):
+        default_port = self._port or DEFAULT_PORT
+        default_ssl = self._ssl or DEFAULT_SSL
+        return vol.Schema(
+            {
+                vol.Required(CONF_HOST, default=self._host): str,
+                vol.Required(CONF_PORT, default=default_port): int,
+                vol.Required(CONF_SSL, default=default_ssl): bool,
+            }
+        )
+
+    @callback
+    def _credentials_schema(self):
+        return vol.Schema(
+            {
+                vol.Optional(
+                    CONF_USERNAME, description={"suggested_value": self._username}
+                ): str,
+                vol.Optional(
+                    CONF_PASSWORD, description={"suggested_value": self._password}
+                ): str,
+            }
+        )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+    """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+    """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/kodi/const.py b/homeassistant/components/kodi/const.py
index 7cb93f0d283..26677f99e5e 100644
--- a/homeassistant/components/kodi/const.py
+++ b/homeassistant/components/kodi/const.py
@@ -1,2 +1,17 @@
 """Constants for the Kodi platform."""
 DOMAIN = "kodi"
+
+CONF_WS_PORT = "ws_port"
+
+DATA_CONNECTION = "connection"
+DATA_KODI = "kodi"
+DATA_REMOVE_LISTENER = "remove_listener"
+DATA_VERSION = "version"
+
+DEFAULT_PORT = 8080
+DEFAULT_SSL = False
+DEFAULT_TIMEOUT = 5
+DEFAULT_WS_PORT = 9090
+
+EVENT_TURN_OFF = "kodi.turn_off"
+EVENT_TURN_ON = "kodi.turn_on"
diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py
new file mode 100644
index 00000000000..a5c93d08f72
--- /dev/null
+++ b/homeassistant/components/kodi/device_trigger.py
@@ -0,0 +1,89 @@
+"""Provides device automations for Kodi."""
+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 (
+    ATTR_ENTITY_ID,
+    CONF_DEVICE_ID,
+    CONF_DOMAIN,
+    CONF_ENTITY_ID,
+    CONF_PLATFORM,
+    CONF_TYPE,
+)
+from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv, entity_registry
+from homeassistant.helpers.typing import ConfigType
+
+from .const import DOMAIN, EVENT_TURN_OFF, EVENT_TURN_ON
+
+TRIGGER_TYPES = {"turn_on", "turn_off"}
+
+TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend(
+    {
+        vol.Required(CONF_ENTITY_ID): cv.entity_id,
+        vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
+    }
+)
+
+
+async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]:
+    """List device triggers for Kodi devices."""
+    registry = await entity_registry.async_get_registry(hass)
+    triggers = []
+
+    # Get all the integrations entities for this device
+    for entry in entity_registry.async_entries_for_device(registry, device_id):
+        if entry.domain == "media_player":
+            triggers.append(
+                {
+                    CONF_PLATFORM: "device",
+                    CONF_DEVICE_ID: device_id,
+                    CONF_DOMAIN: DOMAIN,
+                    CONF_ENTITY_ID: entry.entity_id,
+                    CONF_TYPE: "turn_on",
+                }
+            )
+            triggers.append(
+                {
+                    CONF_PLATFORM: "device",
+                    CONF_DEVICE_ID: device_id,
+                    CONF_DOMAIN: DOMAIN,
+                    CONF_ENTITY_ID: entry.entity_id,
+                    CONF_TYPE: "turn_off",
+                }
+            )
+
+    return triggers
+
+
+@callback
+def _attach_trigger(
+    hass: HomeAssistant, config: ConfigType, action: AutomationActionType, event_type
+):
+    @callback
+    def _handle_event(event: Event):
+        if event.data[ATTR_ENTITY_ID] == config[CONF_ENTITY_ID]:
+            hass.async_run_job(action({"trigger": config}, context=event.context))
+
+    return hass.bus.async_listen(event_type, _handle_event)
+
+
+async def async_attach_trigger(
+    hass: HomeAssistant,
+    config: ConfigType,
+    action: AutomationActionType,
+    automation_info: dict,
+) -> CALLBACK_TYPE:
+    """Attach a trigger."""
+    config = TRIGGER_SCHEMA(config)
+
+    if config[CONF_TYPE] == "turn_on":
+        return _attach_trigger(hass, config, action, EVENT_TURN_ON)
+
+    if config[CONF_TYPE] == "turn_off":
+        return _attach_trigger(hass, config, action, EVENT_TURN_OFF)
+
+    return lambda: None
diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json
index 43b318d1584..cc4aa0e88af 100644
--- a/homeassistant/components/kodi/manifest.json
+++ b/homeassistant/components/kodi/manifest.json
@@ -2,6 +2,11 @@
   "domain": "kodi",
   "name": "Kodi",
   "documentation": "https://www.home-assistant.io/integrations/kodi",
-  "requirements": ["jsonrpc-async==0.6", "jsonrpc-websocket==0.6"],
-  "codeowners": ["@armills"]
-}
+  "requirements": ["pykodi==0.1"],
+  "codeowners": [
+    "@armills",
+    "@OnFreund"
+  ],
+  "zeroconf": ["_xbmc-jsonrpc-h._tcp.local."],
+  "config_flow": true
+}
\ No newline at end of file
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index 85fe152b21a..96095767a01 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -1,21 +1,12 @@
 """Support for interfacing with the XBMC/Kodi JSON-RPC API."""
-import asyncio
-from collections import OrderedDict
 from datetime import timedelta
 from functools import wraps
 import logging
 import re
-import socket
-import urllib
 
-import aiohttp
-import jsonrpc_async
 import jsonrpc_base
-import jsonrpc_websocket
 import voluptuous as vol
 
-from homeassistant.components.kodi import SERVICE_CALL_METHOD
-from homeassistant.components.kodi.const import DOMAIN
 from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
 from homeassistant.components.media_player.const import (
     MEDIA_TYPE_CHANNEL,
@@ -38,27 +29,40 @@ from homeassistant.components.media_player.const import (
     SUPPORT_VOLUME_SET,
     SUPPORT_VOLUME_STEP,
 )
+from homeassistant.config_entries import SOURCE_IMPORT
 from homeassistant.const import (
+    ATTR_ENTITY_ID,
     CONF_HOST,
     CONF_NAME,
     CONF_PASSWORD,
     CONF_PORT,
     CONF_PROXY_SSL,
+    CONF_SSL,
     CONF_TIMEOUT,
     CONF_USERNAME,
-    EVENT_HOMEASSISTANT_STOP,
     STATE_IDLE,
     STATE_OFF,
     STATE_PAUSED,
     STATE_PLAYING,
 )
 from homeassistant.core import callback
-from homeassistant.helpers import config_validation as cv, script
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import config_validation as cv, entity_platform
 from homeassistant.helpers.event import async_track_time_interval
-from homeassistant.helpers.template import Template
 import homeassistant.util.dt as dt_util
-from homeassistant.util.yaml import dump
+
+from .const import (
+    CONF_WS_PORT,
+    DATA_CONNECTION,
+    DATA_KODI,
+    DATA_VERSION,
+    DEFAULT_PORT,
+    DEFAULT_SSL,
+    DEFAULT_TIMEOUT,
+    DEFAULT_WS_PORT,
+    DOMAIN,
+    EVENT_TURN_OFF,
+    EVENT_TURN_ON,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -69,13 +73,6 @@ CONF_TURN_ON_ACTION = "turn_on_action"
 CONF_TURN_OFF_ACTION = "turn_off_action"
 CONF_ENABLE_WEBSOCKET = "enable_websocket"
 
-DEFAULT_NAME = "Kodi"
-DEFAULT_PORT = 8080
-DEFAULT_TCP_PORT = 9090
-DEFAULT_TIMEOUT = 5
-DEFAULT_PROXY_SSL = False
-DEFAULT_ENABLE_WEBSOCKET = True
-
 DEPRECATED_TURN_OFF_ACTIONS = {
     None: None,
     "quit": "Application.Quit",
@@ -118,15 +115,18 @@ SUPPORT_KODI = (
     | SUPPORT_SHUFFLE_SET
     | SUPPORT_PLAY
     | SUPPORT_VOLUME_STEP
+    | SUPPORT_TURN_OFF
+    | SUPPORT_TURN_ON
 )
 
+
 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
     {
         vol.Required(CONF_HOST): cv.string,
-        vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+        vol.Optional(CONF_NAME): cv.string,
         vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
-        vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
-        vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean,
+        vol.Optional(CONF_TCP_PORT, default=DEFAULT_WS_PORT): cv.port,
+        vol.Optional(CONF_PROXY_SSL, default=DEFAULT_SSL): cv.boolean,
         vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA,
         vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(
             cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)
@@ -134,102 +134,93 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
         vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
         vol.Inclusive(CONF_USERNAME, "auth"): cv.string,
         vol.Inclusive(CONF_PASSWORD, "auth"): cv.string,
-        vol.Optional(
-            CONF_ENABLE_WEBSOCKET, default=DEFAULT_ENABLE_WEBSOCKET
-        ): cv.boolean,
+        vol.Optional(CONF_ENABLE_WEBSOCKET, default=True): cv.boolean,
     }
 )
 
 
-def _check_deprecated_turn_off(hass, turn_off_action):
-    """Create an equivalent script for old turn off actions."""
-    if isinstance(turn_off_action, str):
-        method = DEPRECATED_TURN_OFF_ACTIONS[turn_off_action]
-        new_config = OrderedDict(
-            [
-                ("service", f"{DOMAIN}.{SERVICE_CALL_METHOD}"),
-                (
-                    "data_template",
-                    OrderedDict([("entity_id", "{{ entity_id }}"), ("method", method)]),
-                ),
-            ]
-        )
-        example_conf = dump(OrderedDict([(CONF_TURN_OFF_ACTION, new_config)]))
-        _LOGGER.warning(
-            "The '%s' action for turn off Kodi is deprecated and "
-            "will cease to function in a future release. You need to "
-            "change it for a generic Home Assistant script sequence, "
-            "which is, for this turn_off action, like this:\n%s",
-            turn_off_action,
-            example_conf,
-        )
-        new_config["data_template"] = OrderedDict(
-            [
-                (key, Template(value, hass))
-                for key, value in new_config["data_template"].items()
-            ]
-        )
-        turn_off_action = [new_config]
-    return turn_off_action
+SERVICE_ADD_MEDIA = "add_to_playlist"
+SERVICE_CALL_METHOD = "call_method"
+
+ATTR_MEDIA_TYPE = "media_type"
+ATTR_MEDIA_NAME = "media_name"
+ATTR_MEDIA_ARTIST_NAME = "artist_name"
+ATTR_MEDIA_ID = "media_id"
+ATTR_METHOD = "method"
+
+
+KODI_ADD_MEDIA_SCHEMA = {
+    vol.Required(ATTR_MEDIA_TYPE): cv.string,
+    vol.Optional(ATTR_MEDIA_ID): cv.string,
+    vol.Optional(ATTR_MEDIA_NAME): cv.string,
+    vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
+}
+
+KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
+    {vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
+)
+
+
+def find_matching_config_entries_for_host(hass, host):
+    """Search existing config entries for one matching the host."""
+    for entry in hass.config_entries.async_entries(DOMAIN):
+        if entry.data[CONF_HOST] == host:
+            return entry
+    return None
 
 
 async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
     """Set up the Kodi platform."""
-    if DOMAIN not in hass.data:
-        hass.data[DOMAIN] = {}
-
-    unique_id = None
-    # Is this a manual configuration?
-    if discovery_info is None:
-        name = config.get(CONF_NAME)
-        host = config.get(CONF_HOST)
-        port = config.get(CONF_PORT)
-        tcp_port = config.get(CONF_TCP_PORT)
-        encryption = config.get(CONF_PROXY_SSL)
-        websocket = config.get(CONF_ENABLE_WEBSOCKET)
-    else:
-        name = f"{DEFAULT_NAME} ({discovery_info.get('hostname')})"
-        host = discovery_info.get("host")
-        port = discovery_info.get("port")
-        tcp_port = DEFAULT_TCP_PORT
-        encryption = DEFAULT_PROXY_SSL
-        websocket = DEFAULT_ENABLE_WEBSOCKET
-        properties = discovery_info.get("properties")
-        if properties is not None:
-            unique_id = properties.get("uuid", None)
-
-    # Only add a device once, so discovered devices do not override manual
-    # config.
-    ip_addr = socket.gethostbyname(host)
-    if ip_addr in hass.data[DOMAIN]:
+    if discovery_info:
+        # Now handled by zeroconf in the config flow
         return
 
-    # If we got an unique id, check that it does not exist already.
-    # This is necessary as netdisco does not deterministally return the same
-    # advertisement when the service is offered over multiple IP addresses.
-    if unique_id is not None:
-        for device in hass.data[DOMAIN].values():
-            if device.unique_id == unique_id:
-                return
+    host = config[CONF_HOST]
+    if find_matching_config_entries_for_host(hass, host):
+        return
 
-    entity = KodiDevice(
-        hass,
-        name=name,
-        host=host,
-        port=port,
-        tcp_port=tcp_port,
-        encryption=encryption,
-        username=config.get(CONF_USERNAME),
-        password=config.get(CONF_PASSWORD),
-        turn_on_action=config.get(CONF_TURN_ON_ACTION),
-        turn_off_action=config.get(CONF_TURN_OFF_ACTION),
-        timeout=config.get(CONF_TIMEOUT),
-        websocket=websocket,
-        unique_id=unique_id,
+    websocket = config.get(CONF_ENABLE_WEBSOCKET)
+    ws_port = config.get(CONF_TCP_PORT) if websocket else None
+
+    entry_data = {
+        CONF_NAME: config.get(CONF_NAME, host),
+        CONF_HOST: host,
+        CONF_PORT: config.get(CONF_PORT),
+        CONF_WS_PORT: ws_port,
+        CONF_USERNAME: config.get(CONF_USERNAME),
+        CONF_PASSWORD: config.get(CONF_PASSWORD),
+        CONF_SSL: config.get(CONF_PROXY_SSL),
+        CONF_TIMEOUT: config.get(CONF_TIMEOUT),
+    }
+
+    hass.async_create_task(
+        hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_data
+        )
     )
 
-    hass.data[DOMAIN][ip_addr] = entity
-    async_add_entities([entity], update_before_add=True)
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the Kodi media player platform."""
+    platform = entity_platform.current_platform.get()
+    platform.async_register_entity_service(
+        SERVICE_ADD_MEDIA, KODI_ADD_MEDIA_SCHEMA, "async_add_media_to_playlist"
+    )
+    platform.async_register_entity_service(
+        SERVICE_CALL_METHOD, KODI_CALL_METHOD_SCHEMA, "async_call_method"
+    )
+
+    data = hass.data[DOMAIN][config_entry.entry_id]
+    connection = data[DATA_CONNECTION]
+    version = data[DATA_VERSION]
+    kodi = data[DATA_KODI]
+    name = config_entry.data[CONF_NAME]
+    uid = config_entry.unique_id
+    if uid is None:
+        uid = config_entry.entry_id
+
+    entity = KodiEntity(connection, kodi, name, uid, version)
+    async_add_entities([entity])
 
 
 def cmd(func):
@@ -253,97 +244,38 @@ def cmd(func):
     return wrapper
 
 
-class KodiDevice(MediaPlayerEntity):
+class KodiEntity(MediaPlayerEntity):
     """Representation of a XBMC/Kodi device."""
 
-    def __init__(
-        self,
-        hass,
-        name,
-        host,
-        port,
-        tcp_port,
-        encryption=False,
-        username=None,
-        password=None,
-        turn_on_action=None,
-        turn_off_action=None,
-        timeout=DEFAULT_TIMEOUT,
-        websocket=True,
-        unique_id=None,
-    ):
-        """Initialize the Kodi device."""
-        self.hass = hass
+    def __init__(self, connection, kodi, name, uid, version):
+        """Initialize the Kodi entity."""
+        self._connection = connection
+        self._kodi = kodi
         self._name = name
-        self._unique_id = unique_id
-        self._media_position_updated_at = None
-        self._media_position = None
-
-        kwargs = {"timeout": timeout, "session": async_get_clientsession(hass)}
-
-        if username is not None:
-            kwargs["auth"] = aiohttp.BasicAuth(username, password)
-            image_auth_string = f"{username}:{password}@"
-        else:
-            image_auth_string = ""
-
-        http_protocol = "https" if encryption else "http"
-        ws_protocol = "wss" if encryption else "ws"
-
-        self._http_url = f"{http_protocol}://{host}:{port}/jsonrpc"
-        self._image_url = f"{http_protocol}://{image_auth_string}{host}:{port}/image"
-        self._ws_url = f"{ws_protocol}://{host}:{tcp_port}/jsonrpc"
-
-        self._http_server = jsonrpc_async.Server(self._http_url, **kwargs)
-        if websocket:
-            # Setup websocket connection
-            self._ws_server = jsonrpc_websocket.Server(self._ws_url, **kwargs)
-
-            # Register notification listeners
-            self._ws_server.Player.OnPause = self.async_on_speed_event
-            self._ws_server.Player.OnPlay = self.async_on_speed_event
-            self._ws_server.Player.OnAVStart = self.async_on_speed_event
-            self._ws_server.Player.OnAVChange = self.async_on_speed_event
-            self._ws_server.Player.OnResume = self.async_on_speed_event
-            self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event
-            self._ws_server.Player.OnSeek = self.async_on_speed_event
-            self._ws_server.Player.OnStop = self.async_on_stop
-            self._ws_server.Application.OnVolumeChanged = self.async_on_volume_changed
-            self._ws_server.System.OnQuit = self.async_on_quit
-            self._ws_server.System.OnRestart = self.async_on_quit
-            self._ws_server.System.OnSleep = self.async_on_quit
-
-            def on_hass_stop(event):
-                """Close websocket connection when hass stops."""
-                self.hass.async_create_task(self._ws_server.close())
-
-            self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
-        else:
-            self._ws_server = None
-
-        # Script creation for the turn on/off config options
-        if turn_on_action is not None:
-            turn_on_action = script.Script(
-                self.hass,
-                turn_on_action,
-                f"{self.name} turn ON script",
-                DOMAIN,
-                change_listener=self.async_update_ha_state(True),
-            )
-        if turn_off_action is not None:
-            turn_off_action = script.Script(
-                self.hass,
-                _check_deprecated_turn_off(hass, turn_off_action),
-                f"{self.name} turn OFF script",
-                DOMAIN,
-            )
-        self._turn_on_action = turn_on_action
-        self._turn_off_action = turn_off_action
-        self._enable_websocket = websocket
-        self._players = []
+        self._unique_id = uid
+        self._version = version
+        self._players = None
         self._properties = {}
         self._item = {}
         self._app_properties = {}
+        self._media_position_updated_at = None
+        self._media_position = None
+
+    def _reset_state(self, players=None):
+        self._players = players
+        self._properties = {}
+        self._item = {}
+        self._app_properties = {}
+        self._media_position_updated_at = None
+        self._media_position = None
+
+    @property
+    def _kodi_is_off(self):
+        return self._players is None
+
+    @property
+    def _no_active_players(self):
+        return not self._players
 
     @callback
     def async_on_speed_event(self, sender, data):
@@ -363,14 +295,10 @@ class KodiDevice(MediaPlayerEntity):
     def async_on_stop(self, sender, data):
         """Handle the stop of the player playback."""
         # Prevent stop notifications which are sent after quit notification
-        if self._players is None:
+        if self._kodi_is_off:
             return
 
-        self._players = []
-        self._properties = {}
-        self._item = {}
-        self._media_position_updated_at = None
-        self._media_position = None
+        self._reset_state([])
         self.async_write_ha_state()
 
     @callback
@@ -383,34 +311,31 @@ class KodiDevice(MediaPlayerEntity):
     @callback
     def async_on_quit(self, sender, data):
         """Reset the player state on quit action."""
-        self._players = None
-        self._properties = {}
-        self._item = {}
-        self._app_properties = {}
-        self.hass.async_create_task(self._ws_server.close())
-
-    async def _get_players(self):
-        """Return the active player objects or None."""
-        try:
-            return await self.server.Player.GetActivePlayers()
-        except jsonrpc_base.jsonrpc.TransportError:
-            if self._players is not None:
-                _LOGGER.info("Unable to fetch kodi data")
-                _LOGGER.debug("Unable to fetch kodi data", exc_info=True)
-            return None
+        self._reset_state()
+        self.hass.async_create_task(self._connection.close())
 
     @property
     def unique_id(self):
         """Return the unique id of the device."""
         return self._unique_id
 
+    @property
+    def device_info(self):
+        """Return device info for this device."""
+        return {
+            "identifiers": {(DOMAIN, self.unique_id)},
+            "name": self.name,
+            "manufacturer": "Kodi",
+            "sw_version": self._version,
+        }
+
     @property
     def state(self):
         """Return the state of the device."""
-        if self._players is None:
+        if self._kodi_is_off:
             return STATE_OFF
 
-        if not self._players:
+        if self._no_active_players:
             return STATE_IDLE
 
         if self._properties["speed"] == 0:
@@ -418,36 +343,13 @@ class KodiDevice(MediaPlayerEntity):
 
         return STATE_PLAYING
 
-    async def async_ws_connect(self):
-        """Connect to Kodi via websocket protocol."""
-        try:
-            ws_loop_future = await self._ws_server.ws_connect()
-        except jsonrpc_base.jsonrpc.TransportError:
-            _LOGGER.info("Unable to connect to Kodi via websocket")
-            _LOGGER.debug("Unable to connect to Kodi via websocket", exc_info=True)
-            return
-
-        async def ws_loop_wrapper():
-            """Catch exceptions from the websocket loop task."""
-            try:
-                await ws_loop_future
-            except jsonrpc_base.TransportError:
-                # Kodi abruptly ends ws connection when exiting. We will try
-                # to reconnect on the next poll.
-                pass
-            # Update HA state after Kodi disconnects
-            self.async_write_ha_state()
-
-        # Create a task instead of adding a tracking job, since this task will
-        # run until the websocket connection is closed.
-        self.hass.loop.create_task(ws_loop_wrapper())
-
     async def async_added_to_hass(self):
         """Connect the websocket if needed."""
-        if not self._enable_websocket:
+        if not self._connection.can_subscribe:
             return
 
-        asyncio.create_task(self.async_ws_connect())
+        if self._connection.connected:
+            self._on_ws_connected()
 
         self.async_on_remove(
             async_track_time_interval(
@@ -457,32 +359,62 @@ class KodiDevice(MediaPlayerEntity):
             )
         )
 
+    @callback
+    def _on_ws_connected(self):
+        """Call after ws is connected."""
+        self._register_ws_callbacks()
+        self.async_schedule_update_ha_state(True)
+
+    async def _async_ws_connect(self):
+        """Connect to Kodi via websocket protocol."""
+        try:
+            await self._connection.connect()
+            self._on_ws_connected()
+        except jsonrpc_base.jsonrpc.TransportError:
+            _LOGGER.info("Unable to connect to Kodi via websocket")
+            _LOGGER.debug("Unable to connect to Kodi via websocket", exc_info=True)
+
     async def _async_connect_websocket_if_disconnected(self, *_):
         """Reconnect the websocket if it fails."""
-        if not self._ws_server.connected:
-            await self.async_ws_connect()
+        if not self._connection.connected:
+            await self._async_ws_connect()
+
+    @callback
+    def _register_ws_callbacks(self):
+        self._connection.server.Player.OnPause = self.async_on_speed_event
+        self._connection.server.Player.OnPlay = self.async_on_speed_event
+        self._connection.server.Player.OnAVStart = self.async_on_speed_event
+        self._connection.server.Player.OnAVChange = self.async_on_speed_event
+        self._connection.server.Player.OnResume = self.async_on_speed_event
+        self._connection.server.Player.OnSpeedChanged = self.async_on_speed_event
+        self._connection.server.Player.OnSeek = self.async_on_speed_event
+        self._connection.server.Player.OnStop = self.async_on_stop
+        self._connection.server.Application.OnVolumeChanged = (
+            self.async_on_volume_changed
+        )
+        self._connection.server.System.OnQuit = self.async_on_quit
+        self._connection.server.System.OnRestart = self.async_on_quit
+        self._connection.server.System.OnSleep = self.async_on_quit
 
     async def async_update(self):
         """Retrieve latest state."""
-        self._players = await self._get_players()
-
-        if self._players is None:
-            self._properties = {}
-            self._item = {}
-            self._app_properties = {}
+        if not self._connection.connected:
+            self._reset_state()
             return
 
-        self._app_properties = await self.server.Application.GetProperties(
-            ["volume", "muted"]
-        )
+        self._players = await self._kodi.get_players()
+
+        if self._kodi_is_off:
+            self._reset_state()
+            return
 
         if self._players:
-            player_id = self._players[0]["playerid"]
+            self._app_properties = await self._kodi.get_application_properties(
+                ["volume", "muted"]
+            )
 
-            assert isinstance(player_id, int)
-
-            self._properties = await self.server.Player.GetProperties(
-                player_id, ["time", "totaltime", "speed", "live"]
+            self._properties = await self._kodi.get_player_properties(
+                self._players[0], ["time", "totaltime", "speed", "live"]
             )
 
             position = self._properties["time"]
@@ -490,37 +422,23 @@ class KodiDevice(MediaPlayerEntity):
                 self._media_position_updated_at = dt_util.utcnow()
                 self._media_position = position
 
-            self._item = (
-                await self.server.Player.GetItem(
-                    player_id,
-                    [
-                        "title",
-                        "file",
-                        "uniqueid",
-                        "thumbnail",
-                        "artist",
-                        "albumartist",
-                        "showtitle",
-                        "album",
-                        "season",
-                        "episode",
-                    ],
-                )
-            )["item"]
+            self._item = await self._kodi.get_playing_item_properties(
+                self._players[0],
+                [
+                    "title",
+                    "file",
+                    "uniqueid",
+                    "thumbnail",
+                    "artist",
+                    "albumartist",
+                    "showtitle",
+                    "album",
+                    "season",
+                    "episode",
+                ],
+            )
         else:
-            self._properties = {}
-            self._item = {}
-            self._app_properties = {}
-            self._media_position = None
-            self._media_position_updated_at = None
-
-    @property
-    def server(self):
-        """Active server for json-rpc requests."""
-        if self._enable_websocket and self._ws_server.connected:
-            return self._ws_server
-
-        return self._http_server
+            self._reset_state([])
 
     @property
     def name(self):
@@ -530,13 +448,13 @@ class KodiDevice(MediaPlayerEntity):
     @property
     def should_poll(self):
         """Return True if entity has to be polled for state."""
-        return not (self._enable_websocket and self._ws_server.connected)
+        return (not self._connection.can_subscribe) or (not self._connection.connected)
 
     @property
     def volume_level(self):
         """Volume level of the media player (0..1)."""
         if "volume" in self._app_properties:
-            return self._app_properties["volume"] / 100.0
+            return int(self._app_properties["volume"]) / 100.0
 
     @property
     def is_volume_muted(self):
@@ -598,9 +516,7 @@ class KodiDevice(MediaPlayerEntity):
         if thumbnail is None:
             return None
 
-        url_components = urllib.parse.urlparse(thumbnail)
-        if url_components.scheme == "image":
-            return f"{self._image_url}/{urllib.parse.quote_plus(thumbnail)}"
+        return self._kodi.thumbnail_url(thumbnail)
 
     @property
     def media_title(self):
@@ -650,128 +566,72 @@ class KodiDevice(MediaPlayerEntity):
     @property
     def supported_features(self):
         """Flag media player features that are supported."""
-        supported_features = SUPPORT_KODI
+        return SUPPORT_KODI
 
-        if self._turn_on_action is not None:
-            supported_features |= SUPPORT_TURN_ON
-
-        if self._turn_off_action is not None:
-            supported_features |= SUPPORT_TURN_OFF
-
-        return supported_features
-
-    @cmd
     async def async_turn_on(self):
-        """Execute turn_on_action to turn on media player."""
-        if self._turn_on_action is not None:
-            await self._turn_on_action.async_run(
-                variables={"entity_id": self.entity_id}
-            )
-        else:
-            _LOGGER.warning("turn_on requested but turn_on_action is none")
+        """Turn the media player on."""
+        _LOGGER.debug("Firing event to turn on device")
+        self.hass.bus.async_fire(EVENT_TURN_ON, {ATTR_ENTITY_ID: self.entity_id})
 
-    @cmd
     async def async_turn_off(self):
-        """Execute turn_off_action to turn off media player."""
-        if self._turn_off_action is not None:
-            await self._turn_off_action.async_run(
-                variables={"entity_id": self.entity_id}
-            )
-        else:
-            _LOGGER.warning("turn_off requested but turn_off_action is none")
+        """Turn the media player off."""
+        _LOGGER.debug("Firing event to turn off device")
+        self.hass.bus.async_fire(EVENT_TURN_OFF, {ATTR_ENTITY_ID: self.entity_id})
 
     @cmd
     async def async_volume_up(self):
         """Volume up the media player."""
-        assert (await self.server.Input.ExecuteAction("volumeup")) == "OK"
+        await self._kodi.volume_up()
 
     @cmd
     async def async_volume_down(self):
         """Volume down the media player."""
-        assert (await self.server.Input.ExecuteAction("volumedown")) == "OK"
+        await self._kodi.volume_down()
 
     @cmd
     async def async_set_volume_level(self, volume):
         """Set volume level, range 0..1."""
-        await self.server.Application.SetVolume(int(volume * 100))
+        await self._kodi.set_volume_level(int(volume * 100))
 
     @cmd
     async def async_mute_volume(self, mute):
         """Mute (true) or unmute (false) media player."""
-        await self.server.Application.SetMute(mute)
-
-    async def async_set_play_state(self, state):
-        """Handle play/pause/toggle."""
-        players = await self._get_players()
-
-        if players is not None and players:
-            await self.server.Player.PlayPause(players[0]["playerid"], state)
+        await self._kodi.mute(mute)
 
     @cmd
     async def async_media_play_pause(self):
         """Pause media on media player."""
-        await self.async_set_play_state("toggle")
+        await self._kodi.play_pause()
 
     @cmd
     async def async_media_play(self):
         """Play media."""
-        await self.async_set_play_state(True)
+        await self._kodi.play()
 
     @cmd
     async def async_media_pause(self):
         """Pause the media player."""
-        await self.async_set_play_state(False)
+        await self._kodi.pause()
 
     @cmd
     async def async_media_stop(self):
         """Stop the media player."""
-        players = await self._get_players()
-
-        if players:
-            await self.server.Player.Stop(players[0]["playerid"])
-
-    async def _goto(self, direction):
-        """Handle for previous/next track."""
-        players = await self._get_players()
-
-        if players:
-            if direction == "previous":
-                # First seek to position 0. Kodi goes to the beginning of the
-                # current track if the current track is not at the beginning.
-                await self.server.Player.Seek(players[0]["playerid"], 0)
-
-            await self.server.Player.GoTo(players[0]["playerid"], direction)
+        await self._kodi.stop()
 
     @cmd
     async def async_media_next_track(self):
         """Send next track command."""
-        await self._goto("next")
+        await self._kodi.next_track()
 
     @cmd
     async def async_media_previous_track(self):
         """Send next track command."""
-        await self._goto("previous")
+        await self._kodi.previous_track()
 
     @cmd
     async def async_media_seek(self, position):
         """Send seek command."""
-        players = await self._get_players()
-
-        time = {}
-
-        time["milliseconds"] = int((position % 1) * 1000)
-        position = int(position)
-
-        time["seconds"] = int(position % 60)
-        position /= 60
-
-        time["minutes"] = int(position % 60)
-        position /= 60
-
-        time["hours"] = int(position)
-
-        if players:
-            await self.server.Player.Seek(players[0]["playerid"], time)
+        await self._kodi.media_seek(position)
 
     @cmd
     async def async_play_media(self, media_type, media_id, **kwargs):
@@ -779,30 +639,27 @@ class KodiDevice(MediaPlayerEntity):
         media_type_lower = media_type.lower()
 
         if media_type_lower == MEDIA_TYPE_CHANNEL:
-            await self.server.Player.Open({"item": {"channelid": int(media_id)}})
+            await self._kodi.play_channel(int(media_id))
         elif media_type_lower == MEDIA_TYPE_PLAYLIST:
-            await self.server.Player.Open({"item": {"playlistid": int(media_id)}})
+            await self._kodi.play_playlist(int(media_id))
         elif media_type_lower == "directory":
-            await self.server.Player.Open({"item": {"directory": str(media_id)}})
-        elif media_type_lower == "plugin":
-            await self.server.Player.Open({"item": {"file": str(media_id)}})
+            await self._kodi.play_directory(str(media_id))
         else:
-            await self.server.Player.Open({"item": {"file": str(media_id)}})
+            await self._kodi.play_file(str(media_id))
 
+    @cmd
     async def async_set_shuffle(self, shuffle):
         """Set shuffle mode, for the first player."""
-        if not self._players:
+        if self._no_active_players:
             raise RuntimeError("Error: No active player.")
-        await self.server.Player.SetShuffle(
-            {"playerid": self._players[0]["playerid"], "shuffle": shuffle}
-        )
+        await self._kodi.set_shuffle(shuffle)
 
     async def async_call_method(self, method, **kwargs):
         """Run Kodi JSONRPC API method with params."""
         _LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs)
         result_ok = False
         try:
-            result = await getattr(self.server, method)(**kwargs)
+            result = self._kodi.call_method(method, **kwargs)
             result_ok = True
         except jsonrpc_base.jsonrpc.ProtocolError as exc:
             result = exc.args[2]["error"]
@@ -835,10 +692,14 @@ class KodiDevice(MediaPlayerEntity):
             )
         return result
 
+    async def async_clear_playlist(self):
+        """Clear default playlist (i.e. playlistid=0)."""
+        await self._kodi.clear_playlist()
+
     async def async_add_media_to_playlist(
         self, media_type, media_id=None, media_name="ALL", artist_name=""
     ):
-        """Add a media to default playlist (i.e. playlistid=0).
+        """Add a media to default playlist.
 
         First the media type must be selected, then
         the media can be specified in terms of id or
@@ -846,78 +707,43 @@ class KodiDevice(MediaPlayerEntity):
         All the albums of an artist can be added with
         media_name="ALL"
         """
-        params = {"playlistid": 0}
         if media_type == "SONG":
             if media_id is None:
-                media_id = await self.async_find_song(media_name, artist_name)
+                media_id = await self._async_find_song(media_name, artist_name)
             if media_id:
-                params["item"] = {"songid": int(media_id)}
+                self._kodi.add_song_to_playlist(int(media_id))
 
         elif media_type == "ALBUM":
             if media_id is None:
                 if media_name == "ALL":
-                    await self.async_add_all_albums(artist_name)
+                    await self._async_add_all_albums(artist_name)
                     return
 
-                media_id = await self.async_find_album(media_name, artist_name)
+                media_id = await self._async_find_album(media_name, artist_name)
             if media_id:
-                params["item"] = {"albumid": int(media_id)}
+                self._kodi.add_album_to_playlist(int(media_id))
 
         else:
             raise RuntimeError("Unrecognized media type.")
 
-        if media_id is not None:
-            try:
-                await self.server.Playlist.Add(params)
-            except jsonrpc_base.jsonrpc.ProtocolError as exc:
-                result = exc.args[2]["error"]
-                _LOGGER.error(
-                    "Run API method %s.Playlist.Add(%s) error: %s",
-                    self.entity_id,
-                    media_type,
-                    result,
-                )
-            except jsonrpc_base.jsonrpc.TransportError:
-                _LOGGER.warning(
-                    "TransportError trying to add playlist to %s", self.entity_id
-                )
-        else:
+        if media_id is None:
             _LOGGER.warning("No media detected for Playlist.Add")
 
-    async def async_add_all_albums(self, artist_name):
+    async def _async_add_all_albums(self, artist_name):
         """Add all albums of an artist to default playlist (i.e. playlistid=0).
 
         The artist is specified in terms of name.
         """
-        artist_id = await self.async_find_artist(artist_name)
+        artist_id = await self._async_find_artist(artist_name)
 
-        albums = await self.async_get_albums(artist_id)
+        albums = await self._kodi.get_albums(artist_id)
 
         for alb in albums["albums"]:
-            await self.server.Playlist.Add(
-                {"playlistid": 0, "item": {"albumid": int(alb["albumid"])}}
-            )
+            await self._kodi.add_album_to_playlist(int(alb["albumid"]))
 
-    async def async_clear_playlist(self):
-        """Clear default playlist (i.e. playlistid=0)."""
-        return self.server.Playlist.Clear({"playlistid": 0})
-
-    async def async_get_artists(self):
-        """Get artists list."""
-        return await self.server.AudioLibrary.GetArtists()
-
-    async def async_get_albums(self, artist_id=None):
-        """Get albums list."""
-        if artist_id is None:
-            return await self.server.AudioLibrary.GetAlbums()
-
-        return await self.server.AudioLibrary.GetAlbums(
-            {"filter": {"artistid": int(artist_id)}}
-        )
-
-    async def async_find_artist(self, artist_name):
+    async def _async_find_artist(self, artist_name):
         """Find artist by name."""
-        artists = await self.async_get_artists()
+        artists = await self._kodi.get_artists()
         try:
             out = self._find(artist_name, [a["artist"] for a in artists["artists"]])
             return artists["artists"][out[0][0]]["artistid"]
@@ -925,35 +751,26 @@ class KodiDevice(MediaPlayerEntity):
             _LOGGER.warning("No artists were found: %s", artist_name)
             return None
 
-    async def async_get_songs(self, artist_id=None):
-        """Get songs list."""
-        if artist_id is None:
-            return await self.server.AudioLibrary.GetSongs()
-
-        return await self.server.AudioLibrary.GetSongs(
-            {"filter": {"artistid": int(artist_id)}}
-        )
-
-    async def async_find_song(self, song_name, artist_name=""):
+    async def _async_find_song(self, song_name, artist_name=""):
         """Find song by name and optionally artist name."""
         artist_id = None
         if artist_name != "":
-            artist_id = await self.async_find_artist(artist_name)
+            artist_id = await self._async_find_artist(artist_name)
 
-        songs = await self.async_get_songs(artist_id)
+        songs = await self._kodi.get_songs(artist_id)
         if songs["limits"]["total"] == 0:
             return None
 
         out = self._find(song_name, [a["label"] for a in songs["songs"]])
         return songs["songs"][out[0][0]]["songid"]
 
-    async def async_find_album(self, album_name, artist_name=""):
+    async def _async_find_album(self, album_name, artist_name=""):
         """Find album by name and optionally artist name."""
         artist_id = None
         if artist_name != "":
-            artist_id = await self.async_find_artist(artist_name)
+            artist_id = await self._async_find_artist(artist_name)
 
-        albums = await self.async_get_albums(artist_id)
+        albums = await self._kodi.get_albums(artist_id)
         try:
             out = self._find(album_name, [a["label"] for a in albums["albums"]])
             return albums["albums"][out[0][0]]["albumid"]
diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json
new file mode 100644
index 00000000000..fe7c0d52149
--- /dev/null
+++ b/homeassistant/components/kodi/strings.json
@@ -0,0 +1,47 @@
+{
+  "config": {
+    "flow_title": "Kodi: {name}",
+    "step": {
+      "host": {
+        "description": "Kodi connection information. Please make sure to enable \"Allow control of Kodi via HTTP\" in System/Settings/Network/Services.",
+        "data": {
+          "host": "[%key:common::config_flow::data::host%]",
+          "port": "[%key:common::config_flow::data::port%]",
+          "ssl": "Connect over SSL"
+        }
+      },
+      "discovery_confirm": {
+        "description": "Do you want to add Kodi (`{name}`) to Home Assistant?",
+        "title": "Discovered Kodi"
+      },
+      "ws_port": {
+        "description": "The WebSocket port (sometimes called TCP port in Kodi). In order to connect over WebSocket, you need to enable \"Allow programs ... to control Kodi\" in System/Settings/Network/Services. If WebSocket is not enabled, remove the port and leave empty.",
+        "data": {
+          "ws_port": "[%key:common::config_flow::data::port%]"
+        }
+      },
+      "credentials": {
+        "description": "Please enter your Kodi user name and password. These can be found in System/Settings/Network/Services.",
+        "data": {
+          "username": "[%key:common::config_flow::data::username%]",
+          "password": "[%key:common::config_flow::data::password%]"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+      "unknown": "[%key:common::config_flow::error::unknown%]"
+    },
+    "abort": {
+      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+      "cannot_connect": "Cannot connect to discovered Kodi"
+    }
+  },
+  "device_automation": {
+    "trigger_type": {
+      "turn_on": "{entity_name} was requested to turn on",
+      "turn_off": "{entity_name} was requested to turn off"
+    }
+  }
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index d8934bdead8..8a9343cf58b 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -95,6 +95,7 @@ FLOWS = [
     "isy994",
     "izone",
     "juicenet",
+    "kodi",
     "konnected",
     "life360",
     "lifx",
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 58da782a75f..3e686061de8 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -67,6 +67,9 @@ ZEROCONF = {
     ],
     "_wled._tcp.local.": [
         "wled"
+    ],
+    "_xbmc-jsonrpc-h._tcp.local.": [
+        "kodi"
     ]
 }
 
diff --git a/requirements_all.txt b/requirements_all.txt
index 75dbce861c2..ff2305d503a 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -805,12 +805,6 @@ iperf3==0.1.11
 # homeassistant.components.verisure
 jsonpath==0.82
 
-# homeassistant.components.kodi
-jsonrpc-async==0.6
-
-# homeassistant.components.kodi
-jsonrpc-websocket==0.6
-
 # homeassistant.components.kaiterra
 kaiterra-async-client==0.0.2
 
@@ -1434,6 +1428,9 @@ pyitachip2ir==0.0.7
 # homeassistant.components.kira
 pykira==0.1.1
 
+# homeassistant.components.kodi
+pykodi==0.1
+
 # homeassistant.components.kwb
 pykwb==0.0.8
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 45f83bd1b2d..12f5de1c5a3 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -683,6 +683,9 @@ pyisy==2.0.2
 # homeassistant.components.kira
 pykira==0.1.1
 
+# homeassistant.components.kodi
+pykodi==0.1
+
 # homeassistant.components.lastfm
 pylast==3.2.1
 
diff --git a/tests/components/kodi/__init__.py b/tests/components/kodi/__init__.py
new file mode 100644
index 00000000000..bbb7c962143
--- /dev/null
+++ b/tests/components/kodi/__init__.py
@@ -0,0 +1,41 @@
+"""Tests for the Kodi integration."""
+from homeassistant.components.kodi.const import CONF_WS_PORT, DOMAIN
+from homeassistant.const import (
+    CONF_HOST,
+    CONF_NAME,
+    CONF_PASSWORD,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_USERNAME,
+)
+
+from .util import MockConnection
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def init_integration(hass) -> MockConfigEntry:
+    """Set up the Kodi integration in Home Assistant."""
+    entry_data = {
+        CONF_NAME: "name",
+        CONF_HOST: "1.1.1.1",
+        CONF_PORT: 8080,
+        CONF_WS_PORT: 9090,
+        CONF_USERNAME: "user",
+        CONF_PASSWORD: "pass",
+        CONF_SSL: False,
+    }
+    entry = MockConfigEntry(domain=DOMAIN, data=entry_data, title="name")
+    with patch("homeassistant.components.kodi.Kodi.ping", return_value=True), patch(
+        "homeassistant.components.kodi.Kodi.get_application_properties",
+        return_value={"version": {"major": 1, "minor": 1}},
+    ), patch(
+        "homeassistant.components.kodi.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        entry.add_to_hass(hass)
+        await hass.config_entries.async_setup(entry.entry_id)
+        await hass.async_block_till_done()
+
+    return entry
diff --git a/tests/components/kodi/test_config_flow.py b/tests/components/kodi/test_config_flow.py
new file mode 100644
index 00000000000..7933081b70f
--- /dev/null
+++ b/tests/components/kodi/test_config_flow.py
@@ -0,0 +1,325 @@
+"""Test the Kodi config flow."""
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.kodi.config_flow import (
+    CannotConnectError,
+    InvalidAuthError,
+)
+from homeassistant.components.kodi.const import DEFAULT_TIMEOUT, DOMAIN
+
+from .util import (
+    TEST_CREDENTIALS,
+    TEST_DISCOVERY,
+    TEST_HOST,
+    TEST_IMPORT,
+    TEST_WS_PORT,
+    UUID,
+    MockConnection,
+)
+
+from tests.async_mock import AsyncMock, patch
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+async def user_flow(hass):
+    """Return a user-initiated flow after filling in host info."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+    assert result["type"] == "form"
+    assert result["errors"] == {}
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"], TEST_HOST
+    )
+
+    assert result["type"] == "form"
+    assert result["errors"] == {}
+
+    return result["flow_id"]
+
+
+@pytest.fixture
+async def discovery_flow(hass):
+    """Return a discovery flow after confirmation."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+    )
+    assert result["type"] == "form"
+    result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+
+    assert result["type"] == "form"
+    assert result["errors"] == {}
+    return result["flow_id"]
+
+
+async def test_user_flow(hass, user_flow):
+    """Test a successful user initiated flow."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping", return_value=True,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ), patch(
+        "homeassistant.components.kodi.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.kodi.async_setup_entry", return_value=True,
+    ) as mock_setup_entry:
+        result = await hass.config_entries.flow.async_configure(
+            user_flow, TEST_CREDENTIALS
+        )
+
+        assert result["type"] == "form"
+        assert result["errors"] == {}
+
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"], TEST_WS_PORT
+        )
+
+    assert result2["type"] == "create_entry"
+    assert result2["title"] == TEST_HOST["host"]
+    assert result2["data"] == {
+        **TEST_HOST,
+        **TEST_CREDENTIALS,
+        **TEST_WS_PORT,
+        "name": None,
+        "timeout": DEFAULT_TIMEOUT,
+    }
+
+    await hass.async_block_till_done()
+    assert len(mock_setup.mock_calls) == 1
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass, user_flow):
+    """Test we handle invalid auth."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping",
+        side_effect=InvalidAuthError,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result = await hass.config_entries.flow.async_configure(
+            user_flow, TEST_CREDENTIALS
+        )
+
+    assert result["type"] == "form"
+    assert result["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect_http(hass, user_flow):
+    """Test we handle cannot connect over HTTP error."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping",
+        side_effect=CannotConnectError,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result = await hass.config_entries.flow.async_configure(
+            user_flow, TEST_CREDENTIALS
+        )
+
+    assert result["type"] == "form"
+    assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_exception_http(hass, user_flow):
+    """Test we handle generic exception over HTTP."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping", side_effect=Exception,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result = await hass.config_entries.flow.async_configure(
+            user_flow, TEST_CREDENTIALS
+        )
+
+    assert result["type"] == "form"
+    assert result["errors"] == {"base": "unknown"}
+
+
+async def test_form_cannot_connect_ws(hass, user_flow):
+    """Test we handle cannot connect over WebSocket error."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping", return_value=True,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result = await hass.config_entries.flow.async_configure(
+            user_flow, TEST_CREDENTIALS
+        )
+
+    with patch.object(
+        MockConnection, "connect", AsyncMock(side_effect=CannotConnectError)
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"], TEST_WS_PORT
+        )
+
+    assert result2["type"] == "form"
+    assert result2["errors"] == {"base": "cannot_connect"}
+
+    with patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(connected=False),
+    ):
+        result3 = await hass.config_entries.flow.async_configure(
+            result2["flow_id"], TEST_WS_PORT
+        )
+
+    assert result3["type"] == "form"
+    assert result3["errors"] == {"base": "cannot_connect"}
+
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping",
+        side_effect=CannotConnectError,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result4 = await hass.config_entries.flow.async_configure(
+            result3["flow_id"], TEST_WS_PORT
+        )
+
+    assert result4["type"] == "form"
+    assert result4["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_exception_ws(hass, user_flow):
+    """Test we handle generic exception over WebSocket."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping", return_value=True,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result = await hass.config_entries.flow.async_configure(
+            user_flow, TEST_CREDENTIALS
+        )
+
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping", side_effect=Exception,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"], TEST_WS_PORT
+        )
+
+    assert result2["type"] == "form"
+    assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_discovery(hass, discovery_flow):
+    """Test discovery flow works."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping", return_value=True,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ), patch(
+        "homeassistant.components.kodi.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.kodi.async_setup_entry", return_value=True,
+    ) as mock_setup_entry:
+        result = await hass.config_entries.flow.async_configure(
+            discovery_flow, TEST_CREDENTIALS
+        )
+
+        assert result["type"] == "form"
+        assert result["errors"] == {}
+
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"], TEST_WS_PORT
+        )
+
+    assert result2["type"] == "create_entry"
+    assert result2["title"] == "hostname"
+    assert result2["data"] == {
+        **TEST_HOST,
+        **TEST_CREDENTIALS,
+        **TEST_WS_PORT,
+        "name": "hostname",
+        "timeout": DEFAULT_TIMEOUT,
+    }
+
+    await hass.async_block_till_done()
+    assert len(mock_setup.mock_calls) == 1
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_discovery_cannot_connect_http(hass, discovery_flow):
+    """Test discovery aborts if cannot connect."""
+    with patch(
+        "homeassistant.components.kodi.config_flow.Kodi.ping",
+        side_effect=CannotConnectError,
+    ), patch(
+        "homeassistant.components.kodi.config_flow.get_kodi_connection",
+        return_value=MockConnection(),
+    ):
+        result = await hass.config_entries.flow.async_configure(
+            discovery_flow, TEST_CREDENTIALS
+        )
+
+    assert result["type"] == "abort"
+    assert result["reason"] == "cannot_connect"
+
+
+async def test_discovery_duplicate_data(hass, discovery_flow):
+    """Test discovery aborts if same mDNS packet arrives."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+    )
+    assert result["type"] == "abort"
+    assert result["reason"] == "already_in_progress"
+
+
+async def test_discovery_updates_unique_id(hass):
+    """Test a duplicate discovery id aborts and updates existing entry."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        unique_id=UUID,
+        data={"host": "dummy", "port": 11, "namename": "dummy.local."},
+    )
+
+    entry.add_to_hass(hass)
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+    )
+
+    assert result["type"] == "abort"
+    assert result["reason"] == "already_configured"
+
+    assert entry.data["host"] == "1.1.1.1"
+    assert entry.data["port"] == 8080
+    assert entry.data["name"] == "hostname"
+
+
+async def test_form_import(hass):
+    """Test we get the form with import source."""
+    with patch(
+        "homeassistant.components.kodi.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.kodi.async_setup_entry", return_value=True,
+    ) as mock_setup_entry:
+        result = await hass.config_entries.flow.async_init(
+            DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_IMPORT,
+        )
+
+    assert result["type"] == "create_entry"
+    assert result["title"] == TEST_IMPORT["name"]
+    assert result["data"] == TEST_IMPORT
+
+    await hass.async_block_till_done()
+    assert len(mock_setup.mock_calls) == 1
+    assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py
new file mode 100644
index 00000000000..f091343d8a3
--- /dev/null
+++ b/tests/components/kodi/test_device_trigger.py
@@ -0,0 +1,129 @@
+"""The tests for Kodi device triggers."""
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.kodi import DOMAIN
+from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
+from homeassistant.setup import async_setup_component
+
+from . import init_integration
+
+from tests.common import (
+    MockConfigEntry,
+    assert_lists_same,
+    async_get_device_automations,
+    async_mock_service,
+    mock_device_registry,
+    mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+    """Return an empty, loaded, registry."""
+    return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+    """Return an empty, loaded, registry."""
+    return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+    """Track calls to a mock service."""
+    return async_mock_service(hass, "test", "automation")
+
+
+@pytest.fixture
+async def kodi_media_player(hass):
+    """Get a kodi media player."""
+    await init_integration(hass)
+    return f"{MP_DOMAIN}.name"
+
+
+async def test_get_triggers(hass, device_reg, entity_reg):
+    """Test we get the expected triggers from a kodi."""
+    config_entry = MockConfigEntry(domain=DOMAIN, data={})
+    config_entry.add_to_hass(hass)
+    device_entry = device_reg.async_get_or_create(
+        config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "host", 1234)},
+    )
+    entity_reg.async_get_or_create(MP_DOMAIN, DOMAIN, "5678", device_id=device_entry.id)
+    expected_triggers = [
+        {
+            "platform": "device",
+            "domain": DOMAIN,
+            "type": "turn_off",
+            "device_id": device_entry.id,
+            "entity_id": f"{MP_DOMAIN}.kodi_5678",
+        },
+        {
+            "platform": "device",
+            "domain": DOMAIN,
+            "type": "turn_on",
+            "device_id": device_entry.id,
+            "entity_id": f"{MP_DOMAIN}.kodi_5678",
+        },
+    ]
+    triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+    assert_lists_same(triggers, expected_triggers)
+
+
+async def test_if_fires_on_state_change(hass, calls, kodi_media_player):
+    """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": "",
+                        "entity_id": kodi_media_player,
+                        "type": "turn_on",
+                    },
+                    "action": {
+                        "service": "test.automation",
+                        "data_template": {
+                            "some": ("turn_on - {{ trigger.entity_id }}")
+                        },
+                    },
+                },
+                {
+                    "trigger": {
+                        "platform": "device",
+                        "domain": DOMAIN,
+                        "device_id": "",
+                        "entity_id": kodi_media_player,
+                        "type": "turn_off",
+                    },
+                    "action": {
+                        "service": "test.automation",
+                        "data_template": {
+                            "some": ("turn_off - {{ trigger.entity_id }}")
+                        },
+                    },
+                },
+            ]
+        },
+    )
+
+    await hass.services.async_call(
+        MP_DOMAIN, "turn_on", {"entity_id": kodi_media_player}, blocking=True,
+    )
+
+    await hass.async_block_till_done()
+    assert len(calls) == 1
+    assert calls[0].data["some"] == f"turn_on - {kodi_media_player}"
+
+    await hass.services.async_call(
+        MP_DOMAIN, "turn_off", {"entity_id": kodi_media_player}, blocking=True,
+    )
+
+    await hass.async_block_till_done()
+    assert len(calls) == 2
+    assert calls[1].data["some"] == f"turn_off - {kodi_media_player}"
diff --git a/tests/components/kodi/test_init.py b/tests/components/kodi/test_init.py
new file mode 100644
index 00000000000..b272e005012
--- /dev/null
+++ b/tests/components/kodi/test_init.py
@@ -0,0 +1,25 @@
+"""Test the Kodi integration init."""
+from homeassistant.components.kodi.const import DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
+
+from . import init_integration
+
+from tests.async_mock import patch
+
+
+async def test_unload_entry(hass):
+    """Test successful unload of entry."""
+    with patch(
+        "homeassistant.components.kodi.media_player.async_setup_entry",
+        return_value=True,
+    ):
+        entry = await init_integration(hass)
+
+    assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+    assert entry.state == ENTRY_STATE_LOADED
+
+    assert await hass.config_entries.async_unload(entry.entry_id)
+    await hass.async_block_till_done()
+
+    assert entry.state == ENTRY_STATE_NOT_LOADED
+    assert not hass.data.get(DOMAIN)
diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py
new file mode 100644
index 00000000000..0e0582047d1
--- /dev/null
+++ b/tests/components/kodi/util.py
@@ -0,0 +1,67 @@
+"""Test the Kodi config flow."""
+from homeassistant.components.kodi.const import DEFAULT_SSL
+
+TEST_HOST = {
+    "host": "1.1.1.1",
+    "port": 8080,
+    "ssl": DEFAULT_SSL,
+}
+
+
+TEST_CREDENTIALS = {"username": "username", "password": "password"}
+
+
+TEST_WS_PORT = {"ws_port": 9090}
+
+UUID = "11111111-1111-1111-1111-111111111111"
+TEST_DISCOVERY = {
+    "host": "1.1.1.1",
+    "port": 8080,
+    "hostname": "hostname.local.",
+    "type": "_xbmc-jsonrpc-h._tcp.local.",
+    "name": "hostname._xbmc-jsonrpc-h._tcp.local.",
+    "properties": {"uuid": UUID},
+}
+
+
+TEST_IMPORT = {
+    "name": "name",
+    "host": "1.1.1.1",
+    "port": 8080,
+    "ws_port": 9090,
+    "username": "username",
+    "password": "password",
+    "ssl": True,
+    "timeout": 7,
+}
+
+
+class MockConnection:
+    """A mock kodi connection."""
+
+    def __init__(self, connected=True):
+        """Mock the Kodi connection."""
+        self._connected = connected
+
+    async def connect(self):
+        """Mock connect."""
+        pass
+
+    @property
+    def connected(self):
+        """Mock connected."""
+        return self._connected
+
+    @property
+    def can_subscribe(self):
+        """Mock can_subscribe."""
+        return False
+
+    async def close(self):
+        """Mock close."""
+        pass
+
+    @property
+    def server(self):
+        """Mock server."""
+        return None