← Back to portfolio Français

Pendulum installation

Firmware architecture overview

Firmware for a large kinetic pendulum installation, built from a chain of motorised arm segments and a rotary electromagnetic booster at the base. Four independent PlatformIO firmware projects target Teensy 4.1 boards — one per arm segment (small, middle, long) plus one for the booster — sharing the same OSC communication layer but each with its own physical geometry, motor count, and OSC address namespace.

Hardware topology

Commands arrive over the network as OSC messages, which reach an x-io Technologies NGIMU unit acting as an OSC-over-serial bridge. Each Teensy 4.1 controller receives OSC both directly over its USB serial port and via the NGIMU on its auxiliary serial port (Serial1), decoded through a SLIP-framed OSC layer (Receive.cpp/Send.cpp, based on Seb Madgwick's OSC99 library with an EventScheduler/EventTrigger layer for periodic/edge-triggered sends).

Each Teensy drives its own actuator hardware:

Pendulum installation hardware topology OSC commands arrive over the network into an x-io NGIMU bridge, which relays them over serial to four independent Teensy 4.1 controllers, each driving its own motor or electromagnet coil hardware and physical actuators. Control network OSC messages over UDP NGIMU bridge x-io Technologies, OSC ⇄ serial 4× Teensy 4.1 controllers Small/middle/long arm + booster Motor / coil drivers Cytron boards or coil PWM+DIR Physical actuators Carriage motors / electromagnets Encoders and limit switches feed position back up to their Teensy
Hardware topology: network → NGIMU → controllers → drivers → actuators

Shared firmware loop

All four firmware variants share the same overall loop shape, built from the same generic Receive.cpp / Send.cpp pair:

  1. OSC input (SLIP) — ReceiveDoTasks() pulls bytes from both the USB serial port and the NGIMU auxiliary serial port, feeding two independent SLIP decoders.
  2. Dispatch by address — each decoded OSC message is matched against a literal address pattern (e.g. /smallArm/destinationM1, /booster/activeZone) and updates the relevant controller's state: a motor's destination, a flag like brakeState or broadcast, or triggers an action like calibrate().
  3. Control loop — the arm controllers check limit switches (debounced via Bounce), run the calibration state machine if active, and otherwise drive motors toward their destinations; the booster controller reads its rotary encoder, determines swing direction, and evaluates which electromagnet "zone" is active.
  4. Hardware I/O — PWM/direction signals go out to the Cytron driver or coil driver; encoder and limit-switch state comes back in.
  5. Scheduled telemetry — SendDoTasks() runs an EventScheduler that periodically packages state (motor positions, calibration status, limit-switch triggers, errors, etc.) into OSC messages sent back out over Serial1 to the NGIMU, gated by each controller's own broadcast/broadcastState flag.

This loop repeats indefinitely inside each .ino's loop().

Shared firmware loop structure Each of the four Teensy firmwares runs the same loop shape: decode incoming OSC over serial, dispatch it by address to update local state, run the control loop against limit switches and encoders, drive the hardware, then send scheduled telemetry back out, repeating every main loop iteration. OSC input (SLIP) USB serial + NGIMU aux serial Dispatch by address Updates destinations and flags Control loop Limit switches, calibration, closed-loop move Hardware I/O PWM+DIR out, encoders/switches in Scheduled telemetry Position, status, errors out over serial ↻ repeats every main-loop iteration
Shared firmware loop, common to all four controller variants

Arm segment chain

The three arm firmwares share the same underlying motor/limit-switch/calibration machinery, but describe different points along one mechanical chain, from the rotary base to the tip:

SegmentMotorsRole of each motor
Booster (base)— (coil ring)Electromagnetically drives the pendulum's rotary swing
Long armM1, M2, M3M1 = damper carriage; M2 = receiver, connects to the booster/base axle; M3 = transmitter, connects to the middle arm's receiver
Middle armM1, M2, M3M1 = damper carriage; M2 = receiver, connects to the long arm's transmitter; M3 = transmitter, connects to the small arm's receiver
Small arm (tip)M1, M2M1 = damper carriage; M2 = receiver, connects to the middle arm's transmitter

Within each arm, SAFE_DISTANCE_* constants enforce a minimum encoder-tick gap between adjacent carriages so they can never physically collide, and each carriage's min_pos/max_pos is continuously recomputed relative to its neighbours in update_motor_positions().

Pendulum arm segment chain, base to tip The booster forms the rotary base, mechanically linked in sequence to the long arm, then the middle arm, then the small arm at the tip. Base → tip Booster Coil driver,rotary base Long arm 3 carriages Middle arm 3 carriages Small arm (tip) 2 carriages
Mechanical chain from the booster base to the small-arm tip

Per-arm specifics

Small armMiddle armLong arm
Motors233
Limit switches3 (top, top-mid, bottom)4 (top, top-mid, mid-bottom, bottom)4 (top, top-mid, mid-bottom, bottom)
BrakeYes (brakeControlPin, checkBrakeState())No — brake OSC handler commented out, no brake check in the loopYes (brakeState restored, checked via OSC and checkBrakeState())
Full-scale ticks300,000 (CALIBRATE_MAX_SMALL_ARM)800,000 (CALIBRATE_MAX_MIDDLE_ARM)6,791,754 (CALIBRATE_MAX_LONG_ARM), ≈137 cm top to bottom
OSC namespace/smallArm/.../middleArm/.../longArm/...
Struct typependulum_small_armpendulum_mid_long_arms (instance middleArm)pendulum_mid_long_arms (instance longArm)

Each arm's calibration routine drives its extreme motors to their limit switches to zero the encoders, then records min_pos/max_pos for the rest of the run. A move_motors() step reduces PWM speed in fixed distance bands as a carriage nears its destination (motor_movement()), and gives a short PWM "boost" if a motor appears stuck (position unchanged over ~5000 loop iterations).

Power-loss recovery (small/middle/long arm)

All three arm firmwares persist the last known motor positions to EEPROM (magic-numbered struct, saveLastKnownPositions() / loadLastKnownPositions()):

Booster: electromagnetic rotary drive

The booster is architecturally different from the three arms — instead of driving carriages along a track, it pulses a ring of electromagnetic coils to add energy to the pendulum's rotary swing, timed against a single rotary encoder:

OSC reference

Arm controllers (smallArm / middleArm / longArm)

AddressDirectionPurpose
/*/destinationM1, /*/destinationM2, /*/destinationM3*inSet a motor's target position (scaled 0–1000, converted to encoder ticks)
/*/calibrateinStart (or restart) the calibration routine
/*/stopAllMotorsinZero all PWM outputs and freeze current position as the destination
/*/brakeStateinEngage/release the brake solenoid (small arm and long arm only)
/*/broadcastinEnable/disable this controller's telemetry
/*/positionM1, /*/positionM2, /*/positionM3*outCurrent motor position
/*/calibrationStatusoutWhether calibration has completed
/*/resumeReliabilityoutEEPROM resume state: none / clean / approximate
/*/maxPossiblePositionM1, /*/minPossiblePositionM2, etc.outLive safe-range bounds derived from neighbouring carriages
/*/limitSwitch0N TriggeredoutOne-shot notification that a limit switch fired
/teensy/erroroutGeneric error string (bad OSC address, decode failure, etc.)

* destinationM3/positionM3 only exist on the middle and long arm (3-motor) firmware.

Booster controller

AddressDirectionPurpose
/booster/oninEnable/disable coil firing (also zeroes PWM immediately when disabled)
/booster/strengthinPWM magnitude applied to an active coil
/booster/activeZoneinSelects how many electromagnet pairs are live (0–3)
/booster/brakeStateinEngage/release the booster's brake
/rotary/broadcastinEnable/disable telemetry
/rotary/valueoutRaw rotary encoder position (sent every 60 s)
/teensy/erroroutGeneric error string

Build

Each variant is its own PlatformIO project targeting a Teensy 4.1:

[env:teensy41]
platform = teensy
board = teensy41
framework = arduino

monitor_port = /dev/ttyACM0
monitor_speed = 9600