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,193 @@
|
|
|
1
|
+
"""Source schema: the sources the SDK declares to the Source Bus.
|
|
2
|
+
|
|
3
|
+
The Source Bus drives discovery: it sends ``Describe`` and the SDK answers from
|
|
4
|
+
this schema (see ``docs/spec_discrepancies.md`` item 10). The schema is declared
|
|
5
|
+
once at connect time and replayed on every ``Describe``, so it must be answerable
|
|
6
|
+
synchronously without probing devices.
|
|
7
|
+
|
|
8
|
+
Each stream maps 1:1 to a Source Bus source: a ``source_id``, a kind, and the
|
|
9
|
+
kind's scan-time layout. A robot stream's ``channels`` declare the keys every
|
|
10
|
+
``RobotPayload.state`` sample may carry (each sample is a dict keyed by channel
|
|
11
|
+
key; a key may be omitted for "no reading this sample").
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import warnings
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
from loop_sdk.source.domain.config import RobotConfigOptions
|
|
22
|
+
from loop_sdk.source.domain.source_kind import SourceKind
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChannelRole(str, Enum):
|
|
26
|
+
"""Data-kind of a channel, by its role in THIS source's purpose (closed set).
|
|
27
|
+
|
|
28
|
+
Purpose-relative, not robot-boundary: ``CORE`` is the essential payload the
|
|
29
|
+
source exists to carry (the action on an action source, the measured state on a
|
|
30
|
+
state source); ``AUX`` is supplementary provenance/metadata carried alongside
|
|
31
|
+
(values used to derive the action, source labels, diagnostics, mode/task
|
|
32
|
+
context). "Actually executed vs candidate" is distinguished by *which* source it
|
|
33
|
+
is (state source = ground truth, action source = command), not by this role.
|
|
34
|
+
Maps to ``pb2.ChannelRole``. A value outside this set is rejected at declaration
|
|
35
|
+
time (pydantic raises on construction)."""
|
|
36
|
+
|
|
37
|
+
CORE = "core" # the essential signal this source exists to carry
|
|
38
|
+
AUX = "aux" # supplementary provenance/metadata carried alongside
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RotationType(str, Enum):
|
|
42
|
+
"""Orientation encoding for a rotation channel (closed set).
|
|
43
|
+
|
|
44
|
+
Maps to ``pb2.RobotRotationType``. Omit (``None``) for a non-rotation channel —
|
|
45
|
+
a scalar angle uses ``unit='rad'``, not a rotation type."""
|
|
46
|
+
|
|
47
|
+
QUATERNION = "quaternion"
|
|
48
|
+
EULER = "euler"
|
|
49
|
+
ROTATION_6D = "rotation_6d"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Recommended physical units. Units are unbounded (mm, N, kPa, …), so this is NOT
|
|
53
|
+
# enforced: an unrecognized unit warns rather than rejects (see ``ChannelSpec``).
|
|
54
|
+
RECOMMENDED_UNITS = frozenset({
|
|
55
|
+
"rad", "rad/s", "rad/s²", "m", "m/s", "m/s²", "normalized", "normalized/s",
|
|
56
|
+
"N", "N·m", "quat", "rot6d", "ms", "bool", "enum", "g",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ChannelSpec(BaseModel):
|
|
61
|
+
"""One ordered dimension of a robot-state vector.
|
|
62
|
+
|
|
63
|
+
``key`` is the client's verbatim channel name, preserved losslessly. ``role``
|
|
64
|
+
and ``rot_type`` are closed enums (invalid values raise on construction);
|
|
65
|
+
``unit`` is semi-open (a non-recommended value warns, not raises)."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(frozen=True)
|
|
68
|
+
|
|
69
|
+
key: str
|
|
70
|
+
label: str = ""
|
|
71
|
+
role: ChannelRole | None = None
|
|
72
|
+
unit: str = ""
|
|
73
|
+
rot_type: RotationType | None = None
|
|
74
|
+
group: str = ""
|
|
75
|
+
range: tuple[float, float] | None = None
|
|
76
|
+
|
|
77
|
+
@field_validator("unit")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _warn_unrecommended_unit(cls, value: str) -> str:
|
|
80
|
+
if value and value not in RECOMMENDED_UNITS:
|
|
81
|
+
warnings.warn(
|
|
82
|
+
f"unit {value!r} is not in the recommended set {sorted(RECOMMENDED_UNITS)}; "
|
|
83
|
+
"it is accepted as-is",
|
|
84
|
+
stacklevel=2,
|
|
85
|
+
)
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def _check_range(self) -> ChannelSpec:
|
|
90
|
+
if self.range is not None and self.range[0] > self.range[1]:
|
|
91
|
+
raise ValueError(f"range min {self.range[0]} exceeds max {self.range[1]} for channel {self.key!r}")
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RobotStreamSchema(BaseModel):
|
|
96
|
+
"""A robot-state source: a named channel layout, one heterogeneous state dict per sample."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True)
|
|
99
|
+
|
|
100
|
+
source_id: str
|
|
101
|
+
name: str = ""
|
|
102
|
+
channels: tuple[ChannelSpec, ...] = Field(min_length=1)
|
|
103
|
+
# Per-axis config candidates the source can open with. Empty (the default)
|
|
104
|
+
# means no negotiation: the Source Bus opens it with the device default and
|
|
105
|
+
# no ``on_select`` callback fires.
|
|
106
|
+
available_options: RobotConfigOptions = RobotConfigOptions()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def kind(self) -> SourceKind:
|
|
110
|
+
return SourceKind.ROBOT
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def channel_keys(self) -> frozenset[str]:
|
|
114
|
+
"""The declared channel keys a sample's ``state`` may carry."""
|
|
115
|
+
return frozenset(channel.key for channel in self.channels)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TactileChannelSpec(BaseModel):
|
|
119
|
+
"""One tactile channel descriptor."""
|
|
120
|
+
|
|
121
|
+
model_config = ConfigDict(frozen=True)
|
|
122
|
+
|
|
123
|
+
key: str
|
|
124
|
+
label: str = ""
|
|
125
|
+
sample_format: str = ""
|
|
126
|
+
sample_count: int = 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TactileStreamSchema(BaseModel):
|
|
130
|
+
"""A tactile source carrying its channel layout."""
|
|
131
|
+
|
|
132
|
+
model_config = ConfigDict(frozen=True)
|
|
133
|
+
|
|
134
|
+
source_id: str
|
|
135
|
+
name: str = ""
|
|
136
|
+
channels: tuple[TactileChannelSpec, ...] = Field(min_length=1)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def kind(self) -> SourceKind:
|
|
140
|
+
return SourceKind.TACTILE
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class MarkerStreamSchema(BaseModel):
|
|
144
|
+
"""A marker source. Markers are sparse, event-like data-plane samples and carry no layout."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(frozen=True)
|
|
147
|
+
|
|
148
|
+
source_id: str
|
|
149
|
+
name: str = ""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def kind(self) -> SourceKind:
|
|
153
|
+
return SourceKind.MARKER
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SourceSchema(BaseModel):
|
|
157
|
+
"""The full set of sources the SDK declares to the Source Bus."""
|
|
158
|
+
|
|
159
|
+
model_config = ConfigDict(frozen=True)
|
|
160
|
+
|
|
161
|
+
robot: tuple[RobotStreamSchema, ...] = ()
|
|
162
|
+
tactile: tuple[TactileStreamSchema, ...] = ()
|
|
163
|
+
marker: tuple[MarkerStreamSchema, ...] = ()
|
|
164
|
+
|
|
165
|
+
@model_validator(mode="after")
|
|
166
|
+
def _check_non_empty_and_unique(self) -> SourceSchema:
|
|
167
|
+
streams = (*self.robot, *self.tactile, *self.marker)
|
|
168
|
+
if not streams:
|
|
169
|
+
raise ValueError("SourceSchema must declare at least one source")
|
|
170
|
+
ids = [stream.source_id for stream in streams]
|
|
171
|
+
if len(ids) != len(set(ids)):
|
|
172
|
+
raise ValueError("source_id values must be unique across all streams")
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def kind_of(self, source_id: str) -> SourceKind:
|
|
176
|
+
"""Return the kind of a declared source, raising ``KeyError`` if unknown."""
|
|
177
|
+
for stream in self.robot:
|
|
178
|
+
if stream.source_id == source_id:
|
|
179
|
+
return SourceKind.ROBOT
|
|
180
|
+
for stream in self.tactile:
|
|
181
|
+
if stream.source_id == source_id:
|
|
182
|
+
return SourceKind.TACTILE
|
|
183
|
+
for stream in self.marker:
|
|
184
|
+
if stream.source_id == source_id:
|
|
185
|
+
return SourceKind.MARKER
|
|
186
|
+
raise KeyError(source_id)
|
|
187
|
+
|
|
188
|
+
def robot_channel_keys(self, source_id: str) -> frozenset[str]:
|
|
189
|
+
"""Return the declared channel keys for a robot source."""
|
|
190
|
+
for stream in self.robot:
|
|
191
|
+
if stream.source_id == source_id:
|
|
192
|
+
return stream.channel_keys
|
|
193
|
+
raise KeyError(source_id)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Source SDK domain exceptions.
|
|
2
|
+
|
|
3
|
+
Business-rule violations the SDK surfaces to the customer. The outbound adapter
|
|
4
|
+
translates transport-level failures (``grpc.RpcError`` and the like) into these
|
|
5
|
+
before they propagate, per the project exception rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SourceError(Exception):
|
|
12
|
+
"""Base class for all Source SDK errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SchemaMismatchError(SourceError):
|
|
16
|
+
"""A frame does not match the declared schema for its source."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, source_id: str, detail: str) -> None:
|
|
19
|
+
super().__init__(f"frame for source {source_id!r} does not match schema: {detail}")
|
|
20
|
+
self.source_id = source_id
|
|
21
|
+
self.detail = detail
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnknownSourceError(SourceError):
|
|
25
|
+
"""A frame names a source that was not declared in the schema."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, source_id: str) -> None:
|
|
28
|
+
super().__init__(f"source {source_id!r} was not declared in the source schema")
|
|
29
|
+
self.source_id = source_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotConnectedError(SourceError):
|
|
33
|
+
"""An operation requires an active connection that does not exist."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubscriptionFailedError(SourceError):
|
|
37
|
+
"""A subscription ended in failure.
|
|
38
|
+
|
|
39
|
+
The Source Bus reports a per-source fault (``STREAM_STATE_FAILED``) on the
|
|
40
|
+
read stream — most commonly because the source is not open (no producer
|
|
41
|
+
streaming it), or because the underlying source terminated abnormally.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, source_id: str, detail: str = "") -> None:
|
|
45
|
+
suffix = f": {detail}" if detail else ""
|
|
46
|
+
super().__init__(f"subscription to source {source_id!r} failed{suffix}")
|
|
47
|
+
self.source_id = source_id
|
|
48
|
+
self.detail = detail
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Source-kind discriminator for the Source Bus SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the kinds the Source Bus gRPC contract carries (camera is excluded from
|
|
4
|
+
the contract and therefore from this SDK).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SourceKind(str, Enum):
|
|
13
|
+
"""The source kinds the SDK can declare and stream."""
|
|
14
|
+
|
|
15
|
+
TACTILE = "tactile"
|
|
16
|
+
ROBOT = "robot"
|
|
17
|
+
MARKER = "marker"
|
|
18
|
+
POLICY_ACTION = "policy_action" # read-plane only: actions consumed back from the bus
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Send/connection statistics surfaced to the customer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SendStats(BaseModel):
|
|
9
|
+
"""Counters for the SDK send pipeline and connection liveness.
|
|
10
|
+
|
|
11
|
+
``Loop`` cannot reach into the customer process, so these are the SDK's own
|
|
12
|
+
view of how delivery is going (see ``docs/spec_discrepancies.md`` item 12).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(frozen=True)
|
|
16
|
+
|
|
17
|
+
enqueued: int = 0
|
|
18
|
+
sent: int = 0
|
|
19
|
+
failed: int = 0
|
|
20
|
+
dropped: int = 0
|
|
21
|
+
reconnects: int = 0
|
|
22
|
+
connected: bool = False
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Latest-only frame queue for the send pipeline.
|
|
2
|
+
|
|
3
|
+
``send()`` runs on the customer's real-time control thread, so enqueue must never
|
|
4
|
+
block. The queue holds only the most recent frame: a new frame replaces an
|
|
5
|
+
unsent one, and the replaced frame is counted as dropped (see
|
|
6
|
+
``docs/spec_discrepancies.md`` item 15). This keeps the stream at the freshest
|
|
7
|
+
state under backpressure rather than falling behind. A sentinel ``None`` unblocks
|
|
8
|
+
the sender thread on shutdown.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from collections import deque
|
|
15
|
+
|
|
16
|
+
from loop_sdk.source.domain.frame import FrameSample
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FrameQueue:
|
|
20
|
+
"""A thread-safe queue that keeps only the latest frame (drops the previous one)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._items: deque[FrameSample] = deque(maxlen=1)
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self._not_empty = threading.Condition(self._lock)
|
|
26
|
+
self._closed = False
|
|
27
|
+
self._woken = False
|
|
28
|
+
self._dropped = 0
|
|
29
|
+
|
|
30
|
+
def put(self, frame: FrameSample) -> None:
|
|
31
|
+
"""Enqueue a frame without blocking, replacing an unsent one (counted as dropped)."""
|
|
32
|
+
with self._not_empty:
|
|
33
|
+
if self._closed:
|
|
34
|
+
return
|
|
35
|
+
if self._items:
|
|
36
|
+
self._dropped += 1
|
|
37
|
+
self._items.append(frame) # maxlen=1 evicts the previous frame
|
|
38
|
+
self._not_empty.notify()
|
|
39
|
+
|
|
40
|
+
def get(self) -> FrameSample | None:
|
|
41
|
+
"""Block until a frame is available; return ``None`` once closed or woken.
|
|
42
|
+
|
|
43
|
+
A pending wake takes priority over queued items so the consumer (a
|
|
44
|
+
per-session sender) retires promptly on session end. Queued frames are
|
|
45
|
+
left in place for the next session — the queue survives reconnects.
|
|
46
|
+
"""
|
|
47
|
+
with self._not_empty:
|
|
48
|
+
while not self._items and not self._closed and not self._woken:
|
|
49
|
+
self._not_empty.wait()
|
|
50
|
+
if self._woken:
|
|
51
|
+
self._woken = False
|
|
52
|
+
return None
|
|
53
|
+
if self._closed and not self._items:
|
|
54
|
+
return None
|
|
55
|
+
return self._items.popleft()
|
|
56
|
+
|
|
57
|
+
def wake(self) -> None:
|
|
58
|
+
"""Unblock a waiting ``get()`` once so it returns ``None`` without closing the queue.
|
|
59
|
+
|
|
60
|
+
Used to retire a per-session sender thread while keeping the queue open
|
|
61
|
+
across reconnects.
|
|
62
|
+
"""
|
|
63
|
+
with self._not_empty:
|
|
64
|
+
self._woken = True
|
|
65
|
+
self._not_empty.notify_all()
|
|
66
|
+
|
|
67
|
+
def reset_wake(self) -> None:
|
|
68
|
+
"""Clear a pending wake at the start of a new session.
|
|
69
|
+
|
|
70
|
+
Guards against a wake set at the end of one session leaking into the
|
|
71
|
+
next and prematurely retiring its sender.
|
|
72
|
+
"""
|
|
73
|
+
with self._not_empty:
|
|
74
|
+
self._woken = False
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
"""Mark the queue closed and wake any waiting consumer."""
|
|
78
|
+
with self._not_empty:
|
|
79
|
+
self._closed = True
|
|
80
|
+
self._not_empty.notify_all()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def dropped(self) -> int:
|
|
84
|
+
with self._lock:
|
|
85
|
+
return self._dropped
|