13 minutes
Bluewind - let’s make a smart bluetooth fan smart again!
In a recent surprising turn of events, I got to connect with some listeners of Jupiter Broadcasting shows, one of which happens to be involved in organizing the Berlin Hack and Tell meetup, which is a cool monthly event where people do short demos of cool projects they had hacked together. After some chatting with this funky group of tech enthusiasts, I was encouraged to present some of my project that seem to be a perfect fit for Berlin Hack and Tell, so ahead of submitting my lightning talk, I wanted to make a blog post here first to put down in words how this project came to be and what it actually is, in case anyone fancies finding out more about it.
Give yourself no excuse
When it comes to building positive habits, including trying to exercise regularly, I find it important to lower the barrier to entry for myself. One of the ways that I have achieved this is having an indoor cycling setup with a bike on a turbo in front of a TV screen. The bike is always ready, the turbo can be turned on remotely from my phone and controlled by a sports app that suggests me the optimal workout for each day. I try to always have fresh cycling clothes ready. No need for my brain to perform mental gymnastics and participate in a self indulgent competition of making up excuses related to all those minor inconveniences, seemingly rendering me incapable of just jumping on a bike and sweating out some of my frustrations and calories that I could really do with getting rid of.
As a techie the gadgetry side of sport science is very appealing to me and naturally I have eventually paired my turbo trainer with an expensive bluetooth fan which promised seamless integration into my indoor cycling ecosystem of gadgets. Alas, it was not meant to be. The fan is able to simulate wind speed of up to 25km/h. However the indoor trainer does not simulate correct speed in ERG mode, and that is the mode I predominantly use for my training (using TrainerRoad). The fan can also track your heart rate, but for whatever reason it would never reach the full speed no matter how high my HR had gotten during the workouts I had tried this feature on. This has left me stuck with the manual speed mode, where I must adjust the speed myself between the 4 predefined speed presets. That would not be a big issue had it not been for the fact that in order to use this option I have to either:
- Jump off the bike in order to reach the buttons of this, so called, “smart” fan
- Switch apps on my phone multiple times mid ride from TrainerRoad to the Wahoo app and back in order to adjust the speed
This was not ideal and definitely did not match my usual “low barrier to entry” vision for how I like to enjoy my indoor sweating time. And don’t get me wrong, this fan is fantastic (pun intended) and does a great job in the core area of its functionality - being a fan and cooling me down, but I decided to take the matter in my own hands and take it up a notch from good to great!
Research
First things first, we need to see what is already available. A search and a look around had uncovered a github repository which we can use as a code donor for our project: https://github.com/garanj/wearwind. Although it is a Java project, an application for Android wear, we can learn a few interesting facts from it. To start, it is controlling the fan by sending it byte arrays of data in the following format:
private val POWER_ON = byteArrayOf(4, 4, 1)
private val POWER_OFF = byteArrayOf(2, 0)
This was a good starting point and following some experimentation I have decoded a few more commands which I found useful, notably sending the unit to sleep:
SLEEP = [0x4, 0x1]
And setting a speed of choice:
# Second byte is the speed, values can range from 0x1 to 0x64
MIN_SPEED = [0x2, 0x1]
FULL_SPEED = [0x2, 0x64]
Here are some other commands that I figured out I could send to the device:
ON = [0x4, 0x4, 0x2] # Wake up the fan
OFF = [0x2, 0x0] # Shut the fan off, including BLE
SLEEP = [0x4, 0x1] # Shut the fan off, leave BLE on
HR = [0x4, 0x2] # Put device in auto HR mode
SPD = [0x4, 0x3] # Put device in auto speed mode
The beginning of my journey with python and BLE started with doing research on existing libraries and implementations of the BLE stack, where I have discovered the following resources:
These seemed like they are used in various projects on github, and the pybluez even comes with handy examples for how to do BLE scans:
Trying to use this library however I quickly learned an interesting fact: BLE is not supported on many laptop or desktop wifi/ble modules! Having had trouble running this code, on a whim I decided to try it our on a raspberry pi 4 I had laying around, and that worked without a hitch. I was able to scan for BLE devices nearby and read their advertised lists of characteristics:
service000a = "00001801-0000-1000-8000-00805f9b34fb" # Generic Attribute Profile
service000a_char000b = "00002a05-0000-1000-8000-00805f9b34fb" # Service Changed
service000a_char000b_desc000d = "00002902-0000-1000-8000-00805f9b34fb" # Client Characteristic Configuration
service000e = "0000180a-0000-1000-8000-00805f9b34fb" # Device Information
service000e_char000f = "00002a29-0000-1000-8000-00805f9b34fb" # Manufacturer Name String
service000e_char0011 = "00002a25-0000-1000-8000-00805f9b34fb" # Serial Number String
service000e_char0013 = "00002a27-0000-1000-8000-00805f9b34fb" # Hardware Revision String
service000e_char0015 = "00002a26-0000-1000-8000-00805f9b34fb" # Firmware Revision String
service0017 = "a026ee01-0a7d-4ab3-97fa-f1500f9feb8b" # Vendor specific
service0017_char0018 = "a026e002-0a7d-4ab3-97fa-f1500f9feb8b" # Vendor specific
service0017_char0018_desc001a = "00002902-0000-1000-8000-00805f9b34fb" # Client Characteristic Configuration
service0017_char001b = "a026e004-0a7d-4ab3-97fa-f1500f9feb8b" # Vendor specific
service0017_char001b_desc001d = "00002902-0000-1000-8000-00805f9b34fb" # Client Characteristic Configuration
service001e = "a026ee0c-0a7d-4ab3-97fa-f1500f9feb8b" # Vendor specific
service001e_char001f = "a026e038-0a7d-4ab3-97fa-f1500f9feb8b" # Vendor specific
service001e_char001f_desc0021 = "00002902-0000-1000-8000-00805f9b34fb" # Client Characteristic Configuration
Characteristics are like fields, or ports, that you can either read from or write to, and after some more experimentation I have identified that in order to control the fan I need to send data to characteristic a026e038-0a7d-4ab3-97fa-f1500f9feb8b
.
For a much better explanation of how BLE works and what tools you can use to play with it, I highly recommend this blog post: The Practical Guide to Hacking Bluetooth Low Energy by Vaibhav Bedi.
Moving forward, I had struggled to use the basic pybluez library for this type of communication and have switched over to using a simple yet powerful, more abstract library, i.e. bleak
: https://github.com/hbldh/bleak.
It is during this transition that I realized that the BLE protocol is only used for scanning for devices and exposing their capabilities. In case of my Headwind fan, once I have found out the characteristic and MAC address to send data to, I no longer rely on the BLE stack for anything and could go back to sending data to the fan directly from my laptop. I did not need to scp my code to my local trusty Pi4 no longer from here on out.
PoC
With what we have learned so far, I started off by bundling all of the bluetooth-related functionality into the Headwind
class. This will be our means of talking to the fan and asking it for state information.
from bleak import BleakClient
# Config byte arrays
ON = [0x4, 0x4, 0x2]
OFF = [0x2, 0x0]
SLEEP = [0x4, 0x1]
HR = [0x4, 0x2]
SPD = [0x4, 0x3]
MIN_SPEED = [0x2, 0x1]
HALF_SPEED = [0x2, 0x32]
FULL_SPEED = [0x2, 0x64]
# Characteristic to read state and write config to
CHARACTERISTIC = "a026e038-0a7d-4ab3-97fa-f1500f9feb8b"
class Headwind:
fanClient = None
flaskApp = None
def __init__(self, flaskApp, address):
self.flaskApp = flaskApp
self.fanClient = BleakClient(address)
Bleak
abstracts the complexity related to establishing the bluetooth connection, and reading and writing data to the bluetooth device.
In case of the Headwind fan, we have established that to read the state of the fan we need to read the value of the CHARACTERISTIC
like so:
async def readSpeed(self):
try:
async with self.fanClient as client:
result = await client.read_gatt_char(CHARACTERISTIC)
# The result will be a byte array
# In this case it will return something similar to: 0xFD-01-XX-04
# Where XX is the current fan speed
# Hence we return the 3rd array element to get the speed:
return result[2]
except Exception:
return 0
Ok, so what about changing the speed? It is also fairly straightforward. Take a look.
async def writeSpeed(self, speed):
if speed > 0:
# Create a byte array that includes:
# - the mode (first element)
# - the speed value (second element)
value = [0x2, speed]
try:
async with self.fanClient as client:
# Then we write the prepared byte array to the same CHARACTERISTIC address
await client.write_gatt_char(CHARACTERISTIC, value)
return True
except Exception as e:
return False
else:
return False
Now that we have established an abstraction for our smart fan, we can just create an instance of it and start controlling it.
fan = Headwind.Headwind("<bluetooth address>")
fan.writeOn() # Wake up the fan
fan.readSpeed() # Read the current fan speed
fan.writeSpeed(100) # Tell the fan to run at 100% speed
fan.writeSleep() # Send the fan back to sleep
Buttons
But how are we going to interact with this fan? What I need is a way to change its speed without getting off and back on the bike. The answer, to no one’s surprise, is Home Assistant. Initially I looked into writing a proper Home Assistant integration that would expose the fan as a device with various entities that can be probed and controlled. The process of creating new Home Assistant integrations, based on my limited quick research, is however somewhat convoluted. As someone who comes from a programming background it seems counter intuitive and I will be exploring it at a later date. For now, the fallback plan will be to implement a simple REST API in Flask that we can then expose to Home Assistant via its rest_command
interface.
Flask
Flask is a fantastic framework for creating REST APIs and more, very quickly and simply. Here I have prepared a snippet of what we need in order to create an endpoint that will increase fan speed by 25%. This endpoint will be called each time I “press a button” in Home Assistant.
@app.route("/speed/increase", methods=["POST"])
async def setIncreaseSpeed():
# First we read the current speed of the fan
currentSpeed = await fan.readSpeed()
newSpeed = 0
# We define the new desired speed, depending on the current speed
if currentSpeed < 25:
newSpeed = 25
elif currentSpeed < 50:
newSpeed = 50
elif currentSpeed < 75:
newSpeed = 75
elif currentSpeed < 100:
newSpeed = 100
# Lastly we send the fan our new speed setting
fan_status = await fan.writeSpeed(newSpeed)
if not fan_status:
return f"Failed to increase speed by 25%", 503
return f"Increased speed by 25%", 200
This is one of the endpoints I created, to see the others check out the views.py
module in my repository: https://teapot.octopusx.de/accidentallycompetent/bluewind/src/branch/main/bluewind/views.py
Now that we have the endpoints implemented, we need to start our bluewind Flask server, so that it is ready to receive the REST API calls:
# If you haven't done so already, roll out the virtual env and configure bluewind via environment variables
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
export FLASK_ADDRESS='<Headwind bluetooth address>'
# Then start the server
python3 main.py
Home Assistant
Home Assistant does not currently support, to the best of my knowledge, a way to configure rest_command integrations via the web UI, so we need to hop into our configuration.yaml file and add a block much like this one:
rest_command:
Headwind_on:
url: "http://192.168.0.111:5000/on"
method: "POST"
Headwind_sleep:
url: "http://192.168.0.111:5000/sleep"
method: "POST"
Headwind_speedup:
url: "http://192.168.0.111:5000/speed/increase"
method: "POST"
Headwind_speeddown:
url: "http://192.168.0.111:5000/speed/decrease"
method: "POST"
This way we define a number of endpoints that can be called via the rest_command
service. In our case the commands will be Headwind_on
, Headwind_sleep
, Headwind_speedup
, and Headwind_speeddown
.
In my Home Assistant setup, my Wahoo smart trainer is powered from an ikea tradfri smart socket, which I can also control from a tradfri button press. I can now easily attach the Headwind_on
onto the state of the switch.ikea_socket_office_2
entity, turning the fan on whenever the turbo gets turned on, and the opposite with another automation working in the reverse.
alias: Headwind on
description: ""
trigger:
- platform: state
entity_id:
- switch.ikea_socket_office_2
to: "on"
from: "off"
condition: []
action:
- service: rest_command.Headwind_on
data: {}
mode: single
For my remote control of both my turbo and the Headwind fan I am using a plain old Ikea Tradfri button that is linked with Home Assistant via a Zigbee2MQTT bridge. It is a wireless, coin cell battery powered button with 5 discrete physical buttons, The big middle one, as described above, toggles on and off the Wahoo Kickr Core indoor trainer socket power and in turn the fan. The top and bottom physical buttons, normally used for adjusting light brightness levels in an Ikea smart lighting system, will be repurposed as our speed changing buttons. Each press of the up or down button will increase or decrease the fan speed by 25% respectively.
Here is the yaml code defining my this automation:
alias: Headwind speed up
description: ""
trigger:
- platform: device
domain: mqtt
device_id: a6a84a4ded13d8a3850a4c323ec737e4
type: action
subtype: brightness_up_click
discovery_id: 0xccccccfffe5f0bd7 action_brightness_up_click
condition: []
action:
- service: rest_command.Headwind_speedup
data: {}
mode: single
With this setup, I am able to turn all of my indoor riding gear on and of with a single press of a button, and I am able to adjust the fan speed to my liking remotely from my bicycle while riding. In the end I deployed my bluewind API server on a Raspberry Pi 4 that lives permanently in my training room, but this software will work on any linux machine with access to a bluetooth receiver that is within signal range of your Wahoo Headwind.
As ever, you can find all of the project files available on my Teapot Gitea instance: https://teapot.octopusx.de/accidentallycompetent/bluewind
Future Plans
As with every project, posting an article on my blog usually means that it is about half way finished. Yes, the basic functionality is here and I can use my new automation already, but there are a few things missing for me to call it good and done.
For one, I would like to decipher the rest of the byte codes returned by the BLE characteristics of the Headwind fan. I would like to be able to read what mode the fan is in and select different modes, not just operate the manual speed dial remotely.
Secondly, my current solution omits the issue of scanning and finding the MAC address of your bluetooth fan entirely. This means you need to first scan the aether with some other BLE scanner app in order to find the MAC address and enter it into bluewind’s config via an environment variable. Something I would love to be able to do, is to automatically check if whatever device you run the server on is BLE capable or not. If the former, then scan for local Headwind devices and automatically start communicating with the first one it finds, in case a MAC address is not passed as configuration.
Lastly, once the above two points are fully addressed, I would like to create a proper Home Assistant integration for bluewind. One that reduces the barrier to entry for all users and is much easier to set up for anyone.
I hope you’ve learned something from this blog entry, I certainly learned a lot about the BLE and bluetooth stacks, and I wish you the best in your future training and coding endeavors! Please check in again at a later date to see what I am working on, I do my best to mix and match topics to keep it fresh and interesting!
2659 Words
2023-11-15 21:39