roboapi 0.4.2__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.
Files changed (94) hide show
  1. roboapi/__init__.py +2 -0
  2. roboapi/calibration/__init__.py +6 -0
  3. roboapi/calibration/profile.py +139 -0
  4. roboapi/calibration/session.py +350 -0
  5. roboapi/cli.py +215 -0
  6. roboapi/config/__init__.py +22 -0
  7. roboapi/config/loader.py +119 -0
  8. roboapi/config/models.py +191 -0
  9. roboapi/embodiment.py +251 -0
  10. roboapi/errors.py +37 -0
  11. roboapi/geometry.py +337 -0
  12. roboapi/hardware/__init__.py +5 -0
  13. roboapi/hardware/base.py +113 -0
  14. roboapi/hardware/camera.py +167 -0
  15. roboapi/hardware/franka/__init__.py +5 -0
  16. roboapi/hardware/franka/arm.py +476 -0
  17. roboapi/hardware/franka/desk.py +555 -0
  18. roboapi/hardware/franka/feedback.py +196 -0
  19. roboapi/hardware/franka/guiding.py +104 -0
  20. roboapi/hardware/franka/ik.py +543 -0
  21. roboapi/hardware/franka/limits.py +110 -0
  22. roboapi/hardware/franka/settings.py +116 -0
  23. roboapi/hardware/robotiq.py +350 -0
  24. roboapi/hardware/ur/__init__.py +16 -0
  25. roboapi/hardware/ur/build.py +92 -0
  26. roboapi/hardware/ur/compliant.py +128 -0
  27. roboapi/hardware/ur/dashboard.py +103 -0
  28. roboapi/hardware/ur/feedback.py +159 -0
  29. roboapi/hardware/ur/ipc.py +206 -0
  30. roboapi/hardware/ur/native/CMakeLists.txt +37 -0
  31. roboapi/hardware/ur/native/dependencies.lock.json +13 -0
  32. roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
  33. roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
  34. roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
  35. roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
  36. roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
  37. roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
  38. roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
  39. roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
  40. roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
  41. roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
  42. roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
  43. roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
  44. roboapi/hardware/ur/native/worker.cpp +147 -0
  45. roboapi/hardware/ur/position.py +253 -0
  46. roboapi/hardware/ur/settings.py +95 -0
  47. roboapi/images.py +16 -0
  48. roboapi/initialize.py +137 -0
  49. roboapi/mcp.py +497 -0
  50. roboapi/motion.py +125 -0
  51. roboapi/naming.py +7 -0
  52. roboapi/operations.py +45 -0
  53. roboapi/policy/__init__.py +7 -0
  54. roboapi/policy/client.py +277 -0
  55. roboapi/policy/protocol.py +166 -0
  56. roboapi/policy/settings.py +79 -0
  57. roboapi/product-names.json +12 -0
  58. roboapi/records.py +149 -0
  59. roboapi/runtime.py +911 -0
  60. roboapi/semantics.py +6 -0
  61. roboapi/tasks.py +337 -0
  62. roboapi/web/__init__.py +5 -0
  63. roboapi/web/presets.py +44 -0
  64. roboapi/web/server.py +419 -0
  65. roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
  66. roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
  67. roboapi/web/static/assets/Inter-OFL.txt +93 -0
  68. roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
  69. roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
  70. roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
  71. roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
  72. roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
  73. roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
  74. roboapi/web/static/assets/React-LICENSE.txt +23 -0
  75. roboapi/web/static/assets/Three-LICENSE.txt +23 -0
  76. roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
  77. roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
  78. roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
  79. roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
  80. roboapi/web/static/index.html +15 -0
  81. roboapi-0.4.2.dist-info/METADATA +205 -0
  82. roboapi-0.4.2.dist-info/RECORD +94 -0
  83. roboapi-0.4.2.dist-info/WHEEL +5 -0
  84. roboapi-0.4.2.dist-info/entry_points.txt +2 -0
  85. roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
  86. roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
  87. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
  88. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
  89. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
  90. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
  91. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
  92. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
  93. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
  94. roboapi-0.4.2.dist-info/top_level.txt +1 -0
roboapi/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """RoboAPI — single arm + Robotiq gripper + wrist camera + remote VLA policy,
2
+ packaged as a tool an LLM agent can call. See DESIGN.md."""
@@ -0,0 +1,6 @@
1
+ """Cartesian installation workflow and its current local profile."""
2
+
3
+ from .profile import CalibrationProfile, load_profile, save_profile
4
+ from .session import Calibration
5
+
6
+ __all__ = ["Calibration", "CalibrationProfile", "load_profile", "save_profile"]
@@ -0,0 +1,139 @@
1
+ """One Cartesian installation calibration and atomic local storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import tempfile
9
+ from dataclasses import asdict, dataclass
10
+ from numbers import Real
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ from .. import geometry as geo
16
+ from ..semantics import HOME_POSE7
17
+ from ..motion import pose_reached
18
+
19
+ DEFAULT_PROFILE_PATH = "~/.config/roboapi/calibration.json"
20
+
21
+
22
+ def _pose(value, name):
23
+ if (not isinstance(value, (list, tuple, np.ndarray)) or np.shape(value) != (7,)
24
+ or any(isinstance(x, (bool, np.bool_)) or not isinstance(x, Real)
25
+ or not math.isfinite(x) for x in value)):
26
+ raise ValueError(f"{name} must contain 7 finite values")
27
+ geo.pose7_to_matrix(value)
28
+ return [float(x) for x in value]
29
+
30
+
31
+ def _plane(value):
32
+ if not isinstance(value, (list, tuple, np.ndarray)) or np.shape(value) != (3, 7):
33
+ raise ValueError("reference plane requires three Cartesian poses: A, B and C")
34
+ return [_pose(pose, name) for pose, name in zip(value, ("A", "B", "C"))]
35
+
36
+
37
+ def fit_home_plane(home, plane):
38
+ """Align Home, the level plane's upward normal and A-to-B heading.
39
+
40
+ Home is above the reference plane. All samples use the same tool orientation and
41
+ fixed contact point, so contact-point offsets cancel in the flange-position differences.
42
+ """
43
+ home = np.asarray(_pose(home, "Home"))
44
+ a, b, c = np.asarray(_plane(plane))[:, :3]
45
+ ab, ac = b - a, c - a
46
+ ab_length, ac_length = np.linalg.norm(ab), np.linalg.norm(ac)
47
+ if ab_length <= 1e-6:
48
+ raise ValueError("Place B away from A toward task +X.")
49
+ normal = np.cross(ab, ac)
50
+ if ac_length <= 1e-6 or np.linalg.norm(normal) <= 1e-6 * ab_length * ac_length:
51
+ raise ValueError("Place C on the reference surface, away from line AB.")
52
+ z_axis = normal / np.linalg.norm(normal)
53
+ height = np.dot(home[:3] - a, z_axis)
54
+ if abs(height) <= 1e-6:
55
+ raise ValueError("Home must be above the reference surface.")
56
+ if height < 0:
57
+ z_axis = -z_axis
58
+ x_axis = ab - np.dot(ab, z_axis) * z_axis
59
+ x_axis /= np.linalg.norm(x_axis)
60
+ task_from_base = np.eye(4)
61
+ task_from_base[:3, :3] = np.column_stack((x_axis, np.cross(z_axis, x_axis), z_axis)).T
62
+ task_from_base[:3, 3] = np.asarray(HOME_POSE7[:3]) - task_from_base[:3, :3] @ home[:3]
63
+ native_from_tool = np.eye(4)
64
+ native_from_tool[:3, :3] = (
65
+ (task_from_base[:3, :3] @ geo.pose7_to_matrix(home)[:3, :3]).T
66
+ @ geo.pose7_to_matrix(HOME_POSE7)[:3, :3]
67
+ )
68
+ return geo.FrameAdapter(geo.matrix_to_pose7(task_from_base), geo.matrix_to_pose7(native_from_tool))
69
+
70
+
71
+ @dataclass
72
+ class CalibrationProfile:
73
+ identity: str
74
+ device: dict
75
+ home_native_pose7: list[float]
76
+ plane_native_poses7: list[list[float]]
77
+ check_native_pose7: list[float] | None = None
78
+
79
+ def __post_init__(self):
80
+ if not isinstance(self.identity, str) or not self.identity:
81
+ raise ValueError("calibration installation identity is required")
82
+ if not isinstance(self.device, dict) or not self.device:
83
+ raise ValueError("calibration device information is required")
84
+ # Device-specific validity is checked by the read-only reader.
85
+ json.dumps(self.device, allow_nan=False)
86
+ self.plane_native_poses7 = _plane(self.plane_native_poses7)
87
+ for name in ("home_native_pose7", "check_native_pose7"):
88
+ value = getattr(self, name)
89
+ if value is not None:
90
+ setattr(self, name, _pose(value, name))
91
+ elif name != "check_native_pose7":
92
+ raise ValueError(f"{name} is required")
93
+
94
+ def adapter(self):
95
+ return fit_home_plane(self.home_native_pose7, self.plane_native_poses7)
96
+
97
+ def validate(self, identity):
98
+ self.__post_init__()
99
+ if self.identity != identity:
100
+ raise ValueError("calibration does not match this installation")
101
+ self.adapter()
102
+ if self.check_native_pose7 is None:
103
+ raise ValueError("an independent return to Home must verify calibration")
104
+ if not pose_reached(self.home_native_pose7, self.check_native_pose7):
105
+ raise ValueError("Home return did not complete")
106
+
107
+ def to_dict(self):
108
+ return asdict(self)
109
+
110
+
111
+ def save_profile(profile, path=DEFAULT_PROFILE_PATH):
112
+ """Replace the current local calibration atomically, with owner-only permissions."""
113
+ profile = CalibrationProfile(**profile.to_dict())
114
+ profile.validate(profile.identity)
115
+ target = Path(path).expanduser()
116
+ target.parent.mkdir(parents=True, exist_ok=True)
117
+ fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
118
+ try:
119
+ os.fchmod(fd, 0o600)
120
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
121
+ json.dump(profile.to_dict(), stream, indent=2, sort_keys=True, allow_nan=False)
122
+ stream.write("\n")
123
+ stream.flush()
124
+ os.fsync(stream.fileno())
125
+ os.replace(temporary, target)
126
+ finally:
127
+ if os.path.exists(temporary):
128
+ os.unlink(temporary)
129
+ return target
130
+
131
+
132
+ def load_profile(path=DEFAULT_PROFILE_PATH):
133
+ raw = json.loads(Path(path).expanduser().read_text(encoding="utf-8"))
134
+ if not isinstance(raw, dict):
135
+ raise ValueError("calibration must be a JSON object")
136
+ try:
137
+ return CalibrationProfile(**raw)
138
+ except TypeError as exc:
139
+ raise ValueError(f"invalid calibration: {exc}") from exc
@@ -0,0 +1,350 @@
1
+ """Home and level-plane teaching with one automatic return to Home."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import time
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from .. import geometry as geo
15
+ from ..semantics import ACTION_DT
16
+ from ..operations import exclusive
17
+ from ..errors import ControlNotReady
18
+ from ..motion import dispatch, pose_reached, wait_for_pose
19
+ from .profile import CalibrationProfile, load_profile, save_profile
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Calibration:
25
+ def __init__(self, runtime):
26
+ self.runtime = runtime
27
+ self.emb = emb = runtime.emb
28
+ self.operations = runtime.operations
29
+ self.mode = lambda: runtime.mode
30
+ self.cfg = emb.cfg.calibration
31
+ arm = emb.cfg.arm
32
+ identity = [arm.type, getattr(arm, "host", "") or getattr(arm, "ip", ""),
33
+ self.cfg.tool_id, emb.cfg.gripper.type, emb.cfg.gripper.port,
34
+ emb.cfg.camera.type, emb.cfg.camera.device]
35
+ self.identity = hashlib.sha256(json.dumps(identity).encode()).hexdigest()
36
+ self.profile = self.draft = None
37
+ self._motion_connection = False
38
+ self.guiding = emb.new_guiding_session()
39
+ self.guiding_axis = None
40
+ self._last_sample_timestamp = None
41
+ self.home_pose = None
42
+ self.plane_poses = []
43
+ self.device = None
44
+ self.phase = "acquire"
45
+ self.error = self.notice = None
46
+ try:
47
+ profile = load_profile(self.cfg.profile_file)
48
+ self._validate_profile(profile)
49
+ self._install(profile)
50
+ except FileNotFoundError:
51
+ pass
52
+ except (OSError, ValueError, TypeError) as exc:
53
+ self.error = f"Calibration is invalid. Calibrate again. {exc}"
54
+
55
+ def _validate_profile(self, profile):
56
+ profile.validate(self.identity)
57
+ self.emb.new_feedback_reader().validate_device(profile.device)
58
+
59
+ def _install(self, profile):
60
+ self.emb.arm.set_adapter(profile.adapter())
61
+ self.emb.calibrated = True
62
+ self.profile = profile
63
+ self.phase = "complete"
64
+
65
+ def status(self):
66
+ profile = self.draft if self.phase == "preview" else self.profile if self.phase == "complete" else None
67
+ preview = {"axes_in_native_base": profile.adapter().base_from_task[:3, :3].T.tolist()} if profile else None
68
+ return {"phase": self.phase, "busy": self.operations.busy,
69
+ "teaching_method": "guiding" if self.emb.cfg.arm.type == "franka" else "pendant",
70
+ "guiding_axis": self.guiding_axis,
71
+ "connected": bool(getattr(self.emb.feedback, "live", False)),
72
+ "control_connected": self.emb.connected or self._motion_connection,
73
+ "control_enabled": self.emb.control_ready and self.emb.connected,
74
+ "complete": self.emb.calibrated, "error": self.error, "notice": self.notice,
75
+ "tool_load_ready": ((self.guiding.tool_load_ready or self.emb.arm.native.tool_load_ready)
76
+ if self.guiding else None),
77
+ "preview": preview,
78
+ "profile_file": str(Path(self.cfg.profile_file).expanduser().resolve()),
79
+ "hardware_released": not self.emb.connected and not self._motion_connection
80
+ and not (self.guiding and self.guiding.active)}
81
+
82
+ def interrupt(self):
83
+ self.operations.abort.set()
84
+
85
+ def close(self):
86
+ self.emb.close_feedback()
87
+ if self.guiding is not None:
88
+ self.guiding.close()
89
+ if self._motion_connection:
90
+ self.emb.arm.native.disconnect()
91
+ self._motion_connection = False
92
+
93
+ def _open_reader(self):
94
+ try:
95
+ if self.guiding is not None:
96
+ self.guiding.start(self.guiding_axis)
97
+ with self.emb.prepare_feedback() as reader:
98
+ state = self._snapshot(reader)
99
+ self._last_sample_timestamp = state["timestamp"]
100
+ except Exception:
101
+ if self.guiding is not None:
102
+ self.guiding.close()
103
+ raise
104
+ return state
105
+
106
+ def _snapshot(self, reader=None):
107
+ reader = self.emb.feedback if reader is None else reader
108
+ if reader is None or not reader.live:
109
+ raise ValueError("Read-only feedback is unavailable. Start again.")
110
+ state = reader.snapshot()
111
+ reader.validate_device(state["device"])
112
+ if self.device is not None and state["device"] != self.device:
113
+ raise ValueError("Robot settings changed. Exit and calibrate again.")
114
+ return state
115
+
116
+ def _sample(self):
117
+ if self.guiding is not None:
118
+ self.guiding.check_stopped()
119
+ deadline = time.monotonic() + 0.6
120
+ while True:
121
+ self._check_stop()
122
+ state = self._snapshot()
123
+ self._check_stop()
124
+ if state.get("has_errors"):
125
+ raise ControlNotReady("Clear the robot error, then capture again.")
126
+ if self._last_sample_timestamp is None or state["timestamp"] > self._last_sample_timestamp:
127
+ pose = np.asarray(state["pose"], dtype=float)
128
+ geo.pose7_to_matrix(pose)
129
+ self._last_sample_timestamp = state["timestamp"]
130
+ return pose.tolist()
131
+ if time.monotonic() >= deadline:
132
+ raise ValueError("No fresh feedback. Check the connection and capture again.")
133
+ time.sleep(0.02)
134
+
135
+ def _require_phase(self, phase):
136
+ if self.phase != phase:
137
+ raise ValueError("Follow the current teaching step.")
138
+
139
+ def _check_stop(self):
140
+ if self.operations.abort.is_set():
141
+ raise ValueError("Calibration stopped.")
142
+
143
+ def _return_to(self, target, name):
144
+ """Borrow position control for a captured pose, then restore read-only sampling."""
145
+ self.emb.arm.check_motion_ready()
146
+ if self.guiding is not None:
147
+ self.guiding.check_stopped()
148
+ self.close()
149
+ native, cfg = self.emb.arm.native, self.emb.cfg
150
+ original_cfg = native.cfg if cfg.arm.type == "ur" else None
151
+ if getattr(original_cfg, "zero_ft_on_start", False):
152
+ # C is a contact sample. Preserve the sensor zero until ordinary control at Home.
153
+ native.cfg = replace(original_cfg, zero_ft_on_start=False)
154
+ self._motion_connection = True
155
+ completed = False
156
+ try:
157
+ self._check_stop()
158
+ native.connect()
159
+ self._check_stop()
160
+ if cfg.arm.type == "ur":
161
+ device = {**self.device, "tcp_offset": list(native.get_tcp_offset())}
162
+ self.emb.new_feedback_reader().validate_device(device)
163
+ if not np.allclose(device["tcp_offset"], self.device["tcp_offset"], atol=1e-6, rtol=0):
164
+ raise ValueError("The control connection changed the TCP. Calibrate again.")
165
+ plan = geo.plan_path(
166
+ native.get_eef_pose(), [target], 0.0, [0.0], [0.0],
167
+ speed=cfg.arm.cartesian_speed, angular_speed=cfg.arm.cartesian_angular_speed,
168
+ gripper_travel_s=0.0, dt=ACTION_DT,
169
+ )
170
+ def interrupted():
171
+ self._check_stop()
172
+ if not native.live:
173
+ raise ValueError(native.last_error or "Arm control is unavailable.")
174
+ return False
175
+
176
+ dispatch(native, plan.poses, should_stop=interrupted, position_only=True)
177
+ wait_for_pose(native, target, interrupted, name=name)
178
+ completed = True
179
+ finally:
180
+ try:
181
+ native.disconnect()
182
+ finally:
183
+ if original_cfg is not None:
184
+ native.cfg = original_cfg
185
+ self._motion_connection = False
186
+ try:
187
+ self._open_reader()
188
+ except Exception:
189
+ if completed:
190
+ raise
191
+ logger.warning("Read-only feedback could not resume after a failed return", exc_info=True)
192
+ pose = self._sample()
193
+ if not pose_reached(target, pose):
194
+ raise ValueError(f"{name} was not reached. Check the robot and retry Return {name}.")
195
+ return pose
196
+
197
+ def _return_home(self):
198
+ self._require_phase("return_home")
199
+ pose = self._return_to(self.home_pose, "Home")
200
+ self.draft = replace(self.draft, check_native_pose7=pose)
201
+ self.phase = "preview"
202
+
203
+ @exclusive
204
+ def action(self, action, data=None):
205
+ try:
206
+ return self._action(action, {} if data is None else data)
207
+ except ControlNotReady as exc:
208
+ self.notice = str(exc)
209
+ raise
210
+ except (ValueError, OSError, RuntimeError) as exc:
211
+ self.error = str(exc)
212
+ raise
213
+
214
+ def _action(self, action, data):
215
+ if not isinstance(data, dict):
216
+ raise ValueError("request must be an object")
217
+ if self.mode() != "idle":
218
+ raise ValueError("Stop the current action first.")
219
+ if action not in {"acquire", "confirm_home", "confirm_plane_a", "confirm_plane_b", "confirm_plane_c", "return_home", "return_a",
220
+ "save", "undo", "enable_control", "set_guiding_axis"}:
221
+ raise ValueError("unknown calibration action")
222
+ self.operations.reset_abort()
223
+ self.error = self.notice = None
224
+ if action == "acquire":
225
+ if self.phase not in ("acquire", "complete"):
226
+ raise ValueError("Exit the current calibration first.")
227
+ if data.get("confirmed") is not True:
228
+ raise ValueError("Confirm level-plane teaching and the automatic return to Home after C.")
229
+ self.close()
230
+ self.emb.disconnect_control()
231
+ self._check_stop()
232
+ self.device = None
233
+ self.home_pose = self.draft = None
234
+ self.plane_poses = []
235
+ self.guiding_axis = None
236
+ try:
237
+ self.device = self._open_reader()["device"]
238
+ self._check_stop()
239
+ except Exception:
240
+ self.close()
241
+ raise
242
+ self.emb.control_ready = False
243
+ self.phase = "teach_home"
244
+ elif action == "undo":
245
+ previous = {
246
+ "plane_a": ("teach_home", 0),
247
+ "plane_b": ("plane_a", 0),
248
+ "plane_c": ("plane_b", 1),
249
+ "return_home": ("plane_c", 2),
250
+ "preview": ("plane_c", 2),
251
+ }.get(self.phase)
252
+ if previous is None:
253
+ raise ValueError("There is no recorded step to undo.")
254
+ phase, count = previous
255
+ if phase == "teach_home":
256
+ if self.guiding is not None:
257
+ self.guiding.set_axis(None)
258
+ self.guiding_axis = None
259
+ self.home_pose = None
260
+ self.plane_poses = self.plane_poses[:count]
261
+ self.draft = None
262
+ self.phase = phase
263
+ elif action == "enable_control":
264
+ self._require_phase("complete")
265
+ self._validate_profile(self.profile)
266
+ if data.get("confirmed") is not True or data.get("initialization_confirmed") is not True:
267
+ raise ValueError("Confirm that the area is safe and hardware/gripper initialization is allowed.")
268
+ self.close()
269
+ try:
270
+ with self.emb.prepare_feedback() as reader:
271
+ if reader.snapshot()["device"] != self.profile.device:
272
+ raise ValueError("Robot settings do not match calibration. Calibrate again.")
273
+ self.emb.close_feedback()
274
+ if self.operations.abort.is_set():
275
+ raise ValueError("Connection was stopped.")
276
+ self.runtime.connect()
277
+ if self.emb.cfg.arm.type == "ur":
278
+ offset = list(self.emb.arm.native.get_tcp_offset())
279
+ self.emb.new_feedback_reader().validate_device({
280
+ "serial": self.profile.device["serial"],
281
+ "tcp_offset": offset,
282
+ })
283
+ if not np.allclose(offset, self.profile.device["tcp_offset"], atol=1e-6, rtol=0):
284
+ raise ValueError("The control connection changed the TCP. Calibrate again.")
285
+ if not self.emb.alive or self.operations.abort.is_set():
286
+ raise ValueError("The devices are not ready or the connection was stopped.")
287
+ self.emb.control_ready = True
288
+ except Exception:
289
+ self.emb.control_ready = False
290
+ self.close()
291
+ self.runtime.disconnect()
292
+ self.emb.connect_preview()
293
+ raise
294
+ elif action == "confirm_home":
295
+ self._require_phase("teach_home")
296
+ pose = self._sample()
297
+ if self.guiding is not None:
298
+ self.guiding.set_axis("z")
299
+ self.guiding_axis = "z"
300
+ self.home_pose = pose
301
+ self.phase = "plane_a"
302
+ elif action == "set_guiding_axis":
303
+ if self.guiding is None or self.phase not in {"plane_a", "plane_b", "plane_c"}:
304
+ raise ValueError("Single-axis guiding is available during Franka plane teaching.")
305
+ axis = data.get("axis")
306
+ if axis not in ("x", "y", "z"):
307
+ raise ValueError("Guiding axis must be x, y or z")
308
+ self.guiding.set_axis(axis)
309
+ self.guiding_axis = axis
310
+ elif action == "confirm_plane_a":
311
+ self._require_phase("plane_a")
312
+ self.plane_poses = [self._sample()]
313
+ self.phase = "plane_b"
314
+ elif action == "confirm_plane_b":
315
+ self._require_phase("plane_b")
316
+ pose = self._sample()
317
+ if np.linalg.norm(np.asarray(pose[:3]) - self.plane_poses[0][:3]) <= 1e-6:
318
+ raise ValueError("Place B away from A toward task +X.")
319
+ self.plane_poses.append(pose)
320
+ self.phase = "plane_c"
321
+ elif action == "confirm_plane_c":
322
+ self._require_phase("plane_c")
323
+ plane = [*self.plane_poses, self._sample()]
324
+ candidate = CalibrationProfile(self.identity, self.device, self.home_pose, plane)
325
+ candidate.adapter()
326
+ self.plane_poses = plane
327
+ self.draft = candidate
328
+ self.phase = "return_home"
329
+ self._return_home()
330
+ elif action == "return_home":
331
+ self._return_home()
332
+ elif action == "return_a":
333
+ if self.phase not in {"plane_b", "plane_c"}:
334
+ raise ValueError("Capture A before using Return A during plane teaching.")
335
+ self._return_to(self.plane_poses[0], "A")
336
+ elif action == "save":
337
+ self._require_phase("preview")
338
+ if data.get("confirmed") is not True:
339
+ raise ValueError("Confirm saving the calibration.")
340
+ self._snapshot()
341
+ self._validate_profile(self.draft)
342
+ if self.operations.abort.is_set():
343
+ raise ValueError("Saving was stopped.")
344
+ save_profile(self.draft, self.cfg.profile_file)
345
+ self._install(self.draft)
346
+ if self.guiding is not None:
347
+ self.guiding.close()
348
+ return self.status()
349
+
350
+