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/__init__.py +314 -0
- motorcortex/_connection_state.py +58 -0
- motorcortex/_request_builders.py +157 -0
- motorcortex/_request_utils.py +314 -0
- motorcortex/_subscribe_dispatch.py +90 -0
- motorcortex/exceptions.py +65 -0
- motorcortex/init_threads.py +103 -0
- motorcortex/message_types.py +387 -0
- motorcortex/motorcortex_hash.json +166 -0
- motorcortex/motorcortex_pb2.py +105 -0
- motorcortex/motorcortex_pb2.pyi +1961 -0
- motorcortex/nng_url.py +49 -0
- motorcortex/parameter_tree.py +86 -0
- motorcortex/py.typed +0 -0
- motorcortex/reply.py +108 -0
- motorcortex/request.py +668 -0
- motorcortex/session.py +194 -0
- motorcortex/setup_logger.py +10 -0
- motorcortex/state_callback_handler.py +92 -0
- motorcortex/subscribe.py +400 -0
- motorcortex/subscription.py +414 -0
- motorcortex/timespec.py +173 -0
- motorcortex/version.py +1 -0
- motorcortex_python-1.0.0rc1.dist-info/LICENSE +22 -0
- motorcortex_python-1.0.0rc1.dist-info/METADATA +171 -0
- motorcortex_python-1.0.0rc1.dist-info/RECORD +28 -0
- motorcortex_python-1.0.0rc1.dist-info/WHEEL +5 -0
- motorcortex_python-1.0.0rc1.dist-info/top_level.txt +1 -0
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,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)
|
motorcortex/subscribe.py
ADDED
|
@@ -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
|
+
)
|