griip-core-dev 0.5.6.dev25546__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,1257 @@
1
+ """Thin gRPC transport for VRMCS, backported from the VRMCS-py-client repo.
2
+
3
+ Vendored verbatim from ``vrmcs-client/vrmcs/client.py`` at VRMCS-py-client
4
+ fa1ff32 (the 1.5.0 release). It used to reach us inside the
5
+ ``vention-firmware-grpc-client`` wheel; the wheel it is being replaced by,
6
+ ``vention-grpc-py-sdk``, ships only the generated protobuf/gRPC stubs and no
7
+ hand-written client, so this had to come across with it.
8
+
9
+ It lives in griip-core rather than griip-sdk because four of its five callers
10
+ -- griip-sdk, vention-perception, mmai-griip-api and (through griip-sdk)
11
+ griip-teleop -- already depend on griip-core, and because griip-core is where
12
+ the rest of the shared VRMCS plumbing lives (see ``vrmcs_registration``).
13
+ Putting it in griip-sdk would make perception and mmai-griip-api depend on the
14
+ whole robot SDK -- toppra, trimesh, urchin, pymodbus, pyserial -- to reach a
15
+ gRPC transport.
16
+
17
+ Keep it in step with upstream by re-copying rather than editing in place: local
18
+ changes here will be lost the next time it is refreshed, and belong upstream.
19
+ """
20
+
21
+ import logging
22
+ import threading
23
+ import warnings
24
+ from typing import Callable, Optional
25
+
26
+ import grpc
27
+ from google.protobuf import empty_pb2
28
+ from vention.robots.v1 import robot_service_pb2_grpc
29
+ from vention.robots.v1.move_interpolation_request_pb2 import (
30
+ MoveInterpolationRequest,
31
+ MoveInterpolationResponse,
32
+ )
33
+ from vention.robots.v1.move_request_pb2 import (
34
+ MoveRequest,
35
+ MoveResponse,
36
+ MoveResultCode,
37
+ WaitForMoveCompletionRequest,
38
+ WaitForMoveCompletionResponse,
39
+ )
40
+ from vention.robots.v1.move_until_pb2 import (
41
+ MoveUntilRequest,
42
+ MoveUntilResponse,
43
+ MoveUntilResultCode,
44
+ )
45
+ from vention.robots.v1.plan_request_pb2 import (
46
+ PlanRequest,
47
+ PlanResponse,
48
+ PlanResultCode,
49
+ )
50
+ from vention.robots.v1.robot_alarm_pb2 import (
51
+ RobotAlarm,
52
+ RobotAlarmStreamRequest,
53
+ )
54
+ from vention.robots.v1.robot_configuration_pb2 import (
55
+ GetTcpRequest,
56
+ GetTcpResponse,
57
+ SetPayloadRequest,
58
+ SetPayloadResponse,
59
+ SetPayloadResultCode,
60
+ SetTcpRequest,
61
+ SetTcpResponse,
62
+ SetTcpResultCode,
63
+ )
64
+ from vention.robots.v1.robot_feedback_pb2 import (
65
+ Feedback,
66
+ FeedbackStreamRequest,
67
+ GetFeedbackRequest,
68
+ GetFeedbackResponse,
69
+ )
70
+ from vention.robots.v1.robot_kinematics_pb2 import (
71
+ ForwardKinematicsRequest,
72
+ ForwardKinematicsResponse,
73
+ InverseKinematicsBatchRequest,
74
+ InverseKinematicsBatchResponse,
75
+ InverseKinematicsRequest,
76
+ InverseKinematicsResponse,
77
+ KinResultCode,
78
+ )
79
+ from vention.robots.v1.robot_management_pb2 import (
80
+ ListRobotsResponse,
81
+ RegisterRobotRequest,
82
+ RegisterRobotResponse,
83
+ RobotInfo,
84
+ UnregisterRobotResponse,
85
+ )
86
+ from vention.robots.v1.robot_requests_pb2 import (
87
+ GetRobotInfoRequest,
88
+ JointStatesStreamRequest,
89
+ PositionStreamRequest,
90
+ StopRequest,
91
+ UnregisterRobotRequest,
92
+ )
93
+ from vention.robots.v1.robot_state_pb2 import (
94
+ CommandState,
95
+ CommandStateStreamRequest,
96
+ GetCommandStateRequest,
97
+ GetCommandStateResponse,
98
+ GetJointStatesRequest,
99
+ GetJointStatesResponse,
100
+ GetPositionStateRequest,
101
+ GetPositionStateResponse,
102
+ GetRobotStatesRequest,
103
+ GetRobotStatesResponse,
104
+ JointStates,
105
+ PositionState,
106
+ RobotStates,
107
+ RobotStatesStreamRequest,
108
+ SetToFreedriveRequest,
109
+ SetToFreedriveResponse,
110
+ SetToNormalRequest,
111
+ SetToNormalResponse,
112
+ )
113
+ from vention.robots.v1.stop_request_pb2 import (
114
+ StopResponse,
115
+ StopResultCode,
116
+ )
117
+
118
+ logger = logging.getLogger("vrmcs-client")
119
+ logger.addHandler(logging.NullHandler())
120
+
121
+
122
+ class VRMCSClient:
123
+ """
124
+ A client for interacting with the Vention Robot Motion Control System (VRMCS).
125
+
126
+ .. deprecated::
127
+ This client is deprecated. Use the gRPC stubs from
128
+ ``vention.robots.v1.robot_service_pb2_grpc.RobotServiceStub`` directly
129
+ with types from ``vention.robots.v1.*_pb2``. This client will be removed in a future version (mmai v3.6.0 release).
130
+
131
+ The client communicates with the VRMCS server using gRPC and supports operations
132
+ such as:
133
+ - Robot registration and management
134
+ - Movement execution and planning
135
+ - Operating mode control (normal/freedrive)
136
+ - Interpolated trajectory execution
137
+ """
138
+
139
+ def __init__(self, channel_url: str) -> None:
140
+ """
141
+ Initialize the VRMCS client with a gRPC channel.
142
+
143
+ Args:
144
+ channel_url (str): The URL of the VRMCS server (e.g., "localhost:50051").
145
+ This should include the host and port where the VRMCS
146
+ gRPC server is listening.
147
+ """
148
+ warnings.warn(
149
+ "VRMCSClient is deprecated. Use the gRPC stubs from "
150
+ "vention.robots.v1.robot_service_pb2_grpc.RobotServiceStub directly "
151
+ "with types from vention.robots.v1.*_pb2.",
152
+ DeprecationWarning,
153
+ stacklevel=2,
154
+ )
155
+ self.channel = grpc.insecure_channel(channel_url)
156
+ self.stub = robot_service_pb2_grpc.RobotServiceStub(self.channel)
157
+
158
+ self.joint_stream_thread: Optional[threading.Thread] = None
159
+
160
+ self.position_stream_thread: Optional[threading.Thread] = None
161
+
162
+ self.states_stream_thread: Optional[threading.Thread] = None
163
+
164
+ self.command_state_stream_thread: Optional[threading.Thread] = None
165
+
166
+ self.feedback_stream_thread: Optional[threading.Thread] = None
167
+
168
+ self.robot_alarm_stream_thread: Optional[threading.Thread] = None
169
+
170
+ def register_robot(self, register_robot_req: RegisterRobotRequest) -> RegisterRobotResponse:
171
+ """
172
+ Register a robot instance with the VRMCS server.
173
+
174
+ This method registers a new robot with the system, making it available
175
+ for subsequent control operations. The robot must be properly configured
176
+ before registration.
177
+
178
+ Args:
179
+ register_robot_req (RegisterRobotRequest): The robot registration request
180
+ containing robot instance details, configuration, and other metadata
181
+ required for registration.
182
+
183
+ Returns:
184
+ RegisterRobotResponse: Response containing registration status and any
185
+ error information if registration failed.
186
+
187
+ Raises:
188
+ grpc.RpcError: If there's a communication error with the VRMCS server.
189
+ The error is caught and converted to a failed response.
190
+
191
+ Note:
192
+ Check the response.success field to determine if registration was successful.
193
+ """
194
+ try:
195
+ register_robot_res: RegisterRobotResponse = self.stub.RegisterRobot(register_robot_req)
196
+ if register_robot_res.success:
197
+ logger.info(
198
+ f"Robot '{register_robot_req.robot_instance.name}' "
199
+ + f"with ID '{register_robot_req.robot_instance.id}' registered successfully"
200
+ )
201
+ else:
202
+ logger.error(
203
+ f"Failed to register robot '{register_robot_req.robot_instance.name}' "
204
+ + f"with ID '{register_robot_req.robot_instance.id}': {register_robot_res.error_message}"
205
+ )
206
+ except grpc.RpcError as e:
207
+ logger.error(f"gRPC error during RegisterRobot: {e.code()} - {e.details()}")
208
+ register_robot_res = RegisterRobotResponse(success=False, error_message=str(e))
209
+ return register_robot_res
210
+
211
+ def stop(self, stop_req: StopRequest) -> StopResponse:
212
+ """
213
+ Sends a stop command to the robot.
214
+ This method instructs the robot to stop its current motion.
215
+
216
+ Args:
217
+ stop_req (StopRequest): The stop request containing the robot ID.
218
+ Returns:
219
+ StopResponse: The response from the server containing the result of the stop operation.
220
+ This includes a result code indicating success or failure.
221
+ Raises:
222
+ May raise gRPC exceptions if communication with the server fails.
223
+ """
224
+ stop_res: StopResponse = self.stub.Stop(stop_req)
225
+ if stop_res.result_code == StopResultCode.STOP_RESULT_CODE_SUCCESS:
226
+ logger.info(f"Stop command executed successfully for robot with ID '{stop_req.id}'")
227
+ else:
228
+ logger.error(
229
+ f"Stop command failed for robot with ID '{stop_req.id}' with result code {StopResultCode.Name(stop_res.result_code)}"
230
+ )
231
+ return stop_res
232
+
233
+ def set_to_normal(self, set_to_normal_req: SetToNormalRequest) -> SetToNormalResponse:
234
+ """
235
+ Set a robot to normal operation state.
236
+
237
+ This method transitions a robot from any special operating mode (such as
238
+ freedrive or error state) back to normal operation mode, allowing it to
239
+ execute movement commands and other standard operations.
240
+
241
+ Args:
242
+ set_to_normal_req (SetToNormalRequest): Request containing the robot ID
243
+ and any additional parameters needed to set the robot to normal state.
244
+
245
+ Returns:
246
+ SetToNormalResponse: Response indicating whether the state change was
247
+ successful and any error information if it failed.
248
+
249
+ Note:
250
+ The robot must be properly initialized and connected to transition
251
+ to normal state successfully.
252
+ """
253
+ set_to_normal_res: SetToNormalResponse = self.stub.SetToNormal(set_to_normal_req)
254
+ if set_to_normal_res.success:
255
+ logger.info(f"Robot with ID '{set_to_normal_req.id}' set to normal operation state")
256
+ else:
257
+ logger.error(f"Failed to set robot with ID '{set_to_normal_req.id}' to normal operation state")
258
+ return set_to_normal_res
259
+
260
+ def set_to_freedrive(self, set_to_freedrive_req: SetToFreedriveRequest) -> SetToFreedriveResponse:
261
+ """
262
+ Enable or disable freedrive mode for a robot.
263
+
264
+ Freedrive mode allows manual manipulation of the robot arm by disabling
265
+ motor torques while maintaining position awareness. This is useful for
266
+ manual teaching, positioning, or when physical interaction with the robot
267
+ is required.
268
+
269
+ Args:
270
+ set_to_freedrive_req (SetToFreedriveRequest): Request containing the
271
+ robot ID and enable flag. Set enable=True to activate freedrive
272
+ mode, or enable=False to deactivate it.
273
+
274
+ Returns:
275
+ SetToFreedriveResponse: Response indicating whether the mode change was
276
+ successful and any error information if it failed.
277
+
278
+ Warning:
279
+ When freedrive mode is enabled, the robot cannot execute movement
280
+ commands. Ensure the robot is in a safe position before enabling
281
+ freedrive mode.
282
+ """
283
+ set_to_freedrive_res: SetToFreedriveResponse = self.stub.SetToFreedrive(set_to_freedrive_req)
284
+ if set_to_freedrive_res.success:
285
+ state = "enabled" if set_to_freedrive_req.enable else "disabled"
286
+ logger.info(f"Freedrive mode {state} for robot with ID '{set_to_freedrive_req.id}'")
287
+ else:
288
+ logger.error(f"Failed to change freedrive mode for robot with ID '{set_to_freedrive_req.id}'")
289
+ return set_to_freedrive_res
290
+
291
+ def move(self, move_req: MoveRequest) -> MoveResponse:
292
+ """
293
+ Execute a robot movement command.
294
+
295
+ This method sends a movement request to the robot, which can include
296
+ various types of motion such as joint moves, Cartesian moves, or
297
+ complex trajectories. The method blocks until the movement is completed
298
+ or fails.
299
+
300
+ Args:
301
+ move_req (MoveRequest): The movement request containing target positions,
302
+ motion parameters, robot ID, and other movement specifications.
303
+
304
+ Returns:
305
+ MoveResponse: Response containing the execution result, duration,
306
+ and error information if the movement failed. Check result_code
307
+ for success/failure status.
308
+
309
+ Note:
310
+ - The robot must be in normal operation mode to execute moves
311
+ - Failed movements will include error_segment information indicating
312
+ where the failure occurred in multi-segment moves
313
+ - Duration is reported in seconds for successful moves
314
+ """
315
+ move_res: MoveResponse = self.stub.Move(move_req)
316
+ if move_res.result_code == PlanResultCode.PLAN_RESULT_CODE_SUCCESS:
317
+ logger.info(f"Move command executed successfully in {move_res.duration} seconds")
318
+ else:
319
+ logger.error(f"Move command failed at segment {move_res.error_segment} with plan result code {move_res.result_code}")
320
+ return move_res
321
+
322
+ def plan(self, plan_req: PlanRequest) -> PlanResponse:
323
+ """
324
+ Plan a robot trajectory without executing it.
325
+
326
+ This method generates and validates a motion plan for the specified
327
+ movement without actually executing the robot motion. This is useful
328
+ for verifying that a planned movement is feasible before execution,
329
+ or for pre-computing trajectories.
330
+
331
+ Args:
332
+ plan_req (PlanRequest): The planning request containing target positions,
333
+ motion parameters, robot ID, and constraints for trajectory planning.
334
+
335
+ Returns:
336
+ PlanResponse: Response containing the planning result, estimated duration,
337
+ and error information if planning failed. Check result_code for
338
+ success/failure status.
339
+
340
+ Note:
341
+ - Failed plans will include error_segment information indicating
342
+ where the failure occurred in multi-segment plans
343
+ - Planning does not change robot state or position
344
+ - Successful planning provides duration estimates for the planned motion
345
+ """
346
+ plan_res: PlanResponse = self.stub.Plan(plan_req)
347
+ if plan_res.result_code == PlanResultCode.PLAN_RESULT_CODE_SUCCESS:
348
+ logger.info(f"Plan command executed successfully in {plan_res.duration} seconds")
349
+ else:
350
+ logger.error(f"Plan command failed at segment {plan_res.error_segment} with plan result code {plan_res.result_code}")
351
+ return plan_res
352
+
353
+ def wait_for_move_completion(self, request: WaitForMoveCompletionRequest) -> WaitForMoveCompletionResponse:
354
+ """
355
+ Block until the robot completes its current movement.
356
+
357
+ This method continuously checks the robot's status and blocks execution
358
+ until the robot has finished executing its current move command. This is
359
+ useful for ensuring that subsequent commands are not sent until the
360
+ robot is idle.
361
+ Args:
362
+ request (WaitForMoveCompletionRequest): Request containing the robot ID
363
+ for which to wait for movement completion.
364
+ Returns:
365
+ WaitForMoveCompletionResponse: Response with error code indicating whether the move
366
+ was completed successfully and any error information if it failed.
367
+ """
368
+ wait_res: WaitForMoveCompletionResponse = self.stub.WaitForMoveCompletion(request)
369
+ if wait_res.result_code == MoveResultCode.MOVE_RESULT_CODE_SUCCESS:
370
+ logger.info(f"Robot with ID '{request.id}' has completed its movement and is now idle")
371
+ else:
372
+ logger.error(f"Failed while waiting for robot with ID '{request.id}' to complete movement")
373
+ return wait_res
374
+
375
+ def move_until(self, move_until_req: MoveUntilRequest) -> MoveUntilResponse:
376
+ """
377
+ Execute a force/torque-aware move-until command.
378
+
379
+ This method moves the robot in a given Cartesian direction until one of
380
+ the stop conditions is met, such as contact force threshold reached or
381
+ maximum travel distance reached.
382
+
383
+ Args:
384
+ move_until_req (MoveUntilRequest): Request containing robot ID,
385
+ direction, velocity, travel limit, and contact thresholds.
386
+
387
+ Returns:
388
+ MoveUntilResponse: Response containing the result code and optional
389
+ contact pose.
390
+ """
391
+ move_until_res: MoveUntilResponse = self.stub.MoveUntil(move_until_req)
392
+ if move_until_res.result_code == MoveUntilResultCode.MOVE_UNTIL_RESULT_CODE_CONTACT_REACHED:
393
+ logger.info(f"MoveUntil completed for robot '{move_until_req.id}' with contact reached")
394
+ elif move_until_res.result_code == MoveUntilResultCode.MOVE_UNTIL_RESULT_CODE_MAX_TRAVEL_REACHED:
395
+ logger.info(f"MoveUntil completed for robot '{move_until_req.id}' with max travel reached")
396
+ else:
397
+ logger.error(
398
+ "MoveUntil failed for robot '%s' with result code %s",
399
+ move_until_req.id,
400
+ MoveUntilResultCode.Name(move_until_res.result_code),
401
+ )
402
+ return move_until_res
403
+
404
+ def _joint_stream_consume(
405
+ self,
406
+ stream_req: JointStatesStreamRequest,
407
+ on_data: Callable[[JointStates], None],
408
+ ):
409
+ self.joint_stream_iterator = self.stub.JointStatesStream(stream_req)
410
+ try:
411
+ for response in self.joint_stream_iterator:
412
+ result: JointStates = response
413
+ try:
414
+ on_data(result)
415
+ except Exception as e:
416
+ logger.error(f"Error in joint states callback: {e}")
417
+ except grpc.RpcError as e:
418
+ if e.code() == grpc.StatusCode.CANCELLED:
419
+ logger.info("Joint stream cancelled successfully.")
420
+ else:
421
+ logger.error(f"gRPC error during JointStatesStream: {e.code()} - {e.details()}")
422
+
423
+ def joint_stream_subscribe(
424
+ self,
425
+ stream_req: JointStatesStreamRequest,
426
+ on_data: Callable[[JointStates], None],
427
+ ):
428
+ """
429
+ Subscribe to a stream of joint states.
430
+
431
+ This method starts a background thread that consumes joint state data from the server
432
+ and calls the provided callback function for each update.
433
+
434
+ Args:
435
+ stream_req (JointStatesStreamRequest): Configuration for the joint states stream.
436
+ Includes id and optional max_frequency_hz (0 = no limit).
437
+ on_data (Callable[[JointStates], None]): Callback function that will be called
438
+ with each JointStates update. Any exceptions raised in the callback will be
439
+ logged but won't stop the stream.
440
+
441
+ Example:
442
+ ```python
443
+ def handle_joint_states(joint_states: JointStates):
444
+ print(f"Joint positions: {joint_states.joint_position.positions}")
445
+
446
+ client.joint_stream_subscribe(
447
+ JointStatesStreamRequest(id="robot_1"),
448
+ on_data=handle_joint_states
449
+ )
450
+ ```
451
+
452
+ Note:
453
+ This method starts a background thread. To stop the stream, call
454
+ joint_stream_unsubscribe().
455
+ """
456
+
457
+ if (self.joint_stream_thread is None) or (not self.joint_stream_thread.is_alive()):
458
+ self.joint_stream_thread = threading.Thread(
459
+ target=lambda: self._joint_stream_consume(stream_req, on_data),
460
+ daemon=True, # Daemon thread won't block program exit
461
+ )
462
+ self.joint_stream_thread.start()
463
+ else:
464
+ logger.warning("Joint stream is already active. Subscription request ignored.")
465
+
466
+ def joint_stream_unsubscribe(self):
467
+ """
468
+ Unsubscribes from the joint stream.
469
+
470
+ This method stops the joint stream thread by cancelling the gRPC iterator,
471
+ which causes the streaming loop to exit gracefully, then waits for the thread to terminate.
472
+
473
+ Returns:
474
+ None
475
+ """
476
+ if self.joint_stream_thread:
477
+ if self.joint_stream_thread.is_alive() and self.joint_stream_iterator:
478
+ self.joint_stream_iterator.cancel()
479
+ self.joint_stream_thread.join()
480
+ if self.joint_stream_thread.is_alive():
481
+ logger.warning("Joint stream thread did not exit cleanly within timeout")
482
+ self.joint_stream_thread = None
483
+ self.joint_stream_iterator = None
484
+
485
+ def _position_stream_consume(
486
+ self,
487
+ stream_req: PositionStreamRequest,
488
+ on_data: Callable[[PositionState], None],
489
+ ):
490
+ self.position_stream_iterator = self.stub.PositionStream(stream_req)
491
+ try:
492
+ for response in self.position_stream_iterator:
493
+ result: PositionState = response
494
+ try:
495
+ on_data(result)
496
+ except Exception as e:
497
+ logger.error(f"Error in position state callback: {e}")
498
+ except grpc.RpcError as e:
499
+ if e.code() == grpc.StatusCode.CANCELLED:
500
+ logger.info("Position stream cancelled successfully.")
501
+ else:
502
+ logger.error(f"gRPC error during PositionStream: {e.code()} - {e.details()}")
503
+
504
+ def position_stream_subscribe(
505
+ self,
506
+ stream_req: PositionStreamRequest,
507
+ on_data: Callable[[PositionState], None],
508
+ ):
509
+ """
510
+ Subscribe to a position data stream from the VRMCS server.
511
+
512
+ This method starts a background thread that consumes position data from the server
513
+ and calls the provided callback function for each update.
514
+
515
+ Args:
516
+ stream_req (PositionStreamRequest): The request parameters for the position stream.
517
+ Includes id and optional max_frequency_hz (0 = no limit).
518
+
519
+ on_data (Callable[[PositionState], None]): Callback function that will be called
520
+ with each PositionState update. Any exceptions raised in the callback will be
521
+ logged but won't stop the stream.
522
+
523
+ Example:
524
+ ```python
525
+ def handle_position(position_state: PositionState):
526
+ pose = position_state.cartesian_pose
527
+ print(f"Position: x={pose.position.x}, y={pose.position.y}, z={pose.position.z}")
528
+
529
+ client.position_stream_subscribe(
530
+ PositionStreamRequest(id="robot_1"),
531
+ on_data=handle_position
532
+ )
533
+ ```
534
+
535
+ Note:
536
+ To stop the stream, call the position_stream_unsubscribe method.
537
+ """
538
+
539
+ if (self.position_stream_thread is None) or (not self.position_stream_thread.is_alive()):
540
+ self.position_stream_thread = threading.Thread(
541
+ target=lambda: self._position_stream_consume(stream_req, on_data),
542
+ daemon=True, # Daemon thread won't block program exit
543
+ )
544
+ self.position_stream_thread.start()
545
+ else:
546
+ logger.warning("Position stream is already active. Subscription request ignored.")
547
+
548
+ def position_stream_unsubscribe(self):
549
+ """
550
+ Unsubscribe from the position stream.
551
+
552
+ This method stops the position streaming thread by cancelling the gRPC iterator,
553
+ which causes the streaming loop to exit gracefully, then waits for the thread to terminate.
554
+
555
+ Returns:
556
+ None
557
+ """
558
+ if self.position_stream_thread:
559
+ if self.position_stream_thread.is_alive() and self.position_stream_iterator:
560
+ self.position_stream_iterator.cancel()
561
+ self.position_stream_thread.join()
562
+ if self.position_stream_thread.is_alive():
563
+ logger.warning("Position stream thread did not exit cleanly within timeout")
564
+ self.position_stream_thread = None
565
+ self.position_stream_iterator = None
566
+
567
+ def _states_stream_consume(
568
+ self,
569
+ stream_req: RobotStatesStreamRequest,
570
+ on_data: Callable[[RobotStates], None],
571
+ ):
572
+ self.states_stream_iterator = self.stub.RobotStatesStream(stream_req)
573
+ try:
574
+ for response in self.states_stream_iterator:
575
+ result: RobotStates = response
576
+ try:
577
+ on_data(result)
578
+ except Exception as e:
579
+ logger.error(f"Error in robot states callback: {e}")
580
+ except grpc.RpcError as e:
581
+ if e.code() == grpc.StatusCode.CANCELLED:
582
+ logger.info("Robot states stream cancelled successfully.")
583
+ else:
584
+ logger.error(f"gRPC error during RobotStatesStream: {e.code()} - {e.details()}")
585
+
586
+ def states_stream_subscribe(
587
+ self,
588
+ stream_req: RobotStatesStreamRequest,
589
+ on_data: Callable[[RobotStates], None],
590
+ ):
591
+ """
592
+ Subscribe to a stream of robot state changes.
593
+
594
+ This method starts a background thread that consumes robot state data from the server
595
+ and calls the provided callback function for each state change. Unlike position/joint
596
+ streams which send continuous updates, this is an event-driven stream that only sends
597
+ messages when the robot's state actually changes (connection_state, operational_state,
598
+ or safety_state).
599
+
600
+ Args:
601
+ stream_req (RobotStatesStreamRequest): Configuration for the robot states stream
602
+ including robot ID.
603
+ on_data (Callable[[RobotStates], None]): Callback function that will be called
604
+ with each RobotStates update. Any exceptions raised in the callback will be
605
+ logged but won't stop the stream.
606
+
607
+ Example:
608
+ ```python
609
+ def handle_state_change(states: RobotStates):
610
+ print(f"State changed: {RobotOperationalState.Name(states.operational_state)}")
611
+
612
+ client.states_stream_subscribe(
613
+ RobotStatesStreamRequest(id="robot_1"),
614
+ on_data=handle_state_change
615
+ )
616
+ ```
617
+
618
+ Note:
619
+ To stop the stream, call states_stream_unsubscribe().
620
+ Only one robot states stream can be active at a time per client.
621
+ """
622
+ if (self.states_stream_thread is None) or (not self.states_stream_thread.is_alive()):
623
+ self.states_stream_thread = threading.Thread(
624
+ target=lambda: self._states_stream_consume(stream_req, on_data),
625
+ daemon=True, # Daemon thread won't block program exit
626
+ )
627
+ self.states_stream_thread.start()
628
+ else:
629
+ logger.warning("States stream is already active. Subscription request ignored.")
630
+
631
+ def states_stream_unsubscribe(self):
632
+ """
633
+ Unsubscribe from the robot states stream.
634
+
635
+ This method stops the robot states streaming thread by cancelling the gRPC iterator,
636
+ which causes the streaming loop to exit gracefully, then waits for the thread to terminate.
637
+
638
+ Returns:
639
+ None
640
+ """
641
+ if self.states_stream_thread:
642
+ if self.states_stream_thread.is_alive() and self.states_stream_iterator:
643
+ self.states_stream_iterator.cancel()
644
+ self.states_stream_thread.join()
645
+ if self.states_stream_thread.is_alive():
646
+ logger.warning("States stream thread did not exit cleanly within timeout")
647
+ self.states_stream_thread = None
648
+ self.states_stream_iterator = None
649
+
650
+ def move_interpolation(self, move_interp_req: MoveInterpolationRequest) -> MoveInterpolationResponse:
651
+ """
652
+ Execute an interpolated trajectory movement.
653
+
654
+ This method executes a pre-defined trajectory consisting of multiple
655
+ waypoints with interpolated motion between them. The robot follows
656
+ a smooth path through all specified points, which is useful for
657
+ complex motions and precise trajectory following.
658
+
659
+ Args:
660
+ move_interp_req (MoveInterpolationRequest): Request containing the
661
+ trajectory points, interpolation parameters, timing constraints,
662
+ robot ID, and other trajectory specifications.
663
+
664
+ Returns:
665
+ MoveInterpolationResponse: Response containing execution result,
666
+ duration, and error information if the trajectory failed.
667
+ Check result_code for success/failure status.
668
+
669
+ Note:
670
+ - The robot must be in normal operation mode to execute trajectories
671
+ - Failed trajectories will include error_point information indicating
672
+ which waypoint caused the failure
673
+ - Duration is reported in seconds for successful trajectory planning
674
+ """
675
+ move_interp_res: MoveInterpolationResponse = self.stub.MoveInterpolation(move_interp_req)
676
+ if move_interp_res.result_code == PlanResultCode.PLAN_RESULT_CODE_SUCCESS:
677
+ logger.info(f"Trajectory started successfully in {move_interp_res.duration} seconds")
678
+ else:
679
+ logger.error(
680
+ f"Trajectory plan failed at point {move_interp_res.error_point} " + f"with plan result code {move_interp_res.result_code}"
681
+ )
682
+ return move_interp_res
683
+
684
+ def list_robots(self) -> ListRobotsResponse:
685
+ """
686
+ Retrieve a list of all registered robots from the VRMCS server.
687
+
688
+ """
689
+ list_robots_res: ListRobotsResponse = self.stub.ListRobots(empty_pb2.Empty())
690
+ return list_robots_res
691
+
692
+ def set_payload(self, set_payload_req: SetPayloadRequest) -> SetPayloadResponse:
693
+ """
694
+ Set the payload mass properties for a robot.
695
+
696
+ This method configures the robot's payload properties, which affects dynamics
697
+ calculations and motion planning. Proper payload configuration is essential
698
+ for accurate motion control and safety.
699
+
700
+ Args:
701
+ set_payload_req (SetPayloadRequest): Request containing the robot ID
702
+ and mass properties (mass, center of mass, inertia tensor).
703
+
704
+ Returns:
705
+ SetPayloadResponse: Response indicating whether the payload was set
706
+ successfully and result code.
707
+ """
708
+ set_payload_res: SetPayloadResponse = self.stub.SetPayload(set_payload_req)
709
+ if set_payload_res.result_code == SetPayloadResultCode.SET_PAYLOAD_RESULT_CODE_SUCCESS:
710
+ logger.info("Set payload command executed successfully")
711
+ else:
712
+ logger.error(f"Set payload command failed with result code {set_payload_res.result_code}")
713
+ return set_payload_res
714
+
715
+ def unregister_robot(self, unregister_robot_req: UnregisterRobotRequest) -> UnregisterRobotResponse:
716
+ """
717
+ Unregister a robot from the VRMCS server.
718
+
719
+ This method removes a previously registered robot from the system, freeing
720
+ up resources and making it unavailable for control operations.
721
+
722
+ Args:
723
+ unregister_robot_req (UnregisterRobotRequest): Request containing the
724
+ robot ID to unregister.
725
+
726
+ Returns:
727
+ UnregisterRobotResponse: Response indicating success or failure with
728
+ error message if unregistration failed.
729
+
730
+ Raises:
731
+ grpc.RpcError: If there's a communication error with the VRMCS server.
732
+ The error is caught and converted to a failed response.
733
+ """
734
+ try:
735
+ unregister_robot_res: UnregisterRobotResponse = self.stub.UnregisterRobot(unregister_robot_req)
736
+ if unregister_robot_res.success:
737
+ logger.info(f"Robot with ID '{unregister_robot_req.id}' unregistered successfully")
738
+ else:
739
+ logger.error(f"Failed to unregister robot with ID '{unregister_robot_req.id}': {unregister_robot_res.error_message}")
740
+ except grpc.RpcError as e:
741
+ logger.error(f"gRPC error during UnregisterRobot: {e.code()} - {e.details()}")
742
+ unregister_robot_res = UnregisterRobotResponse(success=False, error_message=str(e))
743
+ return unregister_robot_res
744
+
745
+ def get_robot_info(self, get_robot_info_req: GetRobotInfoRequest) -> RobotInfo:
746
+ """
747
+ Get detailed information about a registered robot.
748
+
749
+ This method retrieves configuration and status information for a specific
750
+ robot including its name, type, and joint configuration.
751
+
752
+ Args:
753
+ get_robot_info_req (GetRobotInfoRequest): Request containing the robot ID.
754
+
755
+ Returns:
756
+ RobotInfo: Information about the robot including id, name,
757
+ robot_type, and joint_names.
758
+
759
+ Raises:
760
+ grpc.RpcError: If there's a communication error with the VRMCS server
761
+ or if the robot is not found.
762
+ """
763
+ robot_info: RobotInfo = self.stub.GetRobotInfo(get_robot_info_req)
764
+ logger.info(f"Retrieved info for robot '{robot_info.name}' (ID: '{robot_info.id}')")
765
+ return robot_info
766
+
767
+ def _command_state_stream_consume(
768
+ self,
769
+ stream_req: CommandStateStreamRequest,
770
+ on_data: Callable[[CommandState], None],
771
+ ):
772
+ self.command_state_stream_iterator = self.stub.CommandStateStream(stream_req)
773
+ try:
774
+ for response in self.command_state_stream_iterator:
775
+ result: CommandState = response
776
+ try:
777
+ on_data(result)
778
+ except Exception as e:
779
+ logger.error(f"Error in command state callback: {e}")
780
+ except grpc.RpcError as e:
781
+ if e.code() == grpc.StatusCode.CANCELLED:
782
+ logger.info("Command state stream cancelled successfully.")
783
+ else:
784
+ logger.error(f"gRPC error during CommandStateStream: {e.code()} - {e.details()}")
785
+
786
+ def command_state_stream_subscribe(
787
+ self,
788
+ stream_req: CommandStateStreamRequest,
789
+ on_data: Callable[[CommandState], None],
790
+ ):
791
+ """
792
+ Subscribe to a stream of robot command states.
793
+
794
+ This method starts a background thread that consumes command state data from the server
795
+ and calls the provided callback function for each update. Command states include joint
796
+ and Cartesian positions, velocities, and accelerations being commanded to the robot.
797
+
798
+ Args:
799
+ stream_req (CommandStateStreamRequest): Configuration for the command
800
+ state stream including id and optional max_frequency_hz (0 = no limit).
801
+
802
+ on_data (Callable[[CommandState], None]): Callback function that will be called
803
+ with each CommandState update. Any exceptions raised in the callback will be
804
+ logged but won't stop the stream.
805
+
806
+ Example:
807
+ ```python
808
+ def handle_command_state(cmd_state: CommandState):
809
+ print(f"Command joint positions: {cmd_state.joint_position.positions}")
810
+
811
+ client.command_state_stream_subscribe(
812
+ CommandStateStreamRequest(id="robot_1"),
813
+ on_data=handle_command_state
814
+ )
815
+ ```
816
+
817
+ Note:
818
+ To stop the stream, call command_state_stream_unsubscribe().
819
+ Only one command state stream can be active at a time per client.
820
+ """
821
+ if (self.command_state_stream_thread is None) or (not self.command_state_stream_thread.is_alive()):
822
+ self.command_state_stream_thread = threading.Thread(
823
+ target=lambda: self._command_state_stream_consume(stream_req, on_data),
824
+ daemon=True, # Daemon thread won't block program exit
825
+ )
826
+ self.command_state_stream_thread.start()
827
+ else:
828
+ logger.warning("Command state stream is already active. Subscription request ignored.")
829
+
830
+ def command_state_stream_unsubscribe(self):
831
+ """
832
+ Unsubscribe from the command state stream.
833
+
834
+ This method stops the command state streaming thread by cancelling the gRPC iterator,
835
+ which causes the streaming loop to exit gracefully, then waits for the thread to terminate.
836
+
837
+ Returns:
838
+ None
839
+ """
840
+ if self.command_state_stream_thread:
841
+ if self.command_state_stream_thread.is_alive() and self.command_state_stream_iterator:
842
+ self.command_state_stream_iterator.cancel()
843
+ self.command_state_stream_thread.join()
844
+ if self.command_state_stream_thread.is_alive():
845
+ logger.warning("Command state stream thread did not exit cleanly within timeout")
846
+ self.command_state_stream_thread = None
847
+ self.command_state_stream_iterator = None
848
+
849
+ def inverse_kinematics_batch(self, ik_req: InverseKinematicsBatchRequest) -> InverseKinematicsBatchResponse:
850
+ """
851
+ Calculate joint positions for a list of given Cartesian poses (Inverse Kinematics).
852
+
853
+ A seed joint position is used as a starting point for the IK solver.
854
+ The initial joint position is used for all target poses.
855
+
856
+ Args:
857
+ ik_req (InverseKinematicsBatchRequest): Request containing robot ID, seed
858
+ joint position in radians, and a list of target Cartesian poses.
859
+
860
+ Returns:
861
+ InverseKinematicsBatchResponse: Response containing computed a list of joint positions,
862
+ result code, and error message if computation failed. Each index of list corresponds
863
+ to the target poses in the request.
864
+
865
+ Note:
866
+ - Multiple solutions may exist; the returned solution will be closest
867
+ to the seed position
868
+ - May fail if target pose is unreachable, in singularity, causes
869
+ self-collision, or exceeds joint limits
870
+ """
871
+ ik_res: InverseKinematicsBatchResponse = self.stub.InverseKinematicsBatch(ik_req)
872
+ return ik_res
873
+
874
+ def inverse_kinematics(self, ik_req: InverseKinematicsRequest) -> InverseKinematicsResponse:
875
+ """
876
+ Calculate joint positions for a given Cartesian pose (Inverse Kinematics).
877
+
878
+ This method computes the joint configuration needed to achieve a specified
879
+ TCP pose. A seed joint position is used as a starting point for the IK solver.
880
+
881
+ Args:
882
+ ik_req (InverseKinematicsRequest): Request containing robot ID, seed
883
+ joint position, target Cartesian pose, and optional joint_constraints
884
+ (list of JointRangeConstraints to limit specific joint angles).
885
+
886
+ Returns:
887
+ InverseKinematicsResponse: Response containing computed joint positions,
888
+ result code, and error message if computation failed.
889
+
890
+ Note:
891
+ - Multiple solutions may exist; the returned solution will be closest
892
+ to the seed position
893
+ - May fail if target pose is unreachable, in singularity, causes
894
+ self-collision, or exceeds joint limits
895
+ - Joint constraints can be specified to limit specific joints to custom ranges
896
+ """
897
+ ik_res: InverseKinematicsResponse = self.stub.InverseKinematics(ik_req)
898
+ if ik_res.result_code == KinResultCode.KIN_RESULT_CODE_SUCCESS:
899
+ logger.info(f"Inverse kinematics computed successfully for robot '{ik_req.id}'")
900
+ else:
901
+ logger.error(f"Inverse kinematics failed for robot '{ik_req.id}': {ik_res.error_message}")
902
+ return ik_res
903
+
904
+ def forward_kinematics(self, fk_req: ForwardKinematicsRequest) -> ForwardKinematicsResponse:
905
+ """
906
+ Calculate Cartesian pose for given joint positions (Forward Kinematics).
907
+
908
+ This method computes the TCP pose that results from a specified joint
909
+ configuration. Useful for verifying joint positions and trajectory planning.
910
+
911
+ Args:
912
+ fk_req (ForwardKinematicsRequest): Request containing robot ID and
913
+ joint positions (radians).
914
+
915
+ Returns:
916
+ ForwardKinematicsResponse: Response containing computed Cartesian pose,
917
+ result code, and error message if computation failed.
918
+
919
+ Note:
920
+ Forward kinematics has a unique solution for any valid joint configuration.
921
+ May fail if joint positions are invalid or exceed limits.
922
+ """
923
+ fk_res: ForwardKinematicsResponse = self.stub.ForwardKinematics(fk_req)
924
+ if fk_res.result_code == KinResultCode.KIN_RESULT_CODE_SUCCESS:
925
+ logger.info(f"Forward kinematics computed successfully for robot '{fk_req.id}'")
926
+ else:
927
+ logger.error(f"Forward kinematics failed for robot '{fk_req.id}': {fk_res.error_message}")
928
+ return fk_res
929
+
930
+ def set_tcp(self, set_tcp_req: SetTcpRequest) -> SetTcpResponse:
931
+ """
932
+ Set the Tool Center Point (TCP) for a robot.
933
+
934
+ This method configures the TCP, which defines the position and orientation
935
+ of the tool attached to the robot's end effector. Proper TCP configuration
936
+ is essential for accurate motion control and task execution.
937
+
938
+ Args:
939
+ set_tcp_req (SetTcpRequest): Request containing the robot ID and TCP frame
940
+ relative to the tool base.
941
+ Returns:
942
+ SetTcpResponse: Response indicating whether the TCP was set successfully
943
+ and result code.
944
+ """
945
+ set_tcp_res: SetTcpResponse = self.stub.SetTcp(set_tcp_req)
946
+ if set_tcp_res.result == SetTcpResultCode.SET_TCP_RESULT_CODE_SUCCESS:
947
+ logger.info("Set TCP command executed successfully")
948
+ else:
949
+ logger.error(f"Set TCP command failed with result code {set_tcp_res.result}")
950
+ return set_tcp_res
951
+
952
+ def get_tcp(self, get_tcp_req: GetTcpRequest) -> GetTcpResponse:
953
+ """
954
+ Get the Tool Center Point (TCP) configuration for a robot.
955
+
956
+ This method retrieves the current TCP configuration, which defines the
957
+ position and orientation of the tool attached to the robot's end effector
958
+ relative to the tool base frame.
959
+
960
+ Args:
961
+ get_tcp_req (GetTcpRequest): Request containing the robot ID for which
962
+ to retrieve the TCP configuration.
963
+
964
+ Returns:
965
+ GetTcpResponse: Response containing the TCP configuration including
966
+ the TCP ID and frame (position and orientation).
967
+
968
+ Raises:
969
+ grpc.RpcError: If there's a communication error with the VRMCS server
970
+ or if the robot is not found.
971
+
972
+ Note:
973
+ The returned frame represents the transformation from the tool base
974
+ to the TCP (tool center point).
975
+ """
976
+ get_tcp_res: GetTcpResponse = self.stub.GetTcp(get_tcp_req)
977
+ logger.info(f"Retrieved TCP configuration for robot '{get_tcp_req.id}'")
978
+ return get_tcp_res
979
+
980
+ def get_joint_states(self, get_joint_states_req: GetJointStatesRequest) -> GetJointStatesResponse:
981
+ """
982
+ Get the current joint states of a robot (one-time query).
983
+
984
+ This method retrieves a single snapshot of the robot's joint positions
985
+ at the time of the request. For continuous monitoring, use
986
+ joint_stream_subscribe instead.
987
+
988
+ Args:
989
+ get_joint_states_req (GetJointStatesRequest): Request containing the
990
+ robot ID.
991
+
992
+ Returns:
993
+ GetJointStatesResponse: Response containing joint states with positions
994
+ and timestamp.
995
+
996
+ Note:
997
+ Use the streaming API for high-frequency monitoring.
998
+ """
999
+ res: GetJointStatesResponse = self.stub.GetJointStates(get_joint_states_req)
1000
+ logger.info(f"Retrieved joint states for robot '{get_joint_states_req.id}'")
1001
+ return res
1002
+
1003
+ def get_position_state(self, get_position_state_req: GetPositionStateRequest) -> GetPositionStateResponse:
1004
+ """
1005
+ Get the current position state of a robot (one-time query).
1006
+
1007
+ This method retrieves a single snapshot of the robot's joint and Cartesian
1008
+ positions at the time of the request. For continuous monitoring, use
1009
+ position_stream_subscribe instead.
1010
+
1011
+ Args:
1012
+ get_position_state_req (GetPositionStateRequest): Request containing
1013
+ the robot ID.
1014
+
1015
+ Returns:
1016
+ GetPositionStateResponse: Response containing position state with joint
1017
+ positions, Cartesian pose, and timestamp.
1018
+
1019
+ Note:
1020
+ Use the streaming API for high-frequency monitoring.
1021
+ """
1022
+ res: GetPositionStateResponse = self.stub.GetPositionState(get_position_state_req)
1023
+ logger.info(f"Retrieved position state for robot '{get_position_state_req.id}'")
1024
+ return res
1025
+
1026
+ def get_robot_states(self, get_robot_states_req: GetRobotStatesRequest) -> GetRobotStatesResponse:
1027
+ """
1028
+ Get the current robot states (one-time query).
1029
+
1030
+ This method retrieves a single snapshot of the robot's operational,
1031
+ safety, and connection states at the time of the request. For continuous
1032
+ monitoring, use states_stream_subscribe instead.
1033
+
1034
+ Args:
1035
+ get_robot_states_req (GetRobotStatesRequest): Request containing the
1036
+ robot ID.
1037
+
1038
+ Returns:
1039
+ GetRobotStatesResponse: Response containing robot states including
1040
+ operational state, safety state, connection state, and timestamp.
1041
+
1042
+ Note:
1043
+ Use the streaming API for high-frequency monitoring.
1044
+ """
1045
+ res: GetRobotStatesResponse = self.stub.GetRobotStates(get_robot_states_req)
1046
+ logger.info(f"Retrieved robot states for robot '{get_robot_states_req.id}'")
1047
+ return res
1048
+
1049
+ def get_command_state(self, get_command_state_req: GetCommandStateRequest) -> GetCommandStateResponse:
1050
+ """
1051
+ Get the current command state of a robot (one-time query).
1052
+
1053
+ This method retrieves a single snapshot of the robot's commanded joint
1054
+ and Cartesian positions, velocities, and accelerations at the time of
1055
+ the request. For continuous monitoring, use command_state_stream_subscribe
1056
+ instead.
1057
+
1058
+ Args:
1059
+ get_command_state_req (GetCommandStateRequest): Request containing the
1060
+ robot ID.
1061
+
1062
+ Returns:
1063
+ GetCommandStateResponse: Response containing command state with joint
1064
+ and Cartesian positions, velocities, accelerations, and timestamps.
1065
+
1066
+ Note:
1067
+ Use the streaming API for high-frequency monitoring of command states.
1068
+ """
1069
+ res: GetCommandStateResponse = self.stub.GetCommandState(get_command_state_req)
1070
+ logger.info(f"Retrieved command state for robot '{get_command_state_req.id}'")
1071
+ return res
1072
+
1073
+ def get_feedback(self, get_feedback_req: GetFeedbackRequest) -> GetFeedbackResponse:
1074
+ """
1075
+ Get the current robot feedback (one-time query).
1076
+
1077
+ This method retrieves a single snapshot of measured robot feedback,
1078
+ including measured joint positions, velocities, torques, and optional
1079
+ Cartesian wrench/velocity information when available.
1080
+
1081
+ Args:
1082
+ get_feedback_req (GetFeedbackRequest): Request containing the robot ID.
1083
+
1084
+ Returns:
1085
+ GetFeedbackResponse: Response containing a Feedback message.
1086
+
1087
+ Note:
1088
+ Use feedback_stream_subscribe for continuous feedback monitoring.
1089
+ """
1090
+ res: GetFeedbackResponse = self.stub.GetFeedback(get_feedback_req)
1091
+ logger.info(f"Retrieved feedback for robot '{get_feedback_req.id}'")
1092
+ return res
1093
+
1094
+ def _feedback_stream_consume(
1095
+ self,
1096
+ stream_req: FeedbackStreamRequest,
1097
+ on_data: Callable[[Feedback], None],
1098
+ ):
1099
+ self.feedback_stream_iterator = self.stub.FeedbackStream(stream_req)
1100
+ try:
1101
+ for response in self.feedback_stream_iterator:
1102
+ result: Feedback = response
1103
+ try:
1104
+ on_data(result)
1105
+ except Exception as e:
1106
+ logger.error(f"Error in feedback callback: {e}")
1107
+ except grpc.RpcError as e:
1108
+ if e.code() == grpc.StatusCode.CANCELLED:
1109
+ logger.info("Feedback stream cancelled successfully.")
1110
+ else:
1111
+ logger.error(f"gRPC error during FeedbackStream: {e.code()} - {e.details()}")
1112
+
1113
+ def feedback_stream_subscribe(
1114
+ self,
1115
+ stream_req: FeedbackStreamRequest,
1116
+ on_data: Callable[[Feedback], None],
1117
+ ):
1118
+ """
1119
+ Subscribe to a stream of measured robot feedback.
1120
+
1121
+ This method starts a background thread that consumes measured feedback
1122
+ data from the server and calls the provided callback function for each
1123
+ update.
1124
+
1125
+ Args:
1126
+ stream_req (FeedbackStreamRequest): Configuration for the feedback
1127
+ stream including robot ID and optional max_frequency_hz
1128
+ (0 = no limit).
1129
+ on_data (Callable[[Feedback], None]): Callback function that will be
1130
+ called with each Feedback update. Any exceptions raised in the
1131
+ callback will be logged but won't stop the stream.
1132
+
1133
+ Example:
1134
+ ```python
1135
+ def handle_feedback(feedback: Feedback):
1136
+ print(f"Measured joints: {feedback.joint_position}")
1137
+
1138
+ client.feedback_stream_subscribe(
1139
+ FeedbackStreamRequest(id="robot_1"),
1140
+ on_data=handle_feedback,
1141
+ )
1142
+ ```
1143
+
1144
+ Note:
1145
+ To stop the stream, call feedback_stream_unsubscribe().
1146
+ Only one feedback stream can be active at a time per client.
1147
+ """
1148
+ if (self.feedback_stream_thread is None) or (not self.feedback_stream_thread.is_alive()):
1149
+ self.feedback_stream_thread = threading.Thread(
1150
+ target=lambda: self._feedback_stream_consume(stream_req, on_data),
1151
+ daemon=True, # Daemon thread won't block program exit
1152
+ )
1153
+ self.feedback_stream_thread.start()
1154
+ else:
1155
+ logger.warning("Feedback stream is already active. Subscription request ignored.")
1156
+
1157
+ def feedback_stream_unsubscribe(self):
1158
+ """
1159
+ Unsubscribe from the feedback stream.
1160
+
1161
+ This method stops the feedback streaming thread by cancelling the gRPC
1162
+ iterator, which causes the streaming loop to exit gracefully, then
1163
+ waits for the thread to terminate.
1164
+
1165
+ Returns:
1166
+ None
1167
+ """
1168
+ if self.feedback_stream_thread:
1169
+ if self.feedback_stream_thread.is_alive() and self.feedback_stream_iterator:
1170
+ self.feedback_stream_iterator.cancel()
1171
+ self.feedback_stream_thread.join()
1172
+ if self.feedback_stream_thread.is_alive():
1173
+ logger.warning("Feedback stream thread did not exit cleanly within timeout")
1174
+ self.feedback_stream_thread = None
1175
+ self.feedback_stream_iterator = None
1176
+
1177
+ def _robot_alarm_stream_consume(
1178
+ self,
1179
+ stream_req: RobotAlarmStreamRequest,
1180
+ on_data: Callable[[RobotAlarm], None],
1181
+ ):
1182
+ self.robot_alarm_stream_iterator = self.stub.RobotAlarmStream(stream_req)
1183
+ try:
1184
+ for response in self.robot_alarm_stream_iterator:
1185
+ result: RobotAlarm = response
1186
+ try:
1187
+ on_data(result)
1188
+ except Exception as e:
1189
+ logger.error(f"Error in robot alarm callback: {e}")
1190
+ except grpc.RpcError as e:
1191
+ if e.code() == grpc.StatusCode.CANCELLED:
1192
+ logger.info("Robot alarm stream cancelled successfully.")
1193
+ else:
1194
+ logger.error(f"gRPC error during RobotAlarmStream: {e.code()} - {e.details()}")
1195
+
1196
+ def robot_alarm_stream_subscribe(
1197
+ self,
1198
+ stream_req: RobotAlarmStreamRequest,
1199
+ on_data: Callable[[RobotAlarm], None],
1200
+ ):
1201
+ """
1202
+ Subscribe to a stream of robot alarms and notifications.
1203
+
1204
+ This method starts a background thread that consumes robot alarm data from the server
1205
+ and calls the provided callback function for each alarm update. Alarms include
1206
+ notifications from the motion controller, robot driver, and actuator driver.
1207
+
1208
+ Args:
1209
+ stream_req (RobotAlarmStreamRequest): Configuration for the robot alarm stream
1210
+ including robot ID.
1211
+ on_data (Callable[[RobotAlarm], None]): Callback function that will be called
1212
+ with each RobotAlarm update containing a list of Notification objects.
1213
+ Any exceptions raised in the callback will be logged but won't stop the stream.
1214
+
1215
+ Example:
1216
+ ```python
1217
+ def handle_alarm(alarm: RobotAlarm):
1218
+ for notification in alarm.notifications:
1219
+ print(f"[{SeverityLevel.Name(notification.severity)}] {notification.message}")
1220
+
1221
+ client.robot_alarm_stream_subscribe(
1222
+ RobotAlarmStreamRequest(id="robot_1"),
1223
+ on_data=handle_alarm
1224
+ )
1225
+ ```
1226
+
1227
+ Note:
1228
+ To stop the stream, call robot_alarm_stream_unsubscribe().
1229
+ Only one robot alarm stream can be active at a time per client.
1230
+ """
1231
+ if (self.robot_alarm_stream_thread is None) or (not self.robot_alarm_stream_thread.is_alive()):
1232
+ self.robot_alarm_stream_thread = threading.Thread(
1233
+ target=lambda: self._robot_alarm_stream_consume(stream_req, on_data),
1234
+ daemon=True, # Daemon thread won't block program exit
1235
+ )
1236
+ self.robot_alarm_stream_thread.start()
1237
+ else:
1238
+ logger.warning("Robot alarm stream is already active. Subscription request ignored.")
1239
+
1240
+ def robot_alarm_stream_unsubscribe(self):
1241
+ """
1242
+ Unsubscribe from the robot alarm stream.
1243
+
1244
+ This method stops the robot alarm streaming thread by cancelling the gRPC iterator,
1245
+ which causes the streaming loop to exit gracefully, then waits for the thread to terminate.
1246
+
1247
+ Returns:
1248
+ None
1249
+ """
1250
+ if self.robot_alarm_stream_thread:
1251
+ if self.robot_alarm_stream_thread.is_alive() and self.robot_alarm_stream_iterator:
1252
+ self.robot_alarm_stream_iterator.cancel()
1253
+ self.robot_alarm_stream_thread.join()
1254
+ if self.robot_alarm_stream_thread.is_alive():
1255
+ logger.warning("Robot alarm stream thread did not exit cleanly within timeout")
1256
+ self.robot_alarm_stream_thread = None
1257
+ self.robot_alarm_stream_iterator = None