Files
ckoch a5d2865eb2 Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:59:47 -04:00

222 lines
7.5 KiB
Python

"""Media player entity for Denon AVR Bluetooth."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import (
CMD_MUTE_OFF,
CMD_MUTE_ON,
CMD_POWER_OFF,
CMD_POWER_ON,
CMD_VOLUME_DOWN,
CMD_VOLUME_UP,
DOMAIN,
RECONNECT_INTERVAL,
VOLUME_PRACTICAL_MAX,
)
from .protocol import DenonBTClient, DenonState, cmd_select_input
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Denon BT media player from a config entry."""
mac: str = entry.data["mac"]
name: str = entry.data.get("name", "Denon AVR")
async_add_entities([DenonBTMediaPlayer(hass, entry, mac, name)])
class DenonBTMediaPlayer(MediaPlayerEntity):
"""Representation of a Denon AVR-S540BT over Bluetooth RFCOMM."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
mac: str,
name: str,
) -> None:
self._hass = hass
self._mac = mac
self._attr_unique_id = mac.replace(":", "").lower()
self._attr_device_info = {
"identifiers": {(DOMAIN, mac)},
"name": name,
"manufacturer": "Denon",
"model": "AVR-S540BT",
"connections": {("bluetooth", mac)},
}
self._client: DenonBTClient | None = None
self._state = DenonState()
self._reconnect_cancel: callback | None = None
self._reconnect_attempts = 0
# ── Lifecycle ────────────────────────────────────────────────────────────
async def async_added_to_hass(self) -> None:
self._client = DenonBTClient(
self._hass, self._mac, self._handle_state_update
)
await self._async_try_connect()
async def async_will_remove_from_hass(self) -> None:
self._cancel_reconnect()
if self._client:
await self._client.async_disconnect()
# ── State callback from protocol client ──────────────────────────────────
@callback
def _handle_state_update(self, state: DenonState) -> None:
"""Called on the event loop when the protocol client reports a change."""
self._state = state
self._attr_available = self._client.connected if self._client else False
if not self._attr_available:
self._schedule_reconnect()
else:
self._reconnect_attempts = 0
self._cancel_reconnect()
self.async_write_ha_state()
# ── Connection / reconnect ───────────────────────────────────────────────
async def _async_try_connect(self) -> None:
if self._client and await self._client.async_connect():
self._attr_available = True
self._reconnect_attempts = 0
_LOGGER.info("Connected to Denon AVR at %s", self._mac)
else:
self._attr_available = False
self._schedule_reconnect()
_LOGGER.warning(
"Could not connect to Denon AVR at %s", self._mac
)
def _schedule_reconnect(self) -> None:
if self._reconnect_cancel is not None:
return
delay = min(RECONNECT_INTERVAL, 5 * (self._reconnect_attempts + 1))
self._reconnect_cancel = async_call_later(
self._hass, delay, self._async_reconnect
)
self._reconnect_attempts += 1
_LOGGER.debug(
"Reconnect to %s in %ds (attempt %d)",
self._mac,
delay,
self._reconnect_attempts,
)
def _cancel_reconnect(self) -> None:
if self._reconnect_cancel:
self._reconnect_cancel()
self._reconnect_cancel = None
async def _async_reconnect(self, _now: Any = None) -> None:
self._reconnect_cancel = None
if self._client:
await self._client.async_disconnect()
await self._async_try_connect()
# ── State properties ─────────────────────────────────────────────────────
@property
def state(self) -> MediaPlayerState:
if not self._attr_available:
return MediaPlayerState.OFF
if self._state.power:
return MediaPlayerState.ON
return MediaPlayerState.OFF
@property
def volume_level(self) -> float | None:
if self._state.volume_raw is None:
return None
return min(1.0, self._state.volume_raw / VOLUME_PRACTICAL_MAX)
@property
def is_volume_muted(self) -> bool | None:
return self._state.muted
@property
def source(self) -> str | None:
return self._state.source
@property
def source_list(self) -> list[str]:
return list(self._state.sources.values())
@property
def extra_state_attributes(self) -> dict[str, Any]:
attrs: dict[str, Any] = {}
if self._state.sound_mode:
attrs["sound_mode"] = self._state.sound_mode
if self._state.volume_raw is not None:
attrs["volume_raw"] = self._state.volume_raw
attrs["volume_display"] = self._state.volume_raw / 2.0
return attrs
# ── Commands ─────────────────────────────────────────────────────────────
async def async_turn_on(self) -> None:
if self._client:
if not self._client.connected:
await self._async_try_connect()
await self._client.async_send(CMD_POWER_ON)
async def async_turn_off(self) -> None:
if self._client:
await self._client.async_send(CMD_POWER_OFF)
async def async_volume_up(self) -> None:
if self._client:
await self._client.async_send(CMD_VOLUME_UP)
async def async_volume_down(self) -> None:
if self._client:
await self._client.async_send(CMD_VOLUME_DOWN)
async def async_mute_volume(self, mute: bool) -> None:
if self._client:
await self._client.async_send(CMD_MUTE_ON if mute else CMD_MUTE_OFF)
async def async_select_source(self, source: str) -> None:
if not self._client:
return
for code, name in self._state.sources.items():
if name == source:
await self._client.async_send(cmd_select_input(code))
return
_LOGGER.warning("Unknown source: %s", source)