Commands latch

A command is a setpoint the robot holds, not an event it performs, and understanding that explains most of the surprises in this chapter.

A setpoint, not an impulse

The last command a robot received stays in effect until the next one arrives. Nothing decays it, nothing times it out, and nothing zeroes it when the process that sent it exits. Run a program that latches a pulse width, kill it, and the rotors keep spinning at that pulse width: the echo in the state stream reports the robot's holder, not the sender.

There is no watchdog anywhere in the command path. This is a deliberate simulator property, not an oversight, and it is the same assumption a real ESC makes about the flight controller upstream of it.

Your send rate is your own business

Because the value latches, the publish rate carries no meaning. A 5 Hz sender and a 50 Hz sender produce exactly the same behaviour if they send the same numbers, and physics runs at its own rate regardless. Publish at whatever rate your controller thinks at.

The practical consequence appears in every loop that waits for state. From examples/rust/src/bin/ex29_hello_cartpole.rs, the response to a stalled state stream is to do nothing at all:

#![allow(unused)]
fn main() {
        if let Err(VrError::Timeout(_)) = robot.wait_new_state(SAMPLE_TIMEOUT) {
            println!("no new state -- holding the last force (it latches)");
            continue;
        }
}
The same in C++ (examples/cpp/ex29_hello_cartpole.cpp)
} catch (const vrsdk::Error& e) {
    if (e.code() != VRSDK_ERR_TIMEOUT) {
        throw;
    }
    std::printf("no new state -- holding the last force (it latches)\n");
    continue;
}
The same in Python (examples/python/ex29_hello_cartpole.py)
except vrsdk.VrError as e:
    if e.code != vrsdk.err.TIMEOUT:
        raise
    print("no new state -- holding the last force (it latches)")
    continue

Skipping the iteration is not a lost command. The previous force is still applied, so holding is the correct response to silence rather than a degraded one.

Releasing a command is itself a command

There is no "stop" verb. To stop pushing, send zero. From examples/rust/src/bin/ex28_hello_msd.rs, where the step force has to be explicitly withdrawn before the plant can ring back to equilibrium:

#![allow(unused)]
fn main() {
    // Release. The force LATCHES, so this zero is not optional.
    println!("   release (set_msd_force(0.0)) -- watch it ring back to equilibrium");
    for i in 0..RELEASE_SAMPLES {
        robot.set_msd_force(0.0)?;
}
The same in C++ (examples/cpp/ex28_hello_msd.cpp)
// Release. The force LATCHES, so this zero is not optional.
std::printf("   release (set_msd_force(0.0)) -- watch it ring back to equilibrium\n");
for (int i = 0; i < RELEASE_SAMPLES; ++i) {
    robot.set_msd_force(0.0);
The same in Python (examples/python/ex28_hello_msd.py)
# Release. The force LATCHES, so this zero is not optional.
print("   release (set_msd_force(0.0)) -- watch it ring back to equilibrium")
for i in range(RELEASE_SAMPLES):
    robot.set_msd_force(0.0)

Leaving that call out does not "let the force fade": the mass sits pushed against its spring forever, and the printout looks like a plant that will not settle.

Gotcha. A Ctrl-C never releases anything. Examples that hand a robot back do it on the normal exit path only, so an interrupted run leaves the last force, the last pulse widths or the last deflections applied until someone else overwrites them.

What each command holds

CommandWhat stays latchedHow to release it
SET_MR_PWMone pulse width per rotor, microsecondssend new pulse widths, or reset()
SET_CARsteer, throttle and brake, microsecondssend new channels, or reset()
SET_MSDthe drive force, newtonsset_msd_force(0.0), or reset()
SET_INVPENthe cart force, newtonsset_cartpole_force(0.0), or reset()
SET_FW_SURFACESone deflection per panel, radianssend new deflections, leave direct mode, or reset()
SET_FW_THRUSTengine thrust, newtonsset_fw_thrust_bias(0.0) gives the engine back to airspeed hold in onboard mode
SET_ANGVELthe rate setpoint, rad/ssend a new one; reset() clears this latch rather than re-seeding it

Watching a latch hold

examples/rust/src/bin/ex31_globalhawk_direct.rs demonstrates the property directly by sending one pose and then sending nothing for roughly two seconds:

#![allow(unused)]
fn main() {
    // ===== latching, and no watchdog =====
    println!("\n-- nothing sent for ~2 s --");
    robot.set_fw_surfaces(&[
        DEFLECT_RAD,
        -DEFLECT_RAD,
        DEFLECT_RAD,
        -DEFLECT_RAD,
        0.0,
        0.0,
    ])?;
    for i in 0..HOLD_SAMPLES {
        if i % 25 == 0 {
            print_echo(&robot, "  latched");
        }
        robot.rate(HZ);
    }
    println!("  unchanged. A command is a setpoint; there is no failsafe behind it.");
}
The same in C++ (examples/cpp/ex31_globalhawk_direct.cpp)
// ===== latching, and no watchdog =====
const std::vector<double> latched = {DEFLECT_RAD, -DEFLECT_RAD, DEFLECT_RAD,
                                     -DEFLECT_RAD, 0.0,         0.0};
std::printf("\n-- nothing sent for ~2 s --\n");
robot.set_fw_surfaces(latched);
for (int i = 0; i < HOLD_SAMPLES; ++i) {
    if (i % 25 == 0) {
        print_echo(robot, "  latched");
    }
    robot.rate(HZ);
}
std::printf("  unchanged. A command is a setpoint; there is no failsafe behind it.\n");
The same in Python (examples/python/ex31_globalhawk_direct.py)
# ===== latching, and no watchdog =====
latched = [DEFLECT_RAD, -DEFLECT_RAD, DEFLECT_RAD, -DEFLECT_RAD, 0.0, 0.0]
print("\n-- nothing sent for ~2 s --")
robot.set_fw_surfaces(latched)
for i in range(HOLD_SAMPLES):
    if i % 25 == 0:
        print_echo(robot, "  latched")
    robot.rate(HZ)
print("  unchanged. A command is a setpoint; there is no failsafe behind it.")

Each printed line has the shape below, and the point of the passage is that the panel numbers in successive lines are identical while nothing is being published:

  latched t=<seconds>s panels=[<six deflections, radians>] engine=<newtons> N  rates=(<p>,<q>,<r>) deg/s

What reset does to a latch

reset() re-latches the robot's initial command, the same thing the simulator's own Reset button does. It is a state reset, not a factory reset: configuration sent through the srv/* services (masses, noise models, rotor curves, skins) survives it.

A live publisher wins again one physics step later, so a control loop that keeps running through a reset barely notices: it sees one snapshot of the initial command and then its own values again. A program that resets and then stops sending sees the initial command stand indefinitely.

On the fixed wing, reset() also reverts the control mode and the estimate source, which is a larger change than re-latching a number and has its own section.

Next: Driving a multirotor

See also: Sending commands, Robot lifecycle, Pacing your loop