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