Piloter un potentiomètre motorisé

Piloter un potentiomètre motorisé

Lors du design de notre petit commutateur de sortie audio, il a été question d'utiliser un potentiomètre motorisé pour le volume. L'idée a vite été écartée parce que cela aurait ajouté du volume et de la complexité à un projet que l'on voulait simple et compact. Néanmoins, la question demeure: peut-on utiliser un potentiomètre motorisé avec des modules Yoctopuce? On a essayé, et la réponse est oui, mais la réalisation demande pas mal de subtilité.

Un potentiomètre motorisé, c'est quoi?

Un potentiomètre motorisé est un potentiomètre équipée un petit moteur. Le curseur peut bien sûr être actionné à la main, mais on peut aussi le faire bouger tout seul avec le moteur. Ce qui en fait un élément d'interface homme/machine assez fascinant. Tout comme les potentiomètres normaux, ils existent en versions linéaire et rotatif.

On a jeté notre dévolu sur une version bon marché fabriqué par Alps Alpine sous la référence RSA0N11M9A0K. C'est un modèle linéaire de 10K, le moteur supporte jusqu'à 10V. Après quelques tests, on a déterminé que le moteur a besoin d'au moins 3V pour bouger. A cette tension il consomme environ 150mA. Par contre, si on lui fourni plus que 3V, il se met à bouger clairement trop vite pour être contrôlable. Ce modèle offre aussi une fonction "Touch sense track", on suppose que c'est un moyen de détecter si l'utilisateur a le doigt sur le curseur, mais il n'y a aucun détail à ce sujet dans le datasheet.

Le potentiomètre motorisé RSA0N11M9A0K
Le potentiomètre motorisé RSA0N11M9A0K



Modules et interconnexions

Pour interfacer la partie potentiomètre, on a choisi d'utiliser un Yocto-Knob mais un Yocto-MaxiKnob aurait convenu aussi. La position du curseur pourra donc être évaluée avec une valeur entre 0 et 1000 ce qui est amplement suffisant pour notre application. Notez que pour avoir un intervalle de valeurs qui corresponde à toute la course du potentiomètre, il faut au préalable calibrer le Yocto-Knob.

La gestion de la partie moteur est plus difficile, il faut être capable de polariser l'alimentation du moteur dans les deux sens. Utiliser un Yocto-Motor-DC pourrait marcher, mais c'est un overkill déraisonnable. On a plutôt choisi d'utiliser un Yocto-IO. L'idée est d'utiliser deux canaux du modules de les configurer de la manière suivante:

  • une sortie "normale", que l'on pilotera activement
  • une sortie "open drain", qu'on laissera tirée vers zéro en permanence

Ainsi on peut faire passer du courant d'un canal à l'autre et donc alimenter notre moteur. Pour changer de sens, il suffit de permuter la configuration des deux canaux.

Par contre, on a un petit problème de puissance: Le Yocto-IO a bien une alimentation interne, mais elle ne peut fournir qu'un maximum de 100mA ce qui est insuffisant. On a donc décidé de prélever du 5V directement sur le bus USB à l'aide d'un câble 1.27-1.27-11 un peu trafiqué.

On prélève du 5V sur le BUS USB
On prélève du 5V sur le BUS USB



Cette façon de faire introduit un autre problème: à 5V le curseur bouge beaucoup trop vite pour qu'on puisse le contrôler de manière précise. C'est pourquoi on a rajouté une résistance de 13Ω en série sur le moteur, ce qui a 150mA, provoquera une chute de tension d'environ 2V.

Le câblage complet
Le câblage complet



Le soft

La partie logicielle apporte aussi son lot de subtilités: On serait tenté de croire que pour faire bouger le curseur jusqu'à une position particulière, il suffit d'alimenter le moteur dans le bon sens, de lire en boucle la position avec le Yocto-Knob et de couper le courant quand la position est atteinte. Mais ça ne marche pas pour plusieurs raison:

  • Cette manière de faire est trop lente, le temps lire la valeur et de couper le courant, le curseur aura largement dépassé la position visée
  • La mécanique a pas mal d'inertie, même si vous coupez l'alimentation pile au bon moment le curseur va continuer à avancer sur quelques millimètres


On a donc choisi une autre approche:

  • La lecture de la position du curseur est basée sur des callbacks pour être aussi réactif que possible.
  • Plutôt que d'allumer l'alimentation en continu, on va plutôt utiliser des impulsions. On a déterminé empiriquement qu'il fallait une impulsion d'au moins 3ms pour bouger le curseur, que pour se déplacer de A à B, il faut une impulsion d'environ |A-B|/3 millisecondes. A chaque callback de changement de position, la longueur de l'impulsion nécessaire pour parcourir le chemin restant est ré-évaluée.
  • Bien que le Yocto-Knob rende des valeurs entre 0 et 1000, il est déraisonnable d'espérer une précision de positionnement de 0.1%. On va se contenter d'une précision de 1% et considérer qu'une position est atteinte lorsque le différence entre la position lue et le position visée est inférieure à 10 en valeur absolue.

Il faut aussi être capable de décider si on reçoit un callback de changement de position parce qu'on est en train de faire bouger le curseur ou si c'est parce l'utilisateur est en train de faire bouger le curseur à la main. On a donc déterminé la règle suivante:

  • Si on reçoit un callback alors qu'on est en train de faire bouger le curseur pour rejoindre une position et que le dernier callback date de moins d'une seconde, alors on persiste à rejoindre cette position. Si le dernier callback date de plus d'une seconde on considère la position comme inatteignable et on abandonne.
  • Si on reçoit un callback et le dernier mouvement autonome a été terminé il y a plus d'une seconde, alors on considère que c'est l'utilisateur qui est en train de bouger le curseur


On a codé tout ça dans la classe Python suivante:

class motorizedSlider:

  MINIMUMPULSE = 3       # Minimum pulse length required to move the slider
  POSPRECISION = 10      # expected tracking precision
  ABSORPTION   = 3       # reduce bouncing
  TIMEOUT      = 1       # cancel auto-move after 1 sec with no move detected

  _io  =None
  _ioBit0 = 0
  _ioBit1 = 0
  _anInput = None
  _aninputIndex =0
  _lastdir = 999
  _lastvalue = -1
  _target   = -1
  _moveCallback = None
  _lasttarget = 0
  _lastStop = YAPI.GetTickCount()

  # automatically called each the slider physically move (resistance change)
  # controls the current movement, and calls user callback if cursor is moved
  # manually
  def buttonChange(self,source,value):
    pos = int(value)
    self._lastvalue = pos
    now = YAPI.GetTickCount()
    delta_time = (YAPI.GetTickCount()-self._lastStop).total_seconds()
    if (self._target>=0)    :
       self._lastStop = now
       delta = abs(pos - self._target)
       if (delta <=  self.POSPRECISION) or (delta_time>=1):
          self._lasttarget=self._target
          self.stop()
          return
       direction =0
       if (self._target>pos):
            direction = -1
       else:
            direction=1
       self._move( direction ,delta)
       return
    else:
      delta_pos = abs(self._lastvalue-self._lasttarget)
      if (abs(delta_pos)>50) or   (delta_time> 1) :
        if self._moveCallback!=None :
          self._moveCallback(self, self._lastvalue)

  # constructor
  def __init__(self,io,ioBit0,iobit1, anInput, moveCallback):
    self._io = io
    self._ioBit0 = ioBit0
    self._ioBit1 = iobit1
    self._anInput = anInput
    self._moveCallback = moveCallback
    anInput.registerValueCallback(self.buttonChange)

  # stop the current movement
  def stop(self):
     self._io.set_bitState(self._ioBit0, 0)
     self._io.set_bitState(self._ioBit1, 0)
     self._target=-1
     self._lastdir =0

  # return the current slider position
  def getPosition(self):
    return self._lastvalue

  # make the slider automatically reach a position
  def setPosition(self,target):
    if target<0 or target>999 :  return
    if   abs(self._lastvalue-target)<=self.POSPRECISION :  return
    self._target=target
    delta = abs(self._target-self._lastvalue)
    if self._target<self._lastvalue :
      self._move(1,delta)
    else:
      self._move(-1,delta)

  # Configure the IO channels according to direction and send an initial
  # electrical pulse to the motor to make it move.
  def _move(self,  direction ,delta):
    if (direction>0):
      if (direction!=self._lastdir):
        self._io.set_bitState(self._ioBit1, 0)
        self._io.set_bitDirection(self._ioBit0, 1)
        self._io.set_bitOpenDrain(self._ioBit0, 0)
        self._io.set_bitDirection(self._ioBit1, 1)
        io.set_bitOpenDrain(self._ioBit1, 1)
      self._io.pulse(self._ioBit0, max(self.MINIMUMPULSE,delta/ self.ABSORPTION))
      self._lastdir =direction
    elif (direction<0):
      if (direction!=self._lastdir):
        self._io.set_bitState(self._ioBit0, 0)
        self._io.set_bitDirection(self._ioBit1, 1)
        self._io.set_bitOpenDrain(self._ioBit1, 0)
        self._io.set_bitDirection(self._ioBit0, 1)
        self._io.set_bitOpenDrain(self._ioBit0, 1)
      self._io.pulse(self._ioBit1, max(self.MINIMUMPULSE,delta/ self.ABSORPTION))
      self._lastdir =direction
    else:
      self.stop()



Le constructeur prend 5 paramètres

  • la fonction YDigitalIO utilisée pour piloter le moteur
  • le numéro du premier canal utilisé dans le fonction digitalIO
  • le numéro du deuxième canal utilisé dans le fonction digitalIO
  • la fonction AnButton utilisée pour lire la position du curseur
  • une fonction de callback qui sera appelée à chaque fois que l'utilisateur bougera le curseur


Les deux méthodes utiles sont getPosition() et setPosition(target) qui permettent respectivement de connaitre la position courante du curseur et de faire bouger le curseur vers une valeur donnée en paramètre.

Proof of concept

Pour tester notre code, on a monté un petit banc de test avec deux curseurs et un interrupteur. A chaque fois qu'un curseur est déplacé par l'utilisateur, on fait bouger l'autre curseur dans un sens qui est déterminé par la position de l'interrupteur.

Notre banc de test
Notre banc de test


Le code de contrôle est très court, la boucle principale ne fait strictement rien puisque tout est géré par callback.

mirror=1
def SwitchAction(source,value):
   global mirror
   mirror= 1 if  int(value)>500 else -1

def Slider1HasMoved(source,value):
   global mirror
   if slider2!=None : slider2.setPosition((mirror-1)*-500 + mirror *value)

def Slider2HasMoved(source,value):
   global mirror
   if slider1!=None : slider1.setPosition((mirror-1)*-500 + mirror *value)

errmsg = YRefParam()
if YAPI.RegisterHub("usb", errmsg) != YAPI.SUCCESS:
    sys.exit("init error" + errmsg.value)

anyButton = YAnButton.FirstAnButton()
if anyButton is None:   sys.exit("Yocto-knob not found");
io = YDigitalIO.FirstDigitalIO()
if io is None:   sys.exit("Yocto-IO not found");

serial = anyButton.get_module().get_serialNumber()
sliderInput1 = YAnButton.FindAnButton(serial+".anButton1")
sliderInput2 = YAnButton.FindAnButton(serial+".anButton2")
switchInput  = YAnButton.FindAnButton(serial+".anButton3")
switchInput.registerValueCallback(SwitchAction)

io.set_outputVoltage(YDigitalIO.OUTPUTVOLTAGE_EXT_V)

slider1 = motorizedSlider(io,0,1,sliderInput1,Slider1HasMoved)
slider2 = motorizedSlider(io,2,3,sliderInput2,Slider2HasMoved)

print("Running...")
while True:
  YAPI.Sleep(1000,errmsg)


Vous pouvez télécharger le programme complet ici.

A notre grande surprise, le résultat est tout à fait utilisable. Les curseurs bougent sans à-coups, ce qui n'était pas gagné d'avance compte-tenu du manque sophistication de la boucle de contrôle physique. Jugez par vous-même sur la vidéo ci dessous.

  


Notez qu'on a utilisé un Yocto-Knob et un Yocto-IO, ce qui nous a permis de controler deux potentiomètres, mais si on avait utilisé Yocto-MaxiKnob et un Yocto-Maxi-IO on aurait pu en controler le double et avoir encore quelques voies disponibles pour interfacer des boutons ou des encodeurs quadratiques.

Commenter aucun commentaire Retour au blog












Yoctopuce, get your stuff connected.