1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 23:53:49 +01:00
Files
core/homeassistant/components/hegel/media_player.py
2026-02-10 22:56:48 +01:00

344 lines
13 KiB
Python

"""Hegel media player platform."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import contextlib
from datetime import timedelta
import logging
from typing import Any
from hegel_ip_client import (
COMMANDS,
HegelClient,
apply_state_changes,
parse_reply_message,
)
from hegel_ip_client.exceptions import HegelConnectionError
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from . import HegelConfigEntry
from .const import CONF_MODEL, DOMAIN, HEARTBEAT_TIMEOUT_MINUTES, MODEL_INPUTS
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: HegelConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Hegel media player from a config entry."""
model = entry.data[CONF_MODEL]
unique_id = entry.unique_id or entry.entry_id
# map inputs (source_map)
source_map: dict[int, str] = (
dict(enumerate(MODEL_INPUTS[model], start=1)) if model in MODEL_INPUTS else {}
)
# Use the client from the config entry's runtime_data (already connected)
client = entry.runtime_data
# Create entity
media = HegelMediaPlayer(
entry,
client,
source_map,
unique_id,
)
async_add_entities([media])
class HegelMediaPlayer(MediaPlayerEntity):
"""Hegel amplifier entity."""
_attr_should_poll = False
_attr_name = None
_attr_has_entity_name = True
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
def __init__(
self,
config_entry: HegelConfigEntry,
client: HegelClient,
source_map: dict[int, str],
unique_id: str,
) -> None:
"""Initialize the Hegel media player entity."""
self._entry = config_entry
self._client = client
self._source_map = source_map
# Set unique_id from config entry
self._attr_unique_id = unique_id
# Set device info
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=config_entry.title,
manufacturer="Hegel",
model=config_entry.data[CONF_MODEL],
)
# State will be populated by async_update on first connection
self._state: dict[str, Any] = {}
# Background tasks
self._connected_watcher_task: asyncio.Task[None] | None = None
self._push_task: asyncio.Task[None] | None = None
self._push_handler: Callable[[str], None] | None = None
async def async_added_to_hass(self) -> None:
"""Handle entity added to Home Assistant."""
await super().async_added_to_hass()
_LOGGER.debug("Hegel media player added to hass: %s", self.entity_id)
# Register push handler for real-time updates from the amplifier
# The client expects a synchronous callable; schedule a coroutine safely
def push_handler(msg: str) -> None:
self._push_task = self.hass.async_create_task(self._async_handle_push(msg))
self._push_handler = push_handler
self._client.add_push_callback(push_handler)
# Register cleanup for push handler using async_on_remove
def cleanup_push_handler() -> None:
if self._push_handler:
self._client.remove_push_callback(self._push_handler)
_LOGGER.debug("Push callback removed")
self._push_handler = None
self.async_on_remove(cleanup_push_handler)
# Perform initial state fetch if already connected
# The watcher handles reconnections, but we need to fetch state on first setup
if self._client.is_connected():
_LOGGER.debug("Client already connected, performing initial state fetch")
await self.async_update()
# Start a watcher task
# Use config_entry.async_create_background_task for automatic cleanup on unload
self._connected_watcher_task = self._entry.async_create_background_task(
self.hass,
self._connected_watcher(),
name=f"hegel_{self.entity_id}_connected_watcher",
)
# Note: No need for async_on_remove - entry.async_create_background_task
# automatically cancels the task when the config entry is unloaded
# Schedule the heartbeat every 2 minutes while the reset timeout is 3 minutes
self.async_on_remove(
async_track_time_interval(
self.hass,
self._send_heartbeat,
timedelta(minutes=HEARTBEAT_TIMEOUT_MINUTES - 1),
)
)
# Send the first heartbeat immediately
self.hass.async_create_task(self._send_heartbeat())
async def _send_heartbeat(self, now=None) -> None:
if not self.available:
return
try:
await self._client.send(
f"-r.{HEARTBEAT_TIMEOUT_MINUTES}", expect_reply=False
)
except (HegelConnectionError, TimeoutError, OSError) as err:
_LOGGER.debug("Heartbeat failed: %s", err)
async def _async_handle_push(self, msg: str) -> None:
"""Handle incoming push message from client (runs in event loop)."""
try:
update = parse_reply_message(msg)
if update.has_changes():
apply_state_changes(self._state, update, logger=_LOGGER, source="push")
# notify HA
self.async_write_ha_state()
except ValueError, KeyError, AttributeError:
_LOGGER.exception("Failed to handle push message")
async def _connected_watcher(self) -> None:
"""Watch the client's connection events and update state accordingly."""
conn_event = self._client.connected_event
disconn_event = self._client.disconnected_event
_LOGGER.debug("Connected watcher started")
try:
while True:
# Wait for connection
_LOGGER.debug("Watcher: waiting for connection")
await conn_event.wait()
_LOGGER.debug("Watcher: connected, refreshing state")
# Immediately notify HA that we're available again
self.async_write_ha_state()
# Schedule a state refresh through HA
self.async_schedule_update_ha_state(force_refresh=True)
# Wait for disconnection using event (no polling!)
_LOGGER.debug("Watcher: waiting for disconnection")
await disconn_event.wait()
_LOGGER.debug("Watcher: disconnected")
# Notify HA that we're unavailable
self.async_write_ha_state()
except asyncio.CancelledError:
_LOGGER.debug("Connected watcher cancelled")
except (HegelConnectionError, OSError) as err:
_LOGGER.warning("Connected watcher failed: %s", err)
async def async_will_remove_from_hass(self) -> None:
"""Handle entity removal from Home Assistant.
Note: Push callback cleanup is handled by async_on_remove.
_connected_watcher_task cleanup is handled automatically by
entry.async_create_background_task when the config entry is unloaded.
"""
await super().async_will_remove_from_hass()
# Cancel push task if running (short-lived task, defensive cleanup)
if self._push_task and not self._push_task.done():
self._push_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._push_task
async def async_update(self) -> None:
"""Query the amplifier for the main values and update state dict."""
for cmd in (
COMMANDS["power_query"],
COMMANDS["volume_query"],
COMMANDS["mute_query"],
COMMANDS["input_query"],
):
try:
update = await self._client.send(cmd, expect_reply=True, timeout=3.0)
if update and update.has_changes():
apply_state_changes(
self._state, update, logger=_LOGGER, source="update"
)
except (HegelConnectionError, TimeoutError, OSError) as err:
_LOGGER.debug("Refresh command %s failed: %s", cmd, err)
# update entity state
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Return True if the client is connected."""
return self._client.is_connected()
@property
def state(self) -> MediaPlayerState | None:
"""Return the current state of the media player."""
power = self._state.get("power")
if power is None:
return None
return MediaPlayerState.ON if power else MediaPlayerState.OFF
@property
def volume_level(self) -> float | None:
"""Return the volume level."""
volume = self._state.get("volume")
if volume is None:
return None
return float(volume)
@property
def is_volume_muted(self) -> bool | None:
"""Return whether volume is muted."""
return bool(self._state.get("mute", False))
@property
def source(self) -> str | None:
"""Return the current input source."""
idx = self._state.get("input")
return self._source_map.get(idx, f"Input {idx}") if idx else None
@property
def source_list(self) -> list[str] | None:
"""Return the list of available input sources."""
return [self._source_map[k] for k in sorted(self._source_map.keys())] or None
async def async_turn_on(self) -> None:
"""Turn on the media player."""
try:
await self._client.send(COMMANDS["power_on"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to turn on: {err}") from err
async def async_turn_off(self) -> None:
"""Turn off the media player."""
try:
await self._client.send(COMMANDS["power_off"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to turn off: {err}") from err
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
vol = max(0.0, min(volume, 1.0))
amp_vol = int(round(vol * 100))
try:
await self._client.send(COMMANDS["volume_set"](amp_vol), expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to set volume: {err}") from err
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute the volume."""
try:
await self._client.send(
COMMANDS["mute_on" if mute else "mute_off"], expect_reply=False
)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to set mute: {err}") from err
async def async_volume_up(self) -> None:
"""Increase volume."""
try:
await self._client.send(COMMANDS["volume_up"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to increase volume: {err}") from err
async def async_volume_down(self) -> None:
"""Decrease volume."""
try:
await self._client.send(COMMANDS["volume_down"], expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(f"Failed to decrease volume: {err}") from err
async def async_select_source(self, source: str) -> None:
"""Select input source."""
inv = {v: k for k, v in self._source_map.items()}
idx = inv.get(source)
if idx is None:
raise ServiceValidationError(f"Unknown source: {source}")
try:
await self._client.send(COMMANDS["input_set"](idx), expect_reply=False)
except (HegelConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
f"Failed to select source {source}: {err}"
) from err