A working control loop should be understandable before it touches a motor

Today I wanted to get back to the Antshiv flight-controller work. I already had the aircraft model, the Logitech controller, the PID and mixer, and the RK4 plant. On paper, that sounds like the joystick should just move the drone. It did not. Sometimes a button highlighted, sometimes the aircraft moved, and sometimes the controller was doing exactly what the software told it to do even though the interaction made no sense.

That ended up being useful. The problem was not one broken joystick function. It was that input ownership, mission state, assisted control, camera motion, aircraft motion, and rendering evidence were still too easy to confuse with each other.

So I spent the last two days hardening the desktop software-in-the-loop path in AeroDynControlRig. It now has an explicit flight sequence, deterministic Logitech controls, a velocity-assist mode that behaves more naturally with spring-centred sticks, camera follow, and a test that checks whether an aircraft was actually drawn instead of merely checking that an OpenGL process stayed alive.

What Is Actually Running

This is not a scripted animation. The gamepad produces a pilot command. A mission state decides whether that command is allowed to move the aircraft. The default training assist converts the sticks into velocity targets. The existing quaternion controller produces attitude and collective commands, the geometry-derived mixer resolves four virtual rotor speeds, and the nonlinear rigid-body plant advances through checked RK4 integration.

Control path from a Logitech Mode 2 gamepad through mission state, velocity assist, quaternion PID, four-rotor mixing, checked RK4 rigid-body dynamics, and OpenGL evidence.
The renderer is the evidence surface at the end of the loop. It does not own the controller or the aircraft mathematics.
Mode 2 gamepad
    -> grounded / takeoff / flight / landing mission
    -> assisted body-velocity target
    -> quaternion PID + yaw-rate controller
    -> geometry-derived four-rotor mixer
    -> checked nonlinear RK4 plant
    -> OpenGL aircraft + telemetry

The loaded aircraft contract supplies mass, inertia, gravity, rotor positions, spin directions, thrust and torque coefficients, and speed limits. The controller bundle is revision-linked to that contract. A mismatched or invalid bundle stops initialization instead of silently falling back to placeholder values.

The nonlinear plant uses Newton-Euler rigid-body equations in NED inertial and FRD body coordinates. The application also displays a 12-state hover linearization and local transfer functions, but those are analysis views. They do not replace the nonlinear plant used by the demo.

Why The Joystick Initially Felt Broken

The first problem was control mode. A spring-centred gamepad is not a traditional RC transmitter. In direct attitude mode, releasing the right stick levels the aircraft, but levelling does not remove the horizontal velocity it already accumulated. The drone can continue moving even though the stick returned to the centre. That is mathematically reasonable and awkward for a first desktop demo.

The default mode now treats the right stick as desired horizontal velocity and the left vertical stick as desired climb or descent speed. Returning the sticks to the centre asks an outer loop to brake. Direct attitude and collective control still exist as an advanced mode for studying the plant.

The second problem was mission ownership. I had added a grounded state so that a joystick could not accidentally move the aircraft at startup. The UI showed the physical button correctly, but the button only resumed the simulation clock; it did not request takeoff. The checkmark was real, but it proved the input arrived, not that the mission accepted it. PR #8 connected the normalized face buttons to the mission phases: A requests takeoff or resumes flight, while B requests landing or pauses when landing is not available.

AeroDyn Mode 2 Flight Controls panel showing a connected Logitech Dual Action gamepad, grounded flight phase, virtual motors, training assist, and the button mapping.
The panel separates a connected controller from an active mission. The simulation starts grounded, paused, and with all four virtual motors stopped.

Takeoff And Landing Are Now Explicit Phases

The desktop loop now follows a repeatable sequence:

grounded -> taking off -> flying -> landing -> grounded

Takeoff owns a bounded climb to 1.5 metres. Only after altitude and vertical-speed gates pass does joystick velocity control become active. Landing owns a bounded descent until the ground-contact checks return the aircraft to a paused state with zero virtual motor speed. BACK latches an emergency stop, clears the motor commands, pauses the plant, and resets PID state.

Mission state machine showing grounded, taking off, flying, landing and emergency-stopped states with their transition gates.
A button requests a transition. It does not bypass the current phase or arm physical hardware.

This distinction will matter much more when the same controller ABI reaches the nRF5340. A connected gamepad, a running simulation, a valid controller, an armed physical aircraft, and permission to energize an ESC are different states. Treating them as one boolean would be convenient now and dangerous later.

The Aircraft Was Sometimes Flying Outside The Camera

Another bug looked like an intermittent renderer failure. The aircraft could take off and translate while the camera remained centred on the world origin. The control loop was advancing, but the aircraft eventually left the original camera frustum.

The scene now follows the aircraft by default. I can still disable follow to inspect motion against the world frame, orbit and zoom the camera independently, or use the fit command to restore a deterministic inspection view. Camera inputs never command the aircraft.

AeroDyn desktop simulation with the aircraft visible, camera Follow enabled, a connected Logitech controller, SIL control active, and the flight phase marked Flying.
The live dashboard shows SIL control active, the flying phase, joystick velocity control, and camera follow keeping the aircraft visible.

A Timeout Is Not Rendering Evidence

The older CI smoke test launched the application under Xvfb and waited. That proved the process survived for a few seconds. A blank framebuffer, failed aircraft shader, empty mesh, or aircraft positioned outside the camera could still pass.

The new render check draws a background-only pass and an aircraft pass through the same off-screen framebuffer. It compares the pixels, requires a non-trivial but bounded changed region, rejects OpenGL errors, and exports the framebuffer as a CI artifact.

The aircraft framebuffer produced by AeroDyn's deterministic headless OpenGL render check.
The checked framebuffer changed 16,417 pixels, produced a 334 by 173 pixel aircraft region inside a 640 by 480 target, and reported OpenGL error zero.

Current Reproducible Evidence

  • 20 of 20 tests pass, including the deterministic mission path.
  • Reset begins with zero virtual motor speed and a grounded, paused aircraft.
  • Takeoff reaches a checked 1.5 m target before pilot velocity control becomes active.
  • The framebuffer contains 16,417 changed aircraft pixels with a visible 334 x 173 bounding box.
  • OpenGL error is zero in the Xvfb/llvmpipe render check.

PR #7 contains the aircraft contract, gamepad path, assisted controls, mission phases, mixer and checked plant work. PR #8 adds the framebuffer evidence, camera-follow contract, and final gamepad-to-mission correction. The practical controls are recorded in the joystick guide, and the desktop SIL guide explains the equations, build, run path, and current boundary.

What This Does Not Prove Yet

The motor outputs are virtual. The current plant has a rigid level ground plane, but it does not model landing-gear compliance, ESC current loops, propulsion transients, battery sag, sensor noise, wind, navigation, or autonomous mission planning. The render check proves the current mesh, shader, framebuffer and camera visibility path under Xvfb and llvmpipe; it does not guarantee identical compositor behaviour on every desktop driver.

Most importantly, this is not a physical-flight result. The current value is that the desktop loop gives me a much cleaner mathematical and visual baseline before hardware can add timing, communication, sensing, power, and safety failures at the same time.

What I Want To Connect Next

The next useful step is not adding more dashboard panels. It is replay. A deterministic mission should save its commands, states, controller outputs, motor outputs, and transition reasons so the exact trajectory can be rerun after any controller, mixer, or aircraft-contract change.

After that, the desktop plant can become the oracle for hardware-in-the-loop testing:

AeroDyn simulated sensor frame
    -> physical nRF5340 flight-controller step
    -> four virtual motor commands
    -> AeroDyn checked plant
    -> next simulated sensor frame

The CEVA FSM300 can then replace simulated attitude progressively while the motor path remains virtual. This lets me test timing, dropouts, axis conventions, calibration, failsafes, and controller behaviour before an ESC or propeller is energized.

I am still a long way from a complete aircraft. But this is now a usable loop: I can press takeoff, reach hover, fly with the joystick, release the sticks to brake, land, inspect the state, and reproduce the important parts in CI. That is a much better place to connect the nRF5340 than the checkmark I had yesterday.

Code And Evidence