Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c1784ad4 | |||
| 8e8acccbb2 | |||
| b92b69b0e8 | |||
| 9d277276b8 | |||
| ab0585278c | |||
| 68e338de4e | |||
| 4156dedf5e | |||
| b0d98a9d45 | |||
| d0d4958843 | |||
| de4b7cf9b4 |
@@ -103,6 +103,48 @@ Button entities for each script defined on your Media Server:
|
|||||||
- Shutdown, restart, sleep, hibernate
|
- Shutdown, restart, sleep, hibernate
|
||||||
- Custom scripts
|
- Custom scripts
|
||||||
|
|
||||||
|
### Execute Script Service
|
||||||
|
|
||||||
|
Call `remote_media_player.execute_script` to run any server-defined script with typed parameters:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: remote_media_player.execute_script
|
||||||
|
data:
|
||||||
|
script_name: set_brightness
|
||||||
|
params:
|
||||||
|
level: 75
|
||||||
|
monitor: primary
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters are validated against the script's schema on the server. Scripts define their parameters in `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scripts:
|
||||||
|
set_brightness:
|
||||||
|
command: "python set_brightness.py"
|
||||||
|
label: "Set Brightness"
|
||||||
|
icon: "mdi:brightness-6"
|
||||||
|
timeout: 10
|
||||||
|
parameters:
|
||||||
|
level:
|
||||||
|
type: integer
|
||||||
|
required: true
|
||||||
|
min: 0
|
||||||
|
max: 100
|
||||||
|
description: "Brightness level (0-100)"
|
||||||
|
monitor:
|
||||||
|
type: select
|
||||||
|
options: ["primary", "secondary", "all"]
|
||||||
|
default: "primary"
|
||||||
|
description: "Target monitor"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported parameter types: `string`, `integer`, `float`, `boolean`, `select`.
|
||||||
|
|
||||||
|
Parameters are passed to scripts as environment variables prefixed with `SCRIPT_PARAM_` (e.g., `SCRIPT_PARAM_LEVEL=75`, `SCRIPT_PARAM_MONITOR=primary`).
|
||||||
|
|
||||||
|
Scripts without parameters work as before — just omit `params`.
|
||||||
|
|
||||||
## Example Lovelace Card
|
## Example Lovelace Card
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+11
-45
@@ -1,53 +1,19 @@
|
|||||||
## v0.1.0 (2026-03-26)
|
## v0.3.2 (2026-05-18)
|
||||||
|
|
||||||
Initial release of the Remote Media Player custom integration for Home Assistant.
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- HACS-ready Home Assistant custom integration for controlling remote PC media playback ([7837714](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/7837714))
|
- **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.
|
||||||
- Add turn on / turn off / toggle support for the media player entity ([e66f2f3](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e66f2f3))
|
- `services.yaml` declares `target:` with `integration: remote_media_player`, and the voluptuous schemas accept the `device_id` / `entity_id` / `area_id` keys HA injects.
|
||||||
- Add automatic script reload support ([e4eeb2a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e4eeb2a))
|
- **`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.
|
||||||
- Add media browser integration for Home Assistant ([8cbe33e](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/8cbe33e))
|
|
||||||
- Add display monitor brightness and power control entities ([83153db](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/83153db))
|
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- Fix entity not becoming unavailable on server shutdown ([02bdcc5](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/02bdcc5))
|
- **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.
|
||||||
|
|
||||||
### Performance
|
### UI / Localization
|
||||||
- Reduce WebSocket reconnect interval to 5 seconds ([959c6a4](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/959c6a4))
|
- 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.
|
||||||
- Codebase audit fixes: stability and performance ([a37eb46](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/a37eb46))
|
|
||||||
|
### Documentation
|
||||||
|
- README "Execute Script Service" section rewritten to document the `target:` block, the `params:` payload, and the destructive-script safety note.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
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).
|
||||||
|
|
||||||
#### Documentation
|
|
||||||
- Update README with valid repository URLs ([f2b618a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/f2b618a))
|
|
||||||
- Replace GitHub URLs with git.dolgolyov-family.by ([b3624e6](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b3624e6))
|
|
||||||
- Update CLAUDE.md with git push rules, versioning rules, and commit approval rules ([725fc02](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/725fc02), [b13aa86](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b13aa86), [3798833](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/3798833))
|
|
||||||
|
|
||||||
#### CI/Build
|
|
||||||
- Add Gitea release workflow ([6c56576](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/6c56576))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>All Commits</summary>
|
|
||||||
|
|
||||||
| Hash | Message | Author |
|
|
||||||
|------|---------|--------|
|
|
||||||
| [7837714](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/7837714) | Initial commit: HACS-ready Home Assistant integration | alexei.dolgolyov |
|
|
||||||
| [f2b618a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/f2b618a) | Update README with valid GitHub repository URLs | alexei.dolgolyov |
|
|
||||||
| [725fc02](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/725fc02) | Update CLAUDE.md with git push rules and repo link | alexei.dolgolyov |
|
|
||||||
| [b3624e6](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b3624e6) | Replace GitHub URLs with git.dolgolyov-family.by | alexei.dolgolyov |
|
|
||||||
| [b13aa86](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b13aa86) | Add versioning rules to CLAUDE.md | alexei.dolgolyov |
|
|
||||||
| [3798833](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/3798833) | Update CLAUDE.md with commit/push approval rules | alexei.dolgolyov |
|
|
||||||
| [e66f2f3](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e66f2f3) | Add turn_on/turn_off/toggle support | alexei.dolgolyov |
|
|
||||||
| [959c6a4](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/959c6a4) | Reduce WebSocket reconnect interval to 5 seconds | alexei.dolgolyov |
|
|
||||||
| [e4eeb2a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e4eeb2a) | Add automatic script reload support | alexei.dolgolyov |
|
|
||||||
| [8cbe33e](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/8cbe33e) | Add media browser integration for Home Assistant | alexei.dolgolyov |
|
|
||||||
| [02bdcc5](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/02bdcc5) | Fix entity not becoming unavailable on server shutdown | alexei.dolgolyov |
|
|
||||||
| [83153db](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/83153db) | Add display monitor brightness and power control entities | alexei.dolgolyov |
|
|
||||||
| [a37eb46](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/a37eb46) | Codebase audit fixes: stability and performance | alexei.dolgolyov |
|
|
||||||
| [6c56576](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/6c56576) | ci: add Gitea release workflow | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|||||||
@@ -207,17 +207,28 @@ automation:
|
|||||||
|
|
||||||
### Execute Script Service
|
### 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
|
```yaml
|
||||||
|
# Run on a single hub
|
||||||
service: remote_media_player.execute_script
|
service: remote_media_player.execute_script
|
||||||
|
target:
|
||||||
|
device_id: <device id of the hub>
|
||||||
data:
|
data:
|
||||||
script_name: "echo_test"
|
script_name: "echo_test"
|
||||||
args:
|
params:
|
||||||
- "arg1"
|
message: "hello"
|
||||||
- "arg2"
|
|
||||||
|
# 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
|
## Lovelace Card Examples
|
||||||
|
|
||||||
### Basic Media Control Card
|
### Basic Media Control Card
|
||||||
|
|||||||
@@ -8,34 +8,57 @@ from typing import Any
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.helpers import config_validation as cv
|
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 .api_client import MediaServerClient, MediaServerError
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_FILE_PATH,
|
ATTR_FILE_PATH,
|
||||||
ATTR_SCRIPT_ARGS,
|
|
||||||
ATTR_SCRIPT_NAME,
|
ATTR_SCRIPT_NAME,
|
||||||
|
ATTR_SCRIPT_PARAMS,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_TOKEN,
|
CONF_TOKEN,
|
||||||
|
CONF_USE_SSL,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
DEFAULT_USE_SSL,
|
||||||
|
DEFAULT_VERIFY_SSL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_EXECUTE_SCRIPT,
|
SERVICE_EXECUTE_SCRIPT,
|
||||||
SERVICE_PLAY_MEDIA_FILE,
|
SERVICE_PLAY_MEDIA_FILE,
|
||||||
)
|
)
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
from .foreground_coordinator import ForegroundCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON, Platform.NUMBER, Platform.SWITCH]
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.MEDIA_PLAYER,
|
||||||
|
Platform.BUTTON,
|
||||||
|
Platform.NUMBER,
|
||||||
|
Platform.SWITCH,
|
||||||
|
Platform.SENSOR,
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
|
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 schema for execute_script
|
||||||
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
|
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_SCRIPT_NAME): cv.string,
|
vol.Required(ATTR_SCRIPT_NAME): cv.string,
|
||||||
vol.Optional(ATTR_SCRIPT_ARGS, default=[]): vol.All(
|
vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict,
|
||||||
cv.ensure_list, [cv.string]
|
**_TARGET_FIELDS,
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,10 +66,76 @@ SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
|
|||||||
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
|
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_FILE_PATH): cv.string,
|
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:
|
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.
|
||||||
|
|
||||||
@@ -59,11 +148,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""
|
"""
|
||||||
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
|
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
|
||||||
|
|
||||||
# Create API client
|
# Create API client. ``use_ssl`` / ``verify_ssl`` were added in v0.3.3;
|
||||||
|
# ``.get(..., default)`` keeps pre-existing config entries (which lack the
|
||||||
|
# keys entirely) working at the old http+verify defaults.
|
||||||
client = MediaServerClient(
|
client = MediaServerClient(
|
||||||
host=entry.data[CONF_HOST],
|
host=entry.data[CONF_HOST],
|
||||||
port=entry.data[CONF_PORT],
|
port=entry.data[CONF_PORT],
|
||||||
token=entry.data[CONF_TOKEN],
|
token=entry.data[CONF_TOKEN],
|
||||||
|
use_ssl=entry.data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
|
||||||
|
verify_ssl=entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify connection
|
# Verify connection
|
||||||
@@ -72,29 +165,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await client.close()
|
await client.close()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Create the shared display coordinator BEFORE platform setup so each
|
||||||
|
# display platform's async_setup_entry can register against the same
|
||||||
|
# data source instead of polling /api/display/monitors on its own.
|
||||||
|
display_coordinator = DisplayCoordinator(hass, client)
|
||||||
|
try:
|
||||||
|
await display_coordinator.async_config_entry_first_refresh()
|
||||||
|
except Exception as err: # noqa: BLE001 - first refresh wraps its own errors
|
||||||
|
_LOGGER.warning("Initial display monitor fetch failed, will retry: %s", err)
|
||||||
|
|
||||||
|
# Foreground coordinator — shared by sensor + binary_sensor platforms and
|
||||||
|
# nudged by the media-player WebSocket receiver when it gets a push.
|
||||||
|
foreground_coordinator = ForegroundCoordinator(hass, client)
|
||||||
|
try:
|
||||||
|
await foreground_coordinator.async_config_entry_first_refresh()
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.warning("Initial foreground fetch failed, will retry: %s", err)
|
||||||
|
|
||||||
# Store client in hass.data
|
# Store client in hass.data
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
"client": client,
|
"client": client,
|
||||||
|
"display_coordinator": display_coordinator,
|
||||||
|
"foreground_coordinator": foreground_coordinator,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register services if not already registered
|
# Register services if not already registered
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
|
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
|
||||||
async def async_execute_script(call: ServiceCall) -> dict[str, Any]:
|
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_name = call.data[ATTR_SCRIPT_NAME]
|
||||||
script_args = call.data.get(ATTR_SCRIPT_ARGS, [])
|
script_params = call.data.get(ATTR_SCRIPT_PARAMS, {})
|
||||||
|
target_entries = _resolve_entry_ids(hass, call)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Executing script '%s' with args: %s", script_name, script_args
|
"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: dict[str, Any] = {}
|
||||||
results = {}
|
for entry_id in target_entries:
|
||||||
for entry_id, data in hass.data[DOMAIN].items():
|
data = hass.data[DOMAIN].get(entry_id)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
client: MediaServerClient = data["client"]
|
client: MediaServerClient = data["client"]
|
||||||
try:
|
try:
|
||||||
result = await client.execute_script(script_name, script_args)
|
result = await client.execute_script(script_name, script_params)
|
||||||
results[entry_id] = result
|
results[entry_id] = result
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Script '%s' executed on %s: success=%s",
|
"Script '%s' executed on %s: success=%s",
|
||||||
@@ -125,10 +243,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def async_play_media_file(call: ServiceCall) -> None:
|
async def async_play_media_file(call: ServiceCall) -> None:
|
||||||
"""Handle play_media_file service call."""
|
"""Handle play_media_file service call."""
|
||||||
file_path = call.data[ATTR_FILE_PATH]
|
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 in target_entries:
|
||||||
for entry_id, data in hass.data[DOMAIN].items():
|
data = hass.data[DOMAIN].get(entry_id)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
client: MediaServerClient = data["client"]
|
client: MediaServerClient = data["client"]
|
||||||
try:
|
try:
|
||||||
await client.play_media_file(file_path)
|
await client.play_media_file(file_path)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -31,8 +32,13 @@ from .const import (
|
|||||||
API_BROWSER_BROWSE,
|
API_BROWSER_BROWSE,
|
||||||
API_BROWSER_PLAY,
|
API_BROWSER_PLAY,
|
||||||
API_DISPLAY_MONITORS,
|
API_DISPLAY_MONITORS,
|
||||||
|
API_FOREGROUND,
|
||||||
API_DISPLAY_BRIGHTNESS,
|
API_DISPLAY_BRIGHTNESS,
|
||||||
API_DISPLAY_POWER,
|
API_DISPLAY_POWER,
|
||||||
|
API_DISPLAY_CONTRAST,
|
||||||
|
API_DISPLAY_INPUT_SOURCE,
|
||||||
|
API_DISPLAY_COLOR_PRESET,
|
||||||
|
API_DISPLAY_PICTURE_MODE,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -50,6 +56,18 @@ class MediaServerAuthError(MediaServerError):
|
|||||||
"""Exception for authentication errors."""
|
"""Exception for authentication errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class MediaServerRateLimitError(MediaServerError):
|
||||||
|
"""Raised when the server replies with HTTP 429.
|
||||||
|
|
||||||
|
The media server's in-process token-bucket limiter (v0.3.0+) returns 429
|
||||||
|
with a ``Retry-After`` header — capture it so callers can back off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, retry_after: float | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
class MediaServerClient:
|
class MediaServerClient:
|
||||||
"""Client for the Media Server REST API."""
|
"""Client for the Media Server REST API."""
|
||||||
|
|
||||||
@@ -59,6 +77,8 @@ class MediaServerClient:
|
|||||||
port: int,
|
port: int,
|
||||||
token: str,
|
token: str,
|
||||||
session: aiohttp.ClientSession | None = None,
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
use_ssl: bool = False,
|
||||||
|
verify_ssl: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the client.
|
"""Initialize the client.
|
||||||
|
|
||||||
@@ -67,13 +87,22 @@ class MediaServerClient:
|
|||||||
port: Server port
|
port: Server port
|
||||||
token: API authentication token
|
token: API authentication token
|
||||||
session: Optional aiohttp session (will create one if not provided)
|
session: Optional aiohttp session (will create one if not provided)
|
||||||
|
use_ssl: If True, talk HTTPS instead of HTTP. The media server v0.3.0+
|
||||||
|
supports ``ssl_certfile`` / ``ssl_keyfile`` in ``config.yaml``.
|
||||||
|
verify_ssl: If False, skip TLS certificate verification (only needed
|
||||||
|
for self-signed certs on a trusted LAN).
|
||||||
"""
|
"""
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = int(port) # Ensure port is an integer
|
self._port = int(port) # Ensure port is an integer
|
||||||
self._token = token
|
self._token = token
|
||||||
self._session = session
|
self._session = session
|
||||||
self._own_session = session is None
|
self._own_session = session is None
|
||||||
self._base_url = f"http://{host}:{self._port}"
|
self._use_ssl = use_ssl
|
||||||
|
# aiohttp accepts ``ssl=False`` to disable verification; ``None`` keeps
|
||||||
|
# the default verifying SSLContext.
|
||||||
|
self._ssl: bool | None = False if (use_ssl and not verify_ssl) else None
|
||||||
|
scheme = "https" if use_ssl else "http"
|
||||||
|
self._base_url = f"{scheme}://{host}:{self._port}"
|
||||||
|
|
||||||
async def _ensure_session(self) -> aiohttp.ClientSession:
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||||
"""Ensure we have an aiohttp session."""
|
"""Ensure we have an aiohttp session."""
|
||||||
@@ -88,11 +117,26 @@ class MediaServerClient:
|
|||||||
await self._session.close()
|
await self._session.close()
|
||||||
|
|
||||||
def _get_headers(self) -> dict[str, str]:
|
def _get_headers(self) -> dict[str, str]:
|
||||||
"""Get headers for API requests."""
|
"""Get headers for API requests.
|
||||||
return {
|
|
||||||
"Authorization": f"Bearer {self._token}",
|
When no token is configured the media server runs in anonymous mode
|
||||||
|
(``auth.auth_enabled()`` returns False), so we omit the Authorization
|
||||||
|
header entirely rather than sending ``Bearer `` with an empty value.
|
||||||
|
|
||||||
|
Every request carries a per-call ``X-Request-ID`` that the media server
|
||||||
|
echoes back into its log lines (audit log + access log) so a problem
|
||||||
|
in HA can be correlated to the matching server-side entry. The id is
|
||||||
|
a UUID4 hex (32 chars) which fits the server's ``[A-Za-z0-9._-]{1,128}``
|
||||||
|
allow-list and is therefore preserved verbatim instead of being
|
||||||
|
replaced by a fresh server-side id.
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Request-ID": uuid.uuid4().hex,
|
||||||
}
|
}
|
||||||
|
if self._token:
|
||||||
|
headers["Authorization"] = f"Bearer {self._token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
@@ -119,17 +163,38 @@ class MediaServerClient:
|
|||||||
"""
|
"""
|
||||||
session = await self._ensure_session()
|
session = await self._ensure_session()
|
||||||
url = f"{self._base_url}{endpoint}"
|
url = f"{self._base_url}{endpoint}"
|
||||||
headers = self._get_headers() if auth_required else {}
|
# Always send X-Request-ID, even on unauthenticated calls — it's the
|
||||||
|
# observability hook, not an auth token, and the health endpoint
|
||||||
|
# benefits from being log-correlated just like every other.
|
||||||
|
headers = self._get_headers() if auth_required else {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Request-ID": uuid.uuid4().hex,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
async with session.request(
|
async with session.request(
|
||||||
method, url, headers=headers, json=json_data, timeout=timeout
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json=json_data,
|
||||||
|
timeout=timeout,
|
||||||
|
ssl=self._ssl,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 401:
|
if response.status == 401:
|
||||||
raise MediaServerAuthError("Invalid API token")
|
raise MediaServerAuthError("Invalid API token")
|
||||||
if response.status == 403:
|
if response.status == 403:
|
||||||
raise MediaServerAuthError("Access forbidden")
|
raise MediaServerAuthError("Access forbidden")
|
||||||
|
if response.status == 429:
|
||||||
|
retry_after_raw = response.headers.get("Retry-After", "")
|
||||||
|
try:
|
||||||
|
retry_after = float(retry_after_raw) if retry_after_raw else None
|
||||||
|
except ValueError:
|
||||||
|
retry_after = None
|
||||||
|
raise MediaServerRateLimitError(
|
||||||
|
f"Rate limited by server (retry after {retry_after}s)",
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return await response.json()
|
return await response.json()
|
||||||
@@ -174,13 +239,17 @@ class MediaServerClient:
|
|||||||
"""
|
"""
|
||||||
data = await self._request("GET", API_STATUS)
|
data = await self._request("GET", API_STATUS)
|
||||||
|
|
||||||
# Convert relative album_art_url to absolute URL with token and cache-buster
|
# Convert relative album_art_url to absolute URL with cache-buster
|
||||||
|
# (and token only when auth is enabled on the server side).
|
||||||
if data.get("album_art_url") and data["album_art_url"].startswith("/"):
|
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
|
# Add track info hash to force HA to re-fetch when track changes
|
||||||
import hashlib
|
import hashlib
|
||||||
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
|
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
|
||||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
||||||
data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}"
|
token_param = f"token={self._token}&" if self._token else ""
|
||||||
|
data["album_art_url"] = (
|
||||||
|
f"{self._base_url}{data['album_art_url']}?{token_param}t={track_hash}"
|
||||||
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -287,20 +356,37 @@ class MediaServerClient:
|
|||||||
return await self._request("GET", API_SCRIPTS_LIST)
|
return await self._request("GET", API_SCRIPTS_LIST)
|
||||||
|
|
||||||
async def execute_script(
|
async def execute_script(
|
||||||
self, script_name: str, args: list[str] | None = None
|
self,
|
||||||
|
script_name: str,
|
||||||
|
params: dict[str, str | int | float | bool] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute a script on the server.
|
"""Execute a script on the server.
|
||||||
|
|
||||||
|
The server (v0.3.0+) rate-limits ``/api/scripts/execute`` at 10/min per
|
||||||
|
peer. If we hit 429 we wait for ``Retry-After`` (capped at 30 s) and
|
||||||
|
retry once — enough for a brief HA-side burst without masking a real
|
||||||
|
sustained overload, which falls through as ``MediaServerRateLimitError``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
script_name: Name of the script to execute
|
script_name: Name of the script to execute
|
||||||
args: Optional list of arguments to pass to the script
|
params: Optional named parameters (validated against script schema)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Execution result with success, exit_code, stdout, stderr
|
Execution result with success, exit_code, stdout, stderr
|
||||||
"""
|
"""
|
||||||
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
|
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
|
||||||
json_data = {"args": args or []}
|
json_data = {"params": params or {}}
|
||||||
return await self._request("POST", endpoint, json_data)
|
try:
|
||||||
|
return await self._request("POST", endpoint, json_data)
|
||||||
|
except MediaServerRateLimitError as err:
|
||||||
|
wait = min(err.retry_after or 5.0, 30.0)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"execute_script(%s) rate-limited, retrying after %.1fs",
|
||||||
|
script_name,
|
||||||
|
wait,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
return await self._request("POST", endpoint, json_data)
|
||||||
|
|
||||||
async def get_media_folders(self) -> dict[str, dict[str, Any]]:
|
async def get_media_folders(self) -> dict[str, dict[str, Any]]:
|
||||||
"""Get configured media folders.
|
"""Get configured media folders.
|
||||||
@@ -308,7 +394,12 @@ class MediaServerClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary of folders with folder_id as key and folder config as value
|
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(
|
async def browse_folder(
|
||||||
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100
|
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100
|
||||||
@@ -346,12 +437,12 @@ class MediaServerClient:
|
|||||||
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
|
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
|
||||||
|
|
||||||
async def get_display_monitors(self) -> list[dict[str, Any]]:
|
async def get_display_monitors(self) -> list[dict[str, Any]]:
|
||||||
"""Get list of connected monitors with brightness and power info.
|
"""Get list of connected monitors with brightness, power, DDC/CI state.
|
||||||
|
|
||||||
Returns:
|
Uses the server's short TTL cache so per-entity polling does not pay
|
||||||
List of monitor dicts with id, name, brightness, power_supported, power_on, resolution
|
the full DDC/CI probe cost on every call.
|
||||||
"""
|
"""
|
||||||
return await self._request("GET", f"{API_DISPLAY_MONITORS}?refresh=true")
|
return await self._request("GET", API_DISPLAY_MONITORS)
|
||||||
|
|
||||||
async def set_display_brightness(self, monitor_id: int, brightness: int) -> dict[str, Any]:
|
async def set_display_brightness(self, monitor_id: int, brightness: int) -> dict[str, Any]:
|
||||||
"""Set brightness for a specific monitor.
|
"""Set brightness for a specific monitor.
|
||||||
@@ -381,6 +472,39 @@ class MediaServerClient:
|
|||||||
"POST", f"{API_DISPLAY_POWER}/{monitor_id}", {"on": on}
|
"POST", f"{API_DISPLAY_POWER}/{monitor_id}", {"on": on}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_display_contrast(self, monitor_id: int, contrast: int) -> dict[str, Any]:
|
||||||
|
"""Set DDC/CI contrast for a specific monitor (0-100)."""
|
||||||
|
return await self._request(
|
||||||
|
"POST", f"{API_DISPLAY_CONTRAST}/{monitor_id}", {"contrast": contrast}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_display_input_source(self, monitor_id: int, source: str) -> dict[str, Any]:
|
||||||
|
"""Switch a monitor's DDC/CI input source by enum name (e.g. 'HDMI1')."""
|
||||||
|
return await self._request(
|
||||||
|
"POST", f"{API_DISPLAY_INPUT_SOURCE}/{monitor_id}", {"source": source}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_display_color_preset(self, monitor_id: int, preset: str) -> dict[str, Any]:
|
||||||
|
"""Apply a DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
|
||||||
|
return await self._request(
|
||||||
|
"POST", f"{API_DISPLAY_COLOR_PRESET}/{monitor_id}", {"preset": preset}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_display_picture_mode(self, monitor_id: int, code: int) -> dict[str, Any]:
|
||||||
|
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||||
|
return await self._request(
|
||||||
|
"POST", f"{API_DISPLAY_PICTURE_MODE}/{monitor_id}", {"code": code}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_foreground(self) -> dict[str, Any]:
|
||||||
|
"""Get the foreground window/process snapshot.
|
||||||
|
|
||||||
|
Returns the structured payload described in the media server's
|
||||||
|
``ForegroundInfo`` dataclass: process name, window title, fullscreen
|
||||||
|
flag, owning monitor, geometry, and process start time.
|
||||||
|
"""
|
||||||
|
return await self._request("GET", API_FOREGROUND)
|
||||||
|
|
||||||
|
|
||||||
class MediaServerWebSocket:
|
class MediaServerWebSocket:
|
||||||
"""WebSocket client for real-time media status updates."""
|
"""WebSocket client for real-time media status updates."""
|
||||||
@@ -393,6 +517,9 @@ class MediaServerWebSocket:
|
|||||||
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,
|
on_scripts_changed: Callable[[], None] | None = None,
|
||||||
|
on_foreground_update: Callable[[dict[str, Any]], None] | None = None,
|
||||||
|
use_ssl: bool = False,
|
||||||
|
verify_ssl: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the WebSocket client.
|
"""Initialize the WebSocket client.
|
||||||
|
|
||||||
@@ -403,6 +530,9 @@ class MediaServerWebSocket:
|
|||||||
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
|
on_scripts_changed: Callback when scripts have changed
|
||||||
|
on_foreground_update: Callback when foreground process changes
|
||||||
|
use_ssl: If True, talk WSS instead of WS.
|
||||||
|
verify_ssl: If False, skip TLS certificate verification.
|
||||||
"""
|
"""
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = int(port)
|
self._port = int(port)
|
||||||
@@ -410,7 +540,19 @@ class MediaServerWebSocket:
|
|||||||
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._on_scripts_changed = on_scripts_changed
|
||||||
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
|
self._on_foreground_update = on_foreground_update
|
||||||
|
self._use_ssl = use_ssl
|
||||||
|
self._ssl: bool | None = False if (use_ssl and not verify_ssl) else None
|
||||||
|
# The server's WS endpoint accepts an unauthenticated connection when
|
||||||
|
# api_tokens is empty (see media.py:websocket_endpoint), so we only
|
||||||
|
# append ?token=... when one was configured. Pre-0.3.0 servers only
|
||||||
|
# know the query path; 0.3.0+ servers prefer the ``Sec-WebSocket-Protocol``
|
||||||
|
# subprotocol (which keeps the token out of URLs / Referer / logs) but
|
||||||
|
# still accept the query as a documented back-compat fallback. We send
|
||||||
|
# both so the integration works against either server version.
|
||||||
|
token_query = f"?token={token}" if token else ""
|
||||||
|
scheme = "wss" if use_ssl else "ws"
|
||||||
|
self._ws_url = f"{scheme}://{host}:{self._port}/api/media/ws{token_query}"
|
||||||
self._session: aiohttp.ClientSession | None = None
|
self._session: aiohttp.ClientSession | None = None
|
||||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||||
self._receive_task: asyncio.Task | None = None
|
self._receive_task: asyncio.Task | None = None
|
||||||
@@ -426,11 +568,21 @@ class MediaServerWebSocket:
|
|||||||
if self._session is None or self._session.closed:
|
if self._session is None or self._session.closed:
|
||||||
self._session = aiohttp.ClientSession()
|
self._session = aiohttp.ClientSession()
|
||||||
|
|
||||||
self._ws = await self._session.ws_connect(
|
ws_kwargs: dict[str, Any] = {
|
||||||
self._ws_url,
|
"heartbeat": 30,
|
||||||
heartbeat=30,
|
"timeout": aiohttp.ClientTimeout(total=10),
|
||||||
timeout=aiohttp.ClientTimeout(total=10),
|
}
|
||||||
)
|
if self._token:
|
||||||
|
# Subprotocol-based auth (preferred by media server v0.3.0+).
|
||||||
|
# aiohttp negotiates this header; if the server doesn't echo
|
||||||
|
# it back (older versions), aiohttp still completes the
|
||||||
|
# handshake — at which point the ?token= query in the URL
|
||||||
|
# takes over. Safe across both server generations.
|
||||||
|
ws_kwargs["protocols"] = [f"media-server.token.{self._token}"]
|
||||||
|
if self._ssl is not None:
|
||||||
|
ws_kwargs["ssl"] = self._ssl
|
||||||
|
|
||||||
|
self._ws = await self._session.ws_connect(self._ws_url, **ws_kwargs)
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
# Start receive loop
|
# Start receive loop
|
||||||
@@ -484,15 +636,20 @@ class MediaServerWebSocket:
|
|||||||
):
|
):
|
||||||
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
||||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
||||||
|
token_param = f"token={self._token}&" if self._token else ""
|
||||||
|
http_scheme = "https" if self._use_ssl else "http"
|
||||||
status_data["album_art_url"] = (
|
status_data["album_art_url"] = (
|
||||||
f"http://{self._host}:{self._port}"
|
f"{http_scheme}://{self._host}:{self._port}"
|
||||||
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
|
f"{status_data['album_art_url']}?{token_param}t={track_hash}"
|
||||||
)
|
)
|
||||||
self._on_status_update(status_data)
|
self._on_status_update(status_data)
|
||||||
elif msg_type == "scripts_changed":
|
elif msg_type == "scripts_changed":
|
||||||
_LOGGER.info("Scripts changed notification received")
|
_LOGGER.info("Scripts changed notification received")
|
||||||
if self._on_scripts_changed:
|
if self._on_scripts_changed:
|
||||||
self._on_scripts_changed()
|
self._on_scripts_changed()
|
||||||
|
elif msg_type in ("foreground", "foreground_update"):
|
||||||
|
if self._on_foreground_update:
|
||||||
|
self._on_foreground_update(data.get("data", {}))
|
||||||
elif msg_type == "pong":
|
elif msg_type == "pong":
|
||||||
_LOGGER.debug("Received pong")
|
_LOGGER.debug("Received pong")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Diagnostic binary sensors per display (primary, DDC/CI power-control support)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
from .display_device import display_device_info
|
||||||
|
from .foreground import FOREGROUND_BINARY_SENSORS
|
||||||
|
from .foreground_coordinator import ForegroundCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up display + foreground binary sensor entities."""
|
||||||
|
store = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
display_coordinator: DisplayCoordinator = store["display_coordinator"]
|
||||||
|
foreground_coordinator: ForegroundCoordinator | None = store.get(
|
||||||
|
"foreground_coordinator"
|
||||||
|
)
|
||||||
|
|
||||||
|
entities: list[Any] = []
|
||||||
|
if display_coordinator.data:
|
||||||
|
for monitor in display_coordinator.data.values():
|
||||||
|
entities.append(
|
||||||
|
DisplayPrimaryBinarySensor(display_coordinator, entry, monitor)
|
||||||
|
)
|
||||||
|
entities.append(
|
||||||
|
DisplayPowerControlBinarySensor(display_coordinator, entry, monitor)
|
||||||
|
)
|
||||||
|
|
||||||
|
if foreground_coordinator is not None:
|
||||||
|
entities.extend(
|
||||||
|
cls(foreground_coordinator, entry) for cls in FOREGROUND_BINARY_SENSORS
|
||||||
|
)
|
||||||
|
|
||||||
|
if entities:
|
||||||
|
async_add_entities(entities)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Added %d binary sensor entities (display + foreground)", len(entities)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DisplayBinarySensorBase(
|
||||||
|
CoordinatorEntity[DisplayCoordinator], BinarySensorEntity
|
||||||
|
):
|
||||||
|
"""Common boilerplate for per-display diagnostic binary sensors."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._monitor_id: int = monitor["id"]
|
||||||
|
self._attr_device_info = display_device_info(entry, monitor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _monitor(self) -> dict[str, Any]:
|
||||||
|
if self.coordinator.data is None:
|
||||||
|
return {}
|
||||||
|
return self.coordinator.data.get(self._monitor_id, {})
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase):
|
||||||
|
"""Indicates whether the display is the OS primary monitor."""
|
||||||
|
|
||||||
|
_attr_translation_key = "primary_display"
|
||||||
|
_attr_icon = "mdi:monitor-star"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry, monitor)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_primary_{self._monitor_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return bool(self._monitor.get("is_primary"))
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
|
||||||
|
"""Indicates whether DDC/CI power control is available for this display."""
|
||||||
|
|
||||||
|
_attr_translation_key = "power_control_supported"
|
||||||
|
_attr_icon = "mdi:power-plug"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry, monitor)
|
||||||
|
self._attr_unique_id = (
|
||||||
|
f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return bool(self._monitor.get("power_supported"))
|
||||||
@@ -22,9 +22,13 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
CONF_TOKEN,
|
CONF_TOKEN,
|
||||||
CONF_POLL_INTERVAL,
|
CONF_POLL_INTERVAL,
|
||||||
|
CONF_USE_SSL,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
DEFAULT_POLL_INTERVAL,
|
DEFAULT_POLL_INTERVAL,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
|
DEFAULT_USE_SSL,
|
||||||
|
DEFAULT_VERIFY_SSL,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -44,10 +48,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
|||||||
CannotConnect: If connection fails
|
CannotConnect: If connection fails
|
||||||
InvalidAuth: If authentication fails
|
InvalidAuth: If authentication fails
|
||||||
"""
|
"""
|
||||||
|
# Token is optional: the media server can run without auth tokens, in which
|
||||||
|
# case verify_token() returns "anonymous" and accepts unauthenticated calls.
|
||||||
|
# If the server *does* have tokens configured, get_status() below will 401
|
||||||
|
# and we surface that as "invalid_auth" in the UI.
|
||||||
client = MediaServerClient(
|
client = MediaServerClient(
|
||||||
host=data[CONF_HOST],
|
host=data[CONF_HOST],
|
||||||
port=data[CONF_PORT],
|
port=data[CONF_PORT],
|
||||||
token=data[CONF_TOKEN],
|
token=data.get(CONF_TOKEN, "") or "",
|
||||||
|
use_ssl=data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
|
||||||
|
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -125,11 +135,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
mode=selector.NumberSelectorMode.BOX,
|
mode=selector.NumberSelectorMode.BOX,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_TOKEN): selector.TextSelector(
|
vol.Optional(CONF_TOKEN, default=""): selector.TextSelector(
|
||||||
selector.TextSelectorConfig(
|
selector.TextSelectorConfig(
|
||||||
type=selector.TextSelectorType.PASSWORD
|
type=selector.TextSelectorType.PASSWORD
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_USE_SSL, default=DEFAULT_USE_SSL
|
||||||
|
): selector.BooleanSelector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
|
||||||
|
): selector.BooleanSelector(),
|
||||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ CONF_TOKEN = "token"
|
|||||||
CONF_POLL_INTERVAL = "poll_interval"
|
CONF_POLL_INTERVAL = "poll_interval"
|
||||||
CONF_NAME = "name"
|
CONF_NAME = "name"
|
||||||
CONF_USE_WEBSOCKET = "use_websocket"
|
CONF_USE_WEBSOCKET = "use_websocket"
|
||||||
|
CONF_USE_SSL = "use_ssl"
|
||||||
|
CONF_VERIFY_SSL = "verify_ssl"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULT_PORT = 8765
|
DEFAULT_PORT = 8765
|
||||||
@@ -16,6 +18,12 @@ 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 = 5
|
DEFAULT_RECONNECT_INTERVAL = 5
|
||||||
|
DEFAULT_USE_SSL = False
|
||||||
|
DEFAULT_VERIFY_SSL = True
|
||||||
|
# Displays change rarely (brightness/contrast/input source via physical buttons
|
||||||
|
# or external automations), so a slow shared poll is plenty. The previous
|
||||||
|
# per-entity polling produced ~9 calls every 30 s for a single monitor.
|
||||||
|
DEFAULT_DISPLAY_POLL_INTERVAL = 30
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
API_HEALTH = "/api/health"
|
API_HEALTH = "/api/health"
|
||||||
@@ -37,9 +45,14 @@ API_WEBSOCKET = "/api/media/ws"
|
|||||||
API_BROWSER_FOLDERS = "/api/browser/folders"
|
API_BROWSER_FOLDERS = "/api/browser/folders"
|
||||||
API_BROWSER_BROWSE = "/api/browser/browse"
|
API_BROWSER_BROWSE = "/api/browser/browse"
|
||||||
API_BROWSER_PLAY = "/api/browser/play"
|
API_BROWSER_PLAY = "/api/browser/play"
|
||||||
|
API_FOREGROUND = "/api/foreground"
|
||||||
API_DISPLAY_MONITORS = "/api/display/monitors"
|
API_DISPLAY_MONITORS = "/api/display/monitors"
|
||||||
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
|
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
|
||||||
API_DISPLAY_POWER = "/api/display/power"
|
API_DISPLAY_POWER = "/api/display/power"
|
||||||
|
API_DISPLAY_CONTRAST = "/api/display/contrast"
|
||||||
|
API_DISPLAY_INPUT_SOURCE = "/api/display/input_source"
|
||||||
|
API_DISPLAY_COLOR_PRESET = "/api/display/color_preset"
|
||||||
|
API_DISPLAY_PICTURE_MODE = "/api/display/picture_mode"
|
||||||
|
|
||||||
# Service names
|
# Service names
|
||||||
SERVICE_EXECUTE_SCRIPT = "execute_script"
|
SERVICE_EXECUTE_SCRIPT = "execute_script"
|
||||||
@@ -47,5 +60,5 @@ 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_PARAMS = "params"
|
||||||
ATTR_FILE_PATH = "file_path"
|
ATTR_FILE_PATH = "file_path"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Shared coordinator for per-display monitor state.
|
||||||
|
|
||||||
|
All display platforms (binary_sensor, number, select, sensor, switch) share a
|
||||||
|
single poll cycle through this coordinator instead of each entity calling
|
||||||
|
``GET /api/display/monitors`` from its own ``async_update``. With ~9 display
|
||||||
|
entities per monitor, that change reduces the HTTP load on the media server
|
||||||
|
from 9x per cycle to 1x per cycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
|
from .const import DEFAULT_DISPLAY_POLL_INTERVAL
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Coordinator data: monitor_id -> monitor dict (full payload as returned by
|
||||||
|
# the media server, indexed for O(1) per-entity lookup).
|
||||||
|
DisplayData = dict[int, dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayCoordinator(DataUpdateCoordinator[DisplayData]):
|
||||||
|
"""Polls ``/api/display/monitors`` once and fans out to all display entities."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client: MediaServerClient,
|
||||||
|
poll_interval: int = DEFAULT_DISPLAY_POLL_INTERVAL,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name="Remote Media Player Displays",
|
||||||
|
update_interval=timedelta(seconds=poll_interval),
|
||||||
|
)
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> DisplayData:
|
||||||
|
try:
|
||||||
|
monitors = await self.client.get_display_monitors()
|
||||||
|
except MediaServerError as err:
|
||||||
|
raise UpdateFailed(f"Failed to fetch display monitors: {err}") from err
|
||||||
|
return {monitor["id"]: monitor for monitor in monitors}
|
||||||
|
|
||||||
|
def apply_optimistic(self, monitor_id: int, **fields: Any) -> None:
|
||||||
|
"""Mutate cached monitor data after a successful write and notify entities.
|
||||||
|
|
||||||
|
Avoids a network round trip on every slider tick while still keeping
|
||||||
|
all sibling display entities in sync. The next scheduled refresh
|
||||||
|
reconciles with the server's authoritative state.
|
||||||
|
"""
|
||||||
|
if self.data is None:
|
||||||
|
return
|
||||||
|
monitor = self.data.get(monitor_id)
|
||||||
|
if monitor is None:
|
||||||
|
return
|
||||||
|
monitor.update(fields)
|
||||||
|
self.async_update_listeners()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Helpers for building per-display DeviceInfo.
|
||||||
|
|
||||||
|
Each physical monitor is exposed as its own HA device (linked back to the
|
||||||
|
media-server hub via `via_device`) so that per-display entities (power
|
||||||
|
switch, brightness, future per-display sensors) cluster together, can be
|
||||||
|
placed in their own area/room, and participate in device-based automations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
def display_label(monitor: dict[str, Any]) -> str:
|
||||||
|
"""Return a user-friendly label for a display monitor.
|
||||||
|
|
||||||
|
Resolution is appended when available so that two monitors sharing a
|
||||||
|
name (e.g. two "Generic PnP Monitor" entries) remain distinguishable.
|
||||||
|
"""
|
||||||
|
name = monitor.get("name") or f"Monitor {monitor['id']}"
|
||||||
|
resolution = monitor.get("resolution")
|
||||||
|
if resolution:
|
||||||
|
return f"{name} ({resolution})"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def display_device_identifier(entry: ConfigEntry, monitor_id: int) -> tuple[str, str]:
|
||||||
|
"""Return the stable identifier tuple for a per-display device."""
|
||||||
|
return (DOMAIN, f"{entry.entry_id}_display_{monitor_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def display_device_info(entry: ConfigEntry, monitor: dict[str, Any]) -> DeviceInfo:
|
||||||
|
"""Build DeviceInfo for a per-display device linked to the hub.
|
||||||
|
|
||||||
|
Prefers the manufacturer/model reported by the monitor's EDID; falls back
|
||||||
|
to integration-level defaults so devices still appear sensibly even when
|
||||||
|
EDID parsing returns blanks.
|
||||||
|
"""
|
||||||
|
manufacturer = (monitor.get("manufacturer") or "").strip() or "Remote Media Player"
|
||||||
|
model = (monitor.get("model") or "").strip() or "Display"
|
||||||
|
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={display_device_identifier(entry, monitor["id"])},
|
||||||
|
via_device=(DOMAIN, entry.entry_id),
|
||||||
|
# HA's device tree already shows the parent hub above its children
|
||||||
|
# via `via_device`, so re-stating the entry title here would just
|
||||||
|
# duplicate the hub name on every child row.
|
||||||
|
name=display_label(monitor),
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Foreground process sensor and binary-sensor entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||||
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .foreground_coordinator import ForegroundCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _foreground_device_info(entry: ConfigEntry) -> DeviceInfo:
|
||||||
|
"""All foreground entities share one HA device, linked to the hub."""
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, f"{entry.entry_id}_foreground")},
|
||||||
|
via_device=(DOMAIN, entry.entry_id),
|
||||||
|
name="Foreground",
|
||||||
|
manufacturer="Remote Media Player",
|
||||||
|
model="Foreground Process",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ForegroundEntityBase(CoordinatorEntity[ForegroundCoordinator]):
|
||||||
|
"""Boilerplate shared by every foreground entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._entry = entry
|
||||||
|
self._attr_device_info = _foreground_device_info(entry)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _data(self) -> dict[str, Any]:
|
||||||
|
return self.coordinator.data or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
# Coordinator availability covers HTTP failures; the per-platform
|
||||||
|
# ``available`` flag in the payload reports e.g. "Wayland session".
|
||||||
|
if not super().available:
|
||||||
|
return False
|
||||||
|
return bool(self._data.get("available", True))
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundProcessSensor(_ForegroundEntityBase, SensorEntity):
|
||||||
|
"""Primary sensor: the process name plus full payload as attributes."""
|
||||||
|
|
||||||
|
_attr_icon = "mdi:application"
|
||||||
|
_attr_translation_key = "foreground_process"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_process"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
return self._data.get("process_name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
|
d = self._data
|
||||||
|
return {
|
||||||
|
"pid": d.get("pid"),
|
||||||
|
"executable_path": d.get("executable_path"),
|
||||||
|
"window_title": d.get("window_title"),
|
||||||
|
"window_handle": d.get("window_handle"),
|
||||||
|
"is_fullscreen": d.get("is_fullscreen"),
|
||||||
|
"is_minimized": d.get("is_minimized"),
|
||||||
|
"monitor_id": d.get("monitor_id"),
|
||||||
|
"monitor_geometry": d.get("monitor_geometry"),
|
||||||
|
"window_geometry": d.get("window_geometry"),
|
||||||
|
"started_at": d.get("started_at"),
|
||||||
|
"platform": d.get("platform"),
|
||||||
|
"is_browser": d.get("is_browser"),
|
||||||
|
"browser_page_title": d.get("browser_page_title"),
|
||||||
|
"browser_url": d.get("browser_url"),
|
||||||
|
"available": d.get("available"),
|
||||||
|
"error": d.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundWindowTitleSensor(_ForegroundEntityBase, SensorEntity):
|
||||||
|
_attr_icon = "mdi:window-restore"
|
||||||
|
_attr_translation_key = "window_title"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_window_title"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
return self._data.get("window_title")
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundPidSensor(_ForegroundEntityBase, SensorEntity):
|
||||||
|
_attr_icon = "mdi:identifier"
|
||||||
|
_attr_translation_key = "pid"
|
||||||
|
_attr_entity_registry_enabled_default = False # diagnostic-leaning
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_pid"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int | None:
|
||||||
|
return self._data.get("pid")
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundMonitorSensor(_ForegroundEntityBase, SensorEntity):
|
||||||
|
_attr_icon = "mdi:monitor"
|
||||||
|
_attr_translation_key = "foreground_monitor"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_monitor"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int | None:
|
||||||
|
return self._data.get("monitor_id")
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundStartedAtSensor(_ForegroundEntityBase, SensorEntity):
|
||||||
|
"""Process start time as a timezone-aware datetime."""
|
||||||
|
|
||||||
|
_attr_icon = "mdi:clock-start"
|
||||||
|
_attr_translation_key = "process_started"
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
_attr_entity_registry_enabled_default = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_started_at"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> datetime | None:
|
||||||
|
ts = self._data.get("started_at")
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||||||
|
except (TypeError, ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundFullscreenBinarySensor(_ForegroundEntityBase, BinarySensorEntity):
|
||||||
|
_attr_icon = "mdi:fullscreen"
|
||||||
|
_attr_translation_key = "fullscreen"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_fullscreen"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return bool(self._data.get("is_fullscreen"))
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundMinimizedBinarySensor(_ForegroundEntityBase, BinarySensorEntity):
|
||||||
|
_attr_icon = "mdi:window-minimize"
|
||||||
|
_attr_translation_key = "minimized"
|
||||||
|
_attr_entity_registry_enabled_default = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ForegroundCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, entry)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_foreground_minimized"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return bool(self._data.get("is_minimized"))
|
||||||
|
|
||||||
|
|
||||||
|
FOREGROUND_SENSORS: tuple[type[_ForegroundEntityBase], ...] = (
|
||||||
|
ForegroundProcessSensor,
|
||||||
|
ForegroundWindowTitleSensor,
|
||||||
|
ForegroundPidSensor,
|
||||||
|
ForegroundMonitorSensor,
|
||||||
|
ForegroundStartedAtSensor,
|
||||||
|
)
|
||||||
|
|
||||||
|
FOREGROUND_BINARY_SENSORS: tuple[type[_ForegroundEntityBase], ...] = (
|
||||||
|
ForegroundFullscreenBinarySensor,
|
||||||
|
ForegroundMinimizedBinarySensor,
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Shared coordinator for the foreground (topmost) process snapshot.
|
||||||
|
|
||||||
|
The media server already broadcasts the foreground process over the media
|
||||||
|
WebSocket, but the WS client lives inside the media-player entity. Sensors
|
||||||
|
need their own polling fallback so they keep working when the user disables
|
||||||
|
the WebSocket feature in options, or while the WS is reconnecting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Foreground polls fairly often — the user-facing value (process name)
|
||||||
|
# changes whenever the user alt-tabs, so a coarse poll would feel laggy.
|
||||||
|
# The server side is cached at ~500ms so even a 5s poll stays cheap.
|
||||||
|
DEFAULT_FOREGROUND_POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
|
"""Polls ``/api/foreground`` and fans out to sensor entities."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client: MediaServerClient,
|
||||||
|
poll_interval: int = DEFAULT_FOREGROUND_POLL_INTERVAL,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name="Remote Media Player Foreground",
|
||||||
|
update_interval=timedelta(seconds=poll_interval),
|
||||||
|
)
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await self.client.get_foreground()
|
||||||
|
except MediaServerError as err:
|
||||||
|
raise UpdateFailed(f"Failed to fetch foreground info: {err}") from err
|
||||||
|
|
||||||
|
def apply_websocket_snapshot(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Update from a push event (WebSocket) without an HTTP roundtrip.
|
||||||
|
|
||||||
|
Called by the media-player WS receiver when a ``foreground``/
|
||||||
|
``foreground_update`` frame arrives. Updates ``self.data`` directly
|
||||||
|
so all listening sensors refresh immediately, and avoids the next
|
||||||
|
scheduled poll spending bandwidth on the same value.
|
||||||
|
"""
|
||||||
|
self.async_set_updated_data(data)
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["aiohttp>=3.8.0"],
|
"requirements": ["aiohttp>=3.8.0"],
|
||||||
"version": "1.0.0"
|
"version": "0.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ from .const import (
|
|||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_TOKEN,
|
CONF_TOKEN,
|
||||||
CONF_POLL_INTERVAL,
|
CONF_POLL_INTERVAL,
|
||||||
|
CONF_USE_SSL,
|
||||||
CONF_USE_WEBSOCKET,
|
CONF_USE_WEBSOCKET,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
DEFAULT_POLL_INTERVAL,
|
DEFAULT_POLL_INTERVAL,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DEFAULT_USE_WEBSOCKET,
|
|
||||||
DEFAULT_RECONNECT_INTERVAL,
|
DEFAULT_RECONNECT_INTERVAL,
|
||||||
|
DEFAULT_USE_SSL,
|
||||||
|
DEFAULT_USE_WEBSOCKET,
|
||||||
|
DEFAULT_VERIFY_SSL,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -87,6 +91,8 @@ 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,
|
||||||
|
use_ssl=entry.data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
|
||||||
|
verify_ssl=entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||||
entry=entry,
|
entry=entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,6 +130,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
port: int,
|
port: int,
|
||||||
token: str,
|
token: str,
|
||||||
use_websocket: bool = True,
|
use_websocket: bool = True,
|
||||||
|
use_ssl: bool = DEFAULT_USE_SSL,
|
||||||
|
verify_ssl: bool = DEFAULT_VERIFY_SSL,
|
||||||
entry: ConfigEntry | None = None,
|
entry: ConfigEntry | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator.
|
"""Initialize the coordinator.
|
||||||
@@ -136,6 +144,8 @@ 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
|
||||||
|
use_ssl: Talk WSS instead of WS
|
||||||
|
verify_ssl: Verify TLS cert (off for self-signed)
|
||||||
entry: Config entry (for integration reload on scripts change)
|
entry: Config entry (for integration reload on scripts change)
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -149,6 +159,8 @@ 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._use_ssl = use_ssl
|
||||||
|
self._verify_ssl = verify_ssl
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._ws_client: MediaServerWebSocket | None = None
|
self._ws_client: MediaServerWebSocket | None = None
|
||||||
self._ws_connected = False
|
self._ws_connected = False
|
||||||
@@ -172,6 +184,9 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
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,
|
on_scripts_changed=self._handle_ws_scripts_changed,
|
||||||
|
on_foreground_update=self._handle_ws_foreground_update,
|
||||||
|
use_ssl=self._use_ssl,
|
||||||
|
verify_ssl=self._verify_ssl,
|
||||||
)
|
)
|
||||||
|
|
||||||
if await self._ws_client.connect():
|
if await self._ws_client.connect():
|
||||||
@@ -206,6 +221,19 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# Schedule reconnect attempt
|
# Schedule reconnect attempt
|
||||||
self._schedule_reconnect()
|
self._schedule_reconnect()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_ws_foreground_update(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Forward a foreground WS push into the shared foreground coordinator."""
|
||||||
|
if not self._entry:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
store = self.hass.data[DOMAIN][self._entry.entry_id]
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
coordinator = store.get("foreground_coordinator")
|
||||||
|
if coordinator is not None:
|
||||||
|
coordinator.apply_websocket_snapshot(data)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_ws_scripts_changed(self) -> None:
|
def _handle_ws_scripts_changed(self) -> None:
|
||||||
"""Handle scripts changed notification from WebSocket."""
|
"""Handle scripts changed notification from WebSocket."""
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from typing import Any
|
|||||||
from homeassistant.components.number import NumberEntity, NumberMode
|
from homeassistant.components.number import NumberEntity, NumberMode
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,32 +24,28 @@ async def async_setup_entry(
|
|||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up display brightness number entities from a config entry."""
|
"""Set up display brightness + contrast number entities from a config entry."""
|
||||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
client: MediaServerClient = data["client"]
|
||||||
|
coordinator: DisplayCoordinator = data["display_coordinator"]
|
||||||
|
|
||||||
try:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities = [
|
entities: list[Any] = []
|
||||||
DisplayBrightnessNumber(
|
for monitor in coordinator.data.values():
|
||||||
client=client,
|
if monitor.get("brightness") is not None:
|
||||||
entry=entry,
|
entities.append(DisplayBrightnessNumber(coordinator, client, entry, monitor))
|
||||||
monitor=monitor,
|
if monitor.get("contrast_supported"):
|
||||||
)
|
entities.append(DisplayContrastNumber(coordinator, client, entry, monitor))
|
||||||
for monitor in monitors
|
|
||||||
if monitor.get("brightness") is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
if entities:
|
if entities:
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
_LOGGER.info("Added %d display brightness entities", len(entities))
|
_LOGGER.info("Added %d display number entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class DisplayBrightnessNumber(NumberEntity):
|
class _DisplayNumberBase(CoordinatorEntity[DisplayCoordinator], NumberEntity):
|
||||||
"""Number entity for controlling display brightness."""
|
"""Shared boilerplate for per-display number entities."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_native_min_value = 0
|
_attr_native_min_value = 0
|
||||||
@@ -55,56 +53,90 @@ class DisplayBrightnessNumber(NumberEntity):
|
|||||||
_attr_native_step = 1
|
_attr_native_step = 1
|
||||||
_attr_native_unit_of_measurement = "%"
|
_attr_native_unit_of_measurement = "%"
|
||||||
_attr_mode = NumberMode.SLIDER
|
_attr_mode = NumberMode.SLIDER
|
||||||
_attr_icon = "mdi:brightness-6"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the display brightness entity."""
|
super().__init__(coordinator)
|
||||||
self._client = client
|
self._client = client
|
||||||
self._entry = entry
|
|
||||||
self._monitor_id: int = monitor["id"]
|
self._monitor_id: int = monitor["id"]
|
||||||
self._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
|
self._attr_device_info = display_device_info(entry, monitor)
|
||||||
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
|
@property
|
||||||
def device_info(self) -> DeviceInfo:
|
def _monitor(self) -> dict[str, Any]:
|
||||||
"""Return device info."""
|
if self.coordinator.data is None:
|
||||||
return DeviceInfo(
|
return {}
|
||||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
return self.coordinator.data.get(self._monitor_id, {})
|
||||||
name=self._entry.title,
|
|
||||||
manufacturer="Remote Media Player",
|
|
||||||
model="Media Server",
|
class DisplayBrightnessNumber(_DisplayNumberBase):
|
||||||
)
|
"""Number entity for controlling display brightness."""
|
||||||
|
|
||||||
|
_attr_translation_key = "brightness"
|
||||||
|
_attr_icon = "mdi:brightness-6"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
client: MediaServerClient,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
value = self._monitor.get("brightness")
|
||||||
|
return None if value is None else float(value)
|
||||||
|
|
||||||
async def async_set_native_value(self, value: float) -> None:
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
"""Set the brightness value."""
|
|
||||||
try:
|
try:
|
||||||
await self._client.set_display_brightness(self._monitor_id, int(value))
|
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:
|
except MediaServerError as err:
|
||||||
_LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err)
|
_LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err)
|
||||||
|
return
|
||||||
|
self.coordinator.apply_optimistic(self._monitor_id, brightness=int(value))
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
|
||||||
"""Fetch updated brightness from the server."""
|
class DisplayContrastNumber(_DisplayNumberBase):
|
||||||
|
"""Number entity for controlling DDC/CI display contrast."""
|
||||||
|
|
||||||
|
_attr_translation_key = "contrast"
|
||||||
|
_attr_icon = "mdi:contrast-circle"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
client: MediaServerClient,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
value = self._monitor.get("contrast")
|
||||||
|
return None if value is None else float(value)
|
||||||
|
|
||||||
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
try:
|
try:
|
||||||
monitors = await self._client.get_display_monitors()
|
result = await self._client.set_display_contrast(self._monitor_id, int(value))
|
||||||
for monitor in monitors:
|
|
||||||
if monitor["id"] == self._monitor_id:
|
|
||||||
self._attr_native_value = monitor.get("brightness")
|
|
||||||
break
|
|
||||||
except MediaServerError as err:
|
except MediaServerError as err:
|
||||||
_LOGGER.error("Failed to update brightness for monitor %d: %s", self._monitor_id, err)
|
_LOGGER.error("Failed to set contrast for monitor %d: %s", self._monitor_id, err)
|
||||||
|
return
|
||||||
|
if not result.get("success"):
|
||||||
|
# DDC/CI silently dropped the write — pull authoritative state from
|
||||||
|
# the server instead of trusting our optimistic value.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Monitor %d rejected contrast %d (DDC/CI silently dropped)",
|
||||||
|
self._monitor_id, int(value),
|
||||||
|
)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
return
|
||||||
|
self.coordinator.apply_optimistic(self._monitor_id, contrast=int(value))
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Select platform: DDC/CI input source, color preset, and picture mode."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.select import SelectEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
from .display_device import display_device_info
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up per-display select entities."""
|
||||||
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
client: MediaServerClient = data["client"]
|
||||||
|
coordinator: DisplayCoordinator = data["display_coordinator"]
|
||||||
|
|
||||||
|
if not coordinator.data:
|
||||||
|
return
|
||||||
|
|
||||||
|
entities: list[Any] = []
|
||||||
|
for monitor in coordinator.data.values():
|
||||||
|
if monitor.get("input_source_supported") and monitor.get("available_input_sources"):
|
||||||
|
entities.append(DisplayInputSourceSelect(coordinator, client, entry, monitor))
|
||||||
|
if monitor.get("color_preset_supported") and monitor.get("available_color_presets"):
|
||||||
|
entities.append(DisplayColorPresetSelect(coordinator, client, entry, monitor))
|
||||||
|
if monitor.get("picture_mode_supported") and monitor.get("available_picture_modes"):
|
||||||
|
entities.append(DisplayPictureModeSelect(coordinator, client, entry, monitor))
|
||||||
|
|
||||||
|
if entities:
|
||||||
|
async_add_entities(entities)
|
||||||
|
_LOGGER.info("Added %d display select entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
|
class _DisplaySelectBase(CoordinatorEntity[DisplayCoordinator], SelectEntity):
|
||||||
|
"""Shared base for per-display selects."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
client: MediaServerClient,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._client = client
|
||||||
|
self._monitor_id: int = monitor["id"]
|
||||||
|
self._attr_device_info = display_device_info(entry, monitor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _monitor(self) -> dict[str, Any]:
|
||||||
|
if self.coordinator.data is None:
|
||||||
|
return {}
|
||||||
|
return self.coordinator.data.get(self._monitor_id, {})
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayInputSourceSelect(_DisplaySelectBase):
|
||||||
|
"""Switch the monitor's active input (HDMI1, DP1, ...)."""
|
||||||
|
|
||||||
|
_attr_translation_key = "input_source"
|
||||||
|
_attr_icon = "mdi:video-input-hdmi"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
client: MediaServerClient,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_input_{self._monitor_id}"
|
||||||
|
# Available inputs are a static EDID/DDC capability — capturing them
|
||||||
|
# at discovery avoids re-allocating the option list on every poll.
|
||||||
|
self._attr_options = list(monitor.get("available_input_sources") or [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
current = self._monitor.get("input_source")
|
||||||
|
return current if current in self._attr_options else None
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
try:
|
||||||
|
result = await self._client.set_display_input_source(self._monitor_id, option)
|
||||||
|
except MediaServerError as err:
|
||||||
|
_LOGGER.error("Failed to set input source for monitor %d: %s", self._monitor_id, err)
|
||||||
|
return
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Monitor %d rejected input source %s (DDC/CI silently dropped)",
|
||||||
|
self._monitor_id, option,
|
||||||
|
)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
return
|
||||||
|
self.coordinator.apply_optimistic(self._monitor_id, input_source=option)
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayColorPresetSelect(_DisplaySelectBase):
|
||||||
|
"""Switch the monitor's color temperature preset (sRGB / 6500K / ...)."""
|
||||||
|
|
||||||
|
_attr_translation_key = "color_preset"
|
||||||
|
_attr_icon = "mdi:palette"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
client: MediaServerClient,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_color_preset_{self._monitor_id}"
|
||||||
|
self._attr_options = list(monitor.get("available_color_presets") or [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
current = self._monitor.get("color_preset")
|
||||||
|
return current if current in self._attr_options else None
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
try:
|
||||||
|
result = await self._client.set_display_color_preset(self._monitor_id, option)
|
||||||
|
except MediaServerError as err:
|
||||||
|
_LOGGER.error("Failed to set color preset for monitor %d: %s", self._monitor_id, err)
|
||||||
|
return
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Monitor %d rejected color preset %s (DDC/CI silently dropped)",
|
||||||
|
self._monitor_id, option,
|
||||||
|
)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
return
|
||||||
|
self.coordinator.apply_optimistic(self._monitor_id, color_preset=option)
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayPictureModeSelect(_DisplaySelectBase):
|
||||||
|
"""Switch the monitor's picture/scene mode via VCP 0xDC.
|
||||||
|
|
||||||
|
The server returns options as ``[{code: int, label: str}, ...]``. Labels
|
||||||
|
are exposed as user-facing options and a label→code map drives writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_translation_key = "picture_mode"
|
||||||
|
_attr_icon = "mdi:image-multiple"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
client: MediaServerClient,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_picture_mode_{self._monitor_id}"
|
||||||
|
|
||||||
|
modes = monitor.get("available_picture_modes") or []
|
||||||
|
self._label_to_code: dict[str, int] = {
|
||||||
|
mode["label"]: mode["code"]
|
||||||
|
for mode in modes
|
||||||
|
if "label" in mode and "code" in mode
|
||||||
|
}
|
||||||
|
self._attr_options = list(self._label_to_code.keys())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
current = self._monitor.get("picture_mode")
|
||||||
|
return current if current in self._attr_options else None
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
code = self._label_to_code.get(option)
|
||||||
|
if code is None:
|
||||||
|
_LOGGER.error("Unknown picture mode label: %s", option)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = await self._client.set_display_picture_mode(self._monitor_id, code)
|
||||||
|
except MediaServerError as err:
|
||||||
|
_LOGGER.error("Failed to set picture mode for monitor %d: %s", self._monitor_id, err)
|
||||||
|
return
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Monitor %d rejected picture mode %s (code %d) - monitor's DDC/CI"
|
||||||
|
" implementation of VCP 0xDC may be incomplete",
|
||||||
|
self._monitor_id, option, code,
|
||||||
|
)
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
return
|
||||||
|
self.coordinator.apply_optimistic(self._monitor_id, picture_mode=option)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Diagnostic sensors exposed per display (resolution, etc.)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
from .display_device import display_device_info
|
||||||
|
from .foreground import FOREGROUND_SENSORS
|
||||||
|
from .foreground_coordinator import ForegroundCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up display + foreground sensor entities."""
|
||||||
|
store = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
display_coordinator: DisplayCoordinator = store["display_coordinator"]
|
||||||
|
foreground_coordinator: ForegroundCoordinator | None = store.get(
|
||||||
|
"foreground_coordinator"
|
||||||
|
)
|
||||||
|
|
||||||
|
entities: list[Any] = []
|
||||||
|
|
||||||
|
if display_coordinator.data:
|
||||||
|
entities.extend(
|
||||||
|
DisplayResolutionSensor(display_coordinator, entry, monitor)
|
||||||
|
for monitor in display_coordinator.data.values()
|
||||||
|
if monitor.get("resolution")
|
||||||
|
)
|
||||||
|
|
||||||
|
if foreground_coordinator is not None:
|
||||||
|
entities.extend(
|
||||||
|
cls(foreground_coordinator, entry) for cls in FOREGROUND_SENSORS
|
||||||
|
)
|
||||||
|
|
||||||
|
if entities:
|
||||||
|
async_add_entities(entities)
|
||||||
|
_LOGGER.info("Added %d sensor entities (display + foreground)", len(entities))
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayResolutionSensor(CoordinatorEntity[DisplayCoordinator], SensorEntity):
|
||||||
|
"""Diagnostic sensor reporting the EDID-derived display resolution."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_translation_key = "resolution"
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
_attr_icon = "mdi:monitor-screenshot"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
monitor: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._monitor_id: int = monitor["id"]
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}"
|
||||||
|
self._attr_device_info = display_device_info(entry, monitor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
if self.coordinator.data is None:
|
||||||
|
return None
|
||||||
|
return self.coordinator.data.get(self._monitor_id, {}).get("resolution")
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
execute_script:
|
execute_script:
|
||||||
name: 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:
|
fields:
|
||||||
script_name:
|
script_name:
|
||||||
name: Script Name
|
name: Script Name
|
||||||
@@ -9,10 +16,29 @@ execute_script:
|
|||||||
example: "launch_spotify"
|
example: "launch_spotify"
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
args:
|
params:
|
||||||
name: Arguments
|
name: Parameters
|
||||||
description: Optional list of arguments to pass to the script
|
description: Optional named parameters to pass to the script (validated against script schema)
|
||||||
required: false
|
required: false
|
||||||
example: '["arg1", "arg2"]'
|
example: '{"level": 75, "monitor": "primary"}'
|
||||||
selector:
|
selector:
|
||||||
object:
|
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:
|
||||||
|
|||||||
@@ -8,13 +8,17 @@
|
|||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"token": "API Token",
|
"token": "API Token",
|
||||||
|
"use_ssl": "Use HTTPS",
|
||||||
|
"verify_ssl": "Verify TLS certificate",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"poll_interval": "Poll Interval"
|
"poll_interval": "Poll Interval"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Hostname or IP address of the Media Server",
|
"host": "Hostname or IP address of the Media Server",
|
||||||
"port": "Port number (default: 8765)",
|
"port": "Port number (default: 8765)",
|
||||||
"token": "API authentication token from the server configuration",
|
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
|
||||||
|
"use_ssl": "Talk to the server over HTTPS/WSS. The server must be configured with ssl_certfile and ssl_keyfile in config.yaml.",
|
||||||
|
"verify_ssl": "Verify the server's TLS certificate chain. Turn off only if the server uses a self-signed certificate on a trusted LAN.",
|
||||||
"name": "Display name for this media player",
|
"name": "Display name for this media player",
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
"poll_interval": "How often to poll for status updates (seconds)"
|
||||||
}
|
}
|
||||||
@@ -42,18 +46,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"primary_display": { "name": "Primary display" },
|
||||||
|
"power_control_supported": { "name": "Power control supported" },
|
||||||
|
"fullscreen": { "name": "Fullscreen" },
|
||||||
|
"minimized": { "name": "Minimized" }
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"resolution": { "name": "Resolution" },
|
||||||
|
"foreground_process": { "name": "Foreground process" },
|
||||||
|
"window_title": { "name": "Window title" },
|
||||||
|
"pid": { "name": "PID" },
|
||||||
|
"foreground_monitor": { "name": "Monitor" },
|
||||||
|
"process_started": { "name": "Process started" }
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"brightness": { "name": "Brightness" },
|
||||||
|
"contrast": { "name": "Contrast" }
|
||||||
|
},
|
||||||
|
"switch": {
|
||||||
|
"power": { "name": "Power" }
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"input_source": { "name": "Input source" },
|
||||||
|
"color_preset": { "name": "Color preset" },
|
||||||
|
"picture_mode": { "name": "Picture mode" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"execute_script": {
|
"execute_script": {
|
||||||
"name": "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": {
|
"fields": {
|
||||||
"script_name": {
|
"script_name": {
|
||||||
"name": "Script Name",
|
"name": "Script Name",
|
||||||
"description": "Name of the script to execute (as defined in server config)"
|
"description": "Name of the script to execute (as defined in server config)"
|
||||||
},
|
},
|
||||||
"args": {
|
"params": {
|
||||||
"name": "Arguments",
|
"name": "Parameters",
|
||||||
"description": "Optional list of arguments to pass to the script"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
|
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,21 +25,16 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up display power switch entities from a config entry."""
|
"""Set up display power switch entities from a config entry."""
|
||||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
client: MediaServerClient = data["client"]
|
||||||
|
coordinator: DisplayCoordinator = data["display_coordinator"]
|
||||||
|
|
||||||
try:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
DisplayPowerSwitch(
|
DisplayPowerSwitch(coordinator, client, entry, monitor)
|
||||||
client=client,
|
for monitor in coordinator.data.values()
|
||||||
entry=entry,
|
|
||||||
monitor=monitor,
|
|
||||||
)
|
|
||||||
for monitor in monitors
|
|
||||||
if monitor.get("power_supported", False)
|
if monitor.get("power_supported", False)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -46,80 +43,62 @@ async def async_setup_entry(
|
|||||||
_LOGGER.info("Added %d display power switch entities", len(entities))
|
_LOGGER.info("Added %d display power switch entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class DisplayPowerSwitch(SwitchEntity):
|
class DisplayPowerSwitch(CoordinatorEntity[DisplayCoordinator], SwitchEntity):
|
||||||
"""Switch entity for controlling display power."""
|
"""Switch entity for controlling display power."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||||
|
_attr_translation_key = "power"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the display power switch."""
|
super().__init__(coordinator)
|
||||||
self._client = client
|
self._client = client
|
||||||
self._entry = entry
|
|
||||||
self._monitor_id: int = monitor["id"]
|
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_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
|
||||||
self._attr_name = f"Display {display_name} Power"
|
self._attr_device_info = display_device_info(entry, monitor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _monitor(self) -> dict[str, Any]:
|
||||||
|
if self.coordinator.data is None:
|
||||||
|
return {}
|
||||||
|
return self.coordinator.data.get(self._monitor_id, {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return bool(self._monitor.get("power_on", True))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
"""Return icon based on power state."""
|
return "mdi:monitor" if self.is_on else "mdi:monitor-off"
|
||||||
return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off"
|
|
||||||
|
|
||||||
@property
|
async def _set_power(self, on: bool) -> None:
|
||||||
def device_info(self) -> DeviceInfo:
|
try:
|
||||||
"""Return device info."""
|
result = await self._client.set_display_power(self._monitor_id, on)
|
||||||
return DeviceInfo(
|
except MediaServerError as err:
|
||||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
_LOGGER.error(
|
||||||
name=self._entry.title,
|
"Failed to %s monitor %d: %s",
|
||||||
manufacturer="Remote Media Player",
|
"turn on" if on else "turn off",
|
||||||
model="Media Server",
|
self._monitor_id,
|
||||||
)
|
err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not result.get("success"):
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to %s monitor %d",
|
||||||
|
"turn on" if on else "turn off",
|
||||||
|
self._monitor_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.coordinator.apply_optimistic(self._monitor_id, power_on=on)
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the monitor on."""
|
await self._set_power(True)
|
||||||
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:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the monitor off."""
|
await self._set_power(False)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -8,13 +8,17 @@
|
|||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"token": "API Token",
|
"token": "API Token",
|
||||||
|
"use_ssl": "Use HTTPS",
|
||||||
|
"verify_ssl": "Verify TLS certificate",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"poll_interval": "Poll Interval"
|
"poll_interval": "Poll Interval"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Hostname or IP address of the Media Server",
|
"host": "Hostname or IP address of the Media Server",
|
||||||
"port": "Port number (default: 8765)",
|
"port": "Port number (default: 8765)",
|
||||||
"token": "API authentication token from the server configuration",
|
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
|
||||||
|
"use_ssl": "Talk to the server over HTTPS/WSS. The server must be configured with ssl_certfile and ssl_keyfile in config.yaml.",
|
||||||
|
"verify_ssl": "Verify the server's TLS certificate chain. Turn off only if the server uses a self-signed certificate on a trusted LAN.",
|
||||||
"name": "Display name for this media player",
|
"name": "Display name for this media player",
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
"poll_interval": "How often to poll for status updates (seconds)"
|
||||||
}
|
}
|
||||||
@@ -42,18 +46,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"primary_display": { "name": "Primary display" },
|
||||||
|
"power_control_supported": { "name": "Power control supported" },
|
||||||
|
"fullscreen": { "name": "Fullscreen" },
|
||||||
|
"minimized": { "name": "Minimized" }
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"resolution": { "name": "Resolution" },
|
||||||
|
"foreground_process": { "name": "Foreground process" },
|
||||||
|
"window_title": { "name": "Window title" },
|
||||||
|
"pid": { "name": "PID" },
|
||||||
|
"foreground_monitor": { "name": "Monitor" },
|
||||||
|
"process_started": { "name": "Process started" }
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"brightness": { "name": "Brightness" },
|
||||||
|
"contrast": { "name": "Contrast" }
|
||||||
|
},
|
||||||
|
"switch": {
|
||||||
|
"power": { "name": "Power" }
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"input_source": { "name": "Input source" },
|
||||||
|
"color_preset": { "name": "Color preset" },
|
||||||
|
"picture_mode": { "name": "Picture mode" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"execute_script": {
|
"execute_script": {
|
||||||
"name": "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": {
|
"fields": {
|
||||||
"script_name": {
|
"script_name": {
|
||||||
"name": "Script Name",
|
"name": "Script Name",
|
||||||
"description": "Name of the script to execute (as defined in server config)"
|
"description": "Name of the script to execute (as defined in server config)"
|
||||||
},
|
},
|
||||||
"args": {
|
"params": {
|
||||||
"name": "Arguments",
|
"name": "Parameters",
|
||||||
"description": "Optional list of arguments to pass to the script"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,17 @@
|
|||||||
"host": "Хост",
|
"host": "Хост",
|
||||||
"port": "Порт",
|
"port": "Порт",
|
||||||
"token": "API токен",
|
"token": "API токен",
|
||||||
|
"use_ssl": "Использовать HTTPS",
|
||||||
|
"verify_ssl": "Проверять TLS-сертификат",
|
||||||
"name": "Название",
|
"name": "Название",
|
||||||
"poll_interval": "Интервал опроса"
|
"poll_interval": "Интервал опроса"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Имя хоста или IP-адрес Media Server",
|
"host": "Имя хоста или IP-адрес Media Server",
|
||||||
"port": "Номер порта (по умолчанию: 8765)",
|
"port": "Номер порта (по умолчанию: 8765)",
|
||||||
"token": "Токен аутентификации из конфигурации сервера",
|
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
|
||||||
|
"use_ssl": "Подключаться к серверу по HTTPS/WSS. Сервер должен быть настроен с параметрами ssl_certfile и ssl_keyfile в config.yaml.",
|
||||||
|
"verify_ssl": "Проверять цепочку TLS-сертификатов сервера. Отключайте только если сервер использует самоподписанный сертификат в доверенной локальной сети.",
|
||||||
"name": "Отображаемое имя медиаплеера",
|
"name": "Отображаемое имя медиаплеера",
|
||||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
"poll_interval": "Частота опроса статуса (в секундах)"
|
||||||
}
|
}
|
||||||
@@ -42,18 +46,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"primary_display": { "name": "Основной дисплей" },
|
||||||
|
"power_control_supported": { "name": "Поддержка управления питанием" },
|
||||||
|
"fullscreen": { "name": "Полноэкранный режим" },
|
||||||
|
"minimized": { "name": "Свёрнуто" }
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"resolution": { "name": "Разрешение" },
|
||||||
|
"foreground_process": { "name": "Активный процесс" },
|
||||||
|
"window_title": { "name": "Заголовок окна" },
|
||||||
|
"pid": { "name": "PID" },
|
||||||
|
"foreground_monitor": { "name": "Монитор" },
|
||||||
|
"process_started": { "name": "Запуск процесса" }
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"brightness": { "name": "Яркость" },
|
||||||
|
"contrast": { "name": "Контрастность" }
|
||||||
|
},
|
||||||
|
"switch": {
|
||||||
|
"power": { "name": "Питание" }
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"input_source": { "name": "Источник сигнала" },
|
||||||
|
"color_preset": { "name": "Цветовая температура" },
|
||||||
|
"picture_mode": { "name": "Режим изображения" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"execute_script": {
|
"execute_script": {
|
||||||
"name": "Выполнить скрипт",
|
"name": "Выполнить скрипт",
|
||||||
"description": "Выполнить предопределённый скрипт на медиасервере.",
|
"description": "Выполнить предопределённый скрипт на одном или нескольких хабах Remote Media Player. Если цель не выбрана, скрипт выполнится на всех настроенных хабах.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"script_name": {
|
"script_name": {
|
||||||
"name": "Имя скрипта",
|
"name": "Имя скрипта",
|
||||||
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
|
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
|
||||||
},
|
},
|
||||||
"args": {
|
"params": {
|
||||||
"name": "Аргументы",
|
"name": "Параметры",
|
||||||
"description": "Необязательный список аргументов для передачи скрипту"
|
"description": "Необязательные именованные параметры для скрипта (проверяются по схеме скрипта)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"play_media_file": {
|
||||||
|
"name": "Воспроизвести медиафайл",
|
||||||
|
"description": "Запустить воспроизведение локального медиафайла на одном или нескольких хабах Remote Media Player. Если цель не выбрана, воспроизведение запустится на всех настроенных хабах.",
|
||||||
|
"fields": {
|
||||||
|
"file_path": {
|
||||||
|
"name": "Путь к файлу",
|
||||||
|
"description": "Абсолютный путь к медиафайлу на целевом хабе"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user