motorcortex-python 1.0.0rc1__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.
motorcortex/session.py ADDED
@@ -0,0 +1,194 @@
1
+ #
2
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
3
+ # All rights reserved. Copyright (c) 2026 VECTIONEER.
4
+ #
5
+
6
+ """High-level context-manager wrapper around ``(Request, Subscribe)``.
7
+
8
+ ``motorcortex.Session`` owns both channels, constructs sensible defaults
9
+ for ``MessageTypes`` and ``ParameterTree``, and guarantees ``close()``
10
+ on the way out — including on exceptions inside the ``with`` block.
11
+ Nothing about the underlying API changes: ``session.req`` and
12
+ ``session.sub`` are the same ``Request`` / ``Subscribe`` instances the
13
+ user would get from :func:`motorcortex.connect`, so every existing
14
+ method call keeps working unchanged.
15
+
16
+ The class is intentionally thin. It does not replicate the RPC surface
17
+ of ``Request`` / ``Subscribe`` — pass-through getters would drift from
18
+ the real objects over time. A couple of convenience properties
19
+ (``connectionState``, ``token``) are provided because they're the two
20
+ things users poll most often.
21
+
22
+ Example::
23
+
24
+ with motorcortex.Session(
25
+ "wss://robot.local:5568:5567",
26
+ login="administrator",
27
+ password="administrator",
28
+ certificate="/etc/ssl/certs/motorcortex.pem",
29
+ ) as s:
30
+ reply = s.req.getParameter("root/Control/x").get()
31
+ s.sub.subscribe(["root/Control/x"], "grp", frq_divider=10)
32
+
33
+ The existing :func:`motorcortex.connect` entrypoint is unchanged; code
34
+ that manages ``(req, sub)`` explicitly keeps working.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from types import TracebackType
40
+ from typing import Any, Optional, Type, TYPE_CHECKING
41
+
42
+ from motorcortex.exceptions import McxConnectionError
43
+
44
+ if TYPE_CHECKING:
45
+ from motorcortex.request import Request, ConnectionState
46
+ from motorcortex.subscribe import Subscribe
47
+ from motorcortex.message_types import MessageTypes
48
+ from motorcortex.parameter_tree import ParameterTree
49
+
50
+
51
+ class Session:
52
+ """Context-manager owning a ``(Request, Subscribe)`` pair.
53
+
54
+ Args:
55
+ url: Connection URL (``scheme://host:req_port:sub_port``).
56
+ motorcortex_types: Optional pre-built ``MessageTypes``. A fresh
57
+ one is constructed when omitted.
58
+ param_tree: Optional pre-built ``ParameterTree``. A fresh one
59
+ is constructed when omitted. After :meth:`connect` /
60
+ entering the ``with`` block, the tree is populated from
61
+ the server.
62
+ **kwargs: Forwarded verbatim to :func:`motorcortex.connect` —
63
+ ``login``, ``password``, ``certificate``, ``timeout_ms``,
64
+ ``reconnect``, ``token_update_interval_ms``,
65
+ ``req_number_of_threads``, ``sub_number_of_threads``,
66
+ ``state_update``.
67
+
68
+ The session is lazy — it does not dial until :meth:`connect` is
69
+ called or the ``with`` block is entered. That way constructing a
70
+ Session on its own is cheap and can't raise ``RuntimeError``.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ url: str,
76
+ motorcortex_types: Optional["MessageTypes"] = None,
77
+ param_tree: Optional["ParameterTree"] = None,
78
+ **kwargs: Any,
79
+ ) -> None:
80
+ # Imported lazily so the `motorcortex` package can be imported
81
+ # without pulling in pynng during static analysis.
82
+ from motorcortex.message_types import MessageTypes
83
+ from motorcortex.parameter_tree import ParameterTree
84
+
85
+ self._url = url
86
+ self._kwargs = kwargs
87
+ self._types: "MessageTypes" = motorcortex_types or MessageTypes()
88
+ self._tree: "ParameterTree" = param_tree or ParameterTree()
89
+ self._req: Optional["Request"] = None
90
+ self._sub: Optional["Subscribe"] = None
91
+
92
+ # -- lifecycle -----------------------------------------------------
93
+
94
+ def connect(self) -> "Session":
95
+ """Open the underlying ``Request`` / ``Subscribe`` pair.
96
+
97
+ Idempotent — a second call while already connected is a no-op.
98
+ Returns ``self`` so ``with Session(...).connect() as s:`` works
99
+ (though entering the ``with`` block alone is enough).
100
+ """
101
+ if self._req is not None:
102
+ return self
103
+ from motorcortex import connect as _connect
104
+ self._req, self._sub = _connect(
105
+ self._url, self._types, self._tree, **self._kwargs,
106
+ )
107
+ return self
108
+
109
+ def close(self) -> None:
110
+ """Close subscribe then request. Idempotent; safe on failure."""
111
+ if self._sub is not None:
112
+ try:
113
+ self._sub.close()
114
+ except Exception:
115
+ pass
116
+ self._sub = None
117
+ if self._req is not None:
118
+ try:
119
+ self._req.close()
120
+ except Exception:
121
+ pass
122
+ self._req = None
123
+
124
+ def __enter__(self) -> "Session":
125
+ return self.connect()
126
+
127
+ def __exit__(
128
+ self,
129
+ exc_type: Optional[Type[BaseException]],
130
+ exc: Optional[BaseException],
131
+ tb: Optional[TracebackType],
132
+ ) -> None:
133
+ self.close()
134
+
135
+ def __repr__(self) -> str:
136
+ if self._req is None:
137
+ state = "not-connected"
138
+ else:
139
+ state = self._req.connectionState().name
140
+ return f"Session(url={self._url!r}, state={state})"
141
+
142
+ # -- accessors -----------------------------------------------------
143
+
144
+ @property
145
+ def req(self) -> "Request":
146
+ """The underlying ``Request``. Raises if :meth:`connect` hasn't run."""
147
+ if self._req is None:
148
+ raise McxConnectionError(
149
+ "Session is not connected. Use 'with Session(...) as s:' "
150
+ "or call session.connect() first."
151
+ )
152
+ return self._req
153
+
154
+ @property
155
+ def sub(self) -> "Subscribe":
156
+ """The underlying ``Subscribe``."""
157
+ if self._sub is None:
158
+ raise McxConnectionError(
159
+ "Session is not connected. Use 'with Session(...) as s:' "
160
+ "or call session.connect() first."
161
+ )
162
+ return self._sub
163
+
164
+ @property
165
+ def types(self) -> "MessageTypes":
166
+ """The ``MessageTypes`` instance this session uses."""
167
+ return self._types
168
+
169
+ @property
170
+ def tree(self) -> "ParameterTree":
171
+ """The populated ``ParameterTree`` after connect; empty before."""
172
+ return self._tree
173
+
174
+ @property
175
+ def url(self) -> str:
176
+ return self._url
177
+
178
+ def connectionState(self) -> "ConnectionState":
179
+ """Convenience shortcut for ``session.req.connectionState()``.
180
+
181
+ Returns ``DISCONNECTED`` before :meth:`connect` has run so
182
+ callers don't need to guard against ``None``.
183
+ """
184
+ from motorcortex._connection_state import ConnectionState
185
+ if self._req is None:
186
+ return ConnectionState.DISCONNECTED
187
+ return self._req.connectionState()
188
+
189
+ @property
190
+ def token(self) -> Optional[str]:
191
+ """Convenience shortcut for ``session.req.token`` — the session
192
+ token issued by the engine at login, or ``None`` before connect.
193
+ """
194
+ return None if self._req is None else self._req.token
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/python3
2
+
3
+ #
4
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
5
+ # All rights reserved. Copyright (c) 2021 VECTIONEER.
6
+ #
7
+
8
+ import logging
9
+
10
+ logger = logging.getLogger('mcx')
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/python3
2
+
3
+ #
4
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
5
+ # All rights reserved. Copyright (c) 2025-2026 VECTIONEER.
6
+ #
7
+
8
+ from queue import Queue, Empty
9
+ import threading
10
+ from motorcortex.setup_logger import logger
11
+
12
+ from typing import Any, Callable, Optional, Tuple
13
+
14
+
15
+ class StateCallbackHandler:
16
+ """
17
+ Handles state change callbacks processing.
18
+
19
+ This class manages a background thread and a queue to process state update notifications
20
+ using a user-provided callback function.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ """
25
+ Initializes the StateCallbackHandler.
26
+ """
27
+ self.running: bool = False
28
+ self.callback_queue: Queue = Queue()
29
+ self.callback_thread: Optional[threading.Thread] = None
30
+ self.state_update_handler: Optional[Callable[..., Any]] = None
31
+
32
+ def start(self, state_update_handler: Callable[..., Any]) -> None:
33
+ """
34
+ Start the callback handler with the given update function.
35
+
36
+ Args:
37
+ state_update_handler (Callable[..., Any]):
38
+ The function to call when a state update is notified. Should accept arbitrary arguments.
39
+ """
40
+ if state_update_handler is not None:
41
+ self.state_update_handler = state_update_handler
42
+ self.running = True
43
+ self.callback_thread = threading.Thread(
44
+ target=self._process_callbacks,
45
+ daemon=True
46
+ )
47
+ self.callback_thread.start()
48
+
49
+ def stop(self) -> None:
50
+ """
51
+ Stop the callback handler and clean up resources.
52
+ """
53
+ self.running = False
54
+ if self.callback_thread:
55
+ self.callback_queue.put(None) # Signal thread to exit
56
+ self.callback_thread.join(timeout=1.0)
57
+ self.callback_thread = None
58
+ self.state_update_handler = None
59
+
60
+ def notify(self, *args: Any) -> None:
61
+ """
62
+ Queue a state update notification.
63
+
64
+ Args:
65
+ *args (Any): Arguments to pass to the state update handler.
66
+ """
67
+ if self.state_update_handler:
68
+ self.callback_queue.put(args)
69
+
70
+ def _process_callbacks(self) -> None:
71
+ """
72
+ Process callbacks in a dedicated thread.
73
+ """
74
+ while self.running:
75
+ try:
76
+ args = self.callback_queue.get(timeout=0.1)
77
+ if args is None: # Stop signal
78
+ break
79
+ # The worker thread is only spawned by start() after
80
+ # state_update_handler is set, so it's guaranteed
81
+ # non-None here.
82
+ handler = self.state_update_handler
83
+ assert handler is not None
84
+ try:
85
+ handler(*args)
86
+ except Exception as e:
87
+ logger.exception("Error in state callback: %s", e)
88
+ except Empty:
89
+ continue
90
+ except Exception as e:
91
+ if self.running:
92
+ logger.exception("Error processing callbacks: %s", e)
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/python3
2
+
3
+ #
4
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
5
+ # All rights reserved. Copyright (c) 2016-2026 VECTIONEER.
6
+ #
7
+
8
+ import atexit
9
+
10
+ import pynng # type: ignore[import-untyped]
11
+ from pynng import Sub0, TLSConfig # type: ignore[import-untyped]
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from threading import Event
14
+ from typing import Any, Callable, Dict, Optional, List, TYPE_CHECKING
15
+
16
+ from motorcortex.request import Request, Reply, ConnectionState
17
+ from motorcortex.subscription import Subscription
18
+ from motorcortex.state_callback_handler import StateCallbackHandler
19
+ from motorcortex.setup_logger import logger
20
+ from motorcortex.nng_url import NngUrl
21
+ from motorcortex import _connection_state, _request_utils, _subscribe_dispatch
22
+
23
+ if TYPE_CHECKING:
24
+ from motorcortex.message_types import MessageTypes
25
+
26
+
27
+ def _close_at_exit(inst: "Subscribe") -> None:
28
+ """Shutdown hook mirroring ``motorcortex.request._close_at_exit``.
29
+
30
+ Subscribe is the one that actually hangs interpreter shutdown when
31
+ forgotten — its receive-loop worker is blocked in ``socket.recv()``
32
+ and Python's ``concurrent.futures._python_exit`` waits for it
33
+ forever. This closes the socket (→ recv raises pynng.Closed) and
34
+ shuts down the pool before ``_python_exit`` gets a chance to join
35
+ it, so the worker exits cleanly.
36
+
37
+ Strong reference instead of weakref — when the user's main function
38
+ returns, local ``sub`` goes out of scope and a weakref would resolve
39
+ to None before our hook fires, defeating the purpose.
40
+ """
41
+ try:
42
+ if getattr(inst, "_closed", True):
43
+ return
44
+ inst.close()
45
+ except Exception:
46
+ pass
47
+
48
+
49
+ def _register_shutdown(inst: "Subscribe") -> None:
50
+ """Register ``_close_at_exit(inst)`` to fire before Python joins
51
+ ThreadPoolExecutor workers.
52
+
53
+ ``concurrent.futures._python_exit`` uses ``threading._register_atexit``
54
+ (not regular ``atexit``) so it runs ahead of user-level atexit
55
+ handlers. We hook the same threading-level registry — that way our
56
+ cleanup runs just before ``_python_exit`` gets its turn and the
57
+ pool.shutdown inside close() completes cleanly instead of deadlocking.
58
+
59
+ ``threading._register_atexit`` is a private API but has been stable
60
+ since 3.9. Fall back to regular ``atexit`` on older interpreters /
61
+ if it disappears in a future version — that's strictly worse but
62
+ still better than nothing.
63
+ """
64
+ try:
65
+ import threading
66
+ threading._register_atexit(_close_at_exit, inst) # type: ignore[attr-defined]
67
+ except (AttributeError, ImportError):
68
+ atexit.register(_close_at_exit, inst)
69
+
70
+
71
+ class Subscribe:
72
+ """
73
+ Subscribe class is used to receive continuous parameter updates from the motorcortex server.
74
+
75
+ This class simplifies creating and removing subscription groups, managing the connection,
76
+ and handling the reception of parameter updates.
77
+ """
78
+
79
+ def __init__(self, req: Request, protobuf_types: "MessageTypes", number_of_threads: int = 2) -> None:
80
+ """
81
+ Initialize a Subscribe object.
82
+
83
+ Args:
84
+ req (Request): Reference to a Request instance.
85
+ protobuf_types (MessageTypes): Reference to a MessageTypes instance.
86
+ number_of_threads (int): Thread pool size (minimum 2, None - use default (CPU-based)).
87
+ """
88
+ self._socket: Optional[Sub0] = None
89
+ self._connected_event: Optional[Event] = None
90
+ self._is_connected: bool = False
91
+ self._url: Optional[str] = None
92
+ self._req: Request = req
93
+ self._protobuf_types: Any = protobuf_types
94
+ self._subscriptions: Dict[int, Subscription] = dict()
95
+ if number_of_threads and number_of_threads < 2:
96
+ number_of_threads = 2
97
+ self._pool: ThreadPoolExecutor = ThreadPoolExecutor(
98
+ max_workers=number_of_threads, thread_name_prefix="mcx_sub")
99
+ self._callback_handler: StateCallbackHandler = StateCallbackHandler()
100
+ self._connection_state: ConnectionState = ConnectionState.DISCONNECTED
101
+ # Net safeguard against the "forgot to call close()" hang —
102
+ # see motorcortex.subscribe._close_at_exit for the rationale.
103
+ self._closed: bool = False
104
+ _register_shutdown(self)
105
+
106
+ def __repr__(self) -> str:
107
+ return (
108
+ f"Subscribe(url={self._url!r}, "
109
+ f"state={self._connection_state.name}, "
110
+ f"groups={len(self._subscriptions)})"
111
+ )
112
+
113
+ # -- NNG pipe callbacks -------------------------------------------
114
+ #
115
+ # Methods instead of closures — direct invocation in tests, shorter
116
+ # connect(), same runtime behavior. See ``motorcortex.request`` for
117
+ # the mirror pair. The ``pipe`` arg from NNG is unused.
118
+
119
+ def _on_pipe_connect(self, _pipe: Any) -> None:
120
+ """Subscribe socket accepted a peer pipe — transition to
121
+ ``CONNECTION_OK`` and wake waiters.
122
+ """
123
+ old_state = self._connection_state.name
124
+ self._is_connected = True
125
+ self._connection_state = ConnectionState.CONNECTION_OK
126
+ logger.debug(
127
+ "[SUBSCRIBE-CALLBACK] PRE_CONNECT fired — %s -> %s",
128
+ old_state, self._connection_state.name,
129
+ )
130
+ self._callback_handler.notify(self._req, self, self.connectionState())
131
+ if self._connected_event is not None:
132
+ self._connected_event.set()
133
+
134
+ def _on_pipe_remove(self, _pipe: Any) -> None:
135
+ """Subscribe socket pipe torn down. Delegates to the shared
136
+ state-transition helper.
137
+ """
138
+ old_state = self._connection_state.name
139
+ self._connection_state = _connection_state.next_state_after_pipe_remove(
140
+ self._connection_state
141
+ )
142
+ logger.debug(
143
+ "[SUBSCRIBE-CALLBACK] POST_REMOVE fired — %s -> %s",
144
+ old_state, self._connection_state.name,
145
+ )
146
+ self._is_connected = False
147
+ self._callback_handler.notify(self._req, self, self.connectionState())
148
+ if self._connected_event is not None:
149
+ self._connected_event.set()
150
+
151
+ def connect(self, url: str, **kwargs: Any) -> "Reply[bool]":
152
+ """
153
+ Open a subscription connection.
154
+
155
+ Args:
156
+ url (str): Motorcortex server URL.
157
+ **kwargs: Additional connection parameters. Supported keys include:
158
+ certificate (str, optional): Path to a TLS certificate file for secure connections.
159
+ conn_timeout_ms (int, optional): Connection timeout in milliseconds (default: 1000).
160
+ recv_timeout_ms (int, optional): Receive timeout in milliseconds (default: 500).
161
+ login (str, optional): Username for authentication (if required by server).
162
+ password (str, optional): Password for authentication (if required by server).
163
+ state_update (Callable, optional): Callback function to be called on connection state changes.
164
+ timeout_ms (int, optional): Alternative timeout in milliseconds (used by Request.parse).
165
+
166
+ Returns:
167
+ Reply: A promise that resolves when the connection is established.
168
+ """
169
+ self._connection_state = ConnectionState.CONNECTING
170
+ conn_timeout_ms, recv_timeout_ms, certificate, state_update = _request_utils.parse_connect_kwargs(**kwargs)
171
+
172
+ if state_update:
173
+ self._callback_handler.start(state_update)
174
+
175
+ if not recv_timeout_ms:
176
+ recv_timeout_ms = 500
177
+
178
+ self._url = url
179
+ tls_config = None
180
+ if certificate:
181
+ parsed = NngUrl(url)
182
+ # See request.py for the rationale — pynng defaults auth_mode to
183
+ # REQUIRED, but mbedTLS 3.6+ rejects the self-signed end-entity
184
+ # cert motorcortex ships as a trust anchor. OPTIONAL verifies
185
+ # best-effort without failing on the CA:FALSE check.
186
+ tls_config = TLSConfig(
187
+ TLSConfig.MODE_CLIENT,
188
+ ca_files=certificate,
189
+ server_name=parsed.hostname,
190
+ auth_mode=TLSConfig.AUTH_MODE_OPTIONAL,
191
+ )
192
+
193
+ self._socket = Sub0(recv_timeout=recv_timeout_ms, tls_config=tls_config)
194
+
195
+ self._connected_event = Event()
196
+ self._is_connected = False
197
+
198
+ self._socket.add_pre_pipe_connect_cb(self._on_pipe_connect)
199
+ self._socket.add_post_pipe_remove_cb(self._on_pipe_remove)
200
+
201
+ logger.debug(
202
+ "[SUBSCRIBE] dialing %s (timeout=%dms, tls=%s)",
203
+ url, conn_timeout_ms, bool(tls_config),
204
+ )
205
+ self._socket.dial(url, block=False)
206
+
207
+ self._pool.submit(self.run, self._socket)
208
+
209
+ return Reply(self._pool.submit(
210
+ _request_utils.wait_for_connection, self._connected_event,
211
+ conn_timeout_ms / 1000.0, lambda: self._is_connected,
212
+ ))
213
+
214
+ def close(self) -> None:
215
+ """
216
+ Close connection to the server and clean up resources.
217
+ """
218
+ if self._closed:
219
+ return
220
+ self._closed = True
221
+ logger.debug(
222
+ "[SUBSCRIBE] closing (state=%s, groups=%d)",
223
+ self._connection_state.name, len(self._subscriptions),
224
+ )
225
+
226
+ self._connection_state = ConnectionState.DISCONNECTING
227
+ if self._connected_event:
228
+ self._is_connected = False
229
+ self._connected_event.set()
230
+
231
+ if self._socket:
232
+ self._socket.close()
233
+
234
+ self._callback_handler.stop()
235
+ self._pool.shutdown(wait=True)
236
+
237
+ def run(self, socket: Sub0) -> None:
238
+ """
239
+ Main receive loop for the subscription socket.
240
+
241
+ Args:
242
+ socket (Sub0): The subscription socket to receive messages from.
243
+ """
244
+ # run() is submitted to the pool from connect() right after
245
+ # ``_connected_event`` is created, so it's guaranteed non-None.
246
+ assert self._connected_event is not None
247
+ # Wait for initial connection
248
+ while not self._is_connected:
249
+ self._connected_event.wait() # Wait until connected
250
+
251
+ # Check if we're shutting down
252
+ if not self._is_connected:
253
+ if self._connection_state in (ConnectionState.DISCONNECTING,
254
+ ConnectionState.DISCONNECTED,
255
+ ConnectionState.CONNECTION_FAILED):
256
+ return
257
+
258
+ while True:
259
+ try:
260
+ buffer = socket.recv()
261
+
262
+ except pynng.Timeout:
263
+ # This is normal - just continue
264
+ continue
265
+
266
+ except pynng.Closed:
267
+ break
268
+
269
+ except RuntimeError as e:
270
+ if "pool" in str(e).lower():
271
+ break
272
+ logger.error("[SUBSCRIBE] RuntimeError in recv loop: %s", e)
273
+ continue
274
+
275
+ except Exception as e:
276
+ logger.error("[SUBSCRIBE] %s in recv loop: %s", type(e).__name__, e)
277
+ continue
278
+
279
+ # Header parsing + per-protocol routing lives in
280
+ # motorcortex._subscribe_dispatch so it can be unit-tested
281
+ # against synthetic frames.
282
+ _subscribe_dispatch.dispatch_frame(buffer, self._subscriptions)
283
+
284
+ def subscribe(self, param_list: List[str], group_alias: str, frq_divider: int = 1) -> Subscription:
285
+ """
286
+ Create a subscription group for a list of parameters.
287
+
288
+ Args:
289
+ param_list (List[str]): List of the parameters to subscribe to.
290
+ group_alias (str): Name of the group.
291
+ frq_divider (int, optional): Frequency divider for the group publish rate. Defaults to 1.
292
+
293
+ Returns:
294
+ Subscription: A subscription handle, which acts as a JavaScript Promise. It is resolved when the
295
+ subscription is ready or failed. After the subscription is ready, the handle is used to retrieve the latest data.
296
+ """
297
+ subscription = Subscription(group_alias, self._protobuf_types, frq_divider, self._pool)
298
+ reply = self._req.createGroup(param_list, group_alias, frq_divider)
299
+ reply.then(self._complete, subscription, self._socket).catch(subscription._failed)
300
+
301
+ return subscription
302
+
303
+ def unsubscribe(self, subscription: Subscription) -> "Reply[Any]":
304
+ """
305
+ Unsubscribe from the group.
306
+
307
+ Args:
308
+ subscription (Subscription): Subscription handle.
309
+
310
+ Returns:
311
+ Reply: A promise that resolves when the unsubscribe operation is complete, fails otherwise.
312
+ """
313
+ sub_id = subscription.id()
314
+ sub_id_buf = Subscribe._idBuf(subscription.id())
315
+
316
+ # Reachable only after connect() wired up the socket.
317
+ assert self._socket is not None
318
+
319
+ # stop receiving sub
320
+ try:
321
+ self._socket.unsubscribe(sub_id_buf)
322
+ except Exception as e:
323
+ logger.debug("[SUBSCRIBE] socket.unsubscribe(%d) failed: %s", sub_id, e)
324
+
325
+ # find and remove subscription
326
+ if sub_id in self._subscriptions:
327
+ sub = self._subscriptions[sub_id]
328
+ # stop sub update thread
329
+ sub.done()
330
+ del self._subscriptions[sub_id]
331
+
332
+ # send remove group request to the server
333
+ return self._req.removeGroup(subscription.alias())
334
+
335
+ def connectionState(self) -> ConnectionState:
336
+ """
337
+ Get the current connection state.
338
+
339
+ Returns:
340
+ ConnectionState: The current state of the subscription connection.
341
+ """
342
+ return self._connection_state
343
+
344
+ def resubscribe(self) -> None:
345
+ """
346
+ Resubscribe all current groups after a reconnect.
347
+ """
348
+ logger.debug("[SUBSCRIBE] resubscribing %d groups", len(self._subscriptions))
349
+ # Reachable only after a successful connect().
350
+ assert self._socket is not None
351
+ old_sub = self._subscriptions.copy()
352
+ self._subscriptions.clear()
353
+
354
+ for sub_id, s in old_sub.items():
355
+ try:
356
+ # unsubscribe from the old group
357
+ self._socket.unsubscribe(Subscribe._idBuf(s.id()))
358
+ except Exception as e:
359
+ logger.debug("[SUBSCRIBE] failed to unsubscribe old id %d: %s", sub_id, e)
360
+
361
+ # Subscriptions in ``_subscriptions`` were already completed,
362
+ # so ``layout()`` is non-None by construction.
363
+ layout = s.layout()
364
+ assert layout is not None
365
+ msg = self._req.createGroup(layout, s.alias(), s.frqDivider()).get()
366
+ s._updateId(msg.id)
367
+ self._socket.subscribe(Subscribe._idBuf(s.id()))
368
+ self._subscriptions[s.id()] = s
369
+
370
+ @staticmethod
371
+ def _idBuf(msg_id: int) -> bytes:
372
+ """
373
+ Convert a message id to a 3-byte buffer.
374
+
375
+ Args:
376
+ msg_id (int): The message id.
377
+
378
+ Returns:
379
+ bytes: The id buffer.
380
+ """
381
+ return bytes([msg_id & 0xff, (msg_id >> 8) & 0xff, (msg_id >> 16) & 0xff])
382
+
383
+ def _complete(self, msg: Any, subscription: Subscription, socket: Sub0) -> None:
384
+ """
385
+ Complete the subscription setup after receiving a reply from the server.
386
+
387
+ Args:
388
+ msg (Any): The reply message from the server.
389
+ subscription (Subscription): The subscription object.
390
+ socket (Sub0): The subscription socket.
391
+ """
392
+ if subscription._complete(msg):
393
+ id_buf = Subscribe._idBuf(msg.id)
394
+ socket.subscribe(id_buf)
395
+ self._subscriptions[msg.id] = subscription
396
+ else:
397
+ logger.debug(
398
+ "[SUBSCRIBE] failed to complete group %r (id=%d)",
399
+ subscription.alias(), msg.id,
400
+ )