Dynamixel as motorized lever encoder for museum exhibit with haptic feedback

I want to use a Dynamixel as a compliant sensor with dynamic stops, variable resistance, and the ability to self reset.
Sort of like motorized faders on a high-end digital mixer, but more robust. for a public museum installation.
I wonder if the units will survive rapid and constant back driving, what mode to use, what unit to use, and if this is a good idea.
Thoughts?

You mentioned a couple of different questions in your post, so I’m going to separate them out and answer them one by one for the sake of readability:

  1. Backdrivability: The big concerns with backdriving are the potential of generating high feedback voltages and the loads on the gears from sudden direction changes. I’d recommend going with a larger servo with a lower gear ratio to minimize the voltage generation, and metal gears should stand up well enough to the loads from hand-driven backdriving.
  2. This really depends on the specifics of your project, but my first instinct without much more information would be either position based current mode, or PWM mode depending on if positional accuracy is important.
  3. I’d reccomend the XM430-W210 off the top of my head, as 330 size servos are much more likely to be damaged by backdrive overvoltage.
  4. It’s not really the primary use case for the DYNAMXIEL servos, but it’s a good use of the feedback and compliance capabilities. I think the biggest hurdle is going to be figuring out how to get a natural-ish feel out of the knobs.
1 Like

Thanks @Jonathon !
That’s super helpful!
I wonder about safety with a motor that strong if there’s a code issue or a bug.
3NM may be enough to pinch someone badly.
Would smaller motors work if the power supply were set up to eat up voltage spikes? Maybe a capacitor bank or some kind of shunt?

You can always limit the maximum output force by setting a conservative PWM Limit, to avoid injury.

The overvoltages are generated in the motors themselves during backdriving and can directly damage the unit’s PCB, so while setting up circuit protection between the servos can be helpful, it won’t completely eliminate the risk of damage.

An alternative solution would just be to use XL330 sized servos anyways, especially if you can keep the speed of the backdriving down. They will likely have reduced lifespans, but at $25 each they won’t be too costly to just replace if they are damaged…

1 Like

Are there examples in Arduino if using a Dynamixel as an encoder, for example, in learn/playback/puppeteeering applications?
That would help me write the torque off section of the code.

This tutorial goes over explaining how to combine a rotary encoder to puppeteer a DYNAMIXEL.

It’s more or less the inverse of what you’re looking to do, but I think it’s the closest match that could provide some place to start off in writing your code.

1 Like

For posterity and in case it is useful to others, this works really well!
Video:

No word yet on how long the smallest cheapest Dynamixels hold up to backdriving, but so far so good for testing.

#include "Joystick.h"
#include "Dynamixel2Arduino.h"
// https://github.com/MHeironimus/ArduinoJoystickLibrary/tree/version-2.0?tab=readme-ov-file


/* the 6 dynamixels return their current position over USB HID as Gamepad axes
each dynamixel is assigned to a Gamepad axis the dynamixel
 the dynamixels have zero torque and full compliance until they reach a  postion
then the dynamixel is set to medium torque to return to the limit specified
if the dynamixels are not back driven manually for TIMEOUT seconds they return to 'home' position
joystick code works and joystick axes are in the right order from the POV of the JS GamePad library.
this system supports up to 11 levers, as that the HID limit for gamepads.

Hardware:
Arduino Leonardo (Perhaps the only unit that works with both the Shield and the Joystick Library)
6x DYNAMIXEL XL330-M288-T with FPX330-H101 and FPX330-S102 installed to allow use of a 3D printed lever
DYNAMIXEL Shield (jumper removed, 5v power from power supply into screw terminal)
USB cable
5v Switching Power Supply (don't fry your cumputer by backdriving the USB port with induced voltage!)
*/

#define DXL_SERIAL Serial1
#define DEBUG_SERIAL Serial
const int DXL_DIR_PIN = 2; // DYNAMIXEL Shield DIR PIN
Dynamixel2Arduino dxl(DXL_SERIAL, DXL_DIR_PIN);
const float DXL_PROTOCOL_VERSION = 2.0;

Joystick_ Joystick;

#define NUM_SERVOS 6 // Number of servos
#define TIMEOUT 5000 // Timeout in milliseconds

const uint16_t pulsesPerRevolution = 4096; // Number of pulses per revolution
// Servo IDs
const uint8_t servoIDs[NUM_SERVOS] = {0, 1, 2, 3, 4, 5};
// Position limits for each servo
const int32_t absMinPos = 1270; // should these be ints or like uint16 or something ugly?
const int32_t absMaxPos = 2750;
const int32_t verticalPos = pulsesPerRevolution / 2;
const uint16_t fightTorque = 77;
const uint16_t resetTorque = 77;
const uint8_t maxSpeed = 10; /// doesn't seem to work in this mode

// Home positions for each servo
int32_t fightStartPositions0to1000[NUM_SERVOS] = {670, 333, 751, 386, 570, 1000}; //, 0, 0, 0, 0};
int32_t fightStartPositionsRaw[NUM_SERVOS] = {0, 0, 0, 0, 0, 0};                  //, 0, 0, 0, 0};

const int range = 4096;     // 32767;                                    //(2**16)/2, max resolution of the HID library
unsigned long lastMoveTime; // Last time any servo was moved manually

volatile bool isResetting = false;                         // Is any servo moving?
volatile long lastMoved = 0;                               // Last time any servo was moved
volatile int32_t lastPos[NUM_SERVOS] = {0, 0, 0, 0, 0, 0}; // Last pos each servo was moved
const int wiggleRoom = 50;                                 // How many ticks of wiggle room to allow before we consider the servo to have moved
volatile long lastSentJoystick = 0;                        // Last time any servo was sent to the joystick
const int joystickSampleInterval = 15;                     // How often to send joystick data in milliseconds
//

void setup()
{
  Serial.begin(57600);
  delay(1000);
  Serial.print("========================== Keep it In The Ground Joystick Emulator Exploratorium RGodshaw Arduino V(");
  Serial.print(__VERSION__);
  Serial.print(") ");
  Serial.print(__FILE__);
  Serial.print(": Uploaded ");
  Serial.print(__DATE__);
  Serial.print(", ");
  Serial.println(__TIME__);
  setupGamepad();
  calculatefightStartPositions0to1000();
  setupAllServos();
  isResetting = true;
  Serial.println("Setup complete.");
}

void loop()
{
  (isResetting) ? sendAllServosHome() : updateAllServoHaptics();
  checkTimeout();
  sendAllJoystickPositions();
}

void sendAllServosHome()
{
  if (allMotorsAtHome())
  {
    isResetting = false;
    Serial.println("All motors at home!");
    return;
  }

  for (int i = 0; i < NUM_SERVOS; i++)
  {
    moveServoUnidirectionalTorque(servoIDs[i], absMinPos, resetTorque);
    // int currentPos = dxl.getPresentPosition(servoIDs[i], UNIT_RAW);
    // sendServoPostionAsJoystick(servoIDs[i], currentPos);
  }
}

bool allMotorsAtHome()
{
  for (int i = 0; i < NUM_SERVOS; i++)
  {
    int currentPos = dxl.getPresentPosition(servoIDs[i], UNIT_RAW);
    if (abs(currentPos - absMinPos) > (wiggleRoom))
    {
      return false;
    }
  }
  // Serial.println("All motors at home");
  return true;
}

void updateAllServoHaptics()
{
  for (int i = 0; i < NUM_SERVOS; i++)
  {
    updateServoHaptics(servoIDs[i]);
  }
}

void updateServoHaptics(int ID)
{
  int currentPos = dxl.getPresentPosition(ID, UNIT_RAW);
  if (abs(currentPos - lastPos[ID]) > wiggleRoom / 4)
  {
    lastMoved = millis();
  }
  lastPos[ID] = currentPos;
  moveServoUnidirectionalTorque(ID, fightStartPositionsRaw[ID], fightTorque);
}

void checkTimeout()
{
  if (millis() - lastMoved > TIMEOUT)
  {
    // Only send servos home if they are not already resetting or at home
    if (!isResetting && !allMotorsAtHome())
    {
      isResetting = true;
    }
  }
}

void moveServoUnidirectionalTorque(int ID, int goalPos, int torque)
// called as often as possible that will only ever activate the torque if the servopos is higher than the goal pos, never lower
{
  dxl.setGoalPosition(ID, goalPos, UNIT_RAW);
  dxl.setGoalCurrent(ID, torque);
  int currentPos = dxl.getPresentPosition(ID, UNIT_RAW);
  ((currentPos - goalPos) > wiggleRoom) ? dxl.torqueOn(ID) : dxl.torqueOff(ID);
  ((currentPos - goalPos) > wiggleRoom) ? dxl.ledOn(ID) : dxl.ledOff(ID);
}

void calculatefightStartPositions0to1000()
{
  // unfought limits are from the interactive side of the exhibit, 100.0% is the highest adoption rate possible
  // here we map the percentages x 10 to the lever limits so the haptic code knows where to start fighting the user
  for (int i = 0; i < NUM_SERVOS; i++)
  {
    fightStartPositionsRaw[i] = constrain((map(fightStartPositions0to1000[i], 0, 1000, absMinPos, absMaxPos)), absMinPos, absMaxPos);
  }
}

void setupAllServos()
{
  Serial.println("Setting up servos...");
  dxl.begin(57600);
  dxl.setPortProtocolVersion(DXL_PROTOCOL_VERSION);
  for (int i = 0; i < NUM_SERVOS; i++)
  {
    setupServo(servoIDs[i]);
  }
  Serial.println(" Servos setup complete.");
}

void setupServo(int DXL_ID)
{
  Serial.print(DXL_ID);
  Serial.print(", ");
  dxl.ping(DXL_ID);
  dxl.torqueOff(DXL_ID);
  dxl.setOperatingMode(DXL_ID, OP_CURRENT_BASED_POSITION);
  // dxl.setGoalPWM(DXL_ID, 500);
  dxl.setGoalCurrent(DXL_ID, fightTorque);
}

/*
this part is a re-encapsulation fo the joystick library to make it easier driven by joystick index instead of name
*/

void setupGamepad()
{
  Serial.print("setting up joystick axis: ");
  for (size_t i = 0; i < NUM_SERVOS; i++)
  {
    setupJoystickRange(i);
  }
  Joystick.begin();
  Serial.println("all joystick axes done!");
}

void setupJoystickRange(int index)
{
  // this is the order they appear in to the js gamepad library over usb

  Serial.print(index);
  Serial.print(",");
  // this maps from lever order to the lever axis names per HID protocol standard
  // done so that all the levers can emulate a single usb
  switch (index)
  {
  case 0:
    Joystick.setXAxisRange(-range, range);
    break;
  case 1:
    Joystick.setYAxisRange(-range, range);
    break;
  case 2:
    Joystick.setZAxisRange(-range, range);
    break;
  case 3:
    Joystick.setRxAxisRange(-range, range);
    break;
  case 4:
    Joystick.setRyAxisRange(-range, range);
    break;
  case 5:
    Joystick.setRzAxisRange(-range, range);
    break;
  case 6:
    Joystick.setSteeringRange(-range, range);
    break;
  case 7:
    Joystick.setBrakeRange(-range, range);
    break;
  case 8:
    Joystick.setAcceleratorRange(-range, range);
    break;
  case 9:
    Joystick.setThrottleRange(-range, range);
    break;
  case 10:
    Joystick.setRudderRange(-range, range);
    break;
  default:
    Serial.println("ERROR: Invalid axis");
    break;
  }
  // Serial.println("done");
}

void sendAllJoystickPositions()
{
  for (int i = 0; i < NUM_SERVOS; i++)
  {
    int currentPos = dxl.getPresentPosition(i, UNIT_RAW);
    sendServoPostionAsJoystick(i, currentPos);
  }
}

void sendServoPostionAsJoystick(int ID, int currentPos)
{
  int mappedPosition = map(currentPos, absMinPos, absMaxPos, -range, range);
  mappedPosition = constrain(mappedPosition, -range, range);
  sendJoystickValue(ID, mappedPosition);
}

void testJoystick()
{

  Serial.print("Testing Gamepad...");
  Serial.print("ascending...");

  for (size_t i = 0; i < NUM_SERVOS; i++)
  {
    int scaledValue = map(i, 0, NUM_SERVOS, 0, range);
    sendJoystickValue(i, scaledValue);
  }
  delay(2000);
  Serial.print("descending...");

  for (size_t i = 0; i < NUM_SERVOS; i++)
  {
    int scaledValue = map(i, 0, NUM_SERVOS, -range, 0);
    sendJoystickValue(i, scaledValue);
  }
  delay(2000);
  Serial.print("random...");
  for (size_t i = 0; i < NUM_SERVOS; i++)
  {
    sendJoystickValue(i, random(-range, range));
  }
  delay(2000);
  Serial.print("zero...");

  for (size_t i = 0; i < NUM_SERVOS; i++)
  {
    sendJoystickValue(i, 0);
  }
  delay(2000);
  Serial.println("Done!");
}
void sendJoystickValue(int i, int position)
{
  switch (i)
  {
  case 0:
    Joystick.setXAxis(position);
    break;
  case 1:
    Joystick.setYAxis(position);
    break;
  case 2:
    Joystick.setZAxis(position);
    break;
  case 3:
    Joystick.setRxAxis(position);
    break;
  case 4:
    Joystick.setRyAxis(position);
    break;
  case 5:
    Joystick.setRzAxis(position);
    break;
  case 6:
    Joystick.setSteering(position);
    break;
  case 7:
    Joystick.setBrake(position);
    break;
  case 8:
    Joystick.setAccelerator(position);
    break;
  case 9:
    Joystick.setThrottle(position);
    break;
  case 10:
    Joystick.setRudder(position);
    break;
  default:
    Serial.println("Invalid Servo ID in Gamepad Loop");
    break;
  }
  Joystick.sendState();
}
2 Likes

I’m glad to hear that this worked out for your application! I quite like the adjustable resistance point, and am considering finding an excuse to incorporate this assembly into a demo project.

The shapes of the knobs have me quite curious as to what exhibit these are intended to be installed in?

The exhibit is going to be about how adjustments to various behaviors affect climate change differently. The resistance represents economic difficulties in quickly adopting significant changes. I will try to remember to post more when it is ready for the public!

1 Like