20 Commits

Author SHA1 Message Date
alexei.dolgolyov b92b69b0e8 chore: release v0.3.1
Release / release (push) Successful in 4s
2026-05-18 03:18:09 +03:00
alexei.dolgolyov 9d277276b8 feat(foreground): foreground process sensors + translation key migration
Adds Home Assistant entities for the foreground-process feature shipped
in the media server, plus migrates existing display entities to use HA
translation keys (strings.json / translations/*) so per-language UI text
flows through the standard locale mechanism.

Foreground entities (all share one HA "Foreground" device linked to the
hub via via_device):
- sensor.foreground_process — process name as state + full payload
  (pid, exec path, window title, fullscreen flag, monitor, geometry,
  is_browser, browser_page_title, browser_url, error) as attributes
- sensor.window_title, sensor.pid, sensor.foreground_monitor,
  sensor.process_started (TIMESTAMP device class)
- binary_sensor.fullscreen, binary_sensor.minimized

Data flow:
- ForegroundCoordinator polls GET /api/foreground every 5s (HTTP fallback)
- media_player's WebSocket receiver forwards `foreground` /
  `foreground_update` push frames into the coordinator via
  apply_websocket_snapshot, so sensors update in near-real-time when WS
  is connected and fall back to polling otherwise

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:13:23 +03:00
alexei.dolgolyov ab0585278c feat: shared DisplayCoordinator + optional API token
- Introduce DisplayCoordinator polling /api/display/monitors once per
  cycle and fan out to all per-display entities via CoordinatorEntity.
  Removes ~9x redundant requests per polling cycle that came from each
  binary_sensor/number/select/sensor/switch entity calling
  get_display_monitors() in its own async_update.
- Optimistic write-through via coordinator.apply_optimistic(...) keeps
  sibling entities in sync after slider/select writes without an extra
  network round-trip.
- Make CONF_TOKEN optional. The media server already supports running
  without auth (auth_enabled() returns False when api_tokens is empty),
  so the integration omits the Authorization header and ?token= query
  from REST/WS/album-art URLs when no token is configured. Server-side
  auth-enabled rejections still surface as invalid_auth in the UI.
- Bump manifest version to 0.3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:46:26 +03:00
alexei.dolgolyov 68e338de4e chore: release v0.3.0
Release / release (push) Successful in 3s
2026-05-15 14:52:48 +03:00
alexei.dolgolyov 4156dedf5e feat(displays): per-display devices + DDC/CI capability entities
Restructure how displays are exposed in Home Assistant:

Each physical monitor is now its own HA device linked to the media-server
hub via `via_device`. The hub keeps the media_player + script buttons; per-
display devices hold the power switch, brightness slider, and the new
capability entities. This lets users place displays in their own area/room
and keeps related entities grouped together in the UI.

New platforms:
- sensor: DisplayResolutionSensor (diagnostic, from EDID)
- binary_sensor: DisplayPrimaryBinarySensor + DisplayPowerControlBinarySensor
  (both diagnostic; help users see why a power switch is or isn't created)
- select: DisplayInputSourceSelect (HDMI1/DP1/...), DisplayColorPresetSelect
  (color temperature), DisplayPictureModeSelect (VCP 0xDC scene modes)
- number: added DisplayContrastNumber alongside brightness

Other changes:
- display_device helper centralises the per-display DeviceInfo; pulls real
  manufacturer/model from EDID; device name no longer prepends the hub
  title since via_device already shows the hierarchy.
- api_client gains set_display_{contrast,input_source,color_preset,picture_mode}
  and stops forcing `?refresh=true` on every poll so HA can ride the
  server's TTL cache instead of triggering full DDC/CI probes per entity.
- select / number entities now check the server's `success` flag and re-
  sync from the actual monitor state when a write was silently rejected
  (some monitors honor reads but ignore writes for certain DDC/CI codes).

Bumps manifest.json to 0.3.0 - the device topology change is user-visible
and existing brightness/power entities migrate to per-display devices on
first reload (unique_ids are preserved).
2026-05-15 14:46:50 +03:00
alexei.dolgolyov b0d98a9d45 chore: bump manifest.json version to 0.1.1
Release / release (push) Successful in 4s
2026-03-26 21:41:06 +03:00
alexei.dolgolyov d0d4958843 chore: update release notes for v0.1.1
Release / release (push) Successful in 4s
2026-03-26 21:36:27 +03:00
alexei.dolgolyov de4b7cf9b4 feat: replace script args with typed named parameters
- Change execute_script API from positional args list to named params dict
- Update service schema, API client, and constants
- Add execute_script service documentation to README
2026-03-26 21:35:51 +03:00
alexei.dolgolyov f84cfec43f chore: update release notes for v0.1.0
Release / release (push) Successful in 3s
2026-03-26 00:45:14 +03:00
alexei.dolgolyov 6c5657618f ci: add Gitea release workflow 2026-03-26 00:44:11 +03:00
alexei.dolgolyov a37eb46003 Codebase audit fixes: stability and performance
- Safe int conversion for position/duration (catch ValueError/TypeError)
- Hoist get_media_folders() out of browse loop (N+1 → 1 API call)
- Fix path separator detection alongside folder metadata fetch
- Increase browse pagination limit from 1000 to 5000

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 03:44:25 +03:00
alexei.dolgolyov 37988331eb Update CLAUDE.md with commit/push approval rules 2026-02-04 20:29:54 +03:00
alexei.dolgolyov b13aa86594 Add versioning rules to CLAUDE.md 2026-02-04 20:28:05 +03:00
alexei.dolgolyov b3624e66e1 Replace GitHub URLs with git.dolgolyov-family.by 2026-02-04 20:20:25 +03:00
23 changed files with 1837 additions and 43 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
# Detect pre-release (alpha/beta/rc)
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
IS_PRE="true"
fi
# Read release notes if present
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
fi
BODY_JSON=$(python3 -c "
import json, os
notes = os.environ.get('RELEASE_NOTES', '')
print(json.dumps(notes.strip()))
")
# Create release via Gitea API
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$VERSION\",
\"body\": $BODY_JSON,
\"draft\": false,
\"prerelease\": $IS_PRE
}")
# Fallback: if release already exists for this tag, reuse it
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
if [ -z "$RELEASE_ID" ]; then
echo "::warning::Release already exists for tag $TAG — reusing existing release"
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $DEPLOY_TOKEN")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
fi
echo "Created release $RELEASE_ID for $TAG"
+10 -2
View File
@@ -14,13 +14,21 @@ Or install via HACS as a custom repository.
Requires Media Server running on the target PC.
Media Server Repository: [media-player-server](https://github.com/DolgolyovAlexei/media-player-server)
Media Server Repository: [media-player-server](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
## Integration Location
Integration files location: `U:\custom_components\remote_media_player`
## Versioning
Version is tracked in `custom_components/remote_media_player/manifest.json` - `version` field.
Update this field when releasing a new version.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
## Git Rules
- Always ask for user approval before committing changes to git.
- **ALWAYS ask for user approval before committing and pushing changes.**
- When pushing, always push to all remotes: `git push origin master && git push github master`
+74 -6
View File
@@ -1,7 +1,7 @@
# Remote Media Player - Home Assistant Integration
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub Release](https://img.shields.io/github/v/release/DolgolyovAlexei/haos-hacs-integration-media-player)](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/releases)
[![GitHub Release](https://img.shields.io/github/v/release/DolgolyovAlexei/haos-hacs-integration-media-player)](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/releases)
A Home Assistant custom integration that allows you to control a remote PC's media playback as a media player entity.
@@ -12,6 +12,7 @@ A Home Assistant custom integration that allows you to control a remote PC's med
- Seek support with smooth timeline updates
- Displays current track info (title, artist, album, artwork)
- Real-time updates via WebSocket (with HTTP polling fallback)
- **Turn on/off/toggle support** - Execute custom actions (e.g., lock screen on turn off)
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
- Configurable via Home Assistant UI
@@ -24,7 +25,7 @@ A Home Assistant custom integration that allows you to control a remote PC's med
This integration requires the Media Server to be running on the PC you want to control.
**Media Server Repository:** [media-player-server](https://github.com/DolgolyovAlexei/media-player-server)
**Media Server Repository:** [media-player-server](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
See the Media Server documentation for installation and setup instructions.
@@ -34,7 +35,7 @@ See the Media Server documentation for installation and setup instructions.
1. Open HACS in Home Assistant
2. Click the three dots menu > **Custom repositories**
3. Add this repository URL: `https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player`
3. Add this repository URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player`
4. Select category: **Integration**
5. Click **Add**
6. Search for "Remote Media Player" and click **Download**
@@ -42,7 +43,7 @@ See the Media Server documentation for installation and setup instructions.
### Manual Installation
1. Download the latest release from the [Releases](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/releases) page
1. Download the latest release from the [Releases](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/releases) page
2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory
3. Restart Home Assistant
@@ -69,6 +70,31 @@ A full-featured media player entity with:
- Volume control and mute
- Seek functionality
- Current track information
- Turn on/off/toggle actions (execute server-side callbacks)
### Turn On/Off/Toggle
The media player supports `media_player.turn_on`, `media_player.turn_off`, and `media_player.toggle` actions. These execute optional callbacks configured on the Media Server (e.g., lock screen on turn off).
Configure callbacks in Media Server's `config.yaml`:
```yaml
callbacks:
on_turn_on:
command: "echo PC turned on"
timeout: 10
shell: true
on_turn_off:
command: "rundll32.exe user32.dll,LockWorkStation"
timeout: 5
shell: true
on_toggle:
command: "echo Toggle triggered"
timeout: 10
shell: true
```
### Script Button Entities
@@ -77,6 +103,48 @@ Button entities for each script defined on your Media Server:
- Shutdown, restart, sleep, hibernate
- 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
```yaml
@@ -90,8 +158,8 @@ For detailed documentation, see [custom_components/remote_media_player/README.md
## Support
- [Report an Issue](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/issues)
- [Media Server Repository](https://github.com/DolgolyovAlexei/media-player-server)
- [Report an Issue](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/issues)
- [Media Server Repository](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
## License
+28
View File
@@ -0,0 +1,28 @@
## v0.3.1 (2026-05-18)
### Features
- **Foreground process sensors** — new "Foreground" device (linked to the hub via `via_device`) exposing what's currently in focus on the remote PC:
- `sensor.foreground_process` — process name as state, with full payload (PID, exec path, window title, fullscreen flag, monitor, geometry, browser detection, browser page title/URL, error) as attributes
- `sensor.window_title`, `sensor.pid`, `sensor.foreground_monitor`, `sensor.process_started` (TIMESTAMP device class)
- `binary_sensor.fullscreen`, `binary_sensor.minimized`
- Fed by a new `ForegroundCoordinator` polling `GET /api/foreground` every 5s, with near-real-time updates via the existing WebSocket (`foreground` / `foreground_update` push frames flow into the coordinator) ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727))
- **Optional API token** — `CONF_TOKEN` is now optional. When the media server runs without `api_tokens` configured (auth disabled), the integration omits the `Authorization` header and `?token=` query from REST, WebSocket, and album-art URLs. Server-side auth rejections still surface as `invalid_auth` in the UI ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
### Performance
- **Shared `DisplayCoordinator`** — a single `/api/display/monitors` poll per cycle is now fanned out to all per-display entities (binary sensors, numbers, selects, sensors, switches) via `CoordinatorEntity`. Removes ~9x redundant requests per polling cycle that previously came from each entity calling `get_display_monitors()` in its own `async_update` ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
- **Optimistic write-through** — `coordinator.apply_optimistic(...)` keeps sibling entities in sync after slider/select writes without an extra network round-trip ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
### UI / Localization
- Display entities migrated to Home Assistant **translation keys** (`strings.json` / `translations/*`), so per-language UI text flows through the standard locale mechanism instead of hardcoded English strings ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727) | feat(foreground): foreground process sensors + translation key migration | alexei.dolgolyov |
| [ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852) | feat: shared DisplayCoordinator + optional API token | alexei.dolgolyov |
</details>
@@ -14,26 +14,43 @@ from homeassistant.helpers import config_validation as cv
from .api_client import MediaServerClient, MediaServerError
from .const import (
ATTR_SCRIPT_ARGS,
ATTR_FILE_PATH,
ATTR_SCRIPT_NAME,
ATTR_SCRIPT_PARAMS,
CONF_HOST,
CONF_PORT,
CONF_TOKEN,
DOMAIN,
SERVICE_EXECUTE_SCRIPT,
SERVICE_PLAY_MEDIA_FILE,
)
from .display_coordinator import DisplayCoordinator
from .foreground_coordinator import ForegroundCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON]
PLATFORMS: list[Platform] = [
Platform.MEDIA_PLAYER,
Platform.BUTTON,
Platform.NUMBER,
Platform.SWITCH,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SELECT,
]
# Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_SCRIPT_NAME): cv.string,
vol.Optional(ATTR_SCRIPT_ARGS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict,
}
)
# Service schema for play_media_file
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FILE_PATH): cv.string,
}
)
@@ -63,10 +80,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await client.close()
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
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"client": client,
"display_coordinator": display_coordinator,
"foreground_coordinator": foreground_coordinator,
}
# Register services if not already registered
@@ -74,10 +110,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_execute_script(call: ServiceCall) -> dict[str, Any]:
"""Execute a script on the media server."""
script_name = call.data[ATTR_SCRIPT_NAME]
script_args = call.data.get(ATTR_SCRIPT_ARGS, [])
script_params = call.data.get(ATTR_SCRIPT_PARAMS, {})
_LOGGER.debug(
"Executing script '%s' with args: %s", script_name, script_args
"Executing script '%s' with params: %s", script_name, script_params
)
# Get all clients and execute on all of them
@@ -85,7 +121,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for entry_id, data in hass.data[DOMAIN].items():
client: MediaServerClient = data["client"]
try:
result = await client.execute_script(script_name, script_args)
result = await client.execute_script(script_name, script_params)
results[entry_id] = result
_LOGGER.info(
"Script '%s' executed on %s: success=%s",
@@ -111,6 +147,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
schema=SERVICE_EXECUTE_SCRIPT_SCHEMA,
)
# Register play_media_file service if not already registered
if not hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA_FILE):
async def async_play_media_file(call: ServiceCall) -> None:
"""Handle play_media_file service call."""
file_path = call.data[ATTR_FILE_PATH]
_LOGGER.debug("Service play_media_file called with path: %s", file_path)
# Execute on all configured media server instances
for entry_id, data in hass.data[DOMAIN].items():
client: MediaServerClient = data["client"]
try:
await client.play_media_file(file_path)
_LOGGER.info("Started playback of %s on %s", file_path, entry_id)
except MediaServerError as err:
_LOGGER.error("Failed to play %s on %s: %s", file_path, entry_id, err)
hass.services.async_register(
DOMAIN,
SERVICE_PLAY_MEDIA_FILE,
async_play_media_file,
schema=SERVICE_PLAY_MEDIA_FILE_SCHEMA,
)
# Forward setup to platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -149,6 +208,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Remove services if this was the last entry
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT)
hass.services.async_remove(DOMAIN, SERVICE_PLAY_MEDIA_FILE)
return unload_ok
@@ -22,8 +22,22 @@ from .const import (
API_VOLUME,
API_MUTE,
API_SEEK,
API_TURN_ON,
API_TURN_OFF,
API_TOGGLE,
API_SCRIPTS_LIST,
API_SCRIPTS_EXECUTE,
API_BROWSER_FOLDERS,
API_BROWSER_BROWSE,
API_BROWSER_PLAY,
API_DISPLAY_MONITORS,
API_FOREGROUND,
API_DISPLAY_BRIGHTNESS,
API_DISPLAY_POWER,
API_DISPLAY_CONTRAST,
API_DISPLAY_INPUT_SOURCE,
API_DISPLAY_COLOR_PRESET,
API_DISPLAY_PICTURE_MODE,
)
_LOGGER = logging.getLogger(__name__)
@@ -79,11 +93,16 @@ class MediaServerClient:
await self._session.close()
def _get_headers(self) -> dict[str, str]:
"""Get headers for API requests."""
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
"""Get headers for API requests.
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.
"""
headers = {"Content-Type": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
async def _request(
self,
@@ -165,13 +184,17 @@ class MediaServerClient:
"""
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("/"):
# Add track info hash to force HA to re-fetch when track changes
import hashlib
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}"
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
@@ -245,6 +268,30 @@ class MediaServerClient:
"""
return await self._request("POST", API_SEEK, {"position": position})
async def turn_on(self) -> dict[str, Any]:
"""Send turn on command.
Returns:
Response data
"""
return await self._request("POST", API_TURN_ON)
async def turn_off(self) -> dict[str, Any]:
"""Send turn off command.
Returns:
Response data
"""
return await self._request("POST", API_TURN_OFF)
async def toggle(self) -> dict[str, Any]:
"""Send toggle command.
Returns:
Response data
"""
return await self._request("POST", API_TOGGLE)
async def list_scripts(self) -> list[dict[str, Any]]:
"""List available scripts on the server.
@@ -254,21 +301,135 @@ class MediaServerClient:
return await self._request("GET", API_SCRIPTS_LIST)
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]:
"""Execute a script on the server.
Args:
script_name: Name of the script to execute
args: Optional list of arguments to pass to the script
params: Optional named parameters (validated against script schema)
Returns:
Execution result with success, exit_code, stdout, stderr
"""
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)
async def get_media_folders(self) -> dict[str, dict[str, Any]]:
"""Get configured media folders.
Returns:
Dictionary of folders with folder_id as key and folder config as value
"""
return await self._request("GET", API_BROWSER_FOLDERS)
async def browse_folder(
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100
) -> dict[str, Any]:
"""Browse a media folder.
Args:
folder_id: ID of the folder to browse
path: Path within the folder (empty for root)
offset: Pagination offset
limit: Number of items to return
Returns:
Dictionary with current_path, parent_path, items, total, offset, limit
"""
params = {
"folder_id": folder_id,
"path": path,
"offset": offset,
"limit": limit,
}
query_string = "&".join(f"{k}={v}" for k, v in params.items())
endpoint = f"{API_BROWSER_BROWSE}?{query_string}"
return await self._request("GET", endpoint)
async def play_media_file(self, file_path: str) -> dict[str, Any]:
"""Play a media file by absolute path.
Args:
file_path: Absolute path to the media file
Returns:
Response data with success status
"""
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
async def get_display_monitors(self) -> list[dict[str, Any]]:
"""Get list of connected monitors with brightness, power, DDC/CI state.
Uses the server's short TTL cache so per-entity polling does not pay
the full DDC/CI probe cost on every call.
"""
return await self._request("GET", API_DISPLAY_MONITORS)
async def set_display_brightness(self, monitor_id: int, brightness: int) -> dict[str, Any]:
"""Set brightness for a specific monitor.
Args:
monitor_id: Monitor index
brightness: Brightness level (0-100)
Returns:
Response data with success status
"""
return await self._request(
"POST", f"{API_DISPLAY_BRIGHTNESS}/{monitor_id}", {"brightness": brightness}
)
async def set_display_power(self, monitor_id: int, on: bool) -> dict[str, Any]:
"""Set power state for a specific monitor.
Args:
monitor_id: Monitor index
on: True to turn on, False to turn off
Returns:
Response data with success status
"""
return await self._request(
"POST", f"{API_DISPLAY_POWER}/{monitor_id}", {"on": on}
)
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:
"""WebSocket client for real-time media status updates."""
@@ -280,6 +441,8 @@ class MediaServerWebSocket:
token: str,
on_status_update: Callable[[dict[str, Any]], None],
on_disconnect: Callable[[], None] | None = None,
on_scripts_changed: Callable[[], None] | None = None,
on_foreground_update: Callable[[dict[str, Any]], None] | None = None,
) -> None:
"""Initialize the WebSocket client.
@@ -289,13 +452,21 @@ class MediaServerWebSocket:
token: API authentication token
on_status_update: Callback when status update received
on_disconnect: Callback when connection lost
on_scripts_changed: Callback when scripts have changed
on_foreground_update: Callback when foreground process changes
"""
self._host = host
self._port = int(port)
self._token = token
self._on_status_update = on_status_update
self._on_disconnect = on_disconnect
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
self._on_scripts_changed = on_scripts_changed
self._on_foreground_update = on_foreground_update
# 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.
token_query = f"?token={token}" if token else ""
self._ws_url = f"ws://{host}:{self._port}/api/media/ws{token_query}"
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._receive_task: asyncio.Task | None = None
@@ -369,11 +540,19 @@ class MediaServerWebSocket:
):
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
token_param = f"token={self._token}&" if self._token else ""
status_data["album_art_url"] = (
f"http://{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)
elif msg_type == "scripts_changed":
_LOGGER.info("Scripts changed notification received")
if 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":
_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"))
@@ -44,10 +44,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
CannotConnect: If connection 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(
host=data[CONF_HOST],
port=data[CONF_PORT],
token=data[CONF_TOKEN],
token=data.get(CONF_TOKEN, "") or "",
)
try:
@@ -125,7 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Required(CONF_TOKEN): selector.TextSelector(
vol.Optional(CONF_TOKEN, default=""): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD
)
+22 -2
View File
@@ -15,7 +15,11 @@ DEFAULT_PORT = 8765
DEFAULT_POLL_INTERVAL = 5
DEFAULT_NAME = "Remote Media Player"
DEFAULT_USE_WEBSOCKET = True
DEFAULT_RECONNECT_INTERVAL = 30
DEFAULT_RECONNECT_INTERVAL = 5
# 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_HEALTH = "/api/health"
@@ -28,13 +32,29 @@ API_PREVIOUS = "/api/media/previous"
API_VOLUME = "/api/media/volume"
API_MUTE = "/api/media/mute"
API_SEEK = "/api/media/seek"
API_TURN_ON = "/api/media/turn_on"
API_TURN_OFF = "/api/media/turn_off"
API_TOGGLE = "/api/media/toggle"
API_SCRIPTS_LIST = "/api/scripts/list"
API_SCRIPTS_EXECUTE = "/api/scripts/execute"
API_WEBSOCKET = "/api/media/ws"
API_BROWSER_FOLDERS = "/api/browser/folders"
API_BROWSER_BROWSE = "/api/browser/browse"
API_BROWSER_PLAY = "/api/browser/play"
API_FOREGROUND = "/api/foreground"
API_DISPLAY_MONITORS = "/api/display/monitors"
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
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_EXECUTE_SCRIPT = "execute_script"
SERVICE_PLAY_MEDIA_FILE = "play_media_file"
# Service attributes
ATTR_SCRIPT_NAME = "script_name"
ATTR_SCRIPT_ARGS = "args"
ATTR_SCRIPT_PARAMS = "params"
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)
@@ -4,9 +4,9 @@
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player",
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
"version": "1.0.0"
"version": "0.3.1"
}
@@ -8,11 +8,16 @@ from datetime import datetime, timedelta
from typing import Any
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.components.media_player.const import (
MediaClass,
)
from urllib.parse import quote, unquote
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
@@ -82,6 +87,7 @@ async def async_setup_entry(
port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN],
use_websocket=use_websocket,
entry=entry,
)
# Set up WebSocket connection if enabled
@@ -118,6 +124,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: int,
token: str,
use_websocket: bool = True,
entry: ConfigEntry | None = None,
) -> None:
"""Initialize the coordinator.
@@ -129,6 +136,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: Server port
token: API token
use_websocket: Whether to use WebSocket for updates
entry: Config entry (for integration reload on scripts change)
"""
super().__init__(
hass,
@@ -141,6 +149,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._port = port
self._token = token
self._use_websocket = use_websocket
self._entry = entry
self._ws_client: MediaServerWebSocket | None = None
self._ws_connected = False
self._reconnect_task: asyncio.Task | None = None
@@ -162,6 +171,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
token=self._token,
on_status_update=self._handle_ws_status_update,
on_disconnect=self._handle_ws_disconnect,
on_scripts_changed=self._handle_ws_scripts_changed,
on_foreground_update=self._handle_ws_foreground_update,
)
if await self._ws_client.connect():
@@ -189,9 +200,37 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Re-enable polling as fallback
self.update_interval = timedelta(seconds=self._poll_interval)
_LOGGER.warning("WebSocket disconnected, falling back to polling")
# Trigger an immediate refresh to restart the polling loop.
# Without this, the polling loop stays stopped (it was disabled when
# WebSocket was active) and the entity never becomes unavailable.
self.hass.async_create_task(self.async_request_refresh())
# Schedule reconnect attempt
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
def _handle_ws_scripts_changed(self) -> None:
"""Handle scripts changed notification from WebSocket."""
if self._entry:
_LOGGER.info("Scripts changed, reloading integration")
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._entry.entry_id)
)
else:
_LOGGER.warning("Cannot reload integration: entry not available")
def _schedule_reconnect(self) -> None:
"""Schedule a WebSocket reconnection attempt."""
if self._reconnect_task and not self._reconnect_task.done():
@@ -285,6 +324,10 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
)
@property
@@ -356,7 +399,12 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
if self.coordinator.data is None:
return None
duration = self.coordinator.data.get("duration")
return int(duration) if duration is not None else None
if duration is None:
return None
try:
return int(duration)
except (ValueError, TypeError):
return None
@property
def media_position(self) -> int | None:
@@ -364,7 +412,12 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
if self.coordinator.data is None:
return None
position = self.coordinator.data.get("position")
return int(position) if position is not None else None
if position is None:
return None
try:
return int(position)
except (ValueError, TypeError):
return None
@property
def media_position_updated_at(self) -> datetime | None:
@@ -450,3 +503,172 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to seek: %s", err)
async def async_turn_on(self) -> None:
"""Send turn on command."""
try:
await self.coordinator.client.turn_on()
except MediaServerError as err:
_LOGGER.error("Failed to turn on: %s", err)
async def async_turn_off(self) -> None:
"""Send turn off command."""
try:
await self.coordinator.client.turn_off()
except MediaServerError as err:
_LOGGER.error("Failed to turn off: %s", err)
async def async_toggle(self) -> None:
"""Send toggle command."""
try:
await self.coordinator.client.toggle()
except MediaServerError as err:
_LOGGER.error("Failed to toggle: %s", err)
# Media Browser support
@staticmethod
def _encode_media_id(folder_id: str, path: str = "") -> str:
"""Encode folder_id and path into media_content_id.
Format: folder_id|encoded_path
Root folder: folder_id|
"""
return f"{folder_id}|{quote(path, safe='')}"
@staticmethod
def _decode_media_id(media_content_id: str) -> tuple[str, str]:
"""Decode media_content_id into folder_id and path.
Returns:
Tuple of (folder_id, path)
"""
if not media_content_id or "|" not in media_content_id:
return "", ""
folder_id, encoded_path = media_content_id.split("|", 1)
path = unquote(encoded_path) if encoded_path else ""
return folder_id, path
async def async_browse_media(
self,
media_content_type: str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the media browsing.
Args:
media_content_type: Type of media (unused, but required by HA)
media_content_id: ID in format "folder_id|path" or None for root
Returns:
BrowseMedia object with children
"""
_LOGGER.debug("Browse media: type=%s, id=%s", media_content_type, media_content_id)
# Root level - list all folders
if not media_content_id:
folders = await self.coordinator.client.get_media_folders()
children = [
BrowseMedia(
title=config["label"],
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC, # All folders show as music
media_content_id=self._encode_media_id(folder_id, ""),
can_play=False,
can_expand=True,
)
for folder_id, config in folders.items()
if config.get("enabled", True)
]
return BrowseMedia(
title="Media Folders",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
media_content_id="",
can_play=False,
can_expand=True,
children=children,
)
# Browse specific folder
folder_id, path = self._decode_media_id(media_content_id)
if not folder_id:
raise ValueError("Invalid media_content_id format")
# Get folder contents from API
browse_data = await self.coordinator.client.browse_folder(folder_id, path, offset=0, limit=5000)
# Fetch folder metadata once (not per-item) for building absolute paths
folders = await self.coordinator.client.get_media_folders()
base_path = folders.get(folder_id, {}).get("path", "")
# Detect path separator from server's base_path (Unix vs Windows)
separator = '\\' if '\\' in base_path else '/'
base_path_clean = base_path.rstrip('/\\')
children = []
for item in browse_data.get("items", []):
if item["type"] == "folder":
# Subfolder
item_path = f"{path}/{item['name']}" if path else item['name']
children.append(
BrowseMedia(
title=item["name"],
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
media_content_id=self._encode_media_id(folder_id, item_path),
can_play=False,
can_expand=True,
)
)
elif item.get("is_media", False):
# Media file - build absolute path for playback
file_path_in_folder = f"{path}/{item['name']}" if path else item['name']
absolute_path = f"{base_path_clean}{separator}{file_path_in_folder.replace('/', separator)}"
children.append(
BrowseMedia(
title=item["name"],
media_class=MediaClass.MUSIC,
media_content_type=MediaType.MUSIC,
media_content_id=absolute_path, # Use absolute path as ID for playback
can_play=True,
can_expand=False,
)
)
# Get current folder label
current_title = path.split("/")[-1] if path else browse_data.get("label", folder_id)
return BrowseMedia(
title=current_title,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.MUSIC,
media_content_id=media_content_id,
can_play=False,
can_expand=True,
children=children,
)
async def async_play_media(
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
"""Play a media file.
Args:
media_type: Type of media (unused)
media_id: Absolute file path to media file
**kwargs: Additional arguments (unused)
"""
_LOGGER.debug("Play media: type=%s, id=%s", media_type, media_id)
try:
# media_id is the absolute file path from browse_media
await self.coordinator.client.play_media_file(media_id)
# Request immediate status update
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to play media file: %s", err)
@@ -0,0 +1,142 @@
"""Number platform for Remote Media Player integration (display brightness)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_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 display brightness + contrast number entities from a config entry."""
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("brightness") is not None:
entities.append(DisplayBrightnessNumber(coordinator, client, entry, monitor))
if monitor.get("contrast_supported"):
entities.append(DisplayContrastNumber(coordinator, client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display number entities", len(entities))
class _DisplayNumberBase(CoordinatorEntity[DisplayCoordinator], NumberEntity):
"""Shared boilerplate for per-display number entities."""
_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
_attr_native_unit_of_measurement = "%"
_attr_mode = NumberMode.SLIDER
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 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:
try:
await self._client.set_display_brightness(self._monitor_id, int(value))
except MediaServerError as 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))
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:
result = await self._client.set_display_contrast(self._monitor_id, int(value))
except MediaServerError as 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")
@@ -9,10 +9,10 @@ execute_script:
example: "launch_spotify"
selector:
text:
args:
name: Arguments
description: Optional list of arguments to pass to the script
params:
name: Parameters
description: Optional named parameters to pass to the script (validated against script schema)
required: false
example: '["arg1", "arg2"]'
example: '{"level": 75, "monitor": "primary"}'
selector:
object:
@@ -14,7 +14,7 @@
"data_description": {
"host": "Hostname or IP address of the Media Server",
"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.",
"name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)"
}
@@ -42,6 +42,34 @@
}
}
},
"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": {
"execute_script": {
"name": "Execute Script",
@@ -0,0 +1,104 @@
"""Switch platform for Remote Media Player integration (display power)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
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 display power switch entities from a config entry."""
data = hass.data[DOMAIN][entry.entry_id]
client: MediaServerClient = data["client"]
coordinator: DisplayCoordinator = data["display_coordinator"]
if not coordinator.data:
return
entities = [
DisplayPowerSwitch(coordinator, client, entry, monitor)
for monitor in coordinator.data.values()
if monitor.get("power_supported", False)
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display power switch entities", len(entities))
class DisplayPowerSwitch(CoordinatorEntity[DisplayCoordinator], SwitchEntity):
"""Switch entity for controlling display power."""
_attr_has_entity_name = True
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_translation_key = "power"
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_unique_id = f"{entry.entry_id}_display_power_{self._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, {})
@property
def is_on(self) -> bool:
return bool(self._monitor.get("power_on", True))
@property
def icon(self) -> str:
return "mdi:monitor" if self.is_on else "mdi:monitor-off"
async def _set_power(self, on: bool) -> None:
try:
result = await self._client.set_display_power(self._monitor_id, on)
except MediaServerError as err:
_LOGGER.error(
"Failed to %s monitor %d: %s",
"turn on" if on else "turn off",
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:
await self._set_power(True)
async def async_turn_off(self, **kwargs: Any) -> None:
await self._set_power(False)
@@ -14,7 +14,7 @@
"data_description": {
"host": "Hostname or IP address of the Media Server",
"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.",
"name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)"
}
@@ -42,6 +42,34 @@
}
}
},
"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": {
"execute_script": {
"name": "Execute Script",
@@ -14,7 +14,7 @@
"data_description": {
"host": "Имя хоста или IP-адрес Media Server",
"port": "Номер порта (по умолчанию: 8765)",
"token": "Токен аутентификации из конфигурации сервера",
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
"name": "Отображаемое имя медиаплеера",
"poll_interval": "Частота опроса статуса (в секундах)"
}
@@ -42,6 +42,34 @@
}
}
},
"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": {
"execute_script": {
"name": "Выполнить скрипт",