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,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