Refactor project into two standalone components
Split monorepo into separate units for future independent repositories: - media-server/: Standalone FastAPI server with own README, requirements, config example, and CLAUDE.md - haos-integration/: HACS-ready Home Assistant integration with hacs.json, own README, and CLAUDE.md Both components now have their own .gitignore files and can be easily extracted into separate repositories. Also adds custom icon support for scripts configuration. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
324
haos-integration/custom_components/remote_media_player/README.md
Normal file
324
haos-integration/custom_components/remote_media_player/README.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Remote Media Player - Home Assistant Integration
|
||||
|
||||
A Home Assistant custom component that allows you to control a remote PC's media playback as a media player entity.
|
||||
|
||||
## Features
|
||||
|
||||
- Full media player controls (play, pause, stop, next, previous)
|
||||
- Volume control and mute
|
||||
- Seek support
|
||||
- Displays current track info (title, artist, album, artwork)
|
||||
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
|
||||
- Configurable via Home Assistant UI
|
||||
- Polling with adjustable interval
|
||||
|
||||
## Requirements
|
||||
|
||||
- Home Assistant 2024.1.0 or newer
|
||||
- A running Media Server on your PC (see Media Server repository)
|
||||
|
||||
## Installation
|
||||
|
||||
### HACS (Recommended)
|
||||
|
||||
1. Open HACS in Home Assistant
|
||||
2. Click the three dots menu > Custom repositories
|
||||
3. Add this repository URL and select "Integration"
|
||||
4. Search for "Remote Media Player" and install
|
||||
5. Restart Home Assistant
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Copy the `remote_media_player` folder to your Home Assistant `config/custom_components/` directory:
|
||||
```
|
||||
config/
|
||||
└── custom_components/
|
||||
└── remote_media_player/
|
||||
├── __init__.py
|
||||
├── api_client.py
|
||||
├── button.py
|
||||
├── config_flow.py
|
||||
├── const.py
|
||||
├── manifest.json
|
||||
├── media_player.py
|
||||
├── services.yaml
|
||||
├── strings.json
|
||||
└── translations/
|
||||
└── en.json
|
||||
```
|
||||
|
||||
2. Restart Home Assistant
|
||||
|
||||
## Configuration
|
||||
|
||||
### Via UI (Recommended)
|
||||
|
||||
1. Go to **Settings** > **Devices & Services**
|
||||
2. Click **+ Add Integration**
|
||||
3. Search for "Remote Media Player"
|
||||
4. Enter the connection details:
|
||||
- **Host**: IP address or hostname of your PC running Media Server
|
||||
- **Port**: Server port (default: 8765)
|
||||
- **API Token**: The authentication token from your server
|
||||
- **Name**: Display name for this media player (optional)
|
||||
- **Poll Interval**: How often to update status (default: 5 seconds)
|
||||
|
||||
### Finding Your API Token
|
||||
|
||||
On the PC running Media Server:
|
||||
```bash
|
||||
python -m media_server.main --show-token
|
||||
```
|
||||
|
||||
Or check the config file:
|
||||
- Windows: `%APPDATA%\media-server\config.yaml`
|
||||
- Linux/macOS: `~/.config/media-server/config.yaml`
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, the integration creates a media player entity that you can:
|
||||
|
||||
### Control via UI
|
||||
- Use the media player card in Lovelace
|
||||
- Control from the entity's detail page
|
||||
|
||||
### Control via Services
|
||||
```yaml
|
||||
# Play
|
||||
service: media_player.media_play
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
|
||||
# Pause
|
||||
service: media_player.media_pause
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
|
||||
# Set volume (0.0 - 1.0)
|
||||
service: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
data:
|
||||
volume_level: 0.5
|
||||
|
||||
# Mute
|
||||
service: media_player.volume_mute
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
data:
|
||||
is_volume_muted: true
|
||||
|
||||
# Next/Previous track
|
||||
service: media_player.media_next_track
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
|
||||
# Seek to position (seconds)
|
||||
service: media_player.media_seek
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
data:
|
||||
seek_position: 60
|
||||
```
|
||||
|
||||
### Automations
|
||||
|
||||
Example: Pause PC media when leaving home
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Pause PC media when leaving"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: person.your_name
|
||||
from: "home"
|
||||
action:
|
||||
- service: media_player.media_pause
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
```
|
||||
|
||||
Example: Lower PC volume during quiet hours
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Lower PC volume at night"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "22:00:00"
|
||||
action:
|
||||
- service: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.remote_media_player
|
||||
data:
|
||||
volume_level: 0.3
|
||||
```
|
||||
|
||||
## Script Buttons
|
||||
|
||||
The integration automatically creates **button entities** for each script defined on your Media Server. These buttons allow you to:
|
||||
|
||||
- Lock/unlock the workstation
|
||||
- Shutdown, restart, or put the PC to sleep
|
||||
- Hibernate the PC
|
||||
- Execute custom commands
|
||||
|
||||
### Available Buttons
|
||||
|
||||
After setup, you'll see button entities like:
|
||||
- `button.remote_media_player_lock_screen`
|
||||
- `button.remote_media_player_shutdown`
|
||||
- `button.remote_media_player_restart`
|
||||
- `button.remote_media_player_sleep`
|
||||
- `button.remote_media_player_hibernate`
|
||||
|
||||
### Adding Scripts
|
||||
|
||||
Scripts are configured on the Media Server in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
lock_screen:
|
||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
label: "Lock Screen"
|
||||
description: "Lock the workstation"
|
||||
timeout: 5
|
||||
shell: true
|
||||
```
|
||||
|
||||
After adding scripts, restart the Media Server and reload the integration in Home Assistant.
|
||||
|
||||
### Using Script Buttons
|
||||
|
||||
#### Via UI
|
||||
Add button entities to your dashboard using a button card or entities card.
|
||||
|
||||
#### Via Automation
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Lock PC when leaving home"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: person.your_name
|
||||
from: "home"
|
||||
action:
|
||||
- service: button.press
|
||||
target:
|
||||
entity_id: button.remote_media_player_lock_screen
|
||||
```
|
||||
|
||||
### Execute Script Service
|
||||
|
||||
You can also execute scripts with arguments using the service:
|
||||
|
||||
```yaml
|
||||
service: remote_media_player.execute_script
|
||||
data:
|
||||
script_name: "echo_test"
|
||||
args:
|
||||
- "arg1"
|
||||
- "arg2"
|
||||
```
|
||||
|
||||
## Lovelace Card Examples
|
||||
|
||||
### Basic Media Control Card
|
||||
```yaml
|
||||
type: media-control
|
||||
entity: media_player.remote_media_player
|
||||
```
|
||||
|
||||
### Mini Media Player (requires custom card)
|
||||
```yaml
|
||||
type: custom:mini-media-player
|
||||
entity: media_player.remote_media_player
|
||||
artwork: cover
|
||||
source: icon
|
||||
```
|
||||
|
||||
### Entities Card
|
||||
```yaml
|
||||
type: entities
|
||||
entities:
|
||||
- entity: media_player.remote_media_player
|
||||
type: custom:slider-entity-row
|
||||
full_row: true
|
||||
```
|
||||
|
||||
## Entity Attributes
|
||||
|
||||
The media player entity exposes these attributes:
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `media_title` | Current track title |
|
||||
| `media_artist` | Current artist |
|
||||
| `media_album_name` | Current album |
|
||||
| `media_duration` | Track duration in seconds |
|
||||
| `media_position` | Current position in seconds |
|
||||
| `volume_level` | Volume (0.0 - 1.0) |
|
||||
| `is_volume_muted` | Mute state |
|
||||
| `source` | Media source/player name |
|
||||
|
||||
## Options
|
||||
|
||||
After initial setup, you can adjust options:
|
||||
|
||||
1. Go to **Settings** > **Devices & Services**
|
||||
2. Find "Remote Media Player" and click **Configure**
|
||||
3. Adjust the poll interval as needed
|
||||
|
||||
Lower poll intervals = more responsive but more network traffic.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Integration not found
|
||||
- Restart Home Assistant after installing
|
||||
- Check that all files are in the correct location
|
||||
- Check Home Assistant logs for errors
|
||||
|
||||
### Cannot connect to server
|
||||
- Verify the server is running: `curl http://YOUR_PC_IP:8765/api/health`
|
||||
- Check firewall settings on the PC
|
||||
- Ensure the IP address is correct
|
||||
|
||||
### Invalid token error
|
||||
- Double-check the token matches exactly
|
||||
- Regenerate token if needed: `python -m media_server.main --generate-config`
|
||||
|
||||
### Entity shows unavailable
|
||||
- Check server is running
|
||||
- Check network connectivity
|
||||
- Review Home Assistant logs for connection errors
|
||||
|
||||
### Media controls don't work
|
||||
- Ensure media is playing on the PC
|
||||
- Check server logs for errors
|
||||
- Verify the media player supports the requested action
|
||||
|
||||
## Multiple PCs
|
||||
|
||||
You can add multiple Media Server instances:
|
||||
|
||||
1. Run Media Server on each PC (use different tokens)
|
||||
2. Add the integration multiple times in Home Assistant
|
||||
3. Give each a unique name
|
||||
|
||||
## Supported Features
|
||||
|
||||
| Feature | Supported |
|
||||
|---------|-----------|
|
||||
| Play | Yes |
|
||||
| Pause | Yes |
|
||||
| Stop | Yes |
|
||||
| Next Track | Yes |
|
||||
| Previous Track | Yes |
|
||||
| Volume Set | Yes |
|
||||
| Volume Mute | Yes |
|
||||
| Seek | Yes |
|
||||
| Script Buttons | Yes |
|
||||
| Browse Media | No |
|
||||
| Play Media | No |
|
||||
| Shuffle/Repeat | No |
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,164 @@
|
||||
"""The Remote Media Player integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .api_client import MediaServerClient, MediaServerError
|
||||
from .const import (
|
||||
ATTR_SCRIPT_ARGS,
|
||||
ATTR_SCRIPT_NAME,
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
DOMAIN,
|
||||
SERVICE_EXECUTE_SCRIPT,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON]
|
||||
|
||||
# Service schema for execute_script
|
||||
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SCRIPT_NAME): cv.string,
|
||||
vol.Optional(ATTR_SCRIPT_ARGS, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Remote Media Player from a config entry.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry: Config entry
|
||||
|
||||
Returns:
|
||||
True if setup was successful
|
||||
"""
|
||||
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
|
||||
|
||||
# Create API client
|
||||
client = MediaServerClient(
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
token=entry.data[CONF_TOKEN],
|
||||
)
|
||||
|
||||
# Verify connection
|
||||
if not await client.check_connection():
|
||||
_LOGGER.error("Failed to connect to Media Server")
|
||||
await client.close()
|
||||
return False
|
||||
|
||||
# Store client in hass.data
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"client": client,
|
||||
}
|
||||
|
||||
# Register services if not already registered
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
|
||||
async def async_execute_script(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Execute a script on the media server."""
|
||||
script_name = call.data[ATTR_SCRIPT_NAME]
|
||||
script_args = call.data.get(ATTR_SCRIPT_ARGS, [])
|
||||
|
||||
_LOGGER.debug(
|
||||
"Executing script '%s' with args: %s", script_name, script_args
|
||||
)
|
||||
|
||||
# Get all clients and execute on all of them
|
||||
results = {}
|
||||
for entry_id, data in hass.data[DOMAIN].items():
|
||||
client: MediaServerClient = data["client"]
|
||||
try:
|
||||
result = await client.execute_script(script_name, script_args)
|
||||
results[entry_id] = result
|
||||
_LOGGER.info(
|
||||
"Script '%s' executed on %s: success=%s",
|
||||
script_name,
|
||||
entry_id,
|
||||
result.get("success", False),
|
||||
)
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to execute script '%s' on %s: %s",
|
||||
script_name,
|
||||
entry_id,
|
||||
err,
|
||||
)
|
||||
results[entry_id] = {"success": False, "error": str(err)}
|
||||
|
||||
return results
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_EXECUTE_SCRIPT,
|
||||
async_execute_script,
|
||||
schema=SERVICE_EXECUTE_SCRIPT_SCHEMA,
|
||||
)
|
||||
|
||||
# Forward setup to platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Register update listener for options
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry: Config entry
|
||||
|
||||
Returns:
|
||||
True if unload was successful
|
||||
"""
|
||||
_LOGGER.debug("Unloading Remote Media Player: %s", entry.entry_id)
|
||||
|
||||
# Unload platforms
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
# Close client and remove data
|
||||
data = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
# Shutdown coordinator (WebSocket cleanup)
|
||||
if "coordinator" in data:
|
||||
await data["coordinator"].async_shutdown()
|
||||
|
||||
# Close HTTP client
|
||||
await data["client"].close()
|
||||
|
||||
# Remove services if this was the last entry
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry: Config entry
|
||||
"""
|
||||
_LOGGER.debug("Options updated for: %s", entry.entry_id)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -0,0 +1,407 @@
|
||||
"""API client for communicating with the Media Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
|
||||
from .const import (
|
||||
API_HEALTH,
|
||||
API_STATUS,
|
||||
API_PLAY,
|
||||
API_PAUSE,
|
||||
API_STOP,
|
||||
API_NEXT,
|
||||
API_PREVIOUS,
|
||||
API_VOLUME,
|
||||
API_MUTE,
|
||||
API_SEEK,
|
||||
API_SCRIPTS_LIST,
|
||||
API_SCRIPTS_EXECUTE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MediaServerError(Exception):
|
||||
"""Base exception for Media Server errors."""
|
||||
|
||||
|
||||
class MediaServerConnectionError(MediaServerError):
|
||||
"""Exception for connection errors."""
|
||||
|
||||
|
||||
class MediaServerAuthError(MediaServerError):
|
||||
"""Exception for authentication errors."""
|
||||
|
||||
|
||||
class MediaServerClient:
|
||||
"""Client for the Media Server REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
token: str,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Initialize the client.
|
||||
|
||||
Args:
|
||||
host: Server hostname or IP address
|
||||
port: Server port
|
||||
token: API authentication token
|
||||
session: Optional aiohttp session (will create one if not provided)
|
||||
"""
|
||||
self._host = host
|
||||
self._port = int(port) # Ensure port is an integer
|
||||
self._token = token
|
||||
self._session = session
|
||||
self._own_session = session is None
|
||||
self._base_url = f"http://{host}:{self._port}"
|
||||
|
||||
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
"""Ensure we have an aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
self._own_session = True
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the client session."""
|
||||
if self._own_session and self._session and not self._session.closed:
|
||||
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",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_data: dict | None = None,
|
||||
auth_required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Make an API request.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
endpoint: API endpoint path
|
||||
json_data: Optional JSON body data
|
||||
auth_required: Whether to include authentication header
|
||||
|
||||
Returns:
|
||||
Response data as dictionary
|
||||
|
||||
Raises:
|
||||
MediaServerConnectionError: On connection errors
|
||||
MediaServerAuthError: On authentication errors
|
||||
MediaServerError: On other errors
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
headers = self._get_headers() if auth_required else {}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with session.request(
|
||||
method, url, headers=headers, json=json_data, timeout=timeout
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise MediaServerAuthError("Invalid API token")
|
||||
if response.status == 403:
|
||||
raise MediaServerAuthError("Access forbidden")
|
||||
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
except aiohttp.ClientConnectorError as err:
|
||||
raise MediaServerConnectionError(
|
||||
f"Cannot connect to server at {self._base_url}: {err}"
|
||||
) from err
|
||||
except ClientResponseError as err:
|
||||
raise MediaServerError(f"API error: {err.status} {err.message}") from err
|
||||
except ClientError as err:
|
||||
raise MediaServerConnectionError(f"Connection error: {err}") from err
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Check if the server is reachable and token is valid.
|
||||
|
||||
Returns:
|
||||
True if connection is successful
|
||||
"""
|
||||
try:
|
||||
# First check health (no auth)
|
||||
await self._request("GET", API_HEALTH, auth_required=False)
|
||||
# Then check auth by getting status
|
||||
await self._request("GET", API_STATUS)
|
||||
return True
|
||||
except MediaServerError:
|
||||
return False
|
||||
|
||||
async def get_health(self) -> dict[str, Any]:
|
||||
"""Get server health status (no authentication required).
|
||||
|
||||
Returns:
|
||||
Health status data
|
||||
"""
|
||||
return await self._request("GET", API_HEALTH, auth_required=False)
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get current media playback status.
|
||||
|
||||
Returns:
|
||||
Media status data including state, title, artist, volume, etc.
|
||||
"""
|
||||
data = await self._request("GET", API_STATUS)
|
||||
|
||||
# Convert relative album_art_url to absolute URL with token and cache-buster
|
||||
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}"
|
||||
|
||||
return data
|
||||
|
||||
async def play(self) -> dict[str, Any]:
|
||||
"""Resume or start playback.
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_PLAY)
|
||||
|
||||
async def pause(self) -> dict[str, Any]:
|
||||
"""Pause playback.
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_PAUSE)
|
||||
|
||||
async def stop(self) -> dict[str, Any]:
|
||||
"""Stop playback.
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_STOP)
|
||||
|
||||
async def next_track(self) -> dict[str, Any]:
|
||||
"""Skip to next track.
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_NEXT)
|
||||
|
||||
async def previous_track(self) -> dict[str, Any]:
|
||||
"""Go to previous track.
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_PREVIOUS)
|
||||
|
||||
async def set_volume(self, volume: int) -> dict[str, Any]:
|
||||
"""Set the volume level.
|
||||
|
||||
Args:
|
||||
volume: Volume level (0-100)
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_VOLUME, {"volume": volume})
|
||||
|
||||
async def toggle_mute(self) -> dict[str, Any]:
|
||||
"""Toggle mute state.
|
||||
|
||||
Returns:
|
||||
Response data with new mute state
|
||||
"""
|
||||
return await self._request("POST", API_MUTE)
|
||||
|
||||
async def seek(self, position: float) -> dict[str, Any]:
|
||||
"""Seek to a position in the current track.
|
||||
|
||||
Args:
|
||||
position: Position in seconds
|
||||
|
||||
Returns:
|
||||
Response data
|
||||
"""
|
||||
return await self._request("POST", API_SEEK, {"position": position})
|
||||
|
||||
async def list_scripts(self) -> list[dict[str, Any]]:
|
||||
"""List available scripts on the server.
|
||||
|
||||
Returns:
|
||||
List of scripts with name, description, and timeout
|
||||
"""
|
||||
return await self._request("GET", API_SCRIPTS_LIST)
|
||||
|
||||
async def execute_script(
|
||||
self, script_name: str, args: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a script on the server.
|
||||
|
||||
Args:
|
||||
script_name: Name of the script to execute
|
||||
args: Optional list of arguments to pass to the script
|
||||
|
||||
Returns:
|
||||
Execution result with success, exit_code, stdout, stderr
|
||||
"""
|
||||
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
|
||||
json_data = {"args": args or []}
|
||||
return await self._request("POST", endpoint, json_data)
|
||||
|
||||
|
||||
class MediaServerWebSocket:
|
||||
"""WebSocket client for real-time media status updates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
token: str,
|
||||
on_status_update: Callable[[dict[str, Any]], None],
|
||||
on_disconnect: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the WebSocket client.
|
||||
|
||||
Args:
|
||||
host: Server hostname or IP
|
||||
port: Server port
|
||||
token: API authentication token
|
||||
on_status_update: Callback when status update received
|
||||
on_disconnect: Callback when connection lost
|
||||
"""
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
self._token = token
|
||||
self._on_status_update = on_status_update
|
||||
self._on_disconnect = on_disconnect
|
||||
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._receive_task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish WebSocket connection.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
"""
|
||||
try:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
|
||||
self._ws = await self._session.ws_connect(
|
||||
self._ws_url,
|
||||
heartbeat=30,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
)
|
||||
self._running = True
|
||||
|
||||
# Start receive loop
|
||||
self._receive_task = asyncio.create_task(self._receive_loop())
|
||||
|
||||
_LOGGER.info("WebSocket connected to %s:%s", self._host, self._port)
|
||||
return True
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.warning("WebSocket connection failed: %s", err)
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close WebSocket connection."""
|
||||
self._running = False
|
||||
|
||||
if self._receive_task:
|
||||
self._receive_task.cancel()
|
||||
try:
|
||||
await self._receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._receive_task = None
|
||||
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
_LOGGER.debug("WebSocket disconnected")
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
"""Background loop to receive WebSocket messages."""
|
||||
while self._running and self._ws and not self._ws.closed:
|
||||
try:
|
||||
msg = await self._ws.receive(timeout=60)
|
||||
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
data = msg.json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type in ("status", "status_update"):
|
||||
status_data = data.get("data", {})
|
||||
# Convert album art URL to absolute
|
||||
if (
|
||||
status_data.get("album_art_url")
|
||||
and status_data["album_art_url"].startswith("/")
|
||||
):
|
||||
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
||||
status_data["album_art_url"] = (
|
||||
f"http://{self._host}:{self._port}"
|
||||
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
|
||||
)
|
||||
self._on_status_update(status_data)
|
||||
elif msg_type == "pong":
|
||||
_LOGGER.debug("Received pong")
|
||||
|
||||
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
_LOGGER.warning("WebSocket closed by server")
|
||||
break
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
_LOGGER.error("WebSocket error: %s", self._ws.exception())
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send ping to keep connection alive
|
||||
if self._ws and not self._ws.closed:
|
||||
try:
|
||||
await self._ws.send_json({"type": "ping"})
|
||||
except Exception:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as err:
|
||||
_LOGGER.error("WebSocket receive error: %s", err)
|
||||
break
|
||||
|
||||
# Connection lost, notify callback
|
||||
if self._on_disconnect:
|
||||
self._on_disconnect()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return True if WebSocket is connected."""
|
||||
return self._ws is not None and not self._ws.closed
|
||||
134
haos-integration/custom_components/remote_media_player/button.py
Normal file
134
haos-integration/custom_components/remote_media_player/button.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Button platform for Remote Media Player integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
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 script buttons from a config entry."""
|
||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
||||
|
||||
try:
|
||||
scripts = await client.list_scripts()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to fetch scripts list: %s", err)
|
||||
return
|
||||
|
||||
entities = [
|
||||
ScriptButtonEntity(
|
||||
client=client,
|
||||
entry=entry,
|
||||
script_name=script["name"],
|
||||
script_label=script["label"],
|
||||
script_description=script.get("description", ""),
|
||||
script_icon=script.get("icon"),
|
||||
)
|
||||
for script in scripts
|
||||
]
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
_LOGGER.info("Added %d script button entities", len(entities))
|
||||
|
||||
|
||||
class ScriptButtonEntity(ButtonEntity):
|
||||
"""Button entity for executing a script on the media server."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: MediaServerClient,
|
||||
entry: ConfigEntry,
|
||||
script_name: str,
|
||||
script_label: str,
|
||||
script_description: str,
|
||||
script_icon: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the script button."""
|
||||
self._client = client
|
||||
self._entry = entry
|
||||
self._script_name = script_name
|
||||
self._script_label = script_label
|
||||
self._script_description = script_description
|
||||
|
||||
# Entity attributes
|
||||
self._attr_unique_id = f"{entry.entry_id}_script_{script_name}"
|
||||
self._attr_name = script_label
|
||||
# Use custom icon if provided, otherwise auto-resolve from script name
|
||||
self._attr_icon = script_icon or self._get_icon_for_script(script_name)
|
||||
|
||||
def _get_icon_for_script(self, script_name: str) -> str:
|
||||
"""Get an appropriate icon based on script name."""
|
||||
icon_map = {
|
||||
"lock": "mdi:lock",
|
||||
"unlock": "mdi:lock-open",
|
||||
"shutdown": "mdi:power",
|
||||
"restart": "mdi:restart",
|
||||
"sleep": "mdi:sleep",
|
||||
"hibernate": "mdi:power-sleep",
|
||||
"cancel": "mdi:cancel",
|
||||
}
|
||||
|
||||
script_lower = script_name.lower()
|
||||
for keyword, icon in icon_map.items():
|
||||
if keyword in script_lower:
|
||||
return icon
|
||||
|
||||
return "mdi:script-text"
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
return {
|
||||
"script_name": self._script_name,
|
||||
"description": self._script_description,
|
||||
}
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press - execute the script."""
|
||||
_LOGGER.info("Executing script: %s", self._script_name)
|
||||
try:
|
||||
result = await self._client.execute_script(self._script_name)
|
||||
if result.get("success"):
|
||||
_LOGGER.info(
|
||||
"Script '%s' executed successfully (exit_code=%s)",
|
||||
self._script_name,
|
||||
result.get("exit_code"),
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Script '%s' failed: %s",
|
||||
self._script_name,
|
||||
result.get("stderr") or result.get("error"),
|
||||
)
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to execute script '%s': %s", self._script_name, err)
|
||||
raise
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Config flow for Remote Media Player integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .api_client import (
|
||||
MediaServerClient,
|
||||
MediaServerConnectionError,
|
||||
MediaServerAuthError,
|
||||
)
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_TOKEN,
|
||||
CONF_POLL_INTERVAL,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
DEFAULT_NAME,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
data: User input data
|
||||
|
||||
Returns:
|
||||
Validated data with title
|
||||
|
||||
Raises:
|
||||
CannotConnect: If connection fails
|
||||
InvalidAuth: If authentication fails
|
||||
"""
|
||||
client = MediaServerClient(
|
||||
host=data[CONF_HOST],
|
||||
port=data[CONF_PORT],
|
||||
token=data[CONF_TOKEN],
|
||||
)
|
||||
|
||||
try:
|
||||
health = await client.get_health()
|
||||
# Try authenticated endpoint
|
||||
await client.get_status()
|
||||
except MediaServerConnectionError as err:
|
||||
await client.close()
|
||||
raise CannotConnect(str(err)) from err
|
||||
except MediaServerAuthError as err:
|
||||
await client.close()
|
||||
raise InvalidAuth(str(err)) from err
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
# Return info to store in the config entry
|
||||
return {
|
||||
"title": data.get(CONF_NAME, DEFAULT_NAME),
|
||||
"platform": health.get("platform", "Unknown"),
|
||||
}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Remote Media Player."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step.
|
||||
|
||||
Args:
|
||||
user_input: User provided configuration
|
||||
|
||||
Returns:
|
||||
Flow result
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Check if already configured
|
||||
await self.async_set_unique_id(
|
||||
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=info["title"],
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
# Show configuration form
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
||||
),
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=65535,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_TOKEN): selector.TextSelector(
|
||||
selector.TextSelectorConfig(
|
||||
type=selector.TextSelectorType.PASSWORD
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=60,
|
||||
step=1,
|
||||
unit_of_measurement="seconds",
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Create the options flow.
|
||||
|
||||
Args:
|
||||
config_entry: Config entry
|
||||
|
||||
Returns:
|
||||
Options flow handler
|
||||
"""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for Remote Media Player."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize options flow.
|
||||
|
||||
Args:
|
||||
config_entry: Config entry
|
||||
"""
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options.
|
||||
|
||||
Args:
|
||||
user_input: User provided options
|
||||
|
||||
Returns:
|
||||
Flow result
|
||||
"""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=self._config_entry.options.get(
|
||||
CONF_POLL_INTERVAL,
|
||||
self._config_entry.data.get(
|
||||
CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL
|
||||
),
|
||||
),
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=60,
|
||||
step=1,
|
||||
unit_of_measurement="seconds",
|
||||
mode=selector.NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(Exception):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Constants for the Remote Media Player integration."""
|
||||
|
||||
DOMAIN = "remote_media_player"
|
||||
|
||||
# Configuration keys
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
CONF_TOKEN = "token"
|
||||
CONF_POLL_INTERVAL = "poll_interval"
|
||||
CONF_NAME = "name"
|
||||
CONF_USE_WEBSOCKET = "use_websocket"
|
||||
|
||||
# Default values
|
||||
DEFAULT_PORT = 8765
|
||||
DEFAULT_POLL_INTERVAL = 5
|
||||
DEFAULT_NAME = "Remote Media Player"
|
||||
DEFAULT_USE_WEBSOCKET = True
|
||||
DEFAULT_RECONNECT_INTERVAL = 30
|
||||
|
||||
# API endpoints
|
||||
API_HEALTH = "/api/health"
|
||||
API_STATUS = "/api/media/status"
|
||||
API_PLAY = "/api/media/play"
|
||||
API_PAUSE = "/api/media/pause"
|
||||
API_STOP = "/api/media/stop"
|
||||
API_NEXT = "/api/media/next"
|
||||
API_PREVIOUS = "/api/media/previous"
|
||||
API_VOLUME = "/api/media/volume"
|
||||
API_MUTE = "/api/media/mute"
|
||||
API_SEEK = "/api/media/seek"
|
||||
API_SCRIPTS_LIST = "/api/scripts/list"
|
||||
API_SCRIPTS_EXECUTE = "/api/scripts/execute"
|
||||
API_WEBSOCKET = "/api/media/ws"
|
||||
|
||||
# Service names
|
||||
SERVICE_EXECUTE_SCRIPT = "execute_script"
|
||||
|
||||
# Service attributes
|
||||
ATTR_SCRIPT_NAME = "script_name"
|
||||
ATTR_SCRIPT_ARGS = "args"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "remote_media_player",
|
||||
"name": "Remote Media Player",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/YOUR_USERNAME/haos-remote-media-player",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aiohttp>=3.8.0"],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Media player platform for Remote Media Player integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .api_client import MediaServerClient, MediaServerError, MediaServerWebSocket
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
CONF_POLL_INTERVAL,
|
||||
CONF_USE_WEBSOCKET,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_USE_WEBSOCKET,
|
||||
DEFAULT_RECONNECT_INTERVAL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the media player platform.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry: Config entry
|
||||
async_add_entities: Callback to add entities
|
||||
"""
|
||||
_LOGGER.debug("Setting up media player platform for %s", entry.entry_id)
|
||||
|
||||
try:
|
||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
||||
except KeyError:
|
||||
_LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id)
|
||||
return
|
||||
|
||||
# Get poll interval from options or data
|
||||
poll_interval = entry.options.get(
|
||||
CONF_POLL_INTERVAL,
|
||||
entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL),
|
||||
)
|
||||
|
||||
# Get WebSocket setting from options or data
|
||||
use_websocket = entry.options.get(
|
||||
CONF_USE_WEBSOCKET,
|
||||
entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET),
|
||||
)
|
||||
|
||||
# Create update coordinator with WebSocket support
|
||||
coordinator = MediaPlayerCoordinator(
|
||||
hass,
|
||||
client,
|
||||
poll_interval,
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
token=entry.data[CONF_TOKEN],
|
||||
use_websocket=use_websocket,
|
||||
)
|
||||
|
||||
# Set up WebSocket connection if enabled
|
||||
await coordinator.async_setup()
|
||||
|
||||
# Fetch initial data - don't fail setup if this fails
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Initial data fetch failed, will retry: %s", err)
|
||||
# Continue anyway - the coordinator will retry
|
||||
|
||||
# Store coordinator for cleanup
|
||||
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
||||
|
||||
# Create and add entity
|
||||
entity = RemoteMediaPlayerEntity(
|
||||
coordinator,
|
||||
entry,
|
||||
)
|
||||
_LOGGER.info("Adding media player entity: %s", entity.unique_id)
|
||||
async_add_entities([entity])
|
||||
|
||||
|
||||
class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for fetching media player data with WebSocket support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: MediaServerClient,
|
||||
poll_interval: int,
|
||||
host: str,
|
||||
port: int,
|
||||
token: str,
|
||||
use_websocket: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the coordinator.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
client: Media Server API client
|
||||
poll_interval: Update interval in seconds
|
||||
host: Server hostname
|
||||
port: Server port
|
||||
token: API token
|
||||
use_websocket: Whether to use WebSocket for updates
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Remote Media Player",
|
||||
update_interval=timedelta(seconds=poll_interval),
|
||||
)
|
||||
self.client = client
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._token = token
|
||||
self._use_websocket = use_websocket
|
||||
self._ws_client: MediaServerWebSocket | None = None
|
||||
self._ws_connected = False
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._poll_interval = poll_interval
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the coordinator with WebSocket if enabled."""
|
||||
if self._use_websocket:
|
||||
await self._connect_websocket()
|
||||
|
||||
async def _connect_websocket(self) -> None:
|
||||
"""Establish WebSocket connection."""
|
||||
if self._ws_client:
|
||||
await self._ws_client.disconnect()
|
||||
|
||||
self._ws_client = MediaServerWebSocket(
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
token=self._token,
|
||||
on_status_update=self._handle_ws_status_update,
|
||||
on_disconnect=self._handle_ws_disconnect,
|
||||
)
|
||||
|
||||
if await self._ws_client.connect():
|
||||
self._ws_connected = True
|
||||
# Disable polling - WebSocket handles all updates including position
|
||||
self.update_interval = None
|
||||
_LOGGER.info("WebSocket connected, polling disabled")
|
||||
else:
|
||||
self._ws_connected = False
|
||||
# Keep polling as fallback
|
||||
self.update_interval = timedelta(seconds=self._poll_interval)
|
||||
_LOGGER.warning("WebSocket failed, falling back to polling")
|
||||
# Schedule reconnect attempt
|
||||
self._schedule_reconnect()
|
||||
|
||||
@callback
|
||||
def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None:
|
||||
"""Handle status update from WebSocket."""
|
||||
self.async_set_updated_data(status_data)
|
||||
|
||||
@callback
|
||||
def _handle_ws_disconnect(self) -> None:
|
||||
"""Handle WebSocket disconnection."""
|
||||
self._ws_connected = False
|
||||
# Re-enable polling as fallback
|
||||
self.update_interval = timedelta(seconds=self._poll_interval)
|
||||
_LOGGER.warning("WebSocket disconnected, falling back to polling")
|
||||
# Schedule reconnect attempt
|
||||
self._schedule_reconnect()
|
||||
|
||||
def _schedule_reconnect(self) -> None:
|
||||
"""Schedule a WebSocket reconnection attempt."""
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
return # Already scheduled
|
||||
|
||||
async def reconnect() -> None:
|
||||
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
||||
if self._use_websocket and not self._ws_connected:
|
||||
_LOGGER.info("Attempting WebSocket reconnect...")
|
||||
await self._connect_websocket()
|
||||
|
||||
self._reconnect_task = self.hass.async_create_task(reconnect())
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the API (fallback when WebSocket unavailable).
|
||||
|
||||
Returns:
|
||||
Media status data
|
||||
|
||||
Raises:
|
||||
UpdateFailed: On API errors
|
||||
"""
|
||||
try:
|
||||
data = await self.client.get_status()
|
||||
_LOGGER.debug("HTTP poll received status: %s", data.get("state"))
|
||||
return data
|
||||
except MediaServerError as err:
|
||||
raise UpdateFailed(f"Error communicating with server: {err}") from err
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Unexpected error fetching media status")
|
||||
raise UpdateFailed(f"Unexpected error: {err}") from err
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self._reconnect_task:
|
||||
self._reconnect_task.cancel()
|
||||
try:
|
||||
await self._reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._ws_client:
|
||||
await self._ws_client.disconnect()
|
||||
|
||||
|
||||
class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity):
|
||||
"""Representation of a Remote Media Player."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
# Use the coordinator's last_update_success to detect server availability
|
||||
return self.coordinator.last_update_success
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MediaPlayerCoordinator,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the media player entity.
|
||||
|
||||
Args:
|
||||
coordinator: Data update coordinator
|
||||
entry: Config entry
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_media_player"
|
||||
|
||||
# Device info - must match button.py identifiers
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
manufacturer="Remote Media Player",
|
||||
model="Media Server",
|
||||
sw_version="1.0.0",
|
||||
configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs",
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return the supported features."""
|
||||
return (
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the state of the player."""
|
||||
if self.coordinator.data is None:
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
state = self.coordinator.data.get("state", "idle")
|
||||
state_map = {
|
||||
"playing": MediaPlayerState.PLAYING,
|
||||
"paused": MediaPlayerState.PAUSED,
|
||||
"stopped": MediaPlayerState.IDLE,
|
||||
"idle": MediaPlayerState.IDLE,
|
||||
}
|
||||
return state_map.get(state, MediaPlayerState.IDLE)
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the volume level (0..1)."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
volume = self.coordinator.data.get("volume", 0)
|
||||
return volume / 100.0
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if volume is muted."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get("muted", False)
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Return the content type of current playing media."""
|
||||
return MediaType.MUSIC
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Return the title of current playing media."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get("title")
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Return the artist of current playing media."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get("artist")
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Return the album name of current playing media."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get("album")
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return the image URL of current playing media."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get("album_art_url")
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Return the duration of current playing media in seconds."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
duration = self.coordinator.data.get("duration")
|
||||
return int(duration) if duration is not None else None
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Return the position of current playing media in seconds."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
position = self.coordinator.data.get("position")
|
||||
return int(position) if position is not None else None
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""Return when the position was last updated."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
if self.coordinator.data.get("position") is not None:
|
||||
# Use last_update_success_time if available, otherwise use current time
|
||||
if hasattr(self.coordinator, 'last_update_success_time'):
|
||||
return self.coordinator.last_update_success_time
|
||||
return datetime.now()
|
||||
return None
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""Return the current media source."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get("source")
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
try:
|
||||
await self.coordinator.client.play()
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to play: %s", err)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
try:
|
||||
await self.coordinator.client.pause()
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to pause: %s", err)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
try:
|
||||
await self.coordinator.client.stop()
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to stop: %s", err)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
try:
|
||||
await self.coordinator.client.next_track()
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to skip to next track: %s", err)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
try:
|
||||
await self.coordinator.client.previous_track()
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to go to previous track: %s", err)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
try:
|
||||
await self.coordinator.client.set_volume(int(volume * 100))
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to set volume: %s", err)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute/unmute the volume."""
|
||||
try:
|
||||
# Toggle mute (API toggles, so call it if state differs)
|
||||
if self.is_volume_muted != mute:
|
||||
await self.coordinator.client.toggle_mute()
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to toggle mute: %s", err)
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to a position."""
|
||||
try:
|
||||
await self.coordinator.client.seek(position)
|
||||
await self.coordinator.async_request_refresh()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to seek: %s", err)
|
||||
@@ -0,0 +1,18 @@
|
||||
execute_script:
|
||||
name: Execute Script
|
||||
description: Execute a pre-defined script on the media server
|
||||
fields:
|
||||
script_name:
|
||||
name: Script Name
|
||||
description: Name of the script to execute (as defined in server config)
|
||||
required: true
|
||||
example: "launch_spotify"
|
||||
selector:
|
||||
text:
|
||||
args:
|
||||
name: Arguments
|
||||
description: Optional list of arguments to pass to the script
|
||||
required: false
|
||||
example: '["arg1", "arg2"]'
|
||||
selector:
|
||||
object:
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Media Server",
|
||||
"description": "Enter the connection details for your Media Server.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"token": "API Token",
|
||||
"name": "Name",
|
||||
"poll_interval": "Poll Interval"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of the Media Server",
|
||||
"port": "Port number (default: 8765)",
|
||||
"token": "API authentication token from the server configuration",
|
||||
"name": "Display name for this media player",
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the Media Server. Please check the host and port.",
|
||||
"invalid_auth": "Invalid API token. Please check your token.",
|
||||
"unknown": "An unexpected error occurred."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Media Server is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Options",
|
||||
"data": {
|
||||
"poll_interval": "Poll Interval"
|
||||
},
|
||||
"data_description": {
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"execute_script": {
|
||||
"name": "Execute Script",
|
||||
"description": "Execute a pre-defined script on the media server.",
|
||||
"fields": {
|
||||
"script_name": {
|
||||
"name": "Script Name",
|
||||
"description": "Name of the script to execute (as defined in server config)"
|
||||
},
|
||||
"args": {
|
||||
"name": "Arguments",
|
||||
"description": "Optional list of arguments to pass to the script"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Media Server",
|
||||
"description": "Enter the connection details for your Media Server.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"token": "API Token",
|
||||
"name": "Name",
|
||||
"poll_interval": "Poll Interval"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of the Media Server",
|
||||
"port": "Port number (default: 8765)",
|
||||
"token": "API authentication token from the server configuration",
|
||||
"name": "Display name for this media player",
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the Media Server. Please check the host and port.",
|
||||
"invalid_auth": "Invalid API token. Please check your token.",
|
||||
"unknown": "An unexpected error occurred."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Media Server is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Options",
|
||||
"data": {
|
||||
"poll_interval": "Poll Interval"
|
||||
},
|
||||
"data_description": {
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"execute_script": {
|
||||
"name": "Execute Script",
|
||||
"description": "Execute a pre-defined script on the media server.",
|
||||
"fields": {
|
||||
"script_name": {
|
||||
"name": "Script Name",
|
||||
"description": "Name of the script to execute (as defined in server config)"
|
||||
},
|
||||
"args": {
|
||||
"name": "Arguments",
|
||||
"description": "Optional list of arguments to pass to the script"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Подключение к Media Server",
|
||||
"description": "Введите данные для подключения к Media Server.",
|
||||
"data": {
|
||||
"host": "Хост",
|
||||
"port": "Порт",
|
||||
"token": "API токен",
|
||||
"name": "Название",
|
||||
"poll_interval": "Интервал опроса"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Имя хоста или IP-адрес Media Server",
|
||||
"port": "Номер порта (по умолчанию: 8765)",
|
||||
"token": "Токен аутентификации из конфигурации сервера",
|
||||
"name": "Отображаемое имя медиаплеера",
|
||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Не удалось подключиться к Media Server. Проверьте хост и порт.",
|
||||
"invalid_auth": "Неверный API токен. Проверьте токен.",
|
||||
"unknown": "Произошла непредвиденная ошибка."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Этот Media Server уже настроен."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Настройки",
|
||||
"data": {
|
||||
"poll_interval": "Интервал опроса"
|
||||
},
|
||||
"data_description": {
|
||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"execute_script": {
|
||||
"name": "Выполнить скрипт",
|
||||
"description": "Выполнить предопределённый скрипт на медиасервере.",
|
||||
"fields": {
|
||||
"script_name": {
|
||||
"name": "Имя скрипта",
|
||||
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
|
||||
},
|
||||
"args": {
|
||||
"name": "Аргументы",
|
||||
"description": "Необязательный список аргументов для передачи скрипту"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user