5 Commits

Author SHA1 Message Date
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
8 changed files with 423 additions and 12 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"
+53
View File
@@ -0,0 +1,53 @@
## v0.1.0 (2026-03-26)
Initial release of the Remote Media Player custom integration for Home Assistant.
### Features
- HACS-ready Home Assistant custom integration for controlling remote PC media playback ([7837714](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/7837714))
- Add turn on / turn off / toggle support for the media player entity ([e66f2f3](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e66f2f3))
- Add automatic script reload support ([e4eeb2a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e4eeb2a))
- Add media browser integration for Home Assistant ([8cbe33e](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/8cbe33e))
- Add display monitor brightness and power control entities ([83153db](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/83153db))
### Bug Fixes
- Fix entity not becoming unavailable on server shutdown ([02bdcc5](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/02bdcc5))
### Performance
- Reduce WebSocket reconnect interval to 5 seconds ([959c6a4](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/959c6a4))
- Codebase audit fixes: stability and performance ([a37eb46](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/a37eb46))
---
### Development / Internal
#### Documentation
- Update README with valid repository URLs ([f2b618a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/f2b618a))
- Replace GitHub URLs with git.dolgolyov-family.by ([b3624e6](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b3624e6))
- Update CLAUDE.md with git push rules, versioning rules, and commit approval rules ([725fc02](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/725fc02), [b13aa86](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b13aa86), [3798833](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/3798833))
#### CI/Build
- Add Gitea release workflow ([6c56576](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/6c56576))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [7837714](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/7837714) | Initial commit: HACS-ready Home Assistant integration | alexei.dolgolyov |
| [f2b618a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/f2b618a) | Update README with valid GitHub repository URLs | alexei.dolgolyov |
| [725fc02](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/725fc02) | Update CLAUDE.md with git push rules and repo link | alexei.dolgolyov |
| [b3624e6](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b3624e6) | Replace GitHub URLs with git.dolgolyov-family.by | alexei.dolgolyov |
| [b13aa86](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b13aa86) | Add versioning rules to CLAUDE.md | alexei.dolgolyov |
| [3798833](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/3798833) | Update CLAUDE.md with commit/push approval rules | alexei.dolgolyov |
| [e66f2f3](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e66f2f3) | Add turn_on/turn_off/toggle support | alexei.dolgolyov |
| [959c6a4](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/959c6a4) | Reduce WebSocket reconnect interval to 5 seconds | alexei.dolgolyov |
| [e4eeb2a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e4eeb2a) | Add automatic script reload support | alexei.dolgolyov |
| [8cbe33e](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/8cbe33e) | Add media browser integration for Home Assistant | alexei.dolgolyov |
| [02bdcc5](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/02bdcc5) | Fix entity not becoming unavailable on server shutdown | alexei.dolgolyov |
| [83153db](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/83153db) | Add display monitor brightness and power control entities | alexei.dolgolyov |
| [a37eb46](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/a37eb46) | Codebase audit fixes: stability and performance | alexei.dolgolyov |
| [6c56576](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/6c56576) | ci: add Gitea release workflow | alexei.dolgolyov |
</details>
@@ -27,7 +27,7 @@ from .const import (
_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]
# Service schema for execute_script # Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema( SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
@@ -30,6 +30,9 @@ from .const import (
API_BROWSER_FOLDERS, API_BROWSER_FOLDERS,
API_BROWSER_BROWSE, API_BROWSER_BROWSE,
API_BROWSER_PLAY, API_BROWSER_PLAY,
API_DISPLAY_MONITORS,
API_DISPLAY_BRIGHTNESS,
API_DISPLAY_POWER,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -342,6 +345,42 @@ class MediaServerClient:
""" """
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path}) return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
async def get_display_monitors(self) -> list[dict[str, Any]]:
"""Get list of connected monitors with brightness and power info.
Returns:
List of monitor dicts with id, name, brightness, power_supported, power_on, resolution
"""
return await self._request("GET", f"{API_DISPLAY_MONITORS}?refresh=true")
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}
)
class MediaServerWebSocket: class MediaServerWebSocket:
"""WebSocket client for real-time media status updates.""" """WebSocket client for real-time media status updates."""
@@ -37,6 +37,9 @@ API_WEBSOCKET = "/api/media/ws"
API_BROWSER_FOLDERS = "/api/browser/folders" API_BROWSER_FOLDERS = "/api/browser/folders"
API_BROWSER_BROWSE = "/api/browser/browse" API_BROWSER_BROWSE = "/api/browser/browse"
API_BROWSER_PLAY = "/api/browser/play" API_BROWSER_PLAY = "/api/browser/play"
API_DISPLAY_MONITORS = "/api/display/monitors"
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
API_DISPLAY_POWER = "/api/display/power"
# Service names # Service names
SERVICE_EXECUTE_SCRIPT = "execute_script" SERVICE_EXECUTE_SCRIPT = "execute_script"
@@ -199,6 +199,10 @@ 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()
@@ -381,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:
@@ -389,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:
@@ -571,7 +585,14 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
raise ValueError("Invalid media_content_id format") raise ValueError("Invalid media_content_id format")
# Get folder contents from API # Get folder contents from API
browse_data = await self.coordinator.client.browse_folder(folder_id, path, offset=0, limit=1000) 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 = [] children = []
for item in browse_data.get("items", []): for item in browse_data.get("items", []):
@@ -589,15 +610,8 @@ class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPl
) )
) )
elif item.get("is_media", False): elif item.get("is_media", False):
# Media file # Media file - build absolute path for playback
# Build absolute path for playback
folders = await self.coordinator.client.get_media_folders()
base_path = folders[folder_id]["path"]
file_path_in_folder = f"{path}/{item['name']}" if path else item['name'] file_path_in_folder = f"{path}/{item['name']}" if path else item['name']
# Handle platform path separators
separator = '\\' if '\\' in base_path else '/'
# Ensure base_path doesn't end with separator to avoid double separators
base_path_clean = base_path.rstrip('/\\')
absolute_path = f"{base_path_clean}{separator}{file_path_in_folder.replace('/', separator)}" absolute_path = f"{base_path_clean}{separator}{file_path_in_folder.replace('/', separator)}"
children.append( children.append(
@@ -0,0 +1,110 @@
"""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 import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display brightness 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 = [
DisplayBrightnessNumber(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
if monitor.get("brightness") is not None
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display brightness entities", len(entities))
class DisplayBrightnessNumber(NumberEntity):
"""Number entity for controlling display brightness."""
_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
_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._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_native_value = monitor.get("brightness")
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
self._attr_name = f"Display {display_name} Brightness"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
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)
@@ -0,0 +1,125 @@
"""Switch platform for Remote Media Player integration (display power)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
_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
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._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_is_on = monitor.get("power_on", True)
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
self._attr_name = f"Display {display_name} Power"
@property
def icon(self) -> str:
"""Return icon based on power state."""
return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
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)