core/homeassistant/components/control4/media_player.py

398 lines
13 KiB
Python

"""Platform for Control4 Rooms Media Players."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import enum
import logging
from typing import Any
from pyControl4.error_handling import C4Exception
from pyControl4.room import C4Room
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4Entity
from .const import CONF_DIRECTOR, CONF_DIRECTOR_ALL_ITEMS, CONF_UI_CONFIGURATION, DOMAIN
from .director_utils import update_variables_for_config_entry
_LOGGER = logging.getLogger(__name__)
CONTROL4_POWER_STATE = "POWER_STATE"
CONTROL4_VOLUME_STATE = "CURRENT_VOLUME"
CONTROL4_MUTED_STATE = "IS_MUTED"
CONTROL4_CURRENT_VIDEO_DEVICE = "CURRENT_VIDEO_DEVICE"
CONTROL4_PLAYING = "PLAYING"
CONTROL4_PAUSED = "PAUSED"
CONTROL4_STOPPED = "STOPPED"
CONTROL4_MEDIA_INFO = "CURRENT MEDIA INFO"
CONTROL4_PARENT_ID = "parentId"
VARIABLES_OF_INTEREST = {
CONTROL4_POWER_STATE,
CONTROL4_VOLUME_STATE,
CONTROL4_MUTED_STATE,
CONTROL4_CURRENT_VIDEO_DEVICE,
CONTROL4_MEDIA_INFO,
CONTROL4_PLAYING,
CONTROL4_PAUSED,
CONTROL4_STOPPED,
}
class _SourceType(enum.Enum):
AUDIO = 1
VIDEO = 2
@dataclass
class _RoomSource:
"""Class for Room Source."""
source_type: set[_SourceType]
idx: int
name: str
async def get_rooms(hass: HomeAssistant, entry: ConfigEntry):
"""Return a list of all Control4 rooms."""
director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS]
return [
item
for item in director_all_items
if "typeName" in item and item["typeName"] == "room"
]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Control4 rooms from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
ui_config = entry_data[CONF_UI_CONFIGURATION]
# OS 2 will not have a ui_configuration
if not ui_config:
_LOGGER.debug("No UI Configuration found for Control4")
return
all_rooms = await get_rooms(hass, entry)
if not all_rooms:
return
scan_interval = entry_data[CONF_SCAN_INTERVAL]
_LOGGER.debug("Scan interval = %s", scan_interval)
async def async_update_data() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director."""
try:
return await update_variables_for_config_entry(
hass, entry, VARIABLES_OF_INTEREST
)
except C4Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="room",
update_method=async_update_data,
update_interval=timedelta(seconds=scan_interval),
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
items_by_id = {
item["id"]: item
for item in hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS]
}
item_to_parent_map = {
k: item["parentId"]
for k, item in items_by_id.items()
if "parentId" in item and k > 1
}
entity_list = []
for room in all_rooms:
room_id = room["id"]
sources: dict[int, _RoomSource] = {}
for exp in ui_config["experiences"]:
if room_id == exp["room_id"]:
exp_type = exp["type"]
if exp_type not in ("listen", "watch"):
continue
dev_type = (
_SourceType.AUDIO if exp_type == "listen" else _SourceType.VIDEO
)
for source in exp["sources"]["source"]:
dev_id = source["id"]
name = items_by_id.get(dev_id, {}).get(
"name", f"Unknown Device - {dev_id}"
)
if dev_id in sources:
sources[dev_id].source_type.add(dev_type)
else:
sources[dev_id] = _RoomSource(
source_type={dev_type}, idx=dev_id, name=name
)
try:
hidden = room["roomHidden"]
entity_list.append(
Control4Room(
entry_data,
coordinator,
room["name"],
room_id,
item_to_parent_map,
sources,
hidden,
)
)
except KeyError:
_LOGGER.exception(
"Unknown device properties received from Control4: %s",
room,
)
continue
async_add_entities(entity_list, True)
class Control4Room(Control4Entity, MediaPlayerEntity):
"""Control4 Room entity."""
_attr_has_entity_name = True
def __init__(
self,
entry_data: dict,
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
name: str,
room_id: int,
id_to_parent: dict[int, int],
sources: dict[int, _RoomSource],
room_hidden: bool,
) -> None:
"""Initialize Control4 room entity."""
super().__init__(
entry_data,
coordinator,
None,
room_id,
device_name=name,
device_manufacturer=None,
device_model=None,
device_id=room_id,
)
self._attr_entity_registry_enabled_default = not room_hidden
self._id_to_parent = id_to_parent
self._sources = sources
self._attr_supported_features = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def _create_api_object(self):
"""Create a pyControl4 device object.
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
"""
return C4Room(self.entry_data[CONF_DIRECTOR], self._idx)
def _get_device_from_variable(self, var: str) -> int | None:
current_device = self.coordinator.data[self._idx][var]
if current_device == 0:
return None
return current_device
def _get_current_video_device_id(self) -> int | None:
return self._get_device_from_variable(CONTROL4_CURRENT_VIDEO_DEVICE)
def _get_current_playing_device_id(self) -> int | None:
media_info = self._get_media_info()
if media_info:
if "medSrcDev" in media_info:
return media_info["medSrcDev"]
if "deviceid" in media_info:
return media_info["deviceid"]
return 0
def _get_media_info(self) -> dict | None:
"""Get the Media Info Dictionary if populated."""
media_info = self.coordinator.data[self._idx][CONTROL4_MEDIA_INFO]
if "mediainfo" in media_info:
return media_info["mediainfo"]
return None
def _get_current_source_state(self) -> str | None:
current_source = self._get_current_playing_device_id()
while current_source:
current_data = self.coordinator.data.get(current_source, None)
if current_data:
if current_data.get(CONTROL4_PLAYING, None):
return MediaPlayerState.PLAYING
if current_data.get(CONTROL4_PAUSED, None):
return MediaPlayerState.PAUSED
if current_data.get(CONTROL4_STOPPED, None):
return MediaPlayerState.ON
current_source = self._id_to_parent.get(current_source, None)
return None
@property
def device_class(self) -> MediaPlayerDeviceClass | None:
"""Return the class of this entity."""
for avail_source in self._sources.values():
if _SourceType.VIDEO in avail_source.source_type:
return MediaPlayerDeviceClass.TV
return MediaPlayerDeviceClass.SPEAKER
@property
def state(self):
"""Return whether this room is on or idle."""
if source_state := self._get_current_source_state():
return source_state
if self.coordinator.data[self._idx][CONTROL4_POWER_STATE]:
return MediaPlayerState.ON
return MediaPlayerState.IDLE
@property
def source(self):
"""Get the current source."""
current_source = self._get_current_playing_device_id()
if not current_source or current_source not in self._sources:
return None
return self._sources[current_source].name
@property
def media_title(self) -> str | None:
"""Get the Media Title."""
media_info = self._get_media_info()
if not media_info:
return None
if "title" in media_info:
return media_info["title"]
current_source = self._get_current_playing_device_id()
if not current_source or current_source not in self._sources:
return None
return self._sources[current_source].name
@property
def media_content_type(self):
"""Get current content type if available."""
current_source = self._get_current_playing_device_id()
if not current_source:
return None
if current_source == self._get_current_video_device_id():
return MediaType.VIDEO
return MediaType.MUSIC
async def async_media_play_pause(self):
"""If possible, toggle the current play/pause state.
Not every source supports play/pause.
Unfortunately MediaPlayer capabilities are not dynamic,
so we must determine if play/pause is supported here
"""
if self._get_current_source_state():
await super().async_media_play_pause()
@property
def source_list(self) -> list[str]:
"""Get the available source."""
return [x.name for x in self._sources.values()]
@property
def volume_level(self):
"""Get the volume level."""
return self.coordinator.data[self._idx][CONTROL4_VOLUME_STATE] / 100
@property
def is_volume_muted(self):
"""Check if the volume is muted."""
return bool(self.coordinator.data[self._idx][CONTROL4_MUTED_STATE])
async def async_select_source(self, source):
"""Select a new source."""
for avail_source in self._sources.values():
if avail_source.name == source:
audio_only = _SourceType.VIDEO not in avail_source.source_type
if audio_only:
await self._create_api_object().setAudioSource(avail_source.idx)
else:
await self._create_api_object().setVideoAndAudioSource(
avail_source.idx
)
break
await self.coordinator.async_request_refresh()
async def async_turn_off(self):
"""Turn off the room."""
await self._create_api_object().setRoomOff()
await self.coordinator.async_request_refresh()
async def async_mute_volume(self, mute):
"""Mute the room."""
if mute:
await self._create_api_object().setMuteOn()
else:
await self._create_api_object().setMuteOff()
await self.coordinator.async_request_refresh()
async def async_set_volume_level(self, volume):
"""Set room volume, 0-1 scale."""
await self._create_api_object().setVolume(int(volume * 100))
await self.coordinator.async_request_refresh()
async def async_volume_up(self):
"""Increase the volume by 1."""
await self._create_api_object().setIncrementVolume()
await self.coordinator.async_request_refresh()
async def async_volume_down(self):
"""Decrease the volume by 1."""
await self._create_api_object().setDecrementVolume()
await self.coordinator.async_request_refresh()
async def async_media_pause(self):
"""Issue a pause command."""
await self._create_api_object().setPause()
await self.coordinator.async_request_refresh()
async def async_media_play(self):
"""Issue a play command."""
await self._create_api_object().setPlay()
await self.coordinator.async_request_refresh()
async def async_media_stop(self):
"""Issue a stop command."""
await self._create_api_object().setStop()
await self.coordinator.async_request_refresh()