Compare commits

...

10 Commits

Author SHA1 Message Date
a37eb46003 Codebase audit fixes: stability and performance
- Safe int conversion for position/duration (catch ValueError/TypeError)
- Hoist get_media_folders() out of browse loop (N+1 → 1 API call)
- Fix path separator detection alongside folder metadata fetch
- Increase browse pagination limit from 1000 to 5000

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:10:55 +03:00
83153dbddd Add display monitor brightness and power control entities
- Add NUMBER platform for monitor brightness (0-100)
- Add SWITCH platform for monitor power on/off
- Add display API client methods (get_display_monitors, set_display_brightness, set_display_power)
- Add display API constants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:10:48 +03:00
02bdcc5d4b Fix entity not becoming unavailable on server shutdown
Trigger async_request_refresh() on WebSocket disconnect to restart
the polling loop. Without this, the coordinator's polling stays
stopped and last_update_success is never set to False.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:26:44 +03:00
8cbe33eb72 Add media browser integration for Home Assistant
- Implement async_browse_media() to enable browsing media folders through HA Media Browser UI
- Add async_play_media() to handle file playback from media browser
- Add play_media_file service for automation support
- Add BROWSE_MEDIA and PLAY_MEDIA feature flags
- Implement media browser API client methods (get_media_folders, browse_folder, play_media_file)
- Fix path separator handling for cross-platform compatibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 22:24:08 +03:00
e4eeb2a97b Add automatic script reload support
Features:
- Listen for scripts_changed WebSocket messages
- Automatically reload integration when scripts change
- New on_scripts_changed callback in WebSocket client
- Seamless button entity updates without manual reload

Technical changes:
- Enhanced MediaServerWebSocket with scripts_changed handler
- Updated MediaPlayerCoordinator to trigger integration reload
- Pass config entry to coordinator for reload capability

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 03:53:35 +03:00
959c6a4eda Reduce WebSocket reconnect interval to 5 seconds
Change DEFAULT_RECONNECT_INTERVAL from 30s to 5s for faster
reconnection after server restart.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 12:01:27 +03:00
e66f2f3b36 Add turn_on/turn_off/toggle support
- Add API_TURN_ON, API_TURN_OFF, API_TOGGLE constants
- Add turn_on(), turn_off(), toggle() methods to MediaServerClient
- Implement async_turn_on, async_turn_off, async_toggle in media player
- Add TURN_ON and TURN_OFF to supported features
- Update README with turn on/off/toggle documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 03:44:25 +03:00
37988331eb Update CLAUDE.md with commit/push approval rules 2026-02-04 20:29:54 +03:00
b13aa86594 Add versioning rules to CLAUDE.md 2026-02-04 20:28:05 +03:00
b3624e66e1 Replace GitHub URLs with git.dolgolyov-family.by 2026-02-04 20:20:25 +03:00
9 changed files with 653 additions and 13 deletions

View File

@@ -14,13 +14,21 @@ Or install via HACS as a custom repository.
Requires Media Server running on the target PC. Requires Media Server running on the target PC.
Media Server Repository: [media-player-server](https://github.com/DolgolyovAlexei/media-player-server) Media Server Repository: [media-player-server](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
## Integration Location ## Integration Location
Integration files location: `U:\custom_components\remote_media_player` Integration files location: `U:\custom_components\remote_media_player`
## Versioning
Version is tracked in `custom_components/remote_media_player/manifest.json` - `version` field.
Update this field when releasing a new version.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
## Git Rules ## Git Rules
- Always ask for user approval before committing changes to git. - **ALWAYS ask for user approval before committing and pushing changes.**
- When pushing, always push to all remotes: `git push origin master && git push github master` - When pushing, always push to all remotes: `git push origin master && git push github master`

View File

@@ -1,7 +1,7 @@
# Remote Media Player - Home Assistant Integration # Remote Media Player - Home Assistant Integration
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub Release](https://img.shields.io/github/v/release/DolgolyovAlexei/haos-hacs-integration-media-player)](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/releases) [![GitHub Release](https://img.shields.io/github/v/release/DolgolyovAlexei/haos-hacs-integration-media-player)](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/releases)
A Home Assistant custom integration that allows you to control a remote PC's media playback as a media player entity. A Home Assistant custom integration that allows you to control a remote PC's media playback as a media player entity.
@@ -12,6 +12,7 @@ A Home Assistant custom integration that allows you to control a remote PC's med
- Seek support with smooth timeline updates - Seek support with smooth timeline updates
- Displays current track info (title, artist, album, artwork) - Displays current track info (title, artist, album, artwork)
- Real-time updates via WebSocket (with HTTP polling fallback) - Real-time updates via WebSocket (with HTTP polling fallback)
- **Turn on/off/toggle support** - Execute custom actions (e.g., lock screen on turn off)
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.) - **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
- Configurable via Home Assistant UI - Configurable via Home Assistant UI
@@ -24,7 +25,7 @@ A Home Assistant custom integration that allows you to control a remote PC's med
This integration requires the Media Server to be running on the PC you want to control. This integration requires the Media Server to be running on the PC you want to control.
**Media Server Repository:** [media-player-server](https://github.com/DolgolyovAlexei/media-player-server) **Media Server Repository:** [media-player-server](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
See the Media Server documentation for installation and setup instructions. See the Media Server documentation for installation and setup instructions.
@@ -34,7 +35,7 @@ See the Media Server documentation for installation and setup instructions.
1. Open HACS in Home Assistant 1. Open HACS in Home Assistant
2. Click the three dots menu > **Custom repositories** 2. Click the three dots menu > **Custom repositories**
3. Add this repository URL: `https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player` 3. Add this repository URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player`
4. Select category: **Integration** 4. Select category: **Integration**
5. Click **Add** 5. Click **Add**
6. Search for "Remote Media Player" and click **Download** 6. Search for "Remote Media Player" and click **Download**
@@ -42,7 +43,7 @@ See the Media Server documentation for installation and setup instructions.
### Manual Installation ### Manual Installation
1. Download the latest release from the [Releases](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/releases) page 1. Download the latest release from the [Releases](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/releases) page
2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory 2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory
3. Restart Home Assistant 3. Restart Home Assistant
@@ -69,6 +70,31 @@ A full-featured media player entity with:
- Volume control and mute - Volume control and mute
- Seek functionality - Seek functionality
- Current track information - Current track information
- Turn on/off/toggle actions (execute server-side callbacks)
### Turn On/Off/Toggle
The media player supports `media_player.turn_on`, `media_player.turn_off`, and `media_player.toggle` actions. These execute optional callbacks configured on the Media Server (e.g., lock screen on turn off).
Configure callbacks in Media Server's `config.yaml`:
```yaml
callbacks:
on_turn_on:
command: "echo PC turned on"
timeout: 10
shell: true
on_turn_off:
command: "rundll32.exe user32.dll,LockWorkStation"
timeout: 5
shell: true
on_toggle:
command: "echo Toggle triggered"
timeout: 10
shell: true
```
### Script Button Entities ### Script Button Entities
@@ -90,8 +116,8 @@ For detailed documentation, see [custom_components/remote_media_player/README.md
## Support ## Support
- [Report an Issue](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/issues) - [Report an Issue](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/issues)
- [Media Server Repository](https://github.com/DolgolyovAlexei/media-player-server) - [Media Server Repository](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
## License ## License

View File

@@ -14,6 +14,7 @@ from homeassistant.helpers import config_validation as cv
from .api_client import MediaServerClient, MediaServerError from .api_client import MediaServerClient, MediaServerError
from .const import ( from .const import (
ATTR_FILE_PATH,
ATTR_SCRIPT_ARGS, ATTR_SCRIPT_ARGS,
ATTR_SCRIPT_NAME, ATTR_SCRIPT_NAME,
CONF_HOST, CONF_HOST,
@@ -21,11 +22,12 @@ from .const import (
CONF_TOKEN, CONF_TOKEN,
DOMAIN, DOMAIN,
SERVICE_EXECUTE_SCRIPT, SERVICE_EXECUTE_SCRIPT,
SERVICE_PLAY_MEDIA_FILE,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON] PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON, Platform.NUMBER, Platform.SWITCH]
# Service schema for execute_script # Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema( SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
@@ -37,6 +39,13 @@ SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
} }
) )
# Service schema for play_media_file
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FILE_PATH): cv.string,
}
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remote Media Player from a config entry. """Set up Remote Media Player from a config entry.
@@ -111,6 +120,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
schema=SERVICE_EXECUTE_SCRIPT_SCHEMA, schema=SERVICE_EXECUTE_SCRIPT_SCHEMA,
) )
# Register play_media_file service if not already registered
if not hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA_FILE):
async def async_play_media_file(call: ServiceCall) -> None:
"""Handle play_media_file service call."""
file_path = call.data[ATTR_FILE_PATH]
_LOGGER.debug("Service play_media_file called with path: %s", file_path)
# Execute on all configured media server instances
for entry_id, data in hass.data[DOMAIN].items():
client: MediaServerClient = data["client"]
try:
await client.play_media_file(file_path)
_LOGGER.info("Started playback of %s on %s", file_path, entry_id)
except MediaServerError as err:
_LOGGER.error("Failed to play %s on %s: %s", file_path, entry_id, err)
hass.services.async_register(
DOMAIN,
SERVICE_PLAY_MEDIA_FILE,
async_play_media_file,
schema=SERVICE_PLAY_MEDIA_FILE_SCHEMA,
)
# Forward setup to platforms # Forward setup to platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -149,6 +181,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Remove services if this was the last entry # Remove services if this was the last entry
if not hass.data[DOMAIN]: if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT) hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT)
hass.services.async_remove(DOMAIN, SERVICE_PLAY_MEDIA_FILE)
return unload_ok return unload_ok

View File

@@ -22,8 +22,17 @@ from .const import (
API_VOLUME, API_VOLUME,
API_MUTE, API_MUTE,
API_SEEK, API_SEEK,
API_TURN_ON,
API_TURN_OFF,
API_TOGGLE,
API_SCRIPTS_LIST, API_SCRIPTS_LIST,
API_SCRIPTS_EXECUTE, API_SCRIPTS_EXECUTE,
API_BROWSER_FOLDERS,
API_BROWSER_BROWSE,
API_BROWSER_PLAY,
API_DISPLAY_MONITORS,
API_DISPLAY_BRIGHTNESS,
API_DISPLAY_POWER,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -245,6 +254,30 @@ class MediaServerClient:
""" """
return await self._request("POST", API_SEEK, {"position": position}) return await self._request("POST", API_SEEK, {"position": position})
async def turn_on(self) -> dict[str, Any]:
"""Send turn on command.
Returns:
Response data
"""
return await self._request("POST", API_TURN_ON)
async def turn_off(self) -> dict[str, Any]:
"""Send turn off command.
Returns:
Response data
"""
return await self._request("POST", API_TURN_OFF)
async def toggle(self) -> dict[str, Any]:
"""Send toggle command.
Returns:
Response data
"""
return await self._request("POST", API_TOGGLE)
async def list_scripts(self) -> list[dict[str, Any]]: async def list_scripts(self) -> list[dict[str, Any]]:
"""List available scripts on the server. """List available scripts on the server.
@@ -269,6 +302,85 @@ class MediaServerClient:
json_data = {"args": args or []} json_data = {"args": args or []}
return await self._request("POST", endpoint, json_data) return await self._request("POST", endpoint, json_data)
async def get_media_folders(self) -> dict[str, dict[str, Any]]:
"""Get configured media folders.
Returns:
Dictionary of folders with folder_id as key and folder config as value
"""
return await self._request("GET", API_BROWSER_FOLDERS)
async def browse_folder(
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100
) -> dict[str, Any]:
"""Browse a media folder.
Args:
folder_id: ID of the folder to browse
path: Path within the folder (empty for root)
offset: Pagination offset
limit: Number of items to return
Returns:
Dictionary with current_path, parent_path, items, total, offset, limit
"""
params = {
"folder_id": folder_id,
"path": path,
"offset": offset,
"limit": limit,
}
query_string = "&".join(f"{k}={v}" for k, v in params.items())
endpoint = f"{API_BROWSER_BROWSE}?{query_string}"
return await self._request("GET", endpoint)
async def play_media_file(self, file_path: str) -> dict[str, Any]:
"""Play a media file by absolute path.
Args:
file_path: Absolute path to the media file
Returns:
Response data with success status
"""
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
async def get_display_monitors(self) -> list[dict[str, Any]]:
"""Get list of connected monitors with brightness and power info.
Returns:
List of monitor dicts with id, name, brightness, power_supported, power_on, resolution
"""
return await self._request("GET", f"{API_DISPLAY_MONITORS}?refresh=true")
async def set_display_brightness(self, monitor_id: int, brightness: int) -> dict[str, Any]:
"""Set brightness for a specific monitor.
Args:
monitor_id: Monitor index
brightness: Brightness level (0-100)
Returns:
Response data with success status
"""
return await self._request(
"POST", f"{API_DISPLAY_BRIGHTNESS}/{monitor_id}", {"brightness": brightness}
)
async def set_display_power(self, monitor_id: int, on: bool) -> dict[str, Any]:
"""Set power state for a specific monitor.
Args:
monitor_id: Monitor index
on: True to turn on, False to turn off
Returns:
Response data with success status
"""
return await self._request(
"POST", f"{API_DISPLAY_POWER}/{monitor_id}", {"on": on}
)
class MediaServerWebSocket: class MediaServerWebSocket:
"""WebSocket client for real-time media status updates.""" """WebSocket client for real-time media status updates."""
@@ -280,6 +392,7 @@ class MediaServerWebSocket:
token: str, token: str,
on_status_update: Callable[[dict[str, Any]], None], on_status_update: Callable[[dict[str, Any]], None],
on_disconnect: Callable[[], None] | None = None, on_disconnect: Callable[[], None] | None = None,
on_scripts_changed: Callable[[], None] | None = None,
) -> None: ) -> None:
"""Initialize the WebSocket client. """Initialize the WebSocket client.
@@ -289,12 +402,14 @@ class MediaServerWebSocket:
token: API authentication token token: API authentication token
on_status_update: Callback when status update received on_status_update: Callback when status update received
on_disconnect: Callback when connection lost on_disconnect: Callback when connection lost
on_scripts_changed: Callback when scripts have changed
""" """
self._host = host self._host = host
self._port = int(port) self._port = int(port)
self._token = token self._token = token
self._on_status_update = on_status_update self._on_status_update = on_status_update
self._on_disconnect = on_disconnect 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}" self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None self._ws: aiohttp.ClientWebSocketResponse | None = None
@@ -374,6 +489,10 @@ class MediaServerWebSocket:
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}" f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
) )
self._on_status_update(status_data) self._on_status_update(status_data)
elif msg_type == "scripts_changed":
_LOGGER.info("Scripts changed notification received")
if self._on_scripts_changed:
self._on_scripts_changed()
elif msg_type == "pong": elif msg_type == "pong":
_LOGGER.debug("Received pong") _LOGGER.debug("Received pong")

View File

@@ -15,7 +15,7 @@ DEFAULT_PORT = 8765
DEFAULT_POLL_INTERVAL = 5 DEFAULT_POLL_INTERVAL = 5
DEFAULT_NAME = "Remote Media Player" DEFAULT_NAME = "Remote Media Player"
DEFAULT_USE_WEBSOCKET = True DEFAULT_USE_WEBSOCKET = True
DEFAULT_RECONNECT_INTERVAL = 30 DEFAULT_RECONNECT_INTERVAL = 5
# API endpoints # API endpoints
API_HEALTH = "/api/health" API_HEALTH = "/api/health"
@@ -28,13 +28,24 @@ API_PREVIOUS = "/api/media/previous"
API_VOLUME = "/api/media/volume" API_VOLUME = "/api/media/volume"
API_MUTE = "/api/media/mute" API_MUTE = "/api/media/mute"
API_SEEK = "/api/media/seek" API_SEEK = "/api/media/seek"
API_TURN_ON = "/api/media/turn_on"
API_TURN_OFF = "/api/media/turn_off"
API_TOGGLE = "/api/media/toggle"
API_SCRIPTS_LIST = "/api/scripts/list" API_SCRIPTS_LIST = "/api/scripts/list"
API_SCRIPTS_EXECUTE = "/api/scripts/execute" API_SCRIPTS_EXECUTE = "/api/scripts/execute"
API_WEBSOCKET = "/api/media/ws" API_WEBSOCKET = "/api/media/ws"
API_BROWSER_FOLDERS = "/api/browser/folders"
API_BROWSER_BROWSE = "/api/browser/browse"
API_BROWSER_PLAY = "/api/browser/play"
API_DISPLAY_MONITORS = "/api/display/monitors"
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
API_DISPLAY_POWER = "/api/display/power"
# Service names # Service names
SERVICE_EXECUTE_SCRIPT = "execute_script" SERVICE_EXECUTE_SCRIPT = "execute_script"
SERVICE_PLAY_MEDIA_FILE = "play_media_file"
# Service attributes # Service attributes
ATTR_SCRIPT_NAME = "script_name" ATTR_SCRIPT_NAME = "script_name"
ATTR_SCRIPT_ARGS = "args" ATTR_SCRIPT_ARGS = "args"
ATTR_FILE_PATH = "file_path"

View File

@@ -4,7 +4,7 @@
"codeowners": [], "codeowners": [],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player", "documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"], "requirements": ["aiohttp>=3.8.0"],

View File

@@ -8,11 +8,16 @@ from datetime import datetime, timedelta
from typing import Any from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
) )
from homeassistant.components.media_player.const import (
MediaClass,
)
from urllib.parse import quote, unquote
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -82,6 +87,7 @@ async def async_setup_entry(
port=entry.data[CONF_PORT], port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN], token=entry.data[CONF_TOKEN],
use_websocket=use_websocket, use_websocket=use_websocket,
entry=entry,
) )
# Set up WebSocket connection if enabled # Set up WebSocket connection if enabled
@@ -118,6 +124,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: int, port: int,
token: str, token: str,
use_websocket: bool = True, use_websocket: bool = True,
entry: ConfigEntry | None = None,
) -> None: ) -> None:
"""Initialize the coordinator. """Initialize the coordinator.
@@ -129,6 +136,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: Server port port: Server port
token: API token token: API token
use_websocket: Whether to use WebSocket for updates use_websocket: Whether to use WebSocket for updates
entry: Config entry (for integration reload on scripts change)
""" """
super().__init__( super().__init__(
hass, hass,
@@ -141,6 +149,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._port = port self._port = port
self._token = token self._token = token
self._use_websocket = use_websocket self._use_websocket = use_websocket
self._entry = entry
self._ws_client: MediaServerWebSocket | None = None self._ws_client: MediaServerWebSocket | None = None
self._ws_connected = False self._ws_connected = False
self._reconnect_task: asyncio.Task | None = None self._reconnect_task: asyncio.Task | None = None
@@ -162,6 +171,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
token=self._token, token=self._token,
on_status_update=self._handle_ws_status_update, on_status_update=self._handle_ws_status_update,
on_disconnect=self._handle_ws_disconnect, on_disconnect=self._handle_ws_disconnect,
on_scripts_changed=self._handle_ws_scripts_changed,
) )
if await self._ws_client.connect(): if await self._ws_client.connect():
@@ -189,9 +199,24 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Re-enable polling as fallback # Re-enable polling as fallback
self.update_interval = timedelta(seconds=self._poll_interval) self.update_interval = timedelta(seconds=self._poll_interval)
_LOGGER.warning("WebSocket disconnected, falling back to polling") _LOGGER.warning("WebSocket disconnected, falling back to polling")
# Trigger an immediate refresh to restart the polling loop.
# Without this, the polling loop stays stopped (it was disabled when
# WebSocket was active) and the entity never becomes unavailable.
self.hass.async_create_task(self.async_request_refresh())
# Schedule reconnect attempt # Schedule reconnect attempt
self._schedule_reconnect() self._schedule_reconnect()
@callback
def _handle_ws_scripts_changed(self) -> None:
"""Handle scripts changed notification from WebSocket."""
if self._entry:
_LOGGER.info("Scripts changed, reloading integration")
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._entry.entry_id)
)
else:
_LOGGER.warning("Cannot reload integration: entry not available")
def _schedule_reconnect(self) -> None: def _schedule_reconnect(self) -> None:
"""Schedule a WebSocket reconnection attempt.""" """Schedule a WebSocket reconnection attempt."""
if self._reconnect_task and not self._reconnect_task.done(): if self._reconnect_task and not self._reconnect_task.done():
@@ -285,6 +310,10 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
) )
@property @property
@@ -356,7 +385,12 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
if self.coordinator.data is None: if self.coordinator.data is None:
return None return None
duration = self.coordinator.data.get("duration") duration = self.coordinator.data.get("duration")
return int(duration) if duration is not None else None if duration is None:
return None
try:
return int(duration)
except (ValueError, TypeError):
return None
@property @property
def media_position(self) -> int | None: def media_position(self) -> int | None:
@@ -364,7 +398,12 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
if self.coordinator.data is None: if self.coordinator.data is None:
return None return None
position = self.coordinator.data.get("position") position = self.coordinator.data.get("position")
return int(position) if position is not None else None if position is None:
return None
try:
return int(position)
except (ValueError, TypeError):
return None
@property @property
def media_position_updated_at(self) -> datetime | None: def media_position_updated_at(self) -> datetime | None:
@@ -450,3 +489,172 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
except MediaServerError as err: except MediaServerError as err:
_LOGGER.error("Failed to seek: %s", err) _LOGGER.error("Failed to seek: %s", err)
async def async_turn_on(self) -> None:
"""Send turn on command."""
try:
await self.coordinator.client.turn_on()
except MediaServerError as err:
_LOGGER.error("Failed to turn on: %s", err)
async def async_turn_off(self) -> None:
"""Send turn off command."""
try:
await self.coordinator.client.turn_off()
except MediaServerError as err:
_LOGGER.error("Failed to turn off: %s", err)
async def async_toggle(self) -> None:
"""Send toggle command."""
try:
await self.coordinator.client.toggle()
except MediaServerError as err:
_LOGGER.error("Failed to toggle: %s", err)
# Media Browser support
@staticmethod
def _encode_media_id(folder_id: str, path: str = "") -> str:
"""Encode folder_id and path into media_content_id.
Format: folder_id|encoded_path
Root folder: folder_id|
"""
return f"{folder_id}|{quote(path, safe='')}"
@staticmethod
def _decode_media_id(media_content_id: str) -> tuple[str, str]:
"""Decode media_content_id into folder_id and path.
Returns:
Tuple of (folder_id, path)
"""
if not media_content_id or "|" not in media_content_id:
return "", ""
folder_id, encoded_path = media_content_id.split("|", 1)
path = unquote(encoded_path) if encoded_path else ""
return folder_id, path
async def async_browse_media(
self,
media_content_type: str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the media browsing.
Args:
media_content_type: Type of media (unused, but required by HA)
media_content_id: ID in format "folder_id|path" or None for root
Returns:
BrowseMedia object with children
"""
_LOGGER.debug("Browse media: type=%s, id=%s", media_content_type, media_content_id)
# Root level - list all folders
if not media_content_id:
folders = await self.coordinator.client.get_media_folders()
children = [
BrowseMedia(
title=config["label"],
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC, # All folders show as music
media_content_id=self._encode_media_id(folder_id, ""),
can_play=False,
can_expand=True,
)
for folder_id, config in folders.items()
if config.get("enabled", True)
]
return BrowseMedia(
title="Media Folders",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
media_content_id="",
can_play=False,
can_expand=True,
children=children,
)
# Browse specific folder
folder_id, path = self._decode_media_id(media_content_id)
if not folder_id:
raise ValueError("Invalid media_content_id format")
# Get folder contents from API
browse_data = await self.coordinator.client.browse_folder(folder_id, path, offset=0, limit=5000)
# Fetch folder metadata once (not per-item) for building absolute paths
folders = await self.coordinator.client.get_media_folders()
base_path = folders.get(folder_id, {}).get("path", "")
# Detect path separator from server's base_path (Unix vs Windows)
separator = '\\' if '\\' in base_path else '/'
base_path_clean = base_path.rstrip('/\\')
children = []
for item in browse_data.get("items", []):
if item["type"] == "folder":
# Subfolder
item_path = f"{path}/{item['name']}" if path else item['name']
children.append(
BrowseMedia(
title=item["name"],
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
media_content_id=self._encode_media_id(folder_id, item_path),
can_play=False,
can_expand=True,
)
)
elif item.get("is_media", False):
# Media file - build absolute path for playback
file_path_in_folder = f"{path}/{item['name']}" if path else item['name']
absolute_path = f"{base_path_clean}{separator}{file_path_in_folder.replace('/', separator)}"
children.append(
BrowseMedia(
title=item["name"],
media_class=MediaClass.MUSIC,
media_content_type=MediaType.MUSIC,
media_content_id=absolute_path, # Use absolute path as ID for playback
can_play=True,
can_expand=False,
)
)
# Get current folder label
current_title = path.split("/")[-1] if path else browse_data.get("label", folder_id)
return BrowseMedia(
title=current_title,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
media_content_id=media_content_id,
can_play=False,
can_expand=True,
children=children,
)
async def async_play_media(
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
"""Play a media file.
Args:
media_type: Type of media (unused)
media_id: Absolute file path to media file
**kwargs: Additional arguments (unused)
"""
_LOGGER.debug("Play media: type=%s, id=%s", media_type, media_id)
try:
# media_id is the absolute file path from browse_media
await self.coordinator.client.play_media_file(media_id)
# Request immediate status update
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to play media file: %s", err)

View File

@@ -0,0 +1,110 @@
"""Number platform for Remote Media Player integration (display brightness)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display brightness number entities from a config entry."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities = [
DisplayBrightnessNumber(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
if monitor.get("brightness") is not None
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display brightness entities", len(entities))
class DisplayBrightnessNumber(NumberEntity):
"""Number entity for controlling display brightness."""
_attr_has_entity_name = True
_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:brightness-6"
def __init__(
self,
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._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_native_value = monitor.get("brightness")
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
self._attr_name = f"Display {display_name} Brightness"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
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)

View File

@@ -0,0 +1,125 @@
"""Switch platform for Remote Media Player integration (display power)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display power switch entities from a config entry."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities = [
DisplayPowerSwitch(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
if monitor.get("power_supported", False)
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display power switch entities", len(entities))
class DisplayPowerSwitch(SwitchEntity):
"""Switch entity for controlling display power."""
_attr_has_entity_name = True
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display power switch."""
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_is_on = monitor.get("power_on", True)
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
self._attr_name = f"Display {display_name} Power"
@property
def icon(self) -> str:
"""Return icon based on power state."""
return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
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)
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)