chore: release v0.3.2
Release / release (push) Successful in 3s

This commit is contained in:
2026-05-18 13:13:15 +03:00
parent b92b69b0e8
commit 8e8acccbb2
9 changed files with 201 additions and 48 deletions
@@ -207,17 +207,28 @@ automation:
### Execute Script Service
You can also execute scripts with arguments using the service:
Run a pre-defined server script. Use the `target:` block to scope the call to a
specific hub (or area / entity); omit it to fan out to **all** configured hubs.
```yaml
# Run on a single hub
service: remote_media_player.execute_script
target:
device_id: <device id of the hub>
data:
script_name: "echo_test"
args:
- "arg1"
- "arg2"
params:
message: "hello"
# Run on all hubs (legacy fan-out)
service: remote_media_player.execute_script
data:
script_name: "shutdown"
```
> Without a target, the service runs on every configured Remote Media Player.
> For destructive scripts (shutdown, reboot, lock) always pin a target.
## Lovelace Card Examples
### Basic Media Control Card
@@ -8,9 +8,11 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from .api_client import MediaServerClient, MediaServerError
from .const import (
@@ -39,11 +41,20 @@ PLATFORMS: list[Platform] = [
Platform.SELECT,
]
# Target-selector fields injected by HA when `target:` is declared in services.yaml.
# Listed explicitly so the voluptuous schema does not strip them.
_TARGET_FIELDS = {
vol.Optional(ATTR_DEVICE_ID): vol.Any(cv.string, [cv.string]),
vol.Optional(ATTR_ENTITY_ID): vol.Any(cv.string, [cv.string]),
vol.Optional(ATTR_AREA_ID): vol.Any(cv.string, [cv.string]),
}
# Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_SCRIPT_NAME): cv.string,
vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict,
**_TARGET_FIELDS,
}
)
@@ -51,10 +62,76 @@ SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FILE_PATH): cv.string,
**_TARGET_FIELDS,
}
)
def _as_list(value: Any) -> list[str]:
"""Normalize a target field to a list of IDs (HA passes str or list)."""
if value is None:
return []
if isinstance(value, str):
return [value]
return list(value)
def _resolve_entry_ids(hass: HomeAssistant, call: ServiceCall) -> list[str]:
"""Resolve target selectors in a ServiceCall to config entry IDs.
Returns the entry IDs of Remote Media Player hubs that match the target.
If no target is provided, returns all configured entries (legacy fan-out).
Targets that don't resolve to any of our entries are skipped with a warning.
"""
device_ids = set(_as_list(call.data.get(ATTR_DEVICE_ID)))
entity_ids = set(_as_list(call.data.get(ATTR_ENTITY_ID)))
area_ids = set(_as_list(call.data.get(ATTR_AREA_ID)))
domain_entries: set[str] = set(hass.data.get(DOMAIN, {}).keys())
if not (device_ids or entity_ids or area_ids):
return list(domain_entries)
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
# Expand area_id -> device_ids (devices located in that area).
if area_ids:
for device in dev_reg.devices.values():
if device.area_id in area_ids:
device_ids.add(device.id)
matched: set[str] = set()
for device_id in device_ids:
device = dev_reg.async_get(device_id)
if device is None:
continue
for entry_id in device.config_entries:
if entry_id in domain_entries:
matched.add(entry_id)
for entity_id in entity_ids:
entity = ent_reg.async_get(entity_id)
if entity is None or entity.config_entry_id is None:
continue
if entity.config_entry_id in domain_entries:
matched.add(entity.config_entry_id)
if not matched:
_LOGGER.warning(
"Service call targeted device(s)/entity(ies)/area(s) %s but no "
"Remote Media Player hubs matched — nothing will be executed",
{
"device_id": sorted(device_ids),
"entity_id": sorted(entity_ids),
"area_id": sorted(area_ids),
},
)
return list(matched)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remote Media Player from a config entry.
@@ -108,17 +185,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 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."""
"""Execute a script on the targeted media server hubs."""
script_name = call.data[ATTR_SCRIPT_NAME]
script_params = call.data.get(ATTR_SCRIPT_PARAMS, {})
target_entries = _resolve_entry_ids(hass, call)
_LOGGER.debug(
"Executing script '%s' with params: %s", script_name, script_params
"Executing script '%s' with params %s on entries: %s",
script_name,
script_params,
target_entries,
)
# Get all clients and execute on all of them
results = {}
for entry_id, data in hass.data[DOMAIN].items():
results: dict[str, Any] = {}
for entry_id in target_entries:
data = hass.data[DOMAIN].get(entry_id)
if data is None:
continue
client: MediaServerClient = data["client"]
try:
result = await client.execute_script(script_name, script_params)
@@ -152,10 +235,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)
target_entries = _resolve_entry_ids(hass, call)
_LOGGER.debug(
"Service play_media_file called with path '%s' on entries: %s",
file_path,
target_entries,
)
# Execute on all configured media server instances
for entry_id, data in hass.data[DOMAIN].items():
for entry_id in target_entries:
data = hass.data[DOMAIN].get(entry_id)
if data is None:
continue
client: MediaServerClient = data["client"]
try:
await client.play_media_file(file_path)
@@ -324,7 +324,12 @@ class MediaServerClient:
Returns:
Dictionary of folders with folder_id as key and folder config as value
"""
return await self._request("GET", API_BROWSER_FOLDERS)
response = await self._request("GET", API_BROWSER_FOLDERS)
# Server >= c9ee41a wraps the result as {"folders": {...}, "management_enabled": bool}.
# Older servers returned the flat folder dict directly.
if isinstance(response, dict) and "folders" in response and isinstance(response["folders"], dict):
return response["folders"]
return response
async def browse_folder(
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
"version": "0.3.1"
"version": "0.3.2"
}
@@ -1,6 +1,13 @@
execute_script:
name: Execute Script
description: Execute a pre-defined script on the media server
description: >-
Execute a pre-defined script on one or more Remote Media Player hubs.
If no target is selected, the script runs on ALL configured hubs.
target:
device:
integration: remote_media_player
entity:
integration: remote_media_player
fields:
script_name:
name: Script Name
@@ -16,3 +23,22 @@ execute_script:
example: '{"level": 75, "monitor": "primary"}'
selector:
object:
play_media_file:
name: Play Media File
description: >-
Start playback of a local media file on one or more Remote Media Player hubs.
If no target is selected, playback starts on ALL configured hubs.
target:
device:
integration: remote_media_player
entity:
integration: remote_media_player
fields:
file_path:
name: File Path
description: Absolute path to the media file on the target hub
required: true
example: "C:/Media/movie.mp4"
selector:
text:
@@ -73,15 +73,25 @@
"services": {
"execute_script": {
"name": "Execute Script",
"description": "Execute a pre-defined script on the media server.",
"description": "Execute a pre-defined script on one or more Remote Media Player hubs. If no target is selected, the script runs on all configured hubs.",
"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"
"params": {
"name": "Parameters",
"description": "Optional named parameters to pass to the script (validated against script schema)"
}
}
},
"play_media_file": {
"name": "Play Media File",
"description": "Start playback of a local media file on one or more Remote Media Player hubs. If no target is selected, playback starts on all configured hubs.",
"fields": {
"file_path": {
"name": "File Path",
"description": "Absolute path to the media file on the target hub"
}
}
}
@@ -73,15 +73,25 @@
"services": {
"execute_script": {
"name": "Execute Script",
"description": "Execute a pre-defined script on the media server.",
"description": "Execute a pre-defined script on one or more Remote Media Player hubs. If no target is selected, the script runs on all configured hubs.",
"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"
"params": {
"name": "Parameters",
"description": "Optional named parameters to pass to the script (validated against script schema)"
}
}
},
"play_media_file": {
"name": "Play Media File",
"description": "Start playback of a local media file on one or more Remote Media Player hubs. If no target is selected, playback starts on all configured hubs.",
"fields": {
"file_path": {
"name": "File Path",
"description": "Absolute path to the media file on the target hub"
}
}
}
@@ -73,15 +73,25 @@
"services": {
"execute_script": {
"name": "Выполнить скрипт",
"description": "Выполнить предопределённый скрипт на медиасервере.",
"description": "Выполнить предопределённый скрипт на одном или нескольких хабах Remote Media Player. Если цель не выбрана, скрипт выполнится на всех настроенных хабах.",
"fields": {
"script_name": {
"name": "Имя скрипта",
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
},
"args": {
"name": "Аргументы",
"description": "Необязательный список аргументов для передачи скрипту"
"params": {
"name": "Параметры",
"description": "Необязательные именованные параметры для скрипта (проверяются по схеме скрипта)"
}
}
},
"play_media_file": {
"name": "Воспроизвести медиафайл",
"description": "Запустить воспроизведение локального медиафайла на одном или нескольких хабах Remote Media Player. Если цель не выбрана, воспроизведение запустится на всех настроенных хабах.",
"fields": {
"file_path": {
"name": "Путь к файлу",
"description": "Абсолютный путь к медиафайлу на целевом хабе"
}
}
}