Create your own MiceCraft device - fdechaumont/micecraft GitHub Wiki

Introduction

In this example, we will create a simple device based on Arduino. We will base this example by re-creating a simple version of the Lever of MiceCraft.

To create a device for MiceCraft, we need to write at least:

  1. The arduino code.
  2. A python code able to communicate with your device. If you use the ComManager this is super easy, and the connection/deconnection is managed for you.

Then you can extend the functionalities of your device by adding the followings options:

  • Alarm management
  • Creating a Widget to insert your device in the Graphical interface:
    • providing live visual feedback / monitoring of the state of your device.
    • displaying alarms linked to your device.
    • adding right-click menu on your widget to let people control it from the interface (and enable Testing experiment without animals )
  • A description and blueprints to reproduce your hardware.

Let's code it !

Tip

Practically, you may start by the arduino code, and you will surely go back and forth between your arduino code and your driver code as you implement function. For the purpose of the example, we start by writing the python-side driver.

Writing the python-side driver.

First, let's define what our driver will have to manage:

Important

  • It will receive the press and release state from the arduino. (The arduino detects the lever position with a lidar).
  • The lever is equipped with a LED that the user can control, so it can send the lightOn and lightOff state to the arduino
  • We first create a python file with the definition of our device as an object, so that user can instantiate it in their code.
  • We also add the communication component and its callback (listener is quite inspired by the java listener idea)

the first piece of code:

import logging
from micecraft.soft.ComManager.ComManager import ComManager
from micecraft.soft.DeviceEvent.DeviceEvent import DeviceEvent

class Lever(object):

    def __init__(self, comPort, name="Lever"):
        
        # instantiate the ComManager to send/receive commands to/from the arduino
        self.comManager = ComManager( comPort, self.comListener, alarmName = "Lever" )

Note

the default baud rate of the ComManager is 115200. you can change it with the argument baudrate=.

We will now define the self.comListener function. In this comListener callback, we will receive the "press" and "release" messages from the Arduino.

    def comListener(self , event ):
                        
        if event.description == "release":
            self.release()
                        
        if event.description == "press":
            self.press()

We now need to implement the self.release() and self.press() functions.

  • In those function we will fire an event repeating what the Arduino sent us to the devices that are connected (listening) to our Lever.

Important

  • Several objects in the user code can register to listen to your device. This is up to the design of your user.
  • The user is expecting a DeviceEvent as an argument for its callback. A DeviceEvent provides the generic name of the device, the device object itself, a description as a string, and a data field that you can customize. A timestamp is also generated when you instantiate the object.
    def release(self):
        self.fireEvent( DeviceEvent( "Lever", self, "lever release", data="release" ) )
            
    def press(self):
        self.fireEvent( DeviceEvent( "Lever", self, "lever press", data="press" ) )
        

Note

If you look at the real code of the lever, you will see it is a bit more complicated as there is a debouncer included to avoid multiple messages if the lever is hit twice in a very short time.

We now need to create the fireEvent() method:

    def fireEvent(self, deviceEvent ):
        for listener in self.deviceListenerList:
            listener( deviceEvent )

As you see, there is a self.deviceListenerList that needs to be defined. We will add it to the constructor, and we create 2 functions to add and remove the listener to our device:

    def addDeviceListener(self , listener ):
        self.deviceListenerList.append( listener )
        
    def removeDeviceListener(self , listener ):
        self.deviceListenerList.remove( listener )

Our code is now able to read the press and release state of the lever, and send it to our listener ! Here is the full code so far is:

import logging
from micecraft.soft.ComManager.ComManager import ComManager
from micecraft.soft.DeviceEvent.DeviceEvent import DeviceEvent

class Lever(object):

    def __init__(self, comPort, name="Lever"):
        
        # instantiate the ComManager to send/receive commands to/from the arduino
        self.comManager = ComManager( comPort, self.comListener, alarmName = "Lever" )
        self.deviceListenerList = []

    def comListener(self , event ):
                        
        if event.description == "release":
            self.release()
                        
        if event.description == "press":
            self.press()

    def fireEvent(self, deviceEvent ):
        for listener in self.deviceListenerList:
            listener( deviceEvent )

    def addDeviceListener(self , listener ):
        self.deviceListenerList.append( listener )
        
    def removeDeviceListener(self , listener ):
        self.deviceListenerList.remove( listener )

We now want to add the lightOn and Off feature, we also want to be able to change the pin where the light is connected on the arduino too, so we add those lines:

    def lightOn(self ):
        self.light( True )
        
    def lightOff(self ):
        self.light( False )

    def light(self , on, pinNumber=11, pwm=255 ):
        if on:
            order = f"lightOn {pinNumber},{pwm}"
            self.log( order )
            self.send( order )
            self.fireEvent( DeviceEvent( "Lever", self, "lightOn" ) )
            self._lightOn = True
        else:
            order = f"lightOff {pinNumber}"
            self.log( order )
            self.send( order )
            self.fireEvent( DeviceEvent( "Lever", self, "lightOff" ) )
            self._lightOn = False

... and we add self._lightOn=False to our constructor.

We need to create our self.send function:

    def send(self, message ):
        
        if self.comManager.send(message) == False:
            self.log( f"Can't send message to device: {message}" )

Note that we keep the state of the light in our driver, so that the user can get it. Let's enable that with this next code (and we can even add a switchlight code):

    def isLightOn(self ):
        return self._lightOn
    
    def switchLight(self):
        if self._lightOn:
            self.light( False )
        else:
            self.light( True )

Our driver is complete !

Here is the full code:

import logging
from micecraft.soft.ComManager.ComManager import ComManager
from micecraft.soft.DeviceEvent.DeviceEvent import DeviceEvent

class Lever(object):

    def __init__(self, comPort, name="Lever"):
        
        # instantiate the ComManager to send/receive commands to/from the arduino
        self.comManager = ComManager( comPort, self.comListener, alarmName = "Lever" )
        self.deviceListenerList = []
        self._lightOn = False

    def comListener(self , event ):
                        
        if event.description == "release":
            self.release()
                        
        if event.description == "press":
            self.press()

    def fireEvent(self, deviceEvent ):
        for listener in self.deviceListenerList:
            listener( deviceEvent )

    def addDeviceListener(self , listener ):
        self.deviceListenerList.append( listener )
        
    def removeDeviceListener(self , listener ):
        self.deviceListenerList.remove( listener )

    def send(self, message ):
        
        if self.comManager.send(message) == False:
            self.log( f"Can't send message to device: {message}" )

    def isLightOn(self ):
        return self._lightOn
    
    def switchLight(self):
        if self._lightOn:
            self.light( False )
        else:
            self.light( True )

Writing the arduino code

The following is the complete Arduino code. (I used a Nano version for this example)

Important

  • One pitfall is the communication between the Arduino and your python-driver. So I wrote the receiveSerialData() function to handle incomplete messages, so that you just need to process messages when incomingString is not "".
  • In this example, the driver can directly send the pin number of the Arduino where the led light is connected. A PWM also is expected to set the intensity of the light.
#include <Wire.h>

// Lever control / Fabrice de Chaumont

int LED_PIN = 11; // but any pin can be controlled ( nano PWM pins are 9,10,11,3,5,6 on atMega328p )
int LIDAR_PIN = 3;

int currentState = 10;

//Setup
void setup() {
  
  pinMode( LED_PIN, OUTPUT);
  pinMode( LIDAR_PIN, INPUT ); // The Lidar detects the lever position
  
  Serial.begin( 115200 );
  Serial.setTimeout( 50 );
  
  digitalWrite( LED_PIN, LOW );
  Serial.println("Lever control started.");
}

String stringBuffer="";

String receiveSerialData() {
    
    char rc;
    char endMarker = '\n';

    while (Serial.available() > 0 )
    {
      rc = Serial.read();
      if (rc != endMarker) {
        stringBuffer+=rc;
      }else
      {
        String dataReceived = stringBuffer;
        stringBuffer = "";
        return dataReceived;
      }
    }
    return "";

}

//Loop
void loop() {

  int r = digitalRead(LIDAR_PIN);
  if ( currentState != r )
  {
    if ( r )
    {
      Serial.println("press");
    }else
    {
      Serial.println("release");
    }
    currentState = r;
  }

  String incomingString = receiveSerialData();
  if ( incomingString != "" )  // read incoming orders  
  { 
    
    incomingString.trim();
    
    if( incomingString.startsWith( "lightOn" ) )
    {

      char buffer[200];
      
      int ind1 = incomingString.indexOf(' ');
      if ( ind1 == -1 )
      {
        Serial.println( "Error: argument missing(pos1). should be: lightOn n(1-3),pwm (0-255) example: lightOn 1,128" );
        return;
      }

      int ind2 = incomingString.indexOf(',');
      if ( ind2 == -1 )
      {
        Serial.println( "Error: argument missing(pos2). should be: lightOn n(1-3),pwm (0-255) example: lightOn 1,128" );
        return;
      }

      int ind3 = incomingString.length();

      incomingString.substring(ind1+1, ind2).toCharArray( buffer, 200 );
      int number = atoi( buffer );

      incomingString.substring(ind2+1, ind3).toCharArray( buffer, 200 );
      int pwm = atoi( buffer );

      Serial.println( incomingString );      
      pinMode( number , OUTPUT); 
      analogWrite( number , pwm ); 
    }
  
    
  if ( incomingString.startsWith( "lightOff" ) )
    {
      //Serial.println( incomingString );      

      char buffer[200];
      
      int ind1 = incomingString.indexOf(' ');
      if ( ind1 == -1 )
      {
        Serial.println( "Error: argument missing(pos1). should be: lightOff n(1-3)example: lightOff 1" );
        return;
      }
      //Serial.println( ind1 );      

      int ind2 = incomingString.length();
      
      //Serial.println( ind2 );      

      incomingString.substring(ind1+1, ind2 ).toCharArray( buffer, 200 );
      //Serial.println( "ok" );      
      int number = atoi( buffer );


      Serial.println( incomingString );      
      //Serial.println( number );
      pinMode( number , OUTPUT); // led
      digitalWrite( number , LOW ); 

    }

    if ( incomingString.equals( "hello" ) )
    {
      Serial.println("Hello, i am a *lever* / driver v2.0");
    }
  
  }

    
}

Tip

You can add the "hello" support on your device, that will answer with "Hello, i am a deviceType / driver v_x_.x"

Creating the graphical component

To provide live visual feedback / monitoring of the state of your device, and to be implemented in a general interface, you need to create a widget representing your GUI.

  • Your widget will be positioned and rotated by the user. Take care about this to rotate your widget at its center and still be visible in setGeometry that will clip your drawing and catch mouse events in this area.
  • You can draw whatever you need during the paintEvent callback, take care not to do anything else than display in this function as it needs to be a fast-executed function, and it can be called by the system whenever it needs it.
  • Add a context event, which is handy to let the user interact with your device without using keyboard.
  • Support bindToDevice (here bindToLever) to attach your widget to the existing device so that you can reflect its status in the GUI.
from micecraft.soft.gui.VisualDeviceAlarmStatus import VisualDeviceAlarmStatus

from PyQt6 import QtCore
from PySide6.QtGui import Qt, QColor
from PyQt6.QtWidgets import QWidget, QPushButton, QLabel, QApplication, QMenuBar, QMenu
from PyQt6.QtGui import QPaintEvent, QFont, QPen, QColor, QPainter
from PyQt6.QtCore import QRect
from PyQt6 import *

class WLever(QWidget):

    def __init__(self, x , y , *args, **kwargs):
        super().__init__( *args, **kwargs)
        
        self.x = x*200+100
        self.y = y*200+100
        self.angle = 0
        self.setGeometry( int( self.x ), int ( self.y ), 100, 100)
        self.lever = None
        self.name= "lever"
        self.visualDeviceAlarmStatus = VisualDeviceAlarmStatus()
    
    def setAngle(self , angle ):
        self.angle = angle
        self.update()
    
    def setName(self, name ):
        self.name = name
    
    def contextMenuEvent(self, event):
       
        menu = QMenu(self)
        
        if self.lever != None:
            title = menu.addAction( f"{self.name} connected to {self.lever.comManager.comPort}" )
        else:
            title = menu.addAction( f"{self.name} (no device bound)" )
        title.setDisabled(True)
                
        leverPress = menu.addAction("Simulate lever press")
        leverRelease = menu.addAction("Simulate lever release")
        switchLight = menu.addAction("Switch light")
        
        if self.lever == None:
            leverPress.setDisabled( True )
            switchLight.setDisabled( True )
        
        action = menu.exec(  event.globalPos() )
        
        if action == leverPress:            
            self.lever.press()

        if action == leverRelease:
            self.lever.release()

        if action == switchLight:            
            self.lever.switchLight()
            
    def bindToLever(self , lever ):
        self.lever = lever
    
    def paintEvent(self, event: QPaintEvent):
        
        super().paintEvent( event )
        
        painter = QPainter()
        painter.begin(self)

        painter.translate(self.width()/2,self.height()/2);
        painter.rotate(self.angle);
        painter.translate(-self.width()/2,-self.height()/2);
                
        # block
        painter.fillRect( 25+0, 50 , 50 , 50, QColor( 150 , 150, 150 ))
        painter.setPen(QtGui.QPen(QtGui.QColor(100,100,100), 4)) 
        painter.drawRect( 25+0, 50 , 50 , 50 )
        
        painter.fillRect( 25+10, 90 , 30 , 30, QColor( 220 , 100, 100))
        
        if self.lever != None:
            if self.lever.isLightOn():
                painter.fillRect( 25+0, 50 , 50 , 50, QColor( 255 , 255, 150 ))
            else:
                painter.fillRect( 25+0, 50 , 50 , 50, QColor( 50 , 50, 50 ))
            
        
        if self.lever != None:
            self.visualDeviceAlarmStatus.draw( painter, self.lever )
                
        font = QFont('Times', 10)
        font.setBold(True)
        painter.setPen(QtGui.QPen(QtGui.QColor(100,100,100), 4))
        painter.setFont( font )
        painter.drawText( QRect( 0, 0 , 100,50 ), Qt.AlignCenter, self.name )
        
        painter.end()

    def mousePressEvent(self, event):
        self.__mousePressPos = None
        self.__mouseMovePos = None
        if event.button() == Qt.MouseButton.LeftButton:            
            self.__mousePressPos = event.globalPos()
            self.__mouseMovePos = event.globalPos()

        super(WLever, self).mousePressEvent(event)

Note

Now that your lever widget is created, you can put it in the main interface of your experiment. Check Device: Lever section to see the code to embed a lever in GUI.

Displaying alarms linked to your device.

In the previous code, the alarm calls are the followings:

    from micecraft.soft.gui.VisualDeviceAlarmStatus import VisualDeviceAlarmStatus

    # the object (that will keep for instance the state of the blinking message)
    self.visualDeviceAlarmStatus = VisualDeviceAlarmStatus()

    # in the paint section:
    self.visualDeviceAlarmStatus.draw( painter, self.lever )

Note

Note that in the VisualDeviceAlarmStatus, the device is checked like this:

alarm = device.isAlarmOn() in your device

So you need to implement isAlarmOn()

Other hardware

Raspberry Pi:

You can create a more complex device with a Raspberry Pi. The Device: Touch Screen is build around this device, check its code to see how it works. To put it in a nutshell, the only difference is that the Raspberry will most probably be driven with a Python code, and you need to set some system setting to load your code at startup, enable serial communication. The main idea is the same as in this example.

⚠️ **GitHub.com Fallback** ⚠️