mirror of
https://github.com/koush/scrypted.git
synced 2026-08-03 03:40:45 +01:00
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""Client library for connecting to a Scrypted server over engine.io RPC.
|
|
|
|
Provides :class:`EioRpcTransport` and :func:`connect_scrypted_client`, the
|
|
importable equivalent of the bootstrap that previously only existed inline in
|
|
test.py. Typical usage::
|
|
|
|
transport, sdk = await connect_scrypted_client(
|
|
asyncio.get_event_loop(), "https://localhost:10443", username, password
|
|
)
|
|
for id in sdk.systemManager.getSystemState():
|
|
print(sdk.systemManager.getDeviceById(id).name)
|
|
await transport.close()
|
|
|
|
Must be called from a running event loop; the transport schedules its send
|
|
loop on construction.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
|
|
import aiohttp
|
|
import engineio
|
|
|
|
import plugin_remote
|
|
import rpc_reader
|
|
from cluster_setup import ClusterSetup
|
|
from plugin_remote import DeviceManager, MediaManager, SystemManager
|
|
from scrypted_python.scrypted_sdk import ScryptedStatic
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_PLUGIN_ID = "@scrypted/core"
|
|
DEFAULT_CONNECT_TIMEOUT = 30
|
|
|
|
|
|
class ScryptedConnectionError(Exception):
|
|
"""Raised when the Scrypted engine.io connection cannot be established."""
|
|
|
|
|
|
class EioRpcTransport(rpc_reader.RpcTransport):
|
|
"""RpcTransport over an engine.io connection."""
|
|
|
|
def __init__(
|
|
self,
|
|
loop: asyncio.AbstractEventLoop,
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
# When a session is provided, engineio must defer to its connector
|
|
# (ssl_verify=True skips engineio's own ssl.create_default_context,
|
|
# a blocking call some frameworks forbid on the event loop). Only
|
|
# fall back to engineio's ssl_verify=False when we have no session;
|
|
# Scrypted servers use self-signed certificates by default. engineio
|
|
# never closes externally provided sessions, so the transport takes
|
|
# ownership: close() closes the session. Callers that need to keep
|
|
# the session alive should not share it with the transport.
|
|
self._http_session = http_session
|
|
self.eio = engineio.AsyncClient(
|
|
http_session=http_session, ssl_verify=http_session is not None
|
|
)
|
|
self.loop = loop
|
|
self.write_error: Exception | None = None
|
|
self.read_queue: asyncio.Queue = asyncio.Queue()
|
|
self.write_queue: asyncio.Queue = asyncio.Queue()
|
|
self._send_task: asyncio.Task | None = None
|
|
# Set by connect_scrypted_client() so close() can tear it down.
|
|
self.read_task: asyncio.Task | None = None
|
|
|
|
@self.eio.on("message")
|
|
def on_message(data):
|
|
self.read_queue.put_nowait(data)
|
|
|
|
self._send_task = loop.create_task(self.send_loop())
|
|
|
|
async def read(self):
|
|
return await self.read_queue.get()
|
|
|
|
async def send_loop(self):
|
|
while True:
|
|
data = await self.write_queue.get()
|
|
try:
|
|
await self.eio.send(data)
|
|
except Exception as e:
|
|
# Any send failure kills the link; surface it on next write.
|
|
self.write_error = e
|
|
break
|
|
|
|
def writeBuffer(self, buffer, reject):
|
|
if self.write_error:
|
|
if reject:
|
|
reject(self.write_error)
|
|
return
|
|
self.write_queue.put_nowait(buffer)
|
|
|
|
def writeSerialized(self, j, reject):
|
|
# engineio json-encodes dict payloads on send and json-decodes text
|
|
# frames on receive, so messages cross the wire as engine.io JSON
|
|
# packets and arrive at readLoop() already deserialized to dicts.
|
|
return self.writeBuffer(j, reject)
|
|
|
|
async def close(self) -> None:
|
|
"""Tear the transport down, cancelling background tasks."""
|
|
tasks = [task for task in (self._send_task, self.read_task) if task]
|
|
self._send_task = None
|
|
self.read_task = None
|
|
for task in tasks:
|
|
task.cancel()
|
|
try:
|
|
await self.eio.disconnect()
|
|
except Exception:
|
|
logger.debug("Error disconnecting engine.io client", exc_info=True)
|
|
for task in tasks:
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
if self._http_session:
|
|
await self._http_session.close()
|
|
self._http_session = None
|
|
|
|
|
|
async def _login(
|
|
base_url: str,
|
|
username: str,
|
|
password: str,
|
|
timeout: float,
|
|
login_session: aiohttp.ClientSession | None,
|
|
) -> dict:
|
|
"""POST /login and return the parsed response.
|
|
|
|
Uses a temporary no-verify session when none is provided (and closes it);
|
|
a caller-provided session is left open. Raises ScryptedConnectionError on
|
|
any request failure.
|
|
"""
|
|
owns_session = login_session is None
|
|
session = login_session or aiohttp.ClientSession(
|
|
connector=aiohttp.TCPConnector(ssl=False)
|
|
)
|
|
try:
|
|
async with session.post(
|
|
f"{base_url}/login",
|
|
json={"username": username, "password": password},
|
|
raise_for_status=True,
|
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
) as response:
|
|
return await response.json()
|
|
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
|
raise ScryptedConnectionError(f"Login to {base_url} failed: {err}") from err
|
|
finally:
|
|
if owns_session:
|
|
await session.close()
|
|
|
|
|
|
async def connect_scrypted_client(
|
|
loop: asyncio.AbstractEventLoop,
|
|
base_url: str,
|
|
username: str,
|
|
password: str,
|
|
plugin_id: str = DEFAULT_PLUGIN_ID,
|
|
login_session: aiohttp.ClientSession | None = None,
|
|
transport: EioRpcTransport | None = None,
|
|
timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
) -> tuple[EioRpcTransport, ScryptedStatic]:
|
|
"""Login and establish the engine.io RPC session.
|
|
|
|
Returns ``(transport, sdk)``. The caller owns the transport and must
|
|
``await transport.close()`` when done. If ``login_session`` is omitted, a
|
|
temporary session with certificate verification disabled is used for the
|
|
login request only; pass a configured session to control TLS behavior.
|
|
Likewise pass a pre-built :class:`EioRpcTransport` to control the
|
|
engine.io connection's session (the transport takes ownership of that
|
|
session and closes it in ``close()``). ``timeout`` bounds each phase of
|
|
the connection: the login request, the engine.io connect, and the wait
|
|
for initial system state. Raises :class:`ScryptedConnectionError` on any
|
|
failure.
|
|
"""
|
|
try:
|
|
login_response = await _login(
|
|
base_url, username, password, timeout, login_session
|
|
)
|
|
if "authorization" not in login_response:
|
|
raise ScryptedConnectionError(
|
|
f"Login to {base_url} did not return an authorization header "
|
|
f"(response keys: {sorted(login_response)})"
|
|
)
|
|
except ScryptedConnectionError:
|
|
# A caller-provided transport already owns a session and a running
|
|
# send loop; failing before the engine.io phase must not leak them.
|
|
if transport is not None:
|
|
await transport.close()
|
|
raise
|
|
|
|
if transport is None:
|
|
transport = EioRpcTransport(loop)
|
|
try:
|
|
await asyncio.wait_for(
|
|
transport.eio.connect(
|
|
base_url,
|
|
headers={"Authorization": login_response["authorization"]},
|
|
engineio_path=f"/endpoint/{plugin_id}/engine.io/api/",
|
|
),
|
|
timeout,
|
|
)
|
|
except (engineio.exceptions.ConnectionError, asyncio.TimeoutError) as err:
|
|
await transport.close()
|
|
raise ScryptedConnectionError(f"engine.io connect failed: {err}") from err
|
|
|
|
ret: asyncio.Future[ScryptedStatic] = loop.create_future()
|
|
peer, peer_read_loop = await rpc_reader.prepare_peer_readloop(loop, transport)
|
|
peer.params["print"] = logger.debug
|
|
|
|
def get_remote(api, plugin_id_, host_info):
|
|
cluster_setup = ClusterSetup(loop, peer)
|
|
remote = plugin_remote.PluginRemote(
|
|
cluster_setup, api, plugin_id_, host_info, loop
|
|
)
|
|
wrapped = remote.setSystemState
|
|
|
|
async def remote_set_system_state(system_state):
|
|
await wrapped(system_state)
|
|
|
|
async def resolve():
|
|
if ret.done():
|
|
return
|
|
sdk = ScryptedStatic()
|
|
sdk.api = api
|
|
sdk.remote = remote
|
|
system_manager = SystemManager(api, remote.systemState)
|
|
sdk.systemManager = system_manager
|
|
sdk.deviceManager = DeviceManager(remote.nativeIds, system_manager)
|
|
sdk.mediaManager = MediaManager(await api.getMediaManager())
|
|
if not ret.done():
|
|
# PluginRemote.notify dispatches events to the attached
|
|
# systemManager's registry (same wiring as loadZip); this
|
|
# is what makes systemManager.listen() callbacks fire.
|
|
remote.systemManager = system_manager
|
|
ret.set_result(sdk)
|
|
|
|
loop.create_task(resolve())
|
|
|
|
remote.setSystemState = remote_set_system_state
|
|
return remote
|
|
|
|
peer.params["getRemote"] = get_remote
|
|
read_task = loop.create_task(peer_read_loop())
|
|
transport.read_task = read_task
|
|
|
|
def on_read_loop_done(task: asyncio.Task) -> None:
|
|
# Surface a dead link immediately instead of waiting for the timeout.
|
|
if ret.done() or task.cancelled():
|
|
return
|
|
err = task.exception()
|
|
ret.set_exception(
|
|
ScryptedConnectionError(
|
|
f"Connection to {base_url} lost during handshake: {err}"
|
|
if err
|
|
else f"Connection to {base_url} closed during handshake"
|
|
)
|
|
)
|
|
|
|
read_task.add_done_callback(on_read_loop_done)
|
|
|
|
try:
|
|
sdk = await asyncio.wait_for(asyncio.shield(ret), timeout)
|
|
except asyncio.TimeoutError as err:
|
|
await transport.close()
|
|
raise ScryptedConnectionError(
|
|
f"Timed out waiting for Scrypted system state from {base_url}"
|
|
) from err
|
|
except ScryptedConnectionError:
|
|
await transport.close()
|
|
raise
|
|
return transport, sdk
|