antioch-py 2.0.6__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.

Potentially problematic release.


This version of antioch-py might be problematic. Click here for more details.

Files changed (99) hide show
  1. antioch/__init__.py +0 -0
  2. antioch/message.py +87 -0
  3. antioch/module/__init__.py +53 -0
  4. antioch/module/clock.py +62 -0
  5. antioch/module/execution.py +278 -0
  6. antioch/module/input.py +127 -0
  7. antioch/module/module.py +218 -0
  8. antioch/module/node.py +357 -0
  9. antioch/module/token.py +42 -0
  10. antioch/session/__init__.py +150 -0
  11. antioch/session/ark.py +504 -0
  12. antioch/session/asset.py +65 -0
  13. antioch/session/error.py +80 -0
  14. antioch/session/record.py +158 -0
  15. antioch/session/scene.py +1521 -0
  16. antioch/session/session.py +220 -0
  17. antioch/session/task.py +323 -0
  18. antioch/session/views/__init__.py +40 -0
  19. antioch/session/views/animation.py +189 -0
  20. antioch/session/views/articulation.py +245 -0
  21. antioch/session/views/basis_curve.py +186 -0
  22. antioch/session/views/camera.py +92 -0
  23. antioch/session/views/collision.py +75 -0
  24. antioch/session/views/geometry.py +74 -0
  25. antioch/session/views/ground_plane.py +63 -0
  26. antioch/session/views/imu.py +73 -0
  27. antioch/session/views/joint.py +64 -0
  28. antioch/session/views/light.py +175 -0
  29. antioch/session/views/pir_sensor.py +140 -0
  30. antioch/session/views/radar.py +73 -0
  31. antioch/session/views/rigid_body.py +282 -0
  32. antioch/session/views/xform.py +119 -0
  33. antioch_py-2.0.6.dist-info/METADATA +115 -0
  34. antioch_py-2.0.6.dist-info/RECORD +99 -0
  35. antioch_py-2.0.6.dist-info/WHEEL +5 -0
  36. antioch_py-2.0.6.dist-info/entry_points.txt +2 -0
  37. antioch_py-2.0.6.dist-info/top_level.txt +2 -0
  38. common/__init__.py +0 -0
  39. common/ark/__init__.py +60 -0
  40. common/ark/ark.py +128 -0
  41. common/ark/hardware.py +121 -0
  42. common/ark/kinematics.py +31 -0
  43. common/ark/module.py +85 -0
  44. common/ark/node.py +94 -0
  45. common/ark/scheduler.py +439 -0
  46. common/ark/sim.py +33 -0
  47. common/assets/__init__.py +3 -0
  48. common/constants.py +47 -0
  49. common/core/__init__.py +52 -0
  50. common/core/agent.py +296 -0
  51. common/core/auth.py +305 -0
  52. common/core/registry.py +331 -0
  53. common/core/task.py +36 -0
  54. common/message/__init__.py +59 -0
  55. common/message/annotation.py +89 -0
  56. common/message/array.py +500 -0
  57. common/message/base.py +517 -0
  58. common/message/camera.py +91 -0
  59. common/message/color.py +139 -0
  60. common/message/frame.py +50 -0
  61. common/message/image.py +171 -0
  62. common/message/imu.py +14 -0
  63. common/message/joint.py +47 -0
  64. common/message/log.py +31 -0
  65. common/message/pir.py +16 -0
  66. common/message/point.py +109 -0
  67. common/message/point_cloud.py +63 -0
  68. common/message/pose.py +148 -0
  69. common/message/quaternion.py +273 -0
  70. common/message/radar.py +58 -0
  71. common/message/types.py +37 -0
  72. common/message/vector.py +786 -0
  73. common/rome/__init__.py +9 -0
  74. common/rome/client.py +430 -0
  75. common/rome/error.py +16 -0
  76. common/session/__init__.py +54 -0
  77. common/session/environment.py +31 -0
  78. common/session/sim.py +240 -0
  79. common/session/views/__init__.py +263 -0
  80. common/session/views/animation.py +73 -0
  81. common/session/views/articulation.py +184 -0
  82. common/session/views/basis_curve.py +102 -0
  83. common/session/views/camera.py +147 -0
  84. common/session/views/collision.py +59 -0
  85. common/session/views/geometry.py +102 -0
  86. common/session/views/ground_plane.py +41 -0
  87. common/session/views/imu.py +66 -0
  88. common/session/views/joint.py +81 -0
  89. common/session/views/light.py +96 -0
  90. common/session/views/pir_sensor.py +115 -0
  91. common/session/views/radar.py +82 -0
  92. common/session/views/rigid_body.py +236 -0
  93. common/session/views/viewport.py +21 -0
  94. common/session/views/xform.py +39 -0
  95. common/utils/__init__.py +4 -0
  96. common/utils/comms.py +571 -0
  97. common/utils/logger.py +123 -0
  98. common/utils/time.py +42 -0
  99. common/utils/usd.py +12 -0
common/utils/comms.py ADDED
@@ -0,0 +1,571 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Callable
5
+ from threading import Event
6
+ from typing import Any, TypeVar
7
+
8
+ import zenoh
9
+
10
+ from common.message import Message
11
+
12
+ DEFAULT_COMMS_SUBSCRIBER_RING_DEPTH = 4096
13
+
14
+ T = TypeVar("T", bound=Message)
15
+
16
+
17
+ class CommsSession:
18
+ """
19
+ Lightweight, synchronous communication session built on top of Zenoh.
20
+
21
+ This session provides send/publish operations, subscriber creation, and
22
+ advanced features like queryables and direct publisher management.
23
+ """
24
+
25
+ def __init__(self):
26
+ """
27
+ Create a new communication session.
28
+ """
29
+
30
+ # Get router endpoint from environment or use localhost default
31
+ router_endpoint = os.environ.get("ZENOH_ROUTER_ENDPOINT", "tcp/127.0.0.1:7447")
32
+
33
+ self.config = zenoh.Config()
34
+ self.config.insert_json5("mode", '"peer"')
35
+ self.config.insert_json5("listen/endpoints", '["tcp/0.0.0.0:0"]')
36
+ self.config.insert_json5("scouting/multicast/enabled", "false")
37
+ self.config.insert_json5("connect/endpoints", f'["{router_endpoint}"]') # Discovery via router
38
+ self.session = zenoh.open(self.config)
39
+
40
+ def __del__(self) -> None:
41
+ """
42
+ Ensure the session is closed when garbage collected.
43
+ """
44
+
45
+ self.close()
46
+
47
+ def __enter__(self) -> CommsSession:
48
+ """
49
+ Enter the context manager.
50
+ """
51
+
52
+ return self
53
+
54
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
55
+ """
56
+ Exit the context manager and close the session.
57
+ """
58
+
59
+ self.close()
60
+
61
+ def get_session_info(self) -> dict[str, Any]:
62
+ """
63
+ Get information about the session.
64
+
65
+ :return: Information about the Zenoh session.
66
+ """
67
+
68
+ return {
69
+ "zid": self.session.info.zid(),
70
+ "routers": self.session.info.routers_zid(),
71
+ "peers": self.session.info.peers_zid(),
72
+ }
73
+
74
+ def declare_subscriber(self, path: str) -> CommsSubscriber:
75
+ """
76
+ Create a new subscriber for the given path.
77
+
78
+ :param path: The path to subscribe to.
79
+ :return: A new CommsSubscriber instance.
80
+ """
81
+
82
+ return CommsSubscriber(self.session, path)
83
+
84
+ def declare_async_subscriber(self, path: str) -> CommsAsyncSubscriber:
85
+ """
86
+ Create a new async subscriber that uses callbacks for event-driven reception.
87
+
88
+ :param path: The path to subscribe to.
89
+ :return: A new CommsAsyncSubscriber instance.
90
+ """
91
+
92
+ return CommsAsyncSubscriber(self.session, path)
93
+
94
+ def declare_callback_subscriber(
95
+ self,
96
+ path: str,
97
+ callback: Callable[[zenoh.Sample], None],
98
+ ) -> zenoh.Subscriber[None]:
99
+ """
100
+ Create a new callback subscriber for the given path.
101
+
102
+ :param path: The path to subscribe to.
103
+ :param callback: Callback function to handle incoming messages.
104
+ :return: A new zenoh.Subscriber instance.
105
+ """
106
+
107
+ return self.session.declare_subscriber(path, callback)
108
+
109
+ def declare_queryable(
110
+ self,
111
+ path: str,
112
+ complete: bool = True,
113
+ ) -> CommsQueryable:
114
+ """
115
+ Create a synchronous queryable endpoint for request-response patterns.
116
+
117
+ Returns a queryable wrapper that provides a clean API for receiving queries.
118
+
119
+ :param path: The path pattern to respond to queries on.
120
+ :param complete: Whether this queryable provides complete information.
121
+ :return: A new CommsQueryable instance.
122
+ """
123
+
124
+ return CommsQueryable(self.session, path, complete)
125
+
126
+ def declare_callback_queryable(
127
+ self,
128
+ path: str,
129
+ callback: Callable[[zenoh.Query], None],
130
+ complete: bool = True,
131
+ ) -> zenoh.Queryable[None]:
132
+ """
133
+ Create a callback-based queryable for async request-response patterns.
134
+
135
+ The callback is invoked by Zenoh automatically for each query, allowing
136
+ asynchronous handling without manual recv() calls.
137
+
138
+ :param path: The path pattern to respond to queries on.
139
+ :param callback: Callback function that receives zenoh.Query objects directly.
140
+ :param complete: Whether this queryable provides complete information.
141
+ :return: A new zenoh.Queryable instance.
142
+ """
143
+
144
+ return self.session.declare_queryable(path, callback, complete=complete)
145
+
146
+ def declare_publisher(self, path: str) -> CommsPublisher:
147
+ """
148
+ Create a dedicated publisher for a path.
149
+
150
+ :param path: The path to publish to.
151
+ :return: A CommsPublisher instance.
152
+ """
153
+
154
+ return CommsPublisher(self.session, path)
155
+
156
+ def query(
157
+ self,
158
+ path: str,
159
+ response_type: type[T],
160
+ request: Message | bytes | None = None,
161
+ target: zenoh.QueryTarget = zenoh.QueryTarget.ALL,
162
+ timeout: float = 10.0,
163
+ ) -> T:
164
+ """
165
+ Send a typed query and wait for the first response.
166
+
167
+ :param path: The path to query.
168
+ :param response_type: The expected message type class for the response.
169
+ :param request: Optional request message to send with the query.
170
+ :param target: The target of the query.
171
+ :param timeout: Maximum time to wait for a reply in seconds.
172
+ :return: The first successful response message.
173
+ :raises TimeoutError: When the query times out.
174
+ """
175
+
176
+ payload = None
177
+ if request is not None:
178
+ if isinstance(request, Message):
179
+ request = request.pack()
180
+ payload = request
181
+
182
+ # Send query with payload
183
+ replies = self.session.get(
184
+ path,
185
+ target=target,
186
+ payload=payload,
187
+ timeout=timeout,
188
+ consolidation=zenoh.ConsolidationMode.NONE,
189
+ congestion_control=zenoh.CongestionControl.DROP,
190
+ )
191
+
192
+ # Get first valid response
193
+ for reply in replies:
194
+ result = reply.result
195
+ if isinstance(result, zenoh.ReplyError):
196
+ error = result.payload.to_string()
197
+ if "timeout" in error.lower():
198
+ raise TimeoutError(f"Query {path} timed out")
199
+ raise RuntimeError(f"Query {path} failed: {error}")
200
+ return response_type.unpack(result.payload.to_bytes())
201
+
202
+ # Raise error if no response was received
203
+ raise RuntimeError(f"No response received for query {path}")
204
+
205
+ def close(self) -> None:
206
+ """
207
+ Close the communication session.
208
+ """
209
+
210
+ if not self.session.is_closed():
211
+ self.session.close()
212
+
213
+
214
+ class CommsSubscriber:
215
+ """
216
+ Communication subscriber for handling message reception.
217
+
218
+ Provides methods for receiving messages from a specific communication path.
219
+ Each subscriber operates independently with its own message buffer.
220
+ """
221
+
222
+ def __init__(self, session: zenoh.Session, path: str) -> None:
223
+ """
224
+ Initialize the subscriber.
225
+
226
+ :param session: The Zenoh session.
227
+ :param path: The path to subscribe to.
228
+ """
229
+
230
+ handler = zenoh.handlers.RingChannel(DEFAULT_COMMS_SUBSCRIBER_RING_DEPTH)
231
+ self._subscriber = session.declare_subscriber(path, handler)
232
+
233
+ def recv(self, message_cls: type[T]) -> T:
234
+ """
235
+ Receive the next message, blocking until one is available.
236
+
237
+ :param message_cls: The expected message type class.
238
+ :return: The deserialized message.
239
+ """
240
+
241
+ sample = self._subscriber.handler.recv()
242
+ data = sample.payload.to_bytes()
243
+ return message_cls.unpack(data)
244
+
245
+ def recv_latest(self, message_cls: type[T]) -> T:
246
+ """
247
+ Drain all messages and return the latest one, blocking if none available.
248
+
249
+ This method prevents the "drain bug" by ensuring we always get the most
250
+ recent message, discarding any stale messages that may have accumulated.
251
+
252
+ :param message_cls: The expected message type class.
253
+ :return: The latest deserialized message.
254
+ """
255
+
256
+ latest_sample = None
257
+
258
+ # First, drain all available messages without blocking
259
+ # We don't deserialize here - just keep the raw samples
260
+ while True:
261
+ sample = self._subscriber.handler.try_recv()
262
+ if sample is None:
263
+ break
264
+ latest_sample = sample
265
+
266
+ # If we have a latest sample, deserialize and return it
267
+ if latest_sample is not None:
268
+ data = latest_sample.payload.to_bytes()
269
+ return message_cls.unpack(data)
270
+
271
+ # Otherwise, wait for a new message
272
+ return self.recv(message_cls)
273
+
274
+ def try_recv(self, message_cls: type[T]) -> T | None:
275
+ """
276
+ Try to receive a message without blocking.
277
+
278
+ :param message_cls: The expected message type class.
279
+ :return: The deserialized message if available, None otherwise.
280
+ """
281
+
282
+ sample = self._subscriber.handler.try_recv()
283
+ if sample is None:
284
+ return None
285
+
286
+ data = sample.payload.to_bytes()
287
+ return message_cls.unpack(data)
288
+
289
+ def try_recv_sample(self) -> zenoh.Sample | None:
290
+ """
291
+ Try to receive a raw sample without blocking.
292
+
293
+ :return: The raw Zenoh sample if available, None otherwise.
294
+ """
295
+
296
+ return self._subscriber.handler.try_recv()
297
+
298
+ def try_recv_latest(self, message_cls: type[T]) -> T | None:
299
+ """
300
+ Try to drain all messages and return the latest one without blocking.
301
+
302
+ This is the non-blocking version of recv_latest. It prevents the "drain bug"
303
+ by ensuring we always get the most recent message if any are available.
304
+
305
+ :param message_cls: The expected message type class.
306
+ :return: The latest deserialized message if available, None otherwise.
307
+ """
308
+
309
+ latest_sample = None
310
+
311
+ # Drain all available messages without blocking
312
+ # We don't deserialize here - just keep the raw samples
313
+ while True:
314
+ sample = self._subscriber.handler.try_recv()
315
+ if sample is None:
316
+ break
317
+ latest_sample = sample
318
+
319
+ # If we have a latest sample, deserialize and return it
320
+ if latest_sample is not None:
321
+ data = latest_sample.payload.to_bytes()
322
+ return message_cls.unpack(data)
323
+
324
+ return None
325
+
326
+ def drain(self) -> bool:
327
+ """
328
+ Drain all queued messages without processing them.
329
+
330
+ :return: True if any messages were drained, False otherwise.
331
+ """
332
+
333
+ drained_any = False
334
+ while self._subscriber.handler.try_recv() is not None:
335
+ drained_any = True
336
+
337
+ return drained_any
338
+
339
+ def close(self) -> None:
340
+ """
341
+ Close the subscriber and release resources.
342
+ """
343
+
344
+ self._subscriber.undeclare()
345
+
346
+
347
+ class CommsAsyncSubscriber:
348
+ """
349
+ Async subscriber that uses callbacks for event-driven message reception.
350
+
351
+ Provides blocking recv with timeout and automatic wake-up on message arrival.
352
+ Useful for event-driven loops that need to respond to both messages and shutdown events.
353
+ """
354
+
355
+ def __init__(self, session: zenoh.Session, path: str) -> None:
356
+ """
357
+ Initialize the async subscriber.
358
+
359
+ :param session: The Zenoh session.
360
+ :param path: The path to subscribe to.
361
+ """
362
+
363
+ self._message_available = Event()
364
+ self._latest_sample: zenoh.Sample | None = None
365
+ self._subscriber = session.declare_subscriber(path, self._on_message)
366
+
367
+ def recv_timeout(self, message_cls: type[T], timeout: float | None = None) -> T | None:
368
+ """
369
+ Receive a message with optional timeout.
370
+
371
+ Blocks until a message arrives or timeout expires. Uses event-driven
372
+ approach to wake up immediately when messages arrive.
373
+
374
+ :param message_cls: The expected message type class.
375
+ :param timeout: Timeout in seconds (None for infinite).
376
+ :return: The deserialized message if available, None on timeout.
377
+ """
378
+
379
+ # Wait for message with timeout
380
+ if not self._message_available.wait(timeout):
381
+ return None
382
+ if self._latest_sample is None:
383
+ raise RuntimeError("No message available")
384
+
385
+ # Get the message and reset event
386
+ data = self._latest_sample.payload.to_bytes()
387
+ self._latest_sample = None
388
+ self._message_available.clear()
389
+ return message_cls.unpack(data)
390
+
391
+ def close(self) -> None:
392
+ """
393
+ Close the subscriber and release resources.
394
+ """
395
+
396
+ self._subscriber.undeclare()
397
+
398
+ def _on_message(self, sample: zenoh.Sample) -> None:
399
+ """
400
+ Callback invoked when a message arrives.
401
+
402
+ :param sample: The received sample.
403
+ """
404
+
405
+ self._latest_sample = sample
406
+ self._message_available.set()
407
+
408
+
409
+ class CommsPublisher:
410
+ """
411
+ Dedicated publisher for efficient message publication to a single path.
412
+ """
413
+
414
+ def __init__(self, session: zenoh.Session, path: str) -> None:
415
+ """
416
+ Initialize the publisher.
417
+
418
+ :param session: The Zenoh session.
419
+ :param path: The path to publish to.
420
+ """
421
+
422
+ self._publisher = session.declare_publisher(
423
+ path,
424
+ congestion_control=zenoh.CongestionControl.DROP,
425
+ reliability=zenoh.Reliability.BEST_EFFORT,
426
+ )
427
+
428
+ def publish(self, data: bytes | Message) -> None:
429
+ """
430
+ Publish data to the publisher's path.
431
+
432
+ :param data: Raw bytes or a Message that will be serialized.
433
+ """
434
+
435
+ if isinstance(data, Message):
436
+ data = data.pack()
437
+ self._publisher.put(data)
438
+
439
+ def close(self) -> None:
440
+ """
441
+ Close the publisher and release resources.
442
+ """
443
+
444
+ self._publisher.undeclare()
445
+
446
+
447
+ class Query:
448
+ """
449
+ Communication query for handling response to a query request.
450
+
451
+ Important: make sure you call drop() (or use the context manager) to make sure
452
+ the query is finalized!
453
+ """
454
+
455
+ def __init__(self, query: zenoh.Query) -> None:
456
+ """
457
+ Initialize the query.
458
+
459
+ :param query: The underlying Zenoh query.
460
+ """
461
+
462
+ self._query = query
463
+
464
+ def __enter__(self) -> Query:
465
+ """
466
+ Enter the context manager.
467
+ """
468
+
469
+ self._query.__enter__()
470
+ return self
471
+
472
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
473
+ """
474
+ Exit the context manager and drop the query.
475
+ """
476
+
477
+ self._query.__exit__(exc_type, exc_val, exc_tb)
478
+
479
+ @property
480
+ def key_expr(self) -> str:
481
+ """
482
+ Get the key expression of the query.
483
+
484
+ :return: The key expression.
485
+ """
486
+
487
+ return str(self._query.key_expr)
488
+
489
+ def payload(self, message_cls: type[T]) -> T:
490
+ """
491
+ Get the payload of the query.
492
+
493
+ :param message_cls: The message type to unpack the payload into.
494
+ :return: The unpacked message.
495
+ :raises RuntimeError: If the query has no payload.
496
+ """
497
+
498
+ if self._query.payload is None:
499
+ raise RuntimeError("Query has no payload")
500
+ return message_cls.unpack(self._query.payload.to_bytes())
501
+
502
+ def reply(self, data: Message | bytes) -> None:
503
+ """
504
+ Send a reply to the query.
505
+
506
+ :param data: Response data. Raw bytes or a Message that will be serialized.
507
+ """
508
+
509
+ if isinstance(data, Message):
510
+ data = data.pack()
511
+ self._query.reply(
512
+ self._query.key_expr,
513
+ payload=data,
514
+ congestion_control=zenoh.CongestionControl.DROP,
515
+ )
516
+
517
+ def drop(self) -> None:
518
+ """
519
+ Drop the query.
520
+ """
521
+
522
+ self._query.drop()
523
+
524
+
525
+ class CommsQueryable:
526
+ """
527
+ Lightweight wrapper around a Zenoh queryable for request-response patterns.
528
+
529
+ Provides a clean API for receiving and processing queries without manual unwrapping.
530
+ """
531
+
532
+ def __init__(self, session: zenoh.Session, path: str, complete: bool = True) -> None:
533
+ """
534
+ Initialize the queryable wrapper.
535
+
536
+ :param session: The Zenoh session.
537
+ :param path: The path pattern to respond to queries on.
538
+ :param complete: Whether this queryable provides complete information.
539
+ """
540
+
541
+ handler = zenoh.handlers.RingChannel(DEFAULT_COMMS_SUBSCRIBER_RING_DEPTH)
542
+ self._queryable = session.declare_queryable(path, handler, complete=complete)
543
+
544
+ def recv(self) -> Query:
545
+ """
546
+ Receive the next query, blocking until one is available.
547
+
548
+ :return: The query wrapped in our Query helper.
549
+ """
550
+
551
+ zenoh_query = self._queryable.handler.recv()
552
+ return Query(zenoh_query)
553
+
554
+ def try_recv(self) -> Query | None:
555
+ """
556
+ Try to receive a query without blocking.
557
+
558
+ :return: The query wrapped in our Query helper, or None if no query available.
559
+ """
560
+
561
+ zenoh_query = self._queryable.handler.try_recv()
562
+ if zenoh_query is None:
563
+ return None
564
+ return Query(zenoh_query)
565
+
566
+ def close(self) -> None:
567
+ """
568
+ Close the queryable and release resources.
569
+ """
570
+
571
+ self._queryable.undeclare()
common/utils/logger.py ADDED
@@ -0,0 +1,123 @@
1
+ from common.message import Log, LogLevel, Message
2
+ from common.utils.comms import CommsSession
3
+
4
+
5
+ class Logger:
6
+ """
7
+ Logger that publishes structured logs to the communication system.
8
+ """
9
+
10
+ def __init__(
11
+ self,
12
+ comms: CommsSession,
13
+ base_channel: str | None = None,
14
+ debug: bool = False,
15
+ print_logs: bool = False,
16
+ ) -> None:
17
+ """
18
+ Initialize the logger.
19
+
20
+ :param comms: Comms session.
21
+ :param base_channel: Optional base channel for logs and telemetry.
22
+ :param debug: Whether to run in debug mode.
23
+ :param print_logs: Whether to print logs to stdout.
24
+ """
25
+
26
+ self._log_publisher = comms.declare_publisher("_logs")
27
+ self._base_channel = base_channel
28
+ self._debug = debug
29
+ self._print_logs = print_logs
30
+ self._let_us: int = 0
31
+
32
+ def set_let(self, let_us: int) -> None:
33
+ """
34
+ Set the logical execution time.
35
+
36
+ :param let_us: Logical execution time in microseconds.
37
+ """
38
+
39
+ self._let_us = let_us
40
+
41
+ def debug(self, message: str) -> None:
42
+ """
43
+ Log a debug message.
44
+
45
+ :param message: Log message.
46
+ """
47
+
48
+ if self._debug:
49
+ self._log(LogLevel.DEBUG, message)
50
+
51
+ def info(self, message: str) -> None:
52
+ """
53
+ Log an info message.
54
+
55
+ :param message: Log message.
56
+ """
57
+
58
+ self._log(LogLevel.INFO, message)
59
+
60
+ def warning(self, message: str) -> None:
61
+ """
62
+ Log a warning message.
63
+
64
+ :param message: Log message.
65
+ """
66
+
67
+ self._log(LogLevel.WARNING, message)
68
+
69
+ def error(self, message: str) -> None:
70
+ """
71
+ Log an error message.
72
+
73
+ :param message: Log message.
74
+ """
75
+
76
+ self._log(LogLevel.ERROR, message)
77
+
78
+ def telemetry(self, channel: str, telemetry: Message | dict) -> None:
79
+ """
80
+ Record telemetry data from a Message or a JSON-serializable dictionary.
81
+
82
+ :param channel: Telemetry channel (alphanumeric, underscore, hyphen, period, slash).
83
+ :param telemetry: The message or dict to record.
84
+ """
85
+
86
+ if not all(c.isalnum() or c in ("_", "-", ".", "/") for c in channel):
87
+ raise ValueError(f"Invalid channel format: {channel}")
88
+
89
+ # Pack telemetry data based on type
90
+ if isinstance(telemetry, Message):
91
+ packed_data = telemetry.pack()
92
+ elif isinstance(telemetry, dict):
93
+ packed_data = Message.pack_json(telemetry)
94
+
95
+ self._log_publisher.publish(
96
+ Log(
97
+ level=LogLevel.INFO,
98
+ message=None,
99
+ channel=f"{self._base_channel}/{channel}" if self._base_channel else channel,
100
+ let_us=self._let_us,
101
+ telemetry=packed_data,
102
+ )
103
+ )
104
+
105
+ def _log(self, level: LogLevel, message: str) -> None:
106
+ """
107
+ Send a log message through Zenoh.
108
+
109
+ :param level: The log level.
110
+ :param message: The log message.
111
+ """
112
+
113
+ self._log_publisher.publish(
114
+ Log(
115
+ level=level,
116
+ message=message,
117
+ channel=f"{self._base_channel}/logs" if self._base_channel else None,
118
+ let_us=self._let_us,
119
+ )
120
+ )
121
+
122
+ if self._print_logs:
123
+ print(f"[{level.value.upper()}] {message}")