October 14, 2017
23 min. read
This post is part of the Burn-In Cart series.
Before I get to deeply into specific functionality and operation of our charging carts, I thought it would be good to take a detour into drivers for custom hardware. In addition I’ll cover mocking hardware devices to allow testing of your drivers and operating your system in a simulation with no hardware present. This allows you to flush out your hardware design with software before fabricating PCBs. (A PCB takes a long time to “compile”.)
When the system is dependent on external devices, such as the 144 Android tablets that will be connected to it, a little Python code is much easier to write and refactor to simulate tablets than an separate Android app. With the Requests package, I can easily act like a tablet calling a web service. I can also prove my system before I interface with our programmers to know the problem shouldn’t be on my side. We will talk more about this in later articles.
I cover hardware at first, because I want you to understand a little about it before I get to software, but this will be mostly software related article.
Hardware Interfaces
All Raspberry Pi logic is at the 3.3V level, so care must be taken to use 3.3V logic chips or use level converters. Those used to 5V capable chips, such as the Arduino, need to be careful. In the case of driving higher voltages, MOSFETs are common and must be specified with Vgs appropriate for logic levels. These are unsurprisingly called “Logic Level” MOSFETs. I use these to drive 12V logic from the 3.3V of the RPi.
There are a few hardware interfaces available on the Raspberry Pi, the most commonly used being GPIO and I2C bus.
SMBus
On the RPi, the I2C bus is called SMbus. I2C and SMBus are essentially compatible at 100 KHz. I2C allows 400 KHz and 3.4 MHz interfacing that SMBus does not support. I named my packages to follow the smbus Python package. This type of bus has a clock line and a data line. Both are pulled up to 3.3V via a resistor and devices using the bus must pull them down to ground. It is a master/slave setup with multiple devices allowed on a given bus. All slaves have a 7-bit address number and must be unique on the bus. The Raspberry Pi is the master, which always starts communication.
The master will send an 8-bit value, with the first 7-bit the address and the last bit controlling if this is a read or write. The slave will respond that it received the info and the master will continue with more commands until the exchange is complete.
I’m using the smbus interface for getting a silicon serial number, reading voltage and current across a shunt to determine power being used, and reading temperature of the board. These are three devices on the same bus, all with different addresses.
These chips can get fairly complex in functionality, but all use the same interfaces. So mocking the hardware will get harder with complexity. However, there may only get a few of the available smbus commands that you use and therefore must implement in your fake devices.
GPIO
The GPIO interface is for General Purpose Input Output. These are single pins that you can use to send data to devices in an output mode or receive information from devices with an input mode. This is what I use to drive a chain of shift registers. Almost all pins are available for GPIO, unless you use them for other buses. GPIO 2 and 3 are used for SMBus. I could not use SMBus if these were allocated to GPIO. The same is true with reserved pins for SPI and UART buses. And important part of designing a hardware project is allocation of IO.
For a shift register, we need 4 outputs: clock, data, enable, and strobe. We clock data into the buffer and then strobe it from buffer directly to output. This keeps the bits from shifting down the actual outputs and only toggles them when we have shifted everything through.
This is a little different to mock in software, as you need to look at pin transitions. For example, the data line value is used when the clock line has a rising transition (goes from low to high). So my mock devices will need a hook to these transitions that are important and emulate functionality. Although writing tests for the objects helped me find a subtle issue with these callbacks that became important for simulation, as you will see below.
Software Interfaces
When RPi.GPIO is imported, you get a global object that you can only setup once. If you try to start a new one before using .cleanup()
on the existing one, you will get a warning. And if you try to share pins with multiple instances of GPIO, you are in uncharted (read: dangerous) territory. As I was mocking up the GPIO interface, I started trying to emulate this error condition. However, a properly structured Python project with only over one reference to GPIO, so this was overkill.
All of my RPi hardware drivers have an interface reference as the first argument, either for GPIO or SMBus. This allows me to easily pass in a reference to the single object that holds that interface. There is no worry about accidentally creating a second one. This also allows me to pass in a fake version of GPIO or SMBus for testing and simulation.
My imports for these interfaces looks like this:
try:
import RPi.GPIO as GPIO
import smbus
SIMULATION = False
except ImportError:
from rpi_hardware.mocked import GPIO
from rpi_hardware.mocked import smbus
SIMULATION = True
With the boolean SIMULATION
variable, I can control if my system is running normally or in simulation. This is usually the difference between my running on the configured RPi or my local laptop. With PyCharm I can just change the interpreter reference and debug exactly the same either locally or over SSH on the RPi. That is worth the price of admission to the Professional version of PyCharm.
FakeGPIO
In order to only allow a single GPIO, I used a singleton pattern by overriding __new__
. This stores the instance in a class level attribute of __self__
. The constructor needs to be defined as def _init()
instead of def __init__()
, as we are not creating new object each time. For break down between tests or to reset the object, the destroy()
method is provided. This is what I call when I’m emulating the GPIO.cleanup()
.
class Singleton(object):
def __new__(cls, *args, **kwds):
self = "__self__"
if not hasattr(cls, self):
instance = object.__new__(cls)
instance._init(*args, **kwds)
setattr(cls, self, instance)
return getattr(cls, self)
def _init(self, *args, **kwargs):
raise NotImplementedError('must implement init method')
@classmethod
def destroy(cls):
self = '__self__'
if hasattr(cls, self):
instance = getattr(cls, self)
del instance
delattr(cls, self)
The full FakeGPIO source can be viewed here, as it is too large to include inline. There is quite a bit of bookkeeping with constants and handling both BCM and BOARD based pin numbering schemes. All of the normal function are replacements for what your hardware driver would normally call. You can look through the object and see how things are working. I’m liberal with ValueError
to help shake out issues with the bad hardware driver calls. It is easier to find these problems in software than when we are trying to interface with actual hardware.
The two major data structures in FakeGPIO are _pins
and _edge_callback
.
The _pins
attribute of the FakeGPIO stores all GPIO pins direction and value as a 2 item list. The key is the BCM value for the pin. All internal values are BCM numbering and BOARD pin numbers are translated to BCM for use. For direction, we are using the defined constants of GPIO.IN
and GPIO.OUT
. All constants have been defined using the underlying C code values, in case a user is bad and calls with a number rather than referencing the constant.
The _edge_callback
attribute is a defaultdict(list)
that will store callback methods for each pin (again BCM naming). The value is a tuple with (edge_type, callback)
. This could possibly be done more efficiently with a tuple as key, such as (pin, edge_type)
. Then I could use the dictionary hash to find exact callbacks I need, instead of looking if I have the proper transition for a given pin. Although if you are pin heavy and callback light, the overhead of tuple building for key search might actually be slower than the loop when you have a 50-50 chance of a callback.
There are a few helper under functions, but the two that are important in use of FakeGPIO are _simulate_set_pin
and _simulate_read_out_pin
. When we have an input, we are expecting hardware to provide that value. The _simulate_set_pin
method is how your virtual hardware provides an input. When we have an output, we expect hardware to read it and do something with it. The _simulate_read_out_pin
method is where your virtual hardware can read those values. In the implementation of my fake HCF4904 shift register hardware, I’m using a callback function to tell me that the device has been clocked. But I use this _simulate_read_out_pin
to get the value of the data pin at that point.
HCF4094 Driver
This driver is for a shift register. This is a device that allows you to use a clock and data to serially shift out bits to be available parallel as 8-bits for each chip.
The logic on this is confusing sometimes, because making the RPi pin high (3.3V) turns on a MOSFET that pulls the high voltage output low. When the RPi pin is low, the MOSFET turns off, allowing the line to pull high (12V). This also makes mocking the hardware backwards.
To make things a little less confusing, I’ve defined _OUTPUT_HIGH
and _OUTPUT_LOW
that are already flipped.
__init__
is fairly simple, just storing the 4 pins used by the device and setting up the 4 pins as GPIO.OUTPUT
.
class HCF4094(object):
"""
Drives HCF4094 shift register with external pull up resistors
to high voltage and N-MOSFET pulling down to ground.
HCF4094 is capable of higher voltages than RPi interface (I'm using 12V.)
This allows a more robust signal line, but requires isolation with RPi.
I'm driving base of DMN3404 N-MOSFET through 100R resistor.
Base is pulled to Ground via 10K resistor.
Drain is connected directly to output for each of the 4 pins and via 4.7K resistor to 12V.
Source is connected to ground.
I am chaining 6 boards with each having 6 HCF4094 devices. This allows 288 outputs.
Chaining HCF4094 requires nothing different in software, with exception of larger data for shift_data.
"""
# Due to N-MOSFET Pulling down, logic is reversed
_OUTPUT_HIGH = 0
_OUTPUT_LOW = 1
def __init__(self, gpio_ref, data_gpio, clock_gpio, strobe_gpio, out_enable_gpio,
enable_output_immediate=False):
"""
Initialization
:param gpio_ref: reference to RPi.GPIO object
:param data_gpio: data pin number
:param clock_gpio: clock pin number
:param strobe_gpio: strobe pin number
:param out_enable_gpio: output enable pin number
:return:
"""
self._gpio = gpio_ref
self._data_pin = data_gpio
self._clock_pin = clock_gpio
self._strobe_pin = strobe_gpio
self._out_enable_pin = out_enable_gpio
self._gpio.setup(self._data_pin, self._gpio.OUT, initial=self._OUTPUT_LOW)
self._gpio.setup(self._clock_pin, self._gpio.OUT, initial=self._OUTPUT_LOW)
self._gpio.setup(self._out_enable_pin, self._gpio.OUT, initial=self._OUTPUT_LOW)
self._gpio.setup(self._strobe_pin, self._gpio.OUT, initial=self._OUTPUT_LOW)
if enable_output_immediate:
self.set_output_enable(True)
def set_output_enable(self, enable):
"""
Set output enable pin
:param enable: State of pin
:return: None
"""
output_val = self._OUTPUT_LOW
if enable:
output_val = self._OUTPUT_HIGH
self._gpio.output(self._out_enable_pin, output_val)
To send data out, we send an iterable of 0
or 1
values that are clocked out and then the whole data set is strobed to move from buffer to actual output.
I’m using an OrderedDict
as my internal storage so I can easily set bit values by dictionary key, but shift them out in the correct order to the proper pin on the shift registers. (Keeping track of bits is a big deal when your shift length is 288.)
def shift_data(self, data):
"""
Shifts data out, in order of the list.
:param data: Data to be shifted as list or tuple
:return: Bits shifted count
"""
shift_count = 0
self._gpio.output(self._strobe_pin, self._OUTPUT_LOW)
for bit_value in data:
# Inverting due to N-MOSFET inversion, also error if not 0/1.
value = (HCF4094._OUTPUT_LOW, HCF4094._OUTPUT_HIGH)[bit_value]
self._gpio.output(self._data_pin, value)
self._gpio.output(self._clock_pin, self._OUTPUT_HIGH)
self._gpio.output(self._clock_pin, self._OUTPUT_LOW)
shift_count += 1
self._gpio.output(self._strobe_pin, self._OUTPUT_HIGH)
return shift_count
HCF4094 Fake Hardware
I call my fake hardware HCF4094Capture
as I’m capturing the data that is sent out. In addition to the normal fields in HCF4094.__init__
, I also have bit_list
and callback
. The bit_length
is determined by len(bit_list)
. The callback
will be sent a list of tuples with (pin_number, current_state)
if any of the pins have changed since last strobe.
I attach a callback to the FakeGPIO
for a transition on the clock and strobe pin. This is a FALLING
transition, even though I’m actually wanting a RISING
transition at the HCF4094 chip. Again, this is due to the reversing on the MOSFETs. Data is buffered during the clock callback and toggled to output (and callback processed) with a strobe callback. I initially had this backwards and could not get my tests to pass. That is why we take the time to write tests.
class HCF4094Capture(object):
"""
This class is used to emulate the data that is shifted to the HCF4094.
A callback method is registered and will be called with a list of tuples (index, bit_state) for each
output that has changed since last strobe. This allows you to simulate hardware that occurs when the
bit state changes.
An example of this would be if the bit is controlling power to a device. With a `1` bit state, you
could simulate what actions occur when power is applied to the device.
"""
def __init__(self, gpio_ref, data_gpio, clock_gpio, strobe_gpio, out_enable_gpio,
bits_list, callback):
"""
Initialization
Callback method details:
One argument is given to callback method, a list of tuples (index, 0 or 1).
index: index of bits_list which has changed since last strobe event.
0 or 1: new state of bit.
It is expected that you maintain old state to see if you need to trigger simulation events for what
electrical event corresponds with that bit changing.
:param gpio_ref: reference to Mock.GPIO object
:param data_gpio: data pin number
:param clock_gpio: clock pin number
:param strobe_gpio: strobe pin number
:param out_enable_gpio: output enable pin number
:param bits_list: list of bit states for current output, order is furthest to nearest bit. As shifting
occurs at index 0 and finished at index ``n``.
This sets initial state to trigger callbacks and bit length
Initial state usually all 0, so `[0] * bit_depth` might be easy initialization
:param callback: method to call when bits change
"""
self._gpio = gpio_ref
self._data_pin = data_gpio
self._clock_pin = clock_gpio
self._strobe_pin = strobe_gpio
self._out_enable_pin = out_enable_gpio
test_list = [bit for bit in bits_list if bit not in (0, 1)]
if len(test_list) != 0:
raise ValueError('bits_list may only contain 0 or 1. Found {}'.format(test_list))
self._bit_count = len(bits_list)
self.current_data = tuple(bits_list)
self._buffered_data = []
self._callback = callback
# Register with Mock GPIO to call methods when clock or strobe occurs
# We are using FALLING instead of RISING, because logic is backwards due to
# MOSFET for output.
self._gpio.add_event_callback(self._clock_pin, self._gpio.FALLING, self._clocked)
self._gpio.add_event_callback(self._strobe_pin, self._gpio.FALLING, self._ strobed)
These two methods are callbacks from GPIO. When a clock transition occurs, we push data (swapped due to negative logic) in the buffer. When the strobe occurs, we send data to attached callbacks.
If strobe is not brought low before data is transferred, it will strobe data down the outputs as you clock the shift. This can be useful in some situations for moving displays. However, I do not allow this in my driver, so I did not emulate in the virtual hardware. We could add this by testing for strobe state and calling _strobed
if needed in _clocked
.
def _clocked(self):
# Have to reverse data, as MOSFET output is backwards
bit_value = (1, 0)[self._gpio._simulate_read_out_pin(self._data_pin)]
self._buffered_data.append(bit_value)
def _strobed(self):
self._send_data()
My goal for the _send_data
method is to build a tuple of index
and value
for pins that have changed.
First I generate the new_data
by adding buffered data to the end and taking the last bits that satisfy the _bit_count
of our shift register system. Then I’m using a list comprehension with enumerate
to generate my index
and zip
to give me an (old, new)
tuple. The list comprehension only outputs if old != new
.
Before sending to the callback, I update my current_data
in case the callback decides to process the entire output data.
def _send_data(self):
# Make list with last `self._bit_count` number of bits shifted.
# May be called with only a few bits shifted, so need to include old data.
new_data = (list(self.current_data) + self._buffered_data)[-self._bit_count:]
changes = [(index, new)
for (index, (old, new)) in enumerate(zip(self.current_data, new_data))
if old != new]
self.current_data = tuple(new_data)
self._callback(changes)
Testing FakeGPIO
First we look at parts of the tests I wrote when building the FakeGPIO object. With my fixtures, I’m not returning any data. RPi.GPIO uses the global GPIO reference and I do the same. However, I’m using fixtures to reset the GPIO to three states: Initialized (raw), Using BCM numbering (bcm), and using Board numbering (board).
@pytest.fixture
def raw():
GPIO.cleanup()
@pytest.fixture
def bcm():
GPIO.cleanup()
GPIO.setmode(GPIO.BCM)
@pytest.fixture
def board():
GPIO.cleanup()
GPIO.setmode(GPIO.BOARD)
I’ve previously talked about using the with pytest.raises(ExceptionType)
as a method of proving error conditions are caught. I’m skipping some of these tests and simple functionality checks. You can see those at the full file link above.
GPIO is capable of calling a method when a pin has a rising or falling transition. To catch this callback, I’m using the mock
package. func = mock.Mock()
provides a callback function that allows you to test what was sent to it or if it was even called.
In both of these methods, I’m creating the callback, configuring the pin so a transition will fire the callback, adding the callback, and triggering it with an output
call. To test that it was called, I use func.assert_called_with()
. I’m using no arguments, because GPIO callbacks have none. When we write tests for our HCF4094Capture
fake object, you will see callbacks with an argument.
Note: If you need many callbacks and only want to use one function, look at functools partial
for freezing arguments in a function.
def test_rising_callback(board):
func = mock.Mock()
GPIO.setup(38, GPIO.OUT, initial=GPIO.LOW)
GPIO.add_event_callback(38, GPIO.RISING, func)
GPIO.output(38, GPIO.HIGH)
func.assert_called_with()
def test_falling_callback(bcm):
func = mock.Mock()
GPIO.setup(6, GPIO.OUT, initial=GPIO.HIGH)
GPIO.add_event_callback(6, GPIO.FALLING, func)
GPIO.output(6, GPIO.LOW)
func.assert_called_with()
Testing HCF4094
Testing HCF4094
functionality is not too complex, because the main functionality we have with a shift register is to just shift out data. Like the GPIO
testing above, we need to handle callbacks and will be using mock
again.
I define the 4 pins so I can share them between the driver and fake hardware, otherwise they won’t link up. I have one fixture that creates the FakeGPIO
from mocked. Then I create a callback with mock.Mock()
. We pass this into HCF4094Capture
to get changed data back. Then I create the hardware driver HCF4094
and return everything needed in tests as a tuple.
import mock
import pytest
from rpi_hardware.mocked import GPIO
from rpi_hardware.mocked import HCF4904Capture
from rpi_hardware import HCF4094
OUT_EN = 20
STROBE = 19
CLOCK = 26
DATA = 21
@pytest.fixture
def capture():
GPIO.cleanup()
GPIO.setmode(GPIO.BCM)
callback = mock.Mock()
hcf_capture = HCF4904Capture(GPIO, DATA, CLOCK, STROBE, OUT_EN, [0]*16, callback)
hcf = HCF4094(GPIO, DATA, CLOCK, STROBE, OUT_EN, True)
return hcf_capture, hcf, callback
In the first test, you can see how I’m using tuple unpacking to get at the objects sent in with the fixture. I want to test the callback functionality for the fake hardware, without bringing the hardware driver into the mix. So I reached inside the object and modified the internal attributes that would be created with clock callbacks and strobe callbacks. This allowed me to test functionality expected if those callbacks worked.
Notice that we are again testing the fact that a call back occurred with the mock function by using callback.assert_called_with(expected arguments)
. In this scenario it isn’t just testing that a callback occurred, but doing essentially an assert [expected arguments] == [callback arguments received]
.
def test_hcf_capture_send_data_internal(capture):
hcf_capture, hcf, callback = capture
hcf_capture._buffered_data = [1]
hcf_capture._send_data()
callback.assert_called_with([(15, 1)])
hcf_capture._buffered_data = [1]*16
hcf_capture._send_data()
callback.assert_called_with([(index, 1) for index in range(15)])
The value of writing the above function and testing just one piece became apparent when I was developing the callback structure for getting data from HCF4094
. This test was to be a full up test of the HCF4094
hardware driver, sending pin changes through the FakeGPIO
and triggering callbacks in HCF4094Capture
which will trigger a callback for data changed. It is a complex chain. And it didn’t work. I received a failed test, because the callback was never called.
This is where I discovered that the callback I needed to register with FakeGPIO
was FALLING
instead of RISING
, due to the reversed logic. I am a firm believer that tests do not take any more time than writing throw away code to test a method. And instances like this really help you find issues with your code.
def test_hcf4904_shift_data(capture):
hcf_capture, hcf, callback = capture
# Shift full set of 1's should get all changes
hcf.shift_data([1]*16)
callback.assert_called_with([(index, 1) for index in range(16)])
# Partial shift
hcf.shift_data([0, 1, 0, 1])
callback.assert_called_with([(12, 0), (14, 0)])
So these callbacks in HCF4094Capture
are useful for testing operation of our hardware. But this is the hook to simulate the system. If we see a certain pin go to 1
then I know a tablet has been given charging power. I can start making the battery voltage go up and send a web service call indicating PowerApplied
. I’ll cover simulation using these interfaces in another article.
Fake SMBus
Implementing fake hardware for SMBus is both easier and harder. The interface to the hardware is defined, so we can use actual method calls and don’t have to track pin transitions in a custom way for each device type. However, the device can be very complex and this would need to be implemented virtually for full simulation. Often you only need to use a certain subset of functionality to cover what you actually use for a device.
However, if you have a full capabilities driver, then you need full capabilities simulator to integrate everything into tests. For the complex chips, I stub out the functionality I need for simulation and am doing much of the testing with the actual hardware. Everything is a tradeoff of effort vs return.
I’m not going to go into SMBus
with much detail, as the GPIO
implementation was more complex in the topics I wanted to cover in this article. I have two objects in my mocked/smbus.py file: SMBus
and FakeSMBDevice
. To create a virtual SMBus device, you would inherit from FakeSMBDevice
, which provides function stubs for functionality available in SMBus
and registers the device with SMBus
. You only need to implement what you use. If you get a NotImplementedError
, then you use it and didn’t implement it.
DS28CM00 Driver
The DS28CM00
chips is about as simple as an SMBus device gets. This is a silicon serial number. Its sole purpose is to give you a unique 6-byte serial number.
Why would you use something like this? It allows me to keep all software the same on each RPi and use this to load unique configurations from the DB at startup. So pushing updates to all carts is simply a multiple target SCP transfer of files, with no unique configuration data. This is very helpful.
This is a ROM with only 1-bit configurable (toggle between SMBus and I2C modes). Since I drive this at 100 KHz, I never need to touch this. I only need to read the serial number value and check the CRC on the data received. Then I provide this in a hex string format. Since calling hardware is slow and this will not change after being read, I cache the value and only call once.
from .util.crc import crc8_check
class DS28CM00(object):
"""
I2C driver for DS28CM00 silicon serial number
This is a simple chip that only gives you a unique serial number.
It is useful if you want to load the same software on multiple systems
and still have them able to determine who they are.
I use this serial as the primary key in the DB. This allows the device to
load config data from the DB at start up.
"""
_ADDRESS = 0b1010000
def __init__(self, smbus_ref):
"""
Initalize object and give proper smbus to use.
:param smbus_ref: smbus object, as create with smbus.Smbus(bus_number) or mock smbus object.
"""
self._smbus = smbus_ref
self._serial_number = None
self._serial_hex = None
@property
def serial_number(self):
"""
Read silicon serial number.
:return: 48-bit serial number as hex value
:raises: ValueError if CRC check fails
"""
if not self._serial_number:
# Load it once and cache it
self._smbus.write_byte(self._ADDRESS, 0x00)
data = [self._smbus.read_byte(self._ADDRESS) for _ in range(8)]
if not crc8_check(data[:-1], data[-1]):
raise ValueError('CRC validation failed for reading serial number.')
self._serial_number = 0
for byte_value in data[1:-1]:
self._serial_number = (self._serial_number << 8) + byte_value
self._serial_hex = hex(self._serial_number)
return self._serial_hex
Fake DS28CM00
As you see in the code above, I’m only using two methods on the SMBus
object: write_byte
and read_byte
. So these are the only methods I need to implement for my FakeSMBusDevice
.
At __init__
, we pass in the serial number we want the device to have. I do some validation that we have the correct number of bits and values are within range. I used a list of integers, instead of bytes
or bytearray
as it eliminated conversions to do the CRC-8 calculation. This is transparent to the user of the DS28CM00
real device.
I setup the memory into _data
and also build a _write_map
that only allows writing to the 8th byte (configuration register), but have not implemented this write. When reading an SMBus memory, it is common to write the address location then repeatedly call read_byte
. So we have an internal _index
that points to the next memory. Each call to read_byte
increments this and wraps around if we get to the edge of memory.
from .smbus import FakeSMBusDevice
from rpi_hardware import DS28CM00
from rpi_hardware.util.crc import crc8_value
class FakeDS28CM00(FakeSMBusDevice):
"""
Fake Hardware for DS28CM00, to be talked to using DS28CM00 object for testing and simulation
DS28CM00 is a silicon serial number. So we just have a simple memory device that
is read with multiple byte calls.
Note: Only implemented write for address selection. Not for writing of the one
configuration bit, which would occur with a write of 0x08 followed by 0x00 or 0x01.
"""
_ADDRESS = DS28CM00._ADDRESS
def __init__(self, smbus, serial_number_byte_list):
"""
Initalize object and give proper smbus to use.
:param smbus: mock smbus object.
:param serial_number_byte_list: 6 member list with bytes for serial number
"""
if not len(serial_number_byte_list) == 6:
raise ValueError('serial_number_byte_list must be a list of 6 byte integers.')
if max(serial_number_byte_list) > 255 or min(serial_number_byte_list) < 0:
raise ValueError('serial_number_byte_list contains values not within range(256)')
# Memory is Family Code (0x70), 6 bytes of serial number, crc8, Control Register byte (0x01)
data = [0x70] + serial_number_byte_list[:]
self._data = data + [crc8_value(data)] + [0x01]
self._write_map = [False] * 8 + [True]
self._index = 0
super().__init__(smbus, self._ADDRESS)
def _inc_index(self):
self._index += 1
if self._index > 8:
self._index = 0
def write_byte(self, byte):
if -1 < byte < 9:
self._index = byte
else:
raise ValueError('Valid memory addresses for write at 0x00 to 0x08.')
def read_byte(self):
value = self._data[self._index]
self._inc_index()
return value
Testing DS28CM00
Testing is straight forward, with a fixture to initialize the mocked.SMBus
. I first test that bad values for serial number initialization are not allowed in FakeDS28CM00
. Then test_straight_read
is just a simple serial number read from FakeDS28CM00
through mocked.SMBus
into DS28CM00
and validation that data in is the same a data out.
import pytest
from rpi_hardware.mocked import smbus
from rpi_hardware.mocked import FakeDS28CM00
from rpi_hardware import DS28CM00
@pytest.fixture
def smb():
bus = smbus.SMBus(1)
return bus
def test_bad_byte_values(smb):
bad_number_values = [1, 2, 3, 4, 5]
with pytest.raises(ValueError):
ds = FakeDS28CM00(smb, bad_number_values)
bad_values = [0, -1, 256, 3, 19, 43]
with pytest.raises(ValueError):
ds = FakeDS28CM00(smb, bad_values)
def test_straight_read(smb):
serial_number = [15, 45, 120, 255, 0, 192]
fds = FakeDS28CM00(smb, serial_number)
rds = DS28CM00(smb)
serial = 0
for byte in serial_number:
serial = (serial << 8) + byte
assert hex(serial) == rds.serial_number
Because a CRC is included to validate data and detect if transmission errors occurred, I could add in bad data functionality to my FakeDS28CM00
object and test my CRC errors in the main object. This would allow me to help validate all outcomes.
Summary
This is one of my longer articles, but I hope it was of value for you. Let’s hit some bullet points of what we covered:
- Raspberry Pi common hardware interfaces
- How to implement a Singleton model in Python
- How to create a mocked replacement for both GPIO and SMBus
- How to structure imports to switch between real and fake interfaces
- How to implement drivers in a way that allows interface substitutions
- Testing with
pytest
andmock
to verify callback functions - Testing drivers with mocked hardware
When I look at all of those, I can understand why this article is so long. In future articles for this series, I will discuss implementation of the cart workflow and web software and simulation, using these virtual devices.
Part 2 of 3 in the Burn-In Cart series.