luckyrobots 0.1.71__py3-none-any.whl → 0.1.73__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.
luckyrobots/client.py CHANGED
@@ -9,8 +9,6 @@ from __future__ import annotations
9
9
 
10
10
  import logging
11
11
  import time
12
- import statistics
13
- from dataclasses import dataclass
14
12
  from types import SimpleNamespace
15
13
  from typing import Any, Optional
16
14
 
@@ -19,26 +17,18 @@ logger = logging.getLogger("luckyrobots.client")
19
17
  try:
20
18
  from .grpc.generated import agent_pb2 # type: ignore
21
19
  from .grpc.generated import agent_pb2_grpc # type: ignore
22
- from .grpc.generated import camera_pb2 # type: ignore
23
- from .grpc.generated import camera_pb2_grpc # type: ignore
24
20
  from .grpc.generated import common_pb2 # type: ignore
25
- from .grpc.generated import media_pb2 # type: ignore
26
21
  from .grpc.generated import mujoco_pb2 # type: ignore
27
22
  from .grpc.generated import mujoco_pb2_grpc # type: ignore
28
23
  from .grpc.generated import scene_pb2 # type: ignore
29
24
  from .grpc.generated import scene_pb2_grpc # type: ignore
30
- from .grpc.generated import telemetry_pb2 # type: ignore
31
- from .grpc.generated import telemetry_pb2_grpc # type: ignore
32
- from .grpc.generated import viewport_pb2 # type: ignore
33
- from .grpc.generated import viewport_pb2_grpc # type: ignore
34
25
  except Exception as e: # pragma: no cover
35
26
  raise ImportError(
36
27
  "Missing generated gRPC stubs. Regenerate them from the protos in "
37
28
  "src/luckyrobots/grpc/proto into src/luckyrobots/grpc/generated."
38
29
  ) from e
39
30
 
40
- # Import Pydantic models for type-checked responses
41
- from .models import ObservationResponse, StateSnapshot, DomainRandomizationConfig
31
+ from .models import ObservationResponse
42
32
 
43
33
 
44
34
  class GrpcConnectionError(Exception):
@@ -53,24 +43,18 @@ class LuckyEngineClient:
53
43
  """
54
44
  Client for connecting to the LuckyEngine gRPC server.
55
45
 
56
- Provides access to all gRPC services defined by the protos under
57
- `src/luckyrobots/rpc/proto`:
58
- - SceneService
59
- - MujocoService
60
- - TelemetryService
61
- - AgentService
62
- - ViewportService
63
- - CameraService
46
+ Provides access to gRPC services for RL training:
47
+ - AgentService: observations, stepping, resets
48
+ - SceneService: simulation mode control
49
+ - MujocoService: health checks
64
50
 
65
51
  Usage:
66
52
  client = LuckyEngineClient(host="127.0.0.1", port=50051)
67
53
  client.connect()
54
+ client.wait_for_server()
68
55
 
69
- # Access services
70
- scene_info = client.scene.GetSceneInfo(client.pb.scene.GetSceneInfoRequest())
71
- joint_state = client.mujoco.GetJointState(
72
- client.pb.mujoco.GetJointStateRequest()
73
- )
56
+ schema = client.get_agent_schema()
57
+ obs = client.step(actions=[0.0] * 12)
74
58
 
75
59
  client.close()
76
60
  """
@@ -102,25 +86,17 @@ class LuckyEngineClient:
102
86
  # Service stubs (populated after connect)
103
87
  self._scene = None
104
88
  self._mujoco = None
105
- self._telemetry = None
106
89
  self._agent = None
107
- self._viewport = None
108
- self._camera = None
109
90
 
110
91
  # Cached agent schemas: agent_name -> (observation_names, action_names)
111
- # Populated lazily by get_agent_schema() or fetch_schema()
112
92
  self._schema_cache: dict[str, tuple[list[str], list[str]]] = {}
113
93
 
114
94
  # Protobuf modules (for discoverability + explicit imports).
115
95
  self._pb = SimpleNamespace(
116
96
  common=common_pb2,
117
- media=media_pb2,
118
97
  scene=scene_pb2,
119
98
  mujoco=mujoco_pb2,
120
- telemetry=telemetry_pb2,
121
99
  agent=agent_pb2,
122
- viewport=viewport_pb2,
123
- camera=camera_pb2,
124
100
  )
125
101
 
126
102
  def connect(self) -> None:
@@ -145,10 +121,7 @@ class LuckyEngineClient:
145
121
  # Create service stubs
146
122
  self._scene = scene_pb2_grpc.SceneServiceStub(self._channel)
147
123
  self._mujoco = mujoco_pb2_grpc.MujocoServiceStub(self._channel)
148
- self._telemetry = telemetry_pb2_grpc.TelemetryServiceStub(self._channel)
149
124
  self._agent = agent_pb2_grpc.AgentServiceStub(self._channel)
150
- self._viewport = viewport_pb2_grpc.ViewportServiceStub(self._channel)
151
- self._camera = camera_pb2_grpc.CameraServiceStub(self._channel)
152
125
 
153
126
  logger.info(f"Channel opened to {target} (server not verified yet)")
154
127
 
@@ -162,10 +135,7 @@ class LuckyEngineClient:
162
135
  self._channel = None
163
136
  self._scene = None
164
137
  self._mujoco = None
165
- self._telemetry = None
166
138
  self._agent = None
167
- self._viewport = None
168
- self._camera = None
169
139
  logger.info("gRPC channel closed")
170
140
 
171
141
  def is_connected(self) -> bool:
@@ -209,8 +179,6 @@ class LuckyEngineClient:
209
179
  Returns:
210
180
  True if server became available, False if timeout.
211
181
  """
212
- import time
213
-
214
182
  start = time.perf_counter()
215
183
 
216
184
  while time.perf_counter() - start < timeout:
@@ -228,8 +196,6 @@ class LuckyEngineClient:
228
196
 
229
197
  return False
230
198
 
231
- # --- Protobuf modules (discoverable + explicit) ---
232
-
233
199
  @property
234
200
  def pb(self) -> Any:
235
201
  """Access protobuf modules grouped by domain (e.g., `client.pb.scene`)."""
@@ -244,21 +210,9 @@ class LuckyEngineClient:
244
210
  """Set the default robot name used by calls that accept an optional robot_name."""
245
211
  self._robot_name = robot_name
246
212
 
247
- # --- Service stubs ---
248
- #
249
- # Confirmed working:
250
- # - mujoco: GetMujocoInfo, GetJointState, SendControl
251
- # - agent: GetObservation, ResetAgent, GetAgentSchema, StreamAgent
252
- #
253
- # Placeholders (not yet confirmed working - use at your own risk):
254
- # - scene: GetSceneInfo
255
- # - telemetry: StreamTelemetry
256
- # - viewport: (no methods implemented)
257
- # - camera: ListCameras, StreamCamera
258
-
259
213
  @property
260
214
  def scene(self) -> Any:
261
- """SceneService stub. [PLACEHOLDER - not confirmed working]"""
215
+ """SceneService stub."""
262
216
  if self._scene is None:
263
217
  raise GrpcConnectionError("Not connected. Call connect() first.")
264
218
  return self._scene
@@ -270,13 +224,6 @@ class LuckyEngineClient:
270
224
  raise GrpcConnectionError("Not connected. Call connect() first.")
271
225
  return self._mujoco
272
226
 
273
- @property
274
- def telemetry(self) -> Any:
275
- """TelemetryService stub. [PLACEHOLDER - not confirmed working]"""
276
- if self._telemetry is None:
277
- raise GrpcConnectionError("Not connected. Call connect() first.")
278
- return self._telemetry
279
-
280
227
  @property
281
228
  def agent(self) -> Any:
282
229
  """AgentService stub."""
@@ -284,81 +231,17 @@ class LuckyEngineClient:
284
231
  raise GrpcConnectionError("Not connected. Call connect() first.")
285
232
  return self._agent
286
233
 
287
- @property
288
- def viewport(self) -> Any:
289
- """ViewportService stub. [PLACEHOLDER - not confirmed working]"""
290
- if self._viewport is None:
291
- raise GrpcConnectionError("Not connected. Call connect() first.")
292
- return self._viewport
293
-
294
- @property
295
- def camera(self) -> Any:
296
- """CameraService stub. [PLACEHOLDER - not confirmed working]"""
297
- if self._camera is None:
298
- raise GrpcConnectionError("Not connected. Call connect() first.")
299
- return self._camera
300
-
301
- # --- Convenience methods ---
302
-
303
- def get_scene_info(self, timeout: Optional[float] = None):
304
- """Get scene information. [PLACEHOLDER - not confirmed working]"""
305
- raise NotImplementedError(
306
- "get_scene_info() is not yet confirmed working. "
307
- "Remove this check if you want to test it."
308
- )
309
- timeout = timeout or self.timeout
310
- return self.scene.GetSceneInfo(
311
- self.pb.scene.GetSceneInfoRequest(),
312
- timeout=timeout,
313
- )
314
-
315
234
  def get_mujoco_info(self, robot_name: str = "", timeout: Optional[float] = None):
316
- """Get MuJoCo model information."""
235
+ """Get MuJoCo model information (joint names, limits, etc.)."""
317
236
  timeout = timeout or self.timeout
318
237
  robot_name = robot_name or self._robot_name
319
238
  if not robot_name:
320
- raise ValueError(
321
- "robot_name is required (pass `robot_name=` or set it once via "
322
- "LuckyEngineClient(robot_name=...) / client.set_robot_name(...))."
323
- )
239
+ raise ValueError("robot_name is required")
324
240
  return self.mujoco.GetMujocoInfo(
325
241
  self.pb.mujoco.GetMujocoInfoRequest(robot_name=robot_name),
326
242
  timeout=timeout,
327
243
  )
328
244
 
329
- def get_joint_state(self, robot_name: str = "", timeout: Optional[float] = None):
330
- """Get current joint state."""
331
- timeout = timeout or self.timeout
332
- robot_name = robot_name or self._robot_name
333
- if not robot_name:
334
- raise ValueError(
335
- "robot_name is required (pass `robot_name=` or set it once via "
336
- "LuckyEngineClient(robot_name=...) / client.set_robot_name(...))."
337
- )
338
- return self.mujoco.GetJointState(
339
- self.pb.mujoco.GetJointStateRequest(robot_name=robot_name),
340
- timeout=timeout,
341
- )
342
-
343
- def send_control(
344
- self,
345
- controls: list[float],
346
- robot_name: str = "",
347
- timeout: Optional[float] = None,
348
- ):
349
- """Send control commands to the robot."""
350
- timeout = timeout or self.timeout
351
- robot_name = robot_name or self._robot_name
352
- if not robot_name:
353
- raise ValueError(
354
- "robot_name is required (pass `robot_name=` or set it once via "
355
- "LuckyEngineClient(robot_name=...) / client.set_robot_name(...))."
356
- )
357
- return self.mujoco.SendControl(
358
- self.pb.mujoco.SendControlRequest(robot_name=robot_name, controls=controls),
359
- timeout=timeout,
360
- )
361
-
362
245
  def get_agent_schema(self, agent_name: str = "", timeout: Optional[float] = None):
363
246
  """Get agent schema (observation/action sizes and names).
364
247
 
@@ -395,18 +278,6 @@ class LuckyEngineClient:
395
278
 
396
279
  return resp
397
280
 
398
- def fetch_schema(self, agent_name: str = "", timeout: Optional[float] = None) -> None:
399
- """Fetch and cache agent schema for named observation access.
400
-
401
- Call this once before get_observation() to enable accessing observations
402
- by name (e.g., obs["proj_grav_x"]).
403
-
404
- Args:
405
- agent_name: Agent name (empty = default agent).
406
- timeout: RPC timeout.
407
- """
408
- self.get_agent_schema(agent_name=agent_name, timeout=timeout)
409
-
410
281
  def get_observation(
411
282
  self,
412
283
  agent_name: str = "",
@@ -415,10 +286,6 @@ class LuckyEngineClient:
415
286
  """
416
287
  Get the RL observation vector for an agent.
417
288
 
418
- This returns only the flat observation vector defined by the agent's
419
- observation spec in LuckyEngine. For sensor data (joints, telemetry,
420
- cameras), use the dedicated methods.
421
-
422
289
  Args:
423
290
  agent_name: Agent name (empty = default agent).
424
291
  timeout: RPC timeout.
@@ -435,30 +302,17 @@ class LuckyEngineClient:
435
302
  "LuckyEngineClient(robot_name=...) / client.set_robot_name(...))."
436
303
  )
437
304
 
438
- # Request only the agent's RL observation (agent_frame in gRPC terms).
439
- # Joint state and telemetry have their own dedicated methods.
440
305
  resp = self.agent.GetObservation(
441
306
  self.pb.agent.GetObservationRequest(
442
307
  robot_name=resolved_robot_name,
443
308
  agent_name=agent_name,
444
309
  include_joint_state=False,
445
- include_agent_frame=True, # The RL observation vector
310
+ include_agent_frame=True,
446
311
  include_telemetry=False,
447
312
  ),
448
313
  timeout=timeout,
449
314
  )
450
315
 
451
- # Extract observation from agent_frame.
452
- #
453
- # In gRPC, the "agent_frame" is the message containing the RL observation
454
- # data from LuckyEngine's agent system. It includes:
455
- # - observations: flat float vector matching the agent's observation spec
456
- # - actions: the last action vector sent to the agent (echoed back)
457
- # - timestamp_ms: wall-clock time when the observation was captured
458
- # - frame_number: monotonic counter for ordering observations
459
- #
460
- # This is distinct from joint_state (raw MuJoCo qpos/qvel) and telemetry
461
- # (debugging data like contact forces, energy, etc).
462
316
  agent_frame = getattr(resp, "agent_frame", None)
463
317
  observations = []
464
318
  actions = []
@@ -471,7 +325,6 @@ class LuckyEngineClient:
471
325
  timestamp_ms = getattr(agent_frame, "timestamp_ms", timestamp_ms)
472
326
  frame_number = getattr(agent_frame, "frame_number", frame_number)
473
327
 
474
- # Look up cached schema for named access
475
328
  cache_key = agent_name or "agent_0"
476
329
  obs_names, action_names = self._schema_cache.get(cache_key, (None, None))
477
330
 
@@ -485,163 +338,126 @@ class LuckyEngineClient:
485
338
  action_names=action_names,
486
339
  )
487
340
 
488
- def get_state(
341
+ def reset_agent(
489
342
  self,
490
343
  agent_name: str = "",
491
- include_observation: bool = True,
492
- include_joint_state: bool = True,
493
- camera_names: Optional[list[str]] = None,
494
- width: int = 0,
495
- height: int = 0,
496
- format: str = "raw",
344
+ randomization_cfg: Optional[Any] = None,
497
345
  timeout: Optional[float] = None,
498
- ) -> StateSnapshot:
346
+ ):
499
347
  """
500
- Get a bundled snapshot of multiple data sources.
501
-
502
- Use this for efficiency when you need multiple data types in one call.
503
- For single data types, prefer the dedicated methods:
504
- - get_observation() for RL observation vector
505
- - get_joint_state() for joint positions/velocities
506
- - stream_telemetry() for telemetry (streaming only)
507
- - stream_camera() for camera frames (streaming)
348
+ Reset a specific agent.
508
349
 
509
350
  Args:
510
- agent_name: Agent name (empty = default agent).
511
- include_observation: Include RL observation vector.
512
- include_joint_state: Include joint positions/velocities.
513
- camera_names: List of camera names to include frames from.
514
- width: Desired width for camera frames (0 = native).
515
- height: Desired height for camera frames (0 = native).
516
- format: Image format ("raw" or "jpeg").
517
- timeout: RPC timeout.
351
+ agent_name: Agent logical name. Empty string means default agent.
352
+ randomization_cfg: Optional domain randomization config for this reset.
353
+ timeout: Timeout in seconds (uses default if None).
518
354
 
519
355
  Returns:
520
- StateSnapshot with requested data.
356
+ ResetAgentResponse with success and message fields.
521
357
  """
522
358
  timeout = timeout or self.timeout
523
359
 
524
- resolved_robot_name = self._robot_name
525
- if not resolved_robot_name:
526
- raise ValueError(
527
- "robot_name is required (set it once via "
528
- "LuckyEngineClient(robot_name=...) / client.set_robot_name(...))."
529
- )
360
+ request_kwargs = {"agent_name": agent_name}
530
361
 
531
- cameras = []
532
- if camera_names:
533
- for name in camera_names:
534
- cameras.append(
535
- self.pb.agent.GetCameraFrameRequest(
536
- name=name,
537
- width=width,
538
- height=height,
539
- format=format,
540
- )
541
- )
362
+ if randomization_cfg is not None:
363
+ randomization_proto = self._randomization_to_proto(randomization_cfg)
364
+ request_kwargs["dr_config"] = randomization_proto
542
365
 
543
- resp = self.agent.GetObservation(
544
- self.pb.agent.GetObservationRequest(
545
- robot_name=resolved_robot_name,
546
- agent_name=agent_name,
547
- include_joint_state=include_joint_state,
548
- include_agent_frame=include_observation,
549
- include_telemetry=False, # Telemetry is streaming-only
550
- cameras=cameras,
551
- ),
366
+ return self.agent.ResetAgent(
367
+ self.pb.agent.ResetAgentRequest(**request_kwargs),
552
368
  timeout=timeout,
553
369
  )
554
370
 
555
- # Build ObservationResponse if requested
556
- obs_response = None
557
- if include_observation:
558
- agent_frame = getattr(resp, "agent_frame", None)
559
- observations = []
560
- actions = []
561
- if agent_frame is not None:
562
- observations = list(agent_frame.observations) if agent_frame.observations else []
563
- actions = list(agent_frame.actions) if agent_frame.actions else []
564
-
565
- # Look up cached schema for named access
566
- cache_key = agent_name or "agent_0"
567
- obs_names, action_names = self._schema_cache.get(cache_key, (None, None))
568
-
569
- obs_response = ObservationResponse(
570
- observation=observations,
571
- actions=actions,
572
- timestamp_ms=getattr(resp, "timestamp_ms", 0),
573
- frame_number=getattr(resp, "frame_number", 0),
574
- agent_name=cache_key,
575
- observation_names=obs_names,
576
- action_names=action_names,
577
- )
371
+ def step(
372
+ self,
373
+ actions: list[float],
374
+ agent_name: str = "",
375
+ timeout_ms: int = 0,
376
+ timeout: Optional[float] = None,
377
+ ) -> ObservationResponse:
378
+ """
379
+ Synchronous RL step: apply action, wait for physics, return observation.
578
380
 
579
- return StateSnapshot(
580
- observation=obs_response,
581
- joint_state=getattr(resp, "joint_state", None) if include_joint_state else None,
582
- camera_frames=list(getattr(resp, "camera_frames", [])) if camera_names else None,
583
- timestamp_ms=getattr(resp, "timestamp_ms", 0),
584
- frame_number=getattr(resp, "frame_number", 0),
585
- )
381
+ Args:
382
+ actions: Action vector to apply for this step.
383
+ agent_name: Agent name (empty = default agent).
384
+ timeout_ms: Server-side timeout for the step in milliseconds.
385
+ timeout: RPC timeout in seconds.
586
386
 
587
- def stream_agent(self, agent_name: str = "", target_fps: int = 30):
387
+ Returns:
388
+ ObservationResponse with observation after physics step.
588
389
  """
589
- Start streaming agent observations.
390
+ timeout = timeout or self.timeout
590
391
 
591
- Returns an iterator of AgentFrame messages.
592
- """
593
- return self.agent.StreamAgent(
594
- self.pb.agent.StreamAgentRequest(
595
- agent_name=agent_name, target_fps=target_fps
392
+ resp = self.agent.Step(
393
+ self.pb.agent.StepRequest(
394
+ agent_name=agent_name,
395
+ actions=actions,
396
+ timeout_ms=timeout_ms,
596
397
  ),
398
+ timeout=timeout,
597
399
  )
598
400
 
599
- def reset_agent(
401
+ if not resp.success:
402
+ raise RuntimeError(f"Step failed: {resp.message}")
403
+
404
+ agent_frame = resp.observation
405
+ observations = list(agent_frame.observations) if agent_frame.observations else []
406
+ actions_out = list(agent_frame.actions) if agent_frame.actions else []
407
+ timestamp_ms = getattr(agent_frame, "timestamp_ms", 0)
408
+ frame_number = getattr(agent_frame, "frame_number", 0)
409
+
410
+ cache_key = agent_name or "agent_0"
411
+ obs_names, action_names = self._schema_cache.get(cache_key, (None, None))
412
+
413
+ return ObservationResponse(
414
+ observation=observations,
415
+ actions=actions_out,
416
+ timestamp_ms=timestamp_ms,
417
+ frame_number=frame_number,
418
+ agent_name=cache_key,
419
+ observation_names=obs_names,
420
+ action_names=action_names,
421
+ )
422
+
423
+ def set_simulation_mode(
600
424
  self,
601
- agent_name: str = "",
602
- randomization_cfg: Optional[Any] = None,
425
+ mode: str = "fast",
603
426
  timeout: Optional[float] = None,
604
427
  ):
605
428
  """
606
- Reset a specific agent (full reset: clear buffers, reset state, resample commands, apply MuJoCo state).
607
-
608
- Useful for multi-env RL where individual agents need to be reset without resetting the entire scene.
429
+ Set simulation timing mode.
609
430
 
610
431
  Args:
611
- agent_name: Agent logical name. Convention is `agent_0`, `agent_1`, ...
612
- Empty string means "default agent" (agent_0).
613
- randomization_cfg: Optional domain randomization config for this reset.
614
- timeout: Timeout in seconds (uses default if None).
432
+ mode: "realtime", "deterministic", or "fast"
433
+ - realtime: Physics runs at 1x wall-clock speed
434
+ - deterministic: Physics runs at fixed rate
435
+ - fast: Physics runs as fast as possible (for RL training)
436
+ timeout: RPC timeout in seconds.
615
437
 
616
438
  Returns:
617
- ResetAgentResponse with success and message fields.
439
+ SetSimulationModeResponse with success and current mode.
618
440
  """
619
441
  timeout = timeout or self.timeout
620
442
 
621
- # Build request with optional randomization config
622
- request_kwargs = {"agent_name": agent_name}
443
+ mode_map = {
444
+ "realtime": 0,
445
+ "deterministic": 1,
446
+ "fast": 2,
447
+ }
448
+ mode_value = mode_map.get(mode.lower(), 2)
623
449
 
624
- if randomization_cfg is not None:
625
- randomization_proto = self._randomization_to_proto(randomization_cfg)
626
- request_kwargs["dr_config"] = randomization_proto
627
-
628
- return self.agent.ResetAgent(
629
- self.pb.agent.ResetAgentRequest(**request_kwargs),
450
+ return self.scene.SetSimulationMode(
451
+ self.pb.scene.SetSimulationModeRequest(mode=mode_value),
630
452
  timeout=timeout,
631
453
  )
632
454
 
633
455
  def _randomization_to_proto(self, randomization_cfg: Any):
634
- """Convert domain randomization config to proto message.
635
-
636
- Accepts any object with randomization config fields (DomainRandomizationConfig,
637
- PhysicsDRCfg from luckylab, or similar).
638
- """
456
+ """Convert domain randomization config to proto message."""
639
457
  proto_kwargs = {}
640
458
 
641
- # Helper to get attribute value, checking for None
642
459
  def get_val(name: str, default=None):
643
460
  val = getattr(randomization_cfg, name, default)
644
- # Handle both None and empty tuples/lists
645
461
  if val is None or (isinstance(val, (tuple, list)) and len(val) == 0):
646
462
  return None
647
463
  return val
@@ -663,7 +479,7 @@ class LuckyEngineClient:
663
479
  if joint_vel is not None and joint_vel != 0.0:
664
480
  proto_kwargs["joint_velocity_noise"] = joint_vel
665
481
 
666
- # Physics parameters (ranges)
482
+ # Physics parameters
667
483
  friction = get_val("friction_range")
668
484
  if friction is not None:
669
485
  proto_kwargs["friction_range"] = list(friction)
@@ -708,187 +524,3 @@ class LuckyEngineClient:
708
524
  proto_kwargs["terrain_difficulty"] = terrain_diff
709
525
 
710
526
  return self.pb.agent.DomainRandomizationConfig(**proto_kwargs)
711
-
712
- def stream_telemetry(self, target_fps: int = 30):
713
- """
714
- Start streaming telemetry data. [PLACEHOLDER - not confirmed working]
715
-
716
- Returns an iterator of TelemetryFrame messages.
717
- """
718
- raise NotImplementedError(
719
- "stream_telemetry() is not yet confirmed working. "
720
- "Remove this check if you want to test it."
721
- )
722
- return self.telemetry.StreamTelemetry(
723
- self.pb.telemetry.StreamTelemetryRequest(target_fps=target_fps),
724
- )
725
-
726
- def list_cameras(self, timeout: Optional[float] = None):
727
- """List available cameras. [PLACEHOLDER - not confirmed working]"""
728
- raise NotImplementedError(
729
- "list_cameras() is not yet confirmed working. "
730
- "Remove this check if you want to test it."
731
- )
732
- timeout = timeout or self.timeout
733
- return self.camera.ListCameras(
734
- self.pb.camera.ListCamerasRequest(),
735
- timeout=timeout,
736
- )
737
-
738
- def stream_camera(
739
- self,
740
- camera_id: Optional[int] = None,
741
- camera_name: Optional[str] = None,
742
- target_fps: int = 30,
743
- width: int = 640,
744
- height: int = 480,
745
- format: str = "raw",
746
- ):
747
- """
748
- Start streaming camera frames. [PLACEHOLDER - not confirmed working]
749
-
750
- Args:
751
- camera_id: Camera entity ID (use either id or name).
752
- camera_name: Camera name (use either id or name).
753
- target_fps: Desired frames per second.
754
- width: Desired width (0 = native).
755
- height: Desired height (0 = native).
756
- format: Image format ("raw" or "jpeg").
757
-
758
- Returns an iterator of ImageFrame messages.
759
- """
760
- raise NotImplementedError(
761
- "stream_camera() is not yet confirmed working. "
762
- "Remove this check if you want to test it."
763
- )
764
- if camera_id is not None:
765
- request = self.pb.camera.StreamCameraRequest(
766
- id=self.pb.common.EntityId(id=camera_id),
767
- target_fps=target_fps,
768
- width=width,
769
- height=height,
770
- format=format,
771
- )
772
- elif camera_name is not None:
773
- request = self.pb.camera.StreamCameraRequest(
774
- name=camera_name,
775
- target_fps=target_fps,
776
- width=width,
777
- height=height,
778
- format=format,
779
- )
780
- else:
781
- raise ValueError("Either camera_id or camera_name must be provided")
782
-
783
- return self.camera.StreamCamera(request)
784
-
785
- def benchmark(
786
- self,
787
- duration_seconds: float = 5.0,
788
- method: str = "get_observation",
789
- print_results: bool = True,
790
- ) -> "BenchmarkResult":
791
- """
792
- Benchmark gRPC performance.
793
-
794
- Measures actual FPS and latency for observation calls.
795
-
796
- Args:
797
- duration_seconds: How long to run the benchmark.
798
- method: Which method to benchmark ("get_observation" or "stream_agent").
799
- print_results: Whether to print results to console.
800
-
801
- Returns:
802
- BenchmarkResult with FPS and latency statistics.
803
- """
804
- if method not in ("get_observation", "stream_agent"):
805
- raise ValueError(f"Unknown method: {method}. Use 'get_observation' or 'stream_agent'.")
806
-
807
- latencies: list[float] = []
808
- frame_times: list[float] = []
809
- frame_count = 0
810
- start_time = time.perf_counter()
811
- last_frame_time = start_time
812
-
813
- if method == "get_observation":
814
- while time.perf_counter() - start_time < duration_seconds:
815
- call_start = time.perf_counter()
816
- self.get_observation()
817
- call_end = time.perf_counter()
818
-
819
- latencies.append((call_end - call_start) * 1000) # ms
820
- frame_times.append(call_end - last_frame_time)
821
- last_frame_time = call_end
822
- frame_count += 1
823
-
824
- elif method == "stream_agent":
825
- stream = self.stream_agent(target_fps=1000) # Request max FPS
826
- for frame in stream:
827
- now = time.perf_counter()
828
- if now - start_time >= duration_seconds:
829
- break
830
-
831
- frame_times.append(now - last_frame_time)
832
- last_frame_time = now
833
- frame_count += 1
834
-
835
- total_time = time.perf_counter() - start_time
836
-
837
- # Calculate statistics
838
- actual_fps = frame_count / total_time if total_time > 0 else 0
839
-
840
- if len(frame_times) > 1:
841
- # Remove first frame (warmup)
842
- frame_times = frame_times[1:]
843
- fps_from_frames = 1.0 / statistics.mean(frame_times) if frame_times else 0
844
- else:
845
- fps_from_frames = actual_fps
846
-
847
- result = BenchmarkResult(
848
- method=method,
849
- duration_seconds=total_time,
850
- frame_count=frame_count,
851
- actual_fps=actual_fps,
852
- avg_latency_ms=statistics.mean(latencies) if latencies else 0,
853
- min_latency_ms=min(latencies) if latencies else 0,
854
- max_latency_ms=max(latencies) if latencies else 0,
855
- std_latency_ms=statistics.stdev(latencies) if len(latencies) > 1 else 0,
856
- p50_latency_ms=statistics.median(latencies) if latencies else 0,
857
- p99_latency_ms=sorted(latencies)[int(len(latencies) * 0.99)] if latencies else 0,
858
- )
859
-
860
- if print_results:
861
- print(f"\n{'=' * 50}")
862
- print(f"Benchmark Results ({method})")
863
- print(f"{'=' * 50}")
864
- print(f"Duration: {result.duration_seconds:.2f}s")
865
- print(f"Frames: {result.frame_count}")
866
- print(f"Actual FPS: {result.actual_fps:.1f}")
867
- if latencies:
868
- print(f"{'─' * 50}")
869
- print(f"Latency (ms):")
870
- print(f" avg: {result.avg_latency_ms:.2f}")
871
- print(f" min: {result.min_latency_ms:.2f}")
872
- print(f" max: {result.max_latency_ms:.2f}")
873
- print(f" std: {result.std_latency_ms:.2f}")
874
- print(f" p50: {result.p50_latency_ms:.2f}")
875
- print(f" p99: {result.p99_latency_ms:.2f}")
876
- print(f"{'=' * 50}\n")
877
-
878
- return result
879
-
880
-
881
- @dataclass
882
- class BenchmarkResult:
883
- """Results from a benchmark run."""
884
-
885
- method: str
886
- duration_seconds: float
887
- frame_count: int
888
- actual_fps: float
889
- avg_latency_ms: float
890
- min_latency_ms: float
891
- max_latency_ms: float
892
- std_latency_ms: float
893
- p50_latency_ms: float
894
- p99_latency_ms: float