In this guide, we'll run through the process of writing a driver that uses NiceLib to wrap a C library, using the example of an NI DAQ. A NiceLib-based driver consists of three parts: low-level, mid-level, and high-level interfaces.
- Low-level
- Mimics the C interface directly
- Mid-level
- Has the same functions as the low-level interface, but with a more convenient interface
- High-level
- Is nice and pythonic, often doesn't necessarily mimic the C interface's structure
NiceLib semi-automatically generates a low-level interface for us. To tell it how to do so, we create a build file, which contains a build()
. This function invokes build_lib()
with arguments that tell it what library (.dll/.so file) we're wrapping and what header(s) to use.
For our ni
library, we name our file _build_ni.py
:
# _build_ni.py from nicelib import build_lib header_info = r'C:\Program Files (x86)\National Instruments\NI-DAQ\DAQmx ANSI C Dev\include\NIDAQmx.h' lib_name = 'nicaiu' def build(): build_lib(header_info, lib_name, '_nilib', __file__) if __name__ == '__main__': from instrumental.log import log_to_screen log_to_screen(fmt='%(message)s') build()
You can see we've indicated the path to the header NIDAQmx.h
and the library nicaiu.dll
that are used by the Windows version of NI-DAQmx.
Now let's manually invoke a build:
$ python _build_ni.py
Module _nilib does not yet exist, building it now. This may take a minute...
Searching for headers...
Found C:\Program Files (x86)\National Instruments\NI-DAQ\DAQmx ANSI C Dev\include\NIDAQmx.h
Parsing and cleaning headers...
Successfully parsed input headers
Compiling cffi module...
Writing macros...
Done building _nilib
Nice, it worked! We now have a freshly-generated _nilib.py
module, which is our low-level interface. You can then call load_lib('foo', __package__)
to load the LibInfo
object, as we'll see in the mid-level interface section.
We won't always be this fortunate, since headers sometimes include nonstandard syntax which nicelib's parsing system can't handle. In that case, there are two basic approaches:
- Manually include only the necessary snippets of the header, cleaning up any unparseable syntax.
- Use
build_lib
's options, includingtoken_hooks
andast_hooks
to avoid or programmatically clean up the problem syntax.
Option 1 can be good for quickly moving on and starting to write your mid-level interface. You can include just a few functions that you want to test out, and perhaps later come back and pursue option 2. For example, we could do the following for our DAQmx driver:
from nicelib import build_lib source = """ #define __CFUNC __stdcall typedef signed int int32; typedef unsigned int uInt32; int32 __CFUNC DAQmxGetSysDevNames(char *data, uInt32 bufferSize); int32 __CFUNC DAQmxGetDevProductNum(const char device[], uInt32 *data); int32 __CFUNC DAQmxGetDevSerialNum(const char device[], uInt32 *data); """ lib_name = 'nicaiu' def build(): build_lib(None, lib_name, '_nilib', __file__, preamble=source)
We use header_info=None
to skip loading any external header files, and pass in our source via the preamble
parameter.
Option 2 is more complete, but can sometimes be tricky, as it requires some extra knowledge of C and why some section of code may not be parsing correctly (e.g. because it's actually C++, which happens commonly in some libs written only for Windows). In the simplest case, you may just need to exclude a problematic header that's being included, or use one of the pre-written token hooks or ast hooks that nicelib provides. In other cases, you may need to write your own hook to clean up the header. See the nicelib docs for a more detailed account of how to use token/ast hooks and the other paramters of build_lib()
.
Once we have a low-level interface that can be loaded via load_lib()
, we can start to work on the mid-level bindings. What's the point of these bindings? Well, they make the functions a lot more hospitable to work with. Take for example int32 DAQmxGetSysDevNames(char *data, uInt32 bufferSize)
. This function takes a preallocated char
buffer and its length, returning its string within the buffer, and an error code as the int32
return value. Using the low-level binding looks like this:
buflen = 1024 data = ffi.new('char[]', buflen) retval = DAQmxGetSysDevNames(data, buflen) handle_daq_retval(retval) result = ffi.string(data)
Seems kinda verbose---and this function only takes two arguments! Write too much code like this and your code's intent will drown in a sea of bookkeeping. In contrast, using a mid-level binding looks like this:
result = NiceNI.GetSysDevNames()
Better, right? So how do you write these mid-level bindings? Here's a simple start:
from nicelib import load_lib, NiceLib, Sig class NiceNI(NiceLib): _info_ = load_lib('ni', __package__) _prefix_ = 'DAQmx' GetErrorString = Sig('in', 'buf', 'len') GetSysDevNames = Sig('buf', 'len') CreateTask = Sig('in', 'out')
We define a subclass of NiceLib
that specifies some general info about the library, as well as some signature (Sig
) definitions for the functions we want to wrap. _info_
specifies the lib we're wrapping, and _prefix_
is a prefix that will be removed from the names of the functions. A Sig
specifies the purpose of each of its function's parameters, e.g. whether it's an input, an output, or something more special.
For instance, CreateTask
was was matched with Sig('in', 'out')
, reflecting that int32 DAQmxCreateTask(const char taskName[], TaskHandle *taskHandle)
uses taskName
as an input, and taskHandle
as an output. This tells nicelib that CreateTask
takes only one argument and returns one value, and nicelib creates a function accordingly:
In [1]: NiceNI.CreateTask('myTask')
Out[1]: (<cdata 'void *' 0x000000000AD49250>, 0)
But wait, there are two values here, what's going on? The first part makes sense, that's our taskHandle
(of type TaskHandle
, an alias for void*
), but what's the zero from? It's the actual return value, the error-code of type int32
. What if we want to ignore this value, or do something else with it? That's where RetHandler
s come in. We'll talk more about these later, but nicelib comes with two builtin return handlers, ret_return
and ret_ignore
. ret_return
is used by default, and it tacks the C return value on the the end of the Python return values. ret_ignore
simply ignores the return value. There are a few levels at which we can specify the return handler, but to apply it to all functions within the lib we use the _ret_
attribute:
from nicelib import load_lib, NiceLib, Sig, ret_ignore
class NiceNI(NiceLib):
_info_ = load_lib('ni', __package__)
_prefix_ = 'DAQmx'
_ret_ = ret_ignore
GetErrorString = Sig('in', 'buf', 'len')
GetSysDevNames = Sig('buf', 'len')
CreateTask = Sig('in', 'out')
Now let's try again:
In [1]: NiceNI.CreateTask('myTask')
Out[1]: <cdata 'void *' 0x0000000009169250>
For now let's ignore the return codes; we'll handle them properly later. Now that we've explained 'in'
and 'out'
, what do 'buf'
and 'len'
do? Recall that DAQmxGetSysDevNames(char *data, uInt32 bufferSize)
takes a char
buffer and its length, writing a C-string into the buffer. The pair of 'buf'
and 'len'
are made for exactly such a situation---nicelib will create a char
array, passing it in for the 'buf'
parameter, and its length in as the 'len'
parameter, then extracting a bytes
object using ffi.string()
and returning it:
In [2]: NiceNI.GetSysDevNames()
Out[2]: b'Dev1'
You can check out the nicelib docs to find a listing of all the possible Sig
string codes and what they do.
TODO:
- NiceObject classdefs
- RetHandlers
Now let's get start writing our driver:
from instrumental.drivers.daq import DAQ class NIDAQ(DAQ): _INST_PARAMS_ = ['name', 'serial', 'model'] def _initialize(self): self.name = self._paramset['name'] self._dev = self.mx.Device(self.name)
We inherit from DAQ
, a subclass of Instrument
, and use the _INST_PARAMS_
class attribute to declare what parameters our instrument can use to construct itself.