mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +00:00
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:
parent
e0e31693f5
commit
c1ed584f2d
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
260
homeassistant/components/kodi/config_flow.py
Normal file
260
homeassistant/components/kodi/config_flow.py
Normal 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."""
|
@ -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"
|
||||||
|
89
homeassistant/components/kodi/device_trigger.py
Normal file
89
homeassistant/components/kodi/device_trigger.py
Normal 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
|
@ -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
|
||||||
|
}
|
@ -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"]
|
||||||
|
47
homeassistant/components/kodi/strings.json
Normal file
47
homeassistant/components/kodi/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -95,6 +95,7 @@ FLOWS = [
|
|||||||
"isy994",
|
"isy994",
|
||||||
"izone",
|
"izone",
|
||||||
"juicenet",
|
"juicenet",
|
||||||
|
"kodi",
|
||||||
"konnected",
|
"konnected",
|
||||||
"life360",
|
"life360",
|
||||||
"lifx",
|
"lifx",
|
||||||
|
@ -67,6 +67,9 @@ ZEROCONF = {
|
|||||||
],
|
],
|
||||||
"_wled._tcp.local.": [
|
"_wled._tcp.local.": [
|
||||||
"wled"
|
"wled"
|
||||||
|
],
|
||||||
|
"_xbmc-jsonrpc-h._tcp.local.": [
|
||||||
|
"kodi"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
41
tests/components/kodi/__init__.py
Normal file
41
tests/components/kodi/__init__.py
Normal 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
|
325
tests/components/kodi/test_config_flow.py
Normal file
325
tests/components/kodi/test_config_flow.py
Normal 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
|
129
tests/components/kodi/test_device_trigger.py
Normal file
129
tests/components/kodi/test_device_trigger.py
Normal 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}"
|
25
tests/components/kodi/test_init.py
Normal file
25
tests/components/kodi/test_init.py
Normal 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)
|
67
tests/components/kodi/util.py
Normal file
67
tests/components/kodi/util.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user