loop-sdk 0.1.0__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,288 @@
1
+ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
2
+ """Client and server classes corresponding to protobuf-defined services."""
3
+ import grpc
4
+ import warnings
5
+
6
+ from ..v1 import source_ingest_pb2 as v1_dot_source__ingest__pb2
7
+
8
+ GRPC_GENERATED_VERSION = '1.81.1'
9
+ GRPC_VERSION = grpc.__version__
10
+ _version_not_supported = False
11
+
12
+ try:
13
+ from grpc._utilities import first_version_is_lower
14
+ _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
15
+ except ImportError:
16
+ _version_not_supported = True
17
+
18
+ if _version_not_supported:
19
+ raise RuntimeError(
20
+ f'The grpc package installed is at version {GRPC_VERSION},'
21
+ + ' but the generated code in v1/source_ingest_pb2_grpc.py depends on'
22
+ + f' grpcio>={GRPC_GENERATED_VERSION}.'
23
+ + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
24
+ + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
25
+ )
26
+
27
+
28
+ class SourceIngestServiceStub:
29
+ """SourceIngestService is the foundation-owned gRPC contract for remote source
30
+ clients (sensor/helper processes). It replaces the legacy
31
+ recorder.tactile.v1.TactileIngestService and generalizes it from tactile-only
32
+ to every remote source kind via per-kind descriptors. Cameras use this
33
+ contract for discovery/control; camera media frames are pulled over RTSP/RTP.
34
+
35
+ Connect - control plane: bidirectional stream. The client drives the
36
+ stream with ClientEvent; the server answers with
37
+ RecorderCommand. command_id correlates a command with the
38
+ event(s) the client emits in response.
39
+ StreamSamples - data plane: client-streaming. The client pushes SampleBatch
40
+ messages; the server acknowledges once with
41
+ StreamSamplesResponse.
42
+ """
43
+
44
+ def __init__(self, channel):
45
+ """Constructor.
46
+
47
+ Args:
48
+ channel: A grpc.Channel.
49
+ """
50
+ self.Connect = channel.stream_stream(
51
+ '/loop.foundation.source.v1.SourceIngestService/Connect',
52
+ request_serializer=v1_dot_source__ingest__pb2.ClientEvent.SerializeToString,
53
+ response_deserializer=v1_dot_source__ingest__pb2.RecorderCommand.FromString,
54
+ _registered_method=True)
55
+ self.StreamSamples = channel.stream_unary(
56
+ '/loop.foundation.source.v1.SourceIngestService/StreamSamples',
57
+ request_serializer=v1_dot_source__ingest__pb2.SampleBatch.SerializeToString,
58
+ response_deserializer=v1_dot_source__ingest__pb2.StreamSamplesResponse.FromString,
59
+ _registered_method=True)
60
+
61
+
62
+ class SourceIngestServiceServicer:
63
+ """SourceIngestService is the foundation-owned gRPC contract for remote source
64
+ clients (sensor/helper processes). It replaces the legacy
65
+ recorder.tactile.v1.TactileIngestService and generalizes it from tactile-only
66
+ to every remote source kind via per-kind descriptors. Cameras use this
67
+ contract for discovery/control; camera media frames are pulled over RTSP/RTP.
68
+
69
+ Connect - control plane: bidirectional stream. The client drives the
70
+ stream with ClientEvent; the server answers with
71
+ RecorderCommand. command_id correlates a command with the
72
+ event(s) the client emits in response.
73
+ StreamSamples - data plane: client-streaming. The client pushes SampleBatch
74
+ messages; the server acknowledges once with
75
+ StreamSamplesResponse.
76
+ """
77
+
78
+ def Connect(self, request_iterator, context):
79
+ """Missing associated documentation comment in .proto file."""
80
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
81
+ context.set_details('Method not implemented!')
82
+ raise NotImplementedError('Method not implemented!')
83
+
84
+ def StreamSamples(self, request_iterator, context):
85
+ """Missing associated documentation comment in .proto file."""
86
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
87
+ context.set_details('Method not implemented!')
88
+ raise NotImplementedError('Method not implemented!')
89
+
90
+
91
+ def add_SourceIngestServiceServicer_to_server(servicer, server):
92
+ rpc_method_handlers = {
93
+ 'Connect': grpc.stream_stream_rpc_method_handler(
94
+ servicer.Connect,
95
+ request_deserializer=v1_dot_source__ingest__pb2.ClientEvent.FromString,
96
+ response_serializer=v1_dot_source__ingest__pb2.RecorderCommand.SerializeToString,
97
+ ),
98
+ 'StreamSamples': grpc.stream_unary_rpc_method_handler(
99
+ servicer.StreamSamples,
100
+ request_deserializer=v1_dot_source__ingest__pb2.SampleBatch.FromString,
101
+ response_serializer=v1_dot_source__ingest__pb2.StreamSamplesResponse.SerializeToString,
102
+ ),
103
+ }
104
+ generic_handler = grpc.method_handlers_generic_handler(
105
+ 'loop.foundation.source.v1.SourceIngestService', rpc_method_handlers)
106
+ server.add_generic_rpc_handlers((generic_handler,))
107
+ server.add_registered_method_handlers('loop.foundation.source.v1.SourceIngestService', rpc_method_handlers)
108
+
109
+
110
+ # This class is part of an EXPERIMENTAL API.
111
+ class SourceIngestService:
112
+ """SourceIngestService is the foundation-owned gRPC contract for remote source
113
+ clients (sensor/helper processes). It replaces the legacy
114
+ recorder.tactile.v1.TactileIngestService and generalizes it from tactile-only
115
+ to every remote source kind via per-kind descriptors. Cameras use this
116
+ contract for discovery/control; camera media frames are pulled over RTSP/RTP.
117
+
118
+ Connect - control plane: bidirectional stream. The client drives the
119
+ stream with ClientEvent; the server answers with
120
+ RecorderCommand. command_id correlates a command with the
121
+ event(s) the client emits in response.
122
+ StreamSamples - data plane: client-streaming. The client pushes SampleBatch
123
+ messages; the server acknowledges once with
124
+ StreamSamplesResponse.
125
+ """
126
+
127
+ @staticmethod
128
+ def Connect(request_iterator,
129
+ target,
130
+ options=(),
131
+ channel_credentials=None,
132
+ call_credentials=None,
133
+ insecure=False,
134
+ compression=None,
135
+ wait_for_ready=None,
136
+ timeout=None,
137
+ metadata=None):
138
+ return grpc.experimental.stream_stream(
139
+ request_iterator,
140
+ target,
141
+ '/loop.foundation.source.v1.SourceIngestService/Connect',
142
+ v1_dot_source__ingest__pb2.ClientEvent.SerializeToString,
143
+ v1_dot_source__ingest__pb2.RecorderCommand.FromString,
144
+ options,
145
+ channel_credentials,
146
+ insecure,
147
+ call_credentials,
148
+ compression,
149
+ wait_for_ready,
150
+ timeout,
151
+ metadata,
152
+ _registered_method=True)
153
+
154
+ @staticmethod
155
+ def StreamSamples(request_iterator,
156
+ target,
157
+ options=(),
158
+ channel_credentials=None,
159
+ call_credentials=None,
160
+ insecure=False,
161
+ compression=None,
162
+ wait_for_ready=None,
163
+ timeout=None,
164
+ metadata=None):
165
+ return grpc.experimental.stream_unary(
166
+ request_iterator,
167
+ target,
168
+ '/loop.foundation.source.v1.SourceIngestService/StreamSamples',
169
+ v1_dot_source__ingest__pb2.SampleBatch.SerializeToString,
170
+ v1_dot_source__ingest__pb2.StreamSamplesResponse.FromString,
171
+ options,
172
+ channel_credentials,
173
+ insecure,
174
+ call_credentials,
175
+ compression,
176
+ wait_for_ready,
177
+ timeout,
178
+ metadata,
179
+ _registered_method=True)
180
+
181
+
182
+ class SourceReadServiceStub:
183
+ """---------------------------------------------------------------------------
184
+ Read plane — external consumers subscribe to a source's sample stream.
185
+
186
+ The mirror of ingest: ingest (SourceIngestService) is client->server (the
187
+ server is a sink); read is server->client (the server is a source). The
188
+ bus-side primitive already exists in-process as SourceSession.subscribe with
189
+ per-subscriber fan-out; this service is its network front for external clients
190
+ (e.g. a customer robot receiving actions an in-Loop control loop produces, or
191
+ a client pulling inference outputs published as a source).
192
+ ---------------------------------------------------------------------------
193
+
194
+ """
195
+
196
+ def __init__(self, channel):
197
+ """Constructor.
198
+
199
+ Args:
200
+ channel: A grpc.Channel.
201
+ """
202
+ self.Subscribe = channel.unary_stream(
203
+ '/loop.foundation.source.v1.SourceReadService/Subscribe',
204
+ request_serializer=v1_dot_source__ingest__pb2.SubscribeRequest.SerializeToString,
205
+ response_deserializer=v1_dot_source__ingest__pb2.SubscribeEvent.FromString,
206
+ _registered_method=True)
207
+
208
+
209
+ class SourceReadServiceServicer:
210
+ """---------------------------------------------------------------------------
211
+ Read plane — external consumers subscribe to a source's sample stream.
212
+
213
+ The mirror of ingest: ingest (SourceIngestService) is client->server (the
214
+ server is a sink); read is server->client (the server is a source). The
215
+ bus-side primitive already exists in-process as SourceSession.subscribe with
216
+ per-subscriber fan-out; this service is its network front for external clients
217
+ (e.g. a customer robot receiving actions an in-Loop control loop produces, or
218
+ a client pulling inference outputs published as a source).
219
+ ---------------------------------------------------------------------------
220
+
221
+ """
222
+
223
+ def Subscribe(self, request, context):
224
+ """Subscribe streams one already-open source's samples to an external client.
225
+ Data plane is per source_id (matching SampleBatch/subscribe). The source
226
+ must be open (a producer streaming it); an unopened/failed source is
227
+ reported via SubscribeEvent.state = STREAM_STATE_FAILED, not a hard error.
228
+ """
229
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
230
+ context.set_details('Method not implemented!')
231
+ raise NotImplementedError('Method not implemented!')
232
+
233
+
234
+ def add_SourceReadServiceServicer_to_server(servicer, server):
235
+ rpc_method_handlers = {
236
+ 'Subscribe': grpc.unary_stream_rpc_method_handler(
237
+ servicer.Subscribe,
238
+ request_deserializer=v1_dot_source__ingest__pb2.SubscribeRequest.FromString,
239
+ response_serializer=v1_dot_source__ingest__pb2.SubscribeEvent.SerializeToString,
240
+ ),
241
+ }
242
+ generic_handler = grpc.method_handlers_generic_handler(
243
+ 'loop.foundation.source.v1.SourceReadService', rpc_method_handlers)
244
+ server.add_generic_rpc_handlers((generic_handler,))
245
+ server.add_registered_method_handlers('loop.foundation.source.v1.SourceReadService', rpc_method_handlers)
246
+
247
+
248
+ # This class is part of an EXPERIMENTAL API.
249
+ class SourceReadService:
250
+ """---------------------------------------------------------------------------
251
+ Read plane — external consumers subscribe to a source's sample stream.
252
+
253
+ The mirror of ingest: ingest (SourceIngestService) is client->server (the
254
+ server is a sink); read is server->client (the server is a source). The
255
+ bus-side primitive already exists in-process as SourceSession.subscribe with
256
+ per-subscriber fan-out; this service is its network front for external clients
257
+ (e.g. a customer robot receiving actions an in-Loop control loop produces, or
258
+ a client pulling inference outputs published as a source).
259
+ ---------------------------------------------------------------------------
260
+
261
+ """
262
+
263
+ @staticmethod
264
+ def Subscribe(request,
265
+ target,
266
+ options=(),
267
+ channel_credentials=None,
268
+ call_credentials=None,
269
+ insecure=False,
270
+ compression=None,
271
+ wait_for_ready=None,
272
+ timeout=None,
273
+ metadata=None):
274
+ return grpc.experimental.unary_stream(
275
+ request,
276
+ target,
277
+ '/loop.foundation.source.v1.SourceReadService/Subscribe',
278
+ v1_dot_source__ingest__pb2.SubscribeRequest.SerializeToString,
279
+ v1_dot_source__ingest__pb2.SubscribeEvent.FromString,
280
+ options,
281
+ channel_credentials,
282
+ insecure,
283
+ call_credentials,
284
+ compression,
285
+ wait_for_ready,
286
+ timeout,
287
+ metadata,
288
+ _registered_method=True)
File without changes
@@ -0,0 +1,123 @@
1
+ """Public facade: the one-object surface a Source Bus producer uses.
2
+
3
+ ``SourceProducer.connect()`` is the composition root for the library — it wires the
4
+ gRPC source client and the session, then opens the connection. The customer's
5
+ hot loop only calls ``send_*`` (non-blocking).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ from loop_sdk.source.domain.config import OnSelectRobotConfig
13
+ from loop_sdk.source.domain.frame import (
14
+ MarkerFrame,
15
+ RobotFrame,
16
+ RobotStateValue,
17
+ TactileChannelValue,
18
+ TactileFrame,
19
+ )
20
+ from loop_sdk.source.domain.schema import SourceSchema
21
+ from loop_sdk.source.domain.stats import SendStats
22
+ from loop_sdk.source.outbound.grpc_source_client import GrpcSourceClient
23
+ from loop_sdk.source.service.producer_session import ProducerSession
24
+ from loop_sdk.source.setup.config import SourceConfig
25
+
26
+
27
+ class SourceProducer:
28
+ """A connected Source Bus producer for one declared schema."""
29
+
30
+ def __init__(self, session: ProducerSession) -> None:
31
+ self._session = session
32
+
33
+ @classmethod
34
+ def connect(
35
+ cls,
36
+ loop_addr: str,
37
+ schema: SourceSchema,
38
+ config: SourceConfig | None = None,
39
+ client_id: str | None = None,
40
+ on_select: OnSelectRobotConfig | None = None,
41
+ ) -> SourceProducer:
42
+ """Open a connection to the Loop Source Bus and begin answering its commands.
43
+
44
+ ``loop_addr`` is the Source Bus's ``host:port``. ``schema`` declares the
45
+ sources the SDK will stream. Recording start/stop is operator-driven; the
46
+ SDK starts streaming when the Source Bus opens a session.
47
+
48
+ ``on_select`` is the robot config apply callback. When a robot source
49
+ advertises ``available_options``, the Source Bus selects one config and
50
+ sends it on Open; ``on_select(source_id, requested)`` reconfigures the
51
+ device and returns the config it actually applied (``None`` = applied as
52
+ requested; raising fails the source). Omit it when no robot source
53
+ negotiates a config.
54
+ """
55
+ settings = config or SourceConfig()
56
+ client = GrpcSourceClient(
57
+ loop_addr=loop_addr,
58
+ client_id=client_id or uuid.uuid4().hex,
59
+ version=settings.client_version,
60
+ reconnect_backoff_seconds=settings.reconnect_backoff_seconds,
61
+ reconnect_backoff_max_seconds=settings.reconnect_backoff_max_seconds,
62
+ on_select=on_select,
63
+ )
64
+ session = ProducerSession(schema, client)
65
+ session.start()
66
+ return cls(session)
67
+
68
+ def send_robot(
69
+ self,
70
+ source_id: str,
71
+ timestamp_us: int,
72
+ sequence: int,
73
+ state: dict[str, RobotStateValue],
74
+ ) -> None:
75
+ """Send one robot-state sample as named channel readings. Non-blocking.
76
+
77
+ ``state`` maps the source's declared channel keys to heterogeneous
78
+ readings (scalar/vector/int/bool/string). Omit a key (or map it to
79
+ ``None``) for "no reading this sample"."""
80
+ self._session.send(RobotFrame(source_id=source_id, timestamp_us=timestamp_us, sequence=sequence, state=state))
81
+
82
+ def send_tactile(
83
+ self,
84
+ source_id: str,
85
+ timestamp_us: int,
86
+ sequence: int,
87
+ channels: tuple[TactileChannelValue, ...],
88
+ ) -> None:
89
+ """Send one tactile sample. Non-blocking."""
90
+ self._session.send(
91
+ TactileFrame(source_id=source_id, timestamp_us=timestamp_us, sequence=sequence, channels=channels)
92
+ )
93
+
94
+ def send_marker(self, source_id: str, timestamp_us: int, sequence: int, label: str, category: str = "") -> None:
95
+ """Send one sparse marker sample. Non-blocking."""
96
+ self._session.send(
97
+ MarkerFrame(
98
+ source_id=source_id,
99
+ timestamp_us=timestamp_us,
100
+ sequence=sequence,
101
+ label=label,
102
+ category=category,
103
+ )
104
+ )
105
+
106
+ def report_source_error(self, source_id: str, code: str, message: str = "") -> None:
107
+ """Report a per-source fault. The Source Bus maps it to ``SourceEvent(FAILED)``
108
+ so the recorder fails that source fast. Best-effort, non-blocking."""
109
+ self._session.report_error(source_id, code, message)
110
+
111
+ def stats(self) -> SendStats:
112
+ """Return a snapshot of delivery and connection statistics."""
113
+ return self._session.stats()
114
+
115
+ def close(self) -> None:
116
+ """Stop sending and tear the connection down."""
117
+ self._session.close()
118
+
119
+ def __enter__(self) -> SourceProducer:
120
+ return self
121
+
122
+ def __exit__(self, *_exc: object) -> None:
123
+ self.close()
@@ -0,0 +1,54 @@
1
+ """Public facade: the one-object surface a Source Bus consumer uses.
2
+
3
+ The mirror of ``SourceProducer``. ``SourceConsumer.connect()`` wires the gRPC
4
+ read client and opens the channel; ``subscribe()`` returns a blocking iterator of
5
+ decoded domain frames for one already-open source. The customer's loop only
6
+ iterates::
7
+
8
+ with SourceConsumer.connect(loop_addr) as consumer:
9
+ for frame in consumer.subscribe("franka"):
10
+ act(frame) # e.g. a RobotFrame: frame.state["joint_0"]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterator
16
+
17
+ from loop_sdk.source.domain.frame import FrameSample
18
+ from loop_sdk.source.outbound.grpc_source_reader import GrpcSourceReader
19
+ from loop_sdk.source.port.source_reader import SourceReader
20
+
21
+
22
+ class SourceConsumer:
23
+ """A connected Source Bus consumer that reads sources back off the bus."""
24
+
25
+ def __init__(self, reader: SourceReader) -> None:
26
+ self._reader = reader
27
+
28
+ @classmethod
29
+ def connect(cls, loop_addr: str) -> SourceConsumer:
30
+ """Open a connection to the Loop Source Bus read plane.
31
+
32
+ ``loop_addr`` is the Source Bus's ``host:port``.
33
+ """
34
+ return cls(GrpcSourceReader(loop_addr=loop_addr))
35
+
36
+ def subscribe(self, source_id: str, session_id: str = "") -> Iterator[FrameSample]:
37
+ """Stream one already-open source's samples as decoded domain frames.
38
+
39
+ Iteration ends when the source stops cleanly and raises
40
+ ``SubscriptionFailedError`` if the bus reports the source faulted (most
41
+ commonly: the source is not open). ``session_id`` optionally scopes the
42
+ subscription, mirroring ``Open``.
43
+ """
44
+ return self._reader.subscribe(source_id, session_id)
45
+
46
+ def close(self) -> None:
47
+ """Cancel any in-flight subscription and tear the connection down."""
48
+ self._reader.close()
49
+
50
+ def __enter__(self) -> SourceConsumer:
51
+ return self
52
+
53
+ def __exit__(self, *_exc: object) -> None:
54
+ self.close()
File without changes
@@ -0,0 +1,61 @@
1
+ """Robot config negotiation models (mirrors the foundation contract).
2
+
3
+ A robot source advertises per-axis candidate lists (``RobotConfigOptions``) at
4
+ Describe time; the Source Bus selects one value per axis into a ``RobotConfig``
5
+ and sends it on Open; the SDK applies it (via the ``on_select`` callback) and
6
+ reports the config it actually applied back on the control plane.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+
16
+ class RobotConfig(BaseModel):
17
+ """One negotiated robot configuration: the selection the server opens with,
18
+ and the config the client confirms it actually applied.
19
+
20
+ ``control_hz`` and ``action_space`` are the required runtime-negotiated axes;
21
+ the rest are optional identity / compatibility-gate fields."""
22
+
23
+ model_config = ConfigDict(frozen=True)
24
+
25
+ control_hz: int
26
+ action_space: str
27
+ gripper_type: str = ""
28
+ finger_type: str = ""
29
+ robot_type: str = ""
30
+ robot_firmware_version: str = ""
31
+ teleoperation_version: str = ""
32
+
33
+
34
+ class RobotConfigOptions(BaseModel):
35
+ """Per-axis candidate lists a robot source advertises at Describe time. Each
36
+ axis is independent; the server selects one value per axis into a
37
+ ``RobotConfig``. Empty lists everywhere means "no negotiation" (the device
38
+ opens with its default config)."""
39
+
40
+ model_config = ConfigDict(frozen=True)
41
+
42
+ control_hz: tuple[int, ...] = ()
43
+ action_space: tuple[str, ...] = ()
44
+ gripper_type: tuple[str, ...] = ()
45
+ finger_type: tuple[str, ...] = ()
46
+ robot_type: tuple[str, ...] = ()
47
+ robot_firmware_version: tuple[str, ...] = ()
48
+ teleoperation_version: tuple[str, ...] = ()
49
+
50
+
51
+ # Apply callback: the Source Bus selected ``requested`` for ``source_id``; the
52
+ # client reconfigures the device and returns the config it ACTUALLY applied
53
+ # (may differ, e.g. requested 100 Hz but the device runs at 90). Returning
54
+ # ``None`` means "applied exactly as requested". Raising signals the source
55
+ # cannot open with that config (reported as a per-source FAILED state).
56
+ #
57
+ # IMPORTANT: this runs synchronously on the SDK control thread (the one that
58
+ # processes Open/Close/Shutdown). Keep it fast and non-blocking — a slow or
59
+ # hanging callback stalls the whole control plane (including a clean ``close()``).
60
+ # Do the heavy device reconfiguration off this thread if it can block.
61
+ OnSelectRobotConfig = Callable[[str, RobotConfig], RobotConfig | None]
@@ -0,0 +1,108 @@
1
+ """Frame samples: the transport-free representation of one ``send()``.
2
+
3
+ A ``FrameSample`` is one timestamped sample for one source. The outbound adapter
4
+ maps it to a ``pb2.SampleBatch`` of one ``Sample`` at the gRPC boundary; the
5
+ domain never references protobuf.
6
+
7
+ ``timestamp_us`` and ``sequence`` map to ``Sample.timestamp_us`` (int64
8
+ microseconds) and ``Sample.sequence`` (uint64) on the wire — see
9
+ ``docs/spec_discrepancies.md`` item 9(c).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pydantic import BaseModel, ConfigDict
15
+
16
+ from loop_sdk.source.domain.source_kind import SourceKind
17
+
18
+ # One named robot signal: the heterogeneous state-value domain (float, vector,
19
+ # int, bool, string), mirroring a RobotEnv observation value. ``None`` means the
20
+ # reading is absent for this sample — the key is omitted from the wire, not sent
21
+ # as an empty value. ``bool`` precedes ``int`` because ``bool`` is an ``int``
22
+ # subclass and must be encoded as a distinct arm.
23
+ RobotStateValue = bool | int | float | str | list[float] | None
24
+
25
+
26
+ class RobotFrame(BaseModel):
27
+ """One robot-state sample: a dict of named channel readings.
28
+
29
+ Keys are the source's verbatim channel keys; values are heterogeneous
30
+ (scalar/vector/int/bool/string). A key absent from ``state`` (or mapped to
31
+ ``None``) means "no reading this sample" and is omitted on the wire."""
32
+
33
+ model_config = ConfigDict(frozen=True)
34
+
35
+ source_id: str
36
+ timestamp_us: int
37
+ sequence: int
38
+ state: dict[str, RobotStateValue]
39
+
40
+ @property
41
+ def kind(self) -> SourceKind:
42
+ return SourceKind.ROBOT
43
+
44
+
45
+ class TactileChannelValue(BaseModel):
46
+ """One channel's reading within a tactile sample."""
47
+
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ key: str
51
+ raw: bytes = b""
52
+ values_i16: tuple[int, ...] = ()
53
+
54
+
55
+ class TactileFrame(BaseModel):
56
+ """One tactile sample carrying per-channel readings."""
57
+
58
+ model_config = ConfigDict(frozen=True)
59
+
60
+ source_id: str
61
+ timestamp_us: int
62
+ sequence: int
63
+ channels: tuple[TactileChannelValue, ...]
64
+
65
+ @property
66
+ def kind(self) -> SourceKind:
67
+ return SourceKind.TACTILE
68
+
69
+
70
+ class MarkerFrame(BaseModel):
71
+ """One sparse marker sample."""
72
+
73
+ model_config = ConfigDict(frozen=True)
74
+
75
+ source_id: str
76
+ timestamp_us: int
77
+ sequence: int
78
+ label: str
79
+ category: str = ""
80
+
81
+ @property
82
+ def kind(self) -> SourceKind:
83
+ return SourceKind.MARKER
84
+
85
+
86
+ class PolicyActionFrame(BaseModel):
87
+ """One policy-action sample read back from a deploy-owned action source.
88
+
89
+ ``actions`` is the list of action vectors emitted for one input sample-set tick;
90
+ the input-sample-set fields trace which observation produced it (``None`` when
91
+ the producer did not set them). Read-plane only — the SDK does not produce these.
92
+ """
93
+
94
+ model_config = ConfigDict(frozen=True)
95
+
96
+ source_id: str
97
+ timestamp_us: int
98
+ sequence: int
99
+ actions: tuple[tuple[float, ...], ...]
100
+ input_sample_set_source_id: str | None = None
101
+ input_sample_set_sequence: int | None = None
102
+
103
+ @property
104
+ def kind(self) -> SourceKind:
105
+ return SourceKind.POLICY_ACTION
106
+
107
+
108
+ FrameSample = RobotFrame | TactileFrame | MarkerFrame | PolicyActionFrame