Driving WS2812B LEDs with a Raspberry Pi

Writing LED sequences can be quite a faff when you’re making your own programs and hardware. I decided that a handy way of visualising the patterns would be… to visualise them, as an image. To do this, I’ve written a simple program that takes a bitmap image and outputs the colours of each pixel in each row, to the corresponding LED. The image is the same width as the target number of pixels, and each row represents a frame.

This allows me to do my LED sequencing using software I’m already familiar with, such as Photoshop or GIMP.

Cylon Eyes
Cylon Eyes

So far, I’ve had versions of the program (re-written for required variations in available libraries) running on the Orange Pi (Armbian), ESP8266 (Arduino IDE) and the Raspberry Pi (twice, because I lost the original version). This post covers the latter, using the current libraries discussed on the Adafruit website, which unfortunately seem to only work with Python 3.

Required Equipment & Setup

For this tutorial, you will require the following :

  • A Raspberry Pi, cables and an SD card
  • A string of WS2812B LEDs – 8 is a good number
  • Appropriate wires to connect the LEDs to the Raspberry Pi GPIO header

I’ve used the linked LEDs as they are convenient for testing. You will need to solder wires, or a header to these – I usually lay a standard male header (4 pins) on the pads and solder it down as it is handy for prototyping. The male header lets me use female->female dupont wires to connect to a Raspberry Pi. Also note that some variations have the red and green wired backwards – this can be easily be fixed in software (I have included a boolean near the start of the code in pictureLED.py to enable the switch).

The LEDs should be wired into the Raspberry Pi while it is powered down to reduce the chance of fatal mistakes (always rewire with a computer powered down as they are very sensitive to electrical shocks and the GPIO pins are wired directly into the inner gubbins of the main chip). Wiring is as follows, where the red wire goes to +5v, the blue wire to data and the black wire to ground (there are two on the suggested LED part which is useful when using separate power supplies for the Raspberry Pi and the LEDs).

Raspberry Pi LED Wiring
Raspberry Pi LED Wiring

The WS2812B LEDs are only borderline able to be driven from 3.3v to the data pin (the Raspberry Pi does this). While this is true, with short wires I have never had an issue. I will detail how to use a MOSFET to increase the data signal voltage to 5v later.

I will assume you have a clean install of Raspbian on the SD card in the Raspberry Pi. Before doing anything else, ensure you run the following command in a terminal :

sudo apt-get update

This updates the database of software on the Raspberry Pi and ensures that you’re requesting the latest versions of programs.

Next run the following commands in a terminal :

sudo pip3 install rpi_ws281x

sudo pip3 install adafruit-circuitpython-neopixel

The first time I ran these commands, I had an error for an unknown reason. I ran them again and everything installed properly.

First Test

To make sure everything is working, enter the following program into your favourite text editor on your Raspberry Pi and save it as test.py in a new folder (creating a new folder in the current directory : mkdir <foldername>, switch into the new folder : cd <foldername>, editor suggestion : nano test.py).

#!/usr/bin/env python3
# The line above tells the Raspberry Pi that we want to run this
#   script in Python 3 - so we don't have to tell it later

import board
import neopixel
from time import sleep

# An example program that cycles through Red, Green Blue and then stops

LEDcount = 8

pixels = neopixel.NeoPixel(board.D18, LEDcount)

#red
pixels.fill((100,0,0))
sleep(1)
#green
pixels.fill((0,100,0))
sleep(1)
#blue
pixels.fill((0,0,100))
sleep(1)

# and off
pixels.fill((0,0,0))

If you’re powering the LEDs from the Raspberry Pi, do not turn on too many. 8 is likely to already be too many as they use up to 60mA each (480mA for 8!). To limit power in these examples, I’m not using full brightness (full brightness would be 255 in the first test program), limiting the number of lit LEDs or am only using single colours at a time. For white light and full brightness on all LEDs – use an external power supply if you have more than one or two LEDs! Thanks to PJRC for actually doing some measurements.

Once entered and saved, from a terminal in the saved file’s directory, enter the following command to give the file permission to execute as a script :

chmod +x test.py

“chmod” is the permissions modification command and I think of “+x” as meaning “plus executable”.

We’re ready to run our test – with everything connected, run the following command in the terminal :

sudo ./test.py

If everything goes to play, you should see the connected LEDs cycle through red, green and then blue before turning off. If you see any errors, google is your friend. Also check that you ran the command while in the correct directory, and have permission to use the sudo command.

Make a “Clear” Script

A useful script to make at this stage is a “clear” script. This means if you interrupt another script, or create a script that leaves LEDs on and you want to get rid of them, you can run this script to tidy them up.

#!/usr/bin/env python3
import board
import neopixel
import sys

# assume a default 200 LEDs
LEDcount = 200

# handle parameter
if len(sys.argv) == 2:
   # we have been passed a parameter - this should be the number of LEDs
   LEDcount = int(sys.argv[1])

pixels = neopixel.NeoPixel(board.D18, LEDcount)

pixels.fill((0,0,0))

As before, enter the program, save it with a name (clear.py for example) and then make it executable with chmod. This script can be used in two ways – if you note the “handle parameter” if statement, by using a parameter when running the script you can define how many LEDs you would like to clear. If no parameter is passed, the script assumes you have 200. Note that overestimating is not an issue and so 200 will clear any number up to 200. If you have 201 LEDs, the final LED will remain lit!

To use the script you’ve just entered, type the following at the command line :

sudo ./clear.py

This will clear the first 200 LEDs. To clear a specific number of LEDs (for example, the first 4 of 8 LEDs or if you have 450 LEDs) :

sudo ./clear.py 4

sudo ./clear.py 450

Preparing an Image

Creating the Image we’ll be using to control the LEDs is fairly trivial if you are familiar with any painting programs. The more basic the painting software, the easier the creation! The only complication is making sure that you save in the correct file format (24bit bitmap “.bmp”). Note that I previously tested JPEGs, but found that the compression made black not equal 0,0,0 and so LEDs would be visibly lit when I didn’t want them to be.

For demonstration, I will use GIMP to create my image, because it is free.

  1. Launch GIMP and create a new image. Make it as wide in pixels as the number of LEDs you have connected (8 for me) and as tall as the number of frames you’d like (16 for me).

    New Image
    New Image
  2. Fill the drawing area with black (RGB 0,0,0).
  3. Zoom in lots until you can see individual pixels and start painting. The pencil (individual pixel drawing tool) is probably best for this. In GIMP you need to change the pencil “brush” size to 1px.
  4. Draw your picture.
  5. Once done, to save in GIMP, select “Export As…” from the File menu.

    Export Image
    Export Image
  6. Name your file, pick a location, select “Windows BMP image” and click Export.

    Export Options
    Export Options
  7. Select “24 bits” under “Advanced Options” in the Export Image as BMP dialog box which appears.

I have created the following example.bmp. Note the red graduated tint in the lower half (only between 0 and 80 out of 255 intensity, which will be significantly more pronounced on the LEDs). I have uploaded the actual file (the following image has been enlarged to make it visible) and it can be downloaded from here (right click and select “Save link as…”).

Example
Example

If you have drawn your image on a computer other than your Raspberry Pi, you will want to transfer it. There are many ways of doing this, from USB drive, to email. I will be using a built in tool command line tool available on Linux and MacOS called “scp”.

In this example, we are assuming that the image file is saved in a folder called “To Transfer” in your home folder on your desktop / laptop computer (aka “the source”), is called “example.bmp”, that the network name of your Raspberry Pi (aka “the destination”) is still the default “raspberrypi”, the username is the default “pi” and that you want to move it to a folder in the Raspberry Pi home directory called “RGBLED”.

If you haven’t consciously changed the network name from “raspberrypi”, it will still be this – if you have, use the new name instead. You can see the network name in the command prompt on the Raspberry Pi if I remember correctly, it is the bit after the “@” symbol).

To move the file from the source to the destination, enter the following command on the source computer :

scp ~/To\ Transfer/example.bmp pi@raspberrypi.local:~/RGBLED/

You will be asked for your Raspberry Pi password (the destination, not the source) and you should almost instantly see the file as 100% sent and the source returns to the standard command prompt. If there is an error, make sure that the source and destination are on the same network, the file paths are correct at both ends, the password is correct and that you haven’t made any typos. If you still have issues, there are websites available that will take you through the process more carefully if you search for “transferring files to a Raspberry Pi using scp”.

In later examples, I will assume that the file “example.bmp” is located in the ~/RGBLED/ (also known as /home/pi/RGBLED/) folder on the Raspberry Pi.

The pictureLED.py Script

The main python script which converts an image into a sequence of LED based patterns is as follows, or can be downloaded directly from here (right click and select “Save link as…”). I recommend placing it in the same “~/RGBLED/” folder on the Raspberry Pi.

#!/usr/bin/env python3

#******************
#* pictureLED.py - convert bmp image into LED sequence Code
#* https://elephantandchicken.co.uk/stuffandnonsense
#* Circuit :
#* Connect LED ground to both the Raspberry Pi ground and the LED power supply's ground
#* Connect LED data to pin D18 (if not boosting with MOSFET)
#* Connect LED +V to the LED power supply 5V
#* Do not mix 5V and 3.3V!
#* If in doubt, details of the circuit and software for controlling from linux
#* are on my website
#*******************

import sys
import board
import neopixel
from PIL import Image

from time import sleep

# Edit the following line if red and green are backwards
switchColours = false

def fixColour(colour, scale):
    # correct the colour order for my ws2812 LEDs and scale the brightness
    # correct colour by switching [0], [1] and [2]
    theResult = (int(scale*colour[0]/100),int(scale*colour[1]/100),int(scale*colour[2]/100))
    if switchColours:
        theResult = (int(scale*colour[1]/100),int(scale*colour[0]/100),int(scale*colour[2]/100))
    return theResult

if len(sys.argv) == 1:
    print("You've not provided any details! Command format is <required> [option] : sudo pictureLED.py <image.bmp> [fps] [loop? 1/0] [loopdelay] [brightness percent] [pin]")
    quit()
if len(sys.argv) > 1:
    # identify image file - use defaults for all other parameters
    imgfile = sys.argv[1]
    fps = 15.0
    maxIntensity = 100
    pin = 18
    loopimg = True
    loopDelay = 0
if len(sys.argv) > 2:
    # set the fps
    fps = float(sys.argv[2])
if len(sys.argv) > 3:
    # loop?
    if sys.argv[3] == "1":
        loopimg = True
    else:
        loopimg = False
if len(sys.argv) > 4:
    # loop delay (at the end of each cycle)
    loopDelay = float(sys.argv[4])
if len(sys.argv) > 5:
    # set a maximum LED intensity and scale all values
    maxIntensity = int(sys.argv[5])
if len(sys.argv) > 6:
    # parameter 5 - set pin for neopixel data
    pin = sys.argv[6]
im = Image.open(imgfile)
pix = im.load()
pixw = im.size[0]
pixh = im.size[1]
print("Image size = ",im.size)
PIXEL_NUM = pixw

if pin==18:
    pinName = board.D18
    # add other options here
else:
    pinName = board.D18

pixels = neopixel.NeoPixel(pinName, PIXEL_NUM, auto_write=False) # pin, number of LEDs

stat = True

while(stat):
    for y in range(0, pixh):
        for x in range(0, pixw):
            pixels[x] = fixColor([pix[x,y][1],pix[x,y][0],pix[x,y][2]],maxIntensity)
        pixels.show()
        sleep(1/(fps))
    sleep(loopDelay)
    stat = loopimg
pixels.fill((0,0,0))
pixels.show()

The program is not actually that large, with the majority of it being a clumsy decoding of the various parameters sent by the user when they execute the command. For a fixed application, much of this could be removed to produce something like the following (untested) code :

#!/usr/bin/env python3

#******************
#* fixedPictureLED.py - convert bmp image into LED sequence Code
#* https://elephantandchicken.co.uk/stuffandnonsense
#* Circuit :
#* Connect LED ground to both the Raspberry Pi ground and the LED power supply's ground
#* Connect LED data to pin D18 (if not boosting with MOSFET)
#* Connect LED +V to the LED power supply 5V
#* Do not mix 5V and 3.3V!
#* If in doubt, details of the circuit and software for controlling from linux
#* are on my website
#*******************

import sys
import board
import neopixel
from PIL import Image

from time import sleep

#################
# Define the image path here!
imgfile = "/path/to/the/image.bmp"
#################

# Other parameters :
fps = 15.0
maxIntensity = 100
pin = 18
loopimg = True
loopDelay = 0
switchColours = false

def fixColour(colour, scale):
    # correct the colour order for my ws2812 LEDs and scale the brightness
    # correct colour by switching [0], [1] and [2]
    theResult = (int(scale*colour[0]/100),int(scale*colour[1]/100),int(scale*colour[2]/100)) 
    if switchColours:
        theResult = (int(scale*colour[0]/100),int(scale*colour[1]/100),int(scale*colour[2]/100)) 
    return theResult

im = Image.open(imgfile)
pix = im.load()
pixw = im.size[0]
pixh = im.size[1]
print("Image size = ",im.size)
PIXEL_NUM = pixw

if pin==18:
    pinName = board.D18
    # add other options here
else:
    pinName = board.D18

pixels = neopixel.NeoPixel(pinName, PIXEL_NUM, auto_write=False) # pin, number of LEDs

stat = True

while(stat):
    for y in range(0, pixh):
        for x in range(0, pixw):
            pixels[x] = fixColor([pix[x,y][1],pix[x,y][0],pix[x,y][2]],maxIntensity)
        pixels.show()
        sleep(1/(fps))
    sleep(loopDelay)
    stat = loopimg
pixels.fill((0,0,0))
pixels.show()

Note the “auto_write=False” parameter passed when generating the “pixels” object. This changes the library’s behaviour so that it only sends updates to the LEDs once the function “pixels.show()” is called.

As before, once you have pictureLED.py saved (pictureLED.py specifically as fixedPictureLED.py is just an example application) on the Raspberry Pi, run the chmod command to allow the file to be executed.

Running the pictureLED.py Script

To run pictureLED.py, open a terminal with the current directory matching the location of both pictureLED.py and example.bmp. The command format is as follows :

sudo ./pictureLED.py <filename.bmp> [fps] [loop? 1/0] [loopdelay] [brightness percent] [pin]

Parameters between “<>” are required and parameters between “[]” are optional, although all preceding optional parameters are required. These are two example commands :

sudo ./pictureLED.py example.bmp

sudo ./pictureLED.py example.bmp 2 0 0 50

The first example uses all default settings (15 fps, loop, zero second loop delay (time before the pattern repeats), 0% brightness reduction and the default pin) on the image “example.bmp”. The pattern will repeat indefinitely – to stop it, press Ctrl-c.

The second example passes a number of parameters, more specifically describing how we want the program to run. In this example, we run at 2 fps, not looping, zero second loop delay, at 50% of full brightness. Note we do not specify the pin – this is because pin D18 is currently the only available pin in my code.

Running at Boot

A simple way to make your LED sequence run when the Raspberry Pi powers on (note, powers on, not when you log in) is to edit the operating system file “/etc/rc.local”. Absolute care should be taken, as if you make a mistake, the Raspberry Pi will not finish booting. Assuming that the absolute path (from the root level of the disk, not from the Home (aka “~”) folder) for pictureLED.py is “/home/pi/RGBLED/pictureLED.py”, enter the following command in a terminal :

sudo nano /etc/rc.local

A file will open in the text editor nano. Use the arrow keys to scroll to the bottom of the file. The last line should be “exit 0”. Above this enter the following new text :

/home/pi/RGBLED/pictureLED.py /home/pi/RGBLED/example.bmp 2 &

There are a number of things to note here.

  • All paths have to be absolute as this means that they are always correct, no matter where the current directory is when the script executes.
  • “sudo” is not needed because the parent script is already executing with root permissions.
  • As shown it is best (don’t not do it) to include a ” &” (that is a space followed by an ampersand). This tells the computer to run the script as a background process and means that if your script throws and error, or stops to wait for user input, the computer does not hang but keeps booting. It also means that the computer doesn’t wait for your script to finish before continuing (imagine we used the default “loop” option).
External Power Supply

To drive more LEDs at full brightness, connect the LEDs to an external 5v power supply, remembering to connect the ground between both the LED power supply and the Raspberry Pi (not doing so can damage the Raspberry Pi or LEDs as connecting the grounds holds both devices to the same reference point and avoids the voltages floating between the two supplies, potentially causing high voltages where there shouldn’t be). The following shows a suggested wiring :

External Power LED Wiring
External Power LED Wiring

Do not connect the 5V to the Raspberry Pi (unless you know what you are doing, and it is the only power supply to the Raspberry Pi (i.e. there is no micro USB connected)).

Remember that each LED could draw up to 60mA and ensure that your selected LED power supply is able to provide sufficient current to power all of your LEDs. Given that the supply is at 5v, this means you are looking for 5×0.06 = 0.3 watts per LED. For example, if you had 100 LEDs, you would need a power supply rated to at least 30 watts at 5v.

5v Data Signal

Another potential issue which has not impacted me, is due to the Raspberry Pi sending data to the LEDs at 3.3v. I believe that the minimum voltage required by the LEDs when running at 5v is 3v and 3.3v does not give much overhead. This is likely to result in issues when longer wires result in a further reduction in voltage, potentially causing intermittent issues or complete failure to operate.

This can be solved by voltage shifting the Raspberry Pi output using a MOSFET. The following method is actually a way of getting bi-directional level shifting (very useful for I2C), but is simple enough that we might as well use it here. I’ve based the circuit on the one here : http://www.hobbytronics.co.uk/mosfet-voltage-level-converter, please double check that my implementation is correct! Note the strip board could be smaller in reality (you could manage a 4×3!), but I couldn’t get Fritzing to show stood up resistors.

MOSFET LED Wiring
MOSFET LED Wiring

Note that the orange wire is connected to a 3.3v pin on the Raspberry Pi. As labelled, the pinout for the 2N7000 MOSFET is Source, Gate, Drain from left to right, with the flat on the package facing you.

Future Project

The next thing I’d like to do is write a program to allow me to draw animations for an n by n LED matrix and save them in this image format. It shouldn’t be too difficult and would be fun and interesting. I don’t have a significant sized LED matrix though – I think the biggest I have is only an 8×8.

Custom Hard Disk Cooling

Update : I’ve realised that changing the pull up resistor to a pull down resistor means that the circuit will react better if the fan 12V is applied before the Arduino power supply is activated. As such, I’ve moved my resistor so that it is now between the gate pin and ground.

Having a bit of spare time, I decided to transfer a load of files from my previous computer (a Mac) to my new computer (Linux). As I had a lot of data from a number of hard disks to move, I decided the best method would be using an external eSATA drive. Thankfully, due to a stalled home build SAS project I started a few years ago, I have a couple to hand. The are branded as SUUNION SUBE3ZW enclosures, the “E” being optional and meaning they support eSATA. Helpfully they come with an internal SATA to eSATA expansion slot adapter and my motherboard has a feature in the BIOS that lets you turn hot swapping on for a SATA port. Without the latter, I’d have needed to power down my computer every time I wanted to switch in a different disk. These SUUNION enclosures have almost no internet presence, but were really cheap about 7 or 8 years ago when I bought them.

SUBE3ZW Enclosure
SUBE3ZW Enclosure

While transferring data, I noticed in the S.M.A.R.T data for the drive that it was getting quite warm and had reached 50°C. I looked up the temperature ratings for the drive in question (a 1.5TB Seagate Barracuda) and discovered they were rated to a maximum temperature of 50°C… oh dear. It seems that it isn’t great to run some (7200rpm) disks in fan-less enclosures.

Initially I grabbed my home made soldering air filter (120mm computer fan, connected through an inline power switch to a 12v wall-wart) without the filter to cool it down, but since I had time to kill waiting for the file transfers and the fan was quite loud considering my girlfriend was trying to work in the same room, I started looking into adding some speed control.

The Circuit

Parts used:

  • 1x Arduino Pro Mini (anything similar will do, including an UNO – note that devices with different main chips will have different PWM requirements and the code will not work without changes).
  • USB to UART board, like this one.
  • 1x 2N7000 MOSFET – an excellent all-rounder MOSFET.
  • 1x 1N4007 Diode – massively over-specified, but I have a bag of them and they cost very little. Any similar diode will do (I suspect anything from the 1N400x range is fine).
  • 1x 10k Resistor (0.5W).
  • Breadboard – the circuit could be built on protoboard for a more permanent solution.
  • Some wire, suitable for use in breadboard.
Breadboard Layout of Fan Controller
Breadboard Layout of Fan Controller

Three things to take care of :

  • Do not connect the 12V supply to the microcontroller – this will damage it, and potentially any connected computer.
  • Note the pins on the left of the Arduino are reversed on some clones – my device has the pins in the opposite order to those shown in the drawing above. If your Arduino matches the drawing above and you are using the same USB adapter as I am, your adapter should be face down into the table. If your Arduino and USB adapter are both identical to mine, the adapter should be face up.
  • Ensure the USB adapter power jumper is set to 5V if you are using a 5V arduino. The circuit should also work with a 3.3V Arduino (the minimum gate trigger voltage for the 2N7000 MOSFET is 3V), although ensure all voltages are correct.

The following is a rough schematic – all ground points need to be linked (as per the breadboard layout).

Fan PWM Controller Schematic
Fan PWM Controller Schematic

The 10k resistor (R1) is a pull down resistor and is needed to control the input to the MOSFET – without this the MOSFET might get very hot and fail if the Arduino is removed or if the connected pin is set as an input. The resistor is best located as close to the MOSFET gate pin as possible. The Diode (D2) (yes, the numbering system is wonky) dissipates the back EMF generated by the fan when the supply is cut. The MOSFET (Q3) switches the 12V supply (on the low side of the fan) based on the PWM signal from pin 10 on the Arduino, while effectively isolating the Arduino from the 12V and preventing damage. On its own, the Arduino would not be able to provide 12V, and can not provide the 180mA required by the fan I am using. The 2N7000 MOSFET is actually able to switch voltages up to 60V, although that is more than my fan can handle.

Once I had programmed the Arduino, I disconnected the DTR pin from the USB adapter to prevent my computer from resetting the Arduino constantly – this will not be possible so easily if you’re using an Arduino Uno. The trick I like to use to do this with an Arduino Pro Mini is as follows :

  1. When soldering headers onto the Arduino Pro Mini, replace the supplied male header with a female header – this makes programming the boards much easier as the USB adapter has male headers.
  2. Once programming is completed and you do not want the board to reset when a serial connection is established, place a 5 pin female header with extended pins (wire wrap / pass through style) between the Arduino and the USB adapter, taking care that the missing pin is the DTR (“GRN”) pin.

Note that it is still possible to program the board in this condition if you carefully press the reset button at the exact right moment. Also note that I actually use a 6 pin header with one pin trimmed off (because it reminds me what the header is for when I see it lying on my desk and I don’t throw it away).

Disable Reset
Disable Reset
The Fan

I’ve used a small 40x40x20mm fan that I had in a cupboard. Other fans will be fine, although note that at these high PWM frequencies, I did have some difficulty with one cheaper fan where it was struggling to start.

To help with this, I’ve modified my Arduino software to give the fan a short burst of maximum speed (a fraction of a second) whenever the fan speed changes to help ensure the fan is running – while there is still a PWM duty below which the fan will not run, I have found that this specific fan generally runs down to as low as 50/255 (~20%) of full. Note the PWM duty does not correspond to fan speed – most of the variation available is between 200 and 255 (~80% to ~100%).

It is recommended that you put a switch in the 12V supply so that it is possible to turn off the fan easily. An improvement might be to take the pull up supply from the 12V using a regulator to generate 5V (for the whole circuit). Care should be taken to not back feed the computer’s USB with a slightly different 5V supply. There is a built in regulator on the Arduino Pro Mini which could be used for this, where 12V could be provided to the “RAW” pin, although 12V is the maximum recommended voltage for this regulator and it might get warm with sustained use. This would be a good option if you were also measuring temperatures using the Arduino in another application.

The Stand

A quick and easy way to make an adjustable stand for a fan is to use a piece of stiff wire, bent into shape through two of the fan’s screw holes. I’ve used a piece of stainless steel welding wire as can be seen in the following photograph.

Fan and Stand
Fan and Stand

The wire is in one piece and crosses the front of the fan between the two screw holes (on the other side of the fan from the camera). Pliers will make bending the wire easier.

Arduino Software
/******************
 * Arduino High Frequency PWM Fan Controller Code
 * https://elephantandchicken.co.uk/stuffandnonsense
 * Circuit :
 * Connect the gate of a 2N7000 to arduino pin 10.
 * Connect a 10k Resistor between the gate and 5V.
 * Connect the source pin to ground.
 * Connect a 1N4007 diode between the drain pin and 12V with the band facing 12V.
 * Connect a fan between the 12V rail and the drain pin.
 * Remember to connect the grounds for the 12V and 5V circuits!
 * Do not mix 12V and 5V!
 * If in doubt, details of the circuit and software for controlling from linux
 * are on my website
 *******************/

int fan = 10; // set fan output pin
int fanspeed = 255; // start full speed
// for SerialEvent
String inputString = "";
boolean stringComplete = false;

void setup() {
  // set the fan pin as an output
  pinMode(fan, OUTPUT);
  
  // adjust the PWM speed to make it fast enough that you can't hear it
  // see : https://playground.arduino.cc/Main/TimerPWMCheatsheet
  TCCR1B = (TCCR1B & 0b11111000) | 1; // hard coded for pins 9 or 10
  
  // Start serial - doesn't need to be very fast
  Serial.begin(9600);
  Serial.println("\nPWM Fan Controller Started");

  // For safety, as we don't know what is happening yet, turn the fan onto full
  digitalWrite(fan, HIGH);
}

void loop() {
  if(stringComplete){ // We've recieved a message
    // reset the serial variables ready for the next message
    String msg = inputString; // store locally so serialEvent can get back to capturing input
    inputString = "";
    stringComplete = false;
    
    if(msg.startsWith("s")){ // "set" command
      if(fanspeed!=msg.substring(1).toInt()){ // if the requested speed is different...
        fanspeed = msg.substring(1).toInt(); // update the fan speed
        digitalWrite(fan, 1); // these two lines help with a stalled fan...
        delay(50); //             by ramping the speed to full with no PWM for a short period
        analogWrite(fan, fanspeed); // set the new PWM duty
      }
      Serial.println(fanspeed); // confirm the new speed to the host
    }else if(msg.startsWith("c")){ // "check" command
      Serial.println(fanspeed); // the host has asked what PWM duty we're currently using
    }
  }
}

void serialEvent() {
  // Standard serial code from "SerialEvent" example
  while (Serial.available()) {
    // get the new byte:
    char inChar = (char)Serial.read();
    // add it to the inputString:
    inputString += inChar;
    // if the incoming character is a newline, set a flag
    // so the main loop can do something about it:
    if (inChar == '\n' || inChar == '\r') {
      stringComplete = true;
    }
  }
}
Linux Software

Due to the limitations of my knowledge in various languages, I’ve used a mixture of bash and python as well as an existing piece of software to achieve what I wanted. My objectives were to continually monitor the external hard disk temperature, and set the fan speed based on this temperature. First things first, I used the “Disks” utility on Ubuntu to identify the external disks location – for exampe : /dev/sdc

The path “/dev/sd*” (where * is a letter of the alphabet) should exist for each connected disk, the similar files with a number on the end as well represent individual partitions and we are not concerned with these at the moment.

Next identify the serial port – with the adapter connected, type “ls /dev/ttyUSB*” at the command line. If you’re lucky, you will get a single result called “/dev/ttyUSB0” or similar. Note this can and does change, especially when you’re plugging and unplugging USB adapters, so you might need to re-check. If the previous command doesn’t return anything, try “ls /dev/tty*”. This will return a long list – ignore all entries which are just “/dev/tty#”, “/dev/ttys#” or “/dev/ttyprintk” where “#” is a number. Look at what else is there… unplug the adapter and run the command again. What is different? Plug the adapter back in again – does it re-appear? Whatever appears and disappears when you plug and unplug the adapter is the serial port you are looking for. As mentioned, the number might change as you are messing around.

Make a note of the disk path and serial port path. Mine are “/dev/sdc” and “/dev/ttyUSB0”.

Software which might need installing :

  • watch (sudo apt-get install watch) – allows you to run a command every n seconds
  • hddtemp (sudo apt-get install hddtemp) – reports the identified disk’s temperature
  • python (sudo apt-get install python) – programming language, probably already installed
  • pyserial (pip install pyserial) – a python library to work with serial ports

Create a folder and move into it (to do this in the command line, use mkdir HDDTemperature && cd ./HDDTemperature to create a folder called HDDTemperature at your current location). Create three plain text files called hddFanControl.sh, hddFanControl.py and run.sh using your favourite text editor. Enter the following scripts into each respectively :

# Script to check a hard disks temperature, and send a fan speed over serial
# Usage :
# run.sh /dev/sd* /dev/ttyUSB*
# Ensure run.sh, hddFanControl.sh and hddFanControl.py are all in the current
# active directory.
# Note you need permission to access both the hdd and the serial port
# Note you'll need to disable reset on the MCU
# Serial fan controller should accept speed settings in the format s[0-255]
# where [0-255] is the pwm value between 0 and 255. Note most speed control
# is between 200 and 255, and often a fan needs 255 to start (due to the high
# pwm frequency (I'm using 31khz I think - there aren't many options on an 8
# bit AVR, just 60khz and 30khz which don't create noise. 60khz struggled to
# start the motor. AVR code I used to set the pwm speed was :
# TCCR1B = (TCCR1B & 0b11111000) | 1;
# and I'm using pin 10 (this command would also work for pin 9)
# be warned that messing with these pwm settings impacts other timing commands
# 
# https://elephantandchicken.co.uk/stuffandnonsense
# 12/12/2018

# hdd location
VARHDD="$1"
# serial port
VARSER="$2"

TEMPERATURE=`hddtemp $VARHDD | grep -oh "..°C" | grep -ohE "[0-9]{1,4}"`
echo "Selected disk temperature is $TEMPERATURE°C"

python ./hddFanControl.py $VARSER $TEMPERATURE
# Script to check a hard disks temperature, and send a fan speed over serial
# Usage :
# run.sh /dev/sd* /dev/ttyUSB*
# Ensure run.sh, hddFanControl.sh and hddFanControl.py are all in the current
# active directory.
# Note you need permission to access both the hdd and the serial port
# Note you'll need to disable reset on the MCU
# Serial fan controller should accept speed settings in the format s[0-255]
# where [0-255] is the pwm value between 0 and 255. Note most speed control
# is between 200 and 255, and often a fan needs 255 to start (due to the high
# pwm frequency (I'm using 31khz I think - there aren't many options on an 8
# bit AVR, just 60khz and 30khz which don't create noise. 60khz struggled to
# start the motor. AVR code I used to set the pwm speed was :
# TCCR1B = (TCCR1B & 0b11111000) | 1;
# and I'm using pin 10 (this command would also work for pin 9)
# be warned that messing with these pwm settings impacts other timing commands
# 
# https://elephantandchicken.co.uk/stuffandnonsense
# 12/12/2018

import time
import serial
import sys
from math import log

# How aggressive to make the fan curve? 0.4 is normal, 0.1 ramps up quickly
# at the start then levels off, 1 is linear (pwm duty, not cooling), 2 mainly
# ramps at the top end (would be basically off until the temp was at the top)
fanCurve = 0.4 

Temperature = int(sys.argv[2])
maxTemp = 50
minTemp = 30
tempRange = maxTemp-minTemp

tempLift = Temperature-minTemp

if tempLift <= 0:
	pwmDuty = 0
elif tempLift >= tempRange:
	pwmDuty = 255
	print "\nWarning! Hard Disk is hot!"
	print "Disk is currently : " + str(Temperature) + " degrees C"
else:
	var1 = (tempLift**fanCurve)
	var2 = (tempRange**fanCurve)
	pwmDuty = int(255*var1/var2)
	print "\nCurrent Temperature : " + str(Temperature)
	print "Current Target PWM setting : " + str(pwmDuty)

ser = serial.Serial(
	port=str(sys.argv[1]),
	baudrate=9600,
	parity=serial.PARITY_NONE,
	stopbits=serial.STOPBITS_ONE,
	bytesize=serial.EIGHTBITS
)

ser.isOpen()

#flush with an empty line
ser.write('\n')
time.sleep(0.1)
ser.flushInput()

# moved the following into the mcu
#if pwmDuty > 0:
#	# make sure we are not stalled
#	ser.write('s255\n')
#	time.sleep(0.1)

#check current setting
ser.write('c\n')
time.sleep(0.1)
out = ''
while ser.inWaiting() > 0:
        out += ser.read(1)
if out != '':
        print "\nMCU previous PWM was : " + str(int(out))

#send pwm setting
ser.write('s' + str(pwmDuty) + '\n')

# pause and see if there was a response
time.sleep(0.1)
out = ''
while ser.inWaiting() > 0:
	out += ser.read(1)
if out != '':
	print "MCU current PWM is : " + str(int(out))

ser.close()
exit()
# See comment in hddFanControl
# https://elephantandchicken.co.uk/stuffandnonsense
# 12/12/2018

watch -n60 ./hddFanControl.sh $1 $2

Back at the command line, enter the following three commands to convert the files into runnable software :

chmod +x ./hddFanControl.sh

chmod +x ./hddFanControl.py

chmod +x ./run.sh

To run the software, ensure that the Arduino is powered and connected to the computer through the USB adapter, then apply power to the fan. The fan should start at maximum speed.

You’ll need the disk and serial port paths from above to run the software, once you have them and have checked they are correct, enter the following (but using your hard disk and serial port) :

sudo run.sh /dev/sdc /dev/ttyUSB0

If everything works correctly, you should see something like the following, which will update every 60 seconds :

Screenshot
Screenshot

To stop the program, press Ctrl-C. Once you’re finished with the fan, power down the 12V fan supply and the Arduino.

Changing Parameters

To change the temperature update frequency, you will need to edit the file run.sh. The interval between measurements is represented by the 60 in “-n60” and can be replaced by other durations in seconds. To change the interval to be two minutes, run.sh would need to be changed to :

# See comment in hddFanControl
# https://elephantandchicken.co.uk/stuffandnonsense
# 12/12/2018

watch -n120 ./hddFanControl.sh $1 $2

All other parameters which can easily be modified are contained within the python file hddFanControl.py and allow the “normal” temperature range, and temperature/PWM response curve to be modified. Normal temperatures are defined by the variables maxTemp and minTemp (set to 50 and 30 respectively in the provided code). When the measured temperature is outside of this range, the fan duty will be pegged at either 0% (30°C or less) or 100% (50°C or more). These values can be modified the shift the operating temperature range if needed.

The temperature/PWM response curve is modelled as the temperature above the minTemp raised to a power (a variable called fanCurve). By adjusting the power, the characteristic of the curve can be adjusted. By default I am using a power of 0.4, which works well for my use case. The following plot shows the impact of varying the value of fanCurve.

PWM Plot
PWM Plot

Adjusting the value allows the user to modify how early or late the % duty increases. Note that this only relates to the PWM duty and not fan speed. I have generally found that most of the variation in fan speed is observed in the last 25% of PWM duty.

Note that modifying the fanCurve variable is the best way of adjusting the balancing temperature of a specific state. Using 0.4, I was able to achieve a stable 37°C disk temperature, which was considered satisfactory, and comparable to the temperatures of my internal hard disks.

Conclusions

If you’re wondering, the file copy still hasn’t finished. (Update : it finally has).