Skip to main content

franka/robot/target_control/
mod.rs

1//! Target control: a 1 kHz loop on its own thread that follows targets a low-rate commander
2//! sets whenever it likes.
3//!
4//! Most programs that want to move a Franka are not 1 kHz programs: a planner, a vision
5//! loop, a script over a socket, a hand on a keyboard. They produce *targets* -- at 10 Hz,
6//! in bursts, with pauses -- each a step the robot must never see as a step. This is the
7//! fourth way to control the robot, next to the callbacks, `ActiveControl` and the
8//! read-only stream: [`Robot::start_cartesian_target_control`] and
9//! [`Robot::start_joint_target_control`] spawn a named thread that runs the crate's own
10//! control loop and hand back a handle whose `set_*` can be called from any thread, at any
11//! rate, with any target. The handle's [`stop`](CartesianTargetControl::stop) brings the
12//! command to rest on the last target, finishes the motion and returns the loop's result.
13//!
14//! # Two backends
15//! The [`Backend`] of the options decides what the generator's output becomes. The default,
16//! [`Backend::Impedance`], sends **torques**: the hybrid joint and Cartesian impedance law of
17//! [`ImpedanceGains`] (`Kp = J^T Kx J + Kq`, `Kd = J^T Kxd J + Kqd`, the damping on the
18//! measured velocity, or with [`ImpedanceOptions::velocity_feedforward`] on the velocity error
19//! `dq_goal - dq`, plus the Coriolis term, clamped to the torque limits) tracks a joint target
20//! `q_goal` -- the generator's own output on the joint interface, the solution of a
21//! differential inverse kinematics following the generator's pose one cycle at a time on the
22//! Cartesian one ([`IkOptions`]) -- through [`Robot::control_torques`] with the crate's
23//! low-pass filter ([`ImpedanceOptions::cutoff_frequency`]) and torque rate limiter. The goal
24//! never moves a joint faster than [`ImpedanceOptions::joint_velocity_fraction`] of the arm's limit
25//! ([`max_joint_velocity`], which narrows toward the position limits on the FR3): a faster step is
26//! scaled down as a whole and the generator re-anchored on what went out. A joint *measured* faster
27//! than the cap, or than [`FADE_BAND`] of its limit under the barrier's onset if that is lower,
28//! keeps less and less of the law's torque along its motion, none from
29//! [`ImpedanceOptions::velocity_barrier_fraction`] of its limit, where it meets a damping of
30//! [`VELOCITY_BARRIER_GAIN`] on the excess, added before the clamp. Toward a joint
31//! position limit the goal brakes to stop [`ImpedanceOptions::joint_position_margin`] inside it; a
32//! joint measured inside the margin keeps less of the law's torque toward the limit over
33//! [`POSITION_FADE_BAND`], then meets a spring ([`POSITION_BARRIER_STIFFNESS`]). There is no echo of
34//! a torque command, so the loop anchors on the *measured* configuration in its first
35//! cycle (the Cartesian interface on the model's pose of it, where the IK's residual is zero), and
36//! then, every cycle, on the measured state pulled toward the previous desired by at most the
37//! [`Leash`]: exactly the previous desired while the arm follows, so the generator runs from its
38//! own last output and its limits are the whole budget; a bounded distance ahead of an arm that is
39//! held back, so the spring force is bounded by the stiffness times the leash and the generator
40//! resumes from where the arm is once it is let go.
41//! [`Backend::RobotController`] instead streams the generator's output as a pose or joint-position
42//! command to the robot's own impedance controller (`controller_mode`); the rest of this page
43//! describes that path where the two differ.
44//!
45//! # What the loop does every cycle
46//! The three rules of the [`otg`](crate::otg) module: the generator's
47//! limits are **per axis** (a Cartesian budget is a norm, so it gets
48//! [`OtgLimits::per_axis_for_norm`](crate::otg::OtgLimits::per_axis_for_norm)`(3)`), it steps **one nominal cycle** per command
49//! ([`DELTA_T`](crate::rate_limiting::DELTA_T)) whatever the measured period, and it is
50//! **re-anchored on the last command** -- the robot's echo of it (`O_T_EE_c`, `q_d`) with
51//! [`Backend::RobotController`], the leashed previous output with [`Backend::Impedance`] --
52//! with `set_position` before every re-plan. With [`Backend::RobotController`] the
53//! rate limiter under the same budget then runs as the backstop that must never bind
54//! (`limit_rate_cartesian_pose`, `limit_rate_joint_positions`), and the loop's own libfranka
55//! limiter stays on behind it; the observer is told by how much the backstop moved the
56//! command. The first setpoint of the motion is always the anchor itself
57//! -- on FCI v10 the first command is its own filter reference
58//! and would otherwise go out as a jump -- and the start returns only once that cycle has
59//! run, so [`target`](CartesianTargetControl::target) and
60//! [`state`](CartesianTargetControl::state) are valid from the first call.
61//!
62//! The Cartesian target is a pose: three more axes of the same synchronised generator run
63//! on the base-frame rotation vector of the orientation error, `log(R_target * R_echo^T)`,
64//! re-anchored at zero every cycle and composed back as `exp(step) * R_echo`, under a
65//! rotational norm budget of their own; translation and rotation arrive together.
66//!
67//! Two guards. If the *measured* pose strays more than `max_deviation` (m) or
68//! `max_angular_deviation` (rad) from the start, the target freezes where the command is,
69//! the generator brings it to rest, and the loop ends with [`FrankaError::Control`]. And a
70//! stop never finishes on a moving command: once every axis of the generator has landed
71//! (within [`Settle::tolerance`] of the target, slower than [`REST_VELOCITY`], accelerating
72//! less than [`REST_ACCELERATION`] -- the orientation included, in rad) the loop stops
73//! stepping it and holds the last command -- with [`Backend::RobotController`] the robot's
74//! echo of it, continuous with what the robot has by construction, whatever the backstop took
75//! off that command, sent bit for bit and past the backstop; with [`Backend::Impedance`] the
76//! landed desired pose or joint goal, whose torques are those of rest -- for
77//! [`Settle::cycles`] cycles, then
78//! sets `motion_finished` on one more of it: the sequence a robot accepts as "finished at
79//! rest". If the generator has not landed within [`STOP_TIMEOUT_CYCLES`] the same hold starts
80//! from wherever the command is. That hold settles the generator, not the arm: in torque mode
81//! an arm still closing its lag would be handed to the robot's controller short of the goal,
82//! so [`Backend::Impedance`] finishes only once every joint moves slower than
83//! [`REST_JOINT_VELOCITY`], or after [`STOP_TIMEOUT_CYCLES`] more cycles, the law kept on the
84//! held goal meanwhile.
85//!
86//! # Threads
87//! The commander side is a single-writer seqlock ([`TargetSlot`]) the loop polls without
88//! blocking; `set_*` serialises its callers with a mutex on the user side only. The latest
89//! [`RobotState`] is published with `Mutex::try_lock` from the realtime side and read with
90//! `lock` on the user side. Nothing allocates on the realtime thread after the start. The
91//! optional observer is the exception to "nothing of yours runs at 1 kHz": it is called on
92//! the realtime thread every cycle with the state and what was sent, and must not allocate
93//! or block -- copying into a preallocated ring, as `franka_rerun::Recorder::push` does, is
94//! what it is for.
95//!
96//! The loop thread is raised to `SCHED_FIFO` like `Robot::new` raises its caller: to the
97//! highest priority, or to `realtime_priority` when the options name one; when they name a
98//! `cpu`, it also pins itself to that core, whether or not the raise succeeded. A failure of
99//! either is fatal under [`RealtimeConfig::Enforce`] and ignored under
100//! [`RealtimeConfig::Ignore`].
101//!
102//! Only one control or read operation may run on a `Robot` at a time, so while a target
103//! control runs, `robot.read()` and the other loops fail with
104//! [`FrankaError::InvalidOperation`], exactly as with a callback loop on another thread;
105//! `robot.stop()` preempts it, and the handle's `stop` then returns the preemption as a
106//! [`FrankaError::Control`].
107//!
108//! ```no_run
109//! use std::sync::Arc;
110//! use franka::{RealtimeConfig, Robot, TargetControlOptions};
111//!
112//! # fn main() -> franka::FrankaResult<()> {
113//! let robot = Arc::new(Robot::new("172.16.0.2", RealtimeConfig::Enforce)?);
114//! let control = robot.start_cartesian_target_control(TargetControlOptions::default())?;
115//! let start = control.target();
116//! for step in 1..=5 {
117//!     // A planner, a socket, a keyboard: any thread, any rate, any target.
118//!     control.set_position([start[0] + 0.01 * f64::from(step), start[1], start[2]])?;
119//!     std::thread::sleep(std::time::Duration::from_millis(300));
120//! }
121//! // An orientation too: a unit quaternion in [x, y, z, w] order, or a pose as O_T_EE.
122//! let yaw = std::f64::consts::FRAC_PI_8;
123//! let orientation = [0.0, 0.0, (yaw / 2.0).sin(), (yaw / 2.0).cos()];
124//! control.set_target(start, orientation)?;
125//! control.stop()?; // settles on the last target, finishes the motion, joins the thread
126//! # Ok(())
127//! # }
128//! ```
129
130mod cartesian;
131mod envelope;
132mod ik;
133mod impedance;
134mod joint;
135mod options;
136mod position;
137mod rotation;
138mod runner;
139mod slot;
140mod torque;
141mod tuning;
142mod velocity;
143
144pub use cartesian::{CartesianObserver, CartesianSent, CartesianTargetControl};
145pub use ik::{IkOptions, MAX_POSTURE_RATE};
146pub use impedance::{
147    impedance_torques, Backend, ImpedanceGains, ImpedanceOptions, Leash, MIN_FEEDFORWARD_CUTOFF,
148    RATED_TORQUES,
149};
150pub use joint::{JointObserver, JointSent, JointTargetControl};
151pub use options::{JointTargetControlOptions, TargetControlOptions, DEFAULT_LIMIT_FRACTION};
152pub use position::{POSITION_BARRIER_STIFFNESS, POSITION_FADE_BAND};
153pub use rotation::{ORTHONORMAL_TOLERANCE, UNIT_QUATERNION_TOLERANCE};
154pub use slot::TargetSlot;
155pub use tuning::{
156    FieldBound, LiveTuning, SlewGains, TuningDanger, TuningPolicy, TuningUpdate,
157    MIN_JOINT_DAMPING_RATIO, SLEW_TAU,
158};
159pub use velocity::{FADE_BAND, VELOCITY_BARRIER_GAIN};
160
161use runner::Runner;
162pub use runner::{REST_ACCELERATION, REST_JOINT_VELOCITY, REST_VELOCITY};
163
164use std::sync::atomic::{AtomicBool, Ordering};
165use std::sync::mpsc::{self, SyncSender};
166use std::sync::{Arc, Mutex, PoisonError};
167use std::thread::JoinHandle;
168
169use crate::error::{ControlException, FrankaError, FrankaResult};
170use crate::rate_limiting;
171use crate::realtime::{
172    pin_current_thread_to_cpu, set_current_thread_scheduler_priority,
173    set_current_thread_to_highest_scheduler_priority, RealtimeConfig,
174};
175use crate::robot::Robot;
176use crate::robot_state::RobotState;
177use crate::wire::robot::codec::FciVersion;
178
179/// Cycles a stop waits for the generator to land before holding and finishing from wherever
180/// the command is: five seconds.
181pub const STOP_TIMEOUT_CYCLES: u32 = 5000;
182
183/// The message of the [`FrankaError::InvalidOperation`] a `set_*` returns once the loop has
184/// ended, for whatever reason; `stop()` has the reason.
185pub const ENDED_MESSAGE: &str =
186    "franka target control: the control loop has ended; stop() returns its result.";
187
188/// The message of the [`FrankaError::InvalidOperation`] a live-tuning call returns on a session
189/// whose interface or backend has none: [`LiveTuning`] describes the impedance law of the
190/// Cartesian interface, and nothing else runs one.
191pub const NO_TUNING_MESSAGE: &str = "franka target control: this session has no live tuning; \
192     it is the Cartesian interface's on the impedance backend.";
193
194/// The message of the [`FrankaError::InvalidOperation`] a live-tuning call returns on a Cartesian
195/// impedance session whose gains [`LiveTuning::gains`] cannot rebuild from one Cartesian
196/// stiffness -- [`ImpedanceGains::DROID`] is the preset this is true of. Such a session keeps the
197/// gains it was started with and is given no tuning slot, rather than having the six derived
198/// dampings rewritten in one cycle by the first change to any other field.
199pub const DERIVED_GAINS_MESSAGE: &str = "franka target control: this session has no live tuning; \
200     its Cartesian gains are not a scaled ImpedanceGains::CARTESIAN, which is all the one \
201     tunable stiffness can rebuild.";
202
203/// The message of the [`FrankaError::Control`] the loop ends with after the deviation guard
204/// froze the target.
205pub const DEVIATION_MESSAGE: &str = "franka target control: the measured pose strayed \
206     further than max_deviation (or max_angular_deviation) from the start; the target was \
207     frozen where the command was and the motion finished from rest.";
208
209/// How a stop ends: once the generator is within `tolerance` of the target on every axis and
210/// at rest ([`REST_VELOCITY`], [`REST_ACCELERATION`]), the echo of the last command is held
211/// for `cycles` cycles and `motion_finished` set on the next one.
212#[derive(Debug, Clone, Copy, PartialEq)]
213pub struct Settle {
214    /// Per-axis tolerance, in the interface's unit: m for a position, rad for a joint or for
215    /// the orientation error of a pose. Default 1e-3.
216    pub tolerance: f64,
217    /// Cycles the landed command is held, identical, before `motion_finished`. Default 250.
218    pub cycles: u32,
219}
220
221impl Default for Settle {
222    fn default() -> Self {
223        Settle {
224            tolerance: 1e-3,
225            cycles: 250,
226        }
227    }
228}
229
230/// How the loop thread is scheduled: its `SCHED_FIFO` priority (`None`: the highest) and the
231/// CPU it is pinned to, if any.
232#[derive(Debug, Clone, Copy)]
233struct Scheduling {
234    priority: Option<i32>,
235    cpu: Option<usize>,
236}
237
238/// Checks the options both interfaces share.
239fn validate_common(
240    max_deviation: f64,
241    settle: Settle,
242    scheduling: Scheduling,
243    backend: &Backend,
244) -> FrankaResult<()> {
245    if let Backend::Impedance(impedance) = backend {
246        impedance.validate()?;
247    }
248    let positive = |x: f64| x.is_finite() && x > 0.0;
249    if !positive(max_deviation) {
250        return Err(FrankaError::InvalidArgument(format!(
251            "target control: max_deviation must be finite and positive, got {max_deviation}"
252        )));
253    }
254    if !positive(settle.tolerance) || settle.cycles == 0 {
255        return Err(FrankaError::InvalidArgument(format!(
256            "target control: settle needs a finite, positive tolerance and at least one \
257             cycle, got {settle:?}"
258        )));
259    }
260    if let Some(priority) = scheduling.priority {
261        if !(1..=99).contains(&priority) {
262            return Err(FrankaError::InvalidArgument(format!(
263                "target control: realtime_priority must be within 1..=99, got {priority}"
264            )));
265        }
266    }
267    if let Some(cpu) = scheduling.cpu {
268        if cpu >= libc::CPU_SETSIZE as usize {
269            return Err(FrankaError::InvalidArgument(format!(
270                "target control: cpu must be below {}, got {cpu}",
271                libc::CPU_SETSIZE
272            )));
273        }
274    }
275    Ok(())
276}
277
278/// The joint position limits (lower, upper), rad, of the arm speaking `version`: the FER's or
279/// the FR3's `JOINT_POSITION_LIMITS`.
280pub fn joint_position_limits(version: FciVersion) -> ([f64; 7], [f64; 7]) {
281    match version {
282        FciVersion::V5 => rate_limiting::fer::JOINT_POSITION_LIMITS,
283        FciVersion::V10 => rate_limiting::JOINT_POSITION_LIMITS,
284    }
285}
286
287/// The FR3's joint velocity caps, rad/s: the specifications page's `dq_max`, where its
288/// position-dependent envelope saturates away from the joint limits; the `<limit velocity>` of
289/// its URDF carries the same values. Nominal, where the V5 branch of [`max_joint_velocity`]
290/// returns the FER's `MAX_JOINT_VELOCITY` already less libfranka's `LIMIT_EPS` and lost-packet
291/// tolerance: each branch hands back the constant its arm's module defines, so the two differ by
292/// that 1e-3 rad/s.
293const FR3_MAX_JOINT_VELOCITY: [f64; 7] = [2.62, 2.62, 2.62, 2.62, 5.26, 4.18, 5.26];
294
295/// The joint velocity limits, rad/s, of the arm speaking `version` away from its joint position
296/// limits: the FER's `MAX_JOINT_VELOCITY` or the FR3's caps. The FR3's limit narrows toward the
297/// position limits, which the impedance backend follows; the FER's is flat. Its cap and barrier
298/// ([`ImpedanceOptions::joint_velocity_fraction`], [`ImpedanceOptions::velocity_barrier_fraction`])
299/// and [`JointTargetControlOptions::scaled_limits`] are fractions of these.
300pub fn max_joint_velocity(version: FciVersion) -> [f64; 7] {
301    match version {
302        FciVersion::V5 => rate_limiting::fer::MAX_JOINT_VELOCITY,
303        FciVersion::V10 => FR3_MAX_JOINT_VELOCITY,
304    }
305}
306
307/// How far, rad, inside the negotiated version's joint position limits a joint target and an
308/// impedance posture must lie: [`JointTargetControl::set_joints`] and both `start`s refuse a
309/// configuration outside. [`Backend::Impedance`] refuses one inside
310/// [`ImpedanceOptions::joint_position_margin`] where that is larger.
311pub const JOINT_LIMIT_INSET: f64 = 0.02;
312
313/// The inset, rad, targets and postures of `backend` are refused inside.
314fn joint_limit_inset(backend: &Backend) -> f64 {
315    match backend {
316        Backend::Impedance(impedance) => JOINT_LIMIT_INSET.max(impedance.joint_position_margin),
317        Backend::RobotController => JOINT_LIMIT_INSET,
318    }
319}
320
321/// Refuses a `q` outside `limits` inset by `inset`, naming the joint and `what`.
322fn check_joint_limits(
323    q: &[f64; 7],
324    limits: &([f64; 7], [f64; 7]),
325    inset: f64,
326    what: &str,
327) -> FrankaResult<()> {
328    for (i, value) in q.iter().enumerate() {
329        let lower = limits.0[i] + inset;
330        let upper = limits.1[i] - inset;
331        if !(lower..=upper).contains(value) {
332            return Err(FrankaError::InvalidArgument(format!(
333                "target control: the {what} puts joint {} at {value} rad, outside \
334                 [{lower}, {upper}] ({inset} rad inside the arm's limits)",
335                i + 1
336            )));
337        }
338    }
339    Ok(())
340}
341
342/// The posture of an impedance backend, checked against the arm's limits at `start`.
343fn check_posture(backend: &Backend, limits: &([f64; 7], [f64; 7])) -> FrankaResult<()> {
344    match backend {
345        Backend::Impedance(ImpedanceOptions {
346            posture: Some(posture),
347            ..
348        }) => check_joint_limits(posture, limits, joint_limit_inset(backend), "posture"),
349        _ => Ok(()),
350    }
351}
352
353/// What the user thread and the loop thread share.
354struct Shared<const N: usize> {
355    slot: TargetSlot<N>,
356    /// The live tuning, as [`LiveTuning::to_words`]: the same seqlock as `slot` and the same
357    /// single writer, but the loop only ever reads it, and what it carries are targets the loop
358    /// crosses to over [`SLEW_TAU`] rather than a command for the next cycle. A session without
359    /// live tuning leaves it unwritten, which is what [`NO_TUNING_MESSAGE`] reports.
360    tuning: TargetSlot<{ LiveTuning::WORDS }>,
361    stop: AtomicBool,
362    running: AtomicBool,
363    state: Mutex<RobotState>,
364    /// Serialises the writers of `slot` and of `tuning`.
365    writer: Mutex<()>,
366}
367
368impl<const N: usize> Default for Shared<N> {
369    fn default() -> Self {
370        Shared {
371            slot: TargetSlot::default(),
372            tuning: TargetSlot::default(),
373            stop: AtomicBool::new(false),
374            running: AtomicBool::new(false),
375            state: Mutex::new(RobotState::default()),
376            writer: Mutex::new(()),
377        }
378    }
379}
380
381/// The user side, generic over the interface; the public handles wrap it.
382struct Handle<const N: usize> {
383    shared: Arc<Shared<N>>,
384    thread: Option<JoinHandle<FrankaResult<()>>>,
385}
386
387impl<const N: usize> Handle<N> {
388    fn set_target(&self, target: [f64; N]) -> FrankaResult<()> {
389        self.modify_target(|current| *current = target)
390    }
391
392    /// Publishes `modify` of the latest target, under the writer lock so that two callers
393    /// changing different parts of it never lose each other's part.
394    fn modify_target(&self, modify: impl FnOnce(&mut [f64; N])) -> FrankaResult<()> {
395        let _writer = self
396            .shared
397            .writer
398            .lock()
399            .unwrap_or_else(PoisonError::into_inner);
400        if !self.is_running() {
401            return Err(FrankaError::InvalidOperation(ENDED_MESSAGE.to_string()));
402        }
403        let mut target = self.target();
404        modify(&mut target);
405        if target.iter().any(|v| !v.is_finite()) {
406            return Err(FrankaError::InvalidArgument(format!(
407                "target control: the target must be finite, got {target:?}"
408            )));
409        }
410        self.shared.slot.publish(target);
411        Ok(())
412    }
413
414    /// Applies `update` to the published tuning and returns the bounds that clamped a value.
415    ///
416    /// The read, the gate and the publish are one critical section under the same `writer` lock
417    /// `modify_target` takes, so two callers moving different sliders cannot lose each other's,
418    /// and [`LiveTuning::apply_update`] is the only way into the slot: there is no setter that
419    /// takes values past [`LiveTuning::BOUNDS`].
420    fn tune(&self, update: &TuningUpdate) -> FrankaResult<Vec<&'static FieldBound>> {
421        let _writer = self
422            .shared
423            .writer
424            .lock()
425            .unwrap_or_else(PoisonError::into_inner);
426        if !self.is_running() {
427            return Err(FrankaError::InvalidOperation(ENDED_MESSAGE.to_string()));
428        }
429        let mut tuning = self.published_tuning()?;
430        let clamped = tuning.apply_update(update)?;
431        self.shared.tuning.publish(tuning.to_words());
432        Ok(clamped)
433    }
434
435    /// The published tuning targets; the loop is somewhere between these and what it started
436    /// with (see [`TuningPolicy::remaining`]).
437    fn tuning(&self) -> FrankaResult<LiveTuning> {
438        let _writer = self
439            .shared
440            .writer
441            .lock()
442            .unwrap_or_else(PoisonError::into_inner);
443        self.published_tuning()
444    }
445
446    /// The tuning slot, read by a caller already holding `writer`: the slot's one writer is
447    /// therefore excluded, so a load cannot tear and a failure means the slot was never seeded.
448    fn published_tuning(&self) -> FrankaResult<LiveTuning> {
449        let mut words = [0.0; LiveTuning::WORDS];
450        if !self.shared.tuning.load(&mut words) {
451            return Err(FrankaError::InvalidOperation(NO_TUNING_MESSAGE.to_string()));
452        }
453        Ok(LiveTuning::from_words(&words))
454    }
455
456    fn target(&self) -> [f64; N] {
457        let mut target = [0.0; N];
458        // Only a writer mid-update can make a load fail, and a write is a handful of stores.
459        for _ in 0..1000 {
460            if self.shared.slot.load(&mut target) {
461                break;
462            }
463            std::hint::spin_loop();
464        }
465        target
466    }
467
468    fn state(&self) -> RobotState {
469        *self
470            .shared
471            .state
472            .lock()
473            .unwrap_or_else(PoisonError::into_inner)
474    }
475
476    fn is_running(&self) -> bool {
477        self.shared.running.load(Ordering::SeqCst)
478    }
479
480    fn stop(mut self) -> FrankaResult<()> {
481        self.shared.stop.store(true, Ordering::SeqCst);
482        match self.thread.take() {
483            Some(thread) => join(thread),
484            None => Ok(()),
485        }
486    }
487}
488
489impl<const N: usize> Drop for Handle<N> {
490    /// Requests the stop and detaches: the loop settles and finishes on its own, holding its
491    /// `Arc<Robot>` until it has.
492    fn drop(&mut self) {
493        if self.thread.is_some() {
494            self.shared.stop.store(true, Ordering::SeqCst);
495        }
496    }
497}
498
499fn join(thread: JoinHandle<FrankaResult<()>>) -> FrankaResult<()> {
500    thread.join().unwrap_or_else(|_| {
501        Err(FrankaError::Control(ControlException::new(
502            "franka target control: the control thread panicked",
503        )))
504    })
505}
506
507/// `SCHED_FIFO` for the loop thread, the way `Robot::new` does it for its caller, and the
508/// pin to `scheduling.cpu` if there is one. Both are attempted (the pin needs no privilege
509/// the raise may lack); the first failure is the one reported.
510fn schedule(config: RealtimeConfig, scheduling: Scheduling) -> FrankaResult<()> {
511    let priority = match scheduling.priority {
512        None => set_current_thread_to_highest_scheduler_priority(),
513        Some(priority) => set_current_thread_scheduler_priority(priority),
514    };
515    let pin = scheduling.cpu.map_or(Ok(()), pin_current_thread_to_cpu);
516    match priority.and(pin) {
517        Err(message) if config == RealtimeConfig::Enforce => Err(FrankaError::Realtime(message)),
518        _ => Ok(()),
519    }
520}
521
522/// Spawns the loop thread and waits for its first cycle. `body` runs the crate's control
523/// loop and must send on the channel from the first cycle; if it returns before that, its
524/// error is what the start returns.
525fn spawn<const N: usize, F>(
526    name: &str,
527    robot: &Arc<Robot>,
528    shared: Arc<Shared<N>>,
529    scheduling: Scheduling,
530    body: F,
531) -> FrankaResult<Handle<N>>
532where
533    F: FnOnce(&Robot, SyncSender<()>) -> FrankaResult<()> + Send + 'static,
534{
535    let (started, first_cycle) = mpsc::sync_channel::<()>(1);
536    let robot = Arc::clone(robot);
537    let config = robot.robot.realtime_config();
538    shared.running.store(true, Ordering::SeqCst);
539    let thread_shared = Arc::clone(&shared);
540    let thread = std::thread::Builder::new()
541        .name(name.to_string())
542        .spawn(move || {
543            let result = schedule(config, scheduling).and_then(|()| body(&robot, started));
544            thread_shared.running.store(false, Ordering::SeqCst);
545            result
546        })
547        .map_err(|e| {
548            FrankaError::InvalidOperation(format!(
549                "franka target control: cannot spawn the control thread: {e}"
550            ))
551        })?;
552    match first_cycle.recv() {
553        Ok(()) => Ok(Handle {
554            shared,
555            thread: Some(thread),
556        }),
557        Err(_) => Err(join(thread).err().unwrap_or_else(|| {
558            FrankaError::Control(ControlException::new(
559                "franka target control: the control loop ended before its first cycle",
560            ))
561        })),
562    }
563}
564
565impl Robot {
566    /// Starts a Cartesian target control loop on its own thread and returns once its first
567    /// cycle has anchored on the current pose (the model's pose of the measured configuration
568    /// with [`Backend::Impedance`], the commanded one with [`Backend::RobotController`]); see
569    /// the [module documentation](self).
570    ///
571    /// # Errors
572    /// [`FrankaError::InvalidArgument`] if the options are invalid,
573    /// [`FrankaError::Realtime`] if the loop thread cannot be raised to `SCHED_FIFO` or
574    /// pinned to `cpu` under [`RealtimeConfig::Enforce`], whatever [`Robot::load_model`]
575    /// fails with under [`Backend::Impedance`], and whatever [`Robot::control_torques`] or
576    /// [`Robot::control_cartesian_pose`] fails with before its first cycle,
577    /// [`FrankaError::InvalidOperation`] if another control or read operation is running
578    /// among them.
579    pub fn start_cartesian_target_control(
580        self: &Arc<Self>,
581        options: TargetControlOptions,
582    ) -> FrankaResult<CartesianTargetControl> {
583        cartesian::start(self, options)
584    }
585
586    /// Starts a joint target control loop on its own thread and returns once its first cycle
587    /// has anchored on the current joint positions; see the [module documentation](self).
588    ///
589    /// # Errors
590    /// As [`Robot::start_cartesian_target_control`], with
591    /// [`Robot::control_joint_positions`] as the [`Backend::RobotController`] loop.
592    pub fn start_joint_target_control(
593        self: &Arc<Self>,
594        options: JointTargetControlOptions,
595    ) -> FrankaResult<JointTargetControl> {
596        joint::start(self, options)
597    }
598}
599
600#[cfg(test)]
601mod tests;