From ab0585278cf3af6ce1443c5930902fca87b8f7c8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 17 May 2026 23:46:26 +0300 Subject: [PATCH] feat: shared DisplayCoordinator + optional API token - Introduce DisplayCoordinator polling /api/display/monitors once per cycle and fan out to all per-display entities via CoordinatorEntity. Removes ~9x redundant requests per polling cycle that came from each binary_sensor/number/select/sensor/switch entity calling get_display_monitors() in its own async_update. - Optimistic write-through via coordinator.apply_optimistic(...) keeps sibling entities in sync after slider/select writes without an extra network round-trip. - Make CONF_TOKEN optional. The media server already supports running without auth (auth_enabled() returns False when api_tokens is empty), so the integration omits the Authorization header and ?token= query from REST/WS/album-art URLs when no token is configured. Server-side auth-enabled rejections still surface as invalid_auth in the UI. - Bump manifest version to 0.3.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../remote_media_player/__init__.py | 11 ++ .../remote_media_player/api_client.py | 32 +++-- .../remote_media_player/binary_sensor.py | 73 +++++------ .../remote_media_player/config_flow.py | 8 +- .../remote_media_player/const.py | 4 + .../display_coordinator.py | 66 ++++++++++ .../remote_media_player/manifest.json | 2 +- .../remote_media_player/number.py | 115 +++++++++-------- .../remote_media_player/select.py | 117 ++++++++---------- .../remote_media_player/sensor.py | 39 +++--- .../remote_media_player/strings.json | 2 +- .../remote_media_player/switch.py | 92 +++++++------- .../remote_media_player/translations/en.json | 2 +- .../remote_media_player/translations/ru.json | 2 +- 14 files changed, 313 insertions(+), 252 deletions(-) create mode 100644 custom_components/remote_media_player/display_coordinator.py diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py index 57d3796..ce408f5 100644 --- a/custom_components/remote_media_player/__init__.py +++ b/custom_components/remote_media_player/__init__.py @@ -24,6 +24,7 @@ from .const import ( SERVICE_EXECUTE_SCRIPT, SERVICE_PLAY_MEDIA_FILE, ) +from .display_coordinator import DisplayCoordinator _LOGGER = logging.getLogger(__name__) @@ -78,10 +79,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.close() return False + # Create the shared display coordinator BEFORE platform setup so each + # display platform's async_setup_entry can register against the same + # data source instead of polling /api/display/monitors on its own. + display_coordinator = DisplayCoordinator(hass, client) + try: + await display_coordinator.async_config_entry_first_refresh() + except Exception as err: # noqa: BLE001 - first refresh wraps its own errors + _LOGGER.warning("Initial display monitor fetch failed, will retry: %s", err) + # Store client in hass.data hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { "client": client, + "display_coordinator": display_coordinator, } # Register services if not already registered diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py index 79ac05d..1acff36 100644 --- a/custom_components/remote_media_player/api_client.py +++ b/custom_components/remote_media_player/api_client.py @@ -92,11 +92,16 @@ class MediaServerClient: await self._session.close() def _get_headers(self) -> dict[str, str]: - """Get headers for API requests.""" - return { - "Authorization": f"Bearer {self._token}", - "Content-Type": "application/json", - } + """Get headers for API requests. + + When no token is configured the media server runs in anonymous mode + (``auth.auth_enabled()`` returns False), so we omit the Authorization + header entirely rather than sending ``Bearer `` with an empty value. + """ + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers async def _request( self, @@ -178,13 +183,17 @@ class MediaServerClient: """ data = await self._request("GET", API_STATUS) - # Convert relative album_art_url to absolute URL with token and cache-buster + # Convert relative album_art_url to absolute URL with cache-buster + # (and token only when auth is enabled on the server side). if data.get("album_art_url") and data["album_art_url"].startswith("/"): # Add track info hash to force HA to re-fetch when track changes import hashlib track_id = f"{data.get('title', '')}-{data.get('artist', '')}" track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8] - data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}" + token_param = f"token={self._token}&" if self._token else "" + data["album_art_url"] = ( + f"{self._base_url}{data['album_art_url']}?{token_param}t={track_hash}" + ) return data @@ -440,7 +449,11 @@ class MediaServerWebSocket: self._on_status_update = on_status_update self._on_disconnect = on_disconnect self._on_scripts_changed = on_scripts_changed - self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}" + # The server's WS endpoint accepts an unauthenticated connection when + # api_tokens is empty (see media.py:websocket_endpoint), so we only + # append ?token=... when one was configured. + token_query = f"?token={token}" if token else "" + self._ws_url = f"ws://{host}:{self._port}/api/media/ws{token_query}" self._session: aiohttp.ClientSession | None = None self._ws: aiohttp.ClientWebSocketResponse | None = None self._receive_task: asyncio.Task | None = None @@ -514,9 +527,10 @@ class MediaServerWebSocket: ): track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}" track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8] + token_param = f"token={self._token}&" if self._token else "" status_data["album_art_url"] = ( f"http://{self._host}:{self._port}" - f"{status_data['album_art_url']}?token={self._token}&t={track_hash}" + f"{status_data['album_art_url']}?{token_param}t={track_hash}" ) self._on_status_update(status_data) elif msg_type == "scripts_changed": diff --git a/custom_components/remote_media_player/binary_sensor.py b/custom_components/remote_media_player/binary_sensor.py index 452a19f..df6ea7d 100644 --- a/custom_components/remote_media_player/binary_sensor.py +++ b/custom_components/remote_media_player/binary_sensor.py @@ -10,9 +10,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api_client import MediaServerClient, MediaServerError from .const import DOMAIN +from .display_coordinator import DisplayCoordinator from .display_device import display_device_info _LOGGER = logging.getLogger(__name__) @@ -24,25 +25,26 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up per-display binary sensor entities.""" - client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + coordinator: DisplayCoordinator = hass.data[DOMAIN][entry.entry_id][ + "display_coordinator" + ] - try: - monitors = await client.get_display_monitors() - except MediaServerError as err: - _LOGGER.error("Failed to fetch display monitors: %s", err) + if not coordinator.data: return entities: list[Any] = [] - for monitor in monitors: - entities.append(DisplayPrimaryBinarySensor(client, entry, monitor)) - entities.append(DisplayPowerControlBinarySensor(client, entry, monitor)) + for monitor in coordinator.data.values(): + entities.append(DisplayPrimaryBinarySensor(coordinator, entry, monitor)) + entities.append(DisplayPowerControlBinarySensor(coordinator, entry, monitor)) if entities: async_add_entities(entities) _LOGGER.info("Added %d display binary sensor entities", len(entities)) -class _DisplayBinarySensorBase(BinarySensorEntity): +class _DisplayBinarySensorBase( + CoordinatorEntity[DisplayCoordinator], BinarySensorEntity +): """Common boilerplate for per-display diagnostic binary sensors.""" _attr_has_entity_name = True @@ -50,14 +52,20 @@ class _DisplayBinarySensorBase(BinarySensorEntity): def __init__( self, - client: MediaServerClient, + coordinator: DisplayCoordinator, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - self._client = client + super().__init__(coordinator) self._monitor_id: int = monitor["id"] self._attr_device_info = display_device_info(entry, monitor) + @property + def _monitor(self) -> dict[str, Any]: + if self.coordinator.data is None: + return {} + return self.coordinator.data.get(self._monitor_id, {}) + class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase): """Indicates whether the display is the OS primary monitor.""" @@ -67,23 +75,16 @@ class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase): def __init__( self, - client: MediaServerClient, + coordinator: DisplayCoordinator, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - super().__init__(client, entry, monitor) + super().__init__(coordinator, entry, monitor) self._attr_unique_id = f"{entry.entry_id}_display_primary_{self._monitor_id}" - self._attr_is_on = bool(monitor.get("is_primary")) - async def async_update(self) -> None: - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - self._attr_is_on = bool(monitor.get("is_primary")) - break - except MediaServerError as err: - _LOGGER.error("Failed to refresh primary flag for monitor %d: %s", self._monitor_id, err) + @property + def is_on(self) -> bool: + return bool(self._monitor.get("is_primary")) class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase): @@ -94,23 +95,15 @@ class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase): def __init__( self, - client: MediaServerClient, + coordinator: DisplayCoordinator, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - super().__init__(client, entry, monitor) - self._attr_unique_id = f"{entry.entry_id}_display_power_supported_{self._monitor_id}" - self._attr_is_on = bool(monitor.get("power_supported")) + super().__init__(coordinator, entry, monitor) + self._attr_unique_id = ( + f"{entry.entry_id}_display_power_supported_{self._monitor_id}" + ) - async def async_update(self) -> None: - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - self._attr_is_on = bool(monitor.get("power_supported")) - break - except MediaServerError as err: - _LOGGER.error( - "Failed to refresh power_supported flag for monitor %d: %s", - self._monitor_id, err, - ) + @property + def is_on(self) -> bool: + return bool(self._monitor.get("power_supported")) diff --git a/custom_components/remote_media_player/config_flow.py b/custom_components/remote_media_player/config_flow.py index bd54f09..a87483c 100644 --- a/custom_components/remote_media_player/config_flow.py +++ b/custom_components/remote_media_player/config_flow.py @@ -44,10 +44,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, CannotConnect: If connection fails InvalidAuth: If authentication fails """ + # Token is optional: the media server can run without auth tokens, in which + # case verify_token() returns "anonymous" and accepts unauthenticated calls. + # If the server *does* have tokens configured, get_status() below will 401 + # and we surface that as "invalid_auth" in the UI. client = MediaServerClient( host=data[CONF_HOST], port=data[CONF_PORT], - token=data[CONF_TOKEN], + token=data.get(CONF_TOKEN, "") or "", ) try: @@ -125,7 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): mode=selector.NumberSelectorMode.BOX, ) ), - vol.Required(CONF_TOKEN): selector.TextSelector( + vol.Optional(CONF_TOKEN, default=""): selector.TextSelector( selector.TextSelectorConfig( type=selector.TextSelectorType.PASSWORD ) diff --git a/custom_components/remote_media_player/const.py b/custom_components/remote_media_player/const.py index 2c5a26d..1abcef9 100644 --- a/custom_components/remote_media_player/const.py +++ b/custom_components/remote_media_player/const.py @@ -16,6 +16,10 @@ DEFAULT_POLL_INTERVAL = 5 DEFAULT_NAME = "Remote Media Player" DEFAULT_USE_WEBSOCKET = True DEFAULT_RECONNECT_INTERVAL = 5 +# Displays change rarely (brightness/contrast/input source via physical buttons +# or external automations), so a slow shared poll is plenty. The previous +# per-entity polling produced ~9 calls every 30 s for a single monitor. +DEFAULT_DISPLAY_POLL_INTERVAL = 30 # API endpoints API_HEALTH = "/api/health" diff --git a/custom_components/remote_media_player/display_coordinator.py b/custom_components/remote_media_player/display_coordinator.py new file mode 100644 index 0000000..dffb627 --- /dev/null +++ b/custom_components/remote_media_player/display_coordinator.py @@ -0,0 +1,66 @@ +"""Shared coordinator for per-display monitor state. + +All display platforms (binary_sensor, number, select, sensor, switch) share a +single poll cycle through this coordinator instead of each entity calling +``GET /api/display/monitors`` from its own ``async_update``. With ~9 display +entities per monitor, that change reduces the HTTP load on the media server +from 9x per cycle to 1x per cycle. +""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api_client import MediaServerClient, MediaServerError +from .const import DEFAULT_DISPLAY_POLL_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +# Coordinator data: monitor_id -> monitor dict (full payload as returned by +# the media server, indexed for O(1) per-entity lookup). +DisplayData = dict[int, dict[str, Any]] + + +class DisplayCoordinator(DataUpdateCoordinator[DisplayData]): + """Polls ``/api/display/monitors`` once and fans out to all display entities.""" + + def __init__( + self, + hass: HomeAssistant, + client: MediaServerClient, + poll_interval: int = DEFAULT_DISPLAY_POLL_INTERVAL, + ) -> None: + super().__init__( + hass, + _LOGGER, + name="Remote Media Player Displays", + update_interval=timedelta(seconds=poll_interval), + ) + self.client = client + + async def _async_update_data(self) -> DisplayData: + try: + monitors = await self.client.get_display_monitors() + except MediaServerError as err: + raise UpdateFailed(f"Failed to fetch display monitors: {err}") from err + return {monitor["id"]: monitor for monitor in monitors} + + def apply_optimistic(self, monitor_id: int, **fields: Any) -> None: + """Mutate cached monitor data after a successful write and notify entities. + + Avoids a network round trip on every slider tick while still keeping + all sibling display entities in sync. The next scheduled refresh + reconciles with the server's authoritative state. + """ + if self.data is None: + return + monitor = self.data.get(monitor_id) + if monitor is None: + return + monitor.update(fields) + self.async_update_listeners() diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json index 7264b60..6022bcc 100644 --- a/custom_components/remote_media_player/manifest.json +++ b/custom_components/remote_media_player/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_push", "requirements": ["aiohttp>=3.8.0"], - "version": "0.3.0" + "version": "0.3.2" } diff --git a/custom_components/remote_media_player/number.py b/custom_components/remote_media_player/number.py index 77e6a96..2fa0ea0 100644 --- a/custom_components/remote_media_player/number.py +++ b/custom_components/remote_media_player/number.py @@ -9,9 +9,11 @@ from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .api_client import MediaServerClient, MediaServerError from .const import DOMAIN +from .display_coordinator import DisplayCoordinator from .display_device import display_device_info _LOGGER = logging.getLogger(__name__) @@ -23,123 +25,118 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up display brightness + contrast number entities from a config entry.""" - client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + data = hass.data[DOMAIN][entry.entry_id] + client: MediaServerClient = data["client"] + coordinator: DisplayCoordinator = data["display_coordinator"] - try: - monitors = await client.get_display_monitors() - except MediaServerError as err: - _LOGGER.error("Failed to fetch display monitors: %s", err) + if not coordinator.data: return entities: list[Any] = [] - for monitor in monitors: + for monitor in coordinator.data.values(): if monitor.get("brightness") is not None: - entities.append(DisplayBrightnessNumber(client, entry, monitor)) + entities.append(DisplayBrightnessNumber(coordinator, client, entry, monitor)) if monitor.get("contrast_supported"): - entities.append(DisplayContrastNumber(client, entry, monitor)) + entities.append(DisplayContrastNumber(coordinator, client, entry, monitor)) if entities: async_add_entities(entities) _LOGGER.info("Added %d display number entities", len(entities)) -class DisplayBrightnessNumber(NumberEntity): - """Number entity for controlling display brightness.""" +class _DisplayNumberBase(CoordinatorEntity[DisplayCoordinator], NumberEntity): + """Shared boilerplate for per-display number entities.""" _attr_has_entity_name = True - _attr_name = "Brightness" _attr_native_min_value = 0 _attr_native_max_value = 100 _attr_native_step = 1 _attr_native_unit_of_measurement = "%" _attr_mode = NumberMode.SLIDER + + def __init__( + self, + coordinator: DisplayCoordinator, + client: MediaServerClient, + entry: ConfigEntry, + monitor: dict[str, Any], + ) -> None: + super().__init__(coordinator) + self._client = client + self._monitor_id: int = monitor["id"] + self._attr_device_info = display_device_info(entry, monitor) + + @property + def _monitor(self) -> dict[str, Any]: + if self.coordinator.data is None: + return {} + return self.coordinator.data.get(self._monitor_id, {}) + + +class DisplayBrightnessNumber(_DisplayNumberBase): + """Number entity for controlling display brightness.""" + + _attr_name = "Brightness" _attr_icon = "mdi:brightness-6" def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - """Initialize the display brightness entity.""" - self._client = client - self._entry = entry - self._monitor_id: int = monitor["id"] - self._attr_native_value = monitor.get("brightness") + super().__init__(coordinator, client, entry, monitor) self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}" - self._attr_device_info = display_device_info(entry, monitor) + + @property + def native_value(self) -> float | None: + value = self._monitor.get("brightness") + return None if value is None else float(value) async def async_set_native_value(self, value: float) -> None: - """Set the brightness value.""" try: await self._client.set_display_brightness(self._monitor_id, int(value)) - self._attr_native_value = int(value) - self.async_write_ha_state() except MediaServerError as err: _LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err) - - async def async_update(self) -> None: - """Fetch updated brightness from the server.""" - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - self._attr_native_value = monitor.get("brightness") - break - except MediaServerError as err: - _LOGGER.error("Failed to update brightness for monitor %d: %s", self._monitor_id, err) + return + self.coordinator.apply_optimistic(self._monitor_id, brightness=int(value)) -class DisplayContrastNumber(NumberEntity): +class DisplayContrastNumber(_DisplayNumberBase): """Number entity for controlling DDC/CI display contrast.""" - _attr_has_entity_name = True _attr_name = "Contrast" - _attr_native_min_value = 0 - _attr_native_max_value = 100 - _attr_native_step = 1 - _attr_native_unit_of_measurement = "%" - _attr_mode = NumberMode.SLIDER _attr_icon = "mdi:contrast-circle" def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - """Initialize the display contrast entity.""" - self._client = client - self._monitor_id: int = monitor["id"] - self._attr_native_value = monitor.get("contrast") + super().__init__(coordinator, client, entry, monitor) self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}" - self._attr_device_info = display_device_info(entry, monitor) + + @property + def native_value(self) -> float | None: + value = self._monitor.get("contrast") + return None if value is None else float(value) async def async_set_native_value(self, value: float) -> None: - """Set the contrast value.""" try: result = await self._client.set_display_contrast(self._monitor_id, int(value)) except MediaServerError as err: _LOGGER.error("Failed to set contrast for monitor %d: %s", self._monitor_id, err) return if not result.get("success"): + # DDC/CI silently dropped the write — pull authoritative state from + # the server instead of trusting our optimistic value. _LOGGER.warning( "Monitor %d rejected contrast %d (DDC/CI silently dropped)", self._monitor_id, int(value), ) - await self.async_update() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() return - self._attr_native_value = int(value) - self.async_write_ha_state() - - async def async_update(self) -> None: - """Fetch updated contrast from the server.""" - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - self._attr_native_value = monitor.get("contrast") - break - except MediaServerError as err: - _LOGGER.error("Failed to update contrast for monitor %d: %s", self._monitor_id, err) + self.coordinator.apply_optimistic(self._monitor_id, contrast=int(value)) diff --git a/custom_components/remote_media_player/select.py b/custom_components/remote_media_player/select.py index aeaf6d2..67c1157 100644 --- a/custom_components/remote_media_player/select.py +++ b/custom_components/remote_media_player/select.py @@ -9,9 +9,11 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .api_client import MediaServerClient, MediaServerError from .const import DOMAIN +from .display_coordinator import DisplayCoordinator from .display_device import display_device_info _LOGGER = logging.getLogger(__name__) @@ -23,43 +25,50 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up per-display select entities.""" - client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + data = hass.data[DOMAIN][entry.entry_id] + client: MediaServerClient = data["client"] + coordinator: DisplayCoordinator = data["display_coordinator"] - try: - monitors = await client.get_display_monitors() - except MediaServerError as err: - _LOGGER.error("Failed to fetch display monitors: %s", err) + if not coordinator.data: return entities: list[Any] = [] - for monitor in monitors: + for monitor in coordinator.data.values(): if monitor.get("input_source_supported") and monitor.get("available_input_sources"): - entities.append(DisplayInputSourceSelect(client, entry, monitor)) + entities.append(DisplayInputSourceSelect(coordinator, client, entry, monitor)) if monitor.get("color_preset_supported") and monitor.get("available_color_presets"): - entities.append(DisplayColorPresetSelect(client, entry, monitor)) + entities.append(DisplayColorPresetSelect(coordinator, client, entry, monitor)) if monitor.get("picture_mode_supported") and monitor.get("available_picture_modes"): - entities.append(DisplayPictureModeSelect(client, entry, monitor)) + entities.append(DisplayPictureModeSelect(coordinator, client, entry, monitor)) if entities: async_add_entities(entities) _LOGGER.info("Added %d display select entities", len(entities)) -class _DisplaySelectBase(SelectEntity): +class _DisplaySelectBase(CoordinatorEntity[DisplayCoordinator], SelectEntity): """Shared base for per-display selects.""" _attr_has_entity_name = True def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: + super().__init__(coordinator) self._client = client self._monitor_id: int = monitor["id"] self._attr_device_info = display_device_info(entry, monitor) + @property + def _monitor(self) -> dict[str, Any]: + if self.coordinator.data is None: + return {} + return self.coordinator.data.get(self._monitor_id, {}) + class DisplayInputSourceSelect(_DisplaySelectBase): """Switch the monitor's active input (HDMI1, DP1, ...).""" @@ -69,15 +78,21 @@ class DisplayInputSourceSelect(_DisplaySelectBase): def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - super().__init__(client, entry, monitor) + super().__init__(coordinator, client, entry, monitor) self._attr_unique_id = f"{entry.entry_id}_display_input_{self._monitor_id}" + # Available inputs are a static EDID/DDC capability — capturing them + # at discovery avoids re-allocating the option list on every poll. self._attr_options = list(monitor.get("available_input_sources") or []) - current = monitor.get("input_source") - self._attr_current_option = current if current in self._attr_options else None + + @property + def current_option(self) -> str | None: + current = self._monitor.get("input_source") + return current if current in self._attr_options else None async def async_select_option(self, option: str) -> None: try: @@ -90,23 +105,9 @@ class DisplayInputSourceSelect(_DisplaySelectBase): "Monitor %d rejected input source %s (DDC/CI silently dropped)", self._monitor_id, option, ) - # Re-read so the entity state reflects what the monitor actually did. - await self.async_update() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() return - self._attr_current_option = option - self.async_write_ha_state() - - async def async_update(self) -> None: - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - current = monitor.get("input_source") - self._attr_current_option = current if current in self._attr_options else None - break - except MediaServerError as err: - _LOGGER.error("Failed to refresh input source for monitor %d: %s", self._monitor_id, err) + self.coordinator.apply_optimistic(self._monitor_id, input_source=option) class DisplayColorPresetSelect(_DisplaySelectBase): @@ -117,15 +118,19 @@ class DisplayColorPresetSelect(_DisplaySelectBase): def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - super().__init__(client, entry, monitor) + super().__init__(coordinator, client, entry, monitor) self._attr_unique_id = f"{entry.entry_id}_display_color_preset_{self._monitor_id}" self._attr_options = list(monitor.get("available_color_presets") or []) - current = monitor.get("color_preset") - self._attr_current_option = current if current in self._attr_options else None + + @property + def current_option(self) -> str | None: + current = self._monitor.get("color_preset") + return current if current in self._attr_options else None async def async_select_option(self, option: str) -> None: try: @@ -138,29 +143,16 @@ class DisplayColorPresetSelect(_DisplaySelectBase): "Monitor %d rejected color preset %s (DDC/CI silently dropped)", self._monitor_id, option, ) - await self.async_update() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() return - self._attr_current_option = option - self.async_write_ha_state() - - async def async_update(self) -> None: - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - current = monitor.get("color_preset") - self._attr_current_option = current if current in self._attr_options else None - break - except MediaServerError as err: - _LOGGER.error("Failed to refresh color preset for monitor %d: %s", self._monitor_id, err) + self.coordinator.apply_optimistic(self._monitor_id, color_preset=option) class DisplayPictureModeSelect(_DisplaySelectBase): """Switch the monitor's picture/scene mode via VCP 0xDC. - The server returns options as `[{code: int, label: str}, ...]`. We use - labels as the user-facing options and keep a label→code map for writes. + The server returns options as ``[{code: int, label: str}, ...]``. Labels + are exposed as user-facing options and a label→code map drives writes. """ _attr_name = "Picture mode" @@ -168,11 +160,12 @@ class DisplayPictureModeSelect(_DisplaySelectBase): def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - super().__init__(client, entry, monitor) + super().__init__(coordinator, client, entry, monitor) self._attr_unique_id = f"{entry.entry_id}_display_picture_mode_{self._monitor_id}" modes = monitor.get("available_picture_modes") or [] @@ -182,8 +175,11 @@ class DisplayPictureModeSelect(_DisplaySelectBase): if "label" in mode and "code" in mode } self._attr_options = list(self._label_to_code.keys()) - current = monitor.get("picture_mode") - self._attr_current_option = current if current in self._attr_options else None + + @property + def current_option(self) -> str | None: + current = self._monitor.get("picture_mode") + return current if current in self._attr_options else None async def async_select_option(self, option: str) -> None: code = self._label_to_code.get(option) @@ -201,19 +197,6 @@ class DisplayPictureModeSelect(_DisplaySelectBase): " implementation of VCP 0xDC may be incomplete", self._monitor_id, option, code, ) - await self.async_update() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() return - self._attr_current_option = option - self.async_write_ha_state() - - async def async_update(self) -> None: - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - current = monitor.get("picture_mode") - self._attr_current_option = current if current in self._attr_options else None - break - except MediaServerError as err: - _LOGGER.error("Failed to refresh picture mode for monitor %d: %s", self._monitor_id, err) + self.coordinator.apply_optimistic(self._monitor_id, picture_mode=option) diff --git a/custom_components/remote_media_player/sensor.py b/custom_components/remote_media_player/sensor.py index a2b17d8..81ef252 100644 --- a/custom_components/remote_media_player/sensor.py +++ b/custom_components/remote_media_player/sensor.py @@ -10,9 +10,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api_client import MediaServerClient, MediaServerError from .const import DOMAIN +from .display_coordinator import DisplayCoordinator from .display_device import display_device_info _LOGGER = logging.getLogger(__name__) @@ -24,17 +25,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up per-display sensor entities.""" - client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + coordinator: DisplayCoordinator = hass.data[DOMAIN][entry.entry_id][ + "display_coordinator" + ] - try: - monitors = await client.get_display_monitors() - except MediaServerError as err: - _LOGGER.error("Failed to fetch display monitors: %s", err) + if not coordinator.data: return entities = [ - DisplayResolutionSensor(client, entry, monitor) - for monitor in monitors + DisplayResolutionSensor(coordinator, entry, monitor) + for monitor in coordinator.data.values() if monitor.get("resolution") ] @@ -43,7 +43,7 @@ async def async_setup_entry( _LOGGER.info("Added %d display sensor entities", len(entities)) -class DisplayResolutionSensor(SensorEntity): +class DisplayResolutionSensor(CoordinatorEntity[DisplayCoordinator], SensorEntity): """Diagnostic sensor reporting the EDID-derived display resolution.""" _attr_has_entity_name = True @@ -53,24 +53,17 @@ class DisplayResolutionSensor(SensorEntity): def __init__( self, - client: MediaServerClient, + coordinator: DisplayCoordinator, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - """Initialize the display resolution sensor.""" - self._client = client + super().__init__(coordinator) self._monitor_id: int = monitor["id"] - self._attr_native_value = monitor.get("resolution") self._attr_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}" self._attr_device_info = display_device_info(entry, monitor) - async def async_update(self) -> None: - """Refresh resolution from the server (rarely changes).""" - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - self._attr_native_value = monitor.get("resolution") - break - except MediaServerError as err: - _LOGGER.error("Failed to refresh resolution for monitor %d: %s", self._monitor_id, err) + @property + def native_value(self) -> str | None: + if self.coordinator.data is None: + return None + return self.coordinator.data.get(self._monitor_id, {}).get("resolution") diff --git a/custom_components/remote_media_player/strings.json b/custom_components/remote_media_player/strings.json index 1609d63..aed141f 100644 --- a/custom_components/remote_media_player/strings.json +++ b/custom_components/remote_media_player/strings.json @@ -14,7 +14,7 @@ "data_description": { "host": "Hostname or IP address of the Media Server", "port": "Port number (default: 8765)", - "token": "API authentication token from the server configuration", + "token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.", "name": "Display name for this media player", "poll_interval": "How often to poll for status updates (seconds)" } diff --git a/custom_components/remote_media_player/switch.py b/custom_components/remote_media_player/switch.py index 64a1555..42c5f5d 100644 --- a/custom_components/remote_media_player/switch.py +++ b/custom_components/remote_media_player/switch.py @@ -9,9 +9,11 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .api_client import MediaServerClient, MediaServerError from .const import DOMAIN +from .display_coordinator import DisplayCoordinator from .display_device import display_device_info _LOGGER = logging.getLogger(__name__) @@ -23,21 +25,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up display power switch entities from a config entry.""" - client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + data = hass.data[DOMAIN][entry.entry_id] + client: MediaServerClient = data["client"] + coordinator: DisplayCoordinator = data["display_coordinator"] - try: - monitors = await client.get_display_monitors() - except MediaServerError as err: - _LOGGER.error("Failed to fetch display monitors: %s", err) + if not coordinator.data: return entities = [ - DisplayPowerSwitch( - client=client, - entry=entry, - monitor=monitor, - ) - for monitor in monitors + DisplayPowerSwitch(coordinator, client, entry, monitor) + for monitor in coordinator.data.values() if monitor.get("power_supported", False) ] @@ -46,7 +43,7 @@ async def async_setup_entry( _LOGGER.info("Added %d display power switch entities", len(entities)) -class DisplayPowerSwitch(SwitchEntity): +class DisplayPowerSwitch(CoordinatorEntity[DisplayCoordinator], SwitchEntity): """Switch entity for controlling display power.""" _attr_has_entity_name = True @@ -55,54 +52,53 @@ class DisplayPowerSwitch(SwitchEntity): def __init__( self, + coordinator: DisplayCoordinator, client: MediaServerClient, entry: ConfigEntry, monitor: dict[str, Any], ) -> None: - """Initialize the display power switch.""" + super().__init__(coordinator) self._client = client - self._entry = entry self._monitor_id: int = monitor["id"] - self._attr_is_on = monitor.get("power_on", True) self._attr_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}" self._attr_device_info = display_device_info(entry, monitor) + @property + def _monitor(self) -> dict[str, Any]: + if self.coordinator.data is None: + return {} + return self.coordinator.data.get(self._monitor_id, {}) + + @property + def is_on(self) -> bool: + return bool(self._monitor.get("power_on", True)) + @property def icon(self) -> str: - """Return icon based on power state.""" - return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off" + return "mdi:monitor" if self.is_on else "mdi:monitor-off" + + async def _set_power(self, on: bool) -> None: + try: + result = await self._client.set_display_power(self._monitor_id, on) + except MediaServerError as err: + _LOGGER.error( + "Failed to %s monitor %d: %s", + "turn on" if on else "turn off", + self._monitor_id, + err, + ) + return + if not result.get("success"): + _LOGGER.error( + "Failed to %s monitor %d", + "turn on" if on else "turn off", + self._monitor_id, + ) + return + self.coordinator.apply_optimistic(self._monitor_id, power_on=on) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the monitor on.""" - try: - result = await self._client.set_display_power(self._monitor_id, True) - if result.get("success"): - self._attr_is_on = True - self.async_write_ha_state() - else: - _LOGGER.error("Failed to turn on monitor %d", self._monitor_id) - except MediaServerError as err: - _LOGGER.error("Failed to turn on monitor %d: %s", self._monitor_id, err) + await self._set_power(True) async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the monitor off.""" - try: - result = await self._client.set_display_power(self._monitor_id, False) - if result.get("success"): - self._attr_is_on = False - self.async_write_ha_state() - else: - _LOGGER.error("Failed to turn off monitor %d", self._monitor_id) - except MediaServerError as err: - _LOGGER.error("Failed to turn off monitor %d: %s", self._monitor_id, err) - - async def async_update(self) -> None: - """Fetch updated power state from the server.""" - try: - monitors = await self._client.get_display_monitors() - for monitor in monitors: - if monitor["id"] == self._monitor_id: - self._attr_is_on = monitor.get("power_on", True) - break - except MediaServerError as err: - _LOGGER.error("Failed to update power state for monitor %d: %s", self._monitor_id, err) + await self._set_power(False) diff --git a/custom_components/remote_media_player/translations/en.json b/custom_components/remote_media_player/translations/en.json index 1609d63..aed141f 100644 --- a/custom_components/remote_media_player/translations/en.json +++ b/custom_components/remote_media_player/translations/en.json @@ -14,7 +14,7 @@ "data_description": { "host": "Hostname or IP address of the Media Server", "port": "Port number (default: 8765)", - "token": "API authentication token from the server configuration", + "token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.", "name": "Display name for this media player", "poll_interval": "How often to poll for status updates (seconds)" } diff --git a/custom_components/remote_media_player/translations/ru.json b/custom_components/remote_media_player/translations/ru.json index 90cbac2..5e2a887 100644 --- a/custom_components/remote_media_player/translations/ru.json +++ b/custom_components/remote_media_player/translations/ru.json @@ -14,7 +14,7 @@ "data_description": { "host": "Имя хоста или IP-адрес Media Server", "port": "Номер порта (по умолчанию: 8765)", - "token": "Токен аутентификации из конфигурации сервера", + "token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.", "name": "Отображаемое имя медиаплеера", "poll_interval": "Частота опроса статуса (в секундах)" }