Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions homeassistant/components/denon_rs232/media_player.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Media player platform for the Denon RS-232 integration."""

from typing import Literal, cast, override
import re
from typing import Any, Literal, cast, override

from denon_rs232 import (
MIN_VOLUME_DB,
Expand All @@ -13,13 +14,17 @@
)

from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

Expand Down Expand Up @@ -71,6 +76,25 @@
InputSource.DAB: "dab",
}

TUNER_PRESETS_ROOT = "presets"
TUNER_FREQUENCY_MIN = 8750
TUNER_FREQUENCY_MAX = 10800
TUNER_FREQUENCY_LENGTH = 6
#: Reported frequencies at or above this value are AM, which is not supported.
TUNER_FREQUENCY_FM_MAX = 50000


def _tuner_frequency_to_mhz(frequency: str | None) -> str | None:
"""Convert a reported tuner frequency to MHz, or None if it is not FM."""
if frequency is None or not frequency.isdigit():
return None

value = int(frequency)
if value >= TUNER_FREQUENCY_FM_MAX:
return None

return f"{value / 100:.2f}"


async def async_setup_entry(
hass: HomeAssistant,
Expand Down Expand Up @@ -138,7 +162,11 @@ def __init__(

if zone == "main":
self._attr_name = None
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._attr_supported_features |= (
MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
else:
self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3"

Expand Down Expand Up @@ -172,6 +200,13 @@ def _async_update_from_player(self) -> None:
source = self._player.input_source
self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None

if source is InputSource.TUNER:
self._attr_media_channel = _tuner_frequency_to_mhz(
self._receiver.state.main_zone.tuner_frequency
)
else:
self._attr_media_channel = None

volume_min = self._player.volume_min
volume_max = self._player.volume_max
if volume_min is not None:
Expand Down Expand Up @@ -239,3 +274,62 @@ async def async_select_source(self, source: str) -> None:
raise HomeAssistantError("Invalid source")

await self._player.select_input_source(input_source)

@override
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Tune to a tuner preset or an FM frequency."""
if media_type != MediaType.CHANNEL:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_media_type",
translation_placeholders={"media_type": str(media_type)},
)

player = cast(MainPlayer, self._player)
if re.fullmatch(r"[A-G][1-8]", media_id):
await player.set_tuner_preset(media_id)
elif (match := re.fullmatch(r"0*([0-9]{1,5})", media_id)) and (
TUNER_FREQUENCY_MIN <= (frequency := int(match[1])) <= TUNER_FREQUENCY_MAX
):
await player.set_tuner_frequency(f"{frequency:0{TUNER_FREQUENCY_LENGTH}d}")
else:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_tuner_channel",
translation_placeholders={"media_id": media_id},
)

@override
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""List the tuner presets as playable channels."""
if media_content_id not in (None, TUNER_PRESETS_ROOT):
raise BrowseError(f"Media not found: {media_content_id}")

return BrowseMedia(
title="Tuner presets",
media_class=MediaClass.DIRECTORY,
media_content_id=TUNER_PRESETS_ROOT,
media_content_type=MediaType.CHANNELS,
can_play=False,
can_expand=True,
children_media_class=MediaClass.CHANNEL,
children=[
BrowseMedia(
title=preset,
media_class=MediaClass.CHANNEL,
media_content_id=preset,
media_content_type=MediaType.CHANNEL,
can_play=True,
can_expand=False,
)
for preset in (
f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9)
)
],
)
8 changes: 8 additions & 0 deletions homeassistant/components/denon_rs232/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@
}
}
},
"exceptions": {
"invalid_tuner_channel": {
"message": "{media_id} is not a valid tuner preset (A1-G8) or FM frequency in hundredths of MHz (8750-10800; for example, 9930 for 99.30 MHz)."
},
"unsupported_media_type": {
"message": "Cannot play media of type {media_type}. Only tuner channels are supported."
}
},
"selector": {
"model": {
"options": {
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/esphome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.6.0",
"aioesphomeapi==45.6.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.7"
],
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/izone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EXCLUDE, Platform
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
Expand Down Expand Up @@ -108,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
unique_id=controller.device_uid,
title=new_title,
data={CONF_HOST: controller.device_ip},
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand Down
5 changes: 2 additions & 3 deletions homeassistant/components/izone/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,7 @@ async def async_step_integration_discovery(

await self.async_set_unique_id(uid)
self._abort_if_unique_id_configured()
# Discovery host is for confirm-step context only; runtime discovery owns
# current device IP state and keeps it up to date independently of entry data.
# Persist through confirm into entry data as CONF_HOST.
self._discovered_controller_ip = host
return await self.async_step_confirm()

Expand Down Expand Up @@ -357,7 +356,7 @@ async def _async_create_controller_entry(
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self._entry_title(controller.device_uid),
data={},
data={CONF_HOST: controller.device_ip},
)

@callback
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests/components/denon_rs232/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def _default_state() -> MockState:
digital_input=DigitalInputMode.AUTO,
tuner_band=TunerBand.FM,
tuner_mode=TunerMode.AUTO,
tuner_frequency="009930",
),
zone_2=ZoneState(
power=True,
Expand Down
5 changes: 3 additions & 2 deletions tests/components/denon_rs232/snapshots/test_media_player.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
'platform': 'denon_rs232',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 3468>,
'supported_features': <MediaPlayerEntityFeature: 135052>,
'translation_key': 'receiver',
'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_main',
'unit_of_measurement': None,
Expand All @@ -70,7 +70,7 @@
'vcr_2',
'vdp',
]),
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <MediaPlayerEntityFeature: 3468>,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <MediaPlayerEntityFeature: 135052>,
<MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: 'volume_level'>: 0.5555555555555556,
}),
'context': <ANY>,
Expand Down Expand Up @@ -137,6 +137,7 @@
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'receiver',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'AVR-3805 Zone 2',
<MediaPlayerEntityStateAttribute.MEDIA_CHANNEL: 'media_channel'>: '99.30',
<MediaPlayerEntityStateAttribute.INPUT_SOURCE: 'source'>: 'tuner',
<MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST: 'source_list'>: list([
'cd',
Expand Down
Loading
Loading