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>
This commit is contained in:
On Freund 2020-08-21 07:16:58 +03:00 committed by GitHub
parent e0e31693f5
commit c1ed584f2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1362 additions and 527 deletions

View File

@ -224,7 +224,7 @@ homeassistant/components/keenetic_ndms2/* @foxel
homeassistant/components/kef/* @basnijholt homeassistant/components/kef/* @basnijholt
homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/keyboard_remote/* @bendavid
homeassistant/components/knx/* @Julius2342 homeassistant/components/knx/* @Julius2342
homeassistant/components/kodi/* @armills homeassistant/components/kodi/* @armills @OnFreund
homeassistant/components/konnected/* @heythisisnate @kit-klein homeassistant/components/konnected/* @heythisisnate @kit-klein
homeassistant/components/lametric/* @robbiet480 homeassistant/components/lametric/* @robbiet480
homeassistant/components/launch_library/* @ludeeus homeassistant/components/launch_library/* @ludeeus

View File

@ -70,7 +70,6 @@ SERVICE_HANDLERS = {
"openhome": ("media_player", "openhome"), "openhome": ("media_player", "openhome"),
"bose_soundtouch": ("media_player", "soundtouch"), "bose_soundtouch": ("media_player", "soundtouch"),
"bluesound": ("media_player", "bluesound"), "bluesound": ("media_player", "bluesound"),
"kodi": ("media_player", "kodi"),
"lg_smart_device": ("media_player", "lg_soundbar"), "lg_smart_device": ("media_player", "lg_soundbar"),
"nanoleaf_aurora": ("light", "nanoleaf"), "nanoleaf_aurora": ("light", "nanoleaf"),
} }
@ -87,6 +86,7 @@ MIGRATED_SERVICE_HANDLERS = [
"harmony", "harmony",
"homekit", "homekit",
"ikea_tradfri", "ikea_tradfri",
"kodi",
"philips_hue", "philips_hue",
"sonos", "sonos",
"songpal", "songpal",

View File

@ -1,89 +1,100 @@
"""The kodi component.""" """The kodi component."""
import asyncio 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.config_entries import ConfigEntry
from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.const import (
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM CONF_HOST,
from homeassistant.helpers import config_validation as cv CONF_PASSWORD,
CONF_PORT,
SERVICE_ADD_MEDIA = "add_to_playlist" CONF_SSL,
SERVICE_CALL_METHOD = "call_method" CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
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,
}
) )
KODI_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend( from homeassistant.core import HomeAssistant
{vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA 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 = { _LOGGER = logging.getLogger(__name__)
SERVICE_ADD_MEDIA: { PLATFORMS = ["media_player"]
"method": "async_add_media_to_playlist",
"schema": KODI_ADD_MEDIA_SCHEMA,
},
SERVICE_CALL_METHOD: {
"method": "async_call_method",
"schema": KODI_CALL_METHOD_SCHEMA,
},
}
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the Kodi integration.""" """Set up the Kodi integration."""
if any((CONF_PLATFORM, DOMAIN) in cfg.items() for cfg in config.get(MP_DOMAIN, [])): hass.data.setdefault(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.
return True 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

View File

@ -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."""

View File

@ -1,2 +1,17 @@
"""Constants for the Kodi platform.""" """Constants for the Kodi platform."""
DOMAIN = "kodi" 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"

View File

@ -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

View File

@ -2,6 +2,11 @@
"domain": "kodi", "domain": "kodi",
"name": "Kodi", "name": "Kodi",
"documentation": "https://www.home-assistant.io/integrations/kodi", "documentation": "https://www.home-assistant.io/integrations/kodi",
"requirements": ["jsonrpc-async==0.6", "jsonrpc-websocket==0.6"], "requirements": ["pykodi==0.1"],
"codeowners": ["@armills"] "codeowners": [
} "@armills",
"@OnFreund"
],
"zeroconf": ["_xbmc-jsonrpc-h._tcp.local."],
"config_flow": true
}

View File

@ -1,21 +1,12 @@
"""Support for interfacing with the XBMC/Kodi JSON-RPC API.""" """Support for interfacing with the XBMC/Kodi JSON-RPC API."""
import asyncio
from collections import OrderedDict
from datetime import timedelta from datetime import timedelta
from functools import wraps from functools import wraps
import logging import logging
import re import re
import socket
import urllib
import aiohttp
import jsonrpc_async
import jsonrpc_base import jsonrpc_base
import jsonrpc_websocket
import voluptuous as vol 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 import PLATFORM_SCHEMA, MediaPlayerEntity
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNEL,
@ -38,27 +29,40 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_STEP,
) )
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST, CONF_HOST,
CONF_NAME, CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_PORT, CONF_PORT,
CONF_PROXY_SSL, CONF_PROXY_SSL,
CONF_SSL,
CONF_TIMEOUT, CONF_TIMEOUT,
CONF_USERNAME, CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
STATE_IDLE, STATE_IDLE,
STATE_OFF, STATE_OFF,
STATE_PAUSED, STATE_PAUSED,
STATE_PLAYING, STATE_PLAYING,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.template import Template
import homeassistant.util.dt as dt_util 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__) _LOGGER = logging.getLogger(__name__)
@ -69,13 +73,6 @@ CONF_TURN_ON_ACTION = "turn_on_action"
CONF_TURN_OFF_ACTION = "turn_off_action" CONF_TURN_OFF_ACTION = "turn_off_action"
CONF_ENABLE_WEBSOCKET = "enable_websocket" 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 = { DEPRECATED_TURN_OFF_ACTIONS = {
None: None, None: None,
"quit": "Application.Quit", "quit": "Application.Quit",
@ -118,15 +115,18 @@ SUPPORT_KODI = (
| SUPPORT_SHUFFLE_SET | SUPPORT_SHUFFLE_SET
| SUPPORT_PLAY | SUPPORT_PLAY
| SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_STEP
| SUPPORT_TURN_OFF
| SUPPORT_TURN_ON
) )
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_HOST): cv.string, 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_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Optional(CONF_TCP_PORT, default=DEFAULT_WS_PORT): cv.port,
vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean, vol.Optional(CONF_PROXY_SSL, default=DEFAULT_SSL): cv.boolean,
vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TURN_OFF_ACTION): vol.Any( vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(
cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS) 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.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Inclusive(CONF_USERNAME, "auth"): cv.string, vol.Inclusive(CONF_USERNAME, "auth"): cv.string,
vol.Inclusive(CONF_PASSWORD, "auth"): cv.string, vol.Inclusive(CONF_PASSWORD, "auth"): cv.string,
vol.Optional( vol.Optional(CONF_ENABLE_WEBSOCKET, default=True): cv.boolean,
CONF_ENABLE_WEBSOCKET, default=DEFAULT_ENABLE_WEBSOCKET
): cv.boolean,
} }
) )
def _check_deprecated_turn_off(hass, turn_off_action): SERVICE_ADD_MEDIA = "add_to_playlist"
"""Create an equivalent script for old turn off actions.""" SERVICE_CALL_METHOD = "call_method"
if isinstance(turn_off_action, str):
method = DEPRECATED_TURN_OFF_ACTIONS[turn_off_action] ATTR_MEDIA_TYPE = "media_type"
new_config = OrderedDict( ATTR_MEDIA_NAME = "media_name"
[ ATTR_MEDIA_ARTIST_NAME = "artist_name"
("service", f"{DOMAIN}.{SERVICE_CALL_METHOD}"), ATTR_MEDIA_ID = "media_id"
( ATTR_METHOD = "method"
"data_template",
OrderedDict([("entity_id", "{{ entity_id }}"), ("method", method)]),
), KODI_ADD_MEDIA_SCHEMA = {
] vol.Required(ATTR_MEDIA_TYPE): cv.string,
) vol.Optional(ATTR_MEDIA_ID): cv.string,
example_conf = dump(OrderedDict([(CONF_TURN_OFF_ACTION, new_config)])) vol.Optional(ATTR_MEDIA_NAME): cv.string,
_LOGGER.warning( vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
"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, " KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
"which is, for this turn_off action, like this:\n%s", {vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
turn_off_action, )
example_conf,
)
new_config["data_template"] = OrderedDict( def find_matching_config_entries_for_host(hass, host):
[ """Search existing config entries for one matching the host."""
(key, Template(value, hass)) for entry in hass.config_entries.async_entries(DOMAIN):
for key, value in new_config["data_template"].items() if entry.data[CONF_HOST] == host:
] return entry
) return None
turn_off_action = [new_config]
return turn_off_action
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Kodi platform.""" """Set up the Kodi platform."""
if DOMAIN not in hass.data: if discovery_info:
hass.data[DOMAIN] = {} # Now handled by zeroconf in the config flow
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]:
return return
# If we got an unique id, check that it does not exist already. host = config[CONF_HOST]
# This is necessary as netdisco does not deterministally return the same if find_matching_config_entries_for_host(hass, host):
# advertisement when the service is offered over multiple IP addresses. return
if unique_id is not None:
for device in hass.data[DOMAIN].values():
if device.unique_id == unique_id:
return
entity = KodiDevice( websocket = config.get(CONF_ENABLE_WEBSOCKET)
hass, ws_port = config.get(CONF_TCP_PORT) if websocket else None
name=name,
host=host, entry_data = {
port=port, CONF_NAME: config.get(CONF_NAME, host),
tcp_port=tcp_port, CONF_HOST: host,
encryption=encryption, CONF_PORT: config.get(CONF_PORT),
username=config.get(CONF_USERNAME), CONF_WS_PORT: ws_port,
password=config.get(CONF_PASSWORD), CONF_USERNAME: config.get(CONF_USERNAME),
turn_on_action=config.get(CONF_TURN_ON_ACTION), CONF_PASSWORD: config.get(CONF_PASSWORD),
turn_off_action=config.get(CONF_TURN_OFF_ACTION), CONF_SSL: config.get(CONF_PROXY_SSL),
timeout=config.get(CONF_TIMEOUT), CONF_TIMEOUT: config.get(CONF_TIMEOUT),
websocket=websocket, }
unique_id=unique_id,
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): def cmd(func):
@ -253,97 +244,38 @@ def cmd(func):
return wrapper return wrapper
class KodiDevice(MediaPlayerEntity): class KodiEntity(MediaPlayerEntity):
"""Representation of a XBMC/Kodi device.""" """Representation of a XBMC/Kodi device."""
def __init__( def __init__(self, connection, kodi, name, uid, version):
self, """Initialize the Kodi entity."""
hass, self._connection = connection
name, self._kodi = kodi
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
self._name = name self._name = name
self._unique_id = unique_id self._unique_id = uid
self._media_position_updated_at = None self._version = version
self._media_position = None self._players = 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._properties = {} self._properties = {}
self._item = {} self._item = {}
self._app_properties = {} 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 @callback
def async_on_speed_event(self, sender, data): def async_on_speed_event(self, sender, data):
@ -363,14 +295,10 @@ class KodiDevice(MediaPlayerEntity):
def async_on_stop(self, sender, data): def async_on_stop(self, sender, data):
"""Handle the stop of the player playback.""" """Handle the stop of the player playback."""
# Prevent stop notifications which are sent after quit notification # Prevent stop notifications which are sent after quit notification
if self._players is None: if self._kodi_is_off:
return return
self._players = [] self._reset_state([])
self._properties = {}
self._item = {}
self._media_position_updated_at = None
self._media_position = None
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
@ -383,34 +311,31 @@ class KodiDevice(MediaPlayerEntity):
@callback @callback
def async_on_quit(self, sender, data): def async_on_quit(self, sender, data):
"""Reset the player state on quit action.""" """Reset the player state on quit action."""
self._players = None self._reset_state()
self._properties = {} self.hass.async_create_task(self._connection.close())
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
@property @property
def unique_id(self): def unique_id(self):
"""Return the unique id of the device.""" """Return the unique id of the device."""
return self._unique_id 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 @property
def state(self): def state(self):
"""Return the state of the device.""" """Return the state of the device."""
if self._players is None: if self._kodi_is_off:
return STATE_OFF return STATE_OFF
if not self._players: if self._no_active_players:
return STATE_IDLE return STATE_IDLE
if self._properties["speed"] == 0: if self._properties["speed"] == 0:
@ -418,36 +343,13 @@ class KodiDevice(MediaPlayerEntity):
return STATE_PLAYING 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): async def async_added_to_hass(self):
"""Connect the websocket if needed.""" """Connect the websocket if needed."""
if not self._enable_websocket: if not self._connection.can_subscribe:
return return
asyncio.create_task(self.async_ws_connect()) if self._connection.connected:
self._on_ws_connected()
self.async_on_remove( self.async_on_remove(
async_track_time_interval( 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, *_): async def _async_connect_websocket_if_disconnected(self, *_):
"""Reconnect the websocket if it fails.""" """Reconnect the websocket if it fails."""
if not self._ws_server.connected: if not self._connection.connected:
await self.async_ws_connect() 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): async def async_update(self):
"""Retrieve latest state.""" """Retrieve latest state."""
self._players = await self._get_players() if not self._connection.connected:
self._reset_state()
if self._players is None:
self._properties = {}
self._item = {}
self._app_properties = {}
return return
self._app_properties = await self.server.Application.GetProperties( self._players = await self._kodi.get_players()
["volume", "muted"]
) if self._kodi_is_off:
self._reset_state()
return
if self._players: 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._kodi.get_player_properties(
self._players[0], ["time", "totaltime", "speed", "live"]
self._properties = await self.server.Player.GetProperties(
player_id, ["time", "totaltime", "speed", "live"]
) )
position = self._properties["time"] position = self._properties["time"]
@ -490,37 +422,23 @@ class KodiDevice(MediaPlayerEntity):
self._media_position_updated_at = dt_util.utcnow() self._media_position_updated_at = dt_util.utcnow()
self._media_position = position self._media_position = position
self._item = ( self._item = await self._kodi.get_playing_item_properties(
await self.server.Player.GetItem( self._players[0],
player_id, [
[ "title",
"title", "file",
"file", "uniqueid",
"uniqueid", "thumbnail",
"thumbnail", "artist",
"artist", "albumartist",
"albumartist", "showtitle",
"showtitle", "album",
"album", "season",
"season", "episode",
"episode", ],
], )
)
)["item"]
else: else:
self._properties = {} self._reset_state([])
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
@property @property
def name(self): def name(self):
@ -530,13 +448,13 @@ class KodiDevice(MediaPlayerEntity):
@property @property
def should_poll(self): def should_poll(self):
"""Return True if entity has to be polled for state.""" """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 @property
def volume_level(self): def volume_level(self):
"""Volume level of the media player (0..1).""" """Volume level of the media player (0..1)."""
if "volume" in self._app_properties: if "volume" in self._app_properties:
return self._app_properties["volume"] / 100.0 return int(self._app_properties["volume"]) / 100.0
@property @property
def is_volume_muted(self): def is_volume_muted(self):
@ -598,9 +516,7 @@ class KodiDevice(MediaPlayerEntity):
if thumbnail is None: if thumbnail is None:
return None return None
url_components = urllib.parse.urlparse(thumbnail) return self._kodi.thumbnail_url(thumbnail)
if url_components.scheme == "image":
return f"{self._image_url}/{urllib.parse.quote_plus(thumbnail)}"
@property @property
def media_title(self): def media_title(self):
@ -650,128 +566,72 @@ class KodiDevice(MediaPlayerEntity):
@property @property
def supported_features(self): def supported_features(self):
"""Flag media player features that are supported.""" """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): async def async_turn_on(self):
"""Execute turn_on_action to turn on media player.""" """Turn the media player on."""
if self._turn_on_action is not None: _LOGGER.debug("Firing event to turn on device")
await self._turn_on_action.async_run( self.hass.bus.async_fire(EVENT_TURN_ON, {ATTR_ENTITY_ID: self.entity_id})
variables={"entity_id": self.entity_id}
)
else:
_LOGGER.warning("turn_on requested but turn_on_action is none")
@cmd
async def async_turn_off(self): async def async_turn_off(self):
"""Execute turn_off_action to turn off media player.""" """Turn the media player off."""
if self._turn_off_action is not None: _LOGGER.debug("Firing event to turn off device")
await self._turn_off_action.async_run( self.hass.bus.async_fire(EVENT_TURN_OFF, {ATTR_ENTITY_ID: self.entity_id})
variables={"entity_id": self.entity_id}
)
else:
_LOGGER.warning("turn_off requested but turn_off_action is none")
@cmd @cmd
async def async_volume_up(self): async def async_volume_up(self):
"""Volume up the media player.""" """Volume up the media player."""
assert (await self.server.Input.ExecuteAction("volumeup")) == "OK" await self._kodi.volume_up()
@cmd @cmd
async def async_volume_down(self): async def async_volume_down(self):
"""Volume down the media player.""" """Volume down the media player."""
assert (await self.server.Input.ExecuteAction("volumedown")) == "OK" await self._kodi.volume_down()
@cmd @cmd
async def async_set_volume_level(self, volume): async def async_set_volume_level(self, volume):
"""Set volume level, range 0..1.""" """Set volume level, range 0..1."""
await self.server.Application.SetVolume(int(volume * 100)) await self._kodi.set_volume_level(int(volume * 100))
@cmd @cmd
async def async_mute_volume(self, mute): async def async_mute_volume(self, mute):
"""Mute (true) or unmute (false) media player.""" """Mute (true) or unmute (false) media player."""
await self.server.Application.SetMute(mute) await self._kodi.mute(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)
@cmd @cmd
async def async_media_play_pause(self): async def async_media_play_pause(self):
"""Pause media on media player.""" """Pause media on media player."""
await self.async_set_play_state("toggle") await self._kodi.play_pause()
@cmd @cmd
async def async_media_play(self): async def async_media_play(self):
"""Play media.""" """Play media."""
await self.async_set_play_state(True) await self._kodi.play()
@cmd @cmd
async def async_media_pause(self): async def async_media_pause(self):
"""Pause the media player.""" """Pause the media player."""
await self.async_set_play_state(False) await self._kodi.pause()
@cmd @cmd
async def async_media_stop(self): async def async_media_stop(self):
"""Stop the media player.""" """Stop the media player."""
players = await self._get_players() await self._kodi.stop()
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)
@cmd @cmd
async def async_media_next_track(self): async def async_media_next_track(self):
"""Send next track command.""" """Send next track command."""
await self._goto("next") await self._kodi.next_track()
@cmd @cmd
async def async_media_previous_track(self): async def async_media_previous_track(self):
"""Send next track command.""" """Send next track command."""
await self._goto("previous") await self._kodi.previous_track()
@cmd @cmd
async def async_media_seek(self, position): async def async_media_seek(self, position):
"""Send seek command.""" """Send seek command."""
players = await self._get_players() await self._kodi.media_seek(position)
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)
@cmd @cmd
async def async_play_media(self, media_type, media_id, **kwargs): async def async_play_media(self, media_type, media_id, **kwargs):
@ -779,30 +639,27 @@ class KodiDevice(MediaPlayerEntity):
media_type_lower = media_type.lower() media_type_lower = media_type.lower()
if media_type_lower == MEDIA_TYPE_CHANNEL: 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: 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": elif media_type_lower == "directory":
await self.server.Player.Open({"item": {"directory": str(media_id)}}) await self._kodi.play_directory(str(media_id))
elif media_type_lower == "plugin":
await self.server.Player.Open({"item": {"file": str(media_id)}})
else: 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): async def async_set_shuffle(self, shuffle):
"""Set shuffle mode, for the first player.""" """Set shuffle mode, for the first player."""
if not self._players: if self._no_active_players:
raise RuntimeError("Error: No active player.") raise RuntimeError("Error: No active player.")
await self.server.Player.SetShuffle( await self._kodi.set_shuffle(shuffle)
{"playerid": self._players[0]["playerid"], "shuffle": shuffle}
)
async def async_call_method(self, method, **kwargs): async def async_call_method(self, method, **kwargs):
"""Run Kodi JSONRPC API method with params.""" """Run Kodi JSONRPC API method with params."""
_LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs) _LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs)
result_ok = False result_ok = False
try: try:
result = await getattr(self.server, method)(**kwargs) result = self._kodi.call_method(method, **kwargs)
result_ok = True result_ok = True
except jsonrpc_base.jsonrpc.ProtocolError as exc: except jsonrpc_base.jsonrpc.ProtocolError as exc:
result = exc.args[2]["error"] result = exc.args[2]["error"]
@ -835,10 +692,14 @@ class KodiDevice(MediaPlayerEntity):
) )
return result 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( async def async_add_media_to_playlist(
self, media_type, media_id=None, media_name="ALL", artist_name="" 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 First the media type must be selected, then
the media can be specified in terms of id or 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 All the albums of an artist can be added with
media_name="ALL" media_name="ALL"
""" """
params = {"playlistid": 0}
if media_type == "SONG": if media_type == "SONG":
if media_id is None: 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: if media_id:
params["item"] = {"songid": int(media_id)} self._kodi.add_song_to_playlist(int(media_id))
elif media_type == "ALBUM": elif media_type == "ALBUM":
if media_id is None: if media_id is None:
if media_name == "ALL": if media_name == "ALL":
await self.async_add_all_albums(artist_name) await self._async_add_all_albums(artist_name)
return 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: if media_id:
params["item"] = {"albumid": int(media_id)} self._kodi.add_album_to_playlist(int(media_id))
else: else:
raise RuntimeError("Unrecognized media type.") raise RuntimeError("Unrecognized media type.")
if media_id is not None: if media_id is 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:
_LOGGER.warning("No media detected for Playlist.Add") _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). """Add all albums of an artist to default playlist (i.e. playlistid=0).
The artist is specified in terms of name. 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"]: for alb in albums["albums"]:
await self.server.Playlist.Add( await self._kodi.add_album_to_playlist(int(alb["albumid"]))
{"playlistid": 0, "item": {"albumid": int(alb["albumid"])}}
)
async def async_clear_playlist(self): async def _async_find_artist(self, artist_name):
"""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):
"""Find artist by name.""" """Find artist by name."""
artists = await self.async_get_artists() artists = await self._kodi.get_artists()
try: try:
out = self._find(artist_name, [a["artist"] for a in artists["artists"]]) out = self._find(artist_name, [a["artist"] for a in artists["artists"]])
return artists["artists"][out[0][0]]["artistid"] return artists["artists"][out[0][0]]["artistid"]
@ -925,35 +751,26 @@ class KodiDevice(MediaPlayerEntity):
_LOGGER.warning("No artists were found: %s", artist_name) _LOGGER.warning("No artists were found: %s", artist_name)
return None return None
async def async_get_songs(self, artist_id=None): async def _async_find_song(self, song_name, artist_name=""):
"""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=""):
"""Find song by name and optionally artist name.""" """Find song by name and optionally artist name."""
artist_id = None artist_id = None
if artist_name != "": 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: if songs["limits"]["total"] == 0:
return None return None
out = self._find(song_name, [a["label"] for a in songs["songs"]]) out = self._find(song_name, [a["label"] for a in songs["songs"]])
return songs["songs"][out[0][0]]["songid"] 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.""" """Find album by name and optionally artist name."""
artist_id = None artist_id = None
if artist_name != "": 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: try:
out = self._find(album_name, [a["label"] for a in albums["albums"]]) out = self._find(album_name, [a["label"] for a in albums["albums"]])
return albums["albums"][out[0][0]]["albumid"] return albums["albums"][out[0][0]]["albumid"]

View File

@ -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"
}
}
}

View File

@ -95,6 +95,7 @@ FLOWS = [
"isy994", "isy994",
"izone", "izone",
"juicenet", "juicenet",
"kodi",
"konnected", "konnected",
"life360", "life360",
"lifx", "lifx",

View File

@ -67,6 +67,9 @@ ZEROCONF = {
], ],
"_wled._tcp.local.": [ "_wled._tcp.local.": [
"wled" "wled"
],
"_xbmc-jsonrpc-h._tcp.local.": [
"kodi"
] ]
} }

View File

@ -805,12 +805,6 @@ iperf3==0.1.11
# homeassistant.components.verisure # homeassistant.components.verisure
jsonpath==0.82 jsonpath==0.82
# homeassistant.components.kodi
jsonrpc-async==0.6
# homeassistant.components.kodi
jsonrpc-websocket==0.6
# homeassistant.components.kaiterra # homeassistant.components.kaiterra
kaiterra-async-client==0.0.2 kaiterra-async-client==0.0.2
@ -1434,6 +1428,9 @@ pyitachip2ir==0.0.7
# homeassistant.components.kira # homeassistant.components.kira
pykira==0.1.1 pykira==0.1.1
# homeassistant.components.kodi
pykodi==0.1
# homeassistant.components.kwb # homeassistant.components.kwb
pykwb==0.0.8 pykwb==0.0.8

View File

@ -683,6 +683,9 @@ pyisy==2.0.2
# homeassistant.components.kira # homeassistant.components.kira
pykira==0.1.1 pykira==0.1.1
# homeassistant.components.kodi
pykodi==0.1
# homeassistant.components.lastfm # homeassistant.components.lastfm
pylast==3.2.1 pylast==3.2.1

View File

@ -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

View File

@ -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

View File

@ -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}"

View File

@ -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)

View File

@ -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