Have you always wanted to get your Circuit Playground Bluefruit working with Home Assistant? Have some Circuit Playground Bluefruit devices sitting around and you'd like to use them as temperature sensors or use the buttons to control aspects of your home?
Well, you can. Using Circuit Python and the BTHome specification for advertising data over Bluetooth, you can broadcast the temperature from a Circuit Playground over Bluetooth to your Home Assistant.
The BTHome website has a nice, quick primer on the advertisement format for BLE. An even shorter primer is here. BLE defines the format for advertisements and data packets. We're interested in the Advertisement payload format, which is part of the overall data packet.
The advertisement payload consists of a series of advertising (AD) elements. Each AD element has a length (1 byte), an ID (1 byte), followed by ad element specific data (variable length).
A partial list of element types and their associated IDs are given in the table below.
| Ad element type | ID |
|---|---|
| Flags | 0x01 |
| Partial List of 16 bit UUIDs | 0x02 |
| Complete List of 16 bit UUIDs | 0x03 |
| Partial List of 32 bit UUIDs | 0x04 |
| Complete List of 32 bit UUIDs | 0x05 |
| Partial List of 128 bit UUIDS | 0x06 |
| Complete List of 128 bit UUIDs | 0x07 |
| Short Local Name | 0x08 |
| Complete Local Name | 0x09 |
| Transmit Power Level | 0x0A |
| Class of Device | 0x0D |
| Simple Pairing Hash | 0x0E |
| Simple Pairing Randomizer | 0x0F |
| Security Manager TK Value | 0x 10 |
| Security Manager Out of Band Flags | 0x11 |
| Slave Connection Interval Range | 0x12 |
| List of 16 bit Service Solicitation IDs | 0x14 |
| List of 128 bit Service Solicitation IDs | 0x15 |
| Service Data | 0x16 |
| Public Target Address | 0x17 |
| Random Target Address | 0x18 |
The Flags element for BTHome has the format 0x020106. The first byte is 0x02, indicating that the rest of the ad element is 2 bytes. The second is the ID, which is 0x01. The last byte is a set of flags meaning that the BLE is in discoverable mode. See the BTHome format for more info.
The Complete Local Name element simply has the length of the element, the ID (0x09), and a series of bytes for for the UTF-8 encoded name. Example: 0a 09 43 50 20 42 54 48 6f 6d 65. First byte is 0x0A, meaning there are 10 further bytes. The ID is 0x09. And the following bytes are the ASCII-encoded letters representing "CP BTHome".
The Service Data element type allows arbitrary data to be advertised within it's structure. The format is: length (1 byte), ID of 0x16 (1 byte), Service UUID (2 bytes), followed by a variable number of bytes defined by the Service. The Service UUID is unique to the service and typically it is purchased by a company for use in it's products.
The BTHome specification provides a format for information within the Service Data element type. This service requires a Service UUID that is unique, to differentiate it from other possible services that can be transmitted within the Service Element. The company Allterco Robotics (manufacturer of Shelly products) purchased a UUID and has given permission for anyone to use it with BTHome. The UUID is 0xFCD2. The format of all the various sensors are given on the BTHome site.
The BTHome format consists of the UUID, followed by a byte of flags (determining encryption, trigger-based, and version number) which is normally 0x40 (meaning no encryption, not trigger-based, version 2), followed by bytes that depend on the sensor information being sent.
For example, the temperature data is given by the format of temperature ID (0x45), followed by 2 bytes for the temperature data.
An example of a complete ad payload would be:
[02 01 06 0a 09 43 50 20 42 54 48 6f 6d 65 07 16 d2 fc 40 45 15 01]
|--Flags-|-------Complete Local Name -----|Srv Dt|UUID|Flg|Temp---|
The other BTHome formats are similar. A complete advertisement for the code below is:
[02 01 06 0a 09 43 50 20 42 54 48 6f 6d 65 10 16 d2 fc 40 05 a4 27 01 3a 00 45 15 01 51 82 24]
This has the Flags, Complete Local Name ("CP BTHome"), the Service Data packet with the BTHome elements of illuminance (ID=0x05), button (ID=0x3A), temperature (ID=0x45), and acceleration (ID=0x51).
By leveraging the existing Adafruit Circuit Python Bluetooth BLE code framework, we can add a Service that advertises data in the BTHome format. The Bluetooth advertising payload has various advertising data elements that it supports. Below is a list of the advertising data elements and their associated IDs. (Refer to the header file on this page) The ones we are interested in are the Flags (0x01), Complete Local Name (0x09), and Service Data (0x16).
So we first include all the headers that we will need:
import board import neopixel import time import math import array import digitalio import busio import analogio import simpleio import adafruit_thermistor import adafruit_lis3dh import microcontroller import audiobusio from rainbowio import colorwheel from adafruit_ble import BLERadio from adafruit_ble import advertising #from adafruit_ble.advertising.standard import ProvideServicesAdvertisement from adafruit_ble.uuid import StandardUUID from adafruit_ble.services import Service import adafruit_ble.advertising.standard from adafruit_ble.characteristics.int import Uint8Characteristic, Int16Characteristic
Then we write the Python code for all the sensors we will want to advertise. In this case, it is the temperature sensor, light sensor, accelerometer, and one of the buttons. It seems that, despite the BTHome spec allowing it, the Home Assistant BTHome implementation doesn't allow more than one sensor of the same type to be defined in the payload. (At least I couldn't get it to work).
Note that I also set up the microphone for sound, but I don't actually use it because I don't really need it. The sound BTHome element is a boolean, so we would need determine the threshold which would constitute a sound detected. I'll leave that as an exercise for the reader.
thermistor = adafruit_thermistor.Thermistor(board.TEMPERATURE, 10000, 10000, 23.65, 3950)
# The Circuit Playground uses the Everlight ALS-PT19 as the sensor.
# The datasheet shows a graph of lux vs voltage and the lux ranges from 0 to 10000
# The output in Python ranges from 0 to 65535.
# So I'll map the range 0 65535 to 0 to 10000 as a rough estimate
light = analogio.AnalogIn(board.LIGHT)
mic = audiobusio.PDMIn(board.MICROPHONE_CLOCK, board.MICROPHONE_DATA,
sample_rate=16000, bit_depth=16)
buttonA = digitalio.DigitalInOut(board.BUTTON_A)
buttonA.switch_to_input(pull=digitalio.Pull.DOWN)
buttonB = digitalio.DigitalInOut(board.BUTTON_B)
buttonB.switch_to_input(pull=digitalio.Pull.DOWN)
i2c = busio.I2C(board.ACCELEROMETER_SCL, board.ACCELEROMETER_SDA)
int1 = digitalio.DigitalInOut(board.ACCELEROMETER_INTERRUPT)
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x19, int1=int1)
lis3dh.range = adafruit_lis3dh.RANGE_8_G
Now we get to the parts that were more difficult to figure out. We need to define a class for the overall advertisement that will be used for Bluetooth. This advertisement needs to have the 3 components mentioned above - flags, name, and Service Data. Adafruit builds in the flags and name components into its base Advertisement class. So that part becomes easy. However, we need to have a Service Data component in the Advertisement class as well. But in order to instantiate a Service Data class, we need to give it a Service. So we need to first define a Service class.
We first define a BTHomeService. This will be a simple class that defines a UUID (Universally Unique Identifier). Every Service needs to have a Universally Unique Identifier and the Allterco Robotics company has purchased the UUID of 0xFCD2 for use for the implementation of BTHome.
class BTHomeService(Service):
# UUID given by Alterco Robotics
uuid = StandardUUID(0xFCD2)
# NOTE: There are no characteristics for this service.
# That is, no explicitely defined BLE characteristics will be used. Instead we just have whatever BTHome
# has defined internally in their list. But these are not official characteristics.
# bit 0: Encryption flag - 0 non-encrypted, 1 encrypted
# bit 1: reserved
# bit 2: Trigger based device flag - 0 regular interval, 1 non-regular interval
# bit 3-4: Reserved
# bit 5-7: BTHome Version - version 2, so 010
# This gives 01000000b = 0x40
#device_information = Uint8Characteristic(min_value=0x40,max_value=0x40, initial_value=0x40)
#temperatureField = 0x45 # Data type for 0.1 precision temperature field in BTHome
#temperatureValue = 0x0000 # Dummy info for now
def __get__(self, obj, objtype=None):
super.__get__(obj, objtype)
With the basic Service defined, we can define the advertisement. The only thing we need is to create the service inside the class. The advertisement class will call into the Service to get the bytes it needs to make up the actual advertisement.
class BTHomeAd(advertising.Advertisement): btService = BTHomeService() s = advertising.standard.ServiceData(btService)
With that defined, we can now create classes for the Circuit Playground and all the sensors we need. The BTHome format defines different IDs for the various sensor types. These can be found here.
class BTHomeTemperature:
global thermistor
objectID = 0x45 # temperature
def __get__(self, obj, objtype=None):
b = bytearray(3)
b[0] = self.objectID
temperature = int(thermistor.temperature * 10)
b[1] = temperature & 0xFF
b[2] = (temperature & 0xFF00) >> 8
return b
class BTHomeLight:
objectID = 0x05 # illuminance
def __get__(self, obj, objtype=None):
b = bytearray(4)
b[0] = self.objectID
lux = int(simpleio.map_range(light.value, 0, 65535, 0, 10000) * 100)
b[3] = (lux & 0xFF0000) >> 16
b[2] = (lux & 0x00FF00) >> 8
b[1] = lux & 0x00FF
return b
class BTHomeSound:
objectID = 0x2A # sound - boolean
# Number of samples to read at once.
NUM_SAMPLES = 160
def __init__(self):
# Record an initial sample to calibrate. Assume it's quiet when we start.
self.samples = array.array('H', [0] * self.NUM_SAMPLES)
mic.record(self.samples, len(self.samples))
def mean(self, values):
return sum(values) / len(values)
# Remove DC bias before computing RMS.
def normalized_rms(self, values):
minbuf = int(self.mean(values))
samples_sum = sum(
float(sample - minbuf) * (sample - minbuf)
for sample in values
)
return math.sqrt(samples_sum / len(values))
def get(self):
mic.record(self.samples, len(self.samples))
magnitude = self.normalized_rms(self.samples)
return(magnitude)
# You might want to print this to see the values.
# print(magnitude)
class BTHomeButton:
objectID = 0x3A # button
def __init__(self, button):
self.button = button
self.current_value = button.value
def __get__(self, obj, objtype=None):
b = bytearray(2)
b[0] = self.objectID
if self.button.value != self.current_value:
if not self.current_value:
# Button pressed
self.current_value = True
b[1] = 0x01
else:
# Button released
self.current_value = False
b[1] = 0x00
else:
b[1] =0x00
return b
class BTHomeAccelerometer:
objectID = 0x51
def __get__(self, obj, objtype=None):
b = bytearray(3)
b[0] = self.objectID
x,y,z = lis3dh.acceleration
'''
x = int(abs(x) * 1000)
b[1] = x & 0x0FF
b[2] = (x & 0x00FF00) >> 8
b[3] = self.objectID
y = int(y * 1000)
b[4] = y & 0x0FF
b[5] = (y & 0x00FF00) >> 8
'''
b[0] = self.objectID
z = int(z * 1000)
b[1] = z & 0x0FF
b[2] = (z & 0x00FF00) >> 8
return b
Then we just need a class to contain all the sensors, representing our Circuit Playground Bluefruit.
class BTHomeCircuitPlayground:
device_info = bytearray(1)
device_info[0] = 0x40
temp = BTHomeTemperature()
lux = BTHomeLight()
A = BTHomeButton(buttonA)
B = BTHomeButton(buttonB)
z_accel = BTHomeAccelerometer()
def get(self):
# In objectID order 0x05 0x3A 0x45 0x51
return self.device_info + self.lux + self.A + self.temp + self.z_accel
Now we can just instantiate all the classes, fill in the necessary pieces, and call the get() function on our CP Bluefruit class to grab all the sensor data and put it into the Service Data.
As mentioned above, the Adafruit Advertisement class has the flags and complete_name parameters built into it, so there is no need to define a class for those. We just need to fill them in. The general_discovery flag and the le_only flag are the only ones we need to have set for the flags element.
The actual service is simply set to the bytearray() we get from our CP Bluefruit class. The ServiceData class is written in such a way that setting it will keep the ServiceData UUID in tact and append whatever data you give it. This is why we can simply create the BTHome payload without regard for the ServiceData UUID itself. The BTHome payload consists of the device information followed by all sensor data.
bthomeCP = BTHomeCircuitPlayground()
btad = BTHomeAd()
btad.complete_name = "CP BTHome"
btad.flags.general_discovery = True
btad.flags.le_only = True
btad.s = bthomeCP.get()
print('[' + advertising.to_hex(btad.__bytes__()) + ']')
ble = BLERadio()
pixels = neopixel.NeoPixel(board.NEOPIXEL, 10, brightness=0.2, auto_write=False)
while True:
btad.s = bthomeCP.get()
#print('[' + advertising.to_hex(btad.__bytes__()) + ']')
ble.start_advertising(btad)
# Change this to a non-blocking time comparison
# That way we can check for button presses in the meantime.
time.sleep(INTERVAL)
ble.stop_advertising()
import board
import neopixel
import time
import math
import array
import digitalio
import busio
import analogio
import simpleio
import adafruit_thermistor
import adafruit_lis3dh
import microcontroller
import audiobusio
from rainbowio import colorwheel
from adafruit_ble import BLERadio
from adafruit_ble import advertising
#from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.uuid import StandardUUID
from adafruit_ble.services import Service
import adafruit_ble.advertising.standard
from adafruit_ble.characteristics.int import Uint8Characteristic, Int16Characteristic
DEVICE_NAME = "CP Bluefruit - Living Room"
INTERVAL = 0.25
SENSITIVITY = 0.01
a = advertising.Advertisement()
a.complete_name = "CP Bluefruit"
a.flags.general_discovery = 1
a.flags.le_only = 1
print('[' + advertising.to_hex(a.__bytes__()) + ']')
uuid = StandardUUID(0xD2FC) # The UUID of BTHome Data, as given by Allterco Robotics.
print('UUID: ' + uuid.__str__())
thermistor = adafruit_thermistor.Thermistor(board.TEMPERATURE, 10000, 10000, 23.65, 3950)
# The Circuit Playground uses the Everlight ALS-PT19 as the sensor.
# The datasheet shows a graph of lux vs voltage and the lux ranges from 0 to 10000
# The output in Python ranges from 0 to 65535.
# So I'll map the range 0 65535 to 0 to 10000 as a rough estimate
light = analogio.AnalogIn(board.LIGHT)
mic = audiobusio.PDMIn(board.MICROPHONE_CLOCK, board.MICROPHONE_DATA,
sample_rate=16000, bit_depth=16)
buttonA = digitalio.DigitalInOut(board.BUTTON_A)
buttonA.switch_to_input(pull=digitalio.Pull.DOWN)
buttonB = digitalio.DigitalInOut(board.BUTTON_B)
buttonB.switch_to_input(pull=digitalio.Pull.DOWN)
i2c = busio.I2C(board.ACCELEROMETER_SCL, board.ACCELEROMETER_SDA)
int1 = digitalio.DigitalInOut(board.ACCELEROMETER_INTERRUPT)
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x19, int1=int1)
lis3dh.range = adafruit_lis3dh.RANGE_8_G
class BTHomeService(Service):
# UUID given by Alterco Robotics
uuid = StandardUUID(0xFCD2)
# NOTE: There are no characteristics for this service.
# That is, no explicitely defined BLE characteristics will be used. Instead we just have whatever BTHome
# has defined internally in their list. But these are not official characteristics.
# bit 0: Encryption flag - 0 non-encrypted, 1 encrypted
# bit 1: reserved
# bit 2: Trigger based device flag - 0 regular interval, 1 non-regular interval
# bit 3-4: Reserved
# bit 5-7: BTHome Version - version 2, so 010
# This gives 01000000b = 0x40
#device_information = Uint8Characteristic(min_value=0x40,max_value=0x40, initial_value=0x40)
#temperatureField = 0x45 # Data type for 0.1 precision temperature field in BTHome
#temperatureValue = 0x0000 # Dummy info for now
def __get__(self, obj, objtype=None):
super.__get__(obj, objtype)
class BTHomeAd(advertising.Advertisement):
btService = BTHomeService()
s = advertising.standard.ServiceData(btService)
class BTHomeTemperature:
global thermistor
objectID = 0x45 # temperature
def __get__(self, obj, objtype=None):
b = bytearray(3)
b[0] = self.objectID
temperature = int(thermistor.temperature * 10)
b[1] = temperature & 0xFF
b[2] = (temperature & 0xFF00) >> 8
return b
class BTHomeLight:
objectID = 0x05 # illuminance
def __get__(self, obj, objtype=None):
b = bytearray(4)
b[0] = self.objectID
lux = int(simpleio.map_range(light.value, 0, 65535, 0, 10000) * 100)
b[3] = (lux & 0xFF0000) >> 16
b[2] = (lux & 0x00FF00) >> 8
b[1] = lux & 0x00FF
return b
class BTHomeSound:
objectID = 0x2A # sound - boolean
# Number of samples to read at once.
NUM_SAMPLES = 160
def __init__(self):
# Record an initial sample to calibrate. Assume it's quiet when we start.
self.samples = array.array('H', [0] * self.NUM_SAMPLES)
mic.record(self.samples, len(self.samples))
def mean(self, values):
return sum(values) / len(values)
# Remove DC bias before computing RMS.
def normalized_rms(self, values):
minbuf = int(self.mean(values))
samples_sum = sum(
float(sample - minbuf) * (sample - minbuf)
for sample in values
)
return math.sqrt(samples_sum / len(values))
def get(self):
mic.record(self.samples, len(self.samples))
magnitude = self.normalized_rms(self.samples)
return(magnitude)
# You might want to print this to see the values.
# print(magnitude)
class BTHomeButton:
objectID = 0x3A # button
def __init__(self, button):
self.button = button
self.current_value = button.value
def __get__(self, obj, objtype=None):
b = bytearray(2)
b[0] = self.objectID
if self.button.value != self.current_value:
if not self.current_value:
# Button pressed
self.current_value = True
b[1] = 0x01
else:
# Button released
self.current_value = False
b[1] = 0x00
else:
b[1] =0x00
return b
class BTHomeAccelerometer:
objectID = 0x51
def __get__(self, obj, objtype=None):
b = bytearray(3)
b[0] = self.objectID
x,y,z = lis3dh.acceleration
'''
x = int(abs(x) * 1000)
b[1] = x & 0x0FF
b[2] = (x & 0x00FF00) >> 8
b[3] = self.objectID
y = int(y * 1000)
b[4] = y & 0x0FF
b[5] = (y & 0x00FF00) >> 8
'''
b[0] = self.objectID
z = int(z * 1000)
b[1] = z & 0x0FF
b[2] = (z & 0x00FF00) >> 8
return b
class BTHomeCircuitPlayground:
device_info = bytearray(1)
device_info[0] = 0x40
temp = BTHomeTemperature()
lux = BTHomeLight()
A = BTHomeButton(buttonA)
B = BTHomeButton(buttonB)
z_accel = BTHomeAccelerometer()
def get(self):
# In objectID order 0x05 0x3A 0x45 0x51
return self.device_info + self.lux + self.A + self.temp + self.z_accel
bthomeCP = BTHomeCircuitPlayground()
btad = BTHomeAd()
btad.complete_name = "CP BTHome"
btad.flags.general_discovery = True
btad.flags.le_only = True
btad.s = bthomeCP.get()
print('[' + advertising.to_hex(btad.__bytes__()) + ']')
ble = BLERadio()
pixels = neopixel.NeoPixel(board.NEOPIXEL, 10, brightness=0.2, auto_write=False)
while True:
btad.s = bthomeCP.get()
#print('[' + advertising.to_hex(btad.__bytes__()) + ']')
ble.start_advertising(btad)
# Change this to a non-blocking time comparison
# That way we can check for button presses in the meantime.
time.sleep(INTERVAL)
ble.stop_advertising()
This page (Integrating Circuit Playground Bluefruit into Home Assistant) was last updated on August 31, 2024.