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.
- loop_sdk/__init__.py +84 -0
- loop_sdk/gen/__init__.py +0 -0
- loop_sdk/gen/__init__.pyi +1 -0
- loop_sdk/gen/v1/__init__.py +0 -0
- loop_sdk/gen/v1/__init__.pyi +1 -0
- loop_sdk/gen/v1/source_ingest_pb2.py +132 -0
- loop_sdk/gen/v1/source_ingest_pb2.pyi +466 -0
- loop_sdk/gen/v1/source_ingest_pb2_grpc.py +288 -0
- loop_sdk/source/__init__.py +0 -0
- loop_sdk/source/client.py +123 -0
- loop_sdk/source/consumer.py +54 -0
- loop_sdk/source/domain/__init__.py +0 -0
- loop_sdk/source/domain/config.py +61 -0
- loop_sdk/source/domain/frame.py +108 -0
- loop_sdk/source/domain/schema.py +193 -0
- loop_sdk/source/domain/source_exception.py +48 -0
- loop_sdk/source/domain/source_kind.py +18 -0
- loop_sdk/source/domain/stats.py +22 -0
- loop_sdk/source/outbound/__init__.py +0 -0
- loop_sdk/source/outbound/frame_queue.py +85 -0
- loop_sdk/source/outbound/grpc_source_client.py +360 -0
- loop_sdk/source/outbound/grpc_source_reader.py +82 -0
- loop_sdk/source/outbound/proto_mapping.py +239 -0
- loop_sdk/source/port/__init__.py +0 -0
- loop_sdk/source/port/source_client.py +49 -0
- loop_sdk/source/port/source_reader.py +34 -0
- loop_sdk/source/robot_step_sender.py +410 -0
- loop_sdk/source/service/__init__.py +0 -0
- loop_sdk/source/service/producer_session.py +81 -0
- loop_sdk/source/setup/__init__.py +0 -0
- loop_sdk/source/setup/config.py +30 -0
- loop_sdk-0.1.0.dist-info/METADATA +9 -0
- loop_sdk-0.1.0.dist-info/RECORD +34 -0
- loop_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|