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.
@@ -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)