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/request.py ADDED
@@ -0,0 +1,668 @@
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.request
10
+
11
+ Provides the Request class for managing request connections to a Motorcortex server,
12
+ including login, parameter retrieval, parameter updates, and group management.
13
+ """
14
+
15
+ import atexit
16
+ from threading import Event, Timer
17
+ from concurrent.futures import ThreadPoolExecutor, Future
18
+ from typing import Any, Callable, List, Optional, Union
19
+ from pynng import Req0, TLSConfig # type: ignore[import-untyped]
20
+
21
+ from motorcortex.reply import Reply
22
+ from motorcortex.setup_logger import logger
23
+ from motorcortex.state_callback_handler import StateCallbackHandler
24
+ from motorcortex.parameter_tree import ParameterTree
25
+ from motorcortex.message_types import MessageTypes
26
+ from motorcortex.nng_url import NngUrl
27
+ from motorcortex.exceptions import McxConnectionError
28
+ from motorcortex import _connection_state, _request_builders, _request_utils, motorcortex_pb2 as _pb
29
+ # ConnectionState lives in _connection_state now — re-exported here so
30
+ # ``from motorcortex.request import ConnectionState`` keeps working.
31
+ from motorcortex._connection_state import ConnectionState
32
+
33
+
34
+ def _close_at_exit(inst: "Request") -> None:
35
+ """Shutdown hook: close a ``Request`` the user forgot to clean up.
36
+
37
+ Registered via :func:`_register_shutdown` at construction. See
38
+ motorcortex.subscribe for the rationale on using
39
+ ``threading._register_atexit`` instead of plain ``atexit`` —
40
+ ``concurrent.futures._python_exit`` runs there and would join the
41
+ pool workers before any regular atexit handler could close the
42
+ socket.
43
+ """
44
+ try:
45
+ if getattr(inst, "_closed", True):
46
+ return
47
+ inst.close()
48
+ except Exception:
49
+ pass
50
+
51
+
52
+ def _register_shutdown(inst: "Request") -> None:
53
+ """Register ``_close_at_exit(inst)`` on the ``threading`` atexit
54
+ registry so it fires before ``concurrent.futures._python_exit``.
55
+
56
+ Falls back to regular ``atexit`` if the private threading API is
57
+ absent.
58
+ """
59
+ try:
60
+ import threading
61
+ threading._register_atexit(_close_at_exit, inst) # type: ignore[attr-defined]
62
+ except (AttributeError, ImportError):
63
+ atexit.register(_close_at_exit, inst)
64
+
65
+
66
+ class Request:
67
+ """
68
+ Represents a request connection to a Motorcortex server.
69
+
70
+ The Request class allows you to:
71
+ - Establish and manage a connection to a Motorcortex server.
72
+ - Perform login authentication.
73
+ - Retrieve, set, and overwrite parameter values.
74
+ - Manage parameter groups for efficient batch operations.
75
+ - Save and load parameter trees.
76
+ - Chain asynchronous operations using a promise-like interface (`Reply`).
77
+
78
+ Methods:
79
+ url() -> Optional[str]
80
+ Returns the current connection URL.
81
+
82
+ connect(url: str, **kwargs) -> Reply
83
+ Establishes a connection to the server.
84
+
85
+ close() -> None
86
+ Closes the connection and cleans up resources.
87
+
88
+ send(encoded_msg: Any, do_not_decode_reply: bool = False) -> Optional[Reply]
89
+ Sends an encoded message to the server.
90
+
91
+ login(login: str, password: str) -> Reply
92
+ Sends a login request.
93
+
94
+ connectionState() -> ConnectionState
95
+ Returns the current connection state.
96
+
97
+ getParameterTreeHash() -> Reply
98
+ Requests the parameter tree hash from the server.
99
+
100
+ getParameterTree() -> Reply
101
+ Requests the parameter tree from the server.
102
+
103
+ save(path: str, file_name: str) -> Reply
104
+ Requests the server to save the parameter tree to a file.
105
+
106
+ setParameter(path: str, value: Any, type_name: Optional[str] = None, offset: int = 0, length: int = 0) -> Reply
107
+ Sets a new value for a parameter.
108
+
109
+ setParameterList(param_list: List[dict]) -> Reply
110
+ Sets new values for a list of parameters.
111
+
112
+ getParameter(path: str) -> Reply
113
+ Requests a parameter value and description.
114
+
115
+ getParameterList(path_list: List[str]) -> Reply
116
+ Requests values and descriptions for a list of parameters.
117
+
118
+ overwriteParameter(path: str, value: Any, force_activate: bool = False, type_name: Optional[str] = None) -> Reply
119
+ Overwrites a parameter value and optionally forces it to stay active.
120
+
121
+ releaseParameter(path: str) -> Reply
122
+ Releases the overwrite operation for a parameter.
123
+
124
+ createGroup(path_list: List[str], group_alias: str, frq_divider: int = 1) -> Reply
125
+ Creates a subscription group for a list of parameters.
126
+
127
+ removeGroup(group_alias: str) -> Reply
128
+ Unsubscribes from a group.
129
+
130
+ Examples:
131
+ >>> # Establish a connection
132
+ >>> req = motorcortex.Request(protobuf_types, parameter_tree)
133
+ >>> reply = req.connect("tls+tcp://localhost:6501", certificate="path/to/ca.crt")
134
+ >>> if reply.get():
135
+ ... print("Connected!")
136
+ >>> # Login
137
+ >>> login_reply = req.login("user", "password")
138
+ >>> if login_reply.get().status == motorcortex.OK:
139
+ ... print("Login successful")
140
+ >>> # Get a parameter
141
+ >>> param_reply = req.getParameter("MyDevice.MyParam")
142
+ >>> param = param_reply.get()
143
+ >>> print("Value:", param.value)
144
+ >>> # Set a parameter
145
+ >>> req.setParameter("MyDevice.MyParam", 42)
146
+ >>> # Clean up
147
+ >>> req.close()
148
+ """
149
+
150
+ def __init__(self, protobuf_types: "MessageTypes", parameter_tree: "ParameterTree", number_of_threads: int = 2) -> None:
151
+ """
152
+ Initialize a Request object.
153
+
154
+ Args:
155
+ protobuf_types: Motorcortex message types module.
156
+ parameter_tree: ParameterTree instance.
157
+ number_of_threads (int): Thread pool size (minimum 1, None - use default (CPU-based)).
158
+ """
159
+ self._socket: Optional[Req0] = None
160
+ self._url: Optional[str] = None
161
+ self._connected_event: Optional[Event] = None
162
+ self._connected: bool = False
163
+ self._protobuf_types: "MessageTypes" = protobuf_types
164
+ self._parameter_tree: "ParameterTree" = parameter_tree
165
+ self._connection_state: ConnectionState = ConnectionState.DISCONNECTED
166
+ self._pool: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=number_of_threads,
167
+ thread_name_prefix="mcx_req")
168
+ self._callback_handler: StateCallbackHandler = StateCallbackHandler()
169
+ self._token: Optional[str] = None
170
+ self._token_timer: Optional[Timer] = None
171
+ self._token_update_interval_sec: float = 30.0
172
+ # Net safeguard: if the caller forgets close(), the receive-loop
173
+ # worker stays blocked in recv() and Python's atexit
174
+ # ``_python_exit`` hook (concurrent.futures) joins it forever,
175
+ # hanging the interpreter. Register a weakref-based cleanup that
176
+ # fires ahead of that hook. Explicit close() makes this a no-op
177
+ # (``__closed`` flag is set and re-entry is guarded).
178
+ self._closed: bool = False
179
+ _register_shutdown(self)
180
+
181
+ def url(self) -> Optional[str]:
182
+ """Return the current connection URL."""
183
+ return self._url
184
+
185
+ def __repr__(self) -> str:
186
+ return (
187
+ f"Request(url={self._url!r}, "
188
+ f"state={self._connection_state.name})"
189
+ )
190
+
191
+ @property
192
+ def motorcortex_types(self) -> "MessageTypes":
193
+ """The ``MessageTypes`` instance passed at construction.
194
+
195
+ Exposed so callers (tests, higher-level helpers) can reach the
196
+ bundled protobuf module without constructing a second
197
+ ``MessageTypes`` — e.g. ``req.motorcortex_types.motorcortex().OK``.
198
+ """
199
+ return self._protobuf_types
200
+
201
+ # -- NNG pipe callbacks -------------------------------------------
202
+ #
203
+ # These used to be nested closures inside ``connect()`` that
204
+ # captured ``self`` plus the local ``connected_event`` / ``connected``
205
+ # vars. Promoting them to methods makes their behavior unit-testable
206
+ # by direct invocation (no live pipe event needed) and shrinks
207
+ # ``connect()`` accordingly. The NNG library passes a ``pipe`` arg
208
+ # we don't inspect — prefixed with ``_``.
209
+
210
+ def _on_pipe_connect(self, _pipe: Any) -> None:
211
+ """Socket accepted a peer pipe. Transition to ``CONNECTION_OK``
212
+ and wake anyone blocked in :func:`_request_utils.wait_for_connection`.
213
+ """
214
+ old_state = self._connection_state.name
215
+ self._connected = True
216
+ self._connection_state = ConnectionState.CONNECTION_OK
217
+ logger.debug(
218
+ "[REQUEST-CALLBACK] PRE_CONNECT fired — %s -> %s",
219
+ old_state, self._connection_state.name,
220
+ )
221
+ self._callback_handler.notify(self, self.connectionState())
222
+ if self._connected_event is not None:
223
+ self._connected_event.set()
224
+
225
+ def _on_pipe_remove(self, _pipe: Any) -> None:
226
+ """Socket pipe torn down. Pick the right terminal state via
227
+ :func:`_connection_state.next_state_after_pipe_remove`, then
228
+ wake waiters.
229
+ """
230
+ old_state = self._connection_state.name
231
+ self._connection_state = _connection_state.next_state_after_pipe_remove(
232
+ self._connection_state
233
+ )
234
+ logger.debug(
235
+ "[REQUEST-CALLBACK] POST_REMOVE fired — %s -> %s",
236
+ old_state, self._connection_state.name,
237
+ )
238
+ self._connected = False
239
+ self._callback_handler.notify(self, self.connectionState())
240
+ if self._connected_event is not None:
241
+ self._connected_event.set()
242
+
243
+ def connect(self, url: str, **kwargs: Any) -> "Reply[bool]":
244
+ """
245
+ Establish a connection to the Motorcortex server.
246
+
247
+ Args:
248
+ url: Connection URL.
249
+ **kwargs: Additional connection parameters. Supported keys include:
250
+ certificate (str, optional): Path to a TLS certificate file for secure connections.
251
+ conn_timeout_ms (int, optional): Connection timeout in milliseconds (default: 1000).
252
+ recv_timeout_ms (int, optional): Receive timeout in milliseconds (default: 500).
253
+ login (str, optional): Username for authentication (if required by server).
254
+ password (str, optional): Password for authentication (if required by server).
255
+ state_update (Callable, optional): Callback function to be called on connection state changes.
256
+ timeout_ms (int, optional): Alternative timeout in milliseconds (used by Request.parse).
257
+
258
+ Returns:
259
+ Reply: A promise that resolves when the connection is established.
260
+ """
261
+ self._connection_state = ConnectionState.CONNECTING
262
+ conn_timeout_ms, recv_timeout_ms, certificate, state_update = _request_utils.parse_connect_kwargs(**kwargs)
263
+
264
+ if state_update:
265
+ self._callback_handler.start(state_update)
266
+
267
+ self._url = url
268
+ tls_config = None
269
+ if certificate:
270
+ parsed = NngUrl(url)
271
+ # auth_mode=OPTIONAL: motorcortex deployments ship a self-signed
272
+ # end-entity cert that clients pin directly. Modern mbedTLS (3.6+)
273
+ # refuses to use a non-CA cert as a trust anchor under the
274
+ # REQUIRED mode pynng defaults to, producing a cryptic
275
+ # "Cryptographic error" on handshake. OPTIONAL performs the
276
+ # verification best-effort (still catches the obvious
277
+ # wrong-server case) without aborting on the CA:FALSE check.
278
+ tls_config = TLSConfig(
279
+ TLSConfig.MODE_CLIENT,
280
+ ca_files=certificate,
281
+ server_name=parsed.hostname,
282
+ auth_mode=TLSConfig.AUTH_MODE_OPTIONAL,
283
+ )
284
+
285
+ self._socket = Req0(recv_timeout=recv_timeout_ms, tls_config=tls_config)
286
+ self._connected_event = Event()
287
+ self._connected = False
288
+
289
+ self._socket.add_pre_pipe_connect_cb(self._on_pipe_connect)
290
+ self._socket.add_post_pipe_remove_cb(self._on_pipe_remove)
291
+
292
+ logger.debug(
293
+ "[REQUEST] dialing %s (timeout=%dms, tls=%s)",
294
+ url, conn_timeout_ms, bool(tls_config),
295
+ )
296
+ self._socket.dial(url, block=False)
297
+
298
+ return Reply(self._pool.submit(_request_utils.wait_for_connection, self._connected_event,
299
+ conn_timeout_ms / 1000.0, lambda: self._connected))
300
+
301
+ def close(self) -> None:
302
+ """
303
+ Close the request connection and clean up resources.
304
+ """
305
+ if self._closed:
306
+ return
307
+ self._closed = True
308
+ logger.debug("[REQUEST] closing (state=%s)", self._connection_state.name)
309
+
310
+ self._connection_state = ConnectionState.DISCONNECTING
311
+ self._stopTokenRefresh()
312
+ if self._connected_event:
313
+ self._connected = False
314
+ self._connected_event.set()
315
+
316
+ if self._socket:
317
+ self._socket.close()
318
+
319
+ self._callback_handler.stop()
320
+ self._pool.shutdown(wait=True)
321
+
322
+ def send(self, encoded_msg: Any, do_not_decode_reply: bool = False) -> "Reply[Any]":
323
+ """Send an encoded message to the server.
324
+
325
+ Args:
326
+ encoded_msg: Encoded protobuf message.
327
+ do_not_decode_reply: If True, do not decode the reply.
328
+
329
+ Returns:
330
+ Reply: A promise for the server's reply.
331
+
332
+ Raises:
333
+ McxConnectionError: the underlying socket is closed
334
+ or was never opened. Protocol-level failures (bad
335
+ path, permission denied, etc.) do **not** raise —
336
+ they arrive on ``reply.get().status``. Transport
337
+ failures do.
338
+ """
339
+ if self._socket is None:
340
+ raise McxConnectionError(
341
+ "Cannot send: Request is not connected (socket is None). "
342
+ "Call Request.connect(...) first."
343
+ )
344
+ return Reply(self._pool.submit(
345
+ _request_utils.send_and_recv, self._socket, encoded_msg,
346
+ None if do_not_decode_reply else self._protobuf_types,
347
+ ))
348
+
349
+ def login(self, login: str, password: str) -> "Reply[_pb.StatusMsg]":
350
+ """
351
+ Send a login request to the server.
352
+
353
+ Args:
354
+ login: User login.
355
+ password: User password.
356
+
357
+ Returns:
358
+ Reply: A promise for the login reply.
359
+ """
360
+
361
+ login_msg = self._protobuf_types.createType('motorcortex.LoginMsg')
362
+ login_msg.password = password
363
+ login_msg.login = login
364
+
365
+ return self.send(self._protobuf_types.encode(login_msg))
366
+
367
+ def connectionState(self) -> ConnectionState:
368
+ """
369
+ Get the current connection state.
370
+
371
+ Returns:
372
+ ConnectionState: The current state.
373
+ """
374
+ return self._connection_state
375
+
376
+ def getParameterTreeHash(self) -> "Reply[_pb.ParameterTreeHashMsg]":
377
+ """
378
+ Request a parameter tree hash from the server.
379
+
380
+ Returns:
381
+ Reply: A promise for the parameter tree hash.
382
+ """
383
+
384
+ # getting and instantiating data type from the loaded dict
385
+ param_tree_hash_msg = self._protobuf_types.createType('motorcortex.GetParameterTreeHashMsg')
386
+
387
+ # encoding and sending data
388
+ return self.send(self._protobuf_types.encode(param_tree_hash_msg))
389
+
390
+ def getParameterTree(self) -> "Reply[_pb.ParameterTreeMsg]":
391
+ """
392
+ Request a parameter tree from the server.
393
+
394
+ Returns:
395
+ Reply: A promise for the parameter tree.
396
+ """
397
+
398
+ return Reply(self._pool.submit(
399
+ self._getParameterTree,
400
+ self.getParameterTreeHash(), self._protobuf_types, self._socket, self._url,
401
+ ))
402
+
403
+ def save(self, path: str, file_name: str) -> "Reply[_pb.StatusMsg]":
404
+ """
405
+ Request the server to save a parameter tree to a file.
406
+
407
+ Args:
408
+ path: Path to save the file.
409
+ file_name: Name of the file.
410
+
411
+ Returns:
412
+ Reply: A promise for the save operation.
413
+ """
414
+
415
+ param_save_msg = self._protobuf_types.createType('motorcortex.SaveMsg')
416
+ param_save_msg.path = path
417
+ param_save_msg.file_name = file_name
418
+
419
+ return self.send(self._protobuf_types.encode(param_save_msg))
420
+
421
+ def setParameter(self, path: str, value: Any, type_name: Optional[str] = None, offset: int = 0,
422
+ length: int = 0) -> "Reply[_pb.StatusMsg]":
423
+ """
424
+ Set a new value for a parameter.
425
+
426
+ Args:
427
+ path: Parameter path.
428
+ value: New value.
429
+ type_name: Type name (optional).
430
+ offset: Offset in array (optional).
431
+ length: Number of elements to update (optional).
432
+
433
+ Returns:
434
+ Reply: A promise for the set operation.
435
+ """
436
+
437
+ if (offset == 0) and (length == 0):
438
+ return self.send(self._protobuf_types.encode(_request_builders.build_set_parameter_msg(
439
+ path, value, type_name, self._protobuf_types, self._parameter_tree)))
440
+ else:
441
+ return self.send(self._protobuf_types.encode(_request_builders.build_set_parameter_with_offset_msg(
442
+ offset, length, path, value, type_name, self._protobuf_types, self._parameter_tree)))
443
+
444
+ def setParameterList(self, param_list: List[dict]) -> "Reply[_pb.StatusMsg]":
445
+ """
446
+ Set new values to a parameter list
447
+
448
+ Args:
449
+ param_list([{'path'-`str`,'value'-`any`, 'offset', 'length'}]): a list of the parameters which values update
450
+
451
+ Returns:
452
+ Reply(StatusMsg): A Promise, which resolves when parameters from the list are updated,
453
+ otherwise fails.
454
+
455
+ Examples:
456
+ >>> req.setParameterList([
457
+ >>> {'path': 'root/Control/generator/enable', 'value': False},
458
+ >>> {'path': 'root/Control/generator/amplitude', 'value': 1.4}])
459
+ >>> {'path': 'root/Control/myArray6', 'value': [1.4, 1.5], 'offset': 1, 'length': 2}])
460
+ """
461
+
462
+ # instantiating message type
463
+ set_param_list_msg = self._protobuf_types.createType("motorcortex.SetParameterListMsg")
464
+ # filling with sub messages
465
+ for param in param_list:
466
+ type_name = param.get("type_name", None)
467
+ offset = param.get("offset", 0)
468
+ length = param.get("length", 0)
469
+ if (offset == 0) and (length == 0):
470
+ set_param_list_msg.params.extend([_request_builders.build_set_parameter_msg(
471
+ param["path"], param["value"], type_name, self._protobuf_types, self._parameter_tree)])
472
+ else:
473
+ set_param_list_msg.params.extend([_request_builders.build_set_parameter_with_offset_msg(
474
+ offset, length, param["path"], param["value"],
475
+ type_name, self._protobuf_types, self._parameter_tree)])
476
+
477
+ # encoding and sending data
478
+ return self.send(self._protobuf_types.encode(set_param_list_msg))
479
+
480
+ def getParameter(self, path: str) -> "Reply[_pb.ParameterMsg]":
481
+ """
482
+ Request a parameter value and description from the server.
483
+
484
+ Args:
485
+ path: Parameter path.
486
+
487
+ Returns:
488
+ Reply: A promise for the parameter value.
489
+ """
490
+
491
+ return self.send(self._protobuf_types.encode(
492
+ _request_builders.build_get_parameter_msg(path, self._protobuf_types)))
493
+
494
+ def getParameterList(self, path_list: List[str]) -> "Reply[_pb.ParameterListMsg]":
495
+ """
496
+ Request values and descriptions for a list of parameters.
497
+
498
+ Args:
499
+ path_list: List of parameter paths.
500
+
501
+ Returns:
502
+ Reply: A promise for the parameter list.
503
+ """
504
+
505
+ # instantiating message type
506
+ get_param_list_msg = self._protobuf_types.createType('motorcortex.GetParameterListMsg')
507
+ # filling with sub messages
508
+ for path in path_list:
509
+ get_param_list_msg.params.extend([
510
+ _request_builders.build_get_parameter_msg(path, self._protobuf_types)])
511
+
512
+ # encoding and sending data
513
+ return self.send(self._protobuf_types.encode(get_param_list_msg))
514
+
515
+ def overwriteParameter(self, path: str, value: Any, force_activate: bool = False,
516
+ type_name: Optional[str] = None) -> "Reply[_pb.StatusMsg]":
517
+ """
518
+ Overwrite a parameter value and optionally force it to stay active.
519
+
520
+ Args:
521
+ path: Parameter path.
522
+ value: New value.
523
+ force_activate: Force value to stay active.
524
+ type_name: Type name (optional).
525
+
526
+ Returns:
527
+ Reply: A promise for the overwrite operation.
528
+ """
529
+
530
+ return self.send(self._protobuf_types.encode(_request_builders.build_overwrite_parameter_msg(
531
+ path, value, force_activate, type_name, self._protobuf_types, self._parameter_tree)))
532
+
533
+ def releaseParameter(self, path: str) -> "Reply[_pb.StatusMsg]":
534
+ """
535
+ Release the overwrite operation for a parameter.
536
+
537
+ Args:
538
+ path: Parameter path.
539
+
540
+ Returns:
541
+ Reply: A promise for the release operation.
542
+ """
543
+
544
+ return self.send(self._protobuf_types.encode(
545
+ _request_builders.build_release_parameter_msg(path, self._protobuf_types)))
546
+
547
+ def createGroup(self, path_list: List[str], group_alias: str, frq_divider: int = 1) -> "Reply[_pb.GroupStatusMsg]":
548
+ """
549
+ Create a subscription group for a list of parameters.
550
+
551
+ Args:
552
+ path_list: List of parameter paths.
553
+ group_alias: Group alias.
554
+ frq_divider: Frequency divider.
555
+
556
+ Returns:
557
+ Reply: A promise for the group creation.
558
+ """
559
+
560
+ # instantiating message type
561
+ create_group_msg = self._protobuf_types.createType('motorcortex.CreateGroupMsg')
562
+ create_group_msg.alias = group_alias
563
+ create_group_msg.paths.extend(path_list if type(path_list) is list else [path_list])
564
+ create_group_msg.frq_divider = frq_divider if frq_divider > 1 else 1
565
+ # encoding and sending data
566
+ return self.send(self._protobuf_types.encode(create_group_msg))
567
+
568
+ def removeGroup(self, group_alias: str) -> "Reply[_pb.StatusMsg]":
569
+ """
570
+ Unsubscribe from a group.
571
+
572
+ Args:
573
+ group_alias: Group alias.
574
+
575
+ Returns:
576
+ Reply: A promise for the unsubscribe operation.
577
+ """
578
+
579
+ # instantiating message type
580
+ remove_group_msg = self._protobuf_types.createType('motorcortex.RemoveGroupMsg')
581
+ remove_group_msg.alias = group_alias
582
+ # encoding and sending data
583
+ return self.send(self._protobuf_types.encode(remove_group_msg))
584
+
585
+ @property
586
+ def token(self) -> Optional[str]:
587
+ """Return the current session token."""
588
+ return self._token
589
+
590
+ def getSessionToken(self) -> "Reply[_pb.SessionTokenMsg]":
591
+ """Request a session token from the server."""
592
+ msg = self._protobuf_types.createType('motorcortex.GetSessionTokenMsg')
593
+ return self.send(self._protobuf_types.encode(msg))
594
+
595
+ def restoreSession(self, token: str) -> "Reply[_pb.SessionTokenMsg]":
596
+ """Restore a session using a saved token."""
597
+ msg = self._protobuf_types.createType('motorcortex.RestoreSessionMsg')
598
+ msg.token = token
599
+ return self.send(self._protobuf_types.encode(msg))
600
+
601
+ def _startTokenRefresh(self, interval_sec: float = 30.0) -> None:
602
+ """Start periodic session token refresh.
603
+
604
+ Args:
605
+ interval_sec: Interval between token refreshes in seconds.
606
+ """
607
+ self._token_update_interval_sec = interval_sec
608
+ self._stopTokenRefresh()
609
+ # Fetch token immediately, then schedule periodic refresh
610
+ self._fetchToken()
611
+ self._scheduleTokenRefresh()
612
+
613
+ def _stopTokenRefresh(self) -> None:
614
+ """Stop periodic session token refresh."""
615
+ if self._token_timer:
616
+ self._token_timer.cancel()
617
+ self._token_timer = None
618
+
619
+ def _scheduleTokenRefresh(self) -> None:
620
+ """Schedule the next token refresh."""
621
+ self._stopTokenRefresh()
622
+ self._token_timer = Timer(self._token_update_interval_sec, self._onTokenTimer)
623
+ self._token_timer.daemon = True
624
+ self._token_timer.start()
625
+
626
+ def _onTokenTimer(self) -> None:
627
+ """Timer callback: fetch token and reschedule."""
628
+ self._fetchToken()
629
+ # Always reschedule if not disconnecting/disconnected
630
+ if self._connection_state not in (ConnectionState.DISCONNECTING, ConnectionState.DISCONNECTED):
631
+ self._scheduleTokenRefresh()
632
+
633
+ def _fetchToken(self) -> None:
634
+ """Fetch a session token from the server."""
635
+ if self._connection_state != ConnectionState.CONNECTION_OK:
636
+ return
637
+ try:
638
+ reply = self.getSessionToken()
639
+ if reply:
640
+ msg = reply.get(timeout_ms=5000)
641
+ if msg and hasattr(msg, 'token'):
642
+ self._token = msg.token
643
+ except Exception as e:
644
+ logger.debug("[REQUEST] token refresh failed: %s", e)
645
+
646
+ # Pure protobuf message builders live in ``motorcortex._request_builders``.
647
+ # They used to be private ``__buildXxxMsg`` static methods on this class;
648
+ # moving them out lets them be unit-tested without spinning up a socket.
649
+
650
+ @staticmethod
651
+ def _getParameterTree(
652
+ hash_reply: Reply,
653
+ protobuf_types: MessageTypes,
654
+ socket: Req0,
655
+ url: Optional[str] = None,
656
+ ) -> Any:
657
+ """Thunk that unwraps ``hash_reply`` and delegates to
658
+ :func:`motorcortex._request_utils.fetch_parameter_tree`.
659
+
660
+ Runs inside the request-pool worker (see ``getParameterTree``),
661
+ which is why it takes a pre-queued :class:`Reply` — we can't do
662
+ the ``.get()`` on the caller thread or we'd block the pool-submit.
663
+ """
664
+ tree_hash = hash_reply.get()
665
+ return _request_utils.fetch_parameter_tree(
666
+ tree_hash.hash, protobuf_types, socket, url,
667
+ )
668
+