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,414 @@
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 warnings
9
+ from collections import namedtuple
10
+ from struct import unpack_from
11
+ from concurrent.futures import Future
12
+ import time
13
+ from typing import Any, Callable, List, Optional, Union
14
+ from motorcortex.timespec import Timespec
15
+
16
+
17
+ # Default wait when the caller passes neither ``timeout_sec`` nor
18
+ # ``timeout_ms``. Kept at 1 s for backwards compatibility with the
19
+ # pre-T1.2 signature ``def get(self, timeout_sec: float = 1.0)``.
20
+ _DEFAULT_TIMEOUT_SEC = 1.0
21
+
22
+ # timespec = namedtuple('timespec', 'sec, nsec')
23
+
24
+
25
+ Parameter = namedtuple('Parameter', 'timestamp, value')
26
+
27
+
28
+ class Subscription(object):
29
+ """
30
+ Represents a subscription to a group of parameters in Motorcortex.
31
+
32
+ The Subscription class allows you to:
33
+ - Access the latest values and timestamps for a group of parameters.
34
+ - Poll for updates or use observer callbacks for real-time notifications.
35
+ - Chain asynchronous operations using `then` and `catch` (promise-like interface).
36
+
37
+ Attributes:
38
+ group_alias (str): Alias for the parameter group.
39
+ protobuf_types (Any): Protobuf type definitions.
40
+ frq_divider (int): Frequency divider for the group.
41
+ pool (Any): Thread or process pool for observer callbacks.
42
+
43
+ Methods:
44
+ id() -> int
45
+ Returns the subscription identifier.
46
+
47
+ alias() -> str
48
+ Returns the group alias.
49
+
50
+ frqDivider() -> int
51
+ Returns the frequency divider of the group.
52
+
53
+ read() -> Optional[List[Parameter]]
54
+ Returns the latest values of the parameters in the group.
55
+
56
+ layout() -> Optional[List[str]]
57
+ Returns the ordered list of parameter paths in the group.
58
+
59
+ done() -> bool
60
+ Returns True if the subscription is finished or cancelled.
61
+
62
+ get(timeout_sec: float = 1.0) -> Optional[Any]
63
+ Waits for the subscription to complete, returns the result or None on timeout.
64
+
65
+ then(subscribed_clb: Callable[[Any], None]) -> Subscription
66
+ Registers a callback for successful subscription completion.
67
+
68
+ catch(failed: Callable[[], None]) -> Subscription
69
+ Registers a callback for subscription failure.
70
+
71
+ notify(observer_list: Union[Callable, List[Callable]]) -> None
72
+ Registers observer(s) to be notified on every group update.
73
+
74
+ Examples:
75
+ >>> # Make sure you have a valid connection
76
+ >>> subscription = sub.subscribe(paths, "group1", 100)
77
+ >>> result = subscription.get()
78
+ >>> if result is not None and result.status == motorcortex.OK:
79
+ ... print(f"Subscription successful, layout: {subscription.layout()}")
80
+ ... else:
81
+ ... print(f"Subscription failed. Check parameter paths: {paths}")
82
+ ... sub.close()
83
+ ... exit()
84
+ >>> # Use promise-like interface
85
+ >>> subscription.then(lambda res: print("Subscribed:", res)).catch(lambda: print("Failed"))
86
+ >>> # Use observer for real-time updates
87
+ >>> def on_update(parameters):
88
+ ... for param in parameters:
89
+ ... timestamp = param.timestamp.sec + param.timestamp.nsec * 1e-9
90
+ ... print(f"Update: {timestamp:.6f}, {param.value}")
91
+ >>> subscription.notify(on_update)
92
+ >>> print("Waiting for parameter updates...")
93
+ >>> import time
94
+ >>> while True:
95
+ ... time.sleep(1)
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ group_alias: str,
101
+ protobuf_types: Any,
102
+ frq_divider: int,
103
+ pool: Any
104
+ ) -> None:
105
+ # ``_info`` is the server's reply message (typed as ``Any`` since
106
+ # it's an opaque protobuf). Starts ``None``; populated by
107
+ # ``_complete()`` once the subscribe handshake lands. Use sites
108
+ # only fire after ``_is_complete`` is True, so the value is safe
109
+ # to access without a guard — the typed-as-Any gives up the mypy
110
+ # Optional check, which is fine here (the field's real type is
111
+ # dynamic anyway).
112
+ self._info: Any = None
113
+ self._group_alias: str = group_alias
114
+ self._protobuf_types: Any = protobuf_types
115
+ self._decoder: List[Any] = []
116
+ self._future: Future = Future()
117
+ # ``_values`` / ``_layout`` default to empty lists so indexing
118
+ # and slicing are type-safe. ``read()`` / ``layout()`` still
119
+ # return ``Optional`` by gating on ``_is_complete``, preserving
120
+ # the public "None until ready" contract.
121
+ self._values: List["Parameter"] = []
122
+ self._layout: List[str] = []
123
+ self._is_complete: bool = False
124
+ self._observer_list: List[Callable[[List["Parameter"]], None]] = []
125
+ self._pool: Any = pool
126
+ self._frq_divider: int = frq_divider
127
+ self._dropped_frames: int = 0
128
+
129
+ def __repr__(self) -> str:
130
+ return (
131
+ f"Subscription(alias={self._group_alias!r}, "
132
+ f"frq_divider={self._frq_divider!r}, "
133
+ f"layout_size={len(self._layout)}, "
134
+ f"done={self._future.done()}, "
135
+ f"dropped={self._dropped_frames})"
136
+ )
137
+
138
+ def id(self) -> int:
139
+ """
140
+ Returns:
141
+ int: subscription identifier
142
+ """
143
+ return self._info.id
144
+
145
+ def alias(self) -> str:
146
+ """
147
+ Returns:
148
+ str: group alias
149
+ """
150
+ return self._group_alias
151
+
152
+ def frqDivider(self) -> int:
153
+ """
154
+ Returns:
155
+ int: frequency divider of the group
156
+ """
157
+ return self._frq_divider
158
+
159
+ def read(self) -> Optional[List["Parameter"]]:
160
+ """Read the latest values of the parameters in the group.
161
+
162
+ Returns:
163
+ list of Parameter: list of parameters
164
+ """
165
+ return self._values[:] if self._is_complete else None
166
+
167
+ def layout(self) -> Optional[List[str]]:
168
+ """Get a layout of the group.
169
+
170
+ Returns:
171
+ list of str: ordered list of the parameters in the group
172
+ """
173
+ return self._layout[:] if self._is_complete else None
174
+
175
+ def done(self) -> bool:
176
+ """
177
+ Returns:
178
+ bool: True if the call was successfully canceled or finished running.
179
+
180
+ Examples:
181
+ >>> subscription = sub.subscribe("root/logger/logOut", "log")
182
+ >>> while not subscription.done():
183
+ >>> time.sleep(0.1)
184
+ """
185
+ return self._future.done()
186
+
187
+ def get(
188
+ self,
189
+ timeout_sec: Optional[float] = None,
190
+ *,
191
+ timeout_ms: Optional[int] = None,
192
+ ) -> Optional[Any]:
193
+ """Wait for the subscribe handshake to resolve.
194
+
195
+ Args:
196
+ timeout_sec: **Deprecated** (since 0.28.0, removal
197
+ planned for 2.0). Legacy seconds-based timeout,
198
+ kept as the first positional arg so existing code
199
+ (``subscription.get(5.0)``) keeps working. Prefer
200
+ ``timeout_ms`` for new code — the rest of the
201
+ library is ms-based (see ARCHITECTURE.md §2a).
202
+ Explicit use emits a ``DeprecationWarning``.
203
+ timeout_ms: Timeout in milliseconds (keyword-only).
204
+
205
+ Returns:
206
+ The subscribe-reply message on success, ``None`` on timeout.
207
+
208
+ Examples:
209
+ >>> subscription = sub.subscribe("root/logger/logOut", "log")
210
+ >>> msg = subscription.get(timeout_ms=2000)
211
+ """
212
+ if timeout_ms is not None:
213
+ return self._future.result(timeout_ms / 1000.0)
214
+
215
+ if timeout_sec is not None:
216
+ warnings.warn(
217
+ "Subscription.get(timeout_sec=...) is deprecated; "
218
+ "use timeout_ms= instead. timeout_sec will be removed "
219
+ "in motorcortex-python 2.0.",
220
+ DeprecationWarning,
221
+ stacklevel=2,
222
+ )
223
+ return self._future.result(timeout_sec)
224
+
225
+ return self._future.result(_DEFAULT_TIMEOUT_SEC)
226
+
227
+ def then(self, subscribed_clb: Callable[[Any], None]) -> "Subscription":
228
+ """JavaScript-like promise, which is resolved when the subscription is completed.
229
+
230
+ Args:
231
+ subscribed_clb: callback which is resolved when the subscription is completed.
232
+
233
+ Returns:
234
+ self pointer to add 'catch' callback
235
+
236
+ Examples:
237
+ >>> subscription = sub.subscribe("root/logger/logOut", "log")
238
+ >>> subscription.then(lambda val: print("got: %s"%val)).catch(lambda d: print("failed"))
239
+ """
240
+
241
+ self._future.add_done_callback(
242
+ lambda msg: subscribed_clb(msg.result()) if msg.result() else None
243
+ )
244
+ return self
245
+
246
+ def catch(self, failed: Callable[[], None]) -> "Subscription":
247
+ """JavaScript-like promise, which is resolved when subscription has failed.
248
+
249
+ Args:
250
+ failed: callback which is resolved when the subscription has failed
251
+
252
+ Returns:
253
+ self pointer to add 'then' callback
254
+
255
+ Examples:
256
+ >>> subscription = sub.subscribe("root/logger/logOut", "log")
257
+ >>> subscription.catch(lambda d: print("failed")).then(lambda val: print("got: %s"%val))
258
+ """
259
+
260
+ self._future.add_done_callback(
261
+ lambda msg: failed() if not msg.result() else None
262
+ )
263
+ return self
264
+
265
+ def notify(self, observer_list: Union[
266
+ Callable[[List["Parameter"]], None], List[Callable[[List["Parameter"]], None]]]) -> None:
267
+ """Set an observer, which is notified on every group update.
268
+
269
+ Args:
270
+ observer_list: a callback function (or list of callback functions)
271
+ to notify when new values are available
272
+
273
+ Examples:
274
+ >>> def update(parameters):
275
+ >>> print(parameters) #list of Parameter tuples
276
+ >>> ...
277
+ >>> data_sub.notify(update)
278
+
279
+ """
280
+ # Narrow the union properly so mypy can pick the right branch
281
+ # — ``type(x) is list`` is opaque to the type checker.
282
+ if isinstance(observer_list, list):
283
+ self._observer_list = observer_list
284
+ else:
285
+ self._observer_list = [observer_list]
286
+
287
+ def _complete(self, msg: Any) -> bool:
288
+ """Marks the subscription as complete and initializes decoders.
289
+
290
+ Args:
291
+ msg: Protobuf message containing subscription info.
292
+
293
+ Returns:
294
+ bool: True if completed successfully, False otherwise.
295
+ """
296
+ self._decoder = []
297
+ self._values = []
298
+ self._layout = []
299
+ if msg.status == 0:
300
+ self._info = msg
301
+ for param in msg.params:
302
+ data_type = param.info.data_type
303
+ self._decoder.append(self._protobuf_types.getTypeByHash(data_type))
304
+ self._values.append(Parameter(Timespec(0, 0), [0] * param.info.number_of_elements))
305
+ self._layout.append(param.info.path)
306
+
307
+ self._is_complete = True
308
+ self._future.set_result(msg)
309
+ return True
310
+ else:
311
+ self._failed()
312
+
313
+ return False
314
+
315
+ def _updateId(self, new_id: int) -> None:
316
+ """Updates the subscription identifier.
317
+
318
+ Args:
319
+ new_id: New subscription identifier.
320
+ """
321
+ self._info.id = new_id
322
+
323
+ def _decode_frame(self, counter: int, value_bytes: bytes, timestamp: Timespec) -> None:
324
+ """Decode one parameter's value bytes and store the result.
325
+
326
+ Shared tail of both protocol paths — the only difference
327
+ between protocol 0 and protocol 1 is where ``timestamp`` and
328
+ ``value_bytes`` come from; from here on the work is identical.
329
+ """
330
+ param = self._info.params[counter]
331
+ value = self._decoder[counter].decode(
332
+ value_bytes, len(value_bytes) / param.info.data_size,
333
+ )
334
+ self._values[counter] = Parameter(timestamp, value)
335
+
336
+ def _notify_observers(self) -> None:
337
+ """Snapshot current values and fan out to observers via the pool."""
338
+ if not self._observer_list:
339
+ return
340
+ snapshot = self._values[:]
341
+ for observer in self._observer_list:
342
+ self._pool.submit(observer, snapshot)
343
+
344
+ def _updateProtocol0(self, sub_msg: bytes, length: int) -> None:
345
+ """Updates the subscription values using protocol version 0
346
+ (per-param 16-byte timestamp prepended to every payload).
347
+
348
+ Args:
349
+ sub_msg: Subscription message bytes.
350
+ length: Length of the subscription message.
351
+ """
352
+ if not sub_msg:
353
+ return
354
+ n_params = len(self._info.params)
355
+ for counter, param in enumerate(self._info.params):
356
+ offset = param.offset
357
+ size = param.size
358
+
359
+ # the last element in the group may have a variable size
360
+ if counter + 1 == n_params and (offset + size) > length:
361
+ size = length - offset
362
+
363
+ # Each frame is 16B timestamp + payload. Require both the
364
+ # full frame AND enough bytes for the timestamp itself —
365
+ # size can be trimmed to 0 by the last-param branch above,
366
+ # in which case there's nothing to unpack.
367
+ if size < 16 or (offset + size) > length:
368
+ continue
369
+
370
+ timestamp = Timespec.from_iterable(unpack_from('QQ', sub_msg, offset))
371
+ self._decode_frame(counter, sub_msg[offset + 16: offset + size], timestamp)
372
+
373
+ self._notify_observers()
374
+
375
+ def _updateProtocol1(self, sub_msg: bytes, length: int) -> None:
376
+ """Updates the subscription values using protocol version 1
377
+ (single leading 16-byte timestamp, shared by all params)."""
378
+ if not sub_msg or length < 16:
379
+ return
380
+ timestamp = Timespec.from_iterable(unpack_from('QQ', sub_msg, 0))
381
+ n_params = len(self._info.params)
382
+ for counter, param in enumerate(self._info.params):
383
+ offset = param.offset + 16
384
+ size = param.size
385
+
386
+ # the last element in the group may have a variable size
387
+ if counter + 1 == n_params and (offset + size) > length:
388
+ size = length - offset
389
+
390
+ if (offset + size) > length:
391
+ continue
392
+
393
+ self._decode_frame(counter, sub_msg[offset: offset + size], timestamp)
394
+
395
+ self._notify_observers()
396
+
397
+ def _failed(self) -> None:
398
+ self._future.set_result(None)
399
+
400
+ def droppedFrameCount(self) -> int:
401
+ """Number of wire frames dropped by the dispatcher for this group.
402
+
403
+ Incremented when a frame carries an unrecognised protocol version
404
+ or is otherwise undeliverable. Exposed for diagnostics — a
405
+ subscription that is silently missing updates will show a rising
406
+ count here while its callback stays quiet.
407
+ """
408
+ return self._dropped_frames
409
+
410
+ def _recordDroppedFrame(self) -> None:
411
+ """Called by the dispatcher when a frame for this subscription
412
+ can't be decoded. Keeps the count visible to ``droppedFrameCount()``.
413
+ """
414
+ self._dropped_frames += 1
@@ -0,0 +1,173 @@
1
+ class Timespec:
2
+ """
3
+ Represents a timestamp with seconds and nanoseconds.
4
+
5
+ Provides conversion properties for seconds, milliseconds, microseconds, and nanoseconds.
6
+ Prefer using this class for new code instead of standalone functions.
7
+ """
8
+
9
+ def __init__(self, sec: int, nsec: int):
10
+ self._sec = sec
11
+ self._nsec = nsec
12
+
13
+ def __eq__(self, other: object) -> bool:
14
+ """
15
+ Compare two Timespec objects for equality.
16
+
17
+ Args:
18
+ other (object): Another Timespec instance.
19
+
20
+ Returns:
21
+ bool: True if both timestamps are equal, False otherwise.
22
+ """
23
+ if not isinstance(other, Timespec):
24
+ return NotImplemented
25
+ return self._sec == other._sec and self._nsec == other._nsec
26
+
27
+ def __lt__(self, other: "Timespec") -> bool:
28
+ return (self._sec, self._nsec) < (other._sec, other._nsec)
29
+
30
+ def __le__(self, other: "Timespec") -> bool:
31
+ return (self._sec, self._nsec) <= (other._sec, other._nsec)
32
+
33
+ def __add__(self, other: "Timespec") -> "Timespec":
34
+ total_nsec = self._nsec + other._nsec
35
+ total_sec = self._sec + other._sec + total_nsec // 1_000_000_000
36
+ total_nsec = total_nsec % 1_000_000_000
37
+ return Timespec(total_sec, total_nsec)
38
+
39
+ def __str__(self) -> str:
40
+ return f"{self._sec}s {self._nsec}ns"
41
+
42
+ def __repr__(self) -> str:
43
+ return f"Timespec(sec={self._sec}, nsec={self._nsec})"
44
+
45
+ @classmethod
46
+ def from_iterable(cls, iterable):
47
+ """
48
+ Create a Timespec instance from an iterable containing seconds and nanoseconds.
49
+
50
+ Args:
51
+ iterable: An iterable with two elements: (sec, nsec).
52
+
53
+ Returns:
54
+ Timespec: A new Timespec instance.
55
+ """
56
+ sec, nsec = iterable
57
+ return cls(sec, nsec)
58
+
59
+ @staticmethod
60
+ def from_msec(msec: float) -> "Timespec":
61
+ """
62
+ Create a Timespec instance from milliseconds.
63
+
64
+ Args:
65
+ msec (float): Time in milliseconds.
66
+
67
+ Returns:
68
+ Timespec: A new Timespec instance.
69
+ """
70
+ sec = int(msec // 1000)
71
+ nsec = int((msec % 1000) * 1_000_000)
72
+ return Timespec(sec, nsec)
73
+
74
+ @property
75
+ def sec(self) -> int:
76
+ """
77
+ Returns the seconds component of the timestamp.
78
+ """
79
+ return self._sec
80
+
81
+ @property
82
+ def nsec(self) -> int:
83
+ """
84
+ Returns the nanoseconds component of the timestamp.
85
+ """
86
+ return self._nsec
87
+
88
+ def to_tuple(self) -> tuple[int, int]:
89
+ """
90
+ Returns the timestamp as a (sec, nsec) tuple.
91
+ """
92
+ return self._sec, self._nsec
93
+
94
+
95
+ def compare_timespec(timestamp1: Timespec, timestamp2: Timespec) -> bool:
96
+ """
97
+ Compare two timestamps.
98
+
99
+ Args:
100
+ timestamp1 (timespec): First timestamp to compare.
101
+ timestamp2 (timespec): Second timestamp to compare.
102
+
103
+ Returns:
104
+ bool: True if both timestamps are equal, False otherwise.
105
+
106
+ Note:
107
+ For new code, use Timespec.__eq__ instead.
108
+ """
109
+ return True if (timestamp1.sec == timestamp2.sec) and (timestamp1.nsec == timestamp2.nsec) else False
110
+
111
+
112
+ def timespec_to_sec(timestamp: Timespec) -> float:
113
+ """
114
+ Convert a timestamp to seconds.
115
+
116
+ Args:
117
+ timestamp (timespec): Timestamp to convert.
118
+
119
+ Returns:
120
+ float: Time in seconds.
121
+
122
+ Note:
123
+ For new code, use Timespec.sec_value instead.
124
+ """
125
+ return timestamp.sec + timestamp.nsec / 1000000000.0
126
+
127
+
128
+ def timespec_to_msec(timestamp: Timespec) -> float:
129
+ """
130
+ Convert a timestamp to milliseconds.
131
+
132
+ Args:
133
+ timestamp (timespec): Timestamp to convert.
134
+
135
+ Returns:
136
+ float: Time in milliseconds.
137
+
138
+ Note:
139
+ For new code, use Timespec.msec instead.
140
+ """
141
+ return timestamp.sec * 1000 + timestamp.nsec / 1000000
142
+
143
+
144
+ def timespec_to_usec(timestamp: Timespec) -> float:
145
+ """
146
+ Convert a timestamp to microseconds.
147
+
148
+ Args:
149
+ timestamp (timespec): Timestamp to convert.
150
+
151
+ Returns:
152
+ float: Time in microseconds.
153
+
154
+ Note:
155
+ For new code, use Timespec.usec instead.
156
+ """
157
+ return timestamp.sec * 1000000 + timestamp.nsec / 1000
158
+
159
+
160
+ def timespec_to_nsec(timestamp: Timespec) -> int:
161
+ """
162
+ Convert a timestamp to nanoseconds.
163
+
164
+ Args:
165
+ timestamp (timespec): Timestamp to convert.
166
+
167
+ Returns:
168
+ int: Time in nanoseconds.
169
+
170
+ Note:
171
+ For new code, use Timespec.nsec_value instead.
172
+ """
173
+ return timestamp.sec * 1000000000 + timestamp.nsec
motorcortex/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '1.0.0rc1'
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright 2018 Vectioneer B.V. <info@vectioneer.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this 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, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ "motorcortex" is a trademark of Vectioneer (www.vectioneer.com)