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,49 @@
|
|
|
1
|
+
"""Transport port: the Loop Source Bus connection the session drives.
|
|
2
|
+
|
|
3
|
+
The service orchestrates the handshake and the send pipeline through this
|
|
4
|
+
contract; the concrete gRPC implementation lives in ``outbound/``. Keeping the
|
|
5
|
+
service behind this ABC keeps protobuf out of the application core (per the
|
|
6
|
+
gRPC boundary rule).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
from loop_sdk.source.domain.frame import FrameSample
|
|
14
|
+
from loop_sdk.source.domain.schema import SourceSchema
|
|
15
|
+
from loop_sdk.source.domain.stats import SendStats
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SourceClient(ABC):
|
|
19
|
+
"""One client connection to the Loop Source Bus.
|
|
20
|
+
|
|
21
|
+
Owns the control plane (answer ``Describe`` from ``schema``, surface
|
|
22
|
+
``Open`` / ``Close`` / ``Shutdown``), the data plane (deliver frames), the
|
|
23
|
+
background sender thread, reconnection, and delivery statistics. Translates
|
|
24
|
+
transport failures into domain ``SourceError`` subclasses.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def start(self, schema: SourceSchema) -> None:
|
|
29
|
+
"""Open the connection and begin answering the server's commands from ``schema``."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def submit(self, frame: FrameSample) -> None:
|
|
33
|
+
"""Hand a frame to the send pipeline. Non-blocking; drops oldest under backpressure."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def report_error(self, source_id: str, code: str, message: str = "") -> None:
|
|
37
|
+
"""Report a per-source fault on the control plane (``ClientError``).
|
|
38
|
+
|
|
39
|
+
The Source Bus maps it to a per-source ``SourceEvent(FAILED)`` so a
|
|
40
|
+
consumer (e.g. the recorder) fails that source fast instead of waiting
|
|
41
|
+
for the whole connection to drop. Best-effort and non-blocking."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def stats(self) -> SendStats:
|
|
45
|
+
"""Return a snapshot of delivery and connection statistics."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
"""Stop sending, tear the connection down, and join background threads."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Transport port: the Loop Source Bus read connection a consumer drives.
|
|
2
|
+
|
|
3
|
+
The mirror of ``SourceClient`` (the produce side). The facade subscribes through
|
|
4
|
+
this contract; the concrete gRPC implementation lives in ``outbound/``. Keeping
|
|
5
|
+
the consumer behind this ABC keeps protobuf out of the application core (per the
|
|
6
|
+
gRPC boundary rule).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
|
|
14
|
+
from loop_sdk.source.domain.frame import FrameSample
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SourceReader(ABC):
|
|
18
|
+
"""One client connection that reads a source's samples back off the bus.
|
|
19
|
+
|
|
20
|
+
Owns the gRPC channel and the server-streaming ``Subscribe`` call. Translates
|
|
21
|
+
transport failures and per-source faults into domain ``SourceError`` subclasses.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def subscribe(self, source_id: str, session_id: str = "") -> Iterator[FrameSample]:
|
|
26
|
+
"""Stream one already-open source's samples as domain frames.
|
|
27
|
+
|
|
28
|
+
Yields frames until the source stops cleanly (iteration ends) and raises
|
|
29
|
+
``SubscriptionFailedError`` if the bus reports the source faulted.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def close(self) -> None:
|
|
34
|
+
"""Cancel any in-flight subscription and tear the connection down."""
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""High-level robot-step sender: the four-call surface a control loop wants.
|
|
2
|
+
|
|
3
|
+
``RobotStepSender`` is the ergonomic front door over the low-level
|
|
4
|
+
``SourceProducer``. With loop-sdk installed, a control loop needs one class and
|
|
5
|
+
four calls::
|
|
6
|
+
|
|
7
|
+
sender = RobotStepSender(loop_addr, source_id, options=..., apply_config=...)
|
|
8
|
+
sender.connect() # prepare to stream
|
|
9
|
+
sender.declare(channels) # fix the channel layout ONCE, open the source
|
|
10
|
+
sender.send(timestamp_us, step) # one named-channel reading dict per tick
|
|
11
|
+
sender.disconnect() # tear the connection down
|
|
12
|
+
|
|
13
|
+
Everything generic about streaming a robot onto the Source Bus lives here:
|
|
14
|
+
connection lifecycle, a layout declared once and held fixed, per-tick sequence
|
|
15
|
+
numbering, "no reading this tick" handling (``None``/``NaN`` → omitted on the
|
|
16
|
+
wire), config-negotiation, and an optional consistency-logging hook.
|
|
17
|
+
|
|
18
|
+
**Config negotiation.** A robot advertises the configs it can open with via
|
|
19
|
+
``options`` (a ``RobotConfigOptions``). At Open the Source Bus selects one config
|
|
20
|
+
per axis and hands it back; ``RobotStepSender`` then (1) validates the selection
|
|
21
|
+
against what was advertised — rejecting an impossible config so the source fails
|
|
22
|
+
fast instead of opening misconfigured — and (2) calls your ``apply_config``
|
|
23
|
+
callback to reconfigure the device (e.g. set the control rate) before streaming.
|
|
24
|
+
``apply_config`` returns the config it actually applied (or ``None`` for "as
|
|
25
|
+
requested"); raising from it signals the source cannot open. Omit ``options`` for
|
|
26
|
+
no negotiation.
|
|
27
|
+
|
|
28
|
+
**What this class does NOT do** is interpret your robot's data. ``send`` takes a
|
|
29
|
+
flat ``dict[str, RobotStateValue]`` of named readings; you name the channels and
|
|
30
|
+
produce the readings. The SDK never guesses which field is an "action" or how a
|
|
31
|
+
categorical maps to a number — that domain mapping is yours. ``flatten_step`` is
|
|
32
|
+
a generic structural helper for nested inputs, but column semantics stay with you.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
import math
|
|
39
|
+
import os
|
|
40
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from loop_sdk.source.client import SourceProducer
|
|
44
|
+
from loop_sdk.source.domain.config import RobotConfig, RobotConfigOptions
|
|
45
|
+
from loop_sdk.source.domain.frame import RobotStateValue
|
|
46
|
+
from loop_sdk.source.domain.schema import ChannelSpec, RobotStreamSchema, SourceSchema
|
|
47
|
+
from loop_sdk.source.setup.config import SourceConfig
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
# Sensible turnkey defaults: point at the Source Bus via the LOOP_ADDR env var
|
|
52
|
+
# (falling back to localhost), and name the source "robot-step". Pass explicit
|
|
53
|
+
# values to override.
|
|
54
|
+
_DEFAULT_LOOP_ADDR = os.getenv("LOOP_ADDR", "localhost:50051")
|
|
55
|
+
_DEFAULT_SOURCE_ID = "robot-step"
|
|
56
|
+
|
|
57
|
+
# Called after each successful send for consistency logging / metrics. Kept as a
|
|
58
|
+
# plain callback so the SDK does not depend on any particular logger.
|
|
59
|
+
OnSent = Callable[..., None]
|
|
60
|
+
|
|
61
|
+
# Called at Open with the config the Source Bus selected; reconfigures the device
|
|
62
|
+
# and returns the config actually applied (``None`` = applied as requested).
|
|
63
|
+
# Raising signals the source cannot open with that config.
|
|
64
|
+
ApplyRobotConfig = Callable[[RobotConfig], "RobotConfig | None"]
|
|
65
|
+
|
|
66
|
+
# What a declared layout may be given as.
|
|
67
|
+
ChannelLayout = Sequence[str] | Sequence[ChannelSpec] | Mapping[str, Any]
|
|
68
|
+
|
|
69
|
+
# Config axes the Source Bus negotiates: control_hz + action_space are the
|
|
70
|
+
# required runtime axes (validated explicitly); these are the optional identity /
|
|
71
|
+
# compatibility gates (mirrors RobotConfig / RobotConfigOptions).
|
|
72
|
+
_OPTIONAL_AXES = ("gripper_type", "finger_type", "robot_type", "robot_firmware_version", "teleoperation_version")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def flatten_step(step: Mapping[str, Any], *, separator: str = ".") -> dict[str, RobotStateValue]:
|
|
76
|
+
"""Structurally flatten a nested step dict into ``{dotted.key: value}``.
|
|
77
|
+
|
|
78
|
+
A *generic* convenience for callers whose data is nested — it flattens
|
|
79
|
+
structure only, it does NOT interpret meaning:
|
|
80
|
+
|
|
81
|
+
* nested ``dict`` → dotted keys (``a`` + ``b`` → ``a.b``)
|
|
82
|
+
* a list/tuple of plain numbers → kept whole as a ``list[float]`` vector
|
|
83
|
+
channel (the Source Bus stores vectors natively)
|
|
84
|
+
* a list/tuple containing non-numbers → indexed keys (``a[0]``, ``a[1]``)
|
|
85
|
+
* scalars / bool / str / ``None`` → passed through unchanged
|
|
86
|
+
|
|
87
|
+
Column *identity* is still yours: the keys come verbatim from your dict.
|
|
88
|
+
|
|
89
|
+
Caveat: flattening joins keys with ``separator`` (``.``), so a literal dotted
|
|
90
|
+
key (``{"a.b": ...}``) and a nested one (``{"a": {"b": ...}}``) collapse to the
|
|
91
|
+
same flat key — last write wins. Keep your nesting and key names from
|
|
92
|
+
colliding on the separator.
|
|
93
|
+
"""
|
|
94
|
+
flat: dict[str, RobotStateValue] = {}
|
|
95
|
+
_flatten_into(flat, "", step, separator)
|
|
96
|
+
return flat
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_number(value: Any) -> bool:
|
|
100
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _flatten_into(out: dict[str, RobotStateValue], prefix: str, value: Any, sep: str) -> None:
|
|
104
|
+
if isinstance(value, Mapping):
|
|
105
|
+
for key, sub in value.items():
|
|
106
|
+
child = f"{prefix}{sep}{key}" if prefix else str(key)
|
|
107
|
+
_flatten_into(out, child, sub, sep)
|
|
108
|
+
return
|
|
109
|
+
if isinstance(value, (list, tuple)):
|
|
110
|
+
if all(_is_number(item) for item in value):
|
|
111
|
+
out[prefix] = [float(item) for item in value] # whole vector channel
|
|
112
|
+
return
|
|
113
|
+
for index, item in enumerate(value):
|
|
114
|
+
_flatten_into(out, f"{prefix}[{index}]", item, sep)
|
|
115
|
+
return
|
|
116
|
+
out[prefix] = value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_value(value: RobotStateValue) -> RobotStateValue:
|
|
120
|
+
"""Map a reading to its wire form: ``NaN`` → ``None`` ("no reading"); everything
|
|
121
|
+
else (scalar/bool/str/vector) passes through untouched."""
|
|
122
|
+
if value is None:
|
|
123
|
+
return None
|
|
124
|
+
if isinstance(value, float) and math.isnan(value):
|
|
125
|
+
return None
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _candidate_axes(options: RobotConfigOptions) -> dict[str, tuple[Any, ...]]:
|
|
130
|
+
"""The advertised candidate list per config axis, from the declared options."""
|
|
131
|
+
return {
|
|
132
|
+
"control_hz": options.control_hz,
|
|
133
|
+
"action_space": options.action_space,
|
|
134
|
+
"gripper_type": options.gripper_type,
|
|
135
|
+
"finger_type": options.finger_type,
|
|
136
|
+
"robot_type": options.robot_type,
|
|
137
|
+
"robot_firmware_version": options.robot_firmware_version,
|
|
138
|
+
"teleoperation_version": options.teleoperation_version,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _require_advertised(
|
|
143
|
+
field: str, requested: Any, candidates: dict[str, tuple[Any, ...]], *, allow_empty: bool
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Reject a selected config value that was never advertised for ``field``."""
|
|
146
|
+
if allow_empty and requested in ("", 0):
|
|
147
|
+
return
|
|
148
|
+
options = candidates.get(field, ())
|
|
149
|
+
if options:
|
|
150
|
+
if requested not in options:
|
|
151
|
+
raise ValueError(f"unsupported robot config {field}={requested!r}; available={options!r}")
|
|
152
|
+
return
|
|
153
|
+
if requested not in ("", 0):
|
|
154
|
+
raise ValueError(f"unsupported robot config {field}={requested!r}; no values advertised")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RobotStepSender:
|
|
158
|
+
"""Stream one robot source onto the Loop Source Bus with a four-call surface."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
loop_addr: str = _DEFAULT_LOOP_ADDR,
|
|
163
|
+
source_id: str = _DEFAULT_SOURCE_ID,
|
|
164
|
+
*,
|
|
165
|
+
name: str = "",
|
|
166
|
+
options: RobotConfigOptions | None = None,
|
|
167
|
+
apply_config: ApplyRobotConfig | None = None,
|
|
168
|
+
on_sent: OnSent | None = None,
|
|
169
|
+
config: SourceConfig | None = None,
|
|
170
|
+
client_id: str | None = None,
|
|
171
|
+
connect_producer: Callable[..., Any] | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Configure (but do not yet open) a sender for one robot source. **Build once
|
|
174
|
+
at startup**, before the control loop runs.
|
|
175
|
+
|
|
176
|
+
``loop_addr`` defaults to the ``LOOP_ADDR`` env var (else ``localhost:50051``)
|
|
177
|
+
and ``source_id`` to ``"robot-step"`` — pass explicit values to override.
|
|
178
|
+
``name`` is free-form source metadata persisted by the recorder. ``options``
|
|
179
|
+
advertises the configs the source can open with; when set, the Source Bus
|
|
180
|
+
negotiates one and ``RobotStepSender`` validates it against ``options`` and
|
|
181
|
+
calls ``apply_config`` to reconfigure the device. ``on_sent`` is called
|
|
182
|
+
``(sequence, timestamp_us, source_id, n_values)`` after each successful send.
|
|
183
|
+
``connect_producer`` is a seam for tests (defaults to ``SourceProducer.connect``).
|
|
184
|
+
|
|
185
|
+
Call order over a session::
|
|
186
|
+
|
|
187
|
+
connect() # startup
|
|
188
|
+
send(timestamp_us, ...) # every tick; the FIRST send declares the layout
|
|
189
|
+
# from its keys (make the first frame complete)
|
|
190
|
+
disconnect() # shutdown only — NOT for pauses
|
|
191
|
+
|
|
192
|
+
``declare(channels)`` is optional — call it explicitly after ``connect()``
|
|
193
|
+
only when you want the layout fixed and the config negotiated *before* the
|
|
194
|
+
first ``send`` (e.g. to apply ``control_hz`` to the device before streaming).
|
|
195
|
+
"""
|
|
196
|
+
self.loop_addr = loop_addr
|
|
197
|
+
self.source_id = source_id
|
|
198
|
+
self.name = name
|
|
199
|
+
self._options = options
|
|
200
|
+
self._apply_config = apply_config
|
|
201
|
+
self._on_sent = on_sent
|
|
202
|
+
self._config = config
|
|
203
|
+
self._client_id = client_id
|
|
204
|
+
self._connect_producer = connect_producer or SourceProducer.connect
|
|
205
|
+
|
|
206
|
+
self._producer: SourceProducer | None = None
|
|
207
|
+
self._order: list[str] | None = None
|
|
208
|
+
self._seq = 0
|
|
209
|
+
self._unknown_keys: set[str] = set()
|
|
210
|
+
self._warned_disconnected = False
|
|
211
|
+
self.connected = False
|
|
212
|
+
self.total_sent = 0
|
|
213
|
+
self.total_failed = 0
|
|
214
|
+
|
|
215
|
+
# -- lifecycle ---------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def connect(self) -> bool:
|
|
218
|
+
"""**Step 1 — at startup.** Mark intent to stream. After this, just call
|
|
219
|
+
``send()`` in your loop — the first ``send`` declares the layout and opens the
|
|
220
|
+
transport (the Source Bus needs the schema at open time). Returns ``True``."""
|
|
221
|
+
self.connected = True
|
|
222
|
+
self._warned_disconnected = False
|
|
223
|
+
logger.info("RobotStepSender: ready to stream to %s (source=%s)", self.loop_addr, self.source_id)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def declare(
|
|
227
|
+
self,
|
|
228
|
+
channels: ChannelLayout,
|
|
229
|
+
*,
|
|
230
|
+
name: str | None = None,
|
|
231
|
+
options: RobotConfigOptions | None = None,
|
|
232
|
+
) -> bool:
|
|
233
|
+
"""**Optional — the first ``send()`` does this for you** from its own keys.
|
|
234
|
+
Call ``declare`` explicitly only to fix the layout and open the source
|
|
235
|
+
*before* the first send — i.e. so the Source Bus negotiates the config (your
|
|
236
|
+
``apply_config`` fires at Open) before you start streaming, rather than on the
|
|
237
|
+
first tick.
|
|
238
|
+
|
|
239
|
+
``channels`` is the authoritative, COMPLETE set of channel keys this source
|
|
240
|
+
will ever carry — channel keys, full ``ChannelSpec``s, or a representative
|
|
241
|
+
step dict (its flattened keys become the layout). The layout is fixed here:
|
|
242
|
+
later ``send()`` calls may omit a key (→ "no reading") but may not introduce
|
|
243
|
+
new keys. Idempotent once the source is open.
|
|
244
|
+
"""
|
|
245
|
+
if not self.connected:
|
|
246
|
+
self.connect()
|
|
247
|
+
if self._producer is not None:
|
|
248
|
+
return True
|
|
249
|
+
if options is not None:
|
|
250
|
+
self._options = options
|
|
251
|
+
if name is not None:
|
|
252
|
+
self.name = name
|
|
253
|
+
|
|
254
|
+
specs = self._resolve_specs(channels)
|
|
255
|
+
if not specs:
|
|
256
|
+
logger.warning("RobotStepSender: empty channel layout; not opening source %s", self.source_id)
|
|
257
|
+
return False
|
|
258
|
+
self._order = [spec.key for spec in specs]
|
|
259
|
+
schema = SourceSchema(
|
|
260
|
+
robot=(
|
|
261
|
+
RobotStreamSchema(
|
|
262
|
+
source_id=self.source_id,
|
|
263
|
+
name=self.name,
|
|
264
|
+
channels=tuple(specs),
|
|
265
|
+
available_options=self._options or RobotConfigOptions(),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
self._producer = self._connect_producer(
|
|
270
|
+
self.loop_addr,
|
|
271
|
+
schema,
|
|
272
|
+
config=self._config,
|
|
273
|
+
client_id=self._client_id,
|
|
274
|
+
on_select=self._on_select,
|
|
275
|
+
)
|
|
276
|
+
logger.info(
|
|
277
|
+
"RobotStepSender: source %s open on %s (%d channels)", self.source_id, self.loop_addr, len(self._order)
|
|
278
|
+
)
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
def send(
|
|
282
|
+
self,
|
|
283
|
+
timestamp_us: int,
|
|
284
|
+
step: Mapping[str, RobotStateValue],
|
|
285
|
+
*,
|
|
286
|
+
sequence: int | None = None,
|
|
287
|
+
) -> bool:
|
|
288
|
+
"""**Step 2 — every control tick.** Non-blocking. The **first call declares the
|
|
289
|
+
channel layout from this step's keys** (no separate ``declare`` needed) and
|
|
290
|
+
opens the source; so make your first frame COMPLETE — include every channel
|
|
291
|
+
you will ever send (give "no reading" ones a ``None``/``NaN``), because keys
|
|
292
|
+
absent from that first frame become undeclared and are dropped later.
|
|
293
|
+
|
|
294
|
+
Start calling this *before* the operator hits "start recording" and keep
|
|
295
|
+
calling it — then capture is already live when recording begins; the recorder
|
|
296
|
+
decides what to keep, you just stream the freshest state. Pausing is safe:
|
|
297
|
+
stop calling and resume later (don't ``disconnect()`` for a pause).
|
|
298
|
+
|
|
299
|
+
``step`` maps channel keys to readings (scalar/bool/str/float-vector) and may
|
|
300
|
+
be nested — it is flattened the same way ``declare`` flattens. A declared key
|
|
301
|
+
that is absent or maps to ``None``/``NaN`` is sent as "no reading this tick"
|
|
302
|
+
(omitted on the wire); a key outside the declared layout is dropped with a
|
|
303
|
+
one-time warning. ``sequence`` overrides the auto-incrementing per-source
|
|
304
|
+
counter. Returns ``True`` on success.
|
|
305
|
+
"""
|
|
306
|
+
if not self.connected:
|
|
307
|
+
if not self._warned_disconnected:
|
|
308
|
+
self._warned_disconnected = True
|
|
309
|
+
logger.warning("RobotStepSender: send() before connect()/declare() (or after disconnect); dropping")
|
|
310
|
+
return False
|
|
311
|
+
flat = flatten_step(step)
|
|
312
|
+
try:
|
|
313
|
+
if self._producer is None and not self.declare(flat):
|
|
314
|
+
return False
|
|
315
|
+
assert self._order is not None and self._producer is not None
|
|
316
|
+
self._warn_unknown_keys(flat)
|
|
317
|
+
state = {key: _normalize_value(flat.get(key)) for key in self._order}
|
|
318
|
+
seq = self._seq if sequence is None else sequence
|
|
319
|
+
self._producer.send_robot(self.source_id, timestamp_us, seq, state)
|
|
320
|
+
except Exception as error: # transport/SDK error — never break the control loop
|
|
321
|
+
self.total_failed += 1
|
|
322
|
+
if self.total_failed % 200 == 1: # rate-limit (~once per 10s at 20Hz)
|
|
323
|
+
logger.warning("RobotStepSender: send failed (%d total): %s", self.total_failed, error)
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
# Success bookkeeping + consistency hook, OUTSIDE the transport try so a
|
|
327
|
+
# buggy on_sent callback is not miscounted as a transport failure.
|
|
328
|
+
self._seq = seq + 1
|
|
329
|
+
self.total_sent += 1
|
|
330
|
+
if self._on_sent is not None:
|
|
331
|
+
self._on_sent(sequence=seq, timestamp_us=timestamp_us, source_id=self.source_id, n_values=len(state))
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
def disconnect(self) -> None:
|
|
335
|
+
"""**Step 4 — on shutdown only.** Stop sending and tear the connection down.
|
|
336
|
+
Do NOT call this for a pause — keep the sender open and just stop calling
|
|
337
|
+
``send()``; the link stays alive (keepalive) and streaming resumes when you
|
|
338
|
+
call ``send()`` again."""
|
|
339
|
+
if self._producer is not None:
|
|
340
|
+
try:
|
|
341
|
+
self._producer.close()
|
|
342
|
+
except Exception as error:
|
|
343
|
+
logger.warning("RobotStepSender: close failed: %s", error)
|
|
344
|
+
self._producer = None
|
|
345
|
+
self._order = None
|
|
346
|
+
self._seq = 0
|
|
347
|
+
self._unknown_keys.clear()
|
|
348
|
+
self.connected = False
|
|
349
|
+
logger.info("RobotStepSender: disconnected (sent=%d failed=%d)", self.total_sent, self.total_failed)
|
|
350
|
+
|
|
351
|
+
def stats(self) -> Any:
|
|
352
|
+
"""Delivery/connection statistics from the underlying producer, or ``None``
|
|
353
|
+
before the source is open."""
|
|
354
|
+
return self._producer.stats() if self._producer is not None else None
|
|
355
|
+
|
|
356
|
+
def __enter__(self) -> RobotStepSender:
|
|
357
|
+
self.connect()
|
|
358
|
+
return self
|
|
359
|
+
|
|
360
|
+
def __exit__(self, *_exc: object) -> None:
|
|
361
|
+
self.disconnect()
|
|
362
|
+
|
|
363
|
+
# -- config negotiation ------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def _on_select(self, source_id: str, requested: RobotConfig) -> RobotConfig | None:
|
|
366
|
+
"""Source Bus selected ``requested`` for ``source_id``: validate it against
|
|
367
|
+
the advertised options, then apply it to the device. Raising fails the
|
|
368
|
+
source (the SDK converts it to a per-source FAILED event)."""
|
|
369
|
+
self._validate_selection(source_id, requested)
|
|
370
|
+
applied = self._apply_config(requested) if self._apply_config is not None else None
|
|
371
|
+
logger.info("RobotStepSender: applied robot config for %s: %s", source_id, applied or requested)
|
|
372
|
+
return applied
|
|
373
|
+
|
|
374
|
+
def _validate_selection(self, source_id: str, requested: RobotConfig) -> None:
|
|
375
|
+
if source_id != self.source_id:
|
|
376
|
+
raise ValueError(f"unknown robot source {source_id!r}; expected {self.source_id!r}")
|
|
377
|
+
if self._options is None:
|
|
378
|
+
raise ValueError("robot config selected but no options were advertised")
|
|
379
|
+
candidates = _candidate_axes(self._options)
|
|
380
|
+
hz = int(getattr(requested, "control_hz", 0) or 0)
|
|
381
|
+
action_space = str(getattr(requested, "action_space", "") or "")
|
|
382
|
+
_require_advertised("control_hz", hz, candidates, allow_empty=False)
|
|
383
|
+
_require_advertised("action_space", action_space, candidates, allow_empty=False)
|
|
384
|
+
for field in _OPTIONAL_AXES:
|
|
385
|
+
_require_advertised(field, str(getattr(requested, field, "") or ""), candidates, allow_empty=True)
|
|
386
|
+
|
|
387
|
+
# -- internals ---------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _resolve_specs(channels: ChannelLayout) -> list[ChannelSpec]:
|
|
391
|
+
if isinstance(channels, Mapping):
|
|
392
|
+
return [ChannelSpec(key=key) for key in flatten_step(channels)]
|
|
393
|
+
specs: list[ChannelSpec] = []
|
|
394
|
+
for item in channels:
|
|
395
|
+
specs.append(item if isinstance(item, ChannelSpec) else ChannelSpec(key=str(item)))
|
|
396
|
+
return specs
|
|
397
|
+
|
|
398
|
+
def _warn_unknown_keys(self, step: Mapping[str, RobotStateValue]) -> None:
|
|
399
|
+
"""Surface (never silently accept) keys not in the declared layout — they
|
|
400
|
+
are dropped, since the layout is fixed at declare time."""
|
|
401
|
+
assert self._order is not None
|
|
402
|
+
new = set(step) - set(self._order) - self._unknown_keys
|
|
403
|
+
if new:
|
|
404
|
+
self._unknown_keys |= new
|
|
405
|
+
logger.warning(
|
|
406
|
+
"RobotStepSender: %d key(s) not in the declared layout are NOT sent "
|
|
407
|
+
"(layout fixed at declare): %s",
|
|
408
|
+
len(new),
|
|
409
|
+
sorted(new),
|
|
410
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Producer session: validate frames against the schema and drive the send pipeline.
|
|
2
|
+
|
|
3
|
+
This is the transport-free application core. It owns the rule that a frame must
|
|
4
|
+
match the source it names (right kind, right vector length); it delegates all
|
|
5
|
+
transport — handshake, streaming, reconnection — to the ``SourceClient`` port.
|
|
6
|
+
It never references protobuf or threads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from loop_sdk.source.domain.frame import FrameSample, RobotFrame, TactileFrame
|
|
12
|
+
from loop_sdk.source.domain.schema import SourceSchema
|
|
13
|
+
from loop_sdk.source.domain.source_exception import (
|
|
14
|
+
SchemaMismatchError,
|
|
15
|
+
UnknownSourceError,
|
|
16
|
+
)
|
|
17
|
+
from loop_sdk.source.domain.source_kind import SourceKind
|
|
18
|
+
from loop_sdk.source.domain.stats import SendStats
|
|
19
|
+
from loop_sdk.source.port.source_client import SourceClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProducerSession:
|
|
23
|
+
"""Orchestrate frame validation and delivery for one declared schema."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, schema: SourceSchema, client: SourceClient) -> None:
|
|
26
|
+
self._schema = schema
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def start(self) -> None:
|
|
30
|
+
"""Open the Source Bus connection and begin answering its commands."""
|
|
31
|
+
self._client.start(self._schema)
|
|
32
|
+
|
|
33
|
+
def send(self, frame: FrameSample) -> None:
|
|
34
|
+
"""Validate ``frame`` against the schema and hand it to the send pipeline.
|
|
35
|
+
|
|
36
|
+
Non-blocking: validation is cheap and the SDK drops oldest under
|
|
37
|
+
backpressure. Raises a domain error if the frame contradicts the schema.
|
|
38
|
+
"""
|
|
39
|
+
self._validate(frame)
|
|
40
|
+
self._client.submit(frame)
|
|
41
|
+
|
|
42
|
+
def report_error(self, source_id: str, code: str, message: str = "") -> None:
|
|
43
|
+
"""Report a per-source fault to the Source Bus. Best-effort, non-blocking.
|
|
44
|
+
|
|
45
|
+
Not validated against the schema: a fault must be reportable even for a
|
|
46
|
+
source that failed to enumerate or stopped matching its layout."""
|
|
47
|
+
self._client.report_error(source_id, code, message)
|
|
48
|
+
|
|
49
|
+
def stats(self) -> SendStats:
|
|
50
|
+
return self._client.stats()
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
self._client.close()
|
|
54
|
+
|
|
55
|
+
def _validate(self, frame: FrameSample) -> None:
|
|
56
|
+
declared = self._kind_for(frame.source_id)
|
|
57
|
+
if declared != frame.kind:
|
|
58
|
+
raise SchemaMismatchError(
|
|
59
|
+
frame.source_id,
|
|
60
|
+
f"declared {declared.value}, got {frame.kind.value}",
|
|
61
|
+
)
|
|
62
|
+
if isinstance(frame, RobotFrame):
|
|
63
|
+
self._validate_robot(frame)
|
|
64
|
+
return
|
|
65
|
+
if isinstance(frame, TactileFrame) and not frame.channels:
|
|
66
|
+
raise SchemaMismatchError(frame.source_id, "tactile frame has no channels")
|
|
67
|
+
|
|
68
|
+
def _validate_robot(self, frame: RobotFrame) -> None:
|
|
69
|
+
declared = self._schema.robot_channel_keys(frame.source_id)
|
|
70
|
+
unknown = frame.state.keys() - declared
|
|
71
|
+
if unknown:
|
|
72
|
+
raise SchemaMismatchError(
|
|
73
|
+
frame.source_id,
|
|
74
|
+
f"state carries keys not in the declared channel layout: {sorted(unknown)}",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _kind_for(self, source_id: str) -> SourceKind:
|
|
78
|
+
try:
|
|
79
|
+
return self._schema.kind_of(source_id)
|
|
80
|
+
except KeyError as error:
|
|
81
|
+
raise UnknownSourceError(source_id) from error
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Source SDK configuration, read from ``LOOP_SOURCE_*`` environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
_DISTRIBUTION = "loop-sdk"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _client_version() -> str:
|
|
14
|
+
"""``loop-sdk/<installed version>`` — the SDK's self-reported version on
|
|
15
|
+
``ClientReady``. Tracks the pip-installed distribution version (set from git
|
|
16
|
+
tags by hatch-vcs); falls back when running from an uninstalled source tree."""
|
|
17
|
+
try:
|
|
18
|
+
return f"{_DISTRIBUTION}/{version(_DISTRIBUTION)}"
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
return f"{_DISTRIBUTION}/unknown"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SourceConfig(BaseSettings):
|
|
24
|
+
"""Tunables for the SDK send pipeline and reconnection behavior."""
|
|
25
|
+
|
|
26
|
+
model_config = SettingsConfigDict(env_prefix="LOOP_SOURCE_")
|
|
27
|
+
|
|
28
|
+
reconnect_backoff_seconds: float = Field(default=0.5, gt=0)
|
|
29
|
+
reconnect_backoff_max_seconds: float = Field(default=10.0, gt=0)
|
|
30
|
+
client_version: str = Field(default_factory=_client_version)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loop-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Customer-side gRPC client SDK for the Loop Source Bus.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: grpcio>=1.81.1
|
|
7
|
+
Requires-Dist: protobuf>=7.35.1
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.13.0
|
|
9
|
+
Requires-Dist: pydantic>=2.12.0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
loop_sdk/__init__.py,sha256=Pw36uCwsTds_a4Xr6TBMvYPmeuM4fYDSmU-vxVoSyRg,2345
|
|
2
|
+
loop_sdk/gen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
loop_sdk/gen/__init__.pyi,sha256=lXuz5z0bL5TXqVdaXxiVr-4zF_Q1IZ8kPEkuYGb5eB0,17
|
|
4
|
+
loop_sdk/gen/v1/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
loop_sdk/gen/v1/__init__.pyi,sha256=ZB39-fKcvB-NOu0mP4hzuwEPAg2aHm_05ujLkC2YJyk,32
|
|
6
|
+
loop_sdk/gen/v1/source_ingest_pb2.py,sha256=8tMD1vroTw7OYEAJDOWdjCsaPmrQliJsYj0O-2iC5AE,16814
|
|
7
|
+
loop_sdk/gen/v1/source_ingest_pb2.pyi,sha256=iC8EPNlK4FCblJhMCa2BgsIrxLOLDHTDgz2nYuIbsFY,22688
|
|
8
|
+
loop_sdk/gen/v1/source_ingest_pb2_grpc.py,sha256=duVbns_Ek0wJYTiJuEAaMjGDnT9lIdpBb9lFfgGu1zE,12543
|
|
9
|
+
loop_sdk/source/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
loop_sdk/source/client.py,sha256=eJ_h3V_LI5m-EqJTtkko3bAMEFX81pKw_YQoIqwCNqU,4638
|
|
11
|
+
loop_sdk/source/consumer.py,sha256=5NJAWvFblt80ZvEATN_JSBo2-guXhpyNz8rD8dmrEy8,1989
|
|
12
|
+
loop_sdk/source/robot_step_sender.py,sha256=tzTvA4M723d7cCi4IusxP5-Tpsok-CyzaHORNvrd0qQ,19097
|
|
13
|
+
loop_sdk/source/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
loop_sdk/source/domain/config.py,sha256=7wpF6SHNooYOjcCYFqdN6w4O4ISy1HEj9NLI8_WzGWk,2458
|
|
15
|
+
loop_sdk/source/domain/frame.py,sha256=8jXIX8rySsKiin6a4frnphbsvvLAvfkWRF5dvq5-mO0,3196
|
|
16
|
+
loop_sdk/source/domain/schema.py,sha256=GbirnZMG4mgPlA7qKnMpepL6nz0GcePeAMZPUjdpAQU,6951
|
|
17
|
+
loop_sdk/source/domain/source_exception.py,sha256=8RLo4HKQ1_YtuOPtBujyT8gpOa7as3WtFMWPMF_jrZQ,1684
|
|
18
|
+
loop_sdk/source/domain/source_kind.py,sha256=qWkGqdTh5C5qQXQXmdL4SNHCiF_0M5qejNHrdBQ8Bvg,488
|
|
19
|
+
loop_sdk/source/domain/stats.py,sha256=ne3f108hpKfgPmjbn1pKo__ZiOT5CvDy7SssoebD_wU,583
|
|
20
|
+
loop_sdk/source/outbound/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
loop_sdk/source/outbound/frame_queue.py,sha256=8QXd6YFT_Yk9x4Wq7NYgURKVb2IOcuSkI-DCerlg3_8,3056
|
|
22
|
+
loop_sdk/source/outbound/grpc_source_client.py,sha256=4189bB6Tn20U_jbeE_ygZ7biFZjupccstOtSZQFu9io,15114
|
|
23
|
+
loop_sdk/source/outbound/grpc_source_reader.py,sha256=JTaPrVgg5xxHis9bgWSYpNnw3O0ZOLlAoQ8WuWLN6RQ,3390
|
|
24
|
+
loop_sdk/source/outbound/proto_mapping.py,sha256=FjuNTPrxhz42wsGq3rgcBZ-U7l7JcXdJNj9V6zVKjF4,9035
|
|
25
|
+
loop_sdk/source/port/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
|
+
loop_sdk/source/port/source_client.py,sha256=jfaf9Y2xF5dAgBZxJteguisPw4ks5lRHI2d59E38MVk,1944
|
|
27
|
+
loop_sdk/source/port/source_reader.py,sha256=mNme2_18wrFa9Dk9D9Eb6PnuHHeGy8yTa67Wp3pU6Ek,1262
|
|
28
|
+
loop_sdk/source/service/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
loop_sdk/source/service/producer_session.py,sha256=XTAzjQibGwrNsCmge1uFVZfGdd1jBydHbK-FuATQwLI,3221
|
|
30
|
+
loop_sdk/source/setup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
|
+
loop_sdk/source/setup/config.py,sha256=PlR-txHA_n9P-PVBhhxgBtZXU_NwPnusREn2Z9AHTDs,1092
|
|
32
|
+
loop_sdk-0.1.0.dist-info/METADATA,sha256=xeFRXv6Ji618e-1gWPsQGoe6piVZjGLUkb2DPeu4Qvk,275
|
|
33
|
+
loop_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
34
|
+
loop_sdk-0.1.0.dist-info/RECORD,,
|