esa-livefeed 0.0.1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ © 2026 Ely-Sandra LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: esa-livefeed
3
+ Version: 0.0.1
4
+ Summary: Low-latency live feed server and managed client over raw TCP with JSONL framing, heartbeats, liveness detection, and unbounded per-client backpressure.
5
+ Author-email: Ely-Sandra <info@ely-sandra.com>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://esa-livefeed.readthedocs.io/
8
+ Project-URL: Homepage, https://github.com/ely-sandra/esa-livefeed
9
+ Project-URL: Repository, https://github.com/ely-sandra/esa-livefeed.git
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: hanaro>=2.0.1
17
+ Dynamic: license-file
18
+
19
+ # esa-livefeed
20
+
21
+ Low-latency live feed server and managed client over raw TCP with JSONL framing,
22
+ client-driven heartbeats, bidirectional liveness detection, and unbounded
23
+ per-client backpressure. Pure Python stdlib; zero runtime dependencies.
24
+
25
+ - `LivefeedServer` / `LivefeedServerSettings` — accepts clients (one
26
+ `LivefeedSession` per client: dedicated sender/receiver threads and an
27
+ unbounded outbound queue), fans out pre-encoded frames via
28
+ `publish(data: bytes)`, exposes connected clients via the `clients`
29
+ property, delivers client data frames to a registered message callback
30
+ (with the originating session for targeted replies), drops clients only
31
+ on liveness loss, and logs delivery-delay warnings without ever dropping
32
+ data.
33
+ - `LivefeedClient` / `LivefeedClientSettings` — managed bidirectional
34
+ client: `start()` spawns background threads that connect, handshake,
35
+ deliver data frames to a registered message callback, send messages via
36
+ `send_message(dict)`, heartbeat after 3s of inactivity, detect a dead
37
+ server, and auto-reconnect into fresh state (no backlog).
38
+ - Callbacks are registered on the objects themselves via
39
+ `set_message_callback` / `set_connect_callback` / `set_disconnect_callback`
40
+ (settings carry no callables), and every callback receives the peer
41
+ object first so handlers can communicate back.
42
+
43
+ Wire protocol: newline-delimited JSON. Control frames: `hello`, `auth`,
44
+ `heartbeat` (client only), `ack`, `ping`, `pong`. Data frames are
45
+ application-defined (e.g. `{"type":"data",...}`).
46
+
47
+ ## Building
48
+
49
+ ```bash
50
+ uv build # or: pip wheel .
51
+ ```
52
+
53
+ ## Tests
54
+
55
+ ```bash
56
+ python -m punit
57
+ ```
@@ -0,0 +1,39 @@
1
+ # esa-livefeed
2
+
3
+ Low-latency live feed server and managed client over raw TCP with JSONL framing,
4
+ client-driven heartbeats, bidirectional liveness detection, and unbounded
5
+ per-client backpressure. Pure Python stdlib; zero runtime dependencies.
6
+
7
+ - `LivefeedServer` / `LivefeedServerSettings` — accepts clients (one
8
+ `LivefeedSession` per client: dedicated sender/receiver threads and an
9
+ unbounded outbound queue), fans out pre-encoded frames via
10
+ `publish(data: bytes)`, exposes connected clients via the `clients`
11
+ property, delivers client data frames to a registered message callback
12
+ (with the originating session for targeted replies), drops clients only
13
+ on liveness loss, and logs delivery-delay warnings without ever dropping
14
+ data.
15
+ - `LivefeedClient` / `LivefeedClientSettings` — managed bidirectional
16
+ client: `start()` spawns background threads that connect, handshake,
17
+ deliver data frames to a registered message callback, send messages via
18
+ `send_message(dict)`, heartbeat after 3s of inactivity, detect a dead
19
+ server, and auto-reconnect into fresh state (no backlog).
20
+ - Callbacks are registered on the objects themselves via
21
+ `set_message_callback` / `set_connect_callback` / `set_disconnect_callback`
22
+ (settings carry no callables), and every callback receives the peer
23
+ object first so handlers can communicate back.
24
+
25
+ Wire protocol: newline-delimited JSON. Control frames: `hello`, `auth`,
26
+ `heartbeat` (client only), `ack`, `ping`, `pong`. Data frames are
27
+ application-defined (e.g. `{"type":"data",...}`).
28
+
29
+ ## Building
30
+
31
+ ```bash
32
+ uv build # or: pip wheel .
33
+ ```
34
+
35
+ ## Tests
36
+
37
+ ```bash
38
+ python -m punit
39
+ ```
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "esa-livefeed"
3
+ version = "0.0.1"
4
+ description = "Low-latency live feed server and managed client over raw TCP with JSONL framing, heartbeats, liveness detection, and unbounded per-client backpressure."
5
+ authors = [
6
+ { name="Ely-Sandra", email="info@ely-sandra.com" }
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ classifiers = [
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: OS Independent",
13
+ "Programming Language :: Python"
14
+ ]
15
+ requires-python = ">=3.11"
16
+ dependencies = [
17
+ "hanaro (>=2.0.1)"
18
+ ]
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "build",
23
+ "coverage",
24
+ "mypy",
25
+ "punit (>=1.14.1)",
26
+ "py-conventional-semver (>=1.3.5)",
27
+ "twine"
28
+ ]
29
+
30
+ [project.urls]
31
+ Documentation = "https://esa-livefeed.readthedocs.io/"
32
+ Homepage = "https://github.com/ely-sandra/esa-livefeed"
33
+ Repository = "https://github.com/ely-sandra/esa-livefeed.git"
34
+
35
+
36
+ [tool.uv.sources]
37
+ esa-livefeed = { path = "src/esa-livefeed", editable = true }
38
+
39
+ [tool.uv]
40
+ package = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: © 2026 Ely-Sandra LLC
2
+ # SPDX-License-Identifier: LicenseRef-Ely-Sandra-Proprietary=https://ely-sandra.com/LICENSE.md
3
+
4
+ from .livefeed_client import LivefeedClient
5
+ from .livefeed_client_settings import LivefeedClientSettings
6
+ from .livefeed_server import LivefeedServer
7
+ from .livefeed_server_settings import LivefeedServerSettings
8
+ from .livefeed_session import LivefeedSession
9
+
10
+ __version__ = '0.0.1'
11
+ __commit__ = 'f80fb34'
12
+ __all__ = [
13
+ '__version__', '__commit__',
14
+ 'LivefeedClient',
15
+ 'LivefeedClientSettings',
16
+ 'LivefeedServer',
17
+ 'LivefeedServerSettings',
18
+ 'LivefeedSession',
19
+ ]
@@ -0,0 +1,480 @@
1
+ # SPDX-FileCopyrightText: © 2026 Ely-Sandra LLC
2
+ # SPDX-License-Identifier: LicenseRef-Ely-Sandra-Proprietary=https://ely-sandra.com/LICENSE.md
3
+
4
+ """Managed livefeed client.
5
+
6
+ Provides the ``LivefeedClient`` class which maintains a livefeed connection
7
+ for the lifetime of a :meth:`start`/:meth:`stop` pair. A background
8
+ control thread hosts the connect/stream/reconnect cycle; each connection
9
+ is serviced by a dedicated sender thread (blocked on the client's unbounded
10
+ outbound queue, the sole writer to the socket) and a dedicated receiver
11
+ thread (blocked on the socket, the sole reader).
12
+
13
+ The application registers callbacks with :meth:`set_message_callback`,
14
+ :meth:`set_connect_callback`, and :meth:`set_disconnect_callback`, and sends
15
+ messages with :meth:`send_message`. Data frames are delivered to the
16
+ message callback serially, in receive order; the callback receives the
17
+ client first, so it can reply to the server. A message callback that
18
+ raises ends the connection and triggers the normal reconnect cycle.
19
+
20
+ When the connection breaks (server stop, network loss, liveness timeout),
21
+ the client invokes the disconnect callback and automatically reconnects
22
+ into fresh state; no backlog is ever replayed.
23
+
24
+ Usage
25
+ -----
26
+
27
+ settings = LivefeedClientSettings(host='10.0.0.5', port=3001)
28
+ client = LivefeedClient(settings)
29
+ client.set_message_callback(on_bar)
30
+ client.start()
31
+ ...
32
+ client.stop()
33
+
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from collections.abc import Callable
40
+ from contextlib import suppress
41
+ from queue import Empty, Queue
42
+ import socket
43
+ import time
44
+ from threading import Event, Thread
45
+
46
+ from .livefeed_client_settings import LivefeedClientSettings
47
+ from .protocol import (
48
+ TYPE_AUTH,
49
+ TYPE_AUTHOK,
50
+ TYPE_HEARTBEAT,
51
+ TYPE_HELLO,
52
+ control_types,
53
+ encode_message,
54
+ parse_message,
55
+ split_frames,
56
+ )
57
+
58
+ HEARTBEAT_TICK_SECONDS: float = 0.5
59
+ RECV_BUFFER_SIZE: int = 65536
60
+ RECV_TICK_SECONDS: float = 0.5
61
+ STOP_JOIN_TIMEOUT_SECONDS: float = 10.0
62
+
63
+
64
+ class LivefeedClient:
65
+ """
66
+ Managed livefeed client with automatic reconnection.
67
+
68
+ The application only calls :meth:`start`, :meth:`stop`, and
69
+ :meth:`send_message`. Everything else — connect, handshake, data
70
+ delivery, heartbeats, liveness detection, and reconnection — is
71
+ maintained by the client's background threads. The sender thread is
72
+ the sole writer to the socket: it drains the unbounded outbound queue
73
+ and emits heartbeats after the configured inactivity interval. The
74
+ receiver thread is the sole reader: it delivers data frames to the
75
+ registered message callback, serially, in receive order. Liveness is
76
+ enforced by the sender thread, which wakes on queue timeouts.
77
+
78
+ All callbacks receive the client as their first argument so the
79
+ application can communicate back to the server. A message callback
80
+ that raises ends the connection and triggers the normal reconnect
81
+ cycle.
82
+
83
+ Usage
84
+ -----
85
+
86
+ .. code-block:: python
87
+
88
+ settings = LivefeedClientSettings(host='10.0.0.5', port=3001)
89
+ client = LivefeedClient(settings)
90
+ client.set_message_callback(on_bar)
91
+ client.start()
92
+ ...
93
+ client.stop()
94
+ """
95
+
96
+ __connect_callback: Callable[[LivefeedClient, dict], None] | None
97
+ __disconnect_callback: Callable[[LivefeedClient, str], None] | None
98
+ __end_reason: str
99
+ __last_rx: float
100
+ __last_tx: float
101
+ __logger: logging.Logger
102
+ __message_callback: Callable[[LivefeedClient, dict], None] | None
103
+ __queue: Queue | None
104
+ __receiver_thread: Thread | None
105
+ __running: bool
106
+ __sender_thread: Thread | None
107
+ __settings: LivefeedClientSettings
108
+ __socket: socket.socket | None
109
+ __stopped: Event
110
+ __thread: Thread | None
111
+
112
+ def __init__(self, settings: LivefeedClientSettings) -> None:
113
+ """
114
+ Initialize the client (inert until :meth:`start` is called).
115
+
116
+ :param settings: The client configuration.
117
+ """
118
+ self.__connect_callback = None
119
+ self.__disconnect_callback = None
120
+ self.__end_reason = ''
121
+ self.__last_rx = 0.0
122
+ self.__last_tx = 0.0
123
+ self.__logger = logging.getLogger('esa_livefeed.client')
124
+ self.__message_callback = None
125
+ self.__queue = None
126
+ self.__receiver_thread = None
127
+ self.__running = False
128
+ self.__sender_thread = None
129
+ self.__settings = settings
130
+ self.__socket = None
131
+ self.__stopped = Event()
132
+ self.__thread = None
133
+
134
+ def __authenticate(self, sock: socket.socket) -> None:
135
+ """
136
+ Send the auth frame and wait for the server's authok confirmation.
137
+
138
+ :param sock: The handshake socket (must have a timeout set).
139
+ :raises ConnectionError: When the server rejects the token or the confirmation is malformed.
140
+ :returns: None
141
+ """
142
+ sock.sendall(encode_message({'type': TYPE_AUTH, 'token': self.__settings.token}))
143
+ try:
144
+ ack = parse_message(self.__read_line(sock))
145
+ except ValueError as ex:
146
+ raise ConnectionError(f'malformed authok frame from server: {ex}') from ex
147
+ if ack.get('type') != TYPE_AUTHOK or ack.get('ok') is not True:
148
+ raise ConnectionError(f'authentication rejected by server: {ack!r}')
149
+ self.__last_rx = time.monotonic()
150
+
151
+ def __check_liveness(self, sock: socket.socket) -> bool:
152
+ """
153
+ Close the connection when no frame has been received within the liveness window.
154
+
155
+ :param sock: The connection socket to close when stale.
156
+ :returns: True when the connection is still live, False when it was closed.
157
+ """
158
+ if time.monotonic() - self.__last_rx <= self.__settings.liveness_timeout_seconds:
159
+ return True
160
+ self.__set_end_reason(f'no frame received from server within {self.__settings.liveness_timeout_seconds} seconds')
161
+ with suppress(OSError):
162
+ sock.close()
163
+ return False
164
+
165
+ def __connect_and_stream(self) -> None:
166
+ """
167
+ Open one connection: handshake, then stream until the connection ends.
168
+
169
+ Never raises; the end reason is recorded on the client for the
170
+ reconnect cycle.
171
+
172
+ :returns: None
173
+ """
174
+ try:
175
+ sock = socket.create_connection(
176
+ (self.__settings.host, self.__settings.port),
177
+ timeout=self.__settings.connect_timeout_seconds,
178
+ )
179
+ except OSError as ex:
180
+ self.__set_end_reason(str(ex) or ex.__class__.__name__)
181
+ return
182
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
183
+ self.__socket = sock
184
+ self.__queue = Queue()
185
+ self.__last_rx = time.monotonic()
186
+ self.__last_tx = time.monotonic()
187
+ self.__end_reason = ''
188
+ sender = Thread(target=self.__send_loop, name='livefeed-client-sender', daemon=True)
189
+ receiver = Thread(target=self.__recv_loop, name='livefeed-client-receiver', daemon=True)
190
+ started = False
191
+ try:
192
+ hello_line = self.__read_line(sock)
193
+ try:
194
+ hello = parse_message(hello_line)
195
+ except ValueError as ex:
196
+ raise ConnectionError(f'malformed hello frame from server: {ex}') from ex
197
+ if hello.get('type') != TYPE_HELLO:
198
+ raise ConnectionError(f'expected hello frame, got {hello.get("type")!r}')
199
+ if hello.get('auth'):
200
+ if self.__settings.token is None:
201
+ raise ConnectionError('server requires authentication but no token is configured')
202
+ self.__authenticate(sock)
203
+ self.__logger.info(f'livefeed client connected to {self.__settings.host}:{self.__settings.port}')
204
+ self.__invoke(self.__connect_callback, self, hello)
205
+ sock.settimeout(RECV_TICK_SECONDS)
206
+ sender.start()
207
+ receiver.start()
208
+ started = True
209
+ self.__sender_thread = sender
210
+ self.__receiver_thread = receiver
211
+ while self.__running and sender.is_alive() and receiver.is_alive():
212
+ time.sleep(0.05)
213
+ except Exception as ex:
214
+ self.__set_end_reason(str(ex) or ex.__class__.__name__)
215
+ finally:
216
+ with suppress(OSError):
217
+ sock.close()
218
+ if started:
219
+ sender.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
220
+ receiver.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
221
+ self.__sender_thread = None
222
+ self.__receiver_thread = None
223
+ self.__queue = None
224
+ self.__socket = None
225
+
226
+ def __invoke(self, callback: Callable | None, *args) -> None:
227
+ """
228
+ Invoke a registered callback.
229
+
230
+ :param callback: The callback to invoke (None is a no-op).
231
+ :param args: The positional arguments to pass.
232
+ :returns: None
233
+ """
234
+ if callback is not None:
235
+ callback(*args)
236
+
237
+ def __read_line(self, sock: socket.socket) -> bytes:
238
+ """
239
+ Read one newline-terminated frame from the socket.
240
+
241
+ :param sock: The socket to read from (must have a timeout set).
242
+ :returns: The raw frame, including the trailing newline.
243
+ :raises ConnectionError: When the connection closes before a complete frame arrives.
244
+ """
245
+ buffer = bytearray()
246
+ while not buffer.endswith(b'\n'):
247
+ chunk = sock.recv(4096)
248
+ if not chunk:
249
+ raise ConnectionError('server closed the connection during handshake')
250
+ buffer.extend(chunk)
251
+ return bytes(buffer)
252
+
253
+ def __recv_loop(self) -> None:
254
+ """
255
+ Run the receiver thread: read frames until the connection ends, delivering data frames to the message callback.
256
+
257
+ This thread is the sole reader of the socket. Control frames are
258
+ consumed by the library and are not delivered.
259
+
260
+ :returns: None
261
+ """
262
+ sock = self.__socket
263
+ if sock is None:
264
+ return
265
+ buffer = bytearray()
266
+ try:
267
+ while self.__running:
268
+ try:
269
+ chunk = sock.recv(RECV_BUFFER_SIZE)
270
+ except socket.timeout:
271
+ continue
272
+ if not chunk:
273
+ self.__set_end_reason('server closed the connection')
274
+ with suppress(OSError):
275
+ sock.close()
276
+ return
277
+ buffer.extend(chunk)
278
+ for frame in split_frames(buffer):
279
+ self.__last_rx = time.monotonic()
280
+ message = parse_message(frame)
281
+ if message.get('type') in control_types():
282
+ continue
283
+ try:
284
+ self.__invoke(self.__message_callback, self, message)
285
+ except Exception as ex:
286
+ self.__logger.exception(f'livefeed message callback error: {ex}')
287
+ self.__set_end_reason(f'message callback raised: {ex}')
288
+ with suppress(OSError):
289
+ sock.close()
290
+ return
291
+ except (OSError, ValueError) as ex:
292
+ self.__set_end_reason(str(ex) or ex.__class__.__name__)
293
+
294
+ def __run(self) -> None:
295
+ """
296
+ Run the connect/stream/reconnect cycle until stopped.
297
+
298
+ :returns: None
299
+ """
300
+ while not self.__stopped.is_set():
301
+ self.__connect_and_stream()
302
+ if self.__stopped.is_set():
303
+ break
304
+ reason = self.__end_reason or 'connection closed'
305
+ self.__logger.info(
306
+ f'livefeed client disconnected ({reason}), reconnecting in {self.__settings.reconnect_delay_seconds} seconds'
307
+ )
308
+ try:
309
+ self.__invoke(self.__disconnect_callback, self, reason)
310
+ except Exception as ex:
311
+ self.__logger.exception(f'livefeed disconnect callback error: {ex}')
312
+ deadline = time.monotonic() + self.__settings.reconnect_delay_seconds
313
+ while not self.__stopped.is_set() and time.monotonic() < deadline:
314
+ time.sleep(0.05)
315
+
316
+ def __send_loop(self) -> None:
317
+ """
318
+ Run the sender thread: drain the outbound queue until the connection ends.
319
+
320
+ This thread is the sole writer to the socket. It emits heartbeats
321
+ after the configured inactivity interval and enforces the liveness
322
+ timeout, waking on queue timeouts and after each send.
323
+
324
+ :returns: None
325
+ """
326
+ sock = self.__socket
327
+ queue = self.__queue
328
+ if sock is None or queue is None:
329
+ return
330
+ try:
331
+ while self.__running:
332
+ try:
333
+ data = queue.get(timeout=HEARTBEAT_TICK_SECONDS)
334
+ except Empty:
335
+ if time.monotonic() - self.__last_tx < self.__settings.heartbeat_seconds:
336
+ if not self.__check_liveness(sock):
337
+ return
338
+ continue
339
+ data = encode_message({'type': TYPE_HEARTBEAT, 'ts': time.time_ns()})
340
+ self.__send(sock, data)
341
+ self.__last_tx = time.monotonic()
342
+ if not self.__check_liveness(sock):
343
+ return
344
+ except OSError:
345
+ pass
346
+
347
+ def __send(self, sock: socket.socket, data: bytes) -> None:
348
+ """
349
+ Send raw bytes to the server, retrying through socket timeouts.
350
+
351
+ :param sock: The connection socket to write to.
352
+ :param data: The bytes to send.
353
+ :raises OSError: When the send fails.
354
+ """
355
+ view = memoryview(data)
356
+ while view:
357
+ try:
358
+ sent = sock.send(view)
359
+ except socket.timeout:
360
+ continue
361
+ view = view[sent:]
362
+
363
+ def __set_end_reason(self, reason: str) -> None:
364
+ """
365
+ Record the connection end reason, keeping the first reason set.
366
+
367
+ :param reason: A short human-readable reason.
368
+ :returns: None
369
+ """
370
+ if not self.__end_reason:
371
+ self.__end_reason = reason
372
+
373
+ def __thread_main(self) -> None:
374
+ """
375
+ Run the client's control loop (background thread body).
376
+ """
377
+ try:
378
+ self.__run()
379
+ except Exception as ex:
380
+ self.__logger.exception(f'livefeed client run error: {ex}')
381
+
382
+ @property
383
+ def is_running(self) -> bool:
384
+ """
385
+ Determine whether the client has been started and not stopped.
386
+
387
+ :returns: True when the client's background thread is active.
388
+ """
389
+ return self.__running
390
+
391
+ def send_message(self, message: dict) -> None:
392
+ """
393
+ Enqueue a message to send to the server.
394
+
395
+ The frame is written to the socket by the client's sender thread.
396
+ A no-op when there is no active connection (during startup,
397
+ shutdown, or a reconnect gap).
398
+
399
+ :param message: The JSON-serializable message to send.
400
+ :returns: None
401
+ """
402
+ queue = self.__queue
403
+ if queue is None:
404
+ return
405
+ queue.put(encode_message(message))
406
+
407
+ def set_connect_callback(self, callback: Callable[[LivefeedClient, dict], None] | None) -> None:
408
+ """
409
+ Register the callback invoked when the client finishes connecting.
410
+
411
+ :param callback: The callback to invoke with the client and the server's hello frame, or None to clear.
412
+ :returns: None
413
+ """
414
+ self.__connect_callback = callback
415
+
416
+ def set_disconnect_callback(self, callback: Callable[[LivefeedClient, str], None] | None) -> None:
417
+ """
418
+ Register the callback invoked when a connection ends (not on stop).
419
+
420
+ :param callback: The callback to invoke with the client and the end reason, or None to clear.
421
+ :returns: None
422
+ """
423
+ self.__disconnect_callback = callback
424
+
425
+ def set_message_callback(self, callback: Callable[[LivefeedClient, dict], None] | None) -> None:
426
+ """
427
+ Register the callback invoked when a data frame is received from the server.
428
+
429
+ The callback receives the client first, so it can reply to the
430
+ server via :meth:`send_message`. Control frames (hello, ack,
431
+ pong, ...) are consumed by the library and are not delivered.
432
+
433
+ :param callback: The callback to invoke with the client and the decoded frame, or None to clear.
434
+ :returns: None
435
+ """
436
+ self.__message_callback = callback
437
+
438
+ def start(self) -> None:
439
+ """
440
+ Start the client's background connection thread.
441
+
442
+ Idempotent; a second call while running is a no-op.
443
+
444
+ :returns: None
445
+ """
446
+ if self.__running:
447
+ return
448
+ self.__running = True
449
+ self.__stopped.clear()
450
+ self.__thread = Thread(target=self.__thread_main, name='livefeed-client', daemon=True)
451
+ self.__thread.start()
452
+
453
+ def stop(self) -> None:
454
+ """
455
+ Stop the client, closing the current connection and joining the threads.
456
+
457
+ Idempotent; a second call is a no-op. Blocks until the background
458
+ thread has exited (bounded wait).
459
+
460
+ :returns: None
461
+ """
462
+ if not self.__running:
463
+ return
464
+ self.__running = False
465
+ self.__stopped.set()
466
+ sock = self.__socket
467
+ if sock is not None:
468
+ with suppress(OSError):
469
+ sock.close()
470
+ thread = self.__thread
471
+ if thread is not None:
472
+ thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
473
+ self.__thread = None
474
+ self.__socket = None
475
+ self.__queue = None
476
+
477
+
478
+ __all__ = [
479
+ 'LivefeedClient',
480
+ ]