embodied-ops 0.2.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.
- embodied_ops/__init__.py +66 -0
- embodied_ops/_version.py +1 -0
- embodied_ops/device.py +134 -0
- embodied_ops/endpoints.py +22 -0
- embodied_ops/errors.py +25 -0
- embodied_ops/features.py +115 -0
- embodied_ops/py.typed +0 -0
- embodied_ops/registry.py +91 -0
- embodied_ops/rpc/__init__.py +13 -0
- embodied_ops/rpc/_codec.py +158 -0
- embodied_ops/rpc/client.py +320 -0
- embodied_ops/rpc/server.py +503 -0
- embodied_ops/rpc/types.py +36 -0
- embodied_ops/rpc/v1/__init__.py +1 -0
- embodied_ops/rpc/v1/device.proto +111 -0
- embodied_ops/rpc/v1/device_pb2.py +72 -0
- embodied_ops/rpc/v1/device_pb2_grpc.py +485 -0
- embodied_ops-0.2.0.dist-info/METADATA +135 -0
- embodied_ops-0.2.0.dist-info/RECORD +22 -0
- embodied_ops-0.2.0.dist-info/WHEEL +5 -0
- embodied_ops-0.2.0.dist-info/licenses/LICENSE +176 -0
- embodied_ops-0.2.0.dist-info/top_level.txt +1 -0
embodied_ops/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Public capability protocols for embodied systems."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .device import (
|
|
5
|
+
API_VERSION,
|
|
6
|
+
CalibratableDevice,
|
|
7
|
+
Capability,
|
|
8
|
+
CommandDevice,
|
|
9
|
+
CommandLeaseDevice,
|
|
10
|
+
DeviceManifest,
|
|
11
|
+
HealthReport,
|
|
12
|
+
HealthStatus,
|
|
13
|
+
ObservableDevice,
|
|
14
|
+
OperationalDevice,
|
|
15
|
+
ResettableDevice,
|
|
16
|
+
)
|
|
17
|
+
from .errors import (
|
|
18
|
+
BackendConflictError,
|
|
19
|
+
BackendNotFoundError,
|
|
20
|
+
ContractError,
|
|
21
|
+
EmbodiedOpsError,
|
|
22
|
+
LifecycleError,
|
|
23
|
+
RpcError,
|
|
24
|
+
)
|
|
25
|
+
from .endpoints import unix_socket_path
|
|
26
|
+
from .features import FeatureKind, FeatureSpec, index_features, validate_feature_values
|
|
27
|
+
from .registry import (
|
|
28
|
+
BACKEND_ENTRY_POINT_GROUP,
|
|
29
|
+
BackendFactory,
|
|
30
|
+
BackendRegistry,
|
|
31
|
+
create_device,
|
|
32
|
+
default_registry,
|
|
33
|
+
device_session,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"API_VERSION",
|
|
38
|
+
"BACKEND_ENTRY_POINT_GROUP",
|
|
39
|
+
"BackendConflictError",
|
|
40
|
+
"BackendFactory",
|
|
41
|
+
"BackendNotFoundError",
|
|
42
|
+
"BackendRegistry",
|
|
43
|
+
"CalibratableDevice",
|
|
44
|
+
"Capability",
|
|
45
|
+
"CommandDevice",
|
|
46
|
+
"CommandLeaseDevice",
|
|
47
|
+
"ContractError",
|
|
48
|
+
"DeviceManifest",
|
|
49
|
+
"EmbodiedOpsError",
|
|
50
|
+
"FeatureKind",
|
|
51
|
+
"FeatureSpec",
|
|
52
|
+
"HealthReport",
|
|
53
|
+
"HealthStatus",
|
|
54
|
+
"LifecycleError",
|
|
55
|
+
"ObservableDevice",
|
|
56
|
+
"OperationalDevice",
|
|
57
|
+
"ResettableDevice",
|
|
58
|
+
"RpcError",
|
|
59
|
+
"__version__",
|
|
60
|
+
"create_device",
|
|
61
|
+
"default_registry",
|
|
62
|
+
"device_session",
|
|
63
|
+
"index_features",
|
|
64
|
+
"unix_socket_path",
|
|
65
|
+
"validate_feature_values",
|
|
66
|
+
]
|
embodied_ops/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
embodied_ops/device.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Capability-oriented device contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from .errors import ContractError
|
|
12
|
+
from .features import FeatureSpec, index_features
|
|
13
|
+
|
|
14
|
+
API_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Capability(str, Enum):
|
|
18
|
+
OBSERVE = "observe"
|
|
19
|
+
COMMAND = "command"
|
|
20
|
+
HEALTH = "health"
|
|
21
|
+
CALIBRATE = "calibrate"
|
|
22
|
+
RESET = "reset"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HealthStatus(str, Enum):
|
|
26
|
+
UNKNOWN = "unknown"
|
|
27
|
+
HEALTHY = "healthy"
|
|
28
|
+
DEGRADED = "degraded"
|
|
29
|
+
FAULT = "fault"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class HealthReport:
|
|
34
|
+
status: HealthStatus
|
|
35
|
+
summary: str
|
|
36
|
+
details: Mapping[str, object] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
if not self.summary:
|
|
40
|
+
raise ContractError("health summary must not be empty")
|
|
41
|
+
object.__setattr__(self, "details", MappingProxyType(dict(self.details)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class DeviceManifest:
|
|
46
|
+
"""Backend-neutral description of one operational device."""
|
|
47
|
+
|
|
48
|
+
identifier: str
|
|
49
|
+
capabilities: tuple[Capability, ...]
|
|
50
|
+
observation_features: tuple[FeatureSpec, ...] = ()
|
|
51
|
+
action_features: tuple[FeatureSpec, ...] = ()
|
|
52
|
+
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
53
|
+
api_version: int = API_VERSION
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if not self.identifier or self.identifier.strip() != self.identifier:
|
|
57
|
+
raise ContractError(f"invalid device identifier: {self.identifier!r}")
|
|
58
|
+
if self.api_version != API_VERSION:
|
|
59
|
+
raise ContractError(
|
|
60
|
+
f"unsupported manifest API version {self.api_version}; expected {API_VERSION}"
|
|
61
|
+
)
|
|
62
|
+
if len(set(self.capabilities)) != len(self.capabilities):
|
|
63
|
+
raise ContractError("device capabilities must be unique")
|
|
64
|
+
index_features(self.observation_features)
|
|
65
|
+
index_features(self.action_features)
|
|
66
|
+
if self.observation_features and Capability.OBSERVE not in self.capabilities:
|
|
67
|
+
raise ContractError("observation features require the observe capability")
|
|
68
|
+
if self.action_features and Capability.COMMAND not in self.capabilities:
|
|
69
|
+
raise ContractError("action features require the command capability")
|
|
70
|
+
object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, object]:
|
|
73
|
+
return {
|
|
74
|
+
"api_version": self.api_version,
|
|
75
|
+
"identifier": self.identifier,
|
|
76
|
+
"capabilities": [capability.value for capability in self.capabilities],
|
|
77
|
+
"observation_features": [feature.to_dict() for feature in self.observation_features],
|
|
78
|
+
"action_features": [feature.to_dict() for feature in self.action_features],
|
|
79
|
+
"metadata": dict(self.metadata),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@runtime_checkable
|
|
84
|
+
class OperationalDevice(Protocol):
|
|
85
|
+
"""Minimum lifecycle and health surface implemented by every backend."""
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def manifest(self) -> DeviceManifest: ...
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_connected(self) -> bool: ...
|
|
92
|
+
|
|
93
|
+
def connect(self) -> None: ...
|
|
94
|
+
|
|
95
|
+
def health(self) -> HealthReport: ...
|
|
96
|
+
|
|
97
|
+
def disconnect(self) -> None: ...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@runtime_checkable
|
|
101
|
+
class ObservableDevice(OperationalDevice, Protocol):
|
|
102
|
+
def observe(self) -> Mapping[str, object]: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@runtime_checkable
|
|
106
|
+
class CommandDevice(OperationalDevice, Protocol):
|
|
107
|
+
def command(self, action: Mapping[str, object]) -> Mapping[str, object]: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@runtime_checkable
|
|
111
|
+
class CommandLeaseDevice(CommandDevice, Protocol):
|
|
112
|
+
"""Optional hooks for transports that grant exclusive command ownership.
|
|
113
|
+
|
|
114
|
+
Observation resources may remain connected while a transport acquires and
|
|
115
|
+
releases the command path independently. Implementations must make release
|
|
116
|
+
fail closed and idempotent.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def acquire_command_lease(self) -> None: ...
|
|
120
|
+
|
|
121
|
+
def release_command_lease(self) -> None: ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@runtime_checkable
|
|
125
|
+
class CalibratableDevice(OperationalDevice, Protocol):
|
|
126
|
+
@property
|
|
127
|
+
def is_calibrated(self) -> bool: ...
|
|
128
|
+
|
|
129
|
+
def calibrate(self) -> None: ...
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@runtime_checkable
|
|
133
|
+
class ResettableDevice(OperationalDevice, Protocol):
|
|
134
|
+
def reset(self, target: str | None = None) -> None: ...
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Dependency-free transport endpoint validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import ContractError
|
|
8
|
+
|
|
9
|
+
UNIX_PREFIX = "unix://"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def unix_socket_path(endpoint: str) -> Path:
|
|
13
|
+
if not isinstance(endpoint, str) or endpoint.strip() != endpoint:
|
|
14
|
+
raise ContractError(f"invalid RPC endpoint: {endpoint!r}")
|
|
15
|
+
if not endpoint.startswith("unix:///"):
|
|
16
|
+
raise ContractError("RPC endpoint must use an absolute unix:/// path")
|
|
17
|
+
path = Path(endpoint.removeprefix(UNIX_PREFIX))
|
|
18
|
+
if not path.is_absolute() or str(path) == "/":
|
|
19
|
+
raise ContractError("RPC Unix socket path must be absolute and non-root")
|
|
20
|
+
if len(str(path).encode()) > 100:
|
|
21
|
+
raise ContractError("RPC Unix socket path is too long for portable AF_UNIX use")
|
|
22
|
+
return path
|
embodied_ops/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Public exception hierarchy for embodied-ops."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EmbodiedOpsError(Exception):
|
|
5
|
+
"""Base class for SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContractError(EmbodiedOpsError, ValueError):
|
|
9
|
+
"""A manifest, feature value, or backend violates the public contract."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendNotFoundError(EmbodiedOpsError, LookupError):
|
|
13
|
+
"""No installed backend is registered under the requested name."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BackendConflictError(EmbodiedOpsError):
|
|
17
|
+
"""More than one installed distribution owns the same backend name."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LifecycleError(EmbodiedOpsError, RuntimeError):
|
|
21
|
+
"""A device lifecycle operation is invalid or failed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RpcError(EmbodiedOpsError, RuntimeError):
|
|
25
|
+
"""A versioned device RPC could not be completed."""
|
embodied_ops/features.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Feature manifests and hardware-free value validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import numbers
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
|
|
12
|
+
from .errors import ContractError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FeatureKind(str, Enum):
|
|
16
|
+
"""Portable feature categories understood by SDK adapters."""
|
|
17
|
+
|
|
18
|
+
SCALAR = "scalar"
|
|
19
|
+
VECTOR = "vector"
|
|
20
|
+
IMAGE = "image"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class FeatureSpec:
|
|
25
|
+
"""Describes one named observation or action feature."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
kind: FeatureKind = FeatureKind.SCALAR
|
|
29
|
+
dtype: str = "float32"
|
|
30
|
+
shape: tuple[int, ...] = ()
|
|
31
|
+
unit: str | None = None
|
|
32
|
+
minimum: float | None = None
|
|
33
|
+
maximum: float | None = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not self.name or self.name.strip() != self.name or any(c.isspace() for c in self.name):
|
|
37
|
+
raise ContractError(f"invalid feature name: {self.name!r}")
|
|
38
|
+
if not self.dtype:
|
|
39
|
+
raise ContractError(f"feature {self.name!r} requires a dtype")
|
|
40
|
+
if any(
|
|
41
|
+
not isinstance(size, int) or isinstance(size, bool) or size <= 0 for size in self.shape
|
|
42
|
+
):
|
|
43
|
+
raise ContractError(f"feature {self.name!r} has an invalid shape: {self.shape!r}")
|
|
44
|
+
if self.kind is FeatureKind.SCALAR and self.shape:
|
|
45
|
+
raise ContractError(f"scalar feature {self.name!r} must have shape=()")
|
|
46
|
+
if self.kind is not FeatureKind.SCALAR and not self.shape:
|
|
47
|
+
raise ContractError(f"{self.kind.value} feature {self.name!r} requires a shape")
|
|
48
|
+
for label, value in (("minimum", self.minimum), ("maximum", self.maximum)):
|
|
49
|
+
if value is not None and not math.isfinite(float(value)):
|
|
50
|
+
raise ContractError(f"feature {self.name!r} has non-finite {label}")
|
|
51
|
+
if self.minimum is not None and self.maximum is not None and self.minimum > self.maximum:
|
|
52
|
+
raise ContractError(f"feature {self.name!r} minimum exceeds maximum")
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, object]:
|
|
55
|
+
return {
|
|
56
|
+
"name": self.name,
|
|
57
|
+
"kind": self.kind.value,
|
|
58
|
+
"dtype": self.dtype,
|
|
59
|
+
"shape": list(self.shape),
|
|
60
|
+
"unit": self.unit,
|
|
61
|
+
"minimum": self.minimum,
|
|
62
|
+
"maximum": self.maximum,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def index_features(features: tuple[FeatureSpec, ...]) -> Mapping[str, FeatureSpec]:
|
|
67
|
+
"""Return an immutable name index and reject ambiguous manifests."""
|
|
68
|
+
|
|
69
|
+
indexed: dict[str, FeatureSpec] = {}
|
|
70
|
+
for feature in features:
|
|
71
|
+
if feature.name in indexed:
|
|
72
|
+
raise ContractError(f"duplicate feature name: {feature.name!r}")
|
|
73
|
+
indexed[feature.name] = feature
|
|
74
|
+
return MappingProxyType(indexed)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_feature_values(
|
|
78
|
+
values: Mapping[str, object],
|
|
79
|
+
features: tuple[FeatureSpec, ...],
|
|
80
|
+
*,
|
|
81
|
+
exact: bool = True,
|
|
82
|
+
) -> dict[str, object]:
|
|
83
|
+
"""Validate a feature mapping without rewriting or clamping it.
|
|
84
|
+
|
|
85
|
+
Scalar values are checked exhaustively. Vector and image payload ownership stays
|
|
86
|
+
with the backend because this dependency-free SDK does not impose an array library.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
specs = index_features(features)
|
|
90
|
+
keys = set(values)
|
|
91
|
+
expected = set(specs)
|
|
92
|
+
missing = expected - keys
|
|
93
|
+
unknown = keys - expected
|
|
94
|
+
if missing:
|
|
95
|
+
raise ContractError(f"missing feature values: {sorted(missing)}")
|
|
96
|
+
if exact and unknown:
|
|
97
|
+
raise ContractError(f"unknown feature values: {sorted(unknown)}")
|
|
98
|
+
|
|
99
|
+
validated: dict[str, object] = {}
|
|
100
|
+
for name, spec in specs.items():
|
|
101
|
+
value = values[name]
|
|
102
|
+
if spec.kind is FeatureKind.SCALAR:
|
|
103
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
104
|
+
raise ContractError(f"feature {name!r} must be a real scalar")
|
|
105
|
+
numeric = float(value)
|
|
106
|
+
if not math.isfinite(numeric):
|
|
107
|
+
raise ContractError(f"feature {name!r} must be finite")
|
|
108
|
+
if spec.minimum is not None and numeric < spec.minimum:
|
|
109
|
+
raise ContractError(f"feature {name!r} is below minimum {spec.minimum}")
|
|
110
|
+
if spec.maximum is not None and numeric > spec.maximum:
|
|
111
|
+
raise ContractError(f"feature {name!r} is above maximum {spec.maximum}")
|
|
112
|
+
validated[name] = value
|
|
113
|
+
if not exact:
|
|
114
|
+
validated.update((name, values[name]) for name in unknown)
|
|
115
|
+
return validated
|
embodied_ops/py.typed
ADDED
|
File without changes
|
embodied_ops/registry.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Backend discovery through standard Python package entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
|
|
9
|
+
from .device import OperationalDevice
|
|
10
|
+
from .errors import BackendConflictError, BackendNotFoundError, ContractError
|
|
11
|
+
|
|
12
|
+
BACKEND_ENTRY_POINT_GROUP = "embodied_ops.backends"
|
|
13
|
+
BackendFactory = Callable[[Mapping[str, object]], OperationalDevice]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BackendRegistry:
|
|
17
|
+
"""Resolve local factories and installed backend entry points by stable name."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._local: dict[str, BackendFactory] = {}
|
|
21
|
+
|
|
22
|
+
def register(self, name: str, factory: BackendFactory) -> None:
|
|
23
|
+
if not name or name.strip() != name or any(character.isspace() for character in name):
|
|
24
|
+
raise ContractError(f"invalid backend name: {name!r}")
|
|
25
|
+
if name in self._local:
|
|
26
|
+
raise BackendConflictError(f"backend {name!r} is already registered locally")
|
|
27
|
+
self._local[name] = factory
|
|
28
|
+
|
|
29
|
+
def unregister(self, name: str) -> None:
|
|
30
|
+
self._local.pop(name, None)
|
|
31
|
+
|
|
32
|
+
def names(self) -> tuple[str, ...]:
|
|
33
|
+
return tuple(sorted(set(self._local) | {entry.name for entry in self._entry_points()}))
|
|
34
|
+
|
|
35
|
+
def load(self, name: str) -> BackendFactory:
|
|
36
|
+
if name in self._local:
|
|
37
|
+
return self._local[name]
|
|
38
|
+
matches = [entry for entry in self._entry_points() if entry.name == name]
|
|
39
|
+
if not matches:
|
|
40
|
+
raise BackendNotFoundError(
|
|
41
|
+
f"backend {name!r} is not installed; available backends: {list(self.names())}"
|
|
42
|
+
)
|
|
43
|
+
if len(matches) > 1:
|
|
44
|
+
owners = sorted(
|
|
45
|
+
entry.dist.name if entry.dist is not None else "unknown" for entry in matches
|
|
46
|
+
)
|
|
47
|
+
raise BackendConflictError(
|
|
48
|
+
f"backend {name!r} is provided by multiple packages: {owners}"
|
|
49
|
+
)
|
|
50
|
+
factory = matches[0].load()
|
|
51
|
+
if not callable(factory):
|
|
52
|
+
raise ContractError(f"backend entry point {name!r} did not load a callable factory")
|
|
53
|
+
return factory
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _entry_points() -> tuple[metadata.EntryPoint, ...]:
|
|
57
|
+
return tuple(metadata.entry_points(group=BACKEND_ENTRY_POINT_GROUP))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
default_registry = BackendRegistry()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_device(
|
|
64
|
+
backend: str,
|
|
65
|
+
config: Mapping[str, object] | None = None,
|
|
66
|
+
*,
|
|
67
|
+
registry: BackendRegistry = default_registry,
|
|
68
|
+
) -> OperationalDevice:
|
|
69
|
+
"""Instantiate an installed backend without connecting to hardware."""
|
|
70
|
+
|
|
71
|
+
device = registry.load(backend)(dict(config or {}))
|
|
72
|
+
if not isinstance(device, OperationalDevice):
|
|
73
|
+
raise ContractError(f"backend {backend!r} does not implement OperationalDevice")
|
|
74
|
+
return device
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def device_session(
|
|
79
|
+
backend: str,
|
|
80
|
+
config: Mapping[str, object] | None = None,
|
|
81
|
+
*,
|
|
82
|
+
registry: BackendRegistry = default_registry,
|
|
83
|
+
) -> Iterator[OperationalDevice]:
|
|
84
|
+
"""Connect a backend and guarantee disconnect after success or failure."""
|
|
85
|
+
|
|
86
|
+
device = create_device(backend, config, registry=registry)
|
|
87
|
+
device.connect()
|
|
88
|
+
try:
|
|
89
|
+
yield device
|
|
90
|
+
finally:
|
|
91
|
+
device.disconnect()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Optional versioned RPC transport for embodied-ops devices."""
|
|
2
|
+
|
|
3
|
+
from .client import RemoteDevice
|
|
4
|
+
from .server import DeviceRpcServer
|
|
5
|
+
from .types import PROTOCOL_VERSION, SessionMode, TensorValue
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PROTOCOL_VERSION",
|
|
9
|
+
"DeviceRpcServer",
|
|
10
|
+
"RemoteDevice",
|
|
11
|
+
"SessionMode",
|
|
12
|
+
"TensorValue",
|
|
13
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Strict protobuf conversion at the transport boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from google.protobuf.json_format import MessageToDict, ParseDict
|
|
8
|
+
from google.protobuf.struct_pb2 import Struct
|
|
9
|
+
|
|
10
|
+
from embodied_ops.device import (
|
|
11
|
+
Capability,
|
|
12
|
+
DeviceManifest,
|
|
13
|
+
HealthReport,
|
|
14
|
+
HealthStatus,
|
|
15
|
+
)
|
|
16
|
+
from embodied_ops.errors import ContractError
|
|
17
|
+
from embodied_ops.features import (
|
|
18
|
+
FeatureKind,
|
|
19
|
+
FeatureSpec,
|
|
20
|
+
index_features,
|
|
21
|
+
validate_feature_values,
|
|
22
|
+
)
|
|
23
|
+
from embodied_ops.rpc.types import TensorValue
|
|
24
|
+
from embodied_ops.rpc.v1 import device_pb2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def manifest_to_proto(manifest: DeviceManifest) -> device_pb2.DeviceManifest:
|
|
28
|
+
return device_pb2.DeviceManifest(
|
|
29
|
+
api_version=manifest.api_version,
|
|
30
|
+
identifier=manifest.identifier,
|
|
31
|
+
capabilities=[capability.value for capability in manifest.capabilities],
|
|
32
|
+
observation_features=[
|
|
33
|
+
feature_to_proto(feature) for feature in manifest.observation_features
|
|
34
|
+
],
|
|
35
|
+
action_features=[feature_to_proto(feature) for feature in manifest.action_features],
|
|
36
|
+
metadata=dict(manifest.metadata),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def manifest_from_proto(message: device_pb2.DeviceManifest) -> DeviceManifest:
|
|
41
|
+
return DeviceManifest(
|
|
42
|
+
api_version=message.api_version,
|
|
43
|
+
identifier=message.identifier,
|
|
44
|
+
capabilities=tuple(Capability(value) for value in message.capabilities),
|
|
45
|
+
observation_features=tuple(
|
|
46
|
+
feature_from_proto(feature) for feature in message.observation_features
|
|
47
|
+
),
|
|
48
|
+
action_features=tuple(feature_from_proto(feature) for feature in message.action_features),
|
|
49
|
+
metadata=dict(message.metadata),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def feature_to_proto(feature: FeatureSpec) -> device_pb2.FeatureSpec:
|
|
54
|
+
message = device_pb2.FeatureSpec(
|
|
55
|
+
name=feature.name,
|
|
56
|
+
kind=feature.kind.value,
|
|
57
|
+
dtype=feature.dtype,
|
|
58
|
+
shape=feature.shape,
|
|
59
|
+
)
|
|
60
|
+
if feature.unit is not None:
|
|
61
|
+
message.unit = feature.unit
|
|
62
|
+
if feature.minimum is not None:
|
|
63
|
+
message.minimum = feature.minimum
|
|
64
|
+
if feature.maximum is not None:
|
|
65
|
+
message.maximum = feature.maximum
|
|
66
|
+
return message
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def feature_from_proto(message: device_pb2.FeatureSpec) -> FeatureSpec:
|
|
70
|
+
return FeatureSpec(
|
|
71
|
+
name=message.name,
|
|
72
|
+
kind=FeatureKind(message.kind),
|
|
73
|
+
dtype=message.dtype,
|
|
74
|
+
shape=tuple(message.shape),
|
|
75
|
+
unit=message.unit if message.HasField("unit") else None,
|
|
76
|
+
minimum=message.minimum if message.HasField("minimum") else None,
|
|
77
|
+
maximum=message.maximum if message.HasField("maximum") else None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def values_to_proto(
|
|
82
|
+
values: Mapping[str, object], features: tuple[FeatureSpec, ...]
|
|
83
|
+
) -> list[device_pb2.FeatureValue]:
|
|
84
|
+
validated = validate_feature_values(values, features)
|
|
85
|
+
messages: list[device_pb2.FeatureValue] = []
|
|
86
|
+
for feature in features:
|
|
87
|
+
value = validated[feature.name]
|
|
88
|
+
message = device_pb2.FeatureValue(name=feature.name)
|
|
89
|
+
if feature.kind is FeatureKind.SCALAR:
|
|
90
|
+
message.scalar = float(value)
|
|
91
|
+
else:
|
|
92
|
+
if not isinstance(value, TensorValue):
|
|
93
|
+
raise ContractError(
|
|
94
|
+
f"feature {feature.name!r} requires an embodied_ops.rpc.TensorValue"
|
|
95
|
+
)
|
|
96
|
+
if value.dtype != feature.dtype or value.shape != feature.shape:
|
|
97
|
+
raise ContractError(
|
|
98
|
+
f"feature {feature.name!r} tensor metadata does not match its manifest"
|
|
99
|
+
)
|
|
100
|
+
message.tensor.CopyFrom(
|
|
101
|
+
device_pb2.TensorPayload(
|
|
102
|
+
dtype=value.dtype,
|
|
103
|
+
shape=value.shape,
|
|
104
|
+
data=value.data,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
messages.append(message)
|
|
108
|
+
return messages
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def values_from_proto(
|
|
112
|
+
messages: Sequence[device_pb2.FeatureValue],
|
|
113
|
+
features: tuple[FeatureSpec, ...],
|
|
114
|
+
) -> dict[str, object]:
|
|
115
|
+
specs = index_features(features)
|
|
116
|
+
values: dict[str, object] = {}
|
|
117
|
+
for message in messages:
|
|
118
|
+
if message.name in values:
|
|
119
|
+
raise ContractError(f"duplicate feature value: {message.name!r}")
|
|
120
|
+
try:
|
|
121
|
+
feature = specs[message.name]
|
|
122
|
+
except KeyError as exc:
|
|
123
|
+
raise ContractError(f"unknown feature value: {message.name!r}") from exc
|
|
124
|
+
payload = message.WhichOneof("payload")
|
|
125
|
+
if feature.kind is FeatureKind.SCALAR:
|
|
126
|
+
if payload != "scalar":
|
|
127
|
+
raise ContractError(f"feature {message.name!r} requires a scalar payload")
|
|
128
|
+
values[message.name] = message.scalar
|
|
129
|
+
else:
|
|
130
|
+
if payload != "tensor":
|
|
131
|
+
raise ContractError(f"feature {message.name!r} requires a tensor payload")
|
|
132
|
+
values[message.name] = TensorValue(
|
|
133
|
+
dtype=message.tensor.dtype,
|
|
134
|
+
shape=tuple(message.tensor.shape),
|
|
135
|
+
data=bytes(message.tensor.data),
|
|
136
|
+
)
|
|
137
|
+
return validate_feature_values(values, features)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def health_to_proto(report: HealthReport) -> device_pb2.HealthResponse:
|
|
141
|
+
details = Struct()
|
|
142
|
+
try:
|
|
143
|
+
ParseDict(dict(report.details), details)
|
|
144
|
+
except (TypeError, ValueError) as exc:
|
|
145
|
+
raise ContractError("health details must be protobuf-Struct compatible") from exc
|
|
146
|
+
return device_pb2.HealthResponse(
|
|
147
|
+
status=report.status.value,
|
|
148
|
+
summary=report.summary,
|
|
149
|
+
details=details,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def health_from_proto(message: device_pb2.HealthResponse) -> HealthReport:
|
|
154
|
+
return HealthReport(
|
|
155
|
+
status=HealthStatus(message.status),
|
|
156
|
+
summary=message.summary,
|
|
157
|
+
details=MessageToDict(message.details),
|
|
158
|
+
)
|