Screencast: cast the desktop to Cast, DLNA and AirPlay receivers
An Omarchy shell plugin: a Python/asyncio daemon that discovers network displays and serves an encoded screen capture for them to pull, plus a Quickshell bar widget and panel to drive it. The screen comes from the xdg-desktop-portal ScreenCast interface (asked for every time, never remembered), goes through GStreamer, and is served from a local HTTP port. Desktop audio is mixed in from the default output's monitor. The container follows the receiver: WebM/VP8 for Cast, MPEG-TS/H.264 for DLNA, HLS for AirPlay video. The stream port has to be reachable from the LAN, and none of these protocols can carry a credential, so the capability is the URL: a fresh random path per session, refused to anything off the LAN, capped at four concurrent readers.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from .base import Backend, BackendError
|
||||
from .cast import CastBackend
|
||||
from .dlna import DlnaBackend
|
||||
from .airplay import AirPlayBackend
|
||||
|
||||
__all__ = ["Backend", "BackendError", "CastBackend", "DlnaBackend", "AirPlayBackend", "for_kind"]
|
||||
|
||||
|
||||
def for_kind(kind: str):
|
||||
return {"cast": CastBackend, "dlna": DlnaBackend, "airplay": AirPlayBackend}.get(kind)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""AirPlay, through pyatv.
|
||||
|
||||
Caveat worth knowing: AirPlay *mirroring* (the low-latency screen protocol Macs
|
||||
and iPhones use) is closed and has no open sender implementation on Linux. What
|
||||
does work is AirPlay video playback — the receiver is handed a URL and plays it
|
||||
— so the screen goes out as a live HLS stream. That means several seconds of
|
||||
delay, and a receiver that refuses live HLS refuses the cast.
|
||||
|
||||
pyatv is optional: without it this backend reports itself unavailable instead of
|
||||
taking the whole daemon down.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from ..devices import Device
|
||||
from ..util import log, port_open, state_dir
|
||||
from .base import Backend, BackendError
|
||||
|
||||
INSTALL_HINT = "AirPlay needs pyatv: ~/.local/lib/screencast/venv/bin/pip install pyatv"
|
||||
|
||||
|
||||
async def _pingable(host: str) -> bool:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ping", "-c1", "-W1", host,
|
||||
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
||||
return await proc.wait() == 0
|
||||
|
||||
|
||||
class AirPlayBackend(Backend):
|
||||
kind = "airplay"
|
||||
containers = ("hls",)
|
||||
progressive = False
|
||||
|
||||
def __init__(self, daemon) -> None:
|
||||
super().__init__(daemon)
|
||||
self._atv = None
|
||||
self._atv_id = ""
|
||||
self._play_task: asyncio.Task | None = None
|
||||
self._storage = None
|
||||
self._pairing = None
|
||||
self._pairing_device: Device | None = None
|
||||
|
||||
# ---- pyatv plumbing ----
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
try:
|
||||
import pyatv # noqa: F401
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _storage_obj(self):
|
||||
if self._storage is None:
|
||||
from pyatv.storage.file_storage import FileStorage
|
||||
|
||||
path = state_dir() / "airplay.json"
|
||||
self._storage = FileStorage(str(path), asyncio.get_running_loop())
|
||||
await self._storage.load()
|
||||
# pyatv writes this with a plain open(); it holds the credentials
|
||||
# that let anyone play to the user's receivers.
|
||||
with contextlib.suppress(OSError):
|
||||
path.touch()
|
||||
path.chmod(0o600)
|
||||
return self._storage
|
||||
|
||||
async def _config(self, device: Device):
|
||||
"""Find the receiver again, and be specific about why when we cannot.
|
||||
|
||||
Televisions keep announcing `_airplay._tcp` over mDNS while the receiver
|
||||
itself is switched off, so "it is in the list" says nothing about whether
|
||||
it will talk to us. Check the port first: a refused connection is a
|
||||
setting the user can change, and no amount of scanning will fix it.
|
||||
"""
|
||||
import pyatv
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
storage = await self._storage_obj()
|
||||
port = device.port or 7000
|
||||
if not await port_open(device.host, port):
|
||||
reachable = await port_open(device.host, 80, 1.0) or await _pingable(device.host)
|
||||
log.warning("airplay: %s has nothing listening on %d", device.name, port)
|
||||
raise BackendError(
|
||||
f"{device.name} is on the network but its AirPlay receiver is off — "
|
||||
"turn AirPlay on in the TV's settings"
|
||||
if reachable else
|
||||
f"{device.name} is not answering — switch it on and try again"
|
||||
)
|
||||
# Unicast first (fast, and exact); some receivers only answer multicast.
|
||||
for attempt, timeout in enumerate((5, 8), start=1):
|
||||
confs = await pyatv.scan(loop, hosts=[device.host], timeout=timeout, storage=storage)
|
||||
if confs:
|
||||
if attempt > 1:
|
||||
log.info("airplay: %s answered on attempt %d", device.name, attempt)
|
||||
return confs[0]
|
||||
log.warning("airplay: %s did not answer a unicast scan (%ds)", device.name, timeout)
|
||||
confs = [c for c in await pyatv.scan(loop, timeout=8, storage=storage)
|
||||
if str(c.address) == device.host]
|
||||
if confs:
|
||||
log.info("airplay: %s only answered the multicast scan", device.name)
|
||||
return confs[0]
|
||||
raise BackendError(
|
||||
f"{device.name} accepts connections but did not identify itself — "
|
||||
"check that AirPlay is on and the TV is awake"
|
||||
)
|
||||
|
||||
async def _connect(self, device: Device):
|
||||
if self._atv is not None and self._atv_id == device.id:
|
||||
return self._atv
|
||||
if self._atv is not None:
|
||||
await self.stop(device) # a connection to a different receiver
|
||||
if not self.available():
|
||||
raise BackendError(INSTALL_HINT)
|
||||
import pyatv
|
||||
|
||||
conf = await self._config(device)
|
||||
try:
|
||||
self._atv = await pyatv.connect(
|
||||
conf, asyncio.get_running_loop(), storage=await self._storage_obj()
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("airplay connect to %s failed: %s", device.name, exc)
|
||||
if any(w in str(exc).lower() for w in ("auth", "credential", "pair")):
|
||||
raise BackendError(f"{device.name} needs pairing first") from exc
|
||||
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
|
||||
self._atv_id = device.id
|
||||
log.info("airplay: connected to %s", device.name)
|
||||
return self._atv
|
||||
|
||||
# ---- casting ----
|
||||
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||
atv = await self._connect(device)
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await atv.stream.play_url(url)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("airplay playback ended: %s", exc)
|
||||
self.daemon.note_backend_error(f"{device.name}: {exc}")
|
||||
|
||||
# play_url only returns when playback ends, so it cannot be awaited here.
|
||||
self._play_task = asyncio.create_task(run())
|
||||
await asyncio.sleep(0.5)
|
||||
if self._play_task.done():
|
||||
exc = self._play_task.exception()
|
||||
if exc is not None:
|
||||
raise BackendError(f"{device.name} refused the stream: {exc}")
|
||||
log.info("airplay: %s playing %s", device.name, url)
|
||||
|
||||
async def stop(self, device: Device) -> None:
|
||||
if self._play_task is not None:
|
||||
self._play_task.cancel()
|
||||
self._play_task = None
|
||||
atv, self._atv, self._atv_id = self._atv, None, ""
|
||||
if atv is not None:
|
||||
try:
|
||||
await atv.remote_control.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
atv.close()
|
||||
|
||||
async def set_volume(self, device: Device, volume: float) -> None:
|
||||
atv = await self._connect(device)
|
||||
await atv.audio.set_volume(max(0.0, min(1.0, volume)) * 100.0)
|
||||
|
||||
async def transport(self, device: Device, action: str) -> None:
|
||||
atv = await self._connect(device)
|
||||
rc = atv.remote_control
|
||||
fn = {"play": rc.play, "pause": rc.pause, "stop": rc.stop}.get(action)
|
||||
if fn is None:
|
||||
raise BackendError(f"unknown transport action {action}")
|
||||
await fn()
|
||||
|
||||
async def poll(self, device: Device) -> dict:
|
||||
atv = self._atv
|
||||
if atv is None:
|
||||
return {}
|
||||
out: dict = {"connected": True}
|
||||
try:
|
||||
playing = await atv.metadata.playing()
|
||||
out["playerState"] = str(playing.device_state).split(".")[-1].lower()
|
||||
out["app"] = playing.title or ""
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
out["volume"] = float(atv.audio.volume) / 100.0
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return out
|
||||
|
||||
# ---- pairing (PIN shown on the TV, typed into the panel) ----
|
||||
async def pair_begin(self, device: Device) -> None:
|
||||
if not self.available():
|
||||
raise BackendError(INSTALL_HINT)
|
||||
import pyatv
|
||||
from pyatv.const import Protocol
|
||||
|
||||
await self.pair_cancel()
|
||||
conf = await self._config(device)
|
||||
self._pairing = await pyatv.pair(
|
||||
conf, Protocol.AirPlay, asyncio.get_running_loop(), storage=await self._storage_obj()
|
||||
)
|
||||
await self._pairing.begin()
|
||||
self._pairing_device = device
|
||||
log.info("airplay: pairing with %s (%s)", device.name,
|
||||
"PIN on the TV" if self._pairing.device_provides_pin else "no PIN needed")
|
||||
if not self._pairing.device_provides_pin:
|
||||
# Some receivers want *us* to show a PIN; nothing to type in that case.
|
||||
await self._pairing.finish()
|
||||
|
||||
async def pair_pin(self, pin: str) -> bool:
|
||||
if self._pairing is None:
|
||||
raise BackendError("no pairing in progress")
|
||||
name = self._pairing_device.name if self._pairing_device else "the receiver"
|
||||
self._pairing.pin(pin)
|
||||
try:
|
||||
await self._pairing.finish()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("airplay pairing with %s failed: %s", name, exc)
|
||||
await self.pair_cancel()
|
||||
raise BackendError(f"{name} rejected the code: {exc}") from exc
|
||||
ok = bool(self._pairing.has_paired)
|
||||
log.info("airplay: pairing with %s %s", name, "succeeded" if ok else "was refused")
|
||||
if ok and self._storage is not None:
|
||||
await self._storage.save()
|
||||
# Pairing leaves its own connection behind; drop ours so the next cast
|
||||
# reconnects with the credentials we just stored.
|
||||
self._atv, self._atv_id = None, ""
|
||||
await self.pair_cancel()
|
||||
return ok
|
||||
|
||||
async def pair_cancel(self) -> None:
|
||||
pairing, self._pairing = self._pairing, None
|
||||
self._pairing_device = None
|
||||
if pairing is not None:
|
||||
try:
|
||||
await pairing.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.pair_cancel()
|
||||
if self._atv is not None or self._play_task is not None:
|
||||
await self.stop(Device(id="", kind="airplay", name="", host="", port=0))
|
||||
@@ -0,0 +1,41 @@
|
||||
"""What every receiver protocol has to provide."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..devices import Device
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Backend:
|
||||
kind = ""
|
||||
#: Containers this protocol can be handed, best first.
|
||||
containers: tuple[str, ...] = ("webm",)
|
||||
#: Does it pull a plain HTTP stream (True) or an HLS playlist (False)?
|
||||
progressive = True
|
||||
|
||||
def __init__(self, daemon) -> None:
|
||||
self.daemon = daemon
|
||||
|
||||
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def stop(self, device: Device) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_volume(self, device: Device, volume: float) -> None:
|
||||
raise BackendError("this receiver has no volume control")
|
||||
|
||||
async def set_muted(self, device: Device, muted: bool) -> None:
|
||||
raise BackendError("this receiver has no mute control")
|
||||
|
||||
async def transport(self, device: Device, action: str) -> None:
|
||||
raise BackendError("this receiver has no transport control")
|
||||
|
||||
async def poll(self, device: Device) -> dict:
|
||||
"""Live status of the session: {state, app, volume, muted, error}."""
|
||||
return {}
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Google Cast.
|
||||
|
||||
pychromecast is a threaded, blocking library; every call into it runs in a
|
||||
worker thread and its status callbacks are marshalled back onto the loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
from ..devices import Device
|
||||
from ..util import log
|
||||
from .base import Backend, BackendError
|
||||
|
||||
CONTENT_TYPES = {"webm": "video/webm", "mp4": "video/mp4", "matroska": "video/x-matroska",
|
||||
"mpegts": "video/mp2t"}
|
||||
|
||||
|
||||
class CastBackend(Backend):
|
||||
kind = "cast"
|
||||
# WebM/VP8 only. The receiver plays a WebM live stream from any cluster,
|
||||
# with no index and no seeking. GStreamer's mp4mux "streamable" output is
|
||||
# not that: a client joining after the first fragment cannot start it (the
|
||||
# TV sits on a spinner, and ffprobe hangs the same way), and the CMAF muxer
|
||||
# that would fix it lives in gst-plugins-rs, which is not a dependency worth
|
||||
# taking for a second-best path.
|
||||
containers = ("webm",)
|
||||
|
||||
def __init__(self, daemon) -> None:
|
||||
super().__init__(daemon)
|
||||
self._casts: dict[str, object] = {}
|
||||
|
||||
async def _cast(self, device: Device):
|
||||
cast = self._casts.get(device.id)
|
||||
if cast is not None and getattr(cast, "socket_client", None) is not None:
|
||||
if not cast.socket_client.stop.is_set():
|
||||
return cast
|
||||
self._casts.pop(device.id, None)
|
||||
info = device.extra.get("cast_info")
|
||||
if info is None:
|
||||
raise BackendError("the device is no longer on the network")
|
||||
zconf = self.daemon.discovery.zeroconf
|
||||
|
||||
def connect():
|
||||
import pychromecast
|
||||
|
||||
cast = pychromecast.get_chromecast_from_cast_info(info, zconf, tries=1, timeout=5)
|
||||
cast.wait(timeout=12)
|
||||
return cast
|
||||
|
||||
try:
|
||||
cast = await asyncio.to_thread(connect)
|
||||
except Exception as exc: # noqa: BLE001 - anything from the socket layer
|
||||
raise BackendError(f"could not connect to {device.name}: {exc}") from exc
|
||||
self._casts[device.id] = cast
|
||||
return cast
|
||||
|
||||
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||
cast = await self._cast(device)
|
||||
ctype = CONTENT_TYPES.get(container, "video/webm")
|
||||
|
||||
def start():
|
||||
mc = cast.media_controller
|
||||
mc.play_media(
|
||||
url, ctype, title=title, stream_type="LIVE", autoplay=True,
|
||||
metadata={"metadataType": 0, "title": title},
|
||||
)
|
||||
mc.block_until_active(timeout=20)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(start)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BackendError(f"{device.name} refused the stream: {exc}") from exc
|
||||
log.info("cast: %s playing %s (%s)", device.name, url, ctype)
|
||||
|
||||
async def stop(self, device: Device) -> None:
|
||||
cast = self._casts.get(device.id)
|
||||
if cast is None:
|
||||
return
|
||||
|
||||
def finish():
|
||||
try:
|
||||
cast.media_controller.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
cast.quit_app()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
cast.disconnect()
|
||||
|
||||
await asyncio.to_thread(finish)
|
||||
self._casts.pop(device.id, None)
|
||||
|
||||
async def set_volume(self, device: Device, volume: float) -> None:
|
||||
cast = await self._cast(device)
|
||||
await asyncio.to_thread(
|
||||
functools.partial(cast.socket_client.receiver_controller.set_volume, max(0.0, min(1.0, volume)))
|
||||
)
|
||||
|
||||
async def set_muted(self, device: Device, muted: bool) -> None:
|
||||
cast = await self._cast(device)
|
||||
await asyncio.to_thread(
|
||||
functools.partial(cast.socket_client.receiver_controller.set_volume_muted, bool(muted))
|
||||
)
|
||||
|
||||
async def transport(self, device: Device, action: str) -> None:
|
||||
cast = await self._cast(device)
|
||||
mc = cast.media_controller
|
||||
fn = {"play": mc.play, "pause": mc.pause, "stop": mc.stop}.get(action)
|
||||
if fn is None:
|
||||
raise BackendError(f"unknown transport action {action}")
|
||||
await asyncio.to_thread(fn)
|
||||
|
||||
async def poll(self, device: Device) -> dict:
|
||||
cast = self._casts.get(device.id)
|
||||
if cast is None:
|
||||
return {}
|
||||
status = cast.socket_client.receiver_controller.status
|
||||
media = cast.media_controller.status
|
||||
state = (media.player_state or "").lower() if media else ""
|
||||
return {
|
||||
"app": (status.display_name if status else "") or "",
|
||||
"volume": float(status.volume_level) if status else -1.0,
|
||||
"muted": bool(status.volume_muted) if status else False,
|
||||
"playerState": state,
|
||||
"connected": not cast.socket_client.stop.is_set(),
|
||||
}
|
||||
|
||||
async def close(self) -> None:
|
||||
for cast in list(self._casts.values()):
|
||||
try:
|
||||
await asyncio.to_thread(cast.disconnect)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._casts.clear()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""DLNA / UPnP MediaRenderer.
|
||||
|
||||
A renderer is told to fetch a URL (SetAVTransportURI + Play) and polled for what
|
||||
it is doing. Most TVs handle a live MPEG-TS stream this way; some refuse
|
||||
anything without a duration, which shows up as a Play error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from html import escape
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ..devices import Device
|
||||
from ..util import log
|
||||
from .base import Backend, BackendError
|
||||
|
||||
SOAP_ENV = (
|
||||
'<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"'
|
||||
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>{body}</s:Body></s:Envelope>'
|
||||
)
|
||||
|
||||
PROTOCOL_INFO = {
|
||||
"mpegts": "http-get:*:video/mp2t:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
|
||||
"mp4": "http-get:*:video/mp4:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=8D500000000000000000000000000000",
|
||||
"webm": "http-get:*:video/webm:*",
|
||||
}
|
||||
|
||||
|
||||
def didl(url: str, title: str, container: str) -> str:
|
||||
return (
|
||||
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"'
|
||||
' xmlns:dc="http://purl.org/dc/elements/1.1/"'
|
||||
' xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
|
||||
'<item id="0" parentID="-1" restricted="1">'
|
||||
f"<dc:title>{escape(title)}</dc:title>"
|
||||
"<upnp:class>object.item.videoItem.movie</upnp:class>"
|
||||
f'<res protocolInfo="{PROTOCOL_INFO.get(container, PROTOCOL_INFO["mpegts"])}">{escape(url)}</res>'
|
||||
"</item></DIDL-Lite>"
|
||||
)
|
||||
|
||||
|
||||
class DlnaBackend(Backend):
|
||||
kind = "dlna"
|
||||
# MPEG-TS: a renderer can start on any packet, which is what a live stream
|
||||
# needs (see the note in cast.py about fragmented MP4).
|
||||
containers = ("mpegts",)
|
||||
|
||||
def __init__(self, daemon) -> None:
|
||||
super().__init__(daemon)
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def _http(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||
return self._session
|
||||
|
||||
async def _soap(self, device: Device, service: str, action: str, args: dict) -> dict:
|
||||
control = device.extra.get("control") or {}
|
||||
url = control.get(service)
|
||||
stype = control.get(f"{service}_type")
|
||||
if not url or not stype:
|
||||
raise BackendError(f"{device.name} has no {service} service")
|
||||
# stype comes out of the device's own description XML.
|
||||
body = f'<u:{action} xmlns:u="{escape(stype, quote=True)}">' + "".join(
|
||||
f"<{k}>{escape(str(v))}</{k}>" for k, v in args.items()
|
||||
) + f"</u:{action}>"
|
||||
payload = SOAP_ENV.format(body=body).encode()
|
||||
headers = {
|
||||
"Content-Type": 'text/xml; charset="utf-8"',
|
||||
"SOAPAction": f'"{stype}#{action}"',
|
||||
"Connection": "close",
|
||||
}
|
||||
session = await self._http()
|
||||
try:
|
||||
async with session.post(url, data=payload, headers=headers) as resp:
|
||||
text = (await resp.content.read(256 << 10)).decode("utf-8", "replace")
|
||||
if resp.status >= 400:
|
||||
raise BackendError(f"{action} failed: {_fault(text) or resp.status}")
|
||||
except aiohttp.ClientError as exc:
|
||||
raise BackendError(f"{device.name} did not answer: {exc}") from exc
|
||||
return _soap_values(text)
|
||||
|
||||
async def play(self, device: Device, url: str, *, container: str, title: str) -> None:
|
||||
await self._soap(device, "avtransport", "SetAVTransportURI",
|
||||
{"InstanceID": 0, "CurrentURI": url,
|
||||
"CurrentURIMetaData": didl(url, title, container)})
|
||||
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
|
||||
log.info("dlna: %s playing %s", device.name, url)
|
||||
|
||||
async def stop(self, device: Device) -> None:
|
||||
try:
|
||||
await self._soap(device, "avtransport", "Stop", {"InstanceID": 0})
|
||||
except BackendError as exc:
|
||||
log.debug("dlna stop: %s", exc)
|
||||
|
||||
async def transport(self, device: Device, action: str) -> None:
|
||||
if action == "pause":
|
||||
await self._soap(device, "avtransport", "Pause", {"InstanceID": 0})
|
||||
elif action == "play":
|
||||
await self._soap(device, "avtransport", "Play", {"InstanceID": 0, "Speed": "1"})
|
||||
elif action == "stop":
|
||||
await self.stop(device)
|
||||
else:
|
||||
raise BackendError(f"unknown transport action {action}")
|
||||
|
||||
async def set_volume(self, device: Device, volume: float) -> None:
|
||||
await self._soap(device, "rendering", "SetVolume",
|
||||
{"InstanceID": 0, "Channel": "Master",
|
||||
"DesiredVolume": int(max(0.0, min(1.0, volume)) * 100)})
|
||||
|
||||
async def set_muted(self, device: Device, muted: bool) -> None:
|
||||
await self._soap(device, "rendering", "SetMute",
|
||||
{"InstanceID": 0, "Channel": "Master", "DesiredMute": 1 if muted else 0})
|
||||
|
||||
async def poll(self, device: Device) -> dict:
|
||||
out: dict = {}
|
||||
try:
|
||||
info = await self._soap(device, "avtransport", "GetTransportInfo", {"InstanceID": 0})
|
||||
state = (info.get("CurrentTransportState") or "").lower()
|
||||
out["playerState"] = {"playing": "playing", "paused_playback": "paused",
|
||||
"stopped": "idle", "transitioning": "buffering"}.get(state, state)
|
||||
out["connected"] = True
|
||||
except BackendError:
|
||||
out["connected"] = False
|
||||
if (device.extra.get("control") or {}).get("rendering"):
|
||||
try:
|
||||
vol = await self._soap(device, "rendering", "GetVolume",
|
||||
{"InstanceID": 0, "Channel": "Master"})
|
||||
out["volume"] = int(vol.get("CurrentVolume", 0)) / 100.0
|
||||
except BackendError:
|
||||
pass
|
||||
return out
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session is not None and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
|
||||
def _soap_values(text: str) -> dict:
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except ET.ParseError:
|
||||
return {}
|
||||
values = {}
|
||||
for body in root.iter():
|
||||
if body.tag.endswith("Body"):
|
||||
for resp in body:
|
||||
for child in resp:
|
||||
values[child.tag.split("}")[-1]] = child.text or ""
|
||||
return values
|
||||
|
||||
|
||||
def _fault(text: str) -> str:
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except ET.ParseError:
|
||||
return ""
|
||||
for el in root.iter():
|
||||
if el.tag.endswith("errorDescription"):
|
||||
return el.text or ""
|
||||
return ""
|
||||
Reference in New Issue
Block a user