Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user