Device Driver Guidelines¶
To help ensuring consistency between the various I²C device drivers, this page defines some guidelines how device drivers should be designed. It is highly recommended to follow these guidelines when creating a new device driver.
Directory Structure¶
It is recommended that each device driver contains (at least) the following two files:
commands.py
: Containing all I²C command classes (derived fromI2cCommand
).device.py
: Containing one device class (derived fromI2cDevice
) to wrap each command by a method.
If a Python package contains drivers for multiple I²C devices, create (possibly nested) subdirectories for them, for example like that:
sensirion_i2c_sht
├── sht2x
│ ├── commands.py
│ └── device.py
├── sht3x
│ ├── commands.py
│ └── device.py
└── shtc3
├── commands.py
└── device.py
In addition, it’s often handy to also add a __init__.py
file to each
directory to re-export the classes so the user doesn’t need to care about the
module names (e.g. allow importing Sht3xI2cDevice
instead of
device.Sht3xI2cDevice
).
Command Base Class¶
Within commands.py
, there should be a command class added which is then
inherited by each command of the particular device. This is useful to specify
the communication parameters which are common to all commands, for example the
CRC argorithm. This is how such a class could look like:
from circuitpython_sensirion_i2c_driver import SensirionI2cCommand, CrcCalculator
class Sht3xI2cCmdBase(SensirionI2cCommand):
def __init__(self, command, tx_data, rx_length, read_delay, timeout,
post_processing_time=0.0):
super(Sht3xI2cCmdBase, self).__init__(
command=command,
tx_data=tx_data,
rx_length=rx_length,
read_delay=read_delay,
timeout=timeout,
crc=CrcCalculator(8, 0x31, 0xFF, 0x00),
command_bytes=2,
post_processing_time=post_processing_time,
)
Command TX/RX Data Types¶
Sometimes it’s not easy to decide what type should be passed to the command
constructor (data written to the device) or returned by the
interpret_response()
method
(data received from the device), respectively what physical unit these values
should have. For example, physical values are often transferred over I²C as
integers with a specific scaling and offset to get suitable resolution, but
humans usually prefer to work with a standard physical unit.
To help deciding how to implement it, here some possible solutions:
Pass-Through Raw Values¶
This variant is recommended if the value represents no physical unit or has no offset and scaling.
Examples:
- Measure interval in Milliseconds (e.g.
5
represents 5 ms) - Serial number as a string (no physical unit)
from struct import pack, unpack
class Sht3xI2cCmdSimpleExample(Sht3xI2cCmdBase):
def __init__(self, measure_interval_ms, delay_ms):
super(Sht3xI2cCmdSimpleExample, self).__init__(
tx_data=pack(">HH", measure_interval_ms, delay_ms),
rx_length=4, read_delay=0, timeout=0,
)
def interpret_response(self, data):
# when deriving from SensirionI2cCommand, check and remove CRCs now
checked_data = SensirionI2cCommand.interpret_response(self, data)
# return measure_interval_ms and delay_ms as a tuple(int, int)
return unpack(">HH", checked_data)
Physical Value Conversion¶
If the value has a physical meaning but is transmitted with an offset and/or scale factor, it’s recommended to create a separate class to convert between the underlying raw value and its physical value.
Example: Temperature transmitted as integers, with scale and offset.
from struct import pack, unpack
class Sht3xTemperature():
def __init__(self, ticks):
self.ticks = ticks
@property
def degree_celsius(self):
return (self.ticks / 100.0) - 70.0
@staticmethod
def from_degree_celsius(temperature):
return Sht3xTemperature(round((temperature + 70.0) * 100.0))
# Provide conversion to integer (used in the command class)
def __int__(self):
return self.ticks
# Optional: Provide conversion to string, e.g. for printing
def __str__(self):
return "{:.2f} °C".format(self.degree_celsius)
class Sht3xI2cCmdDataTypeExample(Sht3xI2cCmdBase):
# Note: The passed parameters can either be simple integers (raw ticks)
# or Sht3xTemperature objects since they provide a conversion to integer.
def __init__(self, t_outside, t_inside):
super(Sht3xI2cCmdDataTypeExample, self).__init__(
tx_data=pack(">HH", int(t_outside), int(t_inside)),
rx_length=4, read_delay=0, timeout=0,
)
def interpret_response(self, data):
# when deriving from SensirionI2cCommand, check and remove CRCs now
checked_data = SensirionI2cCommand.interpret_response(self, data)
# return values as tuple(Sht3xTemperature, Sht3xTemperature)
t_outside_ticks, t_inside_ticks = unpack(">HH", checked_data)
return Sht3xTemperature(t_outside_ticks), Sht3xTemperature(t_inside_ticks)
These classes (Sht3xTemperature
in the example above) might be located in
a module named data_types.py
within the driver package, and re-exported in
__init__.py
to make them easily importable by users.
Response Data Types¶
Sometimes a command returns a lot of different values. With the variants
mentioned above, this would lead
interpret_response()
returning a tuple with many values, which is often cumbersome to use since the
values are only identified by their position in the tuple (i.e. are not named).
For such commands, you might consider creating a class to represent the whole response data. There is no compulsory criteria when to use this variant or one of the previously described variants. But as a rule of thumb, you may use this variant if more than three values are returned.
from struct import unpack
from .data_types import Sht3xTemperature
class Sht3xExampleI2cResponse():
def __init__(self, rx_data):
self.rx_data = rx_data
@property
def measure_interval_ms(self):
return unpack(">H", self.rx_data[0:2])[0]
@property
def temperature_outside(self):
return Sht3xTemperature(self.rx_data[2:4])
@property
def temperature_inside(self):
return Sht3xTemperature(self.rx_data[4:6])
# Optional: Provide conversion to string, e.g. for printing
def __str__(self):
return "{:.2f} ms interval, {:.2f} °C outside, {:.2f} °C inside".format(
self.measure_interval_ms, self.temperature_outside,
self.temperature_inside)
class Sht3xI2cCmdResponseTypeExample(Sht3xI2cCmdBase):
def __init__(self):
super(Sht3xI2cCmdResponseTypeExample, self).__init__(
tx_data=None, rx_length=6, read_delay=0, timeout=0,
)
def interpret_response(self, data):
# when deriving from SensirionI2cCommand, check and remove CRCs now
checked_data = SensirionI2cCommand.interpret_response(self, data)
# return values as a single Sht3xExampleI2cResponse object
return Sht3xExampleI2cResponse(checked_data)
This even has the advantage that such response classes can easily be shared between multiple command classes. For example if there are multiple different “read measured values” commands which all return the same data structure, you only have to implement the response data class once and every command can use it to interpret its response.
These classes (Sht3xExampleI2cResponse
in the example above) might be
located in a module named data_types.py
within the driver package, and
re-exported in __init__.py
to make them easily importable by users.
Device Class¶
The command classes mentioned above are fine to specify the I²C interface of each command in an object oriented manner. This is useful for various special use-cases, for example to execute I²C commands asynchronously on standalone I²C masters, or for offline interpretation of logged raw I²C frames. In addition, these command classes might be generated automatically by code generators if a machine readable specification of the I²C commands is available.
But for the typical use-case of synchronously sending commands to a device and
waiting for the response, it’s not very convenient to work with these command
classes directly. Therefore device drivers should provide a device class
(derived from I2cDevice
) which is
basically a thin wrapper around the commands. Typically, each command is
wrapped by a corresponding method, with no additional logic added.
Example:
from circuitpython_sensirion_i2c_driver import I2cDevice
from .commands import Sht3xI2cCmdSimpleExample, Sht3xI2cCmdDataTypeExample, \
Sht3xI2cCmdResponseTypeExample
class Sht3xI2cDevice(I2cDevice):
def __init__(self, connection, slave_address=0x44):
super(Sht3xI2cDevice, self).__init__(connection, slave_address)
def simple_example(self, measure_interval_ms, delay_ms):
return self.execute(Sht3xI2cCmdSimpleExample(measure_interval_ms, delay_ms))
def data_type_example(self, t_outside, t_inside):
return self.execute(Sht3xI2cCmdDataTypeExample(t_outside, t_inside))
def response_type_example(self):
return self.execute(Sht3xI2cCmdResponseExample())
Note
Even if
interpret_response()
of
a command does not return any data, you must still return the result of
execute()
, because in
multi channel mode this method will return a list containing the
exception objects if I²C errors occurred.
Warning
In some specific cases, it might still make sense to add some additional
logic to these wrapper methods. Then you should keep in mind that
execute()
returns a list
of values in case a multi channel transceiver is used. So you’ll have to
handle both, single channel return values and multi channel return values.
Read the property
is_multi_channel
of the underlying
I2cConnection
to determine
whether the returned value is a single- or multi channel response.
In addition, for multi channel responses, keep in mind that the returned values might contain exception objects instead of received data. These need to be handled accordingly.
Example:
def simple_example(self, measure_interval_ms, delay_ms):
result = self.execute(Sht3xI2cCmdSimpleExample(measure_interval_ms, delay_ms))
# Multiply all received values by 2
operation = lambda x: x if isinstance(x, Exception) else x * 2 # skip errors
if self.connection.is_multi_channel:
result = list(map(operation, result)) # convert result of each channel
else:
result = operation(result) # convert the single channel result
return result
API Example¶
From the user perspective, the API of such a device driver might look as following:
from sensirion_i2c_sht.sht3x import Sht3xI2cDevice, Sht3xTemperature
from sensirion_i2c_sht.sht3x.commands import Sht3xI2cCmdSimpleExample
device = Sht3xI2cDevice(my_i2c_connection)
# Simple example: Passed and returned data types are integers.
measure_interval_ms, delay_ms = device.simple_example(1337, 42)
print("Interval: {} ms, Delay: {} ms".format(measure_interval_ms, delay_ms))
# Data type example: Passed data types are either integer or Sht3xTemperature
# objects, returned data types are always Sht3xTemperature objects.
t_outside, t_inside = device.data_type_example(42, Sht3xTemperature.from_degree_celsius(25.0))
print("Temperature outside: {} °C".format(t_outside.degree_celsius)) # explicit
print("Temperature inside: {}".format(t_inside)) # Using __str__() operator
# Response type example: Returned data type is Sht3xExampleI2cResponse
response = device.response_type_example()
print("Interval: {} ms".format(response.measure_interval_ms))
print("Temperature outside: {} °C".format(response.temperature_outside.degree_celsius))
print("Temperature inside: {}".format(response.temperature_inside))
# Direct use of command classes, e.g. to interpret offline data
cmd = Sht3xI2cCmdSimpleExample()
raw_received_data = b"\x00\x09\x09\x3A\x80\xA7"
measure_interval_ms, delay_ms = cmd.interpret_response(raw_received_data)