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.
@@ -0,0 +1,314 @@
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
+ """
9
+ motorcortex-python
10
+
11
+ Motorcortex is a Python library for connecting to Motorcortex servers, handling requests, subscriptions, and parameter trees.
12
+ Provides high-level APIs for communication, login, and data exchange using protocol buffers.
13
+
14
+ See documentation for usage examples.
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ from motorcortex.version import __version__
20
+ from motorcortex.parameter_tree import ParameterTree
21
+ from motorcortex.message_types import MessageTypes
22
+ from motorcortex.request import Request, ConnectionState
23
+ from motorcortex.reply import Reply
24
+ from motorcortex.subscribe import Subscribe
25
+ from motorcortex.subscription import Subscription
26
+ from motorcortex.timespec import (
27
+ Timespec,
28
+ compare_timespec,
29
+ timespec_to_sec,
30
+ timespec_to_msec,
31
+ timespec_to_usec,
32
+ timespec_to_nsec,
33
+ )
34
+ from motorcortex.init_threads import init_nng_threads
35
+ from motorcortex.session import Session
36
+ from motorcortex.exceptions import (
37
+ McxError,
38
+ McxConnectionError,
39
+ McxLoginError,
40
+ McxTimeout,
41
+ )
42
+
43
+ # ``__all__`` is the 1.0 public API surface. Anything not listed here —
44
+ # including module-level helpers defined below (``parseUrl``,
45
+ # ``makeUrl``, ``statusToStr``) and submodule imports like
46
+ # ``motorcortex.setup_logger`` — is implicitly private and may change
47
+ # between minor releases. For logging, do
48
+ # ``logging.getLogger("mcx")`` instead of reaching into the package.
49
+ __all__ = [
50
+ "__version__",
51
+ # Entry points
52
+ "connect",
53
+ "Session",
54
+ # Connection / protocol classes
55
+ "Request",
56
+ "Subscribe",
57
+ "Subscription",
58
+ "Reply",
59
+ "ConnectionState",
60
+ # Protobuf / parameters
61
+ "MessageTypes",
62
+ "ParameterTree",
63
+ # Time
64
+ "Timespec",
65
+ "compare_timespec",
66
+ "timespec_to_sec",
67
+ "timespec_to_msec",
68
+ "timespec_to_usec",
69
+ "timespec_to_nsec",
70
+ # Exceptions
71
+ "McxError",
72
+ "McxConnectionError",
73
+ "McxLoginError",
74
+ "McxTimeout",
75
+ # Tuning
76
+ "init_nng_threads",
77
+ ]
78
+
79
+ init_nng_threads()
80
+
81
+
82
+ def parseUrl(url: str) -> tuple[str, str, int | None, int | None]:
83
+ """
84
+ Parses a Motorcortex connection URL to extract request and subscribe addresses and ports.
85
+
86
+ Args:
87
+ url (str): The connection URL, expected in the format 'address:req_port:sub_port'.
88
+
89
+ Returns:
90
+ tuple: (req_address, sub_address, req_port, sub_port)
91
+ - req_address (str): Address for request connection.
92
+ - sub_address (str): Address for subscribe connection.
93
+ - req_port (int or None): Port for request connection.
94
+ - sub_port (int or None): Port for subscribe connection.
95
+
96
+ If the URL does not contain ports, default endpoints '/mcx_req' and '/mcx_sub' are appended.
97
+ """
98
+ # IPv6 host literals embed colons (``wss://[::1]``), so the rfind-based
99
+ # port scan has to start *after* the closing bracket of the host
100
+ # literal — otherwise it walks into the address bytes and either
101
+ # mis-parses the ports or (in the no-ports case) tries to int() an
102
+ # empty slice. For IPv4 / hostname URLs there is no bracket, so
103
+ # ``port_start`` stays at 0 and behavior matches the pre-fix code.
104
+ host_end = url.rfind(']')
105
+ port_start = host_end + 1 if host_end != -1 else 0
106
+
107
+ end = url.rfind(':')
108
+ if end < port_start:
109
+ return url + '/mcx_req', url + '/mcx_sub', None, None
110
+ start = url.rfind(':', port_start, end)
111
+ if start == -1:
112
+ return url + '/mcx_req', url + '/mcx_sub', None, None
113
+ req_port = int(url[start + 1:end])
114
+ sub_port = int(url[end + 1:])
115
+ address = url[:start]
116
+ return address, address, req_port, sub_port
117
+
118
+
119
+ def makeUrl(address: str, port: int | None) -> str:
120
+ """
121
+ Constructs a URL string from an address and port.
122
+
123
+ Args:
124
+ address (str): The base address.
125
+ port (int or None): The port number.
126
+
127
+ Returns:
128
+ str: The combined address and port in the format 'address:port', or just 'address' if port is None.
129
+ """
130
+ if port:
131
+ return "{}:{}".format(address, port)
132
+
133
+ return address
134
+
135
+
136
+ def connect(
137
+ url: str,
138
+ motorcortex_types: "MessageTypes",
139
+ param_tree: "ParameterTree",
140
+ reconnect: bool = True,
141
+ **kwargs: Any,
142
+ ) -> tuple["Request", "Subscribe"]:
143
+ """
144
+ Establishes connections to Motorcortex request and subscribe endpoints, performs login, and loads the parameter tree.
145
+
146
+ Args:
147
+ url (str): Connection URL in the format 'address:req_port:sub_port' or 'wss://address'.
148
+ motorcortex_types (MessageTypes): Motorcortex message types instance.
149
+ param_tree (ParameterTree): ParameterTree instance to load parameters into.
150
+ reconnect (bool, optional): Whether to enable automatic reconnection. Defaults to True.
151
+ **kwargs: Additional keyword arguments:
152
+ login (str): Username for authentication.
153
+ password (str): Password for authentication.
154
+ certificate (str, optional): Path to a TLS certificate file for secure connections.
155
+ timeout_ms (int, optional): Connection timeout in milliseconds. Defaults to 1000.
156
+ recv_timeout_ms (int, optional): Receive timeout in milliseconds. Defaults to 500.
157
+ token_update_interval_ms (int, optional): Session token refresh interval in milliseconds. Defaults to 30000.
158
+ state_update (Callable, optional): Custom callback for connection state changes.
159
+ If provided, disables the built-in reconnect logic.
160
+ req_number_of_threads (int, optional): Thread pool size for request connection. Defaults to 2.
161
+ sub_number_of_threads (int, optional): Thread pool size for subscribe connection. Defaults to 2.
162
+
163
+ Returns:
164
+ tuple: (req, sub)
165
+ - req (Request): Established request connection.
166
+ - sub (Subscribe): Established subscribe connection.
167
+
168
+ Raises:
169
+ RuntimeError: If connection or login fails.
170
+
171
+ Examples:
172
+ >>> from motorcortex import connect, MessageTypes, ParameterTree
173
+ >>> types = MessageTypes()
174
+ >>> tree = ParameterTree()
175
+ >>> req, sub = connect("wss://192.168.2.100", types, tree,
176
+ ... certificate="mcx.cert.crt", timeout_ms=1000,
177
+ ... login="admin", password="admin",
178
+ ... token_update_interval_ms=15000)
179
+ """
180
+
181
+ token_interval_sec = kwargs.get("token_update_interval_ms", 30000) / 1000.0
182
+
183
+ initial_connect_done = [False] # Now reconnections will trigger callback login
184
+
185
+ if reconnect and not kwargs.get("state_update"):
186
+
187
+ def stateUpdate(req, sub, state):
188
+ if state == ConnectionState.CONNECTION_OK and initial_connect_done[0]:
189
+ # Try to restore session using token, fall back to login
190
+ restored = False
191
+ if req.token:
192
+ try:
193
+ restore_reply = req.restoreSession(req.token)
194
+ restore_msg = restore_reply.get(timeout_ms=5000)
195
+ motorcortex_msg = motorcortex_types.motorcortex()
196
+ if restore_msg and restore_msg.status == motorcortex_msg.OK:
197
+ restored = True
198
+ logger.debug("[SESSION] Session restored using token")
199
+ except Exception as e:
200
+ logger.debug(f"[SESSION] Token restore failed: {e}")
201
+
202
+ if not restored:
203
+ logger.debug("[SESSION] Falling back to login")
204
+ req.login(kwargs.get("login"), kwargs.get("password")).get()
205
+
206
+ req._startTokenRefresh(token_interval_sec)
207
+ sub.resubscribe()
208
+
209
+ kwargs.update(state_update=stateUpdate)
210
+
211
+ # Parse address
212
+ req_address, sub_address, req_port, sub_port = parseUrl(url)
213
+ # Open request connection
214
+ req = Request(motorcortex_types, param_tree, kwargs.get("req_number_of_threads", 2))
215
+ sub = None
216
+ # Wrap every post-construction failure point so half-opened sockets
217
+ # get closed; otherwise the Subscribe receive thread stays blocked in
218
+ # recv() forever and pins the Python interpreter on exit (an atexit
219
+ # ``_python_exit`` handler then joins the worker indefinitely).
220
+ try:
221
+ kwargs_copy = kwargs.copy()
222
+ kwargs_copy.update(state_update=None)
223
+ if not req.connect(makeUrl(req_address, req_port), **kwargs_copy).get():
224
+ raise McxConnectionError(
225
+ "Failed to establish request connection: {}:{}".format(req_address, req_port)
226
+ )
227
+ # Open subscribe connection
228
+ sub = Subscribe(req, motorcortex_types, kwargs.get("sub_number_of_threads", 2))
229
+ if not sub.connect(makeUrl(sub_address, sub_port), **kwargs).get():
230
+ raise McxConnectionError(
231
+ "Failed to establish subscribe connection: {}:{}".format(sub_address, sub_port)
232
+ )
233
+ # Login. With ``Login: disable`` servers the credentials can legitimately
234
+ # be omitted — default to empty strings so the helper does not raise
235
+ # ``KeyError`` on valid callers.
236
+ login_reply = req.login(kwargs.get('login', ''), kwargs.get('password', ''))
237
+ login_reply_msg = login_reply.get()
238
+
239
+ motorcortex_msg = motorcortex_types.motorcortex()
240
+ if not login_reply_msg.status == motorcortex_msg.OK:
241
+ raise McxLoginError(
242
+ "Login failed, status: {}".format(login_reply_msg.status),
243
+ status=login_reply_msg.status,
244
+ )
245
+
246
+ # Requesting a parameter tree
247
+ param_tree_reply = req.getParameterTree()
248
+ tree = param_tree_reply.get()
249
+ param_tree.load(tree)
250
+ except Exception:
251
+ if sub is not None:
252
+ try:
253
+ sub.close()
254
+ except Exception:
255
+ pass
256
+ try:
257
+ req.close()
258
+ except Exception:
259
+ pass
260
+ raise
261
+
262
+ # Start session token refresh
263
+ req._startTokenRefresh(token_interval_sec)
264
+
265
+ initial_connect_done[0] = True # Now reconnections will trigger callback login
266
+
267
+ return req, sub
268
+
269
+
270
+ def statusToStr(motorcortex_msg: Any, code: int) -> str:
271
+ """Converts status codes to a readable message.
272
+
273
+ Args:
274
+ motorcortex_msg(Module): reference to a motorcortex module
275
+ code(int): status code
276
+
277
+ Returns:
278
+ str: status message
279
+
280
+ Examples:
281
+ >>> login_reply = req.login("admin", "iddqd")
282
+ >>> login_reply_msg = login_reply.get()
283
+ >>> if login_reply_msg.status != motorcortex_msg.OK:
284
+ >>> print(motorcortex.statusToStr(motorcortex_msg, login_reply_msg.status))
285
+
286
+ """
287
+
288
+ status = 'Unknown code'
289
+ if code == motorcortex_msg.OK:
290
+ status = 'Success'
291
+ elif code == motorcortex_msg.FAILED:
292
+ status = 'Failed'
293
+ elif code == motorcortex_msg.FAILED_TO_DECODE:
294
+ status = 'Failed to decode request'
295
+ elif code == motorcortex_msg.SUB_LIST_IS_FULL:
296
+ status = 'Failed to subscribe, subscription list is full'
297
+ elif code == motorcortex_msg.WRONG_PARAMETER_PATH:
298
+ status = 'Failed to find parameter'
299
+ elif code == motorcortex_msg.FAILED_TO_SET_REQUESTED_FRQ:
300
+ status = 'Failed to set requested frequency'
301
+ elif code == motorcortex_msg.FAILED_TO_OPEN_FILE:
302
+ status = 'Failed to open file'
303
+ elif code == motorcortex_msg.READ_ONLY_MODE:
304
+ status = 'Logged in, read-only mode'
305
+ elif code == motorcortex_msg.WRONG_PASSWORD:
306
+ status = 'Wrong login or password'
307
+ elif code == motorcortex_msg.USER_NOT_LOGGED_IN:
308
+ status = 'Operation is not permitted, user is not logged in'
309
+ elif code == motorcortex_msg.PERMISSION_DENIED:
310
+ status = 'Operation is not permitted, user has no rights'
311
+
312
+ status += str(' (%s)' % hex(code))
313
+
314
+ return status
@@ -0,0 +1,58 @@
1
+ #
2
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
3
+ # All rights reserved. Copyright (c) 2026 VECTIONEER.
4
+ #
5
+
6
+ """Connection-state enum + pure state-transition helpers.
7
+
8
+ ``ConnectionState`` was historically defined in ``motorcortex.request`` and
9
+ is still re-exported from there (and from ``motorcortex``) for backward
10
+ compatibility. It lives here so pure helpers can depend on it without
11
+ dragging the whole ``request`` module — which would cause a circular
12
+ import.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import Enum
18
+
19
+
20
+ class ConnectionState(Enum):
21
+ """Enumeration of connection states.
22
+
23
+ - CONNECTING: Connection is being established.
24
+ - CONNECTION_OK: Connection is successfully established.
25
+ - CONNECTION_LOST: Connection was lost.
26
+ - CONNECTION_FAILED: Connection attempt failed.
27
+ - DISCONNECTING: Connection is being closed.
28
+ - DISCONNECTED: Connection is closed.
29
+ """
30
+ CONNECTING = 0
31
+ CONNECTION_OK = 1
32
+ CONNECTION_LOST = 2
33
+ CONNECTION_FAILED = 3
34
+ DISCONNECTING = 4
35
+ DISCONNECTED = 5
36
+
37
+
38
+ def next_state_after_pipe_remove(current: ConnectionState) -> ConnectionState:
39
+ """Map the current state to the one we enter when the remote pipe goes away.
40
+
41
+ nng fires its post-remove callback when a peer socket is torn down, but
42
+ the callback itself can't tell *why*: we might have called
43
+ ``close()`` (clean shutdown) or the remote might have died (lost /
44
+ failed). The only disambiguator is the state we were already in.
45
+
46
+ Transitions:
47
+ DISCONNECTING → DISCONNECTED (clean close we initiated)
48
+ CONNECTING → CONNECTION_FAILED (never finished handshake)
49
+ CONNECTION_OK → CONNECTION_LOST (remote went away mid-session)
50
+ anything else → unchanged (idempotent / re-entry safe)
51
+ """
52
+ if current == ConnectionState.DISCONNECTING:
53
+ return ConnectionState.DISCONNECTED
54
+ if current == ConnectionState.CONNECTING:
55
+ return ConnectionState.CONNECTION_FAILED
56
+ if current == ConnectionState.CONNECTION_OK:
57
+ return ConnectionState.CONNECTION_LOST
58
+ return current
@@ -0,0 +1,157 @@
1
+ #
2
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
3
+ # All rights reserved. Copyright (c) 2026 VECTIONEER.
4
+ #
5
+
6
+ """Pure protobuf message builders used by :class:`motorcortex.Request`.
7
+
8
+ These used to be private static methods on ``Request``. Moving them to
9
+ a free-function module so they can be unit-tested without spinning up a
10
+ socket or stubbing the class.
11
+
12
+ Everything here is side-effect-free w.r.t. the wire — the builders just
13
+ produce protobuf messages that the caller later hands to
14
+ ``Request.send()``. They may log an error when no encoder resolves, but
15
+ they do not touch sockets, files, or global state.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Optional, TYPE_CHECKING
21
+
22
+ from motorcortex.setup_logger import logger
23
+
24
+ if TYPE_CHECKING:
25
+ from motorcortex.message_types import MessageTypes
26
+ from motorcortex.parameter_tree import ParameterTree
27
+
28
+
29
+ def _resolve_encoder(
30
+ path: str,
31
+ type_name: Optional[str],
32
+ protobuf_types: "MessageTypes",
33
+ parameter_tree: "ParameterTree",
34
+ ) -> Any:
35
+ """Find the encoder for ``path`` — by explicit ``type_name`` first,
36
+ otherwise by hash looked up in the parameter tree.
37
+
38
+ Returns ``None`` when neither resolution succeeds. Callers (see
39
+ ``build_set_parameter_msg`` / ``build_overwrite_parameter_msg``)
40
+ log and fall back to sending an empty-value message, which the
41
+ server rejects with a clean status code — beats raising
42
+ ``AttributeError`` at the client on ``.encode()``.
43
+ """
44
+ if type_name:
45
+ return protobuf_types.createType(type_name)
46
+ try:
47
+ type_id = parameter_tree.getDataType(path)
48
+ except KeyError:
49
+ return None
50
+ return protobuf_types.getTypeByHash(type_id)
51
+
52
+
53
+ def build_set_parameter_msg(
54
+ path: str,
55
+ value: Any,
56
+ type_name: Optional[str],
57
+ protobuf_types: "MessageTypes",
58
+ parameter_tree: "ParameterTree",
59
+ ) -> Any:
60
+ """``SetParameterMsg`` for a single parameter (whole-value write).
61
+
62
+ When the encoder cannot be resolved (unknown path, no ``type_name``),
63
+ the message is built with an empty ``value`` byte string and sent as-is
64
+ — the server rejects it with a proper error status, which beats
65
+ raising ``AttributeError`` at the client.
66
+ """
67
+ param_value = _resolve_encoder(path, type_name, protobuf_types, parameter_tree)
68
+ msg = protobuf_types.createType("motorcortex.SetParameterMsg")
69
+ msg.path = path
70
+ if param_value is None:
71
+ logger.error("No encoder for path %s (type=%s) — sending empty value", path, type_name)
72
+ msg.value = b""
73
+ else:
74
+ msg.value = param_value.encode(value)
75
+ return msg
76
+
77
+
78
+ def build_set_parameter_with_offset_msg(
79
+ offset: int,
80
+ length: int,
81
+ path: str,
82
+ value: Any,
83
+ type_name: Optional[str],
84
+ protobuf_types: "MessageTypes",
85
+ parameter_tree: "ParameterTree",
86
+ ) -> Any:
87
+ """``SetParameterMsg`` with an ``offset`` + ``length`` slice window.
88
+
89
+ Negative offsets are clamped to zero. ``length == 0`` means "write
90
+ all of ``value``" and is inferred from ``len(value)`` (or 1 for
91
+ scalars). Unknown encoder → empty ``value``, see
92
+ :func:`build_set_parameter_msg`.
93
+ """
94
+ param_value = _resolve_encoder(path, type_name, protobuf_types, parameter_tree)
95
+
96
+ if offset < 0:
97
+ offset = 0
98
+ if length < 0:
99
+ length = 0
100
+ if length == 0:
101
+ length = len(value) if hasattr(value, "__len__") else 1
102
+
103
+ msg = protobuf_types.createType("motorcortex.SetParameterMsg")
104
+ msg.offset.type = 1
105
+ msg.offset.offset = offset
106
+ msg.offset.length = length
107
+ msg.path = path
108
+ if param_value is None:
109
+ logger.error("No encoder for path %s (type=%s) — sending empty value", path, type_name)
110
+ msg.value = b""
111
+ else:
112
+ msg.value = param_value.encode(value)
113
+ return msg
114
+
115
+
116
+ def build_get_parameter_msg(
117
+ path: str,
118
+ protobuf_types: "MessageTypes",
119
+ ) -> Any:
120
+ """``GetParameterMsg`` — trivial, just carries the path."""
121
+ msg = protobuf_types.createType("motorcortex.GetParameterMsg")
122
+ msg.path = path
123
+ return msg
124
+
125
+
126
+ def build_overwrite_parameter_msg(
127
+ path: str,
128
+ value: Any,
129
+ activate: bool,
130
+ type_name: Optional[str],
131
+ protobuf_types: "MessageTypes",
132
+ parameter_tree: "ParameterTree",
133
+ ) -> Any:
134
+ """``OverwriteParameterMsg`` — pins a value until release or deactivate.
135
+
136
+ Unknown encoder → empty ``value``, see :func:`build_set_parameter_msg`.
137
+ """
138
+ param_value = _resolve_encoder(path, type_name, protobuf_types, parameter_tree)
139
+ msg = protobuf_types.createType("motorcortex.OverwriteParameterMsg")
140
+ msg.path = path
141
+ msg.activate = activate
142
+ if param_value is None:
143
+ logger.error("No encoder for path %s (type=%s) — sending empty value", path, type_name)
144
+ msg.value = b""
145
+ else:
146
+ msg.value = param_value.encode(value)
147
+ return msg
148
+
149
+
150
+ def build_release_parameter_msg(
151
+ path: str,
152
+ protobuf_types: "MessageTypes",
153
+ ) -> Any:
154
+ """``ReleaseParameterMsg`` — drop any active overwrite on ``path``."""
155
+ msg = protobuf_types.createType("motorcortex.ReleaseParameterMsg")
156
+ msg.path = path
157
+ return msg