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 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
+ })