mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Add Harman Luxury Audio integration (#175650)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -255,6 +255,7 @@ homeassistant.components.guntamatic.*
|
||||
homeassistant.components.habitica.*
|
||||
homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
homeassistant.components.harman_luxury.*
|
||||
homeassistant.components.hdfury.*
|
||||
homeassistant.components.heos.*
|
||||
homeassistant.components.here_travel_time.*
|
||||
|
||||
Generated
+2
@@ -721,6 +721,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/hardkernel/ @home-assistant/core
|
||||
/homeassistant/components/hardware/ @home-assistant/core
|
||||
/tests/components/hardware/ @home-assistant/core
|
||||
/homeassistant/components/harman_luxury/ @sbesh91
|
||||
/tests/components/harman_luxury/ @sbesh91
|
||||
/homeassistant/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
|
||||
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
|
||||
/homeassistant/components/hassio/ @home-assistant/supervisor
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The Harman Luxury Audio integration."""
|
||||
|
||||
from aioharmanluxury import HarmanLuxuryClient
|
||||
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: HarmanLuxuryConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Harman Luxury from a config entry."""
|
||||
client = HarmanLuxuryClient(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
coordinator = HarmanLuxuryCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: HarmanLuxuryConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Config flow for the Harman Luxury integration."""
|
||||
|
||||
from typing import Any, override
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aioharmanluxury import DeviceInfo, HarmanLuxuryClient, HarmanLuxuryError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
|
||||
class HarmanLuxuryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Harman Luxury."""
|
||||
|
||||
_host: str
|
||||
_name: str
|
||||
|
||||
async def _async_get_info(self, host: str) -> DeviceInfo | None:
|
||||
"""Return the device info, or ``None`` if it has no usable identity."""
|
||||
client = HarmanLuxuryClient(host, async_get_clientsession(self.hass))
|
||||
try:
|
||||
info = await client.async_get_info()
|
||||
except HarmanLuxuryError:
|
||||
return None
|
||||
if not info.serial:
|
||||
return None
|
||||
return info
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
info = await self._async_get_info(user_input[CONF_HOST])
|
||||
if info is None:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(info.serial)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=info.name, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by SSDP discovery."""
|
||||
host = urlparse(discovery_info.ssdp_location or "").hostname
|
||||
serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL)
|
||||
if not host or not serial:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(serial)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
info = await self._async_get_info(host)
|
||||
# The unique ID is the advertised serial; refuse a device whose API
|
||||
# reports a different one, so setup cannot later fail on the mismatch.
|
||||
if info is None or info.serial != serial:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self._host = host
|
||||
self._name = info.name
|
||||
self.context["title_placeholders"] = {"name": info.name}
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm setup of a discovered device."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._name, data={CONF_HOST: self._host}
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"name": self._name},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Harman Luxury integration."""
|
||||
|
||||
DOMAIN = "harman_luxury"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Data update coordinator for Harman Luxury."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from aioharmanluxury import (
|
||||
DeviceInfo,
|
||||
HarmanLuxuryClient,
|
||||
HarmanLuxuryError,
|
||||
HarmanLuxuryState,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HarmanLuxuryConfigEntry = ConfigEntry[HarmanLuxuryCoordinator]
|
||||
|
||||
_SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
|
||||
class HarmanLuxuryCoordinator(DataUpdateCoordinator[HarmanLuxuryState]):
|
||||
"""Poll a Harman Luxury device for its live player state."""
|
||||
|
||||
config_entry: HarmanLuxuryConfigEntry
|
||||
device_info: DeviceInfo
|
||||
position_updated_at: datetime | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HarmanLuxuryConfigEntry,
|
||||
client: HarmanLuxuryClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.title,
|
||||
update_interval=_SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
@override
|
||||
async def _async_setup(self) -> None:
|
||||
"""Fetch static device identity once."""
|
||||
try:
|
||||
self.device_info = await self.client.async_get_info()
|
||||
except HarmanLuxuryError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
if self.device_info.serial != self.config_entry.unique_id:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_device",
|
||||
)
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> HarmanLuxuryState:
|
||||
"""Fetch the latest player state."""
|
||||
try:
|
||||
state = await self.client.async_get_state()
|
||||
except HarmanLuxuryError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
self.position_updated_at = (
|
||||
dt_util.utcnow() if state.position is not None else None
|
||||
)
|
||||
return state
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"domain": "harman_luxury",
|
||||
"name": "Harman Luxury Audio",
|
||||
"codeowners": ["@sbesh91"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/harman_luxury",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioharmanluxury==0.2.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"manufacturer": "Harman Luxury Audio"
|
||||
},
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
|
||||
"manufacturer": "Harman Luxury Audio"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Media player platform for Harman Luxury."""
|
||||
|
||||
from collections.abc import Coroutine
|
||||
from datetime import datetime
|
||||
from typing import Any, override
|
||||
|
||||
from aioharmanluxury import HarmanLuxuryClient, HarmanLuxuryError
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator
|
||||
|
||||
# The device serializes control on a single session; serialize at HA's layer.
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# The device exposes volume on a 0..99 scale.
|
||||
_VOLUME_MAX = 99
|
||||
|
||||
_PLAY_STATE_MAP = {
|
||||
"playing": MediaPlayerState.PLAYING,
|
||||
"paused": MediaPlayerState.PAUSED,
|
||||
"stopped": MediaPlayerState.IDLE,
|
||||
"buffering": MediaPlayerState.BUFFERING,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HarmanLuxuryConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the media player from a config entry."""
|
||||
async_add_entities([HarmanLuxuryMediaPlayer(entry.runtime_data)])
|
||||
|
||||
|
||||
class HarmanLuxuryMediaPlayer(
|
||||
CoordinatorEntity[HarmanLuxuryCoordinator], MediaPlayerEntity
|
||||
):
|
||||
"""Representation of a Harman Luxury streamer."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 1 / _VOLUME_MAX
|
||||
|
||||
_BASE_FEATURES = (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: HarmanLuxuryCoordinator) -> None:
|
||||
"""Initialize the media player."""
|
||||
super().__init__(coordinator)
|
||||
info = coordinator.device_info
|
||||
self._attr_unique_id = info.serial
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, info.serial)},
|
||||
connections={(CONNECTION_NETWORK_MAC, info.mac)} if info.mac else set(),
|
||||
manufacturer="Harman Luxury Audio",
|
||||
model=info.model,
|
||||
name=info.name,
|
||||
)
|
||||
|
||||
@property
|
||||
def _client(self) -> HarmanLuxuryClient:
|
||||
"""Return the device client."""
|
||||
return self.coordinator.client
|
||||
|
||||
@property
|
||||
@override
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the state of the device."""
|
||||
data = self.coordinator.data
|
||||
if not data.online:
|
||||
return MediaPlayerState.OFF
|
||||
return _PLAY_STATE_MAP.get(data.play_state, MediaPlayerState.ON)
|
||||
|
||||
@property
|
||||
@override
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return the supported features."""
|
||||
features = self._BASE_FEATURES
|
||||
data = self.coordinator.data
|
||||
if data.can_play:
|
||||
features |= MediaPlayerEntityFeature.PLAY
|
||||
if data.can_pause:
|
||||
features |= MediaPlayerEntityFeature.PAUSE
|
||||
if data.can_stop:
|
||||
features |= MediaPlayerEntityFeature.STOP
|
||||
if data.can_next:
|
||||
features |= MediaPlayerEntityFeature.NEXT_TRACK
|
||||
if data.can_previous:
|
||||
features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
return features
|
||||
|
||||
@property
|
||||
@override
|
||||
def volume_level(self) -> float:
|
||||
"""Return the volume level (0..1)."""
|
||||
return self.coordinator.data.volume / _VOLUME_MAX
|
||||
|
||||
@property
|
||||
@override
|
||||
def is_volume_muted(self) -> bool:
|
||||
"""Return whether the output is muted."""
|
||||
return self.coordinator.data.muted
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_title(self) -> str | None:
|
||||
"""Return the title of the current media."""
|
||||
return self.coordinator.data.title
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_artist(self) -> str | None:
|
||||
"""Return the artist of the current media."""
|
||||
return self.coordinator.data.artist
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Return the album of the current media."""
|
||||
return self.coordinator.data.album
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return the album art URL."""
|
||||
return self.coordinator.data.art_url
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_duration(self) -> int | None:
|
||||
"""Return the duration of the current media, in seconds."""
|
||||
duration = self.coordinator.data.duration
|
||||
return int(duration) if duration is not None else None
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_position(self) -> int | None:
|
||||
"""Return the position of the current media, in seconds."""
|
||||
position = self.coordinator.data.position
|
||||
return int(position) if position is not None else None
|
||||
|
||||
@property
|
||||
@override
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""Return when the media position was last retrieved."""
|
||||
return self.coordinator.position_updated_at
|
||||
|
||||
async def _async_send(self, coro: Coroutine[Any, Any, None]) -> None:
|
||||
"""Run a client command, translating failures and refreshing state."""
|
||||
try:
|
||||
await coro
|
||||
except HarmanLuxuryError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@override
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level."""
|
||||
await self._async_send(
|
||||
self._client.async_set_volume(round(volume * _VOLUME_MAX))
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute the output."""
|
||||
await self._async_send(self._client.async_set_mute(mute))
|
||||
|
||||
@override
|
||||
async def async_media_play(self) -> None:
|
||||
"""Resume playback."""
|
||||
await self._async_send(self._client.async_control("play"))
|
||||
|
||||
@override
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
await self._async_send(self._client.async_control("pause"))
|
||||
|
||||
@override
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Stop playback."""
|
||||
await self._async_send(self._client.async_control("stop"))
|
||||
|
||||
@override
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Skip to the next track."""
|
||||
await self._async_send(self._client.async_control("next"))
|
||||
|
||||
@override
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Skip to the previous track."""
|
||||
await self._async_send(self._client.async_control("previous"))
|
||||
@@ -0,0 +1,82 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not register any custom service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not register any custom service actions.
|
||||
docs-conditions:
|
||||
status: exempt
|
||||
comment: This integration does not register any conditions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-triggers:
|
||||
status: exempt
|
||||
comment: This integration does not register any triggers.
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: The device API is unauthenticated; there are no credentials to refresh.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: A config entry maps to a single device; there are no dynamic sub-devices.
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: The single media player entity is the primary entity and stays enabled.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: The media player uses the device name via has-entity-name.
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There are no repairable conditions surfaced by the device.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: A config entry maps to a single device; removal is via entry deletion.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up {name}?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Harman Luxury device."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_failed": {
|
||||
"message": "Failed to send the command to the device."
|
||||
},
|
||||
"unexpected_device": {
|
||||
"message": "The device at this address reports a different serial number than the configured device."
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -306,6 +306,7 @@ FLOWS = {
|
||||
"guntamatic",
|
||||
"habitica",
|
||||
"hanna",
|
||||
"harman_luxury",
|
||||
"harmony",
|
||||
"hdfury",
|
||||
"hegel",
|
||||
|
||||
@@ -2764,6 +2764,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"harman_luxury": {
|
||||
"name": "Harman Luxury Audio",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"harvey": {
|
||||
"name": "Harvey",
|
||||
"integration_type": "virtual",
|
||||
|
||||
Generated
+10
@@ -135,6 +135,16 @@ SSDP = {
|
||||
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1",
|
||||
},
|
||||
],
|
||||
"harman_luxury": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"manufacturer": "Harman Luxury Audio",
|
||||
},
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2",
|
||||
"manufacturer": "Harman Luxury Audio",
|
||||
},
|
||||
],
|
||||
"harmony": [
|
||||
{
|
||||
"deviceType": "urn:myharmony-com:device:harmony:1",
|
||||
|
||||
@@ -2307,6 +2307,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.harman_luxury.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.hdfury.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+3
@@ -281,6 +281,9 @@ aiogithubapi==26.0.0
|
||||
# homeassistant.components.guardian
|
||||
aioguardian==2026.01.1
|
||||
|
||||
# homeassistant.components.harman_luxury
|
||||
aioharmanluxury==0.2.3
|
||||
|
||||
# homeassistant.components.harmony
|
||||
aioharmony==1.0.8
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for the Harman Luxury integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the Harman Luxury integration in Home Assistant."""
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Common fixtures for the Harman Luxury tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aioharmanluxury import DeviceInfo, HarmanLuxuryState
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.harman_luxury.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_MANUFACTURER,
|
||||
ATTR_UPNP_SERIAL,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_HOST = "1.2.3.4"
|
||||
TEST_SERIAL = "48b782c2-60ac-40c1-a169-8d7ccb81dcc1"
|
||||
TEST_NAME = "Dining Room"
|
||||
|
||||
DEVICE_INFO = DeviceInfo(
|
||||
serial=TEST_SERIAL,
|
||||
model="ARCAM ST5",
|
||||
name=TEST_NAME,
|
||||
mac="02:FE:6C:B7:EB:59",
|
||||
)
|
||||
|
||||
PLAYER_STATE = HarmanLuxuryState(
|
||||
online=True,
|
||||
volume=45,
|
||||
muted=False,
|
||||
play_state="playing",
|
||||
title="Necessary Evil",
|
||||
artist="Motionless In White",
|
||||
album="Graveyard Shift",
|
||||
art_url="http://1.2.3.4/art.jpg",
|
||||
duration=228,
|
||||
position=42,
|
||||
can_play=True,
|
||||
can_pause=True,
|
||||
can_stop=True,
|
||||
can_next=True,
|
||||
can_previous=True,
|
||||
)
|
||||
|
||||
SSDP_DISCOVERY = SsdpServiceInfo(
|
||||
ssdp_usn=f"uuid:{TEST_SERIAL}",
|
||||
ssdp_st="urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
ssdp_location=f"http://{TEST_HOST}:16500/desc.xml",
|
||||
upnp={
|
||||
ATTR_UPNP_SERIAL: TEST_SERIAL,
|
||||
ATTR_UPNP_MANUFACTURER: "Harman Luxury Audio",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=TEST_NAME,
|
||||
data={CONF_HOST: TEST_HOST},
|
||||
unique_id=TEST_SERIAL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client() -> Generator[AsyncMock]:
|
||||
"""Mock the Harman Luxury client."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.harman_luxury.HarmanLuxuryClient",
|
||||
autospec=True,
|
||||
) as mock_client,
|
||||
patch(
|
||||
"homeassistant.components.harman_luxury.config_flow.HarmanLuxuryClient",
|
||||
new=mock_client,
|
||||
),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
client.async_get_info.return_value = DEVICE_INFO
|
||||
client.async_get_state.return_value = PLAYER_STATE
|
||||
yield client
|
||||
@@ -0,0 +1,63 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities[media_player.dining_room-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'media_player',
|
||||
'entity_category': None,
|
||||
'entity_id': 'media_player.dining_room',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'harman_luxury',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 21565>,
|
||||
'translation_key': None,
|
||||
'unique_id': '48b782c2-60ac-40c1-a169-8d7ccb81dcc1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[media_player.dining_room-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'speaker',
|
||||
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: '/api/media_player_proxy/media_player.dining_room?token=deterministic&cache=1f1d8cd564c70097',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Dining Room',
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED: 'is_volume_muted'>: False,
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME: 'media_album_name'>: 'Graveyard Shift',
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_ARTIST: 'media_artist'>: 'Motionless In White',
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_DURATION: 'media_duration'>: 228,
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_POSITION: 'media_position'>: 42,
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT: 'media_position_updated_at'>: HAFakeDatetime(2024, 1, 1, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_TITLE: 'media_title'>: 'Necessary Evil',
|
||||
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <MediaPlayerEntityFeature: 21565>,
|
||||
<MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: 'volume_level'>: 0.45454545454545453,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'media_player.dining_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'playing',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Test the Harman Luxury config flow."""
|
||||
|
||||
from dataclasses import replace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aioharmanluxury import HarmanLuxuryError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.harman_luxury.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import DEVICE_INFO, SSDP_DISCOVERY, TEST_HOST, TEST_NAME, TEST_SERIAL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_user_flow(hass: HomeAssistant) -> None:
|
||||
"""Test the full user configuration flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_NAME
|
||||
assert result["data"] == {CONF_HOST: TEST_HOST}
|
||||
assert result["result"].unique_id == TEST_SERIAL
|
||||
|
||||
|
||||
async def test_user_flow_cannot_connect(
|
||||
hass: HomeAssistant, mock_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test the user flow recovers from a connection error."""
|
||||
mock_client.async_get_info.side_effect = HarmanLuxuryError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
mock_client.async_get_info.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_flow_blank_serial(
|
||||
hass: HomeAssistant, mock_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test the user flow recovers from a device that reports no serial."""
|
||||
mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
mock_client.async_get_info.return_value = DEVICE_INFO
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test aborting the user flow when the device is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: TEST_HOST}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_ssdp_flow(hass: HomeAssistant) -> None:
|
||||
"""Test the SSDP discovery flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_NAME
|
||||
assert result["data"] == {CONF_HOST: TEST_HOST}
|
||||
assert result["result"].unique_id == TEST_SERIAL
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_ssdp_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test SSDP discovery aborts and updates the host when already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
discovery = replace(SSDP_DISCOVERY, ssdp_location="http://5.5.5.5:16500/desc.xml")
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=discovery
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
# The stale host is updated to the newly discovered one.
|
||||
assert mock_config_entry.data[CONF_HOST] == "5.5.5.5"
|
||||
|
||||
|
||||
async def test_ssdp_flow_cannot_connect(
|
||||
hass: HomeAssistant, mock_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test SSDP discovery aborts when the device cannot be reached."""
|
||||
mock_client.async_get_info.side_effect = HarmanLuxuryError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_ssdp_flow_missing_serial(hass: HomeAssistant) -> None:
|
||||
"""Test SSDP discovery aborts when the advertisement lacks a serial."""
|
||||
discovery = replace(SSDP_DISCOVERY, upnp={})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=discovery
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_ssdp_flow_serial_mismatch(
|
||||
hass: HomeAssistant, mock_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test SSDP discovery aborts when the API serial differs from the advertised one."""
|
||||
mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="different")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Test the Harman Luxury integration setup."""
|
||||
|
||||
from dataclasses import replace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aioharmanluxury import HarmanLuxuryError
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import DEVICE_INFO
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_setup_and_unload(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test a config entry loads and unloads cleanly."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_setup_cannot_connect(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test the config entry retries setup when the device is unreachable."""
|
||||
mock_client.async_get_info.side_effect = HarmanLuxuryError
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_unexpected_device(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test setup fails when the host answers as a different device."""
|
||||
mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="different")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Test the Harman Luxury media player."""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aioharmanluxury import HarmanLuxuryError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_NEXT_TRACK,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_MEDIA_STOP,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
MediaPlayerEntityFeature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import PLAYER_STATE
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
ENTITY_ID = "media_player.dining_room"
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2024-01-01 12:00:00+00:00")
|
||||
@pytest.mark.usefixtures("mock_client")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the media player entity state and attributes."""
|
||||
# Freeze the media-proxy token so the entity_picture URL is deterministic.
|
||||
with patch("secrets.token_hex", return_value="deterministic"):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_volume_set(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test setting the volume level."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.async_set_volume.assert_awaited_once_with(50)
|
||||
|
||||
|
||||
async def test_volume_mute(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test muting the output."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.async_set_mute.assert_awaited_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "command"),
|
||||
[
|
||||
(SERVICE_MEDIA_PAUSE, "pause"),
|
||||
(SERVICE_MEDIA_STOP, "stop"),
|
||||
(SERVICE_MEDIA_NEXT_TRACK, "next"),
|
||||
(SERVICE_MEDIA_PREVIOUS_TRACK, "previous"),
|
||||
],
|
||||
)
|
||||
async def test_transport_commands(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
service: str,
|
||||
command: str,
|
||||
) -> None:
|
||||
"""Test transport control services forward the right command."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.async_control.assert_awaited_once_with(command)
|
||||
|
||||
|
||||
async def test_command_error(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test a failing device command raises a HomeAssistantError."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_client.async_control.side_effect = HarmanLuxuryError
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_off_state(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test the player reports off when the device is not online."""
|
||||
mock_client.async_get_state.return_value = replace(PLAYER_STATE, online=False)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_OFF
|
||||
|
||||
|
||||
async def test_media_play_when_paused(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test that play is available and forwarded when the source is paused."""
|
||||
mock_client.async_get_state.return_value = replace(
|
||||
PLAYER_STATE, play_state="paused"
|
||||
)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.async_control.assert_awaited_once_with("play")
|
||||
|
||||
|
||||
async def test_transport_features_are_independent(
|
||||
hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test a source that only allows pause does not advertise play or stop."""
|
||||
mock_client.async_get_state.return_value = replace(
|
||||
PLAYER_STATE, can_play=False, can_pause=True, can_stop=False
|
||||
)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
features = hass.states.get(ENTITY_ID).attributes[ATTR_SUPPORTED_FEATURES]
|
||||
assert features & MediaPlayerEntityFeature.PAUSE
|
||||
assert not features & MediaPlayerEntityFeature.PLAY
|
||||
assert not features & MediaPlayerEntityFeature.STOP
|
||||
|
||||
|
||||
async def test_becomes_unavailable_on_error(
|
||||
hass: HomeAssistant,
|
||||
mock_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the entity goes unavailable when polling fails."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
mock_client.async_get_state.side_effect = HarmanLuxuryError
|
||||
freezer.tick(timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE
|
||||
Reference in New Issue
Block a user