lekit-robot 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,44 @@
1
+ """Public local and remote Robot interfaces."""
2
+
3
+ from .observation import CameraObservation, Observation, ObservationStatus, RobotObservation
4
+ from .remote import (
5
+ ActionMode,
6
+ ActionOutcomeUnknownError,
7
+ ActionReceipt,
8
+ ActionRejectedError,
9
+ AmbiguousRobotNameError,
10
+ ControlBusyError,
11
+ InvalidTargetError,
12
+ RemoteRobot,
13
+ RobotClientError,
14
+ RobotDescriptor,
15
+ RobotDisconnectedError,
16
+ RobotIdentityConflictError,
17
+ RobotNotFoundError,
18
+ TransportUnavailableError,
19
+ UnsupportedCommandError,
20
+ connect,
21
+ )
22
+
23
+ __all__ = [
24
+ "ActionMode",
25
+ "ActionOutcomeUnknownError",
26
+ "ActionReceipt",
27
+ "ActionRejectedError",
28
+ "AmbiguousRobotNameError",
29
+ "CameraObservation",
30
+ "ControlBusyError",
31
+ "InvalidTargetError",
32
+ "Observation",
33
+ "ObservationStatus",
34
+ "RemoteRobot",
35
+ "RobotClientError",
36
+ "RobotDescriptor",
37
+ "RobotDisconnectedError",
38
+ "RobotIdentityConflictError",
39
+ "RobotNotFoundError",
40
+ "RobotObservation",
41
+ "TransportUnavailableError",
42
+ "UnsupportedCommandError",
43
+ "connect",
44
+ ]
@@ -0,0 +1,90 @@
1
+ """Public observation models shared by local and remote Robot interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Literal
9
+
10
+ from numpy.typing import NDArray
11
+
12
+ ObservationStatus = Literal[
13
+ "connected",
14
+ "unavailable",
15
+ "opening",
16
+ "warming_up",
17
+ "reconfiguring",
18
+ "stale",
19
+ "failed",
20
+ ]
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RobotObservation:
25
+ """One best-effort sample from the active Robot generation."""
26
+
27
+ status: ObservationStatus
28
+ state: dict[str, object] | None
29
+ captured_at: datetime | None
30
+ error: str | None = None
31
+ generation: int = 0
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class CameraObservation:
36
+ """One latest Camera sample, or an explicit unavailable state."""
37
+
38
+ status: ObservationStatus
39
+ image: NDArray | None
40
+ captured_at: datetime | None
41
+ fps: int = 0
42
+ error: str | None = None
43
+ camera_id: str | None = None
44
+ name: str | None = None
45
+ generation: int = 0
46
+ frame_sequence: int = 0
47
+ depth: NDArray | None = None
48
+
49
+ @property
50
+ def color(self) -> NDArray | None:
51
+ """Return the color image using the public stream-oriented name."""
52
+ return self.image
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Observation:
57
+ """A versioned Robot state and keyed set of independent Camera samples."""
58
+
59
+ observed_at: datetime
60
+ robot: RobotObservation
61
+ cameras: Mapping[str, CameraObservation]
62
+ node_instance_id: str = ""
63
+ config_revision: int = 0
64
+ robot_id: str = ""
65
+ robot_name: str = ""
66
+
67
+ def camera(
68
+ self,
69
+ identifier: str | None = None,
70
+ *,
71
+ name: str | None = None,
72
+ ) -> CameraObservation:
73
+ """Find one Camera by mapping key, stable ID, or current name."""
74
+ if (identifier is None) == (name is None):
75
+ raise TypeError("Provide exactly one camera identifier or name")
76
+ requested = identifier if identifier is not None else name
77
+ assert requested is not None
78
+ direct = self.cameras.get(requested)
79
+ if direct is not None:
80
+ return direct
81
+ matches = [
82
+ camera
83
+ for camera in self.cameras.values()
84
+ if camera.camera_id == requested or camera.name == requested
85
+ ]
86
+ if not matches:
87
+ raise KeyError(f"Camera {requested!r} is not present in this observation")
88
+ if len(matches) > 1:
89
+ raise KeyError(f"Camera name {requested!r} is ambiguous")
90
+ return matches[0]
@@ -0,0 +1,254 @@
1
+ """Versioned wire contract shared by Robot transport Adapters.
2
+
3
+ This Module deliberately has no Zenoh imports. It owns names, keys, schemas,
4
+ and JSON encoding so transport details cannot leak into Robot Runtime code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import unicodedata
11
+ from typing import Literal, Self
12
+ from urllib.parse import quote
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
+
16
+ PROTOCOL_MAJOR = 1
17
+ PROTOCOL_ROOT = "lekit/v1"
18
+
19
+
20
+ class ProtocolModel(BaseModel):
21
+ """Strict base for all v1 JSON payloads."""
22
+
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+
26
+ class FieldManifest(ProtocolModel):
27
+ key: str
28
+ label: str
29
+ minimum: float
30
+ maximum: float
31
+ step: float
32
+ unit: str | None = None
33
+
34
+
35
+ class CommandManifest(ProtocolModel):
36
+ fields: list[FieldManifest] = Field(default_factory=list)
37
+ modes: list[Literal["stream", "point", "linear"]] = Field(default_factory=list)
38
+
39
+
40
+ class CameraManifest(ProtocolModel):
41
+ camera_id: str
42
+ name: str
43
+ generation: int = Field(ge=0)
44
+ status: str
45
+ streams: list[Literal["color", "depth"]] = Field(default_factory=list)
46
+
47
+
48
+ class RobotManifest(ProtocolModel):
49
+ schema_name: Literal["lekit.robot.manifest.v1"] = "lekit.robot.manifest.v1"
50
+ protocol_major: Literal[1] = 1
51
+ robot_id: str
52
+ robot_name: str
53
+ node_instance_id: str
54
+ config_revision: int = Field(ge=0)
55
+ robot_generation: int = Field(ge=0)
56
+ robot_status: str
57
+ joint_target: CommandManifest | None = None
58
+ eef_target: CommandManifest | None = None
59
+ gripper_target: CommandManifest | None = None
60
+ cameras: list[CameraManifest] = Field(default_factory=list)
61
+
62
+
63
+ class RobotSample(ProtocolModel):
64
+ status: str
65
+ state: dict[str, object] | None = None
66
+ captured_at: str | None = None
67
+ error: str | None = None
68
+ generation: int = Field(ge=0)
69
+
70
+
71
+ class CameraSampleMetadata(ProtocolModel):
72
+ status: str
73
+ captured_at: str | None = None
74
+ fps: int = Field(default=0, ge=0)
75
+ error: str | None = None
76
+ camera_id: str
77
+ name: str
78
+ generation: int = Field(ge=0)
79
+ frame_sequence: int = Field(ge=0)
80
+ has_color: bool = False
81
+ has_depth: bool = False
82
+
83
+
84
+ class ObservationMetadata(ProtocolModel):
85
+ schema_name: Literal["lekit.robot.observation.v1"] = "lekit.robot.observation.v1"
86
+ robot_id: str
87
+ robot_name: str
88
+ node_instance_id: str
89
+ config_revision: int = Field(ge=0)
90
+ observed_at: str
91
+ robot: RobotSample
92
+ cameras: dict[str, CameraSampleMetadata] = Field(default_factory=dict)
93
+
94
+
95
+ class FrameMetadata(ProtocolModel):
96
+ schema_name: Literal["lekit.robot.frame.v1"] = "lekit.robot.frame.v1"
97
+ camera_id: str
98
+ name: str
99
+ stream: Literal["color", "depth"]
100
+ generation: int = Field(ge=0)
101
+ frame_sequence: int = Field(ge=0)
102
+ captured_at: str | None = None
103
+ pixel_format: str
104
+
105
+
106
+ class LeaseRequest(ProtocolModel):
107
+ schema_name: Literal["lekit.robot.lease-request.v1"] = "lekit.robot.lease-request.v1"
108
+ operation: Literal["acquire", "renew", "release"]
109
+ controller_session_id: str
110
+ lease_id: str | None = None
111
+ fencing_token: int | None = Field(default=None, ge=1)
112
+
113
+
114
+ class LeaseReceipt(ProtocolModel):
115
+ schema_name: Literal["lekit.robot.lease-receipt.v1"] = "lekit.robot.lease-receipt.v1"
116
+ accepted: bool
117
+ code: str
118
+ lease_id: str | None = None
119
+ fencing_token: int | None = Field(default=None, ge=1)
120
+ ttl_ms: int = Field(default=0, ge=0)
121
+ message: str | None = None
122
+
123
+
124
+ class ActionRequest(ProtocolModel):
125
+ schema_name: Literal["lekit.robot.action-request.v1"] = "lekit.robot.action-request.v1"
126
+ request_id: str
127
+ controller_session_id: str
128
+ client_sequence: int = Field(ge=1)
129
+ lease_id: str
130
+ fencing_token: int = Field(ge=1)
131
+ robot_id: str
132
+ node_instance_id: str
133
+ expected_robot_generation: int = Field(ge=0)
134
+ sent_at_unix_ns: int = Field(gt=0)
135
+ max_age_ms: int = Field(default=1000, ge=1, le=60_000)
136
+ kind: Literal["joint_target", "eef_target", "gripper_target"]
137
+ mode: Literal["stream", "point", "linear"] | None = None
138
+ values: dict[str, float]
139
+
140
+ @model_validator(mode="after")
141
+ def values_are_finite(self) -> Self:
142
+ if not self.values or any(not math.isfinite(value) for value in self.values.values()):
143
+ raise ValueError("Action values must be non-empty finite numbers")
144
+ if self.kind == "eef_target" and self.mode is None:
145
+ raise ValueError("EEF targets require a mode")
146
+ if self.kind != "eef_target" and self.mode is not None:
147
+ raise ValueError("Joint and gripper targets do not accept a mode")
148
+ return self
149
+
150
+
151
+ class ActionReceiptPayload(ProtocolModel):
152
+ schema_name: Literal["lekit.robot.action-receipt.v1"] = "lekit.robot.action-receipt.v1"
153
+ request_id: str
154
+ status: Literal["accepted", "duplicate", "rejected"]
155
+ code: str
156
+ robot_generation: int = Field(ge=0)
157
+ accepted_at_unix_ns: int | None = None
158
+ applied_values: dict[str, float] | None = None
159
+ message: str | None = None
160
+
161
+
162
+ def normalize_robot_name(name: str) -> str:
163
+ """Normalize a display name for exact, case-insensitive discovery."""
164
+ normalized = unicodedata.normalize("NFKC", name).strip()
165
+ if not normalized:
166
+ raise ValueError("Robot name must not be empty")
167
+ if len(normalized.encode("utf-8")) > 64:
168
+ raise ValueError("Robot name must be at most 64 UTF-8 bytes")
169
+ return normalized.casefold()
170
+
171
+
172
+ def key_chunk(value: str) -> str:
173
+ """Encode one opaque identifier as a single safe Zenoh key chunk."""
174
+ normalized = unicodedata.normalize("NFKC", value).strip()
175
+ if not normalized:
176
+ raise ValueError("Key value must not be empty")
177
+ return quote(normalized, safe="abcdefghijklmnopqrstuvwxyz0123456789_.-")
178
+
179
+
180
+ def name_key(name: str) -> str:
181
+ return key_chunk(normalize_robot_name(name))
182
+
183
+
184
+ def live_robot_key(robot_id: str, node_instance_id: str) -> str:
185
+ return f"{PROTOCOL_ROOT}/live/robots/{key_chunk(robot_id)}/{key_chunk(node_instance_id)}"
186
+
187
+
188
+ def live_name_key(robot_name: str, robot_id: str, node_instance_id: str) -> str:
189
+ return f"{PROTOCOL_ROOT}/live/names/{name_key(robot_name)}/{key_chunk(robot_id)}/{key_chunk(node_instance_id)}"
190
+
191
+
192
+ def live_name_selector(robot_name: str) -> str:
193
+ return f"{PROTOCOL_ROOT}/live/names/{name_key(robot_name)}/*/*"
194
+
195
+
196
+ def robot_root(robot_id: str, node_instance_id: str) -> str:
197
+ return f"{PROTOCOL_ROOT}/robots/{key_chunk(robot_id)}/{key_chunk(node_instance_id)}"
198
+
199
+
200
+ def manifest_key(robot_id: str, node_instance_id: str) -> str:
201
+ return f"{robot_root(robot_id, node_instance_id)}/manifest"
202
+
203
+
204
+ def observation_selector(robot_id: str, node_instance_id: str) -> str:
205
+ return f"{robot_root(robot_id, node_instance_id)}/observations/current/**"
206
+
207
+
208
+ def observation_meta_key(robot_id: str, node_instance_id: str) -> str:
209
+ return f"{robot_root(robot_id, node_instance_id)}/observations/current/meta"
210
+
211
+
212
+ def observation_frame_key(
213
+ robot_id: str,
214
+ node_instance_id: str,
215
+ camera_id: str,
216
+ stream: Literal["color", "depth"],
217
+ ) -> str:
218
+ return (
219
+ f"{robot_root(robot_id, node_instance_id)}/observations/current/"
220
+ f"cameras/{key_chunk(camera_id)}/{stream}"
221
+ )
222
+
223
+
224
+ def state_stream_key(robot_id: str, node_instance_id: str) -> str:
225
+ return f"{robot_root(robot_id, node_instance_id)}/streams/state"
226
+
227
+
228
+ def camera_stream_key(
229
+ robot_id: str,
230
+ node_instance_id: str,
231
+ camera_id: str,
232
+ stream: Literal["color", "depth"],
233
+ ) -> str:
234
+ return f"{robot_root(robot_id, node_instance_id)}/streams/cameras/{key_chunk(camera_id)}/{stream}"
235
+
236
+
237
+ def camera_stream_selector(robot_id: str, node_instance_id: str) -> str:
238
+ return f"{robot_root(robot_id, node_instance_id)}/streams/cameras/**"
239
+
240
+
241
+ def lease_key(robot_id: str, node_instance_id: str) -> str:
242
+ return f"{robot_root(robot_id, node_instance_id)}/control/lease"
243
+
244
+
245
+ def command_key(robot_id: str, node_instance_id: str) -> str:
246
+ return f"{robot_root(robot_id, node_instance_id)}/commands"
247
+
248
+
249
+ def encode_json(model: BaseModel) -> bytes:
250
+ return model.model_dump_json(by_alias=True).encode("utf-8")
251
+
252
+
253
+ def decode_json(model_type: type[ProtocolModel], payload: bytes | str) -> ProtocolModel:
254
+ return model_type.model_validate_json(payload)
lekit_robot/py.typed ADDED
File without changes