17 Commits

Author SHA1 Message Date
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
16 changed files with 1333 additions and 29 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. 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 Location
Integration files location: `U:\custom_components\remote_media_player` 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 ## 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` - 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 # Remote Media Player - Home Assistant Integration
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/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. 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 - Seek support with smooth timeline updates
- Displays current track info (title, artist, album, artwork) - Displays current track info (title, artist, album, artwork)
- Real-time updates via WebSocket (with HTTP polling fallback) - 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.) - **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
- Configurable via Home Assistant UI - 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. 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. 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 1. Open HACS in Home Assistant
2. Click the three dots menu > **Custom repositories** 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** 4. Select category: **Integration**
5. Click **Add** 5. Click **Add**
6. Search for "Remote Media Player" and click **Download** 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 ### 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 2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory
3. Restart Home Assistant 3. Restart Home Assistant
@@ -69,6 +70,31 @@ A full-featured media player entity with:
- Volume control and mute - Volume control and mute
- Seek functionality - Seek functionality
- Current track information - 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 ### Script Button Entities
@@ -77,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
@@ -90,8 +158,8 @@ For detailed documentation, see [custom_components/remote_media_player/README.md
## Support ## Support
- [Report an Issue](https://github.com/DolgolyovAlexei/haos-hacs-integration-media-player/issues) - [Report an Issue](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/issues)
- [Media Server Repository](https://github.com/DolgolyovAlexei/media-player-server) - [Media Server Repository](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server)
## License ## License
+30
View File
@@ -0,0 +1,30 @@
## v0.3.0 (2026-05-15)
### Migration / Behavior Changes
- Each physical monitor is now its own Home Assistant device, linked to the media-server hub via `via_device`. Existing brightness and power entities migrate to per-display devices automatically on first reload — `unique_id`s are preserved, but entities will move under new devices in the UI and can be placed in their own area/room ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- The hub keeps the `media_player` and script buttons; per-display devices hold the power switch, brightness slider, and the new DDC/CI capability entities ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
### Features
- **New diagnostic sensors**: `DisplayResolutionSensor` exposes the active resolution parsed from EDID ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New diagnostic binary sensors**: `DisplayPrimaryBinarySensor` and `DisplayPowerControlBinarySensor` make it visible why a power switch is or isn't created for a given display ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New select entities**:
- `DisplayInputSourceSelect` — switch active input (HDMI1, DP1, etc.) via DDC/CI
- `DisplayColorPresetSelect` — color temperature presets
- `DisplayPictureModeSelect` — VCP 0xDC scene modes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New number entity**: `DisplayContrastNumber` exposed alongside the existing brightness slider ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- Per-display devices now show real manufacturer/model pulled from EDID; device names no longer prepend the hub title (the hierarchy is already shown via `via_device`) ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- Select and number entities verify the server's `success` flag and re-sync from the actual monitor state when a write is silently rejected — some monitors honor DDC/CI reads but ignore writes for certain codes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
### Performance
- `api_client` no longer forces `?refresh=true` on every poll, letting the integration ride the media server's TTL cache instead of triggering a full DDC/CI probe per entity update ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded) | feat(displays): per-display devices + DDC/CI capability entities | alexei.dolgolyov |
</details>
@@ -14,26 +14,41 @@ from homeassistant.helpers import config_validation as cv
from .api_client import MediaServerClient, MediaServerError from .api_client import MediaServerClient, MediaServerError
from .const import ( from .const import (
ATTR_SCRIPT_ARGS, ATTR_FILE_PATH,
ATTR_SCRIPT_NAME, ATTR_SCRIPT_NAME,
ATTR_SCRIPT_PARAMS,
CONF_HOST, CONF_HOST,
CONF_PORT, CONF_PORT,
CONF_TOKEN, CONF_TOKEN,
DOMAIN, DOMAIN,
SERVICE_EXECUTE_SCRIPT, SERVICE_EXECUTE_SCRIPT,
SERVICE_PLAY_MEDIA_FILE,
) )
_LOGGER = logging.getLogger(__name__) _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 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] }
), )
# Service schema for play_media_file
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FILE_PATH): cv.string,
} }
) )
@@ -74,10 +89,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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 media server."""
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, {})
_LOGGER.debug( _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 # Get all clients and execute on all of them
@@ -85,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for entry_id, data in hass.data[DOMAIN].items(): for entry_id, data in hass.data[DOMAIN].items():
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",
@@ -111,6 +126,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
schema=SERVICE_EXECUTE_SCRIPT_SCHEMA, 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 # Forward setup to platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -149,6 +187,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Remove services if this was the last entry # Remove services if this was the last entry
if not hass.data[DOMAIN]: if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT) hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT)
hass.services.async_remove(DOMAIN, SERVICE_PLAY_MEDIA_FILE)
return unload_ok return unload_ok
@@ -22,8 +22,21 @@ from .const import (
API_VOLUME, API_VOLUME,
API_MUTE, API_MUTE,
API_SEEK, API_SEEK,
API_TURN_ON,
API_TURN_OFF,
API_TOGGLE,
API_SCRIPTS_LIST, API_SCRIPTS_LIST,
API_SCRIPTS_EXECUTE, API_SCRIPTS_EXECUTE,
API_BROWSER_FOLDERS,
API_BROWSER_BROWSE,
API_BROWSER_PLAY,
API_DISPLAY_MONITORS,
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__) _LOGGER = logging.getLogger(__name__)
@@ -245,6 +258,30 @@ class MediaServerClient:
""" """
return await self._request("POST", API_SEEK, {"position": position}) 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]]: async def list_scripts(self) -> list[dict[str, Any]]:
"""List available scripts on the server. """List available scripts on the server.
@@ -254,21 +291,126 @@ 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.
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) 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}
)
class MediaServerWebSocket: class MediaServerWebSocket:
"""WebSocket client for real-time media status updates.""" """WebSocket client for real-time media status updates."""
@@ -280,6 +422,7 @@ class MediaServerWebSocket:
token: str, token: str,
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,
) -> None: ) -> None:
"""Initialize the WebSocket client. """Initialize the WebSocket client.
@@ -289,12 +432,14 @@ class MediaServerWebSocket:
token: API authentication token token: API authentication token
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
""" """
self._host = host self._host = host
self._port = int(port) self._port = int(port)
self._token = token self._token = token
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._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}" self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None self._ws: aiohttp.ClientWebSocketResponse | None = None
@@ -374,6 +519,10 @@ class MediaServerWebSocket:
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}" f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
) )
self._on_status_update(status_data) 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 == "pong": elif msg_type == "pong":
_LOGGER.debug("Received pong") _LOGGER.debug("Received pong")
@@ -0,0 +1,116 @@
"""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 .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
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 binary sensor entities."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities: list[Any] = []
for monitor in monitors:
entities.append(DisplayPrimaryBinarySensor(client, entry, monitor))
entities.append(DisplayPowerControlBinarySensor(client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display binary sensor entities", len(entities))
class _DisplayBinarySensorBase(BinarySensorEntity):
"""Common boilerplate for per-display diagnostic binary sensors."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_device_info = display_device_info(entry, monitor)
class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase):
"""Indicates whether the display is the OS primary monitor."""
_attr_name = "Primary display"
_attr_icon = "mdi:monitor-star"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_primary_{self._monitor_id}"
self._attr_is_on = bool(monitor.get("is_primary"))
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_is_on = bool(monitor.get("is_primary"))
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh primary flag for monitor %d: %s", self._monitor_id, err)
class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
"""Indicates whether DDC/CI power control is available for this display."""
_attr_name = "Power control supported"
_attr_icon = "mdi:power-plug"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
self._attr_is_on = bool(monitor.get("power_supported"))
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_is_on = bool(monitor.get("power_supported"))
break
except MediaServerError as err:
_LOGGER.error(
"Failed to refresh power_supported flag for monitor %d: %s",
self._monitor_id, err,
)
+17 -2
View File
@@ -15,7 +15,7 @@ DEFAULT_PORT = 8765
DEFAULT_POLL_INTERVAL = 5 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 = 30 DEFAULT_RECONNECT_INTERVAL = 5
# API endpoints # API endpoints
API_HEALTH = "/api/health" API_HEALTH = "/api/health"
@@ -28,13 +28,28 @@ API_PREVIOUS = "/api/media/previous"
API_VOLUME = "/api/media/volume" API_VOLUME = "/api/media/volume"
API_MUTE = "/api/media/mute" API_MUTE = "/api/media/mute"
API_SEEK = "/api/media/seek" 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_LIST = "/api/scripts/list"
API_SCRIPTS_EXECUTE = "/api/scripts/execute" API_SCRIPTS_EXECUTE = "/api/scripts/execute"
API_WEBSOCKET = "/api/media/ws" API_WEBSOCKET = "/api/media/ws"
API_BROWSER_FOLDERS = "/api/browser/folders"
API_BROWSER_BROWSE = "/api/browser/browse"
API_BROWSER_PLAY = "/api/browser/play"
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 names
SERVICE_EXECUTE_SCRIPT = "execute_script" SERVICE_EXECUTE_SCRIPT = "execute_script"
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"
@@ -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,
)
@@ -4,9 +4,9 @@
"codeowners": [], "codeowners": [],
"config_flow": true, "config_flow": true,
"dependencies": [], "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", "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.0"
} }
@@ -8,11 +8,16 @@ from datetime import datetime, timedelta
from typing import Any from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
) )
from homeassistant.components.media_player.const import (
MediaClass,
)
from urllib.parse import quote, unquote
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@@ -82,6 +87,7 @@ 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,
entry=entry,
) )
# Set up WebSocket connection if enabled # Set up WebSocket connection if enabled
@@ -118,6 +124,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: int, port: int,
token: str, token: str,
use_websocket: bool = True, use_websocket: bool = True,
entry: ConfigEntry | None = None,
) -> None: ) -> None:
"""Initialize the coordinator. """Initialize the coordinator.
@@ -129,6 +136,7 @@ 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
entry: Config entry (for integration reload on scripts change)
""" """
super().__init__( super().__init__(
hass, hass,
@@ -141,6 +149,7 @@ 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._entry = entry
self._ws_client: MediaServerWebSocket | None = None self._ws_client: MediaServerWebSocket | None = None
self._ws_connected = False self._ws_connected = False
self._reconnect_task: asyncio.Task | None = None self._reconnect_task: asyncio.Task | None = None
@@ -162,6 +171,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
token=self._token, token=self._token,
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,
) )
if await self._ws_client.connect(): if await self._ws_client.connect():
@@ -189,9 +199,24 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Re-enable polling as fallback # Re-enable polling as fallback
self.update_interval = timedelta(seconds=self._poll_interval) self.update_interval = timedelta(seconds=self._poll_interval)
_LOGGER.warning("WebSocket disconnected, falling back to polling") _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 # Schedule reconnect attempt
self._schedule_reconnect() self._schedule_reconnect()
@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: def _schedule_reconnect(self) -> None:
"""Schedule a WebSocket reconnection attempt.""" """Schedule a WebSocket reconnection attempt."""
if self._reconnect_task and not self._reconnect_task.done(): if self._reconnect_task and not self._reconnect_task.done():
@@ -285,6 +310,10 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
) )
@property @property
@@ -356,7 +385,12 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
if self.coordinator.data is None: if self.coordinator.data is None:
return None return None
duration = self.coordinator.data.get("duration") 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 @property
def media_position(self) -> int | None: def media_position(self) -> int | None:
@@ -364,7 +398,12 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
if self.coordinator.data is None: if self.coordinator.data is None:
return None return None
position = self.coordinator.data.get("position") 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 @property
def media_position_updated_at(self) -> datetime | None: def media_position_updated_at(self) -> datetime | None:
@@ -450,3 +489,172 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
except MediaServerError as err: except MediaServerError as err:
_LOGGER.error("Failed to seek: %s", 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,145 @@
"""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 .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
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."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities: list[Any] = []
for monitor in monitors:
if monitor.get("brightness") is not None:
entities.append(DisplayBrightnessNumber(client, entry, monitor))
if monitor.get("contrast_supported"):
entities.append(DisplayContrastNumber(client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display number entities", len(entities))
class DisplayBrightnessNumber(NumberEntity):
"""Number entity for controlling display brightness."""
_attr_has_entity_name = True
_attr_name = "Brightness"
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
_attr_native_unit_of_measurement = "%"
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display brightness entity."""
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._attr_native_value = monitor.get("brightness")
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
async def async_set_native_value(self, value: float) -> None:
"""Set the brightness value."""
try:
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:
_LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err)
async def async_update(self) -> None:
"""Fetch updated brightness from the server."""
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_native_value = monitor.get("brightness")
break
except MediaServerError as err:
_LOGGER.error("Failed to update brightness for monitor %d: %s", self._monitor_id, err)
class DisplayContrastNumber(NumberEntity):
"""Number entity for controlling DDC/CI display contrast."""
_attr_has_entity_name = True
_attr_name = "Contrast"
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
_attr_native_unit_of_measurement = "%"
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:contrast-circle"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display contrast entity."""
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_native_value = monitor.get("contrast")
self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
async def async_set_native_value(self, value: float) -> None:
"""Set the contrast value."""
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"):
_LOGGER.warning(
"Monitor %d rejected contrast %d (DDC/CI silently dropped)",
self._monitor_id, int(value),
)
await self.async_update()
self.async_write_ha_state()
return
self._attr_native_value = int(value)
self.async_write_ha_state()
async def async_update(self) -> None:
"""Fetch updated contrast from the server."""
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_native_value = monitor.get("contrast")
break
except MediaServerError as err:
_LOGGER.error("Failed to update contrast for monitor %d: %s", self._monitor_id, err)
@@ -0,0 +1,219 @@
"""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 .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
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."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities: list[Any] = []
for monitor in monitors:
if monitor.get("input_source_supported") and monitor.get("available_input_sources"):
entities.append(DisplayInputSourceSelect(client, entry, monitor))
if monitor.get("color_preset_supported") and monitor.get("available_color_presets"):
entities.append(DisplayColorPresetSelect(client, entry, monitor))
if monitor.get("picture_mode_supported") and monitor.get("available_picture_modes"):
entities.append(DisplayPictureModeSelect(client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display select entities", len(entities))
class _DisplaySelectBase(SelectEntity):
"""Shared base for per-display selects."""
_attr_has_entity_name = True
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_device_info = display_device_info(entry, monitor)
class DisplayInputSourceSelect(_DisplaySelectBase):
"""Switch the monitor's active input (HDMI1, DP1, ...)."""
_attr_name = "Input source"
_attr_icon = "mdi:video-input-hdmi"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_input_{self._monitor_id}"
self._attr_options = list(monitor.get("available_input_sources") or [])
current = monitor.get("input_source")
self._attr_current_option = 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,
)
# Re-read so the entity state reflects what the monitor actually did.
await self.async_update()
self.async_write_ha_state()
return
self._attr_current_option = option
self.async_write_ha_state()
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
current = monitor.get("input_source")
self._attr_current_option = current if current in self._attr_options else None
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh input source for monitor %d: %s", self._monitor_id, err)
class DisplayColorPresetSelect(_DisplaySelectBase):
"""Switch the monitor's color temperature preset (sRGB / 6500K / ...)."""
_attr_name = "Color preset"
_attr_icon = "mdi:palette"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(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 [])
current = monitor.get("color_preset")
self._attr_current_option = 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.async_update()
self.async_write_ha_state()
return
self._attr_current_option = option
self.async_write_ha_state()
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
current = monitor.get("color_preset")
self._attr_current_option = current if current in self._attr_options else None
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh color preset for monitor %d: %s", self._monitor_id, err)
class DisplayPictureModeSelect(_DisplaySelectBase):
"""Switch the monitor's picture/scene mode via VCP 0xDC.
The server returns options as `[{code: int, label: str}, ...]`. We use
labels as the user-facing options and keep a label→code map for writes.
"""
_attr_name = "Picture mode"
_attr_icon = "mdi:image-multiple"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(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())
current = monitor.get("picture_mode")
self._attr_current_option = 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.async_update()
self.async_write_ha_state()
return
self._attr_current_option = option
self.async_write_ha_state()
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
current = monitor.get("picture_mode")
self._attr_current_option = current if current in self._attr_options else None
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh picture mode for monitor %d: %s", self._monitor_id, err)
@@ -0,0 +1,76 @@
"""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 .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
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 sensor entities."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities = [
DisplayResolutionSensor(client, entry, monitor)
for monitor in monitors
if monitor.get("resolution")
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display sensor entities", len(entities))
class DisplayResolutionSensor(SensorEntity):
"""Diagnostic sensor reporting the EDID-derived display resolution."""
_attr_has_entity_name = True
_attr_name = "Resolution"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:monitor-screenshot"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display resolution sensor."""
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_native_value = monitor.get("resolution")
self._attr_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
async def async_update(self) -> None:
"""Refresh resolution from the server (rarely changes)."""
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_native_value = monitor.get("resolution")
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh resolution for monitor %d: %s", self._monitor_id, err)
@@ -9,10 +9,10 @@ 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:
@@ -0,0 +1,108 @@
"""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 .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
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."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities = [
DisplayPowerSwitch(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
if monitor.get("power_supported", False)
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display power switch entities", len(entities))
class DisplayPowerSwitch(SwitchEntity):
"""Switch entity for controlling display power."""
_attr_has_entity_name = True
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_name = "Power"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display power switch."""
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._attr_is_on = monitor.get("power_on", True)
self._attr_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
@property
def icon(self) -> str:
"""Return icon based on power state."""
return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off"
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the monitor on."""
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:
"""Turn the monitor off."""
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)