Add Harman Luxury Audio integration (#175650)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Steven Beshensky
2026-07-13 10:10:23 -05:00
committed by GitHub
parent e73bf9bd38
commit 65c4b21ac4
21 changed files with 1139 additions and 0 deletions
+1
View File
@@ -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
View File
@@ -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."
}
}
}
+1
View File
@@ -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",
+10
View File
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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