diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 52f146d..95cb943 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,28 +1,19 @@
-## v0.3.1 (2026-05-18)
+## v0.3.2 (2026-05-18)
### Features
-- **Foreground process sensors** — new "Foreground" device (linked to the hub via `via_device`) exposing what's currently in focus on the remote PC:
- - `sensor.foreground_process` — process name as state, with full payload (PID, exec path, window title, fullscreen flag, monitor, geometry, browser detection, browser page title/URL, error) as attributes
- - `sensor.window_title`, `sensor.pid`, `sensor.foreground_monitor`, `sensor.process_started` (TIMESTAMP device class)
- - `binary_sensor.fullscreen`, `binary_sensor.minimized`
- - Fed by a new `ForegroundCoordinator` polling `GET /api/foreground` every 5s, with near-real-time updates via the existing WebSocket (`foreground` / `foreground_update` push frames flow into the coordinator) ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727))
-- **Optional API token** — `CONF_TOKEN` is now optional. When the media server runs without `api_tokens` configured (auth disabled), the integration omits the `Authorization` header and `?token=` query from REST, WebSocket, and album-art URLs. Server-side auth rejections still surface as `invalid_auth` in the UI ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
+- **Targeted service calls** — `remote_media_player.execute_script` and `remote_media_player.play_media_file` now accept Home Assistant's standard `target:` block (`device_id`, `entity_id`, `area_id`). Calls without a target keep the legacy fan-out behavior and run on every configured hub, so existing automations continue to work. Targets are resolved against the device/entity registries and filtered to Remote Media Player hubs only; unmatched targets log a warning and are skipped.
+ - `services.yaml` declares `target:` with `integration: remote_media_player`, and the voluptuous schemas accept the `device_id` / `entity_id` / `area_id` keys HA injects.
+- **`execute_script` parameter rename** — the script payload field is now `params:` (a named dict, validated against the server-side script schema) instead of the previous `args:` list. **Breaking** for automations that still use `args:`; update them to `params:` with the named keys your script expects.
-### Performance
-- **Shared `DisplayCoordinator`** — a single `/api/display/monitors` poll per cycle is now fanned out to all per-display entities (binary sensors, numbers, selects, sensors, switches) via `CoordinatorEntity`. Removes ~9x redundant requests per polling cycle that previously came from each entity calling `get_display_monitors()` in its own `async_update` ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
-- **Optimistic write-through** — `coordinator.apply_optimistic(...)` keeps sibling entities in sync after slider/select writes without an extra network round-trip ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
+### Bug Fixes
+- **Compatibility with new browser-folders response shape** — `MediaServerClient.list_browser_folders()` now unwraps the new server response (`{"folders": {...}, "management_enabled": bool}`) introduced after server commit `c9ee41a`, while still accepting the older flat dict. Restores folder listing on freshly updated media servers.
### UI / Localization
-- Display entities migrated to Home Assistant **translation keys** (`strings.json` / `translations/*`), so per-language UI text flows through the standard locale mechanism instead of hardcoded English strings ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727))
+- Updated English and Russian translations + `strings.json` for the new `target:`-aware service descriptions and the `params` field rename. Service descriptions now explain the "no target = all hubs" behavior.
+
+### Documentation
+- README "Execute Script Service" section rewritten to document the `target:` block, the `params:` payload, and the destructive-script safety note.
---
-
-All Commits
-
-| Hash | Message | Author |
-|------|---------|--------|
-| [9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727) | feat(foreground): foreground process sensors + translation key migration | alexei.dolgolyov |
-| [ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852) | feat: shared DisplayCoordinator + optional API token | alexei.dolgolyov |
-
-
+All changes above are bundled in the single release commit tagged [v0.3.2](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/src/tag/v0.3.2).
diff --git a/custom_components/remote_media_player/README.md b/custom_components/remote_media_player/README.md
index 6a433a5..ef0cd50 100644
--- a/custom_components/remote_media_player/README.md
+++ b/custom_components/remote_media_player/README.md
@@ -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:
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
diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py
index 8579416..e4d8a0b 100644
--- a/custom_components/remote_media_player/__init__.py
+++ b/custom_components/remote_media_player/__init__.py
@@ -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)
diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py
index d61062a..d2b1236 100644
--- a/custom_components/remote_media_player/api_client.py
+++ b/custom_components/remote_media_player/api_client.py
@@ -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
diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json
index c20d952..6022bcc 100644
--- a/custom_components/remote_media_player/manifest.json
+++ b/custom_components/remote_media_player/manifest.json
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
- "version": "0.3.1"
+ "version": "0.3.2"
}
diff --git a/custom_components/remote_media_player/services.yaml b/custom_components/remote_media_player/services.yaml
index fbd0dd0..dd9edf9 100644
--- a/custom_components/remote_media_player/services.yaml
+++ b/custom_components/remote_media_player/services.yaml
@@ -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:
diff --git a/custom_components/remote_media_player/strings.json b/custom_components/remote_media_player/strings.json
index 9b40c1d..58ff1de 100644
--- a/custom_components/remote_media_player/strings.json
+++ b/custom_components/remote_media_player/strings.json
@@ -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"
}
}
}
diff --git a/custom_components/remote_media_player/translations/en.json b/custom_components/remote_media_player/translations/en.json
index 9b40c1d..58ff1de 100644
--- a/custom_components/remote_media_player/translations/en.json
+++ b/custom_components/remote_media_player/translations/en.json
@@ -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"
}
}
}
diff --git a/custom_components/remote_media_player/translations/ru.json b/custom_components/remote_media_player/translations/ru.json
index f9f3d9d..5460efe 100644
--- a/custom_components/remote_media_player/translations/ru.json
+++ b/custom_components/remote_media_player/translations/ru.json
@@ -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": "Абсолютный путь к медиафайлу на целевом хабе"
}
}
}