Developer Tutorial

This tutorial will demonstrate how to use RPi-DeviceAdapters to write, build, and deploy a simple Micro-Manager device adapter for the Raspberry Pi on a laptop or desktop. RPi-DeviceAdapters provides these capabilities to make development easier; you do not need to develop new device adapters directly on the Raspberry Pi.

Linux

Requirements

QEMU installation

The QEMU emulator is used to emulate a ARM processor architecture on a x86_64 system. It is setup as follows:

On Ubuntu, install the emulation packages with the commands:

$ sudo apt update
$ sudo install qemu qemu-user-static qemu-user binfmt-support

If you are not using Ubuntu, search for and install these packages in your system’s respective package manager. Next, register QEMU in the build agent:

$ docker run --rm --privileged multiarch/qemu-user-static:register --reset

Setup

Begin by opening a shell, cloning the RPi-DeviceAdapters repository, and navigating inside the root directory of the cloned repository.

$ # For HTTPS, use https://github.com/kmdouglass/RPi-DeviceAdapters.git
$ git clone git@github.com:kmdouglass/RPi-DeviceAdapters.git
$ cd RPi-DeviceAdapters

Inside you will find a folder named ci (for continuous integration). This folder contains all the tools necessary for developing a new device adapter.

Next, we use the ci/prebuild.sh script to checkout the Micro-Manager source code and 3rdpartypublic dependencies. These will be placed into a directory named /opt/rpi-micromanager. It is required by the build tool’s docker-compose.yml file to place the development files here; let’s first create it and set its ownership:

$ sudo mkdir -p /opt/rpi-micromanager
$ sudo chown $USER:$USER /opt/rpi-micromanager

If you do not want to place the source code in this directory, then you can either:

  1. create a symlink at /opt/rpi-micromanager that points to your alternative directory, or
  2. modify docker-compose.yml to point towards your alternative directory.

The build container uses ccache to decrease the build time. ccache requires that there be a directory in your $HOME folder named .ccache to store the cached artifacts; it will automatically be created for you if it does not already exist when you run the prebuild script.

Let’s run the prebuild script now:

$ ci/prebuild.sh /opt/rpi-micromanager

This step usually takes a few minutes due to the large size of the 3rdpartypublic repository. After it has completed, you should find the following inside /opt/rpi-micromanager:

$ tree -L 1 /opt/rpi-micromanager
/opt/rpi-micromanager
├── 3rdpartypublic
└── micro-manager

Writing device adapters

Writing a general purpose Micro-Manager device adapter is outside the scope of this tutorial; help may be found on the Micro-Manager website and the mailing list. Here we discuss how to build a simple device adapter for the Raspberry Pi. The device adapter will have a single property that can be switched between two states: on and off.

Navigate to the device adapters folder inside the RPi-DeviceAdapters folder.

$ cd src/DeviceAdapters

For the sake of this tutorial, delete the folder named RPiTutorial. We will recreate it and its contents next.

$ rm -rf RPiTutorial

# Recreate the (empty) folder
$ mkdir RPiTutorial

Next, create three empty files named RPiTutorial.h, RPiTutorial.cpp, and Makefile.am inside this folder.

$ cd RPiTutorial
$ touch RPiTutorial.h RPiTutorial.cpp Makefile.am

With your text editor, open the file named RPiTutorial.h, and enter the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
 * Kyle M. Douglass, 2018
 * kyle.m.douglass@gmail.com
 *
 * Tutorial Micro-Manager device adapter for the Raspberry Pi.
 */

#ifndef _RASPBERRYPI_H_
#define _RASPBERRYPI_H_

#include "DeviceBase.h"

class RPiTutorial : public CGenericBase<RPiTutorial>
{
public:
  RPiTutorial();
  ~RPiTutorial();

  // MMDevice API
  int Initialize();
  int Shutdown();

  void GetName(char* name) const;
  bool Busy() {return false;};

  // Settable Properties
  // -------------------
  int OnSwitchOnOff(MM::PropertyBase* pProp, MM::ActionType eAct);

private:
  bool initialized_;
  bool switch_;
};

#endif //_RASPBERRYPI_H_

The most important method defined in this header file is OnSwitchOnOff(MM::PropertyBase* pProp, MM::ActionType eAct), which is the callback method that is called whenever the switch is flipped. The internal state of the switch is stored in the private variable switch_. All other methods are required by the CGenericBase API.

Now let’s implement the switch. Open the file RPiTutorial.cpp and enter the following lines:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/**
 * Kyle M. Douglass, 2018
 * kyle.m.douglass@gmail.com
 *
 * Tutorial Micro-Manager device adapter for the Raspberry Pi.
 */

#include "RPiTutorial.h"
#include "ModuleInterface.h"

using namespace std;

const char* g_DeviceName = "RPiTutorial";

//////////////////////////////////////////////////////////////////////
// Exported MMDevice API
//////////////////////////////////////////////////////////////////////

/**
 * List all supported hardware devices here
 */
MODULE_API void InitializeModuleData()
{
  RegisterDevice(
    g_DeviceName,
    MM::GenericDevice,
    "Control of the Raspberry Pi GPIO pins."
  );
}

MODULE_API MM::Device* CreateDevice(const char* deviceName)
{
  if (deviceName == 0)
     return 0;

  // decide which device class to create based on the deviceName parameter
  if (strcmp(deviceName, g_DeviceName) == 0)
  {
     // create the test device
     return new RPiTutorial();
  }
  // ...supplied name not recognized
  return 0;
}

MODULE_API void DeleteDevice(MM::Device* pDevice)
{
  delete pDevice;
}

//////////////////////////////////////////////////////////////////////
// RPiTutorial implementation
// ~~~~~~~~~~~~~~~~~~~~~~~~~~

/**
 * RPiTutorial constructor.
 *
 * Setup default all variables and create device properties required to exist before
 * intialization. In this case, no such properties were required. All properties will be created in
 * the Initialize() method.
 *
 * As a general guideline Micro-Manager devices do not access hardware in the the constructor. We
 * should do as little as possible in the constructor and perform most of the initialization in the
 * Initialize() method.
 */
RPiTutorial::RPiTutorial() :
  initialized_ (false),
  switch_ (true)
{
  // call the base class method to set-up default error codes/messages
  InitializeDefaultErrorMessages();
}

/**
 * RPiTutorial destructor.
 *
 * If this device used as intended within the Micro-Manager system, Shutdown() will be always
 * called before the destructor. But in any case we need to make sure that all resources are
 * properly released even if Shutdown() was not called.
 */
RPiTutorial::~RPiTutorial()
{
  if (initialized_)
     Shutdown();
}

/**
 * Obtains device name.  Required by the MM::Device API.
 */
void RPiTutorial::GetName(char* name) const
{
  // We just return the name we use for referring to this device adapter.
  CDeviceUtils::CopyLimitedString(name, g_DeviceName);
}

/**
 * Intializes the hardware.
 * 
 * Typically we access and initialize hardware at this point. Device properties are typically
 * created here as well. Required by the MM::Device API.
 */
int RPiTutorial::Initialize()
{
  if (initialized_)
    return DEVICE_OK;

  // set property list
  // -----------------
  // Name
  int ret = CreateStringProperty(MM::g_Keyword_Name, "RPiTutorial device adapter", true);
  assert(ret == DEVICE_OK);
  
  // Description property
  ret = CreateStringProperty(MM::g_Keyword_Description, "A test device adapter", true);
  assert(ret == DEVICE_OK);

  // On/Off switch
  CPropertyAction* pAct = new CPropertyAction (this, &RPiTutorial::OnSwitchOnOff);
  CreateProperty("Switch On/Off", "Off", MM::String, false, pAct);    
  std::vector<std::string> commands;
  commands.push_back("Off");
  commands.push_back("On");
  SetAllowedValues("Switch On/Off", commands);

  // synchronize all properties
  // --------------------------
  ret = UpdateStatus();
  if (ret != DEVICE_OK)
    return ret;

  initialized_ = true;
  return DEVICE_OK;
}

/**
 * Shuts down (unloads) the device. 
 *
 * Ideally this method will completely unload the device and release
 * all resources. Shutdown() may be called multiple times in a row.
 * Required by the MM::Device API.
 */
int RPiTutorial::Shutdown()
{
  initialized_ = false;
  return DEVICE_OK;
}

/**
 * Callback function for on/off switch.
 */
int RPiTutorial::OnSwitchOnOff(MM::PropertyBase* pProp, MM::ActionType eAct)
{
  std::string state;
  if (eAct == MM::BeforeGet) {
    if (switch_) { pProp->Set("On"); }
    else { pProp->Set("Off"); }
  } else if (eAct == MM::AfterSet) {
      pProp->Get(state);
      if (state == "Off") { switch_ = false; }
      else if (state == "On") { switch_ = true; }
      else { return DEVICE_ERR; }
  }

  return DEVICE_OK;
}

Most of this code is boilerplate, i.e. code that is required by the MMDevice API but that does not directly affect the functionality that the user sees. The property that implements the On/Off switch is created here:

1
2
3
4
5
6
  CPropertyAction* pAct = new CPropertyAction (this, &RPiTutorial::OnSwitchOnOff);
  CreateProperty("Switch On/Off", "Off", MM::String, false, pAct);    
  std::vector<std::string> commands;
  commands.push_back("Off");
  commands.push_back("On");
  SetAllowedValues("Switch On/Off", commands);

Its switching behavior is defined here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/**
 * Callback function for on/off switch.
 */
int RPiTutorial::OnSwitchOnOff(MM::PropertyBase* pProp, MM::ActionType eAct)
{
  std::string state;
  if (eAct == MM::BeforeGet) {
    if (switch_) { pProp->Set("On"); }
    else { pProp->Set("Off"); }
  } else if (eAct == MM::AfterSet) {
      pProp->Get(state);
      if (state == "Off") { switch_ = false; }
      else if (state == "On") { switch_ = true; }
      else { return DEVICE_ERR; }
  }

  return DEVICE_OK;
}

Now, open Makefile.am and add the following lines:

1
2
3
4
5
6
AM_CXXFLAGS = $(MMDEVAPI_CXXFLAGS)
deviceadapter_LTLIBRARIES = libmmgr_dal_RPiTutorial.la
libmmgr_dal_RPiTutorial_la_SOURCES = RPiTutorial.cpp RPiTutorial.h \
   ../../MMDevice/MMDevice.h ../../MMDevice/DeviceBase.h
libmmgr_dal_RPiTutorial_la_LIBADD = $(MMDEVAPI_LIBADD)
libmmgr_dal_RPiTutorial_la_LDFLAGS = $(MMDEVAPI_LDFLAGS)

This file instructs Autotools how to create the Makefile when the code is compiled.

Building the libraries

To build the Micro-Manager core and device adapter that we just wrote, we first need to add RPiTutorial to the list of device adapters in src/DeviceAdapters/Makefile.am and src/DeviceAdapters/configure.ac. Here is what Makefile.am looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

AUTOMAKE_OPTIONS = foreign
ACLOCAL_AMFLAGS = -I ../m4

# Please keep these ASCII-lexically sorted (pass through sort(1)).
SUBDIRS = \
	DemoCamera \
	RPiGPIO \
	RPiTutorial \
	Video4Linux

And here is an excerpt of the relevant part of configure.ac that should be modified. In both files, the list of DeviceAdapters should be in alphabetical order.

1
2
3
4
5
6
7
8
# Please keep the list of device adapter directories in ASCII-lexical order,
# with an indent of 3 spaces (no tabs)! (Just pass through sort(1).)
# This is the list of subdirectories containing a Makefile.am.
m4_define([device_adapter_dirs], [m4_strip([
   DemoCamera
   RPiGPIO
   RPiTutorial
   Video4Linux

Now that we have written our device adapter and updated the Autotools files, we need to merge our code with the Micro-Manager source code. This is easily performed with the ci/merge.sh utility script:

$ ci/merge.sh /opt/rpi-micromanager

Each time you change the code you can run this script and it will copy only the changed files into the appropriate directories of /opt/rpi-micromanager/ (or whatever directory you pass as an argument).

The final step is to compile Micro-Manager and the libraries for our device adapter. If this is the first time you are doing this, it may take a long time (around half an hour). Subsequent compilations should be three or four times faster because the compiler cache will have been built.

To begin compilation, change into the ci/build directory.

$ cd ../../../ci/build

The build will be performed inside a Docker container that contains the build dependencies and QEMU, the emulator for the ARM processor architecture. Having a Docker image that is already configured for compilation ensures that you will have the proper dependencies without having to manually configure your environment. To download the Docker image from Dockerhub, run the following command.

$ docker-compose pull

Finally, begin the compilation by running

$ docker-compose up

If all goes well, then you will find the build artifacts in /opt/rpi-micromanager/build at the end of the compilation:

$ tree /opt/rpi-micromanager/build
/opt/rpi-micromanager/build/
├── lib
│   └── micro-manager
│       ├── libmmgr_dal_DemoCamera.la
│       ├── libmmgr_dal_DemoCamera.so.0
│       ├── libmmgr_dal_RPiGPIO.la
│       ├── libmmgr_dal_RPiGPIO.so.0
│       ├── libmmgr_dal_RPiTutorial.la
│       ├── libmmgr_dal_RPiTutorial.so.0
│       ├── _MMCorePy.la
│       ├── MMCorePy.py
│       └── _MMCorePy.so
└── share
    └── micro-manager
        └── MMConfig_demo.cfg

(The contents of your build directory may be different depending on what device adapters were built.) The libraries for the RPiTutorial device adapter are the files libmmgr_dal_RPiTutorial.la and libmmgr_dal_RPiTutorial.so.0. In addition, RPi-DeviceAdapters builds the Micro-Manager Python wrapper. The relevant files for the wrapper are _MMCorePy.* and MMCorePy.py. The Python wrapper may be imported into a Python script to gain access to the methods in the Micro-Manager core.

Whenever you make changes to your code during development, you will need to run the ci/merge.sh script to copy the changes into /opt/rpi-micromanager before recompiling. It would also be good to occassionally pull any updates to the build container by running docker-compose pull, but this should rarely be necessary.

Deploying the app

At this point, you may transfer the compiled librariers to your Raspberry Pi for use. However, manual transfers of the libraries can be cumbersome. Furthermore, it can be diffcult for others to benefit from your work if they have to recompile your source code on their own. For these reasons, RPi-DeviceAdapters provides tools to create a Docker-based app that can easily be uploaded and downloaded from Docker Hub for on-demand use.

To create the app, first navigate to the ci/app folder.

$ cd ../app

Next, use the Makefile to build the Docker image that contains the app.

$ make build

While creating the image, the Makefile will copy the contents of /opt/rpi-micromanager/build into the image and configure the Python environment. (You will need to edit the Makefile and change the location of the build artifacts if you are using a directory other than /opt/rpi-micromanager.)

Let’s verify that the image has been built. Though your output will differ slightly, you should see something similar to the output found below.

$ docker image ls
REPOSITORY                        TAG                                              IMAGE ID            CREATED             SIZE
kmdouglass/rpi-micromanager       build                                            24d67a46e281        5 days ago          745MB

At this point, you will need to create a Docker Hub account if you do not already have one and login via the command line.

$ docker login

The final step before uploading the image is to retag it so that it points to your Docker Hub repository and not the default one.

$ docker tag kmdouglass/rpi-micromanager USERNAME/rpi-micromanager

Here, USERNAME is your Docker Hub username. Finally, we can upload the image:

$ docker push USERNAME/rpi-micromanager

and, from the Raspberry Pi, download the image:

$ docker pull USERNAME/rpi-micromanager

You will need Docker installed on your Raspberry Pi to pull the image.