rcp-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.
- rcp_sdk/__init__.py +34 -0
- rcp_sdk/adapter.py +175 -0
- rcp_sdk/jsonrpc.py +208 -0
- rcp_sdk/models.py +190 -0
- rcp_sdk/py.typed +0 -0
- rcp_sdk/safety.py +54 -0
- rcp_sdk/server.py +597 -0
- rcp_sdk-0.1.0.dist-info/METADATA +395 -0
- rcp_sdk-0.1.0.dist-info/RECORD +13 -0
- rcp_sdk-0.1.0.dist-info/WHEEL +5 -0
- rcp_sdk-0.1.0.dist-info/licenses/LICENSE +216 -0
- rcp_sdk-0.1.0.dist-info/licenses/NOTICE +11 -0
- rcp_sdk-0.1.0.dist-info/top_level.txt +1 -0
rcp_sdk/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""RCP SDK — Robot Context Protocol adapter interface."""
|
|
2
|
+
|
|
3
|
+
from rcp_sdk.models import (
|
|
4
|
+
JointState, TCPPosition, IOSignal, IOState, ProgramInfo, ToolData,
|
|
5
|
+
AdapterCapabilities, RobotIdentity, RobotContext, RCPEvent,
|
|
6
|
+
UploadResult, DiffResult, AuditEntry, RiskLevel,
|
|
7
|
+
)
|
|
8
|
+
from rcp_sdk.adapter import RCPAdapter
|
|
9
|
+
from rcp_sdk.safety import ACTION_RISK, PROHIBITED_ACTIONS, RiskLevels
|
|
10
|
+
from rcp_sdk.jsonrpc import (
|
|
11
|
+
JsonRpcRequest, JsonRpcResponse, JsonRpcError, JsonRpcErrorResponse,
|
|
12
|
+
JsonRpcNotification, RCPErrorCodes, RCPMethods,
|
|
13
|
+
parse_message, serialize_response, serialize_notification,
|
|
14
|
+
)
|
|
15
|
+
from rcp_sdk.server import serve, run
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Models
|
|
19
|
+
"JointState", "TCPPosition", "IOSignal", "IOState", "ProgramInfo", "ToolData",
|
|
20
|
+
"AdapterCapabilities", "RobotIdentity", "RobotContext", "RCPEvent",
|
|
21
|
+
"UploadResult", "DiffResult", "AuditEntry", "RiskLevel",
|
|
22
|
+
# Adapter
|
|
23
|
+
"RCPAdapter",
|
|
24
|
+
# Safety
|
|
25
|
+
"ACTION_RISK", "PROHIBITED_ACTIONS", "RiskLevels",
|
|
26
|
+
# JSON-RPC wire format
|
|
27
|
+
"JsonRpcRequest", "JsonRpcResponse", "JsonRpcError", "JsonRpcErrorResponse",
|
|
28
|
+
"JsonRpcNotification", "RCPErrorCodes", "RCPMethods",
|
|
29
|
+
"parse_message", "serialize_response", "serialize_notification",
|
|
30
|
+
# Server
|
|
31
|
+
"serve", "run",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
rcp_sdk/adapter.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RCP Adapter base class — all OEM adapters implement this interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import AsyncIterator, Optional
|
|
7
|
+
from rcp_sdk.models import (
|
|
8
|
+
RobotIdentity, AdapterCapabilities, JointState, TCPPosition,
|
|
9
|
+
IOState, ProgramInfo, ToolData, RobotContext, RCPEvent, UploadResult,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RCPAdapter(ABC):
|
|
14
|
+
"""Abstract base for RCP OEM adapters."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def connect(self, config: dict) -> None:
|
|
18
|
+
"""Connect to the controller. Config has host, port, etc."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def disconnect(self) -> None:
|
|
22
|
+
"""Disconnect from the controller."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def is_connected(self) -> bool:
|
|
26
|
+
"""Return True if currently connected."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def discover(self) -> RobotIdentity:
|
|
30
|
+
"""Discover the connected robot's identity."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def capabilities(self) -> AdapterCapabilities:
|
|
34
|
+
"""Return what this adapter supports."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def get_joints(self) -> list[JointState]:
|
|
38
|
+
"""Get current joint positions."""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def get_tcp(self) -> TCPPosition:
|
|
42
|
+
"""Get current TCP position."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def get_io(self) -> IOState:
|
|
46
|
+
"""Get all I/O states."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def get_programs(self) -> list[ProgramInfo]:
|
|
50
|
+
"""Get loaded programs."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def get_tools(self) -> list[ToolData]:
|
|
54
|
+
"""Get configured tools."""
|
|
55
|
+
|
|
56
|
+
async def get_program_code(self, module: str) -> str:
|
|
57
|
+
"""Get the source code of a program module from the controller.
|
|
58
|
+
Returns empty string if not supported or module not found."""
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
async def upload_program(self, module: str, code: str) -> UploadResult:
|
|
62
|
+
"""Upload a program module to the controller. GUARDED action."""
|
|
63
|
+
return UploadResult(success=False, message="Upload not supported by this adapter")
|
|
64
|
+
|
|
65
|
+
async def capture_position(self) -> tuple["TCPPosition", list["JointState"]]:
|
|
66
|
+
"""Capture current position. Default: use get_tcp + get_joints."""
|
|
67
|
+
return await self.get_tcp(), await self.get_joints()
|
|
68
|
+
|
|
69
|
+
async def set_io(self, signal: str, value: float) -> None:
|
|
70
|
+
"""Set an I/O output signal value. GUARDED action."""
|
|
71
|
+
raise NotImplementedError("This adapter does not support I/O control")
|
|
72
|
+
|
|
73
|
+
async def verify_interlocks(self) -> tuple[bool, str]:
|
|
74
|
+
"""Check safety interlocks before RESTRICTED actions.
|
|
75
|
+
Default: always passes. OEM adapters override with real checks.
|
|
76
|
+
Returns (ok, reason) — reason is empty string if ok."""
|
|
77
|
+
return (True, "")
|
|
78
|
+
|
|
79
|
+
async def move_to(self, position: TCPPosition) -> dict:
|
|
80
|
+
"""Move TCP to specified position. RESTRICTED action.
|
|
81
|
+
Returns dict with 'success', 'message', and optionally 'actual_tcp', 'drift_mm'."""
|
|
82
|
+
raise NotImplementedError("This adapter does not support motion commands")
|
|
83
|
+
|
|
84
|
+
async def stop(self) -> None:
|
|
85
|
+
"""Immediately stop robot motion. SAFE action."""
|
|
86
|
+
raise NotImplementedError("This adapter does not support stop commands")
|
|
87
|
+
|
|
88
|
+
# ── Optional RESTRICTED actions (adapters opt in) ──
|
|
89
|
+
|
|
90
|
+
async def start_program(self, module: str) -> dict:
|
|
91
|
+
"""Start a program on the controller. RESTRICTED action.
|
|
92
|
+
Returns dict with 'success', 'message'."""
|
|
93
|
+
raise NotImplementedError("This adapter does not support program start")
|
|
94
|
+
|
|
95
|
+
async def jog(self, axis: str, direction: int, speed: float) -> dict:
|
|
96
|
+
"""Jog a single axis. RESTRICTED action.
|
|
97
|
+
axis: 'J1'..'J6' or 'X'|'Y'|'Z'|'RX'|'RY'|'RZ'. direction: +1 or -1."""
|
|
98
|
+
raise NotImplementedError("This adapter does not support jog commands")
|
|
99
|
+
|
|
100
|
+
async def home(self) -> dict:
|
|
101
|
+
"""Move robot to home pose. RESTRICTED action."""
|
|
102
|
+
raise NotImplementedError("This adapter does not support homing")
|
|
103
|
+
|
|
104
|
+
# ── Optional SAFE actions ──
|
|
105
|
+
|
|
106
|
+
async def validate_program(self, code: str) -> dict:
|
|
107
|
+
"""Validate a program against adapter-specific syntax/semantics. SAFE action.
|
|
108
|
+
Returns dict with 'valid', 'errors', 'warnings'."""
|
|
109
|
+
return {"valid": True, "errors": [], "warnings": []}
|
|
110
|
+
|
|
111
|
+
async def diff_program(self, module: str, local_code: str) -> dict:
|
|
112
|
+
"""Diff local code against controller-resident code. SAFE action.
|
|
113
|
+
Default implementation fetches via get_program_code + unified diff.
|
|
114
|
+
Returns dict with 'controller_code', 'added', 'removed', 'changed'."""
|
|
115
|
+
import difflib
|
|
116
|
+
controller_code = await self.get_program_code(module)
|
|
117
|
+
local_lines = local_code.splitlines()
|
|
118
|
+
controller_lines = controller_code.splitlines()
|
|
119
|
+
diff = list(difflib.unified_diff(controller_lines, local_lines, lineterm=""))
|
|
120
|
+
added = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
|
|
121
|
+
removed = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
|
|
122
|
+
changed = min(added, removed)
|
|
123
|
+
return {
|
|
124
|
+
"controller_code": controller_code,
|
|
125
|
+
"added": added - changed,
|
|
126
|
+
"removed": removed - changed,
|
|
127
|
+
"changed": changed,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async def modify_tool_data(self, tool: ToolData) -> dict:
|
|
131
|
+
"""Overwrite a tool definition on the controller. GUARDED action."""
|
|
132
|
+
raise NotImplementedError("This adapter does not support tool-data modification")
|
|
133
|
+
|
|
134
|
+
# ── Optional event streaming ──
|
|
135
|
+
|
|
136
|
+
def subscribe(self, channels: list[str]) -> Optional[AsyncIterator[RCPEvent]]:
|
|
137
|
+
"""
|
|
138
|
+
Open an event stream for the given channels.
|
|
139
|
+
|
|
140
|
+
Return an async iterator that yields RCPEvent items. Return None if this
|
|
141
|
+
adapter doesn't stream events — the server then skips wiring forwarders.
|
|
142
|
+
|
|
143
|
+
Canonical channels: 'position', 'io', 'program', 'safety', 'error'.
|
|
144
|
+
Adapters may define their own OEM-specific channels as well.
|
|
145
|
+
"""
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
async def get_context(self) -> RobotContext:
|
|
149
|
+
"""Aggregate all state into a context block for AI injection."""
|
|
150
|
+
ctx = RobotContext()
|
|
151
|
+
try:
|
|
152
|
+
ctx.identity = await self.discover()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
try:
|
|
156
|
+
ctx.joints = await self.get_joints()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
try:
|
|
160
|
+
ctx.tcp = await self.get_tcp()
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
try:
|
|
164
|
+
ctx.io = await self.get_io()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
try:
|
|
168
|
+
ctx.programs = await self.get_programs()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
try:
|
|
172
|
+
ctx.tools = await self.get_tools()
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
return ctx
|
rcp_sdk/jsonrpc.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""RCP JSON-RPC 2.0 wire format implementation.
|
|
2
|
+
|
|
3
|
+
Zero-dependency module implementing the JSON-RPC 2.0 message format
|
|
4
|
+
for RCP protocol communication. Used by the server module and can be
|
|
5
|
+
used by any transport layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from typing import Any, Union
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class JsonRpcRequest:
|
|
15
|
+
"""A JSON-RPC 2.0 request (expects a response)."""
|
|
16
|
+
method: str
|
|
17
|
+
params: dict
|
|
18
|
+
id: Union[str, int]
|
|
19
|
+
jsonrpc: str = "2.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class JsonRpcResponse:
|
|
24
|
+
"""A JSON-RPC 2.0 success response."""
|
|
25
|
+
result: Any
|
|
26
|
+
id: Union[str, int]
|
|
27
|
+
jsonrpc: str = "2.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class JsonRpcError:
|
|
32
|
+
"""JSON-RPC 2.0 error object."""
|
|
33
|
+
code: int
|
|
34
|
+
message: str
|
|
35
|
+
data: Any = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class JsonRpcErrorResponse:
|
|
40
|
+
"""A JSON-RPC 2.0 error response."""
|
|
41
|
+
error: JsonRpcError
|
|
42
|
+
id: Union[str, int, None]
|
|
43
|
+
jsonrpc: str = "2.0"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class JsonRpcNotification:
|
|
48
|
+
"""A JSON-RPC 2.0 notification (no response expected, no id)."""
|
|
49
|
+
method: str
|
|
50
|
+
params: dict
|
|
51
|
+
jsonrpc: str = "2.0"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RCPErrorCodes:
|
|
55
|
+
"""Standard error codes for the RCP protocol.
|
|
56
|
+
|
|
57
|
+
Ranges:
|
|
58
|
+
-32700 to -32600: JSON-RPC standard errors
|
|
59
|
+
1000-1999: RCP transport errors
|
|
60
|
+
2000-2999: RCP protocol errors
|
|
61
|
+
3000-3999: RCP safety errors
|
|
62
|
+
4000-4999: RCP adapter errors
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# JSON-RPC standard
|
|
66
|
+
PARSE_ERROR = -32700
|
|
67
|
+
INVALID_REQUEST = -32600
|
|
68
|
+
METHOD_NOT_FOUND = -32601
|
|
69
|
+
INVALID_PARAMS = -32602
|
|
70
|
+
INTERNAL_ERROR = -32603
|
|
71
|
+
|
|
72
|
+
# RCP transport (1000-1999)
|
|
73
|
+
CONNECTION_FAILED = 1000
|
|
74
|
+
CONNECTION_TIMEOUT = 1001
|
|
75
|
+
CONNECTION_LOST = 1002
|
|
76
|
+
|
|
77
|
+
# RCP protocol (2000-2999)
|
|
78
|
+
RESOURCE_NOT_FOUND = 2000
|
|
79
|
+
ACTION_NOT_SUPPORTED = 2001
|
|
80
|
+
INVALID_RESOURCE_URI = 2002
|
|
81
|
+
SUBSCRIPTION_FAILED = 2003
|
|
82
|
+
|
|
83
|
+
# RCP safety (3000-3999)
|
|
84
|
+
APPROVAL_REQUIRED = 3000
|
|
85
|
+
APPROVAL_EXPIRED = 3001
|
|
86
|
+
APPROVAL_REJECTED = 3002
|
|
87
|
+
INTERLOCKS_FAILED = 3003
|
|
88
|
+
ACTION_PROHIBITED = 3004
|
|
89
|
+
SAFETY_VIOLATION = 3005
|
|
90
|
+
|
|
91
|
+
# RCP adapter (4000-4999)
|
|
92
|
+
ADAPTER_ERROR = 4000
|
|
93
|
+
ADAPTER_DISCONNECTED = 4001
|
|
94
|
+
ADAPTER_BUSY = 4002
|
|
95
|
+
HARDWARE_ERROR = 4003
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RCPMethods:
|
|
99
|
+
"""Method name constants for RCP operations.
|
|
100
|
+
|
|
101
|
+
These are the JSON-RPC method strings used in the wire protocol.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Resources
|
|
105
|
+
READ = "rcp.read"
|
|
106
|
+
SUBSCRIBE = "rcp.subscribe"
|
|
107
|
+
UNSUBSCRIBE = "rcp.unsubscribe"
|
|
108
|
+
|
|
109
|
+
# Actions
|
|
110
|
+
REQUEST_ACTION = "rcp.action.request"
|
|
111
|
+
APPROVE_ACTION = "rcp.action.approve"
|
|
112
|
+
CANCEL_ACTION = "rcp.action.cancel"
|
|
113
|
+
|
|
114
|
+
# Context
|
|
115
|
+
DISCOVER = "rcp.discover"
|
|
116
|
+
GET_CONTEXT = "rcp.context"
|
|
117
|
+
GET_CAPABILITIES = "rcp.capabilities"
|
|
118
|
+
|
|
119
|
+
# Events (notifications — no response expected)
|
|
120
|
+
EVENT = "rcp.event"
|
|
121
|
+
APPROVAL_REQUIRED = "rcp.approval_required"
|
|
122
|
+
ACTION_RESULT = "rcp.action_result"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_message(raw: Union[str, bytes]) -> Union[JsonRpcRequest, JsonRpcNotification]:
|
|
126
|
+
"""Parse a JSON-RPC 2.0 message from raw string or bytes.
|
|
127
|
+
|
|
128
|
+
Returns a JsonRpcRequest if the message has an 'id' field,
|
|
129
|
+
otherwise returns a JsonRpcNotification.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If the message is not valid JSON-RPC 2.0.
|
|
133
|
+
"""
|
|
134
|
+
if isinstance(raw, bytes):
|
|
135
|
+
raw = raw.decode("utf-8")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(raw)
|
|
139
|
+
except json.JSONDecodeError as e:
|
|
140
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
141
|
+
|
|
142
|
+
if not isinstance(data, dict):
|
|
143
|
+
raise ValueError("JSON-RPC message must be a JSON object")
|
|
144
|
+
|
|
145
|
+
if data.get("jsonrpc") != "2.0":
|
|
146
|
+
raise ValueError("Missing or invalid 'jsonrpc' field (must be '2.0')")
|
|
147
|
+
|
|
148
|
+
method = data.get("method")
|
|
149
|
+
if not isinstance(method, str):
|
|
150
|
+
raise ValueError("Missing or invalid 'method' field")
|
|
151
|
+
|
|
152
|
+
params = data.get("params", {})
|
|
153
|
+
if not isinstance(params, dict):
|
|
154
|
+
raise ValueError("'params' must be a JSON object")
|
|
155
|
+
|
|
156
|
+
msg_id = data.get("id")
|
|
157
|
+
if msg_id is not None:
|
|
158
|
+
return JsonRpcRequest(method=method, params=params, id=msg_id)
|
|
159
|
+
else:
|
|
160
|
+
return JsonRpcNotification(method=method, params=params)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _serialize_value(obj: Any) -> Any:
|
|
164
|
+
"""Recursively convert dataclasses and special types for JSON serialization."""
|
|
165
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
166
|
+
return {k: _serialize_value(v) for k, v in asdict(obj).items()}
|
|
167
|
+
if isinstance(obj, dict):
|
|
168
|
+
return {k: _serialize_value(v) for k, v in obj.items()}
|
|
169
|
+
if isinstance(obj, (list, tuple)):
|
|
170
|
+
return [_serialize_value(v) for v in obj]
|
|
171
|
+
return obj
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def serialize_response(response: Union[JsonRpcResponse, JsonRpcErrorResponse]) -> str:
|
|
175
|
+
"""Serialize a JSON-RPC response to a JSON string.
|
|
176
|
+
|
|
177
|
+
Handles both success responses (JsonRpcResponse) and error
|
|
178
|
+
responses (JsonRpcErrorResponse).
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(response, JsonRpcErrorResponse):
|
|
181
|
+
error_dict: dict[str, Any] = {
|
|
182
|
+
"code": response.error.code,
|
|
183
|
+
"message": response.error.message,
|
|
184
|
+
}
|
|
185
|
+
if response.error.data is not None:
|
|
186
|
+
error_dict["data"] = _serialize_value(response.error.data)
|
|
187
|
+
payload: dict[str, Any] = {
|
|
188
|
+
"jsonrpc": response.jsonrpc,
|
|
189
|
+
"error": error_dict,
|
|
190
|
+
"id": response.id,
|
|
191
|
+
}
|
|
192
|
+
else:
|
|
193
|
+
payload = {
|
|
194
|
+
"jsonrpc": response.jsonrpc,
|
|
195
|
+
"result": _serialize_value(response.result),
|
|
196
|
+
"id": response.id,
|
|
197
|
+
}
|
|
198
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def serialize_notification(notification: JsonRpcNotification) -> str:
|
|
202
|
+
"""Serialize a JSON-RPC notification to a JSON string."""
|
|
203
|
+
payload = {
|
|
204
|
+
"jsonrpc": notification.jsonrpc,
|
|
205
|
+
"method": notification.method,
|
|
206
|
+
"params": _serialize_value(notification.params),
|
|
207
|
+
}
|
|
208
|
+
return json.dumps(payload, separators=(",", ":"))
|
rcp_sdk/models.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RCP data models — shared across all adapters.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class JointState:
|
|
12
|
+
name: str # "J1", "J2", etc.
|
|
13
|
+
position_deg: float
|
|
14
|
+
min_deg: float = -360.0
|
|
15
|
+
max_deg: float = 360.0
|
|
16
|
+
max_speed_deg_s: float = 180.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TCPPosition:
|
|
21
|
+
x: float # mm
|
|
22
|
+
y: float
|
|
23
|
+
z: float
|
|
24
|
+
rx: float # degrees or radians depending on OEM
|
|
25
|
+
ry: float
|
|
26
|
+
rz: float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class IOSignal:
|
|
31
|
+
name: str
|
|
32
|
+
signal_type: Literal["digital_input", "digital_output", "analog_input", "analog_output"]
|
|
33
|
+
value: float # 0/1 for digital, 0.0-1.0 for analog
|
|
34
|
+
# Optional channel mode for analog signals: "voltage" (0–10 V) or
|
|
35
|
+
# "current" (4–20 mA). Adapters that can resolve the per-channel domain
|
|
36
|
+
# (e.g. UR's standard_analog_*_domain) populate this so the IODashboard
|
|
37
|
+
# renders the correct unit (V vs mA). None = unknown / not applicable.
|
|
38
|
+
mode: Literal["voltage", "current"] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class IOState:
|
|
43
|
+
signals: list[IOSignal] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ProgramInfo:
|
|
48
|
+
name: str
|
|
49
|
+
loaded: bool = False
|
|
50
|
+
running: bool = False
|
|
51
|
+
paused: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ToolData:
|
|
56
|
+
name: str
|
|
57
|
+
tcp_x: float = 0.0
|
|
58
|
+
tcp_y: float = 0.0
|
|
59
|
+
tcp_z: float = 0.0
|
|
60
|
+
mass_kg: float = 0.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class AdapterCapabilities:
|
|
65
|
+
read: bool = True
|
|
66
|
+
validate: bool = False
|
|
67
|
+
deploy: bool = False
|
|
68
|
+
execute: bool = False
|
|
69
|
+
events: list[str] = field(default_factory=list)
|
|
70
|
+
streaming: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RobotIdentity:
|
|
75
|
+
manufacturer: str
|
|
76
|
+
model: str
|
|
77
|
+
controller: str
|
|
78
|
+
firmware: str
|
|
79
|
+
serial: str
|
|
80
|
+
joint_count: int = 6
|
|
81
|
+
joint_limits: list[JointState] = field(default_factory=list)
|
|
82
|
+
payload_kg: float = 0.0
|
|
83
|
+
reach_mm: float = 0.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RobotContext:
|
|
88
|
+
"""Pre-formatted context block for LLM injection."""
|
|
89
|
+
identity: RobotIdentity | None = None
|
|
90
|
+
tcp: TCPPosition | None = None
|
|
91
|
+
joints: list[JointState] = field(default_factory=list)
|
|
92
|
+
io: IOState = field(default_factory=IOState)
|
|
93
|
+
programs: list[ProgramInfo] = field(default_factory=list)
|
|
94
|
+
tools: list[ToolData] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
def to_prompt_block(self) -> str:
|
|
97
|
+
"""Format as a text block for LLM system prompt injection."""
|
|
98
|
+
if not self.identity:
|
|
99
|
+
return ""
|
|
100
|
+
lines = [
|
|
101
|
+
"CONNECTED ROBOT (live data via RCP):",
|
|
102
|
+
f" Model: {self.identity.manufacturer} {self.identity.model}",
|
|
103
|
+
f" Controller: {self.identity.controller}",
|
|
104
|
+
f" Firmware: {self.identity.firmware}",
|
|
105
|
+
f" Serial: {self.identity.serial}",
|
|
106
|
+
f" Payload: {self.identity.payload_kg} kg | Reach: {self.identity.reach_mm} mm",
|
|
107
|
+
]
|
|
108
|
+
if self.tcp:
|
|
109
|
+
lines.append(f" Current TCP: X={self.tcp.x:.1f} Y={self.tcp.y:.1f} Z={self.tcp.z:.1f} Rx={self.tcp.rx:.2f} Ry={self.tcp.ry:.2f} Rz={self.tcp.rz:.2f}")
|
|
110
|
+
if self.joints:
|
|
111
|
+
jstr = ", ".join(f"{j.name}={j.position_deg:.1f}" for j in self.joints)
|
|
112
|
+
lines.append(f" Current joints: {jstr}")
|
|
113
|
+
limits = ", ".join(f"{j.name}: {j.min_deg:.0f} to {j.max_deg:.0f}" for j in self.joints)
|
|
114
|
+
lines.append(f" Joint limits: {limits}")
|
|
115
|
+
if self.io.signals:
|
|
116
|
+
di = [s for s in self.io.signals if s.signal_type == "digital_input"]
|
|
117
|
+
do = [s for s in self.io.signals if s.signal_type == "digital_output"]
|
|
118
|
+
if di:
|
|
119
|
+
lines.append(f" Digital inputs ({len(di)}): " + ", ".join(f"{s.name}={'HIGH' if s.value else 'LOW'}" for s in di[:8]))
|
|
120
|
+
if do:
|
|
121
|
+
lines.append(f" Digital outputs ({len(do)}): " + ", ".join(f"{s.name}={'HIGH' if s.value else 'LOW'}" for s in do[:8]))
|
|
122
|
+
if self.programs:
|
|
123
|
+
loaded = [p for p in self.programs if p.loaded]
|
|
124
|
+
if loaded:
|
|
125
|
+
lines.append(f" Loaded program: {loaded[0].name}" + (" (RUNNING)" if loaded[0].running else ""))
|
|
126
|
+
if self.tools:
|
|
127
|
+
for t in self.tools:
|
|
128
|
+
lines.append(f" Tool: {t.name} TCP=({t.tcp_x:.1f},{t.tcp_y:.1f},{t.tcp_z:.1f}) mass={t.mass_kg:.1f}kg")
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
RiskLevel = Literal["SAFE", "GUARDED", "RESTRICTED", "PROHIBITED"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class DiffResult:
|
|
137
|
+
"""Result of diffing local code vs controller code."""
|
|
138
|
+
local_code: str
|
|
139
|
+
controller_code: str
|
|
140
|
+
added: int = 0
|
|
141
|
+
removed: int = 0
|
|
142
|
+
changed: int = 0
|
|
143
|
+
|
|
144
|
+
def summary(self) -> str:
|
|
145
|
+
return f"+{self.added} -{self.removed} ~{self.changed} lines"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class UploadResult:
|
|
150
|
+
success: bool
|
|
151
|
+
message: str = ""
|
|
152
|
+
audit_id: str = ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class AuditEntry:
|
|
157
|
+
"""Append-only audit log entry for GUARDED/RESTRICTED actions."""
|
|
158
|
+
id: str
|
|
159
|
+
timestamp: str # ISO 8601
|
|
160
|
+
action: str
|
|
161
|
+
risk_level: RiskLevel
|
|
162
|
+
approved_by: str
|
|
163
|
+
robot: str # "Manufacturer Model (SN: serial)"
|
|
164
|
+
params: dict = field(default_factory=dict)
|
|
165
|
+
result: str = "" # "success", "failed", "rejected"
|
|
166
|
+
diff_summary: str = ""
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict:
|
|
169
|
+
return {
|
|
170
|
+
"id": self.id,
|
|
171
|
+
"timestamp": self.timestamp,
|
|
172
|
+
"action": self.action,
|
|
173
|
+
"risk_level": self.risk_level,
|
|
174
|
+
"approved_by": self.approved_by,
|
|
175
|
+
"robot": self.robot,
|
|
176
|
+
"params": self.params,
|
|
177
|
+
"result": self.result,
|
|
178
|
+
"diff_summary": self.diff_summary,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class RCPEvent:
|
|
184
|
+
"""A real-time event from the controller."""
|
|
185
|
+
channel: str # "io_changed", "position_changed", "program_state", "error", "cycle", "safety"
|
|
186
|
+
data: dict = field(default_factory=dict)
|
|
187
|
+
timestamp: float = 0.0 # unix timestamp ms
|
|
188
|
+
|
|
189
|
+
def to_dict(self) -> dict:
|
|
190
|
+
return {"channel": self.channel, "data": self.data, "timestamp": self.timestamp}
|
rcp_sdk/py.typed
ADDED
|
File without changes
|
rcp_sdk/safety.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""RCP Safety — risk classifications for adapter actions.
|
|
2
|
+
|
|
3
|
+
These are protocol-level constants. Adapters CANNOT override them.
|
|
4
|
+
The ApprovalManager (host-specific) is NOT part of the SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rcp_sdk.models import RiskLevel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RiskLevels:
|
|
11
|
+
"""Named constants for the 4 safety tiers.
|
|
12
|
+
|
|
13
|
+
Use these instead of raw string literals to avoid typos and enable
|
|
14
|
+
static analysis. ``ACTION_RISK`` values are still strings for JSON
|
|
15
|
+
compatibility, but downstream code should compare against these.
|
|
16
|
+
"""
|
|
17
|
+
SAFE: RiskLevel = "SAFE"
|
|
18
|
+
GUARDED: RiskLevel = "GUARDED"
|
|
19
|
+
RESTRICTED: RiskLevel = "RESTRICTED"
|
|
20
|
+
PROHIBITED: RiskLevel = "PROHIBITED"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Canonical mapping of action name → risk level.
|
|
24
|
+
# Every action the protocol admits is listed here; unknown actions default
|
|
25
|
+
# to GUARDED at the server dispatch site.
|
|
26
|
+
ACTION_RISK: dict[str, RiskLevel] = {
|
|
27
|
+
# SAFE — no approval
|
|
28
|
+
"stop": "SAFE",
|
|
29
|
+
"capture_position": "SAFE",
|
|
30
|
+
"diff_program": "SAFE",
|
|
31
|
+
"validate": "SAFE",
|
|
32
|
+
"disable_freedrive": "SAFE",
|
|
33
|
+
|
|
34
|
+
# GUARDED — one-tap user approval
|
|
35
|
+
"upload_program": "GUARDED",
|
|
36
|
+
"set_io": "GUARDED",
|
|
37
|
+
"modify_tool_data": "GUARDED",
|
|
38
|
+
"enable_freedrive": "GUARDED",
|
|
39
|
+
# W2.48 — pulse DO for N ms with optional DI readback. No motion.
|
|
40
|
+
# Single approval covers the full pulse-and-readback sequence as one operation.
|
|
41
|
+
"test_device": "GUARDED",
|
|
42
|
+
|
|
43
|
+
# RESTRICTED — approval + safety-interlock verification
|
|
44
|
+
"move_to": "RESTRICTED",
|
|
45
|
+
"start_program": "RESTRICTED",
|
|
46
|
+
"jog": "RESTRICTED",
|
|
47
|
+
"home": "RESTRICTED",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
PROHIBITED_ACTIONS = frozenset({
|
|
51
|
+
"override_safety",
|
|
52
|
+
"disable_estop",
|
|
53
|
+
"modify_safety_config",
|
|
54
|
+
})
|