Files
haos-hacs-integration-media…/custom_components/remote_media_player/config_flow.py
T
alexei.dolgolyov 97c1784ad4 feat(client): v0.3.0 server compat — WS subprotocol auth, 429 retry, HTTPS, X-Request-ID
Aligns the integration with the four wire-level changes shipped in
media-server v0.3.0/0.3.1 without breaking back-compat with older
server versions or pre-existing config entries.

- WebSocket auth via Sec-WebSocket-Protocol: media-server.token.<T>
  (preferred by server v0.3.0+). The ?token= query is still sent so
  older servers and unauthenticated mode both keep working — aiohttp
  completes the handshake even when the server doesn't echo the
  subprotocol back.
- 429 Too Many Requests surfaced as MediaServerRateLimitError with
  Retry-After parsed; execute_script() sleeps min(retry_after, 30)
  and retries once before falling through to the caller.
- Optional HTTPS/WSS (CONF_USE_SSL) + optional certificate verification
  toggle (CONF_VERIFY_SSL) wired through the config flow, client, and
  WebSocket. Defaults preserve http+verified behaviour, so existing
  config entries are unchanged.
- X-Request-ID header (uuid4 hex) on every HTTP call so HA-side issues
  can be cross-referenced with the server's access/audit logs. The
  format matches the server's ^[A-Za-z0-9._-]{1,128}\$ allow-list so
  the id is preserved verbatim instead of being replaced server-side.

Bumps manifest version to 0.3.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:37:59 +03:00

241 lines
7.5 KiB
Python

"""Config flow for Remote Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .api_client import (
MediaServerClient,
MediaServerConnectionError,
MediaServerAuthError,
)
from .const import (
DOMAIN,
CONF_TOKEN,
CONF_POLL_INTERVAL,
CONF_USE_SSL,
CONF_VERIFY_SSL,
DEFAULT_PORT,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
DEFAULT_USE_SSL,
DEFAULT_VERIFY_SSL,
)
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
Args:
hass: Home Assistant instance
data: User input data
Returns:
Validated data with title
Raises:
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.get(CONF_TOKEN, "") or "",
use_ssl=data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)
try:
health = await client.get_health()
# Try authenticated endpoint
await client.get_status()
except MediaServerConnectionError as err:
await client.close()
raise CannotConnect(str(err)) from err
except MediaServerAuthError as err:
await client.close()
raise InvalidAuth(str(err)) from err
finally:
await client.close()
# Return info to store in the config entry
return {
"title": data.get(CONF_NAME, DEFAULT_NAME),
"platform": health.get("platform", "Unknown"),
}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remote Media Player."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step.
Args:
user_input: User provided configuration
Returns:
Flow result
"""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Check if already configured
await self.async_set_unique_id(
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info["title"],
data=user_input,
)
# Show configuration form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=65535,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(CONF_TOKEN, default=""): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD
)
),
vol.Optional(
CONF_USE_SSL, default=DEFAULT_USE_SSL
): selector.BooleanSelector(),
vol.Optional(
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
): selector.BooleanSelector(),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional(
CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="seconds",
mode=selector.NumberSelectorMode.SLIDER,
)
),
}
),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow.
Args:
config_entry: Config entry
Returns:
Options flow handler
"""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for Remote Media Player."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow.
Args:
config_entry: Config entry
"""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options.
Args:
user_input: User provided options
Returns:
Flow result
"""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_POLL_INTERVAL,
default=self._config_entry.options.get(
CONF_POLL_INTERVAL,
self._config_entry.data.get(
CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL
),
),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="seconds",
mode=selector.NumberSelectorMode.SLIDER,
)
),
}
),
)
class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
class InvalidAuth(Exception):
"""Error to indicate there is invalid auth."""