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
@@ -0,0 +1,350 @@
1
+ // Deterministic Cartesian control primitives, independent of robot IO.
2
+ #pragma once
3
+
4
+ #include <algorithm>
5
+ #include <cmath>
6
+ #include <cstring>
7
+ #include <optional>
8
+
9
+ #include <ruckig/ruckig.hpp>
10
+
11
+ #include "ur_cpp/control_loop.hpp"
12
+ #include "ur_cpp/geom.hpp"
13
+
14
+ inline vec6 rotateAngular(const Quat &q, const vec6 &v) {
15
+ vec6 r = v;
16
+ const auto w = quatMul(quatMul(q, {0, v[3], v[4], v[5]}), quatConj(q));
17
+ r[3] = w.x_;
18
+ r[4] = w.y_;
19
+ r[5] = w.z_;
20
+ return r;
21
+ }
22
+
23
+ struct AdmittanceLaw {
24
+ Tuning tun_;
25
+ vec6 filt_{}, vel_{}, off_{}, before_{}, force_{};
26
+ vec6 Mh_{}, D0_{}, kr_{}, maxwell_{}, maxwell_before_{}, candidate_{};
27
+ bool first_ = true;
28
+ bool notch_on_ = false;
29
+ double nb_[2][3]{}, na_[2][2]{}, nx_[2][6][2]{}, ny_[2][6][2]{};
30
+
31
+ explicit AdmittanceLaw(const Tuning &t) : tun_(t) {
32
+ const double wc = 2 * M_PI * t.relaxation_hz_;
33
+ for (int i = 0; i < 6; ++i) {
34
+ const double damping = 2 * t.zeta_ * std::sqrt(t.M_[i]) * std::sqrt(t.K_[i]);
35
+ const double extra = (t.hf_mass_ratio_[i] - 1) * t.M_[i];
36
+ Mh_[i] = t.M_[i] + extra;
37
+ D0_[i] = damping - extra * wc;
38
+ kr_[i] = extra * wc * wc;
39
+ }
40
+ }
41
+ void initNotch(double fs) {
42
+ notch_on_ = tun_.notch_freq_ > 0;
43
+ for (int s = 0; s < 2; ++s) {
44
+ const double w = 2 * M_PI * tun_.notch_freq_ * (s + 1) / fs;
45
+ if (w >= .9 * M_PI) {
46
+ nb_[s][0] = 1;
47
+ continue;
48
+ }
49
+ const double alpha = std::sin(w) / 8, den = 1 + alpha;
50
+ nb_[s][0] = nb_[s][2] = 1 / den;
51
+ nb_[s][1] = -2 * std::cos(w) / den;
52
+ na_[s][0] = nb_[s][1];
53
+ na_[s][1] = (1 - alpha) / den;
54
+ }
55
+ }
56
+ void reset() {
57
+ filt_ = {};
58
+ vel_ = {};
59
+ off_ = {};
60
+ before_ = {};
61
+ force_ = {};
62
+ maxwell_ = {};
63
+ maxwell_before_ = {};
64
+ candidate_ = {};
65
+ std::memset(nx_, 0, sizeof(nx_));
66
+ std::memset(ny_, 0, sizeof(ny_));
67
+ first_ = true;
68
+ }
69
+ // Predict velocity, without committing a fictitious unconstrained position.
70
+ bool predict(const vec6 &wrench, double dt) {
71
+ if (!finite6(wrench) || !finite6(off_) || !finite6(vel_) || !finite6(maxwell_) ||
72
+ !std::isfinite(dt) || dt <= 0)
73
+ return false;
74
+ before_ = off_;
75
+ maxwell_before_ = maxwell_;
76
+ vec6 w = wrench;
77
+ if (notch_on_)
78
+ for (int s = 0; s < 2; ++s)
79
+ for (int i = 0; i < 6; ++i) {
80
+ const double y = nb_[s][0] * w[i] + nb_[s][1] * nx_[s][i][0] + nb_[s][2] * nx_[s][i][1] -
81
+ na_[s][0] * ny_[s][i][0] - na_[s][1] * ny_[s][i][1];
82
+ nx_[s][i][1] = nx_[s][i][0];
83
+ nx_[s][i][0] = w[i];
84
+ ny_[s][i][1] = ny_[s][i][0];
85
+ ny_[s][i][0] = y;
86
+ w[i] = y;
87
+ }
88
+ for (int i = 0; i < 6; ++i) {
89
+ filt_[i] = first_ ? w[i] : filt_[i] + dt / (tun_.filter_tau_ + dt) * (w[i] - filt_[i]);
90
+ const double db = i < 3 ? tun_.deadband_force_ : tun_.deadband_torque_;
91
+ force_[i] = std::copysign(std::max(0.0, std::abs(filt_[i]) - db), filt_[i]);
92
+ // One implicit solve for damping + Maxwell stress, with the previous
93
+ // geometric spring. No speculative stress or offset is committed here.
94
+ const double g = 1 / (1 + dt * 2 * M_PI * tun_.relaxation_hz_);
95
+ const double den = Mh_[i] + dt * D0_[i] + dt * dt * kr_[i] * g;
96
+ if (!std::isfinite(den) || den <= 0)
97
+ return false;
98
+ vel_[i] =
99
+ (Mh_[i] * vel_[i] + dt * (force_[i] - tun_.K_[i] * off_[i] - g * maxwell_before_[i])) /
100
+ den;
101
+ }
102
+ first_ = false;
103
+ if (!finite6(filt_) || !finite6(force_) || !finite6(vel_))
104
+ return false;
105
+ const double n = norm3(vel_);
106
+ if (tun_.max_vel_ > 0 && n > tun_.max_vel_)
107
+ for (int i = 0; i < 3; ++i)
108
+ vel_[i] *= tun_.max_vel_ / n;
109
+ candidate_ = vel_;
110
+ return true;
111
+ }
112
+ // Only cancel an unexecuted component of the virtual motion. A P/reference
113
+ // shaping residual must not create offset motion, change its direction, or
114
+ // amplify it. Translation and rotation have independent dimensional groups.
115
+ bool commit(const vec6 &governor_residual, double dt) {
116
+ if (!finite6(governor_residual) || !finite6(candidate_) || !std::isfinite(dt) || dt <= 0)
117
+ return false;
118
+ vec6 accepted{}, next = before_, stress{};
119
+ for (int o : {0, 3}) {
120
+ const double n = norm3(candidate_, o);
121
+ double alpha = 0;
122
+ if (n > 1e-12) {
123
+ double along = 0;
124
+ for (int i = o; i < o + 3; ++i)
125
+ along += governor_residual[i] * (candidate_[i] / n);
126
+ alpha = std::clamp(1 + along / n, 0., 1.);
127
+ }
128
+ for (int i = o; i < o + 3; ++i)
129
+ accepted[i] = alpha * candidate_[i];
130
+ }
131
+ if (!finite6(accepted))
132
+ return false;
133
+ for (int i = 0; i < 3; ++i)
134
+ next[i] = before_[i] + dt * accepted[i];
135
+ const auto r = quatMul(rotvecToQuat(dt * accepted[3], dt * accepted[4], dt * accepted[5]),
136
+ rotvecToQuat(before_[3], before_[4], before_[5]));
137
+ quatToRotvec(r, next[3], next[4], next[5], &before_);
138
+ const double g = 1 / (1 + dt * 2 * M_PI * tun_.relaxation_hz_);
139
+ for (int i = 0; i < 6; ++i)
140
+ stress[i] = g * (maxwell_before_[i] + dt * kr_[i] * accepted[i]);
141
+ if (!finite6(next) || !finite6(stress))
142
+ return false;
143
+ vel_ = accepted;
144
+ off_ = next;
145
+ maxwell_ = stress;
146
+ return true;
147
+ }
148
+ };
149
+
150
+ // Continuous vector velocity / acceleration / jerk shaping. Candidate rejection
151
+ // never latches motion: advance the accepted trajectory and retry next period.
152
+ struct TwistGovernor {
153
+ ruckig::Ruckig<6> otg_;
154
+ ruckig::InputParameter<6> in_;
155
+ vec6 sent_{}, acceleration_{}, displacement_{};
156
+ double dt_, vt_, vr_;
157
+
158
+ TwistGovernor(double h, double v, double w, double a, double alpha, double j, double jw)
159
+ : otg_(h), dt_(h), vt_(v), vr_(w) {
160
+ in_.control_interface = ruckig::ControlInterface::Velocity;
161
+ in_.synchronization = ruckig::Synchronization::None;
162
+ in_.current_position = {};
163
+ in_.target_position = {};
164
+ for (int i = 0; i < 6; ++i) {
165
+ in_.max_velocity[i] = i < 3 ? v : w;
166
+ in_.max_acceleration[i] = (i < 3 ? a : alpha) / std::sqrt(3.0);
167
+ in_.max_jerk[i] = (i < 3 ? j : jw) / std::sqrt(3.0);
168
+ in_.target_acceleration[i] = 0;
169
+ }
170
+ }
171
+
172
+ void reset(const vec6 &velocity = {}) {
173
+ in_.current_position = {};
174
+ in_.current_velocity = sent_ = velocity;
175
+ in_.current_acceleration = acceleration_ = {};
176
+ displacement_ = {};
177
+ accepted_.reset();
178
+ elapsed_ = 0;
179
+ }
180
+
181
+ static bool velocitySegmentInside(const vec6 &v, const vec6 &a, const vec6 &j, double duration,
182
+ int offset, double limit, int depth = 0) {
183
+ // A quadratic velocity segment has three Bernstein control points. The
184
+ // convex hull bounds its norm at every instant, including between ticks.
185
+ vec6 b0 = v, b1 = v, b2 = v;
186
+ for (int i = offset; i < offset + 3; ++i) {
187
+ b1[i] += a[i] * duration / 2;
188
+ b2[i] += a[i] * duration + j[i] * duration * duration / 2;
189
+ }
190
+ if (!finite6(b0) || !finite6(b1) || !finite6(b2))
191
+ return false;
192
+ const double cap = limit + 1e-8;
193
+ if (norm3(b0, offset) > cap || norm3(b2, offset) > cap)
194
+ return false;
195
+ if (norm3(b1, offset) <= cap)
196
+ return true;
197
+ if (depth >= 20)
198
+ return false; // conservative on unresolved near-limit arcs
199
+ vec6 mid_v = v, mid_a = a;
200
+ for (int i = offset; i < offset + 3; ++i) {
201
+ mid_v[i] += a[i] * duration / 2 + j[i] * duration * duration / 8;
202
+ mid_a[i] += j[i] * duration / 2;
203
+ }
204
+ return velocitySegmentInside(v, a, j, duration / 2, offset, limit, depth + 1) &&
205
+ velocitySegmentInside(mid_v, mid_a, j, duration / 2, offset, limit, depth + 1);
206
+ }
207
+
208
+ bool vectorVelocityInside(const ruckig::Trajectory<6> &trajectory) const {
209
+ const auto profiles = trajectory.get_profiles();
210
+ const double duration = trajectory.get_duration();
211
+ if (profiles.size() != 1 || !std::isfinite(duration) || duration < 0)
212
+ return false;
213
+ // Include Ruckig's initial acceleration correction, when present.
214
+ std::array<double, 64> times{};
215
+ std::size_t count = 0;
216
+ times[count++] = 0;
217
+ times[count++] = duration;
218
+ for (const auto &profile : profiles[0]) {
219
+ if (!std::isfinite(profile.brake.duration) || profile.brake.duration < 0)
220
+ return false;
221
+ if (profile.brake.duration > 0) {
222
+ double t = 0;
223
+ for (double h : profile.brake.t) {
224
+ t += h;
225
+ if (t > 0 && t < duration)
226
+ times[count++] = t;
227
+ }
228
+ }
229
+ for (double t : profile.t_sum) {
230
+ t += profile.brake.duration;
231
+ if (t > 0 && t < duration)
232
+ times[count++] = t;
233
+ }
234
+ }
235
+ std::sort(times.begin(), times.begin() + count);
236
+ // On entry from another mode, recover an existing overspeed continuously.
237
+ const double trans = std::max(vt_, norm3(in_.current_velocity));
238
+ const double rot = std::max(vr_, norm3(in_.current_velocity, 3));
239
+ for (std::size_t k = 0; k + 1 < count; ++k) {
240
+ const double start = times[k], end = times[k + 1];
241
+ if (end - start <= 1e-12)
242
+ continue;
243
+ vec6 p{}, v{}, a{}, j{};
244
+ trajectory.at_time(start, p, v, a);
245
+ if (!finite6(p) || !finite6(v) || !finite6(a))
246
+ return false;
247
+ const double mid = (start + end) / 2;
248
+ for (int axis = 0; axis < 6; ++axis) {
249
+ const auto &profile = profiles[0][axis];
250
+ if (mid < profile.brake.duration) {
251
+ const int phase = mid < profile.brake.t[0] ? 0 : 1;
252
+ j[axis] = profile.brake.j[phase];
253
+ } else {
254
+ for (int phase = 0; phase < 7; ++phase)
255
+ if (mid - profile.brake.duration < profile.t_sum[phase]) {
256
+ j[axis] = profile.j[phase];
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ if (!velocitySegmentInside(v, a, j, end - start, 0, trans) ||
262
+ !velocitySegmentInside(v, a, j, end - start, 3, rot))
263
+ return false;
264
+ }
265
+ vec6 p{}, v{}, a{};
266
+ trajectory.at_time(duration, p, v, a);
267
+ return finite6(p) && finite6(v) && finite6(a) && norm3(v) <= trans + 1e-8 &&
268
+ norm3(v, 3) <= rot + 1e-8;
269
+ }
270
+
271
+ bool step(const vec6 &target) {
272
+ if (!finite6(target) || !finite6(in_.current_velocity) || !finite6(in_.current_acceleration) ||
273
+ !std::isfinite(dt_) || dt_ <= 0)
274
+ return false;
275
+ in_.current_position = {};
276
+ in_.target_velocity = target;
277
+ for (int offset : {0, 3}) {
278
+ const double n = std::hypot(target[offset], target[offset + 1], target[offset + 2]);
279
+ const double cap = offset == 0 ? vt_ : vr_;
280
+ if (n > cap)
281
+ for (int i = offset; i < offset + 3; ++i)
282
+ in_.target_velocity[i] *= cap / n;
283
+ }
284
+ const auto desired = in_.target_velocity;
285
+ ruckig::Trajectory<6> candidate;
286
+ auto calculate = [&] {
287
+ return otg_.calculate(in_, candidate) >= 0 && vectorVelocityInside(candidate);
288
+ };
289
+ bool found = calculate();
290
+ if (!found) {
291
+ // Preserve the vector envelope during turns with independent axis timing.
292
+ in_.target_velocity = {};
293
+ found = calculate();
294
+ if (found) {
295
+ double low = 0, high = 1;
296
+ ruckig::Trajectory<6> best = candidate;
297
+ for (int iteration = 0; iteration < 16; ++iteration) {
298
+ const double scale = (low + high) / 2;
299
+ for (int i = 0; i < 6; ++i)
300
+ in_.target_velocity[i] = desired[i] * scale;
301
+ if (calculate()) {
302
+ low = scale;
303
+ best = candidate;
304
+ } else {
305
+ high = scale;
306
+ }
307
+ }
308
+ candidate = best;
309
+ for (int i = 0; i < 6; ++i)
310
+ in_.target_velocity[i] = desired[i] * low;
311
+ }
312
+ }
313
+ if (found) {
314
+ accepted_ = candidate;
315
+ elapsed_ = 0;
316
+ }
317
+ // A failed solve leaves the existing plan advancing, including its velocity
318
+ // and acceleration. There is no fault, zero-velocity target latch or pause.
319
+ if (!advance())
320
+ return false;
321
+ in_.current_velocity = sent_;
322
+ in_.current_acceleration = acceleration_;
323
+ return true;
324
+ }
325
+
326
+ private:
327
+ std::optional<ruckig::Trajectory<6>> accepted_;
328
+ double elapsed_ = 0;
329
+
330
+ bool advance() {
331
+ if (accepted_) {
332
+ vec6 previous{}, velocity{}, acceleration{}, position{};
333
+ accepted_->at_time(elapsed_, previous, velocity, acceleration);
334
+ elapsed_ += dt_;
335
+ accepted_->at_time(elapsed_, position, velocity, acceleration);
336
+ if (!finite6(previous) || !finite6(position) || !finite6(velocity) || !finite6(acceleration))
337
+ return false;
338
+ for (int i = 0; i < 6; ++i)
339
+ displacement_[i] = position[i] - previous[i];
340
+ sent_ = velocity;
341
+ acceleration_ = acceleration;
342
+ if (elapsed_ >= accepted_->get_duration())
343
+ accepted_.reset();
344
+ } else {
345
+ for (int i = 0; i < 6; ++i)
346
+ displacement_[i] = sent_[i] * dt_;
347
+ }
348
+ return finite6(displacement_) && finite6(sent_) && finite6(acceleration_);
349
+ }
350
+ };
@@ -0,0 +1,26 @@
1
+ #pragma once
2
+
3
+ #include <atomic>
4
+ #include <cstdint>
5
+ #include <memory>
6
+ #include <string>
7
+
8
+ #include "ur_cpp/control_loop.hpp"
9
+
10
+ // Reverse-interface writer. The RTDE thread is the only caller of write*.
11
+ struct RobotIO {
12
+ double robot_time_ = 0;
13
+ int32_t safety_mode_ = 1;
14
+ vec6 tcp_speed_{}, qd_{};
15
+ std::atomic<bool> program_running_{false};
16
+ virtual ~RobotIO() = default;
17
+ virtual bool tick(vec6 &tcp_pose, vec6 &q, vec6 &wrench) = 0;
18
+ virtual bool writeTwist(const vec6 &tw) = 0;
19
+ virtual bool writeKeepalive() = 0;
20
+ virtual bool zeroFT() = 0;
21
+ virtual void stopControl() = 0;
22
+ };
23
+
24
+ // The worker opens URCL headless external-control; unit tests provide in-memory IO.
25
+ // On failure, error is set and the pointer is null.
26
+ std::unique_ptr<RobotIO> openRobotIo(const Args &args, std::atomic<bool> &stop, std::string *error);