FreeBSD and I2C

by Tykling


06. feb 2021 15:54 UTC


I2C is a communication protocol which was originally designed and used for inter-board communications between peripherals, like the processor talking to an EEPROM in a system. These days it is also used for short distance communication with sensors - not what it was designed for, but it works anyway. Wikipedia has this to say about I2C:

I2C (Inter-Integrated Circuit), pronounced I-squared-C, is a synchronous, multi-master, multi-slave, packet switched, single-ended, serial communication bus invented in 1982 by Philips Semiconductor (now NXP Semiconductors). It is widely used for attaching lower-speed peripheral ICs to processors and microcontrollers in short-distance, intra-board communication. Alternatively, I2C is spelled I2C (pronounced I-two-C) or IIC (pronounced I-I-C). (thanks Wikipedia!)

I've been playing around with some I2C based sensors from SparkFun. They have QWIIC connectors making it easy to speak to them from SparkFuns OpenLog Artemis. The OpenLog Artemis can log sensor data to SD card and also presents itself as a serial device where it streams data from the sensors in a CSV-like format. It works great and I can highly recommend the whole QWIIC ecosystem.

Occationally I need to use one of the QWIIC sensors from some FreeBSD based system, without the OpenLog Artemis. In this case I am using a Raspberry PI Zero with FreeBSD a 13-CURRENT from December. This is easy because the QWIIC system is just plain I2C underneath, so it is easy to hook a sensor up directly to the GPIO pins of the RPI with one of these.

In FreeBSD land we have i2c(8) which is a userspace utility for reading and writing to I2C devices. I will start by going over basic usage before diving deeper into the actual sensors I am playing with in this learning session.

My test rig for this blogpost looks like this:

Basic I2C on FreeBSD

First things first, I need some hardware with an I2C bus which is exposed to FreeBSD. I am using a Raspberry PI Zero and the I2C bus looks like this in dmesg:

[root@pizero1 ~]# grep I2C /var/run/dmesg.boot 
iicbus0: <OFW I2C bus> on iichb0
iic0: <I2C generic I/O> on iicbus0
[root@pizero1 ~]# 

The first sensor I've hooked up is a SparkFun TMP117 temperature sensor. According to the hookup guide the default I2C address of this sensor is 0x48 which matches what I am seeing after hooking up the hardware and running i2c(8) with the -s flag to make it scan the I2C bus for devices:

[root@pizero1 ~]# i2c -s
Hardware may not support START/STOP scanning; trying less-reliable read method.
Scanning I2C devices on /dev/iic0: 48 
[root@pizero1 ~]# 

After connecting an additional sensor which has a default I2C address of 0x60 the bus scan can see that device too:

[root@pizero1 ~]# i2c -sv
dev: /dev/iic0, addr: 0x0, r/w: r, offset: 0x00, width: 8, count: 1
Hardware may not support START/STOP scanning; trying less-reliable read method.
Scanning I2C devices on /dev/iic0: 48 60 
[root@pizero1 ~]# 

Reading From I2C Devices

Reading from the sensor is straightforward. I specify that I want to read with -d r (meaning read), the length of data to get with -c 2 (meaning 2 bytes) and the address of the device to read from with -a 48:

[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr
0d 67 
[root@pizero1 ~]# while true; do date; i2c -a 48 -d r -c 2 -m tr; sleep 1; done
Sun Dec 20 00:51:45 UTC 2020
0d 67 
Sun Dec 20 00:51:46 UTC 2020
0d 67 
Sun Dec 20 00:51:48 UTC 2020
0d 66 
Sun Dec 20 00:51:49 UTC 2020
0d 66 
^C
[root@pizero1 ~]# 

After some trial and error I also add -m tr to set the addressing mode to complete-transfer which is needed on this I2C bus to make everything work.

The reading shown is a hex value, exactly two bytes as requested with -c 2.

When reading temperature from this sensor I don't need to add -o to the i2c(8) command to specify an offset, since the temperature is stored in the first register in the device. If I wanted to read from the next register (which for the TMP117 is where the configuration is stored) I would use -o 1:

[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr -o 1
22 20 
[root@pizero1 ~]# 

All the registers in the TMP117 are 16 bits (or two bytes), but the I2C command can read or write any number of bytes, just specify the width with -c.

Trying to read more data than the register size just results in FF bytes being returned:

[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr
0c bc 
[root@pizero1 ~]# i2c -a 48 -d r -c 4 -m tr
0c bc ff ff 
[root@pizero1 ~]# i2c -a 48 -d r -c 40 -m tr
0c bd ff ff ff ff ff ff ff ff ff ff ff ff ff ff 
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 
ff ff ff ff ff ff ff ff 
[root@pizero1 ~]# 

Writing To I2C Devices

The i2c(8) utility also makes it easy to write values back to the device. The TMP117 sensor has a programmable low and high thresholds in registers 2 and 3. The first one defaults to 0x6000 and changing it is much like reading, except we want the bytes to be written on stdin:

[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr -o 2
60 00 
[root@pizero1 ~]# echo -en "\x60\x01" | i2c -a 48 -d w -c 2 -m tr -o 2
[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr -o 2
60 01 
[root@pizero1 ~]# echo -en "\x60\x00" | i2c -a 48 -d w -c 2 -m tr -o 2
[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr -o 2
60 00 
[root@pizero1 ~]# 

Understanding Readings From the TMP117 Sensor

Reading the datasheet for the TMP117 sensor I learned that the temperature readings are stored in the first register, as a 16-bit value, which means I use -c 2 for i2c(8) so it reads two bytes (16 bits) from the sensor.

The datasheet for the TMP117 has this to say about the register where the temperature is stored: The data in the result register is in two's complement format, has a data width of 16 bits and a resolution of 7.8125m °C..

Two's Complement Format

Two's complement is a widely used and convenient way to work with signed (negative) numbers in binary. Basically the most significant bit is used to indicate if what follows is a positive or a negative binary number.

The following table is from the TMP117 datasheet, it shows some sample temperatures in celcius and what those readings translate to in decimal, two's complement binary, and hex:

TMP117 Two's Complement Example Readings
Temperature (°C decimal) Sensor register value (0.0078125°C resolution)
Decimal Binary Hex
–256-3276810000000000000008000
-25-32001111001110000000F380
–0.125-161111111111110000FFF0
–0.0078125-11111111111111111FFFF
0000000000000000000000
0.0078125100000000000000010001
0.1251600000000000100000010
112800000000100000000080
25320000001100100000000C80
1001280000110010000000003200
255.99213276701111111111111117FFF

I haven't worked much with two's complement format, so I started out by familiarising myself with it in Python. I borrowed the following function from SO and started out by reproducing the values in the above table:

[root@pizero1 ~]# cat twos.py 
#!/usr/bin/env python3
import sys

def twos(binary_str, bytecount):
    integer = int(binary_str, bytecount)
    bytes = integer.to_bytes(bytecount, byteorder="big", signed=False)
    return int.from_bytes(bytes, byteorder="big", signed=True)

print(twos(sys.argv[1], int(sys.argv[2])))
[root@pizero1 ~]#

Trying it out with the values from the table:

[root@pizero1 ~]# echo "$(python twos.py 1000000000000000 2) * 0.0078125" | bc
-256.0000000
[root@pizero1 ~]# echo "$(python twos.py 1111001110000000 2) * 0.0078125" | bc
-25.0000000
[root@pizero1 ~]# echo "$(python twos.py 1111111111110000 2) * 0.0078125" | bc
-.1250000
[root@pizero1 ~]# echo "$(python twos.py 1111111111111111 2) * 0.0078125" | bc
-.0078125
[root@pizero1 ~]# echo "$(python twos.py 0000000000000000 2) * 0.0078125" | bc
0
[root@pizero1 ~]# echo "$(python twos.py 0000000000000001 2) * 0.0078125" | bc
.0078125
[root@pizero1 ~]# echo "$(python twos.py 0000000000010000 2) * 0.0078125" | bc
.1250000
[root@pizero1 ~]# echo "$(python twos.py 0000000010000000 2) * 0.0078125" | bc
1.0000000
[root@pizero1 ~]# echo "$(python twos.py 0000110010000000 2) * 0.0078125" | bc
25.0000000
[root@pizero1 ~]# echo "$(python twos.py 0011001000000000 2) * 0.0078125" | bc
100.0000000
[root@pizero1 ~]# echo "$(python twos.py 0111111111111111 2) * 0.0078125" | bc
255.9921875
[root@pizero1 ~]# 

Looks like it works perfectly. Going from a reading to celcius is also easy, I tested with the values from the table again:

[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("8000"), byteorder="big", signed=True) * 0.0078125, 8))'
-256.0
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("F380"), byteorder="big", signed=True) * 0.0078125, 8))'
-25.0
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("FFF0"), byteorder="big", signed=True) * 0.0078125, 8))'
-0.125
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("FFFF"), byteorder="big", signed=True) * 0.0078125, 8))'
-0.0078125
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("0000"), byteorder="big", signed=True) * 0.0078125, 8))'
0.0
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("0001"), byteorder="big", signed=True) * 0.0078125, 8))'
0.0078125
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("0010"), byteorder="big", signed=True) * 0.0078125, 8))'
0.125
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("0080"), byteorder="big", signed=True) * 0.0078125, 8))'
1.0
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("0C80"), byteorder="big", signed=True) * 0.0078125, 8))'
25.0
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("3200"), byteorder="big", signed=True) * 0.0078125, 8))'
100.0
[root@pizero1 ~]# python -c 'print(round(int.from_bytes(bytearray.fromhex("7FFF"), byteorder="big", signed=True) * 0.0078125, 8))'
255.9921875
[root@pizero1 ~]# 

This means taking a reading from the sensor and converting it to celcius can be done like this:

[root@pizero1 ~]# i2c -a 48 -d r -c 2 -m tr
0c 81 
[root@pizero1 ~]# python -c "print(round(int.from_bytes(bytearray.fromhex('0c 81'), byteorder='big', signed=True) * 0.0078125, 8))"
25.0078125
[root@pizero1 ~]# 

Final Thoughts

Working with I2C devices from FreeBSD is easy and works very well!

A PI Zero is cheap, and along with a TMP117 you have a nice FreeBSD based high precision temperature measurement setup for like 160,- DKK (just over 20€).

Since the QWIIC sensors can be daisychained such a setup can easily be expanded with more sensors to measure more stuff. Most of the sensors have existing high-quality Arduino libraries (the TMP117 is no different) if interpreting the data turns out to be tricky.

Next step is to take a look at getting FreeBSD support into SparkFuns Qwiic_Py Python package, which requires getting FreeBSD support in Qwiic_I2C_Py which in turn depends on getting FreeBSD support in smbus2.

Donating

I've recently signed up for Github Sponsors meaning it is now easy to sponsor me and my work. If this post or some of my other writing, software or services have helped you then you can consider becoming a sponsor.

Search this blog

Tags for this blogpost