mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
Rewrite of Yamaha musiccast integration (#51561)
* Initial commit for new musiccast integration * Add zone support * Get/set volume level * Remove volume step * Create custom MusicCastData type * Create MusicCastDevice * Fix await * Add power and mute control * Implement all basic media_player parts * Support input switching * Add duration/position support * Add advanced tuner functions * Basic media browser * Add layer in media browser to see all available list_infos * Added join/unjoin services and group informations. Known issue: You can not link zone 2 to main at the moment (WIP) * Many fixes to make multiple zones and grouping work. Next step: implement error handling and remove debugging information * WIP: Added Multizone Support and allows clients to directly jump from one group to another. Known issue: If a server tries to join a group as client, he has to close his group first. Sometimes the device that was a server previously jumps out of the group directly after joining. * Updated group management to make it wait for the updated group information before performing the next actions - Timeouts after 1 second, then polls the distribution data. If the data are still not updated, there will be one retry before an Exception is thrown. Extended the state attributes for clients to make them return group details from their servers (leads to inactive group management buttons for the client). Added documentation and restructured the code. * Make the service handle function name for group specific service calls unique * Added service descriptions for set_sleep_timer, set_alarm, recall_netusb_preset, store_netusb_preset * Added data entries for alarm specific values and a netusb preset list. Implemented fetching function for clock and netusb presets. * Registered and implemented services for set_sleep_timer, set_alarm, recall_netusb_preset, store_netusb_preset. The set_alarm service works with a special mediaplayer alarm lovelace card, I am currently working on. The NetUSB Presets are also available using the media browser. Maybe we could also add the Tuner presets in the future for both setting up the alarm and recalling them via service and media browser. * Removed some debug prints * Moved MusicCast Integration to the aiomusiccast library. This library supports media browsers with multiple pages. Added ssdp support for the discovery * Minor fix in the group management and tidied up a bit * Updated manifest of yamaha musiccast * Update library * Minor fix in the media browser. get_distribution_num does not have to be async, so it has been changed. Adjusted the client join function to turn on the client before joining a group - the musiccast app does so, so hopefully this fixes the rare errors when adding a turned off client to a group. Some reformating and by hooks fixed most of the requirements of the hooks. Known exception from this: mypy throws an error for line 116. * Removed some old out commented code. Fixed some error handling, when the user enters a non reachable or non yamaha host in the manual setup. Fixed linting/styling errors. Implemented tests to bring the coverage for the config flow to 100%. * Fixed linting/styling errors. Return a DeviceInfo object instead of a dict. * Fixed linting/styling errors. Added a new error type to the translations. * In the yamaha API the system_id is equal to the serial number in the DLNA description. Due to that it was possible to configure a device twice, because the serial number from the yamaha API was different. This issue was fixed. * Updated tests and added a test for adding a device manually, which is already present in the system * Remove print statements * Fix sleep timer service call * Fix yamllint error * Shrink PR down to just new library + config flow with discovery * Add __init__.py to .coveragerc * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: Franck Nijhof <git@frenck.dev> * Implement suggestions from code review * Improve identifiers and connections, remove event loop parameter * Add coordinator back * Better exception handling * Fix unique id in ssdp step * Remove abc.ABC from MusicCasteDeviceEntity Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/yamaha_musiccast/config_flow.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Replace the repeat mode mapping from mc to ha by a generic solution Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * add coordinator to the super call of the mediaplayer Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * add the coordinator to the init function of the MusicCastEntity Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Pass the coordinator from the MusicCastEntity init function to the CoordinatorEntity init function Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * merged _handle_config_flow into async_step_user * reformated the exception handling of the user step. In the case that the device already exists, the AbortFlow will be raised. * Removed model from the config entry. It was neither set nor used anymore. * Fixed the test for the config flow. * Use async_write_ha_state instead of schedule_update_ha_state. * Add default value for the system ID gotten in the user step Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update tests/components/yamaha_musiccast/test_config_flow.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Added a fixture to avoid IO in the test_user_input_device_not_found test * Use absolute imprt to import data_entry_flow. * Use local vars for host and serial_number in async_step_user. * Remove ip_address and zone_id properties. * Use device id for the unique ID of an entity instead of the macs * Removed entry_id from the MusicCastEntity init function. * Updated strings and English translation. * don't set the coordinator in the mediaplayer init. * Implemented legacy configuration.yaml support for existing configurations. * Added tests for the newly added config flow step. * Use device_id as identifier * Fix an accidentally relative import * Fix pylint warnings * use logger.error instead of logger.exception in the import step. Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Use CONF_HOST instead of 'host' * Only support the import from configuration.yaml if no config entries are setup for musiccast. If there are already config entries in HA and none of them is a representation of a config given in configuration.yaml (e.g. config added after the first import), an error will be logged. * Update homeassistant/components/yamaha_musiccast/media_player.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Readded PLATFORM_SCHEMA for configuration.yaml * Raise an exception for all services, which are only supported for specific sources. * Bump aiomusiccast to 0.6 to support asyncio sockets Co-authored-by: Michael Harbarth <michael.harbarth@gmx.de> Co-authored-by: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
parent
0709aa7c8c
commit
7e1fec8ee4
@ -1212,6 +1212,7 @@ omit =
|
||||
homeassistant/components/xmpp/notify.py
|
||||
homeassistant/components/xs1/*
|
||||
homeassistant/components/yale_smart_alarm/alarm_control_panel.py
|
||||
homeassistant/components/yamaha_musiccast/__init__.py
|
||||
homeassistant/components/yamaha_musiccast/media_player.py
|
||||
homeassistant/components/yandex_transport/*
|
||||
homeassistant/components/yeelightsunflower/light.py
|
||||
|
@ -568,7 +568,7 @@ homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG
|
||||
homeassistant/components/xiaomi_tv/* @simse
|
||||
homeassistant/components/xmpp/* @fabaff @flowolf
|
||||
homeassistant/components/yale_smart_alarm/* @gjohansson-ST
|
||||
homeassistant/components/yamaha_musiccast/* @jalmeroth
|
||||
homeassistant/components/yamaha_musiccast/* @vigonotion @micha91
|
||||
homeassistant/components/yandex_transport/* @rishatik92 @devbis
|
||||
homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn
|
||||
homeassistant/components/yeelightsunflower/* @lindsaymarkward
|
||||
|
@ -1 +1,134 @@
|
||||
"""The yamaha_musiccast component."""
|
||||
"""The MusicCast integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiomusiccast import MusicCastConnectionException
|
||||
from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import BRAND, DOMAIN
|
||||
|
||||
PLATFORMS = ["media_player"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up MusicCast from a config entry."""
|
||||
|
||||
client = MusicCastDevice(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
coordinator = MusicCastDataUpdateCoordinator(hass, client=client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None:
|
||||
"""Initialize."""
|
||||
self.musiccast = client
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
||||
|
||||
async def _async_update_data(self) -> MusicCastData:
|
||||
"""Update data via library."""
|
||||
try:
|
||||
await self.musiccast.fetch()
|
||||
except MusicCastConnectionException as exception:
|
||||
raise UpdateFailed() from exception
|
||||
return self.musiccast.data
|
||||
|
||||
|
||||
class MusicCastEntity(CoordinatorEntity):
|
||||
"""Defines a base MusicCast entity."""
|
||||
|
||||
coordinator: MusicCastDataUpdateCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
icon: str,
|
||||
coordinator: MusicCastDataUpdateCoordinator,
|
||||
enabled_default: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the MusicCast entity."""
|
||||
super().__init__(coordinator)
|
||||
self._enabled_default = enabled_default
|
||||
self._icon = icon
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the mdi icon of the entity."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Return if the entity should be enabled when first added to the entity registry."""
|
||||
return self._enabled_default
|
||||
|
||||
|
||||
class MusicCastDeviceEntity(MusicCastEntity):
|
||||
"""Defines a MusicCast device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information about this MusicCast device."""
|
||||
return DeviceInfo(
|
||||
connections={
|
||||
(CONNECTION_NETWORK_MAC, format_mac(mac))
|
||||
for mac in self.coordinator.data.mac_addresses.values()
|
||||
},
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
self.coordinator.data.device_id,
|
||||
)
|
||||
},
|
||||
name=self.coordinator.data.network_name,
|
||||
manufacturer=BRAND,
|
||||
model=self.coordinator.data.model_name,
|
||||
sw_version=self.coordinator.data.system_version,
|
||||
)
|
||||
|
130
homeassistant/components/yamaha_musiccast/config_flow.py
Normal file
130
homeassistant/components/yamaha_musiccast/config_flow.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Config flow for MusicCast."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from aiomusiccast import MusicCastConnectionException, MusicCastDevice
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a MusicCast config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
serial_number: str | None = None
|
||||
host: str
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: ConfigType | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
# Request user input, unless we are preparing discovery flow
|
||||
if user_input is None:
|
||||
return self._show_setup_form()
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
serial_number = None
|
||||
|
||||
errors = {}
|
||||
# Check if device is a MusicCast device
|
||||
|
||||
try:
|
||||
info = await MusicCastDevice.get_device_info(
|
||||
host, async_get_clientsession(self.hass)
|
||||
)
|
||||
except (MusicCastConnectionException, ClientConnectorError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
serial_number = info.get("system_id")
|
||||
if serial_number is None:
|
||||
errors["base"] = "no_musiccast_device"
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(serial_number, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=host,
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
"serial": serial_number,
|
||||
},
|
||||
)
|
||||
|
||||
return self._show_setup_form(errors)
|
||||
|
||||
def _show_setup_form(
|
||||
self, errors: dict | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_ssdp(self, discovery_info) -> data_entry_flow.FlowResult:
|
||||
"""Handle ssdp discoveries."""
|
||||
if not await MusicCastDevice.check_yamaha_ssdp(
|
||||
discovery_info[ssdp.ATTR_SSDP_LOCATION], async_get_clientsession(self.hass)
|
||||
):
|
||||
return self.async_abort(reason="yxc_control_url_missing")
|
||||
|
||||
self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL]
|
||||
self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname
|
||||
await self.async_set_unique_id(self.serial_number)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: self.host})
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {
|
||||
"name": discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input=None) -> data_entry_flow.FlowResult:
|
||||
"""Allow the user to confirm adding the device."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self.host,
|
||||
data={
|
||||
CONF_HOST: self.host,
|
||||
"serial": self.serial_number,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="confirm")
|
||||
|
||||
async def async_step_import(self, import_data: dict) -> data_entry_flow.FlowResult:
|
||||
"""Import data from configuration.yaml into the config flow."""
|
||||
res = await self.async_step_user(import_data)
|
||||
if res["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
_LOGGER.info(
|
||||
"Successfully imported %s from configuration.yaml",
|
||||
import_data.get(CONF_HOST),
|
||||
)
|
||||
elif res["type"] == data_entry_flow.RESULT_TYPE_FORM:
|
||||
_LOGGER.error(
|
||||
"Could not import %s from configuration.yaml",
|
||||
import_data.get(CONF_HOST),
|
||||
)
|
||||
return res
|
31
homeassistant/components/yamaha_musiccast/const.py
Normal file
31
homeassistant/components/yamaha_musiccast/const.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""Constants for the MusicCast integration."""
|
||||
from homeassistant.components.media_player.const import (
|
||||
REPEAT_MODE_ALL,
|
||||
REPEAT_MODE_OFF,
|
||||
REPEAT_MODE_ONE,
|
||||
)
|
||||
|
||||
DOMAIN = "yamaha_musiccast"
|
||||
|
||||
BRAND = "Yamaha Corporation"
|
||||
|
||||
# Attributes
|
||||
ATTR_IDENTIFIERS = "identifiers"
|
||||
ATTR_MANUFACTURER = "manufacturer"
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_PLAYLIST = "playlist"
|
||||
ATTR_PRESET = "preset"
|
||||
ATTR_SOFTWARE_VERSION = "sw_version"
|
||||
|
||||
DEFAULT_ZONE = "main"
|
||||
HA_REPEAT_MODE_TO_MC_MAPPING = {
|
||||
REPEAT_MODE_OFF: "off",
|
||||
REPEAT_MODE_ONE: "one",
|
||||
REPEAT_MODE_ALL: "all",
|
||||
}
|
||||
|
||||
INTERVAL_SECONDS = "interval_seconds"
|
||||
|
||||
MC_REPEAT_MODE_TO_HA_MAPPING = {
|
||||
val: key for key, val in HA_REPEAT_MODE_TO_MC_MAPPING.items()
|
||||
}
|
@ -1,8 +1,19 @@
|
||||
{
|
||||
"domain": "yamaha_musiccast",
|
||||
"name": "Yamaha MusicCast",
|
||||
"name": "MusicCast",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
|
||||
"requirements": ["pymusiccast==0.1.6"],
|
||||
"codeowners": ["@jalmeroth"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
"requirements": [
|
||||
"aiomusiccast==0.6"
|
||||
],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Yamaha Corporation"
|
||||
}
|
||||
],
|
||||
"iot_class": "local_push",
|
||||
"codeowners": [
|
||||
"@vigonotion",
|
||||
"@micha91"
|
||||
]
|
||||
}
|
@ -1,216 +1,402 @@
|
||||
"""Support for Yamaha MusicCast Receivers."""
|
||||
import logging
|
||||
import socket
|
||||
"""Implementation of the musiccast media player."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pymusiccast
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_TYPE_MUSIC,
|
||||
REPEAT_MODE_OFF,
|
||||
SUPPORT_CLEAR_PLAYLIST,
|
||||
SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY,
|
||||
SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_REPEAT_SET,
|
||||
SUPPORT_SELECT_SOUND_MODE,
|
||||
SUPPORT_SELECT_SOURCE,
|
||||
SUPPORT_SHUFFLE_SET,
|
||||
SUPPORT_STOP,
|
||||
SUPPORT_TURN_OFF,
|
||||
SUPPORT_TURN_ON,
|
||||
SUPPORT_VOLUME_MUTE,
|
||||
SUPPORT_VOLUME_SET,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
STATE_IDLE,
|
||||
STATE_ON,
|
||||
STATE_OFF,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType
|
||||
|
||||
from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity
|
||||
from .const import (
|
||||
DEFAULT_ZONE,
|
||||
DOMAIN,
|
||||
HA_REPEAT_MODE_TO_MC_MAPPING,
|
||||
INTERVAL_SECONDS,
|
||||
MC_REPEAT_MODE_TO_HA_MAPPING,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_FEATURES = (
|
||||
SUPPORT_PLAY
|
||||
| SUPPORT_PAUSE
|
||||
| SUPPORT_STOP
|
||||
| SUPPORT_PREVIOUS_TRACK
|
||||
| SUPPORT_NEXT_TRACK
|
||||
| SUPPORT_TURN_ON
|
||||
| SUPPORT_TURN_OFF
|
||||
MUSIC_PLAYER_SUPPORT = (
|
||||
SUPPORT_PAUSE
|
||||
| SUPPORT_VOLUME_SET
|
||||
| SUPPORT_VOLUME_MUTE
|
||||
| SUPPORT_TURN_ON
|
||||
| SUPPORT_TURN_OFF
|
||||
| SUPPORT_CLEAR_PLAYLIST
|
||||
| SUPPORT_PLAY
|
||||
| SUPPORT_SHUFFLE_SET
|
||||
| SUPPORT_REPEAT_SET
|
||||
| SUPPORT_PREVIOUS_TRACK
|
||||
| SUPPORT_NEXT_TRACK
|
||||
| SUPPORT_SELECT_SOUND_MODE
|
||||
| SUPPORT_SELECT_SOURCE
|
||||
| SUPPORT_STOP
|
||||
)
|
||||
|
||||
KNOWN_HOSTS_KEY = "data_yamaha_musiccast"
|
||||
INTERVAL_SECONDS = "interval_seconds"
|
||||
|
||||
DEFAULT_PORT = 5005
|
||||
DEFAULT_INTERVAL = 480
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(INTERVAL_SECONDS, default=DEFAULT_INTERVAL): cv.positive_int,
|
||||
vol.Optional(CONF_PORT, default=5000): cv.port,
|
||||
vol.Optional(INTERVAL_SECONDS, default=0): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Set up the Yamaha MusicCast platform."""
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistantType,
|
||||
config,
|
||||
async_add_devices: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Import legacy configurations."""
|
||||
|
||||
known_hosts = hass.data.get(KNOWN_HOSTS_KEY)
|
||||
if known_hosts is None:
|
||||
known_hosts = hass.data[KNOWN_HOSTS_KEY] = []
|
||||
_LOGGER.debug("known_hosts: %s", known_hosts)
|
||||
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
interval = config.get(INTERVAL_SECONDS)
|
||||
|
||||
# Get IP of host to prevent duplicates
|
||||
try:
|
||||
ipaddr = socket.gethostbyname(host)
|
||||
except (OSError) as error:
|
||||
_LOGGER.error("Could not communicate with %s:%d: %s", host, port, error)
|
||||
return
|
||||
|
||||
if [item for item in known_hosts if item[0] == ipaddr]:
|
||||
_LOGGER.warning("Host %s:%d already registered", host, port)
|
||||
return
|
||||
|
||||
if [item for item in known_hosts if item[1] == port]:
|
||||
_LOGGER.warning("Port %s:%d already registered", host, port)
|
||||
return
|
||||
|
||||
reg_host = (ipaddr, port)
|
||||
known_hosts.append(reg_host)
|
||||
|
||||
try:
|
||||
receiver = pymusiccast.McDevice(ipaddr, udp_port=port, mc_interval=interval)
|
||||
except pymusiccast.exceptions.YMCInitError as err:
|
||||
_LOGGER.error(err)
|
||||
receiver = None
|
||||
|
||||
if receiver:
|
||||
for zone in receiver.zones:
|
||||
_LOGGER.debug("Receiver: %s / Port: %d / Zone: %s", receiver, port, zone)
|
||||
add_entities([YamahaDevice(receiver, receiver.zones[zone])], True)
|
||||
if hass.config_entries.async_entries(DOMAIN) and config[CONF_HOST] not in [
|
||||
entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
]:
|
||||
_LOGGER.error(
|
||||
"Configuration in configuration.yaml is not supported anymore. "
|
||||
"Please add this device using the config flow: %s",
|
||||
config[CONF_HOST],
|
||||
)
|
||||
else:
|
||||
known_hosts.remove(reg_host)
|
||||
_LOGGER.warning(
|
||||
"Configuration in configuration.yaml is deprecated. Use the config flow instead"
|
||||
)
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class YamahaDevice(MediaPlayerEntity):
|
||||
"""Representation of a Yamaha MusicCast device."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MusicCast sensor based on a config entry."""
|
||||
coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
def __init__(self, recv, zone):
|
||||
"""Initialize the Yamaha MusicCast device."""
|
||||
self._recv = recv
|
||||
self._name = recv.name
|
||||
self._source = None
|
||||
self._source_list = []
|
||||
self._zone = zone
|
||||
self.mute = False
|
||||
self.media_status = None
|
||||
self.media_status_received = None
|
||||
self.power = STATE_UNKNOWN
|
||||
self.status = STATE_UNKNOWN
|
||||
self.volume = 0
|
||||
self.volume_max = 0
|
||||
self._recv.set_yamaha_device(self)
|
||||
self._zone.set_yamaha_device(self)
|
||||
name = coordinator.data.network_name
|
||||
|
||||
media_players: list[Entity] = []
|
||||
|
||||
for zone in coordinator.data.zones:
|
||||
zone_name = name if zone == DEFAULT_ZONE else f"{name} {zone}"
|
||||
|
||||
media_players.append(
|
||||
MusicCastMediaPlayer(zone, zone_name, entry.entry_id, coordinator)
|
||||
)
|
||||
|
||||
async_add_entities(media_players)
|
||||
|
||||
|
||||
class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
|
||||
"""The musiccast media player."""
|
||||
|
||||
def __init__(self, zone_id, name, entry_id, coordinator):
|
||||
"""Initialize the musiccast device."""
|
||||
self._player_state = STATE_PLAYING
|
||||
self._volume_muted = False
|
||||
self._shuffle = False
|
||||
self._zone_id = zone_id
|
||||
|
||||
super().__init__(
|
||||
name=name,
|
||||
icon="mdi:speaker",
|
||||
coordinator=coordinator,
|
||||
)
|
||||
|
||||
self._volume_min = self.coordinator.data.zones[self._zone_id].min_volume
|
||||
self._volume_max = self.coordinator.data.zones[self._zone_id].max_volume
|
||||
|
||||
self._cur_track = 0
|
||||
self._repeat = REPEAT_MODE_OFF
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when this Entity has been added to HA."""
|
||||
await super().async_added_to_hass()
|
||||
# Sensors should also register callbacks to HA when their state changes
|
||||
self.coordinator.musiccast.register_callback(self.async_write_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Entity being removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
# The opposite of async_added_to_hass. Remove any registered call backs here.
|
||||
self.coordinator.musiccast.remove_callback(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return f"{self._name} ({self._zone.zone_id})"
|
||||
def should_poll(self):
|
||||
"""Push an update after each command."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def _is_netusb(self):
|
||||
return (
|
||||
self.coordinator.data.netusb_input
|
||||
== self.coordinator.data.zones[self._zone_id].input
|
||||
)
|
||||
|
||||
@property
|
||||
def _is_tuner(self):
|
||||
return self.coordinator.data.zones[self._zone_id].input == "tuner"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self.power == STATE_ON and self.status != STATE_UNKNOWN:
|
||||
return self.status
|
||||
return self.power
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
"""Boolean if volume is currently muted."""
|
||||
return self.mute
|
||||
"""Return the state of the player."""
|
||||
if self.coordinator.data.zones[self._zone_id].power == "on":
|
||||
if self._is_netusb and self.coordinator.data.netusb_playback == "pause":
|
||||
return STATE_PAUSED
|
||||
if self._is_netusb and self.coordinator.data.netusb_playback == "stop":
|
||||
return STATE_IDLE
|
||||
return STATE_PLAYING
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
"""Volume level of the media player (0..1)."""
|
||||
return self.volume
|
||||
"""Return the volume level of the media player (0..1)."""
|
||||
volume = self.coordinator.data.zones[self._zone_id].current_volume
|
||||
return (volume - self._volume_min) / (self._volume_max - self._volume_min)
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
"""Return boolean if volume is currently muted."""
|
||||
return self.coordinator.data.zones[self._zone_id].mute
|
||||
|
||||
@property
|
||||
def shuffle(self):
|
||||
"""Boolean if shuffling is enabled."""
|
||||
return (
|
||||
self.coordinator.data.netusb_shuffle == "on" if self._is_netusb else False
|
||||
)
|
||||
|
||||
@property
|
||||
def sound_mode(self):
|
||||
"""Return the current sound mode."""
|
||||
return self.coordinator.data.zones[self._zone_id].sound_program
|
||||
|
||||
@property
|
||||
def sound_mode_list(self):
|
||||
"""Return a list of available sound modes."""
|
||||
return self.coordinator.data.zones[self._zone_id].sound_program_list
|
||||
|
||||
@property
|
||||
def zone(self):
|
||||
"""Return the zone of the media player."""
|
||||
return self._zone_id
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this media_player."""
|
||||
return f"{self.coordinator.data.device_id}_{self._zone_id}"
|
||||
|
||||
async def async_turn_on(self):
|
||||
"""Turn the media player on."""
|
||||
await self.coordinator.musiccast.turn_on(self._zone_id)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self):
|
||||
"""Turn the media player off."""
|
||||
await self.coordinator.musiccast.turn_off(self._zone_id)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_mute_volume(self, mute):
|
||||
"""Mute the volume."""
|
||||
|
||||
await self.coordinator.musiccast.mute_volume(self._zone_id, mute)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_volume_level(self, volume):
|
||||
"""Set the volume level, range 0..1."""
|
||||
await self.coordinator.musiccast.set_volume_level(self._zone_id, volume)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_media_play(self):
|
||||
"""Send play command."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_play()
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service play is not supported for non NetUSB sources."
|
||||
)
|
||||
|
||||
async def async_media_pause(self):
|
||||
"""Send pause command."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_pause()
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service pause is not supported for non NetUSB sources."
|
||||
)
|
||||
|
||||
async def async_media_stop(self):
|
||||
"""Send stop command."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_pause()
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service stop is not supported for non NetUSB sources."
|
||||
)
|
||||
|
||||
async def async_set_shuffle(self, shuffle):
|
||||
"""Enable/disable shuffle mode."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_shuffle(shuffle)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service shuffle is not supported for non NetUSB sources."
|
||||
)
|
||||
|
||||
async def async_select_sound_mode(self, sound_mode):
|
||||
"""Select sound mode."""
|
||||
await self.coordinator.musiccast.select_sound_mode(self._zone_id, sound_mode)
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Return the image url of current playing media."""
|
||||
return self.coordinator.musiccast.media_image_url if self._is_netusb else None
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Return the title of current playing media."""
|
||||
if self._is_netusb:
|
||||
return self.coordinator.data.netusb_track
|
||||
if self._is_tuner:
|
||||
return self.coordinator.musiccast.tuner_media_title
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Return the artist of current playing media (Music track only)."""
|
||||
if self._is_netusb:
|
||||
return self.coordinator.data.netusb_artist
|
||||
if self._is_tuner:
|
||||
return self.coordinator.musiccast.tuner_media_artist
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
"""Return the album of current playing media (Music track only)."""
|
||||
return self.coordinator.data.netusb_album if self._is_netusb else None
|
||||
|
||||
@property
|
||||
def repeat(self):
|
||||
"""Return current repeat mode."""
|
||||
return (
|
||||
MC_REPEAT_MODE_TO_HA_MAPPING.get(self.coordinator.data.netusb_repeat)
|
||||
if self._is_netusb
|
||||
else REPEAT_MODE_OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag of features that are supported."""
|
||||
return SUPPORTED_FEATURES
|
||||
"""Flag media player features that are supported."""
|
||||
return MUSIC_PLAYER_SUPPORT
|
||||
|
||||
async def async_media_previous_track(self):
|
||||
"""Send previous track command."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_previous_track()
|
||||
elif self._is_tuner:
|
||||
await self.coordinator.musiccast.tuner_previous_station()
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service previous track is not supported for non NetUSB or Tuner sources."
|
||||
)
|
||||
|
||||
async def async_media_next_track(self):
|
||||
"""Send next track command."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_next_track()
|
||||
elif self._is_tuner:
|
||||
await self.coordinator.musiccast.tuner_next_station()
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service next track is not supported for non NetUSB or Tuner sources."
|
||||
)
|
||||
|
||||
def clear_playlist(self):
|
||||
"""Clear players playlist."""
|
||||
self._cur_track = 0
|
||||
self._player_state = STATE_OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_repeat(self, repeat):
|
||||
"""Enable/disable repeat mode."""
|
||||
if self._is_netusb:
|
||||
await self.coordinator.musiccast.netusb_repeat(
|
||||
HA_REPEAT_MODE_TO_MC_MAPPING.get(repeat, "off")
|
||||
)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
"Service set repeat is not supported for non NetUSB sources."
|
||||
)
|
||||
|
||||
async def async_select_source(self, source):
|
||||
"""Select input source."""
|
||||
await self.coordinator.musiccast.select_source(self._zone_id, source)
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Return the current input source."""
|
||||
return self._source
|
||||
"""Name of the current input source."""
|
||||
return self.coordinator.data.zones[self._zone_id].input
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
"""List of available input sources."""
|
||||
return self._source_list
|
||||
|
||||
@source_list.setter
|
||||
def source_list(self, value):
|
||||
"""Set source_list attribute."""
|
||||
self._source_list = value
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Return the media content type."""
|
||||
return MEDIA_TYPE_MUSIC
|
||||
return self.coordinator.data.zones[self._zone_id].input_list
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
"""Duration of current playing media in seconds."""
|
||||
return self.media_status.media_duration if self.media_status else None
|
||||
if self._is_netusb:
|
||||
return self.coordinator.data.netusb_total_time
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
return self.media_status.media_image_url if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Artist of current playing media, music track only."""
|
||||
return self.media_status.media_artist if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_album(self):
|
||||
"""Album of current playing media, music track only."""
|
||||
return self.media_status.media_album if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_track(self):
|
||||
"""Track number of current playing media, music track only."""
|
||||
return self.media_status.media_track if self.media_status else None
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Title of current playing media."""
|
||||
return self.media_status.media_title if self.media_status else None
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_position(self):
|
||||
"""Position of current playing media in seconds."""
|
||||
if self.media_status and self.state in [
|
||||
STATE_PLAYING,
|
||||
STATE_PAUSED,
|
||||
STATE_IDLE,
|
||||
]:
|
||||
return self.media_status.media_position
|
||||
if self._is_netusb:
|
||||
return self.coordinator.data.netusb_play_time
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self):
|
||||
@ -218,74 +404,7 @@ class YamahaDevice(MediaPlayerEntity):
|
||||
|
||||
Returns value from homeassistant.util.dt.utcnow().
|
||||
"""
|
||||
return self.media_status_received if self.media_status else None
|
||||
if self._is_netusb:
|
||||
return self.coordinator.data.netusb_play_time_updated
|
||||
|
||||
def update(self):
|
||||
"""Get the latest details from the device."""
|
||||
_LOGGER.debug("update: %s", self.entity_id)
|
||||
self._recv.update_status()
|
||||
self._zone.update_status()
|
||||
|
||||
def update_hass(self):
|
||||
"""Push updates to Home Assistant."""
|
||||
if self.entity_id:
|
||||
_LOGGER.debug("update_hass: pushing updates")
|
||||
self.schedule_update_ha_state()
|
||||
return True
|
||||
|
||||
def turn_on(self):
|
||||
"""Turn on specified media player or all."""
|
||||
_LOGGER.debug("Turn device: on")
|
||||
self._zone.set_power(True)
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn off specified media player or all."""
|
||||
_LOGGER.debug("Turn device: off")
|
||||
self._zone.set_power(False)
|
||||
|
||||
def media_play(self):
|
||||
"""Send the media player the command for play/pause."""
|
||||
_LOGGER.debug("Play")
|
||||
self._recv.set_playback("play")
|
||||
|
||||
def media_pause(self):
|
||||
"""Send the media player the command for pause."""
|
||||
_LOGGER.debug("Pause")
|
||||
self._recv.set_playback("pause")
|
||||
|
||||
def media_stop(self):
|
||||
"""Send the media player the stop command."""
|
||||
_LOGGER.debug("Stop")
|
||||
self._recv.set_playback("stop")
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send the media player the command for prev track."""
|
||||
_LOGGER.debug("Previous")
|
||||
self._recv.set_playback("previous")
|
||||
|
||||
def media_next_track(self):
|
||||
"""Send the media player the command for next track."""
|
||||
_LOGGER.debug("Next")
|
||||
self._recv.set_playback("next")
|
||||
|
||||
def mute_volume(self, mute):
|
||||
"""Send mute command."""
|
||||
_LOGGER.debug("Mute volume: %s", mute)
|
||||
self._zone.set_mute(mute)
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
"""Set volume level, range 0..1."""
|
||||
_LOGGER.debug("Volume level: %.2f / %d", volume, volume * self.volume_max)
|
||||
self._zone.set_volume(volume * self.volume_max)
|
||||
|
||||
def select_source(self, source):
|
||||
"""Send the media player the command to select input source."""
|
||||
_LOGGER.debug("select_source: %s", source)
|
||||
self.status = STATE_UNKNOWN
|
||||
self._zone.set_input(source)
|
||||
|
||||
def new_media_status(self, status):
|
||||
"""Handle updates of the media status."""
|
||||
_LOGGER.debug("new media_status arrived")
|
||||
self.media_status = status
|
||||
self.media_status_received = dt_util.utcnow()
|
||||
return None
|
||||
|
23
homeassistant/components/yamaha_musiccast/strings.json
Normal file
23
homeassistant/components/yamaha_musiccast/strings.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "MusicCast: {name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Set up MusicCast to integrate with Home Assistant.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"yxc_control_url_missing": "The control URL is not given in the ssdp description."
|
||||
},
|
||||
"error": {
|
||||
"no_musiccast_device": "This device seems to be no MusicCast Device."
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"yxc_control_url_missing": "The control URL is not given in the ssdp description."
|
||||
},
|
||||
"error": {
|
||||
"no_musiccast_device": "This device seems to be no MusicCast Device."
|
||||
},
|
||||
"flow_title": "MusicCast: {name}",
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to start set up?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host"
|
||||
},
|
||||
"description": "Set up MusicCast to integrate with Home Assistant."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -289,6 +289,7 @@ FLOWS = [
|
||||
"xbox",
|
||||
"xiaomi_aqara",
|
||||
"xiaomi_miio",
|
||||
"yamaha_musiccast",
|
||||
"yeelight",
|
||||
"zerproc",
|
||||
"zha",
|
||||
|
@ -215,5 +215,10 @@ SSDP = {
|
||||
{
|
||||
"manufacturer": "All Automacao Ltda"
|
||||
}
|
||||
],
|
||||
"yamaha_musiccast": [
|
||||
{
|
||||
"manufacturer": "Yamaha Corporation"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -208,6 +208,9 @@ aiolyric==1.0.7
|
||||
# homeassistant.components.modern_forms
|
||||
aiomodernforms==0.1.5
|
||||
|
||||
# homeassistant.components.yamaha_musiccast
|
||||
aiomusiccast==0.6
|
||||
|
||||
# homeassistant.components.keyboard_remote
|
||||
aionotify==0.2.0
|
||||
|
||||
@ -1585,9 +1588,6 @@ pymonoprice==0.3
|
||||
# homeassistant.components.msteams
|
||||
pymsteams==0.1.12
|
||||
|
||||
# homeassistant.components.yamaha_musiccast
|
||||
pymusiccast==0.1.6
|
||||
|
||||
# homeassistant.components.myq
|
||||
pymyq==3.0.4
|
||||
|
||||
|
@ -133,6 +133,9 @@ aiolyric==1.0.7
|
||||
# homeassistant.components.modern_forms
|
||||
aiomodernforms==0.1.5
|
||||
|
||||
# homeassistant.components.yamaha_musiccast
|
||||
aiomusiccast==0.6
|
||||
|
||||
# homeassistant.components.notion
|
||||
aionotion==1.1.0
|
||||
|
||||
|
1
tests/components/yamaha_musiccast/__init__.py
Normal file
1
tests/components/yamaha_musiccast/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the MusicCast integration."""
|
287
tests/components/yamaha_musiccast/test_config_flow.py
Normal file
287
tests/components/yamaha_musiccast/test_config_flow.py
Normal file
@ -0,0 +1,287 @@
|
||||
"""Test config flow."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiomusiccast import MusicCastConnectionException
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.components.yamaha_musiccast.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_setup_entry():
|
||||
"""Mock setting up a config entry."""
|
||||
with patch(
|
||||
"homeassistant.components.yamaha_musiccast.async_setup_entry", return_value=True
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_device_info_valid():
|
||||
"""Mock getting valid device info from musiccast API."""
|
||||
with patch(
|
||||
"aiomusiccast.MusicCastDevice.get_device_info",
|
||||
return_value={"system_id": "1234567890", "model_name": "MC20"},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_device_info_invalid():
|
||||
"""Mock getting invalid device info from musiccast API."""
|
||||
with patch(
|
||||
"aiomusiccast.MusicCastDevice.get_device_info",
|
||||
return_value={"type": "no_yamaha"},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_device_info_exception():
|
||||
"""Mock raising an unexpected Exception."""
|
||||
with patch(
|
||||
"aiomusiccast.MusicCastDevice.get_device_info",
|
||||
side_effect=Exception("mocked error"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_device_info_mc_exception():
|
||||
"""Mock raising an unexpected Exception."""
|
||||
with patch(
|
||||
"aiomusiccast.MusicCastDevice.get_device_info",
|
||||
side_effect=MusicCastConnectionException("mocked error"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssdp_yamaha():
|
||||
"""Mock that the SSDP detected device is a musiccast device."""
|
||||
with patch("aiomusiccast.MusicCastDevice.check_yamaha_ssdp", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssdp_no_yamaha():
|
||||
"""Mock that the SSDP detected device is not a musiccast device."""
|
||||
with patch("aiomusiccast.MusicCastDevice.check_yamaha_ssdp", return_value=False):
|
||||
yield
|
||||
|
||||
|
||||
# User Flows
|
||||
|
||||
|
||||
async def test_user_input_device_not_found(hass, mock_get_device_info_mc_exception):
|
||||
"""Test when user specifies a non-existing device."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"host": "none"},
|
||||
)
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_input_non_yamaha_device_found(hass, mock_get_device_info_invalid):
|
||||
"""Test when user specifies an existing device, which does not provide the musiccast API."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"host": "127.0.0.1"},
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "no_musiccast_device"}
|
||||
|
||||
|
||||
async def test_user_input_device_already_existing(hass, mock_get_device_info_valid):
|
||||
"""Test when user specifies an existing device."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="1234567890",
|
||||
data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"},
|
||||
)
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"host": "192.168.188.18"},
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_user_input_unknown_error(hass, mock_get_device_info_exception):
|
||||
"""Test when user specifies an existing device, which does not provide the musiccast API."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"host": "127.0.0.1"},
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_user_input_device_found(hass, mock_get_device_info_valid):
|
||||
"""Test when user specifies an existing device."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"host": "127.0.0.1"},
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert isinstance(result2["result"], ConfigEntry)
|
||||
assert result2["data"] == {
|
||||
"host": "127.0.0.1",
|
||||
"serial": "1234567890",
|
||||
}
|
||||
|
||||
|
||||
async def test_import_device_already_existing(hass, mock_get_device_info_valid):
|
||||
"""Test when the configurations.yaml contains an existing device."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="1234567890",
|
||||
data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"},
|
||||
)
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
config = {"platform": "yamaha_musiccast", "host": "192.168.188.18", "port": 5006}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_import_error(hass, mock_get_device_info_exception):
|
||||
"""Test when in the configuration.yaml a device is configured, which cannot be added.."""
|
||||
config = {"platform": "yamaha_musiccast", "host": "192.168.188.18", "port": 5006}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_import_device_successful(hass, mock_get_device_info_valid):
|
||||
"""Test when the device was imported successfully."""
|
||||
config = {"platform": "yamaha_musiccast", "host": "127.0.0.1", "port": 5006}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert isinstance(result["result"], ConfigEntry)
|
||||
assert result["data"] == {
|
||||
"host": "127.0.0.1",
|
||||
"serial": "1234567890",
|
||||
}
|
||||
|
||||
|
||||
# SSDP Flows
|
||||
|
||||
|
||||
async def test_ssdp_discovery_failed(hass, mock_ssdp_no_yamaha):
|
||||
"""Test when an SSDP discovered device is not a musiccast device."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_SSDP},
|
||||
data={
|
||||
ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml",
|
||||
ssdp.ATTR_UPNP_MODEL_NAME: "MC20",
|
||||
ssdp.ATTR_UPNP_SERIAL: "123456789",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "yxc_control_url_missing"
|
||||
|
||||
|
||||
async def test_ssdp_discovery_successful_add_device(hass, mock_ssdp_yamaha):
|
||||
"""Test when the SSDP discovered device is a musiccast device and the user confirms it."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_SSDP},
|
||||
data={
|
||||
ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml",
|
||||
ssdp.ATTR_UPNP_MODEL_NAME: "MC20",
|
||||
ssdp.ATTR_UPNP_SERIAL: "1234567890",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||
assert result["errors"] is None
|
||||
assert result["step_id"] == "confirm"
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{},
|
||||
)
|
||||
|
||||
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert isinstance(result2["result"], ConfigEntry)
|
||||
assert result2["data"] == {
|
||||
"host": "127.0.0.1",
|
||||
"serial": "1234567890",
|
||||
}
|
||||
|
||||
|
||||
async def test_ssdp_discovery_existing_device_update(hass, mock_ssdp_yamaha):
|
||||
"""Test when the SSDP discovered device is a musiccast device, but it already exists with another IP."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="1234567890",
|
||||
data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"},
|
||||
)
|
||||
mock_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_SSDP},
|
||||
data={
|
||||
ssdp.ATTR_SSDP_LOCATION: "http://127.0.0.1/desc.xml",
|
||||
ssdp.ATTR_UPNP_MODEL_NAME: "MC20",
|
||||
ssdp.ATTR_UPNP_SERIAL: "1234567890",
|
||||
},
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert mock_entry.data[CONF_HOST] == "127.0.0.1"
|
Loading…
x
Reference in New Issue
Block a user