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,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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any