lotse-client 0.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lotse_client/__init__.py +30 -0
- lotse_client/_base.py +19 -0
- lotse_client/_commands.py +315 -0
- lotse_client/client.py +364 -0
- lotse_client/errors.py +48 -0
- lotse_client/models.py +1648 -0
- lotse_client/py.typed +0 -0
- lotse_client/subscription.py +108 -0
- lotse_client/version.py +43 -0
- lotse_client-0.0.0.dev0.dist-info/METADATA +83 -0
- lotse_client-0.0.0.dev0.dist-info/RECORD +14 -0
- lotse_client-0.0.0.dev0.dist-info/WHEEL +4 -0
- lotse_client-0.0.0.dev0.dist-info/licenses/LICENSE-APACHE +202 -0
- lotse_client-0.0.0.dev0.dist-info/licenses/LICENSE-MIT +22 -0
lotse_client/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Async client for the control API of lotse, the media daemon for RTSP cameras and WebRTC viewers.
|
|
2
|
+
|
|
3
|
+
`LotseClient` speaks the API over the daemon's Unix socket; the payload types
|
|
4
|
+
are in `lotse_client.models`, generated with the command methods from the
|
|
5
|
+
daemon's JSON Schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from lotse_client.client import LotseClient
|
|
9
|
+
from lotse_client.errors import (
|
|
10
|
+
ApiVersionError,
|
|
11
|
+
CannotConnectError,
|
|
12
|
+
CommandError,
|
|
13
|
+
ConnectionClosedError,
|
|
14
|
+
LotseError,
|
|
15
|
+
ProtocolError,
|
|
16
|
+
)
|
|
17
|
+
from lotse_client.subscription import Subscription
|
|
18
|
+
from lotse_client.version import API_VERSION
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"API_VERSION",
|
|
22
|
+
"ApiVersionError",
|
|
23
|
+
"CannotConnectError",
|
|
24
|
+
"CommandError",
|
|
25
|
+
"ConnectionClosedError",
|
|
26
|
+
"LotseClient",
|
|
27
|
+
"LotseError",
|
|
28
|
+
"ProtocolError",
|
|
29
|
+
"Subscription",
|
|
30
|
+
]
|
lotse_client/_base.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The base class of every generated model.
|
|
2
|
+
|
|
3
|
+
Encoding omits fields that are `None`: the daemon rejects a `null` where the
|
|
4
|
+
Rust type has a default but no `Option` (a source's `options`, for one), and
|
|
5
|
+
leaving a field out always means its default. Decoding ignores fields it does
|
|
6
|
+
not know, because a newer daemon may add them without an API version bump.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from mashumaro.config import BaseConfig
|
|
10
|
+
from mashumaro.mixins.orjson import DataClassORJSONMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Model(DataClassORJSONMixin):
|
|
14
|
+
"""A message type of the control API, encoded and decoded by mashumaro."""
|
|
15
|
+
|
|
16
|
+
class Config(BaseConfig):
|
|
17
|
+
"""Leave out `None` when encoding."""
|
|
18
|
+
|
|
19
|
+
omit_none = True
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Every command of the control API as a typed method.
|
|
2
|
+
|
|
3
|
+
Generated by `scripts/generate_commands.py` from `schema/api.json` and the
|
|
4
|
+
models datamodel-code-generator made from it. Do not edit: change the Rust
|
|
5
|
+
types in lotse, copy its bundle to `schema/`, then run `mise run schema` (or
|
|
6
|
+
`uv run python scripts/generate_commands.py` in `python/`).
|
|
7
|
+
|
|
8
|
+
A command named `domain/verb` is the method `verb` of the `domain` namespace
|
|
9
|
+
(`stream/put` is `client.stream.put`); one without a slash is a method of the
|
|
10
|
+
client. Each method builds the generated command class, sends it through the
|
|
11
|
+
transport and decodes the result the bundle names for it. A subscribing
|
|
12
|
+
command, one the bundle lists an event type for, returns a `Subscription` of
|
|
13
|
+
those events instead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
18
|
+
|
|
19
|
+
from mashumaro.codecs.basic import BasicDecoder
|
|
20
|
+
|
|
21
|
+
from lotse_client.models import (
|
|
22
|
+
AudioMode,
|
|
23
|
+
IceServer,
|
|
24
|
+
InfoCommand,
|
|
25
|
+
InfoResult,
|
|
26
|
+
Metrics,
|
|
27
|
+
MetricsGetCommand,
|
|
28
|
+
Orientation,
|
|
29
|
+
PingCommand,
|
|
30
|
+
SchemaCommand,
|
|
31
|
+
Session,
|
|
32
|
+
SessionAdoptCommand,
|
|
33
|
+
SessionCloseCommand,
|
|
34
|
+
SessionEvent,
|
|
35
|
+
SessionGetCommand,
|
|
36
|
+
SessionList,
|
|
37
|
+
SessionListCommand,
|
|
38
|
+
SourceSpec,
|
|
39
|
+
Stream,
|
|
40
|
+
StreamDeleteCommand,
|
|
41
|
+
StreamEvent,
|
|
42
|
+
StreamGetCommand,
|
|
43
|
+
StreamList,
|
|
44
|
+
StreamListCommand,
|
|
45
|
+
StreamPutCommand,
|
|
46
|
+
StreamPutResult,
|
|
47
|
+
StreamSubscribeCommand,
|
|
48
|
+
UnsubscribeCommand,
|
|
49
|
+
WebrtcCandidateCommand,
|
|
50
|
+
WebrtcOfferCommand,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from collections.abc import Awaitable, Callable
|
|
55
|
+
|
|
56
|
+
from lotse_client._base import Model
|
|
57
|
+
from lotse_client.subscription import Subscription
|
|
58
|
+
|
|
59
|
+
NEW = 0
|
|
60
|
+
"""The `id` a method gives its command; the transport replaces it with the next id."""
|
|
61
|
+
|
|
62
|
+
STREAM_EVENTS: BasicDecoder[StreamEvent] = BasicDecoder(StreamEvent)
|
|
63
|
+
"""Decodes the events of `stream/subscribe`."""
|
|
64
|
+
|
|
65
|
+
SESSION_EVENTS: BasicDecoder[SessionEvent] = BasicDecoder(SessionEvent)
|
|
66
|
+
"""Decodes the events of `webrtc/offer`, `session/adopt`."""
|
|
67
|
+
|
|
68
|
+
type Request = Callable[[Model], Awaitable[dict[str, Any]]]
|
|
69
|
+
"""The transport's `_request`: sends a command, returns its `result`."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Subscribe(Protocol):
|
|
73
|
+
"""The transport's `_subscribe`: a subscribing command, its events."""
|
|
74
|
+
|
|
75
|
+
async def __call__[E](self, command: Model, events: BasicDecoder[E]) -> Subscription[E]: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MetricsCommands:
|
|
79
|
+
"""The `metrics/...` commands, as `client.metrics.<verb>(...)`."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, request: Request, subscribe: Subscribe) -> None:
|
|
82
|
+
"""Bind the transport's two primitives."""
|
|
83
|
+
self._request = request
|
|
84
|
+
self._subscribe = subscribe
|
|
85
|
+
|
|
86
|
+
async def get(self) -> Metrics:
|
|
87
|
+
"""`metrics/get`: process, worker and stream counters."""
|
|
88
|
+
command = MetricsGetCommand(id=NEW)
|
|
89
|
+
return Metrics.from_dict(await self._request(command))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class StreamCommands:
|
|
93
|
+
"""The `stream/...` commands, as `client.stream.<verb>(...)`."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, request: Request, subscribe: Subscribe) -> None:
|
|
96
|
+
"""Bind the transport's two primitives."""
|
|
97
|
+
self._request = request
|
|
98
|
+
self._subscribe = subscribe
|
|
99
|
+
|
|
100
|
+
async def put(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
sources: list[SourceSpec],
|
|
104
|
+
stream_id: str,
|
|
105
|
+
audio: AudioMode = "auto",
|
|
106
|
+
orientation: Orientation = "no_transform",
|
|
107
|
+
preload: bool = False,
|
|
108
|
+
) -> StreamPutResult:
|
|
109
|
+
"""`stream/put`: the idempotent upsert of a stream's desired state.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
sources: The sources, in order; the first that provides a kind wins.
|
|
113
|
+
stream_id: The stream, `^[A-Za-z0-9._-]{1,128}$`.
|
|
114
|
+
audio: Audio handling.
|
|
115
|
+
orientation: How the picture is turned for display. A change reaches the open
|
|
116
|
+
sessions too, from their next frame and without a new answer. Changing only
|
|
117
|
+
this never reconnects.
|
|
118
|
+
preload: Keep the source connected without viewers: connected at put and
|
|
119
|
+
reconnected after failures while set, so the first viewer gets a frame from
|
|
120
|
+
the warm GOP cache. Changing only this never reconnects; turning it off with
|
|
121
|
+
no viewers starts the linger.
|
|
122
|
+
"""
|
|
123
|
+
command = StreamPutCommand(
|
|
124
|
+
id=NEW,
|
|
125
|
+
sources=sources,
|
|
126
|
+
stream_id=stream_id,
|
|
127
|
+
audio=audio,
|
|
128
|
+
orientation=orientation,
|
|
129
|
+
preload=preload,
|
|
130
|
+
)
|
|
131
|
+
return StreamPutResult.from_dict(await self._request(command))
|
|
132
|
+
|
|
133
|
+
async def get(self, *, stream_id: str) -> Stream:
|
|
134
|
+
"""`stream/get`: one stream's state, sources, tracks and counters.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
stream_id: The stream.
|
|
138
|
+
"""
|
|
139
|
+
command = StreamGetCommand(id=NEW, stream_id=stream_id)
|
|
140
|
+
return Stream.from_dict(await self._request(command))
|
|
141
|
+
|
|
142
|
+
async def list(self) -> StreamList:
|
|
143
|
+
"""`stream/list`: every stream, by id."""
|
|
144
|
+
command = StreamListCommand(id=NEW)
|
|
145
|
+
return StreamList.from_dict(await self._request(command))
|
|
146
|
+
|
|
147
|
+
async def delete(self, *, stream_id: str) -> None:
|
|
148
|
+
"""`stream/delete`: removes a stream and closes its sessions; idempotent.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
stream_id: The stream.
|
|
152
|
+
"""
|
|
153
|
+
command = StreamDeleteCommand(id=NEW, stream_id=stream_id)
|
|
154
|
+
await self._request(command)
|
|
155
|
+
|
|
156
|
+
async def subscribe(self, *, stream_id: str | None = None) -> Subscription[StreamEvent]:
|
|
157
|
+
"""`stream/subscribe`: a subscription to one stream's state, or every stream's.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
stream_id: One stream, or all when absent.
|
|
161
|
+
"""
|
|
162
|
+
command = StreamSubscribeCommand(id=NEW, stream_id=stream_id)
|
|
163
|
+
return await self._subscribe(command, STREAM_EVENTS)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class WebrtcCommands:
|
|
167
|
+
"""The `webrtc/...` commands, as `client.webrtc.<verb>(...)`."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, request: Request, subscribe: Subscribe) -> None:
|
|
170
|
+
"""Bind the transport's two primitives."""
|
|
171
|
+
self._request = request
|
|
172
|
+
self._subscribe = subscribe
|
|
173
|
+
|
|
174
|
+
async def offer(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
sdp: str,
|
|
178
|
+
stream_id: str,
|
|
179
|
+
ice_servers: list[IceServer] | None = None,
|
|
180
|
+
session_id: str | None = None,
|
|
181
|
+
) -> Subscription[SessionEvent]:
|
|
182
|
+
"""`webrtc/offer`: opens a session.
|
|
183
|
+
|
|
184
|
+
The subscription's events carry the whole signaling conversation.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
sdp: The browser's SDP offer.
|
|
188
|
+
stream_id: The stream to view.
|
|
189
|
+
ice_servers: Overrides the configured ICE servers for this session.
|
|
190
|
+
session_id: The client's session id, `^[A-Za-z0-9._-]{1,128}$`; a ULID when
|
|
191
|
+
absent.
|
|
192
|
+
"""
|
|
193
|
+
command = WebrtcOfferCommand(
|
|
194
|
+
id=NEW, sdp=sdp, stream_id=stream_id, ice_servers=ice_servers, session_id=session_id
|
|
195
|
+
)
|
|
196
|
+
return await self._subscribe(command, SESSION_EVENTS)
|
|
197
|
+
|
|
198
|
+
async def candidate(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
candidate: str,
|
|
202
|
+
session_id: str,
|
|
203
|
+
sdp_mid: str | None = None,
|
|
204
|
+
sdp_mline_index: int | None = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""`webrtc/candidate`: a trickled candidate from the browser.
|
|
207
|
+
|
|
208
|
+
Taken only from the connection that owns the session.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
candidate: The `candidate:` attribute value; empty is end-of-candidates.
|
|
212
|
+
session_id: The session.
|
|
213
|
+
sdp_mid: The media section, when the browser named it (a client may forward only
|
|
214
|
+
this string; BUNDLE makes it redundant).
|
|
215
|
+
sdp_mline_index: The media section's index.
|
|
216
|
+
"""
|
|
217
|
+
command = WebrtcCandidateCommand(
|
|
218
|
+
id=NEW,
|
|
219
|
+
candidate=candidate,
|
|
220
|
+
session_id=session_id,
|
|
221
|
+
sdp_mid=sdp_mid,
|
|
222
|
+
sdp_mline_index=sdp_mline_index,
|
|
223
|
+
)
|
|
224
|
+
await self._request(command)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class SessionCommands:
|
|
228
|
+
"""The `session/...` commands, as `client.session.<verb>(...)`."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, request: Request, subscribe: Subscribe) -> None:
|
|
231
|
+
"""Bind the transport's two primitives."""
|
|
232
|
+
self._request = request
|
|
233
|
+
self._subscribe = subscribe
|
|
234
|
+
|
|
235
|
+
async def get(self, *, session_id: str) -> Session:
|
|
236
|
+
"""`session/get`: one session's state.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
session_id: The session.
|
|
240
|
+
"""
|
|
241
|
+
command = SessionGetCommand(id=NEW, session_id=session_id)
|
|
242
|
+
return Session.from_dict(await self._request(command))
|
|
243
|
+
|
|
244
|
+
async def list(self) -> SessionList:
|
|
245
|
+
"""`session/list`: every session."""
|
|
246
|
+
command = SessionListCommand(id=NEW)
|
|
247
|
+
return SessionList.from_dict(await self._request(command))
|
|
248
|
+
|
|
249
|
+
async def close(self, *, session_id: str) -> None:
|
|
250
|
+
"""`session/close`; idempotent.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
session_id: The session.
|
|
254
|
+
"""
|
|
255
|
+
command = SessionCloseCommand(id=NEW, session_id=session_id)
|
|
256
|
+
await self._request(command)
|
|
257
|
+
|
|
258
|
+
async def adopt(self, *, session_id: str) -> Subscription[SessionEvent]:
|
|
259
|
+
"""`session/adopt`: takes back an orphaned session.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
session_id: The session.
|
|
263
|
+
"""
|
|
264
|
+
command = SessionAdoptCommand(id=NEW, session_id=session_id)
|
|
265
|
+
return await self._subscribe(command, SESSION_EVENTS)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class Commands(ABC):
|
|
269
|
+
"""Every command as a typed method or a namespace of them.
|
|
270
|
+
|
|
271
|
+
The transport implements the two primitives and calls `__init__`.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(self) -> None:
|
|
275
|
+
"""Bind every namespace to this transport."""
|
|
276
|
+
self.metrics = MetricsCommands(self._request, self._subscribe)
|
|
277
|
+
"""`metrics/get`."""
|
|
278
|
+
self.stream = StreamCommands(self._request, self._subscribe)
|
|
279
|
+
"""`stream/put`, `stream/get`, `stream/list`, `stream/delete`, `stream/subscribe`."""
|
|
280
|
+
self.webrtc = WebrtcCommands(self._request, self._subscribe)
|
|
281
|
+
"""`webrtc/offer`, `webrtc/candidate`."""
|
|
282
|
+
self.session = SessionCommands(self._request, self._subscribe)
|
|
283
|
+
"""`session/get`, `session/list`, `session/close`, `session/adopt`."""
|
|
284
|
+
|
|
285
|
+
@abstractmethod
|
|
286
|
+
async def _request(self, command: Model) -> dict[str, Any]:
|
|
287
|
+
"""Send `command`; return its `result`, or `{}` for `pong`."""
|
|
288
|
+
|
|
289
|
+
@abstractmethod
|
|
290
|
+
async def _subscribe[E](self, command: Model, events: BasicDecoder[E]) -> Subscription[E]:
|
|
291
|
+
"""Send a subscribing `command`; decode its events with `events`."""
|
|
292
|
+
|
|
293
|
+
async def ping(self) -> None:
|
|
294
|
+
"""`ping`: answered with `pong`, not a `result`."""
|
|
295
|
+
command = PingCommand(id=NEW)
|
|
296
|
+
await self._request(command)
|
|
297
|
+
|
|
298
|
+
async def info(self) -> InfoResult:
|
|
299
|
+
"""`info`: version, build, schemes, outputs, codecs, limits and sandbox."""
|
|
300
|
+
command = InfoCommand(id=NEW)
|
|
301
|
+
return InfoResult.from_dict(await self._request(command))
|
|
302
|
+
|
|
303
|
+
async def schema(self) -> dict[str, Any]:
|
|
304
|
+
"""`schema`: the JSON Schema bundle of every message."""
|
|
305
|
+
command = SchemaCommand(id=NEW)
|
|
306
|
+
return await self._request(command)
|
|
307
|
+
|
|
308
|
+
async def unsubscribe(self, *, subscription: int) -> None:
|
|
309
|
+
"""`unsubscribe`: ends a subscription of this connection.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
subscription: The subscribing command's id.
|
|
313
|
+
"""
|
|
314
|
+
command = UnsubscribeCommand(id=NEW, subscription=subscription)
|
|
315
|
+
await self._request(command)
|