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.
- lekit_robot/__init__.py +44 -0
- lekit_robot/observation.py +90 -0
- lekit_robot/protocol.py +254 -0
- lekit_robot/py.typed +0 -0
- lekit_robot/remote.py +374 -0
- lekit_robot/zenoh_client.py +488 -0
- lekit_robot-0.1.0.dist-info/METADATA +160 -0
- lekit_robot-0.1.0.dist-info/RECORD +10 -0
- lekit_robot-0.1.0.dist-info/WHEEL +4 -0
- lekit_robot-0.1.0.dist-info/licenses/LICENSE +176 -0
lekit_robot/__init__.py
ADDED
|
@@ -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]
|
lekit_robot/protocol.py
ADDED
|
@@ -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
|