feat: add Alexa camera HLS compatibility

pull/1898/head
Carlo Costanzo 2 months ago
parent b4661a2a2f
commit 99f035eb78

5
.gitignore vendored

@ -69,6 +69,11 @@ __pycache__
hacs
alexa_media
custom_components
!config/custom_components/
config/custom_components/*
!config/custom_components/alexa_camera_compat/
!config/custom_components/alexa_camera_compat/**
config/custom_components/alexa_camera_compat/__pycache__/
output/playwright/
config/www/community
config/www/ookla_speedtest/

@ -0,0 +1,21 @@
# Alexa Camera Stream Compatibility
Temporary compatibility layer for Home Assistant cameras exposed through the
Alexa CameraStreamController interface.
The integration keeps Home Assistant's normal camera response and image URL,
adds expiration and idle-timeout metadata, and replaces the Low-Latency HLS
camera URI with a short-lived MPEG-TS HLS proxy backed by the camera's go2rtc
restream. The proxy derives its host and stream name from the camera's RTSP
source, exposes only fixed playlist and segment routes, and protects each
session with a random token that expires after five minutes. It advertises
`audioCodec: NONE` in Alexa discovery and stream responses so video-only camera
feeds do not need a fabricated silent AAC producer.
Remove this integration when Home Assistant and Echo Show devices can play the
standard camera stream reliably without the compatibility route.
References:
- [Alexa CameraStreamController interface](https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-camerastreamcontroller.html)
- [Home Assistant Alexa camera stream issue](https://github.com/home-assistant/core/issues/172245)

@ -0,0 +1,256 @@
"""Add temporary-URI metadata to Alexa camera stream responses."""
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import logging
import secrets
from typing import Any
from aiohttp import ClientError, web
from yarl import URL
from homeassistant.components import camera
from homeassistant.components.alexa.capabilities import AlexaCameraStreamController
from homeassistant.components.alexa.handlers import HANDLERS
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
_DOMAIN = "alexa_camera_compat"
_CAMERA_HANDLER = ("Alexa.CameraStreamController", "InitializeCameraStreams")
_STREAM_LIFETIME = timedelta(minutes=5)
_IDLE_TIMEOUT_SECONDS = 30
_MASTER_PLAYLIST_SUFFIX = "/master_playlist.m3u8"
_MEDIA_PLAYLIST_SUFFIX = "/playlist.m3u8"
_GO2RTC_PORT = 1984
_PROXY_PATH = f"/api/{_DOMAIN}"
_ORIGINAL_CAMERA_STREAM_CONFIGURATIONS = (
AlexaCameraStreamController.camera_stream_configurations
)
def _camera_stream_configurations(
controller: AlexaCameraStreamController,
) -> list[dict[str, Any]] | None:
"""Advertise the video-only stream configuration served by this integration."""
configurations = _ORIGINAL_CAMERA_STREAM_CONFIGURATIONS(controller)
if configurations is None:
return None
return [
{**configuration, "audioCodecs": ["NONE"]}
for configuration in configurations
]
class _Go2rtcSessionExpired(Exception):
"""Raised when go2rtc has retired an idle HLS session."""
@dataclass(slots=True)
class _StreamSession:
"""Short-lived mapping from an Alexa request to a go2rtc HLS session."""
expires_at: datetime
go2rtc_base: URL
stream_name: str
stream_id: str | None = None
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class _AlexaCameraHlsView(HomeAssistantView):
"""Serve token-protected MPEG-TS HLS from the Frigate go2rtc restream."""
url = f"{_PROXY_PATH}/{{token}}/{{resource}}"
name = f"api:{_DOMAIN}:hls"
requires_auth = False
def __init__(
self, hass: HomeAssistant, sessions: dict[str, _StreamSession]
) -> None:
self._client = async_get_clientsession(hass)
self._sessions = sessions
async def _async_fetch(
self, session: _StreamSession, path: str, params: dict[str, str]
) -> tuple[bytes, str | None]:
url = session.go2rtc_base.with_path(path)
try:
async with asyncio.timeout(10):
async with self._client.get(url, params=params) as response:
if response.status == 404:
raise _Go2rtcSessionExpired
response.raise_for_status()
return await response.read(), response.headers.get("Content-Type")
except (ClientError, TimeoutError) as err:
_LOGGER.warning("Unable to fetch Alexa HLS resource from go2rtc: %s", err)
raise web.HTTPBadGateway() from err
async def _async_initialize_stream(self, session: _StreamSession) -> None:
if session.stream_id is not None:
return
async with session.lock:
if session.stream_id is not None:
return
body, _ = await self._async_fetch(
session, "/api/stream.m3u8", {"src": session.stream_name}
)
playlist_reference = next(
(
line.strip()
for line in body.decode().splitlines()
if line.strip() and not line.startswith("#")
),
None,
)
if not playlist_reference or not (
stream_id := URL(playlist_reference).query.get("id")
):
raise web.HTTPBadGateway(reason="go2rtc did not return an HLS session")
session.stream_id = stream_id
async def get(
self, request: web.Request, token: str, resource: str
) -> web.Response:
"""Return an Alexa-compatible HLS playlist or MPEG-TS segment."""
session = self._sessions.get(token)
if session is None or session.expires_at <= datetime.now(UTC):
self._sessions.pop(token, None)
raise web.HTTPNotFound()
await self._async_initialize_stream(session)
assert session.stream_id is not None
if resource == "playlist.m3u8":
try:
body, _ = await self._async_fetch(
session,
"/api/hls/playlist.m3u8",
{"id": session.stream_id},
)
except _Go2rtcSessionExpired:
session.stream_id = None
await self._async_initialize_stream(session)
assert session.stream_id is not None
try:
body, _ = await self._async_fetch(
session,
"/api/hls/playlist.m3u8",
{"id": session.stream_id},
)
except _Go2rtcSessionExpired as err:
raise web.HTTPBadGateway(
reason="go2rtc HLS session expired during startup"
) from err
playlist_lines: list[str] = []
for line in body.decode().splitlines():
if not line or line.startswith("#"):
playlist_lines.append(line)
continue
segment_number = URL(line).query.get("n")
if segment_number is None or not segment_number.isdigit():
raise web.HTTPBadGateway(reason="Unexpected go2rtc HLS segment")
playlist_lines.append(f"segment.ts?n={segment_number}")
return web.Response(
text="\n".join(playlist_lines) + "\n",
content_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-store"},
)
if resource == "segment.ts":
segment_number = request.query.get("n")
if segment_number is None or not segment_number.isdigit():
raise web.HTTPBadRequest()
try:
body, content_type = await self._async_fetch(
session,
"/api/hls/segment.ts",
{"id": session.stream_id, "n": segment_number},
)
except _Go2rtcSessionExpired as err:
raise web.HTTPNotFound() from err
return web.Response(
body=body,
content_type=(content_type or "video/mp2t").split(";", 1)[0],
headers={"Cache-Control": "no-store"},
)
raise web.HTTPNotFound()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Wrap the built-in Alexa camera response with temporary-URI metadata."""
sessions: dict[str, _StreamSession] = {}
hass.data[_DOMAIN] = sessions
hls_view = _AlexaCameraHlsView(hass, sessions)
hass.http.register_view(hls_view)
original_handler = HANDLERS[_CAMERA_HANDLER]
AlexaCameraStreamController.camera_stream_configurations = (
_camera_stream_configurations
)
async def async_initialize_camera_stream(
hass: HomeAssistant,
alexa_config: Any,
directive: Any,
context: Any,
) -> Any:
response = await original_handler(hass, alexa_config, directive, context)
expiration_time = (
datetime.now(UTC) + _STREAM_LIFETIME
).replace(microsecond=0).isoformat().replace("+00:00", "Z")
for stream in response.serialize()["event"]["payload"]["cameraStreams"]:
stream["expirationTime"] = expiration_time
stream["idleTimeoutSeconds"] = _IDLE_TIMEOUT_SECONDS
stream["audioCodec"] = "NONE"
stream_uri = URL(stream["uri"])
source = await camera.async_get_stream_source(
hass, directive.entity.entity_id
)
source_uri = URL(source) if source else None
if (
source_uri is not None
and source_uri.scheme in {"rtsp", "rtsps"}
and source_uri.host
and (stream_name := source_uri.path.rsplit("/", 1)[-1])
):
token = secrets.token_urlsafe(24)
proxy_session = _StreamSession(
expires_at=datetime.now(UTC) + _STREAM_LIFETIME,
go2rtc_base=URL.build(
scheme="http", host=source_uri.host, port=_GO2RTC_PORT
),
stream_name=stream_name,
)
sessions[token] = proxy_session
try:
await hls_view._async_initialize_stream(proxy_session)
except (web.HTTPException, _Go2rtcSessionExpired):
sessions.pop(token, None)
else:
stream["uri"] = str(
stream_uri.with_path(
f"{_PROXY_PATH}/{token}/playlist.m3u8"
).with_query(None)
)
elif stream["uri"].endswith(_MASTER_PLAYLIST_SUFFIX):
stream["uri"] = (
stream["uri"][: -len(_MASTER_PLAYLIST_SUFFIX)]
+ _MEDIA_PLAYLIST_SUFFIX
)
now = datetime.now(UTC)
for token, session in list(sessions.items()):
if session.expires_at <= now:
sessions.pop(token, None)
return response
HANDLERS[_CAMERA_HANDLER] = async_initialize_camera_stream
_LOGGER.info("Enabled Alexa video-only MPEG-TS HLS compatibility responses")
return True

@ -0,0 +1,9 @@
{
"domain": "alexa_camera_compat",
"name": "Alexa Camera Stream Compatibility",
"codeowners": ["@CCOSTAN"],
"dependencies": ["alexa"],
"documentation": "https://github.com/home-assistant/core/issues/172245",
"integration_type": "service",
"version": "2.2.0"
}

@ -9,8 +9,11 @@
# Related Issue: 858
# Notes: Wrapper wake-alarm sensors normalize Alexa Media `next_alarm` IDs.
# Notes: Current wake-alarm scope is limited to Carlo Bedroom + Stacey Bedroom.
# Notes: Alexa camera responses use temporary-URI metadata and MPEG-TS HLS from go2rtc.
######################################################################
alexa_camera_compat:
template:
- sensor:
- name: "Last Alexa"

Loading…
Cancel
Save

Powered by TurnKey Linux.