Project showcase

BOFF - Alexa Enabled Open Smart Fan © CC BY-SA

BOFF is a smart fan, controllable by voice with Alexa Smart Home skills and featuring visualisation of environmental conditions.

  • 7,472 views
  • 6 comments
  • 26 respects

Components and supplies

Clear Acrylic 600x400 5mm
Use this or the laser ply.
×3
Laser Ply 600x400 6mm
Use this or the 5mm acrylic. Note if you use this the laser cutting files need to be re-generated for 6mm thickness as they are for 5mm acrylic.
×3
LL120 120mm PC Fan
×4
BME680 breakout board
×1
Clear Acrylic sheet 3mm
For the cover plates. You need 4 plates at about 140mm each.
×1
Abx00004 iso both
Arduino MKR1000
×1
M4 Machine Screw
×16
M3 Machine Screw
×32
M4 Heatfit insert
×16
M3 Heatfit insert
×16
PLA Filament
×1
Arduino Fan Controller PCB
See the PCB Schematic / BOM for parts.
×1
12V DC Power Supply
×1
Neopixel Side Light Strip (120 LEDs)
2x 1M lengths of 120 LEDs per meter stip. Optional!
×2

Necessary tools and machines

Lasercutter
Laser cutter (generic)
3drag
3D Printer (generic)
09507 01
Soldering iron (generic)

Apps and online services

About this project

Alexa, Turn On The Fans!

Alexa, Turn on the Office Fans!

Introduction:

BOFF is an Internet connected Smart Fan. It's Alexa enabled through the Alexa Smart Home skill and provides interesting visualisation of environmental conditions (or just gentle mood lighting if you prefer!) along with a nice cooling breeze of air.

Aim:

The aim of this project was to build an open source, Internet connected Smart Fan, that could be controlled remotely and would sense environmental conditions allowing it to report back issues (feel sleepy in meetings? Maybe the CO2 level is high).

Importantly it is designed to be hacker friendly, an open design that is relatively easy to build and modify. Don't like the enclosure, you'd prefer 2x2 arrangement? or just 2 fans? Sure, adjust the case and you're away. Want to add carbon or other filters, sure, hack the outlets to add a holder in.

Many people like a gentle breeze when getting to sleep, or when waking up, likewise nightlights are also very popular. However you may prefer to turn them off before falling asleep, and nobody wants to get out of bed to turn a fan off, by enabling Alexa integration our comfy sleeper can ask Alexa to turn off the fans and not have to get out of bed.

BOFF has been designed around commodity PC fans. If you are into building your own PC you've probably got a few kicking around, I had a box full, as does my local maker space! These run from 12V DC so no dangerous mains voltages kicking around.

Whilst I had originally intended to use some of the many fans, shortly after starting this project I discovered the LL120 by Corsair. Not only are these nice and quiet (practically silent on low speed) but they also feature 16 RGB LEDs, 12 around the outside and 4 in the central hub. Yes, 16 individually controllable RBG color changing LEDs on a fan and for bonus points they appear to be standard WS2812 Neopixels so easily connect to an Arduino with driver libraries already available. I couldn't resist!

Enclosure

The enclosure is a fairly simple laser cut design. I've built two versions, a maker style clear acrylic version (lets you see the insides!) for my office and a more home friendly version from ply wood which blends in nicely with my book case in the living room.

The box was designed using MakerCase, the attached json file can be uploaded and modified as desired, I used T-Slot settings, but sadly the default 10mm machine screw length combined with 5mm acrylic resulted in the nut not being retained, so I reverted back to Tensol 12 to glue the acrylic together and wood glue for the ply.

I didn't glue the back panel of the acrylic enclosure, but left it in place whilst the glue set for the front panel glue to keep the shape as it's a really tight fit.

Finger guards are also made from laser cut acrylic (3mm) with laser engraved patterns on to indicate the fan visualisation function.

The Internet of Fans

The fans are controlled by an Arduino MKR1000, this connects to WiFi then to the Tinamous.com MQTT server, it stays connected whilst powered on, and subscribes to the status message topic for messages sent to the device. This allows Alexa, and other users in my Tinamous account to post a status message (like a Tweet) which can then be processed by the Arduino.

Likewise the Arduino can publish measurements via the MQTT channel for environmental conditions, status, fan speeds etc.

Alexa integration is achieved with a Smart Home skill that connects to Tinamous via the REST API, sending messages to the fan.

The skill uses the standard Smart Home interface so no custom utterances are used (although adding these would allow the functionality to be extended, such as, setting the display visualisation for each fan, or controlling fan speed and led brightness at the same time), setting a wake up time for the fans to start.

Once the skill is enabled the user is prompted to do a Smart Home discovery, here it queries your Tinamous account for devices tagged with Alexa.SmartDevice. Each device that has that tag is then set-up as a Smart Home device.

The devices are named by using the devices Display Name, allowing more friendly names than you might have used for a username, this also makes it easy to change the name should you need to (Protip: Don't call your fan, "Wooden fan", even if it's in a wooden box, Alexa will insist you are saying "Wooden fence", which is no doubt more popular than a wooden fan, but trying to set the brightness on a wooden fence is not so easy).

Additional tags are used on the device to indicate display categories (Fans, Lights, etc) and capabilities. Where the skill sees a device with a known smart home interface (i.e. Alexa.PowerController) it will add that capability to the device.

Setting Alexa.TemperatureSensor enables the device to act as a temperature sensor, it also needs a field named, or tagged "Temperature".

After the device has been added, Alexa can then handle Smart Home utterances, such as "Alexa, Turn On The Fans", this will look for a device named "Fans", then send the "Turn On" instruction through a Alexa.PowerController interface.

When the skill receives a known directive (i.e. Turn On) it sends a status message to Tinamous directed at the specific device (e.g. @OfficeFans Turn On), the Arduino MQTT client will receive this and can then handle the turn on command.

However this style of communication means that should the device not be online, or have a fault it won't be reported back to Alexa.

The status messages appear to come from me as I authenticated Alexa as myself. I could have made a different member account at Tinamous specifically for Alexa. Likewise, any member or device that has permission to post a status message can control the fan through simple commands.

Sadly I've been unable to get Alexa to differentiate between Alexa.PowerLevelController and Alexa.BrightnessController commands, once the brightness controller is enabled, power level commands come in as set brightness, so either the fan speed or light level can be controlled via Alexa Smart Home, but not both.

The skill is written in C# for .Net Core 2 and can be downloaded from Github. Other than changing the json file for your Lambda settings it should be usable "out of the box" and deployed under your own skill to interface with any devices you may have connected in Tinamous which can subscribe to an MQTT feed.

Security:

Be sure to use the WiFiSSLClient library for the Arduino WiFi, you won't be able to connect on port 8883 for MQTT without it and you won't have a secure connection. If you are using 1883 for MQTT your connection is not secure!

You'll also need to add the Tinamous.com certificate to your Arduino to be able to connect securely.

Sensors

BOFF detects the sensors attached at startup and use these to gather environmental conditions. These include:

BME680 for temperature, humidity, pressure and air quality. I've used the Pimoroni breakout board for this and it's fitted onto the outside of the enclosure to prevent cooling from the fans. Where the Amazon Dot holder is used it's tucked away in the back of that (Although it does appear to suffer from heating from the dot, so a redesign is needed there!).

This VOC sensor needs about 30 minutes to provide meaningful readings after power on as you can see from the chart below. Fortunately the Arduino MKR1000 has battery support which will allow the sensor to remain powered whilst the fan is disconnected (and even continue to measure and sensor measurements).

BME280 for temperature, humidity, pressure. The BME680 is preferred but these can be hard to get hold of so a fallback connector for the 280 is provided.

CCS811 for air quality where the BME680 is not used, or for a second air quality measurement. These need temperature compensation so the BME280 should also be fitted.

TCS3472 for light level. This uses the ThingySticks Arduino Environmental cap, and this sits on top of the Arduino (assuming you've got the header version) and measures light level, which includes individual RGB levels as well. As this sensor is fitted on top of the Arduino it's not much use in the wooden box version. This could be used for brightness control of the LEDs, or to set BOFF to sleep mode when all the lights to out.

MMA8452Q accelerometer. This isn't actually enabled at this time, but it's fitted with the Environmental cap, it might prove interesting down the line for vibration sensing as an indication of fan problems, or perhaps detected a tap on the fan case to start the fans.

Fan tachometer. Each fan has a tachometer output, these pulses are counted by interrupts on the Arduino the speed (RPM) is then computed. I had problems with fan 4's tach into the SCK pin causing the Arduino to latch up when the fans were started so this has not been enabled (pin removed from connector). The PC fan specification is for the tachometer output to be an open collector which means we pull the signal up to a desired voltage with a weak pull up resistor (i.e. 3v3 though the Arduino INPUT_PULLUP) and each pulse will bring the level down to ground. This means the Arduino is not exposed to 12V (or even 5V) from this signal. However, not all fans observe this so check the fan before connecting to the Arduino!

Fan supply voltage is sensed through the potential divider made of R2 (43k) and R3 (10k) to bring the expected 12V down to a level the Arduino ADC can measure (pin A6), about 2.2V.

Dust sensing is not implemented but has space on the control board for the 3 pin PWM type sensor. This needs more investigation and other sensors need more pins which were not available on this version.

Switches - Two switch inputs are provided with LED drives as well for switches should this be desired.

Missing: No sensing is done on power consumption, this would have been a nice addition, particularly for the 5V supply that drives the LEDs as these can get very power hungry, likewise sensing on the 12V fan supply might help diagnose pending fan failures.

That's a lot of sensors! Not all of these are of interest to the end user so they are filtered out at Tinamous to include just the interesting fields for display with other fields being actionable but not shown all the time (i.e. fan supply voltage).

Visualisation

Each of the four fans can be configured to display a certain visualisation parameter. These include: Temperature, humidity, air quality, WiFi strength, time, fan speed, light level, fixed color or a funky pattern.

It goes to 11!

Whilst many visualisations only go to 10, BOFFs goes to 11! The fans have 12 pixels around the outside, the top (12 O'Clock) position is used as the central "Zero" point with the remaining 11 pixels showing the offset. Each visualisation has a pre-set range that these 11 pixels show, either side of the ideal value.

When the measured value is at the ideal value the whole fan face is set to green, but as the measurement moved away from ideal the pixels are lit based on a pre-defined range going from the 12 O'Clock position, either clockwise for above or anti-clockwise for below.

Whilst the measured value is within a ideal (comfortable) range, the pixels will be lit green, below range they will turn blue, and above range red.

Temperature Visualisation:

This display is configured that the ideal temperature is 22°C, with a min and max range of 2.5°C either side. So the 12 O'Clock led would be 22°C, then 11 LEDs to represent 2.5 (0.22°C each). The ideal range is set to +/- 1°C so a temperature in the 21-23°C will result in a green fan/nose, Below 21 and above 23 will be blue and red respectively and below 19.5°C and above 24.5°C will result in a full blue or red fan.

Below the first image shows the fan showing a temperature that is comfortable (green outer and green nose) but below the ideal temperature (the 12 O'Clock position with the display going anti-clockwise around to the 7 O'Clock point), this is about 21°C.

The second image in the set shows an over range (hot) indication, and the third image shows a green band from the ideal 12 O-Clock through to 4 O'Clock position indicating above ideal but still comfortable.

The other visualisations (Humidity, WiFi signal strength etc) work exactly the same way.

Clock Visualisation

Others visualisations worked out much simpler, The clock for example. 12 pixels around the outside worked out perfectly for a LED fan clock!

Color Cycling:

Alternatively the fans can be set to just show a funky pattern or fixed color with no real meaning.

What do you do when you've alredy got 64 LEDs and a clear acrylic case? Add more LEDs! Obviously!

A recent addition to the Neopixel strip range are the side light strips, these have been used with great effect on the fan.

There are two sets (mainly because they come in 1M lengths and the main outer strip needed slightly more than 1M so I had some left over).

Strip 1, as it's called in the software, is a vertical facing strip attached to the inside back wall, this is used to bounce light off the ceiling to provide general background mood lighting.

Strip 2 goes around the fans facing outwards, this is used to indicate interactions with Alexa, provide some animations (fan speed) and Alexa controllable mood lights.

These two strips add an extra 223 LEDs to our setup, the little 1A 7805 style buck regulator on the PCB is unable to cope with these at anything other than minimum levels (actually, as do my eyes, their very bright!). The PCB is designed such that Fan 1 LEDs get their power from the on-board regulator via a diode dropper (so we can get away with 3v3 logic), but fans 2-4 and strips 1 & 2 (fans 5 & 6 on the schematic) can get power either via a jumper for on-board or via a terminal block for an external 5V converter (these are plentiful on eBay for a few dollars).

With just the 4 fans (64 LEDS) and driven at 50% or lower the on-board regulator copes well, but anything more and the second regulator is required.

Air Flow

Ahh yes, well, moving along swiftly....

As you can see the fronts (air intake) of the fans are covered by finger guards which also provide indication to the fans display, this naturally restricts the air flow. These are about 20mm away from the fan so the air has to come from the edges.

Inside the box, well no structure is provided to guide the air but this should be a simple addition with some 3D printing.

Air outlets are in the top of the box. As you can see from the photos and videos I'm experimenting with different vents. The idea of an open design like this is you get to make what you want. Air flow straight up in the winter to try and push the hot air away from the ceiling, air flow as a small jet towards me in the summer for cooling? Air flow design has defiantly been left out of this fan! But never mind, have you seen the LEDs!

The 3D files with this in the project include various outlets, including two blanking plates which hold a Dot at an angle and one for the Echo with a cable outlet designed in.

Problems / Enhancements

Ohhh theirs a fair few things to list here....

Many PWM fans (the 4 pin variety) don't stop on the minimum PWM value, they run at the minimum speed, so we need additional power switching if you want them to actually switch off, this is done through Q3 and Q4 for all the fans, it would be nice to have that split between Fans 1 & 2 and 3 & 4 to allow a greater control (not for this project but other projects based on the fan controller).

Likewise Q4 is rated at about 2 Amps, if we run powerful fans it could easily be double that current, so a bigger fet is needed.

More capacitors! You may notice in the photos I've had to use a big electrolytic input capacitor, when the 4 fans kick in they can take a big chunk of the supply and cause the Arduino supply to drop enough to reset it.

Bigger capacitors for the Neopixels. I used to smaller pads on the PCB for C4 and C5.

Originally I used individual PWM signals for each fan, of the three use cases I've used the controller for now I've never needed this, a single PWM signal would have been enough, and this would free up a number of pins.

Fan tachometer input does weird things on the SCK pin for fan 4, I've tried various things but it really doesn't like that pin.

Fan PWM frequency should be about 25kHz, this is way above the PWM frequency of the Arduino and it's not simple to change (it's possible, but I wasn't sure of side effects so didn't hack down to that level). When running 3 pin fans (i.e. Fan 5) below the 25kHz the fan is noisy.

Fan 5 was added as either a means to draw air over the sensor when the main fans were not running, or as an air flow sensor (by measuring the tachometer output with the fan not powered). However I've never used this so it can be dropped freeing up 2 more valuable pins on the Arduino.

Alexa SmartHome Skills don't differentiate between brightness and power level when the Alexa.BrightnessController and Alexa.PowerlevelController are both enabled. Power level commands come in as set brightness which isn't great when you need to change the fan speed, and the brightness.

Alexa skills are mighty painful to debug, some tips:

  • Ensure your Lambda is in the correct region (Ireland for UK English).
  • Use matching email accounts between Alexa and AWS (Not sure if this is required but it appeared to make a difference).
  • Put lots of logging in your skill, you'll only get something went wrong from Alexa.
  • If your skill randomly stops working check the authentication token. I got no feedback when my refresh token failed other than stuff just not working.
  • Ensure your Lambda has CloudWatch permissions, if you follow examples it might not and you'll not get logs.
  • Don't use the blueprints for SmartHome in Lambda, these are for version 2 which is now deprecated, likewise theirs only one sample, that's in Node JS and it regularly failed to load in the editor. Github has many better samples.
  • Requesting Alexa to set the power level to 0% actually results in a request for 20%. I ended up having to ask for 1% to make the fans stop (they stop below 5%).

The ThingySticks Environment Cap PCB I designed for this project had the wrong pinout for the BME680, combined with an extra 2 week delay to PCB shipping because of customs, when they arrived I was out of the country so I didn't discover the issue until it was way too late.

The fan controller PCBs had the wrong silk screen printed, the fab house used the dimensions layer instead of bNames for the lower silk screen.

Don't have Cura open when you're trying to program the Arduino via the serial port. Cura takes over the port.

Outtakes

Ohh the joy of voice, sometimes Alexa just wants to play silly games with me.... It took a fair few tries to get a good flow of instructions working, Alexa had some fun and games with me in the process...

More Photos and Videos

Because I took loads.....

Makespace laser cutting the ply case

Laser etching the finger guards.

3D Printing the ducts, with mighty bridging that just about worked... (Sorry, my printer camera is wonky)

Code

Boff.inoArduino
This is the main file for the Arduino application
#include <Adafruit_TCS34725.h>
#include <Adafruit_BME680.h>
#include <bme680.h>
#include <bme680_defs.h>
#include <SparkFunCCS811.h>

#include <MQTTClient.h>
#include <system.h>
#include <WiFi101.h>
#include <RTCZero.h>
#include <FastLED.h>
#include <SparkFunCCS811.h>
#include "customTypes.h"


// Global fan info settings.
// used by LEDs and fan control
// as well as other places (e.g. setting fan speed).
// Fans 1,2,3, 4 and 5 , indexed as 0..4
fanInfo_t fanInfos[5];

// Not used in the fan box. 
int switch_pins[] = {A1, A2};
int switch_leds[] = {A3, A4};

// 0: Ignore - manual
// 1: Temperature 
// 2: Humidity
// 3: Pressure
// 4: Air Quality
// 5: Clock
// 6: circle (single fan)
// 7: circle (all fans)
// 8: Dust
// 10: Pomodoro (work + Play)
// 11: Pomodoro Work
// 12: Pomodoro Play
// 13: Fixed Color
// 14: lightLevel
// 15: SelectedFanSpeed (0..11)
// 16: FanSpeed
// 17: WiFiStrength
// 18: OnOff
// 19: MqttFeed
// 100: Fancy
// 255: Automatic
// TODO: Load this from EEPROM or something.
// Let it be settable via MQTT/Alexa/////
DisplayMode fanDisplayModes[] = {
  DisplayMode::Temperature, 
  DisplayMode::Humidity, 
  DisplayMode::AirQuality, 
  DisplayMode::Clock};

// running LED Index, by "Hour" (0 top, 11 at 11 o'clock...)
int redHourIndex = 0;
int lastRedHourIndex = 0;
CRGB ledsSetColor;

// How bright to make the LEDs.
int ledBrightnessPercent = 20;

//#define NUM_LEDS 24
// 4 Fans, 16 LEDs per fan = 64
// 2 1M strips of LEDs, 120 LEDS per M = 240
// 2 1M strips of LEDs, 90 LEDS per M = 180
// 64 + 240 = 304
// 64 (Fans) + 120 + 60-18 (42) (1 stip + 18 LEDs short of 1/2 srtip).
// = 64 + 162 = 226
// + about 74 from the top pointing set
//#define NUM_LEDS 226 + 74
// each fan has 16ish...
// Wooden fan...
#define NUM_LEDS 64
CRGB leds[NUM_LEDS];

// ------------------------------------
// Sensor values (defined here so they 
// can be used across the application).
// -------------------------------------
// What sensors are attached.
bool hasBme280 = false;
bool hasBme680 = false;
bool hasCCS811 = false;
bool hasLightSensor = false;

// BME 280 (or 680)
// Guess at appropriate values whilst not available to be read.
float humidity = 50;
float temperature = 22;
float pressure = 1015.2;
int sensorSource = 0; // 0: Fake, 1: 280, 2: 680

// BME680 specific. toc/air quality 
float gas_resistance;

// CCS811 values.
long ccs811DataUsableAfter;
unsigned int ccsBaseline;
unsigned int tVOC = 0;
unsigned int eCO2 = 400;
uint8_t ccsLastStatusError;

// Light sensor
uint16_t lightLevelLux = 20;
uint16_t redLightLevel = 0;
uint16_t greenLightLevel = 0;
uint16_t blueLightLevel = 0;
uint16_t clearLightLevel = 0;
uint16_t colorTemperature = 0;

// measured rssi.
int rssi;

float voltage = 0.0;

// =============================================

// External MQTT feeds (0..3).
// These should be mapped to the fan they are displayed on
// or the other way around (i.e. feed[2] of interest, 
// used fan[2] to show that.
String mqttFeedsTopic[4] = {"/Radiation/cpm", "", "", ""};

// TODO: The feed value should be normalised into 23 steps with
// 0 = desired value. +11 high, -11 low.
// If the value never goes below the desited valus
// then still use +/-11 just the -1..-11 is never displayed.
// For on/off, anything > than 0 is on.
int mqttFeedsValue[4] = {0,0,0,0};

RTCZero rtc;

// Sensor display range settings.
displayRange_t temperatureRange;
displayRange_t humidityRange;
displayRange_t pressureRange;
displayRange_t airQualityRange;
displayRange_t dustRange;
displayRange_t wifiDisplayRange;

// =============================================
// Switches
// =============================================
// Switch debounce handling.
volatile bool handle_switch1_pressed = false;
volatile bool handle_switch2_pressed = false;


// LED 1 Nose Green: Fans and Neopixel setup done.
// LED 2 Nose Green: Serial port wait done.
// LED 3 Nose Green: WiFi done.
// LED 4 Nose Green: MQTT done.
// the setup function runs once when you press reset or power the board
void setup() {
  pinMode(LED_BUILTIN, OUTPUT);

  setupFans();
  setupNeopixels();
  // Artificial delay so that the first nose
  // isn't instandly green
  delay(5000);
  
  showSetupStageComplete(1);
  delay(1000);
   
  //Initialize serial:
  Serial.begin(9600);
  serialConnectDelay();
  Serial.println("Serial setup complete");
  showSetupStageComplete(2);

  // Setup the display ranges used to show values
  // on the fans.
  temperatureRange = setupTemperatureDisplayRange();
  humidityRange = setupHumidityDisplayRange();
  pressureRange = setupPressureDisplayRange();
  airQualityRange = setupAirQualityDisplayRange();
  wifiDisplayRange = setupWiFiDisplayRange();

  setupSensors();

  setupWiFi();
  showSetupStageComplete(3);

  // TODO: Get time from NTP server
  rtc.begin();
  rtc.setTime(04, 40, 20);
  rtc.setDate(20, 02, 2018);
  printCurrentDateTime();
  delay(2000);

  setupMqtt();
  showSetupStageComplete(4);

  setupSwitches();
  
  Serial.println("Boff version 0.2.3");
  Serial.println("------------------------------------------");

  // Switch the Switch LED on to indicate we're ready...
  setSwitchLEDs(HIGH);
}

void serialConnectDelay() {
  for (int i = 0; i<5; i++) {
    Serial.print("Serial wait ");
    Serial.print(i+1);
    Serial.println("......");
    delay(1000);
  }
}

void printCurrentDateTime() {
  // Print date...
  print2digits(rtc.getDay());
  Serial.print("/");
  print2digits(rtc.getMonth());
  Serial.print("/");
  print2digits(rtc.getYear());
  Serial.print(" ");

  // ...and time
  print2digits(rtc.getHours());
  Serial.print(":");
  print2digits(rtc.getMinutes());
  Serial.print(":");
  print2digits(rtc.getSeconds());

  Serial.println();
}

void print2digits(int number) {
  if (number < 10) {
    Serial.print("0"); // print a 0 before if the number is < than 10
  }
  Serial.print(number);
}

void setupSwitches() {
  
  for (int i=0; i<2; i++) {
      pinMode(switch_leds[i], OUTPUT);
      digitalWrite(switch_leds[i], LOW);
      pinMode(switch_pins[i], INPUT_PULLUP);
  }

  attachInterrupt(switch_pins[0], switch1_pressed, FALLING); 
  attachInterrupt(switch_pins[1], switch2_pressed, FALLING); 
}

// ==============================================================
// Loop functions
// ==============================================================

// Profiling variables.
unsigned long loop_start;
unsigned long loop_took;

void loop() {
  loop_start = millis();
  digitalWrite(LED_BUILTIN, HIGH); // D6 used for input for dust sensor when fitted.

  sensorsLoop();
  fansLoop();
  readInput();
  ledsLoop();
  handleSwitches();
  mqttLoop();
  
  printHeader();
  printInfo();

  digitalWrite(LED_BUILTIN, LOW);    
  // Minimum delay, otherwise WiFi/MQTT processing
  // doesn't happen and we keep disconnecting.
  delay(20);

  fanSpeedDelay();

  

  loop_took = millis() - loop_start;
  //Serial.print("Loop took: ");
  //Serial.print(loop_took);
  //Serial.println("ms");
}


unsigned long lastPrintInfo = 0;
unsigned long lastHeaderPrinted = 0;
void printInfo() {
  // Print only once per...
  if (lastPrintInfo + 500 > millis()) {
    return;
  }

  Serial.print(temperature);
  Serial.print("\t");
  Serial.print(humidity);
  Serial.print("\t");
  Serial.print(pressure);
  Serial.print("\t");
  Serial.print(eCO2);
  Serial.print("\t");
  Serial.print(tVOC);
  Serial.print("\t");
  Serial.print(gas_resistance);
  Serial.print("\t");
  Serial.print(lightLevelLux);
  Serial.print("\t");  
  Serial.print(rssi);
  Serial.print("\t");
  Serial.print(voltage);
  Serial.print("\t");
  // Assume all fans have the same set speed.
  Serial.print(fanInfos[0].speedSet);
  Serial.print("\t[");
  for (int fanId=0; fanId<4;fanId++) {
    Serial.print(fanInfos[fanId].computedRpm);
    Serial.print("\t");
  }
  Serial.print("]\t[");
  for (int fanId=0; fanId<4;fanId++) {
    Serial.print(fanDisplayModes[fanId]);
    Serial.print("\t");
  }
  Serial.print("]\t");
  Serial.print(ledBrightnessPercent);
  Serial.print("\t");
  Serial.println();

  lastPrintInfo = millis();
}

void printHeader() {
  // Print only once per n samples.
  if (lastHeaderPrinted + 20000 > millis()) {
    return;
  }
  Serial.print("T/C: ");
  Serial.print("\t");
  Serial.print("RH /%: ");
  Serial.print("\t");
  Serial.print("BP: ");
  Serial.print("\t");
  Serial.print("eCO2: ");
  Serial.print("\t");
  Serial.print("TVOC: ");
  Serial.print("\t");
  Serial.print("Gas R'");
  Serial.print("\t");
  Serial.print("Light: ");
  Serial.print("\t");
  Serial.print("RSSI: ");
  Serial.print("\t");
  Serial.print("V in:");
  Serial.print("\t");
  Serial.print("Speed: ");
  Serial.print("\t");
  Serial.print("RPMs: ");
  Serial.print("\t\t\t\t");
  Serial.print("\t");
  Serial.print("Display Mode: ");
  Serial.print("\t\t\t\t");
  Serial.print("Brightness: ");
  Serial.print("\t");
  Serial.println();

  lastHeaderPrinted = millis();
}

// ==============================================================
// Switches (Optional)
// ==============================================================
void handleSwitches() {
  // Interrupt from switch 1...
  if (handle_switch1_pressed) {

    Serial.println("Switch 1 Interrupt handling");

    // Ensure we have atleast 200ms delay before checking if the switch is still pressed.
    delay(200);

    // If the switch is now high, ignore it, probably a bounde.
    if (digitalRead(switch_pins[0])) {
      handle_switch1_pressed = false;
      return;
    }
    setSwitchLEDs(LOW);

    if (isMasterPowerEnabled()) {
      publishTinamousStatus("Switch pressed, switching off the fans");
    } else {
      publishTinamousStatus("Switch pressed, switching on the fans");
    }

    // Power the fans on gently
    setFansSpeed(10);
    setPower(!isMasterPowerEnabled());
    delay(2000);
    
    // Set all the fans to 100%
    setFansSpeed(100);
    
    // Erm....
    if (isMasterPowerEnabled()) {
      leds[0] = CRGB::Red; 
      leds[1] = CRGB::Red; 
      leds[2] = CRGB::Red; 
      leds[3] = CRGB::Red; 
    } else {
      leds[0] = CRGB::Green; 
      leds[1] = CRGB::Green; 
      leds[2] = CRGB::Green; 
      leds[3] = CRGB::Green; 
    }

    // wait for Switch1 and two to go high again.
    // to ensure the user has released it 
    while(!digitalRead(switch_pins[0])) { }
    
    handle_switch1_pressed = false;
    setSwitchLEDs(HIGH);
  }

  if (handle_switch2_pressed) {

    Serial.println("Switch 2 Interrupt handling");

    // Ensure we have atleast 200ms delay before checking if the switch is still pressed.
    delay(200);

    // If the switch is now high, ignore it, probably a bounde.
    if (digitalRead(switch_pins[1])) {
      handle_switch1_pressed = false;
      return ;
    }

    setSwitchLEDs(LOW);

    // Do switch 2 stuff here....
    
    while(!digitalRead(switch_pins[1])) { }
    handle_switch2_pressed = false;
    setSwitchLEDs(HIGH);
  }
}

void setSwitchLEDs(bool state) {
  for (int i=0; i<2; i++) {
      digitalWrite(switch_leds[i], state);
  }
}
 
// ==============================================================
// User input
// ==============================================================

int selectedFanId = 1;

void readInput() {

  if (Serial.available()) {
    char instruction = Serial.read();

    switch (instruction) {
      case '0':
        setFansSpeed(0);
        break;
      case '1':
        setFansSpeed(10);
        break;
      case '2':
        setFansSpeed(60);
        break;
      case '3':
        setFansSpeed(100);
        break;
      case 't': // temperature fan
        Serial.println("Fan 1 selected.");
        selectedFanId = 1;
        setFanBackground(selectedFanId, CRGB::Blue);
        break;
      case 'h': // humidity fan
        Serial.println("Fan 2 selected.");
        selectedFanId = 2;
        setFanBackground(selectedFanId, CRGB::Blue);
        break;
      case 'p': // pressure fan
        Serial.println("Fan 3 selected.");
        selectedFanId = 3;
        setFanBackground(selectedFanId, CRGB::Blue);
        break;
      case 'q': // air quality fan
        Serial.println("Fan 4 selected.");
        selectedFanId = 4;
        setFanBackground(selectedFanId, CRGB::Blue);
        break;
      case ',':
        ledsSetColor = CRGB::Red; 
        break;
      case '.':
        ledsSetColor = CRGB::Green;
        break;
      case '+':
        temperature +=0.25;
        humidity +=2;
        eCO2 +=100;
        pressure +=25;
        break;
      case '-':
        temperature -=0.25;
        humidity -=2;
        eCO2 -=100;
        pressure -=25;
        break;
      case 'm':
        setPower(true);
        break;
      case 'n':
        setPower(false);       
        break;
      case '>':
        ledBrightnessPercent+= 5;
        if (ledBrightnessPercent > 100){
          ledBrightnessPercent = 100;
        }
        break;
      case '<':
        ledBrightnessPercent-= 5;
        if (ledBrightnessPercent <= 0){
          ledBrightnessPercent = 0;
        }
        break;
      default:
        Serial.println("Unknown instruction. Select: 0..3, t, h, p, q, o, +/-, m, n, <, >");
        Serial.println("0..3 - Fan speed (0, 1, 7, 11)");
        Serial.println("t - Select [t]emperature fan");
        Serial.println("h - Select [h]umidity fan");
        Serial.println("p - Select [p]ressure fan");
        Serial.println("q - Select air [q]uality fan");
        Serial.println("o - all LEDs [o]ff");
        Serial.println("+/- - increase/decrease faked values");
        Serial.println("m - [m]aster power on");
        Serial.println("n - [m]aster power off");
        Serial.println("> - brighter");
        Serial.println("< - dimmer");
        break;
    }

    updateFanSpeeds();
    //printVariables();
  }
}

void printVariables() {
  Serial.print("Temperature: ");
  Serial.print(temperature);
  Serial.print(", Humidity: ");
  Serial.print(humidity);
  Serial.print(", Pressure: ");
  Serial.print(pressure);
  Serial.print(", eCO2: ");
  Serial.print(eCO2);
  Serial.print(", fan speed: ");
  Serial.print(fanInfos[selectedFanId-1].speedSet);
  Serial.println();
}

// General

void sleepNow() {
  Serial.println("Sleep!"); 
  setPower(0);
  setFansSpeed(0);
  ledBrightnessPercent = 0;
  publishTinamousStatus("Sleep mode activated.");
}

void wakeNow() {
  Serial.println("Wake!"); 

  // TODO: Wake on previous speed or 
  // have a desired wake speed for the fans.
  setFansSpeed(0);
  setPower(1);
  delay(2000);
  setFansSpeed(100);
  
  publishTinamousStatus("Waking up.");
}


// ==========================================================
// Display parameters setup
// ==========================================================

// Setup parameters for temperature display
displayRange_t setupTemperatureDisplayRange() {
  float idealValue = 22;
  int factor = 10;

  displayRange_t range;
  range.idealValue = idealValue * factor;
  
  range.idealRangeLow = (idealValue - 1) * factor; 
  range.idealRangeHigh = (idealValue + 1) * factor; 

  // +/- 6 segments on the display
  range.minValue = (idealValue - 2.5) * factor;  // each segment worth 0.5 C
  range.maxValue = (idealValue + 2.5) * factor; 

  range.factor = factor;
  range.fanSpeedAboveIdeal[0] = 22;
  range.fanSpeedAboveIdeal[1] = 24;
  range.fanSpeedAboveIdeal[2] = 25;
  range.fanSpeedBelowIdeal[0] = 18;
  range.fanSpeedBelowIdeal[1] = 18;
  range.fanSpeedBelowIdeal[2] = 18;
  return range;
}

// Setup parameters for humidity display
displayRange_t setupHumidityDisplayRange() {
  
  float idealValue = 55;
  int factor = 1;

  displayRange_t range;
  range.idealValue = idealValue;

  // 40-60% is "optimal"
  range.idealRangeLow = idealValue - 5; 
  range.idealRangeHigh = idealValue + 5; 

  // this needs to be symmetrical either wide of 
  // the ideal value (at-least until the display 
  // can support it.)
  range.minValue = idealValue - 45; // 10%
  range.maxValue = idealValue + 45; // 100%

  range.factor = factor;
  return range;
}

displayRange_t setupPressureDisplayRange() {
  float idealValue = 1015;
  int factor = 1;

  displayRange_t range;
  range.idealValue = idealValue;
  
  range.idealRangeLow = 1000; 
  range.idealRangeHigh = 1030; 

  // Hack for the -ve value to balance
  // the display.
  range.minValue = 900;
  range.maxValue = 1100;

  range.factor = factor;
  return range;
}

// Using eCO2 as air quality...
// Setup parameters for air quality display
// this is different to temp/humidity in that
// it's only the upper range that matters.
displayRange_t setupAirQualityDisplayRange() {

  // Ideal would really be 0, however with at least
  // 1 person observing it's likely to be a low but not 
  // zero value so will make the display weird. hence
  // set "idealValue" to about a normal level for 
  // one person.
  float idealValue = 400;
  int factor = 1;

  displayRange_t range;
  range.idealValue = idealValue;
  
  range.idealRangeLow = -1000; 
  range.idealRangeHigh = 1000; 

  // Hack for the -ve value to balance
  // the display.
  range.minValue = -2000;
  range.maxValue = 2000; 

  range.factor = factor;
  return range;
}

// WiFi range...
// 0 to -120 (0 = best).
// map to +/-60.
displayRange_t setupWiFiDisplayRange() {
  float idealValue = 0;
  int factor = 1;

  displayRange_t range;
  range.idealValue = idealValue;

  // Anything 20-60 (i.e. -40 to 0)
  range.idealRangeLow = 20;  // i.e. RSSI = -40 (value - 60)
  range.idealRangeHigh = 60;  // i.e. RSSI = 0

  // Hack for the -ve value to balance
  // the display.
  range.minValue = -60;
  range.maxValue = 60;  // 

  range.factor = factor;
  // todo: offset? (e.g. +60 here).
  return range;
}

void switch1_pressed() {
  handle_switch1_pressed = true;
}

void switch2_pressed() {
  handle_switch2_pressed = true;
}
customTypes.hArduino
#ifndef __INC_CUSTOM_TYPES_H
#define __INC_CUSTOM_TYPES_H

#include <FastLED.h>

typedef enum {
    Ignore = 0, // done
    Temperature = 1, // done
    Humidity = 2, // done
    Pressure = 3, // kind of done
    AirQuality = 4,// done
    Clock = 5, // done
    CircleSingleFan = 6, // done
    CircleAllFans = 7, // todo
    Dust = 8, // todo - need ranges
    Timer = 9, // todo
    Pomodoro = 10, // todo
    PomodoroWorkOnly = 11, // todo
    PomodoroPlayOnly = 12, // todo
    FixedColor = 13, // todo
    LightLevel = 14, // todo (need range)
    // The user selected fan speed (0..11)
    SelectedFanSpeed = 15, // done
    // Fan Speed in RPM
    FanSpeed = 16, // broken
    WiFiStrength = 17, // TODO
    OnOff = 18, // TODO
    MqttFeed = 19, // TODO
    Fancy = 100,
    Automatic = 255, // todo
} DisplayMode;

struct DisplayRangeType {
  // The "Perfect" value. represents 12 O'Clock on the clock
  float idealValue;
  // The ideal range that the value is desired/comfortable within
  float idealRangeLow;
  float idealRangeHigh;

  // Min/Max value that is on the display
  float minValue;
  float maxValue;

  int factor;

  // 0 = relative +/- the 12 O'Clock position
  int mode = 0;

  // Low, Medium and High set points
  // when above the ideal point
  int fanSpeedAboveIdeal[3];

  // Low, Medium and High set points
  // when below the ideal point
  int fanSpeedBelowIdeal[3];
};

typedef DisplayRangeType displayRange_t;


struct FanInfoType {
  // The fan Id (1..4) rather than the index in the array
  // a more user friendly (and aligned with the PCB labels!)
  int fanId;
  
  // Control pin for the fan
  int pwmPin;
  
  // Pulse pin from the fan.
  int tachPin;
  
  // If the fan is enabled for use.
  bool enabled;
  
  // The count of the pulses.
  int pulseCount;
  
  // Current RPM computed from pulse counts
  int computedRpm;
  
  // 0..11. Use speedPwm to get the PWM value for the set speet.
  // Uses 0..11 as it displays nicely on the 12 pixel fan LED display
  // This is the requested speed for the fan. See currentSpeed for the 
  // current fan speed
  int speedSet;   

  // May be different to the speedSet if this has not
  // yet been actionsed.
  int currentSpeed = 12;

  // The currently set PWM value for the fan
  // debug/diagnostic use only
  int currentPwm = 0;

  // Indexed by speedSet, pwm value to use at each setting
  // i.e. how fast to run the fan at that speed.
  int speedPwm[12] = {0, 23, 46, 69, 92, 115, 138, 180, 200, 220, 240, 255};
  
  // Array of RPM's expected RPMs indexed by speedSet (0..11)
  // i.e. how fast we expect the fan to be going at a selected speed.
  // e.g. [0] = 0, [1] = 400, [2] = 600, [3] = 800, 
  int expectedRpm[12] = {0, 100, 800, 800, 800, 800, 1200, 1200, 1200, 1500, 1500, 1500};
  
  // How many pulses the fan makes for each RPM
  int pulseToRpmFactor = 4; // 1, 2, or 4 typically.

  // Prefered fan (outer) color for fixed colours.
  CRGB outerColor;
  
  // Color for the fans nose
  CRGB noseColor;
};

typedef FanInfoType fanInfo_t;



#endif
displayLeds.inoArduino
This is responsible for handling the LED driving.
#include "customTypes.h"
#include <FastLED.h>
#include <WiFi101.h>
#include <Math.h>


// Converts from a clock 'hour' position (0..12) (0==12 to make range lookup easier) to a led id (0..15, well 4..15)
// This assumes the fan is placed so the wire exits top right....
int fanOuterLedLookup[] = {14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14};

// Convert the scale 0..11 to an "hour" position on the display.
int normalisedHours[] = {7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6};

// Convert the scale 0..22 to an "hour" position on the display.
// this allows the full face to be used (1... 12[0] for low then 12[0].. 11 for high.
int normalisedHoursFullScale[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};


// ==========================================================
// Neopixel handling
// ==========================================================

void setupNeopixels() {
  FastLED.clear();

  CRGB ledsSetColor = CRGB::Cyan;
  //CRGB ledsSetColor = CHSV(255, 1.0, 20);
  //ledsSetColor.r=50;
  //ledsSetColor.g=50;
  //ledsSetColor.b=50;
  
  // Default all the LEDs to black on start
  // then setup the fans and strips as needed.
  for (int i=0; i< NUM_LEDS; i++) {
    leds[i] = CRGB::Black;
  }

  // Make the noses off for all the fans
  // as it doesn't show up well on camera.
  for (int fanId=0; fanId<4; fanId++) {
    setNoseColor(fanId, CRGB::Black);
    setFanBackground(fanId, CRGB::Blue);
  }
  
  // 15 puts it on A0.
  // 6 - D6 - as used by protoboard at prsent.
  FastLED.addLeds<NEOPIXEL, 15>(leds, NUM_LEDS); 
  FastLED.setBrightness(ledBrightnessPercent * 2.55);
  Serial.println("Neopixels setup...");
  FastLED.show(); 
}

void showSetupStageComplete(int stage) {
    // Set the noses to show startup...
    setNoseColor(stage-1, CRGB::Green);
    setFanBackground(stage-1, CRGB::Blue);
    FastLED.show(); 
    delay(500);
}

// ==================================================


// Loop handler to update the Neopixels (i.e. LED leds + possible others)
// code for this is in the displayLeds file.
void ledsLoop() {
  updateFansLeds();
  updateStrip1Leds();
  updateStrip2Leds();
  endLedUpdate();

  FastLED.show(); 
}

// ==================================================

// Make the fan LEDs red to indicate they are starting.
void makeFanLedsRed() {
  int maxFan = 4;
  for (int fanId = 0; fanId < maxFan; fanId++) {
    setNoseColor(fanId, CRGB::Red);
    setFanBackground(fanId, CRGB::Red);
  }
  FastLED.setBrightness(ledBrightnessPercent * 2.55);
  FastLED.show();
}

// =================================================

void updateFansLeds() {
  int maxFan = 4;
  for (int fanId = 0; fanId < maxFan; fanId++) {
    showOuterValue(fanId);
    showNoseValue(fanId);
  }
}

// 64 LEDs in the 4 fans.
// Strip 1 is up facing at the back (if fitted).
int strip1StartLedNumber = 65;
int strip1EndLedNumber = strip1StartLedNumber + 61;
int strip1LedCount = 61;

// Strip 1 is front facing (if fitted).
int strip2StartLedNumber = strip1EndLedNumber + 1; // 127
int strip2EndLedNumber = strip2StartLedNumber + 162; // 288
int strip2LedCount = 162;

int strip1Direction = 1;
int strip1RunningDotPosition = strip1StartLedNumber;
int lastStrip1DotPosition = strip1StartLedNumber;

int strip2RunningDotPosition = 0;
int lastStrip2DotPosition = 0;

int showAlexaInteraction = 0; // 0: off, 1: 

void showAlexaConnectionActive() {

  // If the LEDs are off (i.e. sleep more)
  // switch them on to show the activity.
  if (ledBrightnessPercent < 2) {
    ledBrightnessPercent = 20;
  }

  showAlexaInteraction = 1;
  for (int i = 0; i<4; i++) {
    ledsLoop();
    delay(300);
  }
  
  // Ensure the strip is cleared
  setStrip2Color(CRGB::Black);
  showAlexaInteraction = 0;
  ledsLoop();
}

// Update the 1st LED strip.
// This may not be fitted.
void updateStrip1Leds(){
  // Check see if 
  if (NUM_LEDS < strip1StartLedNumber) {
    return;
  }

  // otherwise do our normal stuff.
  //showStrip1RunningDisplay();
  showStrip1FixedColor();
}

void updateStrip2Leds(){
  // Check see if 
  if (NUM_LEDS < strip2StartLedNumber) {
    return;
  }

  CRGB color1 = CRGB::Blue;
  CRGB color2 = CRGB::Cyan;

  if (showAlexaInteraction == 1) {
    showAlexaInteraction = 2;
    showStrip2AlexaInteraction(color1, color2);
    return;
  }

  if (showAlexaInteraction == 2) {
    showAlexaInteraction = 1;
    showStrip2AlexaInteraction(color2, color1);
    return;
  }

  // otherwise do our normal stuff.
  showStrip2FixedColor();
  
  // Only show the running display if the fans are on.
  if (isMasterPowerEnabled()) {
    showStrip2RunningDisplay();
  }
}

void showStrip1FixedColor() {
  setStrip1Color(ledsSetColor);
}

void showStrip2FixedColor() {
  setStrip2Color(ledsSetColor);
}

void showStrip1RunningDisplay() {
  strip1RunningDotPosition = strip1RunningDotPosition + strip1Direction;
  
  if (strip1RunningDotPosition > strip1LedCount) {
    strip1RunningDotPosition = strip1LedCount;
    strip1Direction = -1;
  }
  
  if (strip1RunningDotPosition < 0) {
    strip1RunningDotPosition = 0;
    strip1Direction = +1;
  }

  setStrip1Led(lastStrip1DotPosition, CRGB::Black);
  setStrip1Led(strip1RunningDotPosition, CRGB::Blue);
  setStrip1Led(strip1RunningDotPosition+ strip1Direction, CRGB::Green);
  setStrip1Led(strip1RunningDotPosition+ (strip1Direction*2), CRGB::Red);
  
  lastStrip1DotPosition = strip1RunningDotPosition;
}

void showStrip2RunningDisplay() {
  
  strip2RunningDotPosition++;
  if (strip2RunningDotPosition > strip2LedCount) {
    strip2RunningDotPosition = 0;
  }

  setStrip2Led(lastStrip2DotPosition, CRGB::Black);
  lastStrip2DotPosition = strip2RunningDotPosition;

  for (int offset = 0; offset < strip2LedCount; offset+=20) {
    setStrip2Led(strip2RunningDotPosition + offset, CRGB::Cyan);
    setStrip2Led(strip2RunningDotPosition + offset + 1, CRGB::Blue);
    setStrip2Led(strip2RunningDotPosition + offset + 2, CRGB::Green);
    setStrip2Led(strip2RunningDotPosition + offset + 3, CRGB::Green);
    setStrip2Led(strip2RunningDotPosition + offset + 4, CRGB::Blue);
    setStrip2Led(strip2RunningDotPosition + offset + 5, CRGB::Cyan);
  }
}

void setStrip1Color(CRGB color) {
  for (int index=0; index < strip1LedCount; index++) {
     setStrip1Led(index, color);
  }
}

void setStrip2Color(CRGB color) {
  for (int index=0; index < strip2LedCount; index++) {
     setStrip2Led(index, color);
  }
}

void showStrip2AlexaInteraction(CRGB color1, CRGB color2) {
  for (int index = 0; index < strip2LedCount; index+=6) {
    setStrip2Led(index, color1);
    setStrip2Led(index+1, color1);
    setStrip2Led(index+2, color1);
    setStrip2Led(index+3, color1);

    setStrip2Led(index+4, color2);
    setStrip2Led(index+5, color2);
    setStrip2Led(index+6, color2);
  }
}

void setStrip1Led(int index, CRGB color) {
int ledIndex;

  ledIndex = strip1StartLedNumber + index;
  if (ledIndex > strip1EndLedNumber) {
    // Ignore off the end
    return;
  }

  leds[ledIndex] = color;
}

void setStrip2Led(int index, CRGB color) {
int ledIndex;

  ledIndex = strip2StartLedNumber + index;
  if (ledIndex > strip2EndLedNumber) {
    ledIndex = ledIndex - strip2LedCount;
  }

  leds[ledIndex] = color;
}

// This is the final say in LED updates, it
// can override all other changes. e.g. to turn off the LEDs
// or to set the noses as red when the fans are starting up, or
// something else...
void endLedUpdate() {
   lastRedHourIndex = redHourIndex;
   redHourIndex++;

    // run around the outer ring
    // is the "hour" index rather than led id.
    if (redHourIndex >= 12) {
      redHourIndex = 0;
    }

    // Check each of the fan speeds and show a red
    // nose if the speed is low.
    // Enable some point in the future when fans
    // are runnable.
    for (int fanId = 0; fanId <4; fanId++) {
      showFanSpeedLowError(fanId);
    }

    // Override any settings made to the LEDs to switch them off.
    if (ledBrightnessPercent < 2) {
      FastLED.setBrightness( 0 );
    } else {
      FastLED.setBrightness( ledBrightnessPercent * 2.55 );
    }
}

// --------------------------------------------------------

// Show value on fan's outer LEDs.
// fanId: 0..3
void showOuterValue(int fanId) {
  DisplayMode fanMode = fanDisplayModes[fanId];

  //Serial.print("Fan: ");
  //Serial.print(fanId);

  switch (fanMode) {
    case DisplayMode::Ignore:
      // No action. Manual control.
      break;
    case DisplayMode::Temperature:
      showTemperature(fanId);
      break;
    case DisplayMode::Humidity:
      showHumidity(fanId);
      break;
    case DisplayMode::Pressure:
      showPressure(fanId);
      break;
    case DisplayMode::AirQuality:
      showAirQuality(fanId);
      break;
    case DisplayMode::Clock:
      showTime(fanId);
      break;
    case DisplayMode::CircleSingleFan:
      showRunningClock(fanId);
      break;
    case DisplayMode::CircleAllFans:
      // TODO: Use all fans
      showRunningClock(fanId);
      break;
    case DisplayMode::Dust:
      showDust(fanId);
      break;
    case DisplayMode::Timer:
      showTimer(fanId);
      break;
    case DisplayMode::Pomodoro:
      // TODO...
      break;
    case DisplayMode::PomodoroWorkOnly:
    // TODO...
      break;
    case DisplayMode::PomodoroPlayOnly:
    // TODO...
      break;
    case DisplayMode::FixedColor:
      showFixedColor(fanId);
      break;
    case DisplayMode::SelectedFanSpeed:
      showFanSelectedSpeed(fanId);
      break;
    case DisplayMode::FanSpeed:
      showFanRpmSpeed(fanId);
      break;
    case DisplayMode::Fancy:
      showFancy(fanId);
      break;
    case DisplayMode::WiFiStrength:
      showWiFiStrength(fanId);
      break;
    case DisplayMode::OnOff:
      showOnOff(fanId);
      break;
    case DisplayMode::MqttFeed:
      showMqttFeed(fanId);
      break;
    case DisplayMode::Automatic:
      showAutomatic(fanId);
      break;
    default: 
      // Blue nose: Not implemented
      Serial.println("Unknown display mode");
      break;
  }
}

// Use the fans "nose" to show a value. Uses all 4 LEDs.
// Doesn't cycle.
void showNoseValue(int fanId) {
  DisplayMode fanMode = fanDisplayModes[fanId];

  switch (fanMode) {
    case DisplayMode::Ignore:
      // No action. Manual control.
      break;
    case DisplayMode::Temperature:
      showNoseTemperature(fanId);
      break;
    case DisplayMode::Humidity:
      showNoseHumidity(fanId);
      break;
     case DisplayMode::Pressure:
      showNosePressure(fanId);
      break;
    case DisplayMode::AirQuality:
      showNoseAirQuality(fanId);
      break;
    case DisplayMode::Clock:
      setNoseColor(fanId, CRGB::Black);
      break;
    case DisplayMode::CircleSingleFan:
      setNoseColor(fanId, CRGB::Green);
      break;
    case DisplayMode::CircleAllFans:
      setNoseColor(fanId, CRGB::Green);
      break;
    case DisplayMode::Dust:
      showNoseDust(fanId);
      break;
    case DisplayMode::Timer:
      showNoseTimer(fanId);
      break;
    case DisplayMode::Pomodoro:
      // TODO...
      break;
    case DisplayMode::PomodoroWorkOnly:
      // TODO...
      break;
    case DisplayMode::PomodoroPlayOnly:
      // TODO...
      break;
    case DisplayMode::FixedColor:
      showNoseFixedColor(fanId);
      break;
    case DisplayMode::SelectedFanSpeed:
      // red/green for in range.
      showNoseFanSpeed(fanId); 
      break;
    case DisplayMode::FanSpeed:
      showNoseFanSpeed(fanId);
      break;
    case DisplayMode::Fancy:
      showNoseFancy(fanId);
      break;
    case DisplayMode::WiFiStrength:
      showNoseWiFiStrength(fanId);
      break;
    case DisplayMode::OnOff:
      showNoseOnOff(fanId);
      break;
    case DisplayMode::MqttFeed:
      showNoseMqttFeed(fanId);
      break;
    case DisplayMode::Automatic:
      showNoseAutomatic(fanId);
      break;
    default: 
      // Blue nose: Not implemented
      setNoseColor(fanId, CRGB::Blue);
      break;
  }
}

// ----------------------------------
// 1: Temperature display
// ----------------------------------

void showTemperature(int fanId) { 
  // Map the desired value onto the fan surround.
  // *10 to avoid float usage
  int temperatureFactorised = temperature * temperatureRange.factor;

  //Serial.print("Temperature: ");
  //Serial.println(temperature);
  
  mapToFan(fanId, temperatureFactorised, temperatureRange);
}

void showNoseTemperature(int fanId) {
  int temperatureFactorised = temperature * temperatureRange.factor;

  setGenericNose(fanId, temperatureFactorised, temperatureRange);
}
// ----------------------------------

// ----------------------------------
// 2: Humidity Display
// ----------------------------------

void showHumidity(int fanId) {
  mapToFan(fanId, (int)humidity, humidityRange);
}

void showNoseHumidity(int fanId) {
  setGenericNose(fanId, (int)humidity, humidityRange);
}

// ----------------------------------
// 3: Pressure Display
// ----------------------------------

void showPressure(int fanId) {
  
}

void showNosePressure(int fanId) {
  
}

// ----------------------------------
// 4: Air Quality Display
// ----------------------------------

void showAirQuality(int fanId) {
  if (hasBme680) {
    // TODO: different range
    mapToFan(fanId, gas_resistance, airQualityRange);
  } else if (hasCCS811) {
    mapToFan(fanId, eCO2, airQualityRange);
  } else {
    // No sensor.
    setNoseColor(fanId, CRGB::Black);
    setFanBackground(fanId, CRGB::Black);
  }
}

void showNoseAirQuality(int fanId) {
  if (hasBme680) {
    // TODO: different range
    setGenericNose(fanId, gas_resistance, airQualityRange); 
  } else if (hasCCS811) {
    setGenericNose(fanId, eCO2, airQualityRange);
  } else {
    // No sensor.
    setNoseColor(fanId, CRGB::Black);
    setFanBackground(fanId, CRGB::Black);
  }  
}

// ----------------------------------
// 5: Time / clock
// ----------------------------------

void showTime(int fanId) {

  int currentHour = rtc.getHours();
  int currentMinute = rtc.getMinutes(); // 0-59

  int hour = currentHour;
  float factor = (12 / 60);
  int minuteAsHour =  (currentMinute * 12)/60;
  
  Serial.print("Time: ");
  Serial.print(currentHour);
  Serial.print(":");
  Serial.print(currentMinute);
  Serial.print("  Minute as Led Hour: ");
  Serial.print(minuteAsHour);
  Serial.println();

  CRGB color;
  color.red = 0xEE;
  color.green = 0xE8;
  color.blue =  0x20;
  
  //setFanBackground(fanId, color);
  setFanBackground(fanId, CRGB::Black);

  // TODO: Handle hour and minuteAsHour when on the same LED.
  if (hour == minuteAsHour) {
    setLedByHour(fanId, hour, CRGB::Red);
  } else {
    setLedByHour(fanId, hour, CRGB::Green);
    setLedByHour(fanId, minuteAsHour, CRGB::Blue);
  } 
}

// ----------------------------------
// 6: Circle (single fan)
// ----------------------------------

void showRunningClock(int fanId) {
  setLedByHour(fanId, lastRedHourIndex, CRGB::Blue);
  setLedByHour(fanId, redHourIndex, CRGB::Red);
}

// ----------------------------------
// 7: Circle (all fans)
// ----------------------------------

void showAllFansRunningClock(int fanId) {
  //
}

// ----------------------------------
// 8:Dust Display
// ----------------------------------

void showDust(int fanId) {
  
}

void showNoseDust(int fanId) {

}

// ----------------------------------
// 9: Timer
// ----------------------------------

void showTimer(int fanId) {
  
}

void showNoseTimer(int fanId) {
  
}

// ----------------------------------
// 10: Pomodoro. Work (25min) + Play (5 min)
// ----------------------------------

// ----------------------------------
// 11: Pomodoro Work only (0..25 mins)
// ----------------------------------
// TODO: Link to the play fan.

// ----------------------------------
// 12: Pomodoro Play (0..5 mins)
// ----------------------------------
// TODO: Link to the work fan.

// ----------------------------------
// 13: Fixed Color
// ----------------------------------
void showFixedColor(int fanId) {
  CRGB color = fanInfos[fanId].outerColor;
  setFanBackground(fanId, color);
}

void showNoseFixedColor(int fanId) {
  CRGB color = fanInfos[fanId].noseColor; 
  setNoseColor(fanId, color);
}

// ----------------------------------
// 14: Fan Selected Speed
// ----------------------------------
void showFanSelectedSpeed(int fanId) {
  // 0..11 - directly maps to the hour.
  int speed = fanInfos[fanId].speedSet * (11 / 100);

  setFanBackground(fanId, CRGB::Blue);

  for (int i = 0; i<=speed; i++) {
    setLedByHour(fanId, i, CRGB::Green);
  }
}

// ----------------------------------
// 15: Fan Speed
// ----------------------------------
void showFanRpmSpeed(int fanId) {
  displayRange_t displayRange = setupFanDisplayRange(fanId);
  int rpm = fanInfos[fanId].computedRpm;
  mapToFan(fanId, rpm, displayRange);
}

void showNoseFanSpeed(int fanId) {
  // Default to green...
  setNoseColor(fanId, CRGB::Green);
  
  // but show an error if the speed is low.
  showFanSpeedLowError(fanId);
}

// If the fan has a speed error light up the nose
// a bright red color, otherwise leave it as is.
void showFanSpeedLowError(int fanId) {
 
  // If the power is off then ignore any fan speed setting.
  if (!isFanOn(fanId)) {
    return;
  }

  // If fan is stopped then ignore.
  int selectedSpeed = fanInfos[fanId].speedSet;
  if (selectedSpeed == 0) {
    return;
  }

  if (!fanInfos[fanId].enabled) {
    return;
  }

  // Needs updating for speed as %
  /*
  int rpm = fanInfos[fanId].computedRpm;
  // Expect the rpm to be at-least that of the speed below the
  // currentl selected one.  
  int minRpm = fanInfos[fanId].expectedRpm[selectedSpeed-1];

  if (rpm < minRpm) {
    Serial.print("Fan ");
    Serial.print(fanId + 1);
    Serial.print(" RPM below range. Making a red nose.");
    Serial.println();
    setNoseColor(fanId, CRGB::Red);
  } 
  */
}

displayRange_t setupFanDisplayRange(int fanId) {

  fanInfo_t fanInfo = fanInfos[fanId];

  // Ideal value depends on the fan PWM.
  // Min/Max depend on the fan...
  displayRange_t range;
  range.idealValue = 1200; //fanInfo.expectedRpm[fanInfo.speedSet]; // lookup fan/ fan run mode. HACK!
  
  if (fanInfo.speedSet > 0) {
    range.idealRangeLow = 500; //fanInfo.expectedRpm[fanInfo.speedSet-1]; 
  } else {
    range.idealRangeLow = 0;
  }

  // 11 is the max fan speed setting (0..100).
  if (fanInfo.speedSet < 100) {
    range.idealRangeHigh = 1200; //fanInfo.expectedRpm[fanInfo.speedSet+1]; 
  } else {
    range.idealRangeHigh = 1200; //fanInfo.expectedRpm[fanInfo.speedSet] + 50;
  }

  // Hack for the -ve value to balance the display.
  // Assume fan doesn't go above 2000 RPM.
  range.minValue = -(2* fanInfo.expectedRpm[3]);
  range.maxValue = 2* fanInfo.expectedRpm[3];  

  range.factor = 1;
  return range;
}

int fancy_k = 0;
int fancy_j = 0;
int dim=2;
int8_t gHue = 0; // rotating "base color" used by many of the patterns
static uint8_t hue = 0;


// ----------------------------------
// 17: Show the WiFi signal Strength
// ----------------------------------
void showWiFiStrength(int fanId) {
 
  // Assume -40 and above for rssi is good...
  if (rssi > -40) {
    setFanBackground(fanId, CRGB::Green);
  } else {
  
    // hack with +120 se we use only the -ve (red) area.
    // Use 0,11, 10...1 hour positions to indicate
    // 0 to -120 RSSI values.
    // With Green for 0 to -50, 
    // Blue for -50 to -80
    // and red below -80.
    int hour = getRangeHour(rssi, -120, 120);
  
    // Set all the LEDs to the background color
    // then set just the ones appropriate.
    setFanBackground(fanId, CRGB::Yellow);
  
    CRGB color;
    
    if (rssi < -80) {
      color = CRGB::Red;
    } else if (rssi<-50) {
      color = CRGB::Blue;
    } else {
      color = CRGB::Green;
    }
    setLedHourRange(fanId, rssi, wifiDisplayRange, hour, color);
  }
}

// Use the nose to show connectivity.
// Unless connected with a bad signal strength
void showNoseWiFiStrength(int fanId) {
  switch (WiFi.status()) {
    case WL_CONNECTED:
      if (rssi > -30) {
        setNoseColor(fanId, CRGB::Green);
      } else if (rssi > -60) {
        setNoseColor(fanId, CRGB::Yellow);
      } else {
        setNoseColor(fanId, CRGB::Red);
      }
      break;
    case WL_NO_SHIELD:
    case WL_IDLE_STATUS:
    case WL_NO_SSID_AVAIL:
      setNoseColor(fanId, CRGB::Blue);
      break;
    case WL_CONNECT_FAILED:
    case WL_CONNECTION_LOST:
    case WL_DISCONNECTED:
      setNoseColor(fanId, CRGB::Red);
      break;
    default:
      // Unknown state.
      setNoseColor(fanId, CRGB::Blue);
      break;
  }
}

// ----------------------------------
// 19: On/Off indicator (either red/green, or off/green or white).
// ----------------------------------
void showOnOff(int fanId) {
  if (mqttFeedsValue[fanId] > 0) {
    setFanBackground(fanId, CRGB::Green);
  } else {
    setFanBackground(fanId, CRGB::Red);
  }
}

void showNoseOnOff(int fanId) {
  if (mqttFeedsValue[fanId] > 0) {
    setNoseColor(fanId, CRGB::Green);
  } else {
    setNoseColor(fanId, CRGB::Red);
  }
}

// ----------------------------------
void showMqttFeed(int fanId) {
  // +/- 11 already
  int value = mqttFeedsValue[fanId];
}

void showNoseMqttFeed(int fanId) {
  // +/- 11 already
  int value = mqttFeedsValue[fanId];
}


// ----------------------------------
// 100: Fancy LEDs
// ----------------------------------
void showFancy(int fanId) {

   fancy_j++;
  if (fancy_j > NUM_LEDS) {
    //Serial.println("fancy_j reset");
    fancy_j = 0;
  }
    
   // CRGB ledColor = wheel(fancy_j, 2);   
    //setFanBackground(fanId, ledColor);

    // This looks fancy...
    // works way thought the LEDs each look
    /*
    // Set the i'th led to red 
    leds[fancy_j] = CHSV(hue++, 255, 255);
    // Show the leds
    FastLED.show(); 
    // now that we've shown the leds, reset the i'th led to black
    // leds[i] = CRGB::Black;
    fadeall();
    */

    // Sets fan to same color and works way through.
    setFanBackground(fanId, CHSV(hue++, 255, 255));
    setNoseColor(fanId, CHSV(hue++, 255, 128));
    delay(10);
    //leds[fancy_j] = CHSV(hue++, 255, 255);
}

void fadeall() { for(int i = 0; i < NUM_LEDS; i++) { leds[i].nscale8(250); } }

// https://github.com/FastLED/FastLED/blob/master/examples/DemoReel100/DemoReel100.ino
void fancyBeats() {
 
   // colored stripes pulsing at a defined Beats-Per-Minute (BPM)
  uint8_t BeatsPerMinute = 62;
  CRGBPalette16 palette = PartyColors_p;
  uint8_t beat = beatsin8( BeatsPerMinute, 64, 255);
  for( int i = 0; i < NUM_LEDS; i++) { //9948
    leds[i] = ColorFromPalette(palette, gHue+(i*2), beat-gHue+(i*10));
  }
}


void sinelon()
{
  // a colored dot sweeping back and forth, with fading trails
  fadeToBlackBy( leds, NUM_LEDS, 20);
  int pos = beatsin16( 13, 0, NUM_LEDS-1 );
  leds[pos] += CHSV( gHue, 255, 192);
}

void addGlitter( fract8 chanceOfGlitter) 
{
  if( random8() < chanceOfGlitter) {
    leds[ random16(NUM_LEDS) ] += CRGB::White;
  }
}

void showNoseFancy(int fanId) {
  // Color code the nose to indicate which parameter.
  //CRGB ledColor = wheel(fancy_j , dim);   
  //setNoseColor(fanId, ledColor);
}

CRGB wheel(int WheelPos, int dim) {
  
  
  CRGB color;
  if (85 > WheelPos) {
   color.r=0;
   color.g=WheelPos * 3/dim;
   color.b=(255 - WheelPos * 3)/dim;;
  } 
  else if (170 > WheelPos) {
   color.r=WheelPos * 3/dim;
   color.g=(255 - WheelPos * 3)/dim;
   color.b=0;
  }
  else {
   color.r=(255 - WheelPos * 3)/dim;
   color.g=0;
   color.b=WheelPos * 3/dim;
  }

  Serial.print("Wheel: ");
  Serial.print(WheelPos);
  Serial.print(", R: ");
  Serial.print(color.r);
  Serial.print(", G: ");
  Serial.print(color.g);
  Serial.print(", B: ");
  Serial.print(color.b);
  Serial.println();
  return color;
}

// ----------------------------------
// 255: Automatic
// ----------------------------------
// Automatic - show which ever measurement needs attemption
void showAutomatic(int fanId) {
  
}

void showNoseAutomatic(int fanId) {
  // Color code the nose to indicate which parameter.
}

// ----------------------------------

// Map the desired value onto the fan surround.
// where desired value == 12 o'clock
// Min = 7 o'clock
// Max = 6 (or 5) o'clock
// int desiredValue, int idealRangeMin, int idealRangeMax, int minValue, int maxValue)
void mapToFan(int fanId, int value, displayRange_t displayRange) {
  // The hour on the clock the value represents
  int hour = getRangeHour(value, displayRange.minValue, displayRange.maxValue);
  /*
  Serial.print("Hour:");
  Serial.print(hour);
  Serial.print(", Max:");
  Serial.print(displayRange.maxValue);
  Serial.print(", Min:");
  Serial.print(displayRange.minValue);

  Serial.println();
  */

  // Set all the LEDs to the background color
  // then set just the ones appropriate.
  CRGB backgroundColor = getBackgroundColor(fanId, value, displayRange);
  setFanBackground(fanId, backgroundColor);

  CRGB color = getRangeColor(value, displayRange);
  setLedHourRange(fanId, value, displayRange, hour, color);
}

// Get the value as an hour on the clock face.
int getRangeHour(int value, int minValue, int maxValue) {

  // Assumes full scale on the clock (0, 1, 2..11 and 0, 11, 10..1
  if (value > maxValue) {
    //Serial.println("Value above max hour display range.");
    return 11;  
  } else if (value < minValue) {
...

This file has been truncated, please download it to see its full contents.
fanControl.inoArduino
This handled the control of the fans. Not the LEDs on the fans, just the motors.
// Main 12V fan power rail switch.
int master_power_pin = 13; // RX

// State of the master power selection.
bool master_power = false;

volatile int fan_pulse_count[] = {0,0,0,0,0};

// The time when the fan RPM's were last computed.
// and with that, when the fan pulses were reset.
unsigned long lastFanRpmComputedAtMillis;

void setupFans() {
  // Switch off the 12V to the fans (TODO: Pull down resistor).
  pinMode (master_power_pin, OUTPUT);
  digitalWrite(master_power_pin, LOW);

  fanInfos[0] = setUpFan1(2, 0);
  fanInfos[1] = setUpFan2(3, 1);
  fanInfos[2] = setUpFan3(4, 8);
  fanInfos[3] = setUpFan4(5, 9); // Not listed as interrupt source on tech specs.
  fanInfos[4] = setUpFan5(10, 7);
  
  // Setup the fan PWMs and tach.
  for (int i = 0; i<5; i++) {
    pinMode(fanInfos[i].pwmPin, OUTPUT);
    analogWrite(fanInfos[i].pwmPin,0);
    pinMode(fanInfos[i].tachPin, INPUT_PULLUP);
  }

  // MKR1000 Rev.1: Pins [0], [1], 4, 5, 6, [7], [8], ([9]), A1/16, A2/17 (We're using, 0, 1, 8, 9 and 7 for fan tach)
  // Pin 9 not listed on main MKR1000 page as supporting interrupt
  // but is listed on attachInterrupt for Rev1.
  // and A2 + A3 for switches (A3 can probably be ignored.
  // Zero all digital pins, except 4
  attachInterrupt(fanInfos[0].tachPin, fan_one_pulse, FALLING); 
  attachInterrupt(fanInfos[1].tachPin, fan_two_pulse, FALLING);
  attachInterrupt(fanInfos[2].tachPin, fan_three_pulse, FALLING);
  // Not clear if pin 9 is int source + disconnected as it causes the 
  // arduino to lockup when pulsed by the tach.
  //attachInterrupt(fanInfos[3].tachPin, fan_four_pulse, FALLING);
  attachInterrupt(fanInfos[4].tachPin, fan_five_pulse, FALLING);
}

void fansLoop() {
  updateFanSpeeds();

  // Fan RPM's are computed every n seconds.
  if (millis() - lastFanRpmComputedAtMillis > 10000) {
    updateFanPulseCounts();
  }
}

void updateFanSpeeds() {
  for (int fanId = 0; fanId<4; fanId++) {
    fanInfo_t fanInfo = fanInfos[fanId];
    if (fanInfo.speedSet != fanInfo.currentSpeed) {
      setFan(fanId);
    }
  }
}

void updateFanPulseCounts() {
  // Current RPM computed from pulse counts
  int computedRpm;

  // How long (ms) since we've last computed and hence
  // how long is the pulse count over.
  int timeSinceLastCompute = (millis() - lastFanRpmComputedAtMillis);

  // Start by storing the pulses read from the fan tach interrupts.
  // into the fanInfo struct and resetting the pulse info.
  for (int fanId = 0; fanId < 4; fanId++) {
    
    fanInfos[fanId].pulseCount = fan_pulse_count[fanId];
    fan_pulse_count[fanId] = 0;

    if (fanInfos[fanId].pulseCount > 0) {
      // Ensure the array version is updated, not the local copy.
      fanInfos[fanId].computedRpm = computeFanSpeedRpm(fanId, timeSinceLastCompute);
    } else {
      fanInfos[fanId].computedRpm = 0;
    }
  }

  lastFanRpmComputedAtMillis = millis();
}

int computeFanSpeedRpm(int fanId, int timeSinceLastCompute) {
  fanInfo_t fanInfo = fanInfos[fanId];
  int time_factor = 60000 / timeSinceLastCompute;    
  int total_pulses_per_minute = fanInfo.pulseCount * time_factor;
  return total_pulses_per_minute / fanInfo.pulseToRpmFactor;
}


// Set the fans speed as requested in the speedSet
// property of fanInfo
void setFan(int fanId) {
  fanInfo_t fanInfo = fanInfos[fanId];
  int set_speed = fanInfo.speedSet;
  int current_speed = fanInfo.currentSpeed;
  int pwm_frequency = (int)(set_speed * 2.55);
     
  analogWrite(fanInfo.pwmPin, pwm_frequency);

  // Don't use fanInfo. as it's a copy and 
  // the fanInfos array isn't updated.
  fanInfos[fanId].currentPwm = pwm_frequency;
  fanInfos[fanId].currentSpeed = set_speed; //speed;
}

// Switch the power on/off for the fans.
// PWM fans tend to run even at 0 value
// so master power here shuts down the 12v rail
void setPower(bool state) { 
  digitalWrite(master_power_pin, state);
  master_power = state;

  if (master_power) {
    Serial.println("Fan Power ON");
  } else {
    Serial.println("Fan Power OFF");
  }
}

bool isMasterPowerEnabled() {
  return master_power;
}

bool isFanOn(int fanId) {
  if (!master_power) {
    return false;
  }

  if (!fanInfos[fanId].enabled) {
    return true;
  }

  return false;
}

// Set the speed of the fans
void setFansSpeed(int speed) {
  String message;
  message = "Setting fans to speed ";
  message = message + speed;
  message = message + "%";
  
  publishTinamousStatus(message);
  
  for (int i=0; i<4; i++) {
    setFansSpeed(i, speed);
  }
}

void setFansSpeed(int fanId, int speed) {
  fanInfos[fanId].speedSet = speed;
  
  if (fanInfos[fanId].speedSet > 100) {
    fanInfos[fanId].speedSet = 100;
  }
}

// Delay on the main loop (and hence LED cycle) 
// based on the fan speed. So running dots
// go faster for a faster fan speed.
void fanSpeedDelay() {
int delayFactor;

  if (isMasterPowerEnabled()) {
    delayFactor = 100 - fanInfos[0].currentSpeed;
  } else {
    // fans not on, go very slow...
    delayFactor = 100;
  }

  // 0 to 1s delay in loop for fan speed (100% to 0%)
  delay(delayFactor);
}

// ============================================
// Setup fan parameters
// ============================================

fanInfo_t setUpLL120Fan(int fanId, int pwm_pin, int tach_pin) {
  fanInfo_t fanInfo;
  fanInfo.fanId = fanId;
  fanInfo.pwmPin = pwm_pin;
  fanInfo.tachPin = tach_pin;
  fanInfo.enabled = true;
  fanInfo.pulseCount = 0;
  // Current RPM computed from pulse counts
  fanInfo.computedRpm = 0;
  // Array of RPM's expected indexed by fanModel
  // e.g. [0] = 0, [1] = 400, [2] = 600, [3] = 800, ... [11]
  // Leave as defaults
  //fanInfo.expectedRpm[0] = 0;
  fanInfo.speedSet = 0;
  // 600 - 1500 +/- 10% RPM
  fanInfo.pulseToRpmFactor = 2; // 1, 2, or 4 typically.
  return fanInfo;
}

fanInfo_t setUpFan1(int pwm_pin, int tach_pin) {
  fanInfo_t fanInfo = setUpLL120Fan(1, pwm_pin, tach_pin);
  fanInfo.outerColor = CRGB::Green;
  fanInfo.noseColor = CRGB::Orange;
  return fanInfo;
}

fanInfo_t  setUpFan2(int pwm_pin, int tach_pin) {
  fanInfo_t fanInfo = setUpLL120Fan(2, pwm_pin, tach_pin);
  fanInfo.outerColor = CRGB::Green;
  fanInfo.noseColor = CRGB::Blue;
  return fanInfo;
}

fanInfo_t  setUpFan3(int pwm_pin, int tach_pin) {
  fanInfo_t fanInfo = setUpLL120Fan(3, pwm_pin, tach_pin);
  fanInfo.outerColor = CRGB::Green;
  fanInfo.noseColor = CRGB::Orange;
  return fanInfo;
}

fanInfo_t  setUpFan4(int pwm_pin, int tach_pin) {
  fanInfo_t fanInfo = setUpLL120Fan(4, pwm_pin, tach_pin);
  fanInfo.outerColor = CRGB::Green;
  fanInfo.noseColor = CRGB::Orange;
  return fanInfo;
}

// Expected to be 40mm fan 
fanInfo_t setUpFan5(int pwm_pin, int tach_pin) {
  fanInfo_t fanInfo;
  fanInfo.fanId = 5;
  fanInfo.pwmPin = pwm_pin;
  fanInfo.tachPin = tach_pin;
  fanInfo.enabled = false; // not fitted
  fanInfo.pulseCount = 0;
  // Current RPM computed from pulse counts
  fanInfo.computedRpm = 0;
  // Array of RPM's expected indexed by fanModel
  // e.g. [0] = 0, [1] = 400, [2] = 600, [3] = 800, ... [11]
  // Leave as defaults
  //fanInfo.expectedRpm[0] = 0;
  fanInfo.speedSet = 0;
  fanInfo.pulseToRpmFactor = 1; // 1, 2, or 4 typically.
  fanInfo.outerColor = CRGB::Green;
  fanInfo.noseColor = CRGB::Orange;
  return fanInfo;
}

// ==========================================================
// Interrupt handlers
// ==========================================================
void fan_one_pulse() {
  fan_pulse_count[0]++;
}

void fan_two_pulse() {
  fan_pulse_count[1]++;
}

void fan_three_pulse() {
  fan_pulse_count[2]++;
}

void fan_four_pulse() {
  fan_pulse_count[3]++;
}

void fan_five_pulse() {
  fan_pulse_count[4]++;
}
sensors.inoArduino
Responsible for reading the sensors.
#include <SparkFunCCS811.h>

// When the sensors should next be read
unsigned long nextSensorRead = 0;
int sensorReadIntervalSeconds = 30;

int dust_sensor_pin = 6;

// I2C BME680 (Optional)
// Watch address, it might clash with a BME280 if that is also fitted.
Adafruit_BME680 bme680;

// CCS811 sensor (Optional).
#define CCS811_ADDR 0x5B //Default I2C Address
//#define CCS811_ADDR 0x5A //Alternate I2C Address
CCS811 ccs811(CCS811_ADDR);

// TCS34725 Light sensor on the Environment cap.
Adafruit_TCS34725 tcs = Adafruit_TCS34725(TCS34725_INTEGRATIONTIME_2_4MS, TCS34725_GAIN_4X);

// Voltage (12V across R3 (10k) and R2 (43k) )
// 12V --43k--|--10k-- GND
// R3 / (R3+R2) => 0.188 or *5.3 correction
// 3.3 reference, 12 bit ADC -> 0.80566mV/bit.
int voltage_measure_pin = 6; // A6

void setupSensors() {
  // Set to use 12 bit (0-4095) ADC
  analogReadResolution(12);

  setupBME680();
  setupCCS811();
  setupLightSensor();
}


void setupBME680() {
  // If fitted
  Serial.println("Setup BME680...");
  
  // Pimoroni's 680 is at 0x76
  // Adafruit's to 0x77
  if (bme680.begin(0x76)) {
    hasBme680 = true;
    // Set up oversampling and filter initialization
    bme680.setTemperatureOversampling(BME680_OS_8X);
    bme680.setHumidityOversampling(BME680_OS_2X);
    bme680.setPressureOversampling(BME680_OS_4X);
    bme680.setIIRFilterSize(BME680_FILTER_SIZE_3);
    bme680.setGasHeater(320, 150); // 320*C for 150 ms
    Serial.println("BME680 Initialised");
  } else {
    hasBme680 = false;
    Serial.println("Could not find a valid BME680 sensor, check wiring!");
  }
}

void setupCCS811() {
  Serial.println("Setup CCS811...");
  
  bool status = ccs811.begin();
  if (status > 0) {
    Serial.print("CCS811 error. Code: ");
    Serial.println(status);
    hasCCS811 = false;
    return;
  }
  hasCCS811 = true;
  
  // meaure every 10 seconds
  //ccs811.setDriveMode(2);

  // meaure every 1 seconds
  ccs811.setDriveMode(1);

  // Set defaults for now.
  // should be updated once the BME readings are present
  ccs811.setEnvironmentalData(humidity, temperature);

  ccs811DataUsableAfter = millis() + (20 * 60 * 1000);
  Serial.print("CCS811 Data Usable After: ");
  Serial.print(ccs811DataUsableAfter/1000);
  Serial.println("s");

  // TODO: Set baseline from eeprom
}

void setupLightSensor() {
  Serial.println("Setup TCS34725 light sensor...");
  
  if (tcs.begin()) {
    Serial.println("TCS34725 light sensor found and initalized.");
    hasLightSensor = true;
  } else {
    Serial.println("No TCS34725 found ... check your connections.");
    hasLightSensor = false;
    return;
  }
}

// ===================================================
// Loop to read the sensors.
// ===================================================
void sensorsLoop() {
  if (nextSensorRead < millis()) {
        
    measureFanSupplyVoltage();
    readBME280Data();
    readBME680Data();
    readCCS811Data();
    readLightLevel();
    
    measureRssi();
    
    // refresh every n seconds
    nextSensorRead = millis() + (sensorReadIntervalSeconds * 1000);
  }
}

void measureFanSupplyVoltage() {
  // 1/5th of actual fan supply voltage.
  // should measure 0 when master power is off.
  int adcBits = analogRead(voltage_measure_pin);

  // Analog reference is 3.3V
  // Bits are 4096
  // so... 0.000806v per bit
  // 0.80566mV/bit.
  float measured = (0.80566F * (float)adcBits);
  voltage = (measured * 5.3F) / 1000.0;
}

void readBME280Data() {
  if (!hasBme280) {
    return;
  }
}

// Read Temperature/RH/Pressure/VOC's 
// from the BME680 sensor.
void readBME680Data() {
  if (!hasBme680) {
    return;
  }
  
  if (! bme680.performReading()) {
    Serial.println("Failed to perform reading :(");
    return;
  }

  temperature = bme680.temperature;
  pressure = bme680.pressure / 100;
  humidity = bme680.humidity;
  gas_resistance  = bme680.gas_resistance / 1000; 
  sensorSource = 2;
}

unsigned long lastAirMonitor = 0;

void readCCS811Data() {

  if (!hasCCS811) {
    return;
  }
  
  // CCS811
  if (ccs811.dataAvailable()) {       
    ccs811.readAlgorithmResults();
    eCO2 = ccs811.getCO2();
    tVOC = ccs811.getTVOC();
  } else if (ccs811.checkForStatusError())  {
    Serial.print("Status error from CCS811: "); 
    ccsLastStatusError = ccs811.getErrorRegister();
    Serial.print(ccsLastStatusError); 
    Serial.println(); 
  } else {
    Serial.println("CCS811 no data"); 
  }

  lastAirMonitor = millis();
}

void readLightLevel() {
  if (!hasLightSensor) {
    return;
  }
 
  tcs.getRawData(&redLightLevel, &greenLightLevel, &blueLightLevel, &clearLightLevel);
  colorTemperature = tcs.calculateColorTemperature(redLightLevel, greenLightLevel, blueLightLevel);
  lightLevelLux = tcs.calculateLux(redLightLevel, greenLightLevel, blueLightLevel); 
}

void readDustSensor() {
  // TODO:...
}
tinamousMqttClient.inoArduino
Responsible for MQTT interactions.

This file does the processing of status messages sent from Alexa via Tinamous, hence it's responsible for the Alexa control aspect.
#include <MQTTClient.h>
#include <system.h>
#include "secrets.h"

// Be sure to use WiFiSSLClient for an SSL connection.
// for a non ssl (port 1883) use regular WiFiClient.
//WiFiClient networkClient; 
WiFiSSLClient networkClient; 

// MQTT Settings defined in secrets.h
// Set buffer size as the default is WAY to small!.
MQTTClient mqttClient(4096); 

// If we have been connected since powered up 
bool was_connected = false;
String senml = "";
unsigned long nextSendMeasurementsAt = 0;

// converted to lower case in setup.
String lowerDeviceAtName = "@"DEVICE_USERNAME;

// ===============================================
// Setup the connection to the MQTT server.
// ===============================================
bool setupMqtt() {
  senml.reserve(4096);
  lowerDeviceAtName.toLowerCase();
  
  Serial.print("Connecting to Tinamous MQTT Server on port:");
  Serial.println(MQTT_SERVERPORT);
  mqttClient.begin(MQTT_SERVER, MQTT_SERVERPORT, networkClient);

  // Handle received messages.
  mqttClient.onMessage(messageReceived);

  connectToMqttServer();
}

// ===============================================
// Connect/reconnect to the MQTT servier.
// This may be called repeatedly and does nothing
// if already connected.
// ===============================================
bool connectToMqttServer() { 
  if (mqttClient.connected()) {
    return true;
  }

  Serial.println("checking wifi..."); 
  if (WiFi.status() != WL_CONNECTED) { 
    Serial.print("WiFi Not Connected. Status: "); 
    Serial.print(WiFi.status(), HEX); 
    Serial.println();
    
    //WiFi.begin(ssid, pass);
    //delay(1000); 
    return false;
  } 
 
  Serial.println("Connecting to MQTT Server..."); 
  if (!mqttClient.connect(MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD)) { 
    Serial.println("Failed to connect to MQTT Server."); 
    Serial.print("Error: "); 
    Serial.print(mqttClient.lastError()); 
    Serial.print(", Return Code: "); 
    Serial.print(mqttClient.returnCode()); 
    Serial.println(); 

    if (mqttClient.lastError() == LWMQTT_CONNECTION_DENIED) {
      Serial.println("Access denied. Check your username and password. Username should be 'DeviceName.AccountName' e.g. MySensor.MyHome"); 
    }

    if (mqttClient.lastError() == -6) {
      Serial.println("Check your Arduino has the SSL Certificate loaded for Tinmaous.com"); 
      // Load the Firmware Updater sketch onto the Arduino.
      // Use the Tools -> WiFi Firmware Updater utility
    }
    
    delay(10000); 
    return false;
  } 
 
  Serial.println("Connected!"); 

  // Subscribe to status messages sent to this device.
  mqttClient.subscribe("/Tinamous/V1/Status.To/" DEVICE_USERNAME); 

  // Subscribe to any additional data feeds for our displays.
  for (int i=0; i<4; i++) {
    if (mqttFeedsTopic[i] != "") {
      Serial.println("Subscribing to " + mqttFeedsTopic[i]);
      mqttClient.subscribe(mqttFeedsTopic[i]);
    }
  }

  // Subsribe to all of the command's for this device.
  mqttClient.subscribe("/Tinamous/V1/Commands/" DEVICE_USERNAME "/#");
  Serial.println("Subscribed."); 

  // Say Hi.
  if (was_connected) {
    // If we were previously conneced, give a reconnect message.
    Serial.println("****** Reconnect! *****************");
    publishTinamousStatus("Ouch. Appears we got disconnected, well I'm back now.");
  } else {
    // for the first connect, give a "Hello" message.
    publishTinamousStatus("Hello! I'm your biggest (and brightest) fan ;-) #SeeWhatIDidThere. Use '@" DEVICE_USERNAME " Help' for help.");
  }

  was_connected = true;
  return true;
} 

// ===============================================
// Loop processor for MQTT functions
// ===============================================
void mqttLoop() {
  // Call anyway, does nothing if already connected.
  connectToMqttServer();

  // Check inbound and keep alive.
  mqttClient.loop(); 

  // Send measurements (if the time interval is appropriate).
  sendMeasurements();
}

// ===============================================
// Send measurements (Temperature/RH/etc) to the MQTT server
// ===============================================
void sendMeasurements() {
  if (!mqttClient.connected()) {
    Serial.println("Not connected, not sending sensor measurements");
    nextSendMeasurementsAt = millis() + (60 * 1000);
    return;
  }

  beginSenML();
  
  if (nextSendMeasurementsAt < millis()) {
    // Send the measurements
    Serial.println("Sending sensor measurements to Tinamous");

    if (hasBme280 || hasBme680) {
      // Temperature
      appendFloatSenML("Temperature", temperature);
    
      // Humidity
      appendFloatSenML("humidity", humidity);
    
      // pressure
      appendFloatSenML("pressure", pressure);
    }

    if (hasCCS811) {
      // TVOC
      appendFloatSenML("TVOC", tVOC);
      // eCO2
      appendFloatSenML("eCO2", eCO2);

      // it's actually uint8_t
      appendUInt8SenML("ccsLastError", ccsLastStatusError);
    }

    if (hasBme680) {
      // Gas resistance (BME680)
      appendFloatSenML("GasR", gas_resistance);
    }
    
    // Light
    if (hasLightSensor) {
      appendUInt16SenML("light", lightLevelLux);
      appendUInt16SenML("red", redLightLevel);
      appendUInt16SenML("green", greenLightLevel);
      appendUInt16SenML("blue", blueLightLevel);
      appendUInt16SenML("clear", clearLightLevel);
      appendUInt16SenML("colorTemp", colorTemperature);
    }

    // Set color
    // TODO: Use the set HSV value.
    appendStringSenML("color", "12,0.1,0.2");
    //ledsSetColor
    
    // RSSI
    appendFloatSenML("rssi", rssi);

    // Fan Supply Voltage
    appendFloatSenML("fanv", voltage);
    
    // Speed selected
    appendIntSenML("setSpeed", fanInfos[0].speedSet);
    
    // Master power state
    int power = isMasterPowerEnabled() ? 1 : 0;
    appendIntSenML("masterPower", power);

    String fieldname = "";
    
    // Fan speed (RPM)
    // and Display mode for the fans
    for (int fanId=0; fanId<4; fanId++) {
      fieldname = "FanRpm-";
      fieldname = fieldname + fanId;
      appendIntSenML(fieldname, fanDisplayModes[fanId]);
      fieldname = "Display-";
      fieldname = fieldname + fanId;
      appendIntSenML(fieldname, fanDisplayModes[fanId]);
    }
    
    // ledBrightnessPercent (Last)
    appendLastIntSenML("ledBrightness", ledBrightnessPercent);

    sendSenML();

    // Schedule the next publish of sensor measurements
    // to be in n seconds time...
    nextSendMeasurementsAt = millis() + (30 * 1000);
  }
}

void beginSenML() {
  senml = "{'e':[";
}

void appendIntSenML(String name, int value) {
  senml = senml + constructSenMLFieldStart(name);
  senml = senml + value;
  senml = senml + "},";
}

// The last measurement...
void appendLastIntSenML(String name, int value) {
    senml = senml + constructSenMLFieldStart(name);
    senml = senml + value;
    senml = senml + "}";
}

void appendFloatSenML(String name, float value) {
    senml = senml + constructSenMLFieldStart(name);
    senml = senml + value;
    senml = senml + "},";
}

void appendUInt8SenML(String name, uint8_t value) {
    senml = senml + constructSenMLFieldStart(name);
    senml = senml + value;
    senml = senml + "},";
}

void appendUInt16SenML(String name, uint16_t value) {
    senml = senml + constructSenMLFieldStart(name);
    senml = senml + value;
    senml = senml + "},";
}

void appendStringSenML(String name, String value) {
  senml = senml + "{'n':'" + name + "', 'sv':'";
  senml = senml + value;
  senml = senml + "'},";
}

String constructSenMLFieldStart(String name) {
  return "{'n':'" + name + "', 'v':";
}

void sendSenML() {
  // Terminate the json and send the senml to Tinamous
  senml = senml +  "]}";
  Serial.println("Senml:");
  Serial.println(senml);

  if (senml.length() > 2048) {
    Serial.println("*** SENML too long. It will overflow the buffer ***");
  }
  publishTinamousSenMLMeasurements(senml);
}

// ===============================================
// Pubish a status message to the Tinamous MQTT
// topic.
// ===============================================
void publishTinamousStatus(String message) {
  Serial.println("Status: " + message);
  mqttClient.publish("/Tinamous/V1/Status", message); 

  if (mqttClient.lastError() != 0) {
    Serial.print("MQTT Error: "); 
    Serial.print(mqttClient.lastError()); 
    Serial.println(); 
  }

  if (!mqttClient.connected()) {
    Serial.print("Not connected after publishing status. What happened?");
  } else {
    Serial.print("Status message sent.");
  }
}

void publishTinamousSenMLMeasurements(String senml) {
  if (senml.length()> 4096) {
    Serial.println("senml longer than buffer. Ignoring!!!");
    return;
  }
  
  mqttClient.publish("/Tinamous/V1/Measurements/SenML", senml); 
}



// =========================================================================================

// ===============================================
// Process messages received from the MQTT server
// ===============================================
void messageReceived(String &topic, String &payload) { 
  Serial.println("Message from Tinamous on topic: " + topic + " - " + payload); 

  // Show a little light display to show Alexa interaction.
  showAlexaConnectionActive();
  
  // If the payload starts with "/Tinamous/V1/Commands/" DEVICE_USERNAME 
  // then we should handle that...
  if (topic.startsWith("/Tinamous/V1/Commands/" DEVICE_USERNAME)) {
    handleCommand(topic, payload);
    return;
  }

  payload.toLowerCase();

  // If it starts with an @ it's a status post to this deivce.
  if (payload.startsWith(lowerDeviceAtName)) {
    
    // Clean up the status message.
    // replace it with a space so our index of
    // isn't 0 for the start of the line.    
    payload.replace(lowerDeviceAtName, " ");

    if (handleStatusMessage(payload)) {
      return;
    }
  } 

  // Otherwise check to see if the data has come from a custom MQTT subscription
  if (checkCustomMqttFeeds(topic, payload)) {
    return;
  }

  // And finally, reply with unknown message.
  Serial.print("Unknown message: '");
  Serial.print(payload);
  Serial.println("'");
  publishTinamousStatus("Unknown message. use help.");
} 

// =================================================
// Custom MQTT topic handling
// =================================================

bool checkCustomMqttFeeds(String &topic, String &payload) {
  for (int i=0; i<4; i++) {
    if (mqttFeedsTopic[i] == topic) {
      handleCustomMqttFeed(i, topic, payload);
      return true;
    }
  }
  return false;
}

// Expect this to be a simple value containing payload
void handleCustomMqttFeed(int index, String &topic, String &payload) {
int value = payload.toInt();
  Serial.println("Setting custom mqtt feed value to " + value);
  mqttFeedsValue[index] = payload.toInt();
}

// =================================================
// Status message handling
// This is where the device had an "@DeviceName Do Stuff
// message posted to it.
// =================================================
bool handleStatusMessage(String payload) {
int payloadValue;

  // This does FANS only, not leds.
  if (payload.indexOf("fans on")> 0 || payload.indexOf("turn on the fans")> 0) {
    Serial.println("Turn on the fans!");
    setFansSpeed(50);
    setPower(1);
    publishTinamousStatus("Fans on.");
    return true;
  }

  // This does FANS only, not leds.
  if (payload.indexOf("fans off")> 0 || payload.indexOf("turn off the fans")> 0) {
    Serial.println("Turn off the fans!");
    setPower(0);
    setFansSpeed(0);
    publishTinamousStatus("fans off.");
    return true;
  }

  // Alexa, Turn off the fans
  // "turn off" from alexa mapped to turning off the fans and the lights
  if (payload.indexOf("sleep")> 0 || payload.indexOf("turn off")> 0) {
    sleepNow();
    return true;
  }

  // Alexa, Turn on the fans
  // "turn on" from alexa mapped to waking the fans and the lights.
  if (payload.indexOf("wake")> 0 || payload.indexOf("turn on") > 0) {
    wakeNow();
    return true;
  }   

  if (payload.indexOf("leds off")> 0) {
    ledBrightnessPercent = 0;
    return true;
  } 

  if (payload.indexOf("leds on")> 0) {
    ledBrightnessPercent = 50;
    return true;
  } 

  // Alexa command to dim the LEDs.
  if (payload.indexOf("dim leds by")> 0) {
    // TODO: Get the value...
    Serial.println("Dim the leds by x!");
    payload.replace("dim leds by", "");
    payload.trim();
    Serial.println(payload);
    payloadValue = payload.toInt();
    ledBrightnessPercent = ledBrightnessPercent - payloadValue;
    if (ledBrightnessPercent < 0) {
      ledBrightnessPercent = 0;
    }
  }

  // Alexa, set brightness of ___ to 20%
  if (payload.indexOf("set brightness")> 0) {
    // TODO: Get the value...
    Serial.println("Set leds brightness!");
    payload.replace("set brightness", "");
    payload.trim();
    Serial.println(payload);
    payloadValue = payload.toInt();
    ledBrightnessPercent = payloadValue;
    if (ledBrightnessPercent < 0) {
      ledBrightnessPercent = 0;
    }
    if (ledBrightnessPercent > 100) {
      ledBrightnessPercent = 100;
    }

    return true;
  }

  if (payload.indexOf("adjust brightness")> 0) {
    // TODO: Get the value...
    Serial.println("Set leds brightness!");
    payload.replace("adjust brightness", "");
    payload.trim();
    Serial.println(payload);
    payloadValue = payload.toInt();
    ledBrightnessPercent += payloadValue;
    if (ledBrightnessPercent < 0) {
      ledBrightnessPercent = 0;
    }
    if (ledBrightnessPercent > 100) {
      ledBrightnessPercent = 100;
    }
    
    return true;
  }

  // Alexa, Set Powerlevel to 20% for the fans.
  // If the message is "fans speed ##"
  if (payload.indexOf("fan speed")> 0 || payload.indexOf("set powerlevel") > 0) {
    payload.replace("fan speed", "");
    payload.replace("set powerlevel", "");
    payload.trim();
    Serial.print("Fan Speed: '");
    Serial.println(payload);
    // Lets hope it's just an int...
    int speed = payload.toInt();

    if (speed < 0 || speed > 100) {
      return false;
    }
    
    Serial.print("Setting fans speed to ");
    Serial.println(speed, DEC);
    publishTinamousStatus("Fans speed set.");
    if (speed < 2) {
      // PWM fans don't stop at 0 they
      // just run at min speed
      // kill the power for anything 
      // less than 2%.
      setPower(0);
      setFansSpeed(speed);
    } else {
      setPower(1);
      setFansSpeed(speed);
    }
    return true;
  }

  // Alexa, set the fan to red
  // Alexa, change the fan to the color blue
  if (payload.indexOf("set color hsv") > 0) {
    payload.replace("set color hsv", "");
    payload.trim();

    // Now we need to split the hsv values "34.2,0.1, 0.2" =>
    Serial.print("Set color: ");
    Serial.println(payload);

    int commaIndex = payload.indexOf(',');
    int secondCommaIndex = payload.indexOf(',', commaIndex + 1);

    String stringValue;
    float hue;
    float saturation;
    float brightness;
    
    stringValue = payload.substring(0, commaIndex);
    hue = stringValue.toFloat();
    
    stringValue = payload.substring(commaIndex + 1, secondCommaIndex);
    saturation = stringValue.toFloat();
    
    stringValue = payload.substring(secondCommaIndex + 1);
    brightness = stringValue.toFloat();

    Serial.print("HSV: ");
    Serial.print(hue);
    Serial.print(", ");
    Serial.print(saturation);
    Serial.print(", ");
    Serial.print(brightness);
    Serial.println();

    ledsSetColor = CHSV(hue, saturation * 255, 40);
      
    return true;
  }
  
  if (payload.indexOf("help")> 0) {
    Serial.println("Sending help...");
    publishTinamousStatus(
    "Send a message to me (@" DEVICE_USERNAME ") then: \n"
    "'Turn On' to turn the fans and leds on \n" 
    "'wake'  to turn on the lights and fans \n"
    "\n"
    "'Turn Off' to turn the fans and leds off \n"
    "'sleep'  to turn off the lights and fans \n"
    "\n"
    "'fan speed 60' to set the speed to 60% \n"
    "'fans on' to switch on the fans (not leds) \n"
    "'fans off' to switch off the fans (not leds) \n"
    "\n"
    "'leds on'  to turn on the lights \n"
    "'leds off'  to turn off the lights \n"
    "\n"
    "'bright'  to turn the leds to bright \n"
    "'dim'  to dim the lights \n"
    "'set leds to 40' to set the LEDs to 40% brightness \n"
    "'dim leds by 20' to dim the LEDs to 20% \n"
    );
    return true;
  }
  
  return false;
}

// ==============================================
// Command handlers.
// ===============================================

// Handle a command that's come in via the MQTT subscription
void handleCommand(String &topic, String &payload) {
  Serial.println("Handle command: " + topic);
   
  // remove the common bit of the topic and handle just the specific command
  String baseCommand = "/Tinamous/V1/Commands/" DEVICE_USERNAME;
  // Caution! Replaces topic!
  topic.replace(baseCommand, "");
  Serial.print("Handle command: ");
  Serial.println(topic);

  if (topic.startsWith("/Fans/")) {
    handleFansCommands(topic, payload);
    return ;
  }

  // what are we doing with this? Use display instead?
  if (topic.startsWith("/Leds/")) {
    handleLedsCommands(topic, payload);
    return;
  }

  // /Sleep/Now | /Sleep/At/
  if (topic.startsWith("/Sleep/")) {
    handleSleepCommand(topic, payload);
    return;
  }

  // /Wake/Now | /Wake/At/
  if (topic.startsWith("/Wake/")) {
    handleWakeCommand(topic, payload);
    return;
  }

  Serial.print("Unknown command. Topic:");    
  Serial.println(topic);    
  publishTinamousStatus("Sorry I don't know that command.");
}

// ===============================================
// /Fans/Power + value in payload (1 = on, 0 = off).
// ===============================================
void handleFansCommands(String &topic, String &payload) {
int value;

  if (topic == "/Fans/Power") {        
    value = payload.toInt();
    Serial.print("Handle fan power. Requested: ");
    Serial.print(value);
    Serial.println();
    setPower(value);
  } else if (topic == "/Fans/SetSpeed") {
    value = payload.toInt();
    Serial.print("Handle fan speed. Speed Requested: ");
    Serial.print(value); // 0..100
    Serial.print(", payload: "); // 0..100
    Serial.print(payload); // 0..100
    Serial.println();
    setFansSpeed(value);
  } else {
    Serial.println("Unknown FAN command!");    
    publishTinamousStatus("Hello! Sorry I don't know that command. Please check your MQTT topic. Command: ");
  }
}

// ===============================================
// Handle Commands:
// ===============================================
// /Leds/Power + power in payload (1 on, 0 off)
// /Leds/Brightness + brigthness in payload (0..255)
// /Leds/Fanx/DisplayMode x = fan (1-4) + display mode in payload.
// /Leds/Strip/DisplayMode + display mode in payload.
void handleLedsCommands(String &topic, String &payload) {
int value;
value = payload.toInt();

  if (topic == "/Leds/Power") {
    // turn on/off the LEDs.
    if (value > 0) {
      ledBrightnessPercent = 50;
    }
  } else if (topic == "/Leds/Brightness") {
    // Set the brightness
    if (value > 0 && value <= 100) {
      ledBrightnessPercent =  value;
    } else {
      Serial.println("Invalid brightness");
       publishTinamousStatus("Invalid brightness. Range is 0..100. Thanks.");
    }
    Serial.print("Setting leds brightness: " );
    Serial.println(ledBrightnessPercent, DEC);
  } else if (topic == "/Leds/Fan1/DisplayMode") {
    // Set display type for LED1
    Serial.print("Handle fan 1 led DisplayMode: ");
    Serial.println(value);
    fanDisplayModes[0] =  (DisplayMode)value;
  } else if (topic == "/Leds/Fan2/DisplayMode") {
    Serial.print("Handle fan 2 led DisplayMode: ");
    Serial.println(value);
    fanDisplayModes[1] =  (DisplayMode)value;
  } else if (topic == "/Leds/Fan3/DisplayMode") {
    Serial.print("Handle fan 3 led DisplayMode: ");
    Serial.println(value);
    fanDisplayModes[2] =  (DisplayMode)value;
  } else if (topic == "/Leds/Fan4/DisplayMode") {
    Serial.print("Handle fan 4 led DisplayMode: ");
    Serial.println(value);
    fanDisplayModes[3] = (DisplayMode)value;
  } else if (topic == "/Leds/Strip/DisplayMode") {
    Serial.println("Handle led strip DisplayMode");
    // TODO: When we know how to handle this...
  } else {
    Serial.println("Unknown LED command!");    
    publishTinamousStatus("Hello! Sorry I don't know that command. Please check your MQTT topic. Command: ");
  } 
}



// ===============================================
// Handle Sleep request command
// ===============================================
// Commands:
// /Sleep/Now - sleeps NOW Fans and LEDs full off.
// /Sleep/At + value (hh:mm:ss) in payload Sleeps daily at that time
void handleSleepCommand(String &topic, String &payload) {
 
  if (topic == "/Sleep/At") {
    // Expect payload to be hh:mm:ss (or hh:mm)
    String sleepAt = payload;
    // Setup sleep mode at this tine
    Serial.print("Sleep at: "); 
    Serial.println(sleepAt); 
    Serial.println("*** Not implemented ***");
  } else if (topic == "/Sleep/Now") {
    sleepNow();
  }
}

// ===============================================
// Handle wake request command
// ===============================================
// Commands:
// /Sleep/Now - sleeps NOW Fans and LEDs full off.
// /Sleep/At + value (hh:mm:ss) in payload wakes daily at that time
void handleWakeCommand(String &topic, String &payload) {
 
  if (topic == "/Wake/At") {
    // Expect payload to be hh:mm:ss (or hh:mm)
    String wakeAt = payload;
    // Setup sleep mode at this tine
    Serial.print("Wake at: "); 
    Serial.println(wakeAt); 
    Serial.println("*** Not implemented ***");
  } else if (topic == "/Wake/Now") {
    wakeNow();
  }
}
wifiClient.inoArduino
Does WiFi related stuff :-)
#include "secrets.h" 

char ssid[] = SECRET_SSID;    
char pass[] = SECRET_PASS;    
int status = WL_IDLE_STATUS; 

void setupWiFi() {
  Serial.println("Connecting to WiFi...");

  // check for the presence of the shield:
  if (WiFi.status() == WL_NO_SHIELD) {
    Serial.println("WiFi shield not present");
    // don't continue:
    while (true);
  }

  // attempt to connect to WiFi network:
  while ( status != WL_CONNECTED) {
    Serial.print("Attempting to connect to WPA SSID: ");
    Serial.println(ssid);
    // Connect to WPA/WPA2 network:
    status = WiFi.begin(ssid, pass);

    // wait 10 seconds for connection:
    delay(10000);
  }

  // you're connected now, so print out the data:
  Serial.println("You're now connected to the network");
  printCurrentNet();
  printWiFiData();
}

// ---------------------------------------
// WiFi
void printWiFiData() {
  // print your WiFi shield's IP address:
  IPAddress ip = WiFi.localIP();
  Serial.print("IP Address: ");
  Serial.println(ip);
  Serial.println(ip);

  // print your MAC address:
  byte mac[6];
  WiFi.macAddress(mac);
  Serial.print("MAC address: ");
  Serial.print(mac[5], HEX);
  Serial.print(":");
  Serial.print(mac[4], HEX);
  Serial.print(":");
  Serial.print(mac[3], HEX);
  Serial.print(":");
  Serial.print(mac[2], HEX);
  Serial.print(":");
  Serial.print(mac[1], HEX);
  Serial.print(":");
  Serial.println(mac[0], HEX);

}

void printCurrentNet() {
  // print the SSID of the network you're attached to:
  Serial.print("SSID: ");
  Serial.println(WiFi.SSID());

  // print the MAC address of the router you're attached to:
  byte bssid[6];
  WiFi.BSSID(bssid);
  Serial.print("BSSID: ");
  Serial.print(bssid[5], HEX);
  Serial.print(":");
  Serial.print(bssid[4], HEX);
  Serial.print(":");
  Serial.print(bssid[3], HEX);
  Serial.print(":");
  Serial.print(bssid[2], HEX);
  Serial.print(":");
  Serial.print(bssid[1], HEX);
  Serial.print(":");
  Serial.println(bssid[0], HEX);

  // print the received signal strength:
  long rssi = WiFi.RSSI();
  Serial.print("signal strength (RSSI):");
  Serial.println(rssi);

  // print the encryption type:
  byte encryption = WiFi.encryptionType();
  Serial.print("Encryption Type:");
  Serial.println(encryption, HEX);
  Serial.println();
}


String hostName = "www.google.com";

void doPing() {
  Serial.print("Pinging ");
  Serial.print(hostName);
  Serial.print(": ");

  int pingResult = WiFi.ping(hostName);

  if (pingResult >= 0) {
    Serial.print("SUCCESS! RTT = ");
    Serial.print(pingResult);
    Serial.println(" ms");
  } else {
    Serial.print("FAILED! Error code: ");
    Serial.println(pingResult);
  }
}
 
void reconnectWiFi() {
  // attempt to reconnect to WiFi network if the connection was lost:
  while ( status != WL_CONNECTED) {
    Serial.print("Attempting to connect to WPA SSID: ");
    Serial.println(ssid);
    // Connect to WPA/WPA2 network:
    status = WiFi.begin(ssid, pass);

    if (status == WL_CONNECTED) {
      Serial.print("You're re-connected to the network");
      printCurrentNet();
      printWiFiData();
      return;
    }

    delay(5000);
  } 
}

void measureRssi() {
  rssi = WiFi.RSSI();
}
secrets.hArduino
This file contains secret passwords and device dependant settings. You will need to specify your own.
// Modify this file with your WiFi and device MQTT settings.

#define SECRET_SSID "MyWiFiSSID"
#define SECRET_PASS "Password goes in here"

/************************* Tinamous MQTT Setup *********************************/

#define MQTT_SERVER      "<account name>.tinamous.com" // e.g. demo.tinamous.com
#define MQTT_SERVERPORT  8883 

#define MQTT_USERNAME   "<device username>.<account name> // e.g. OfficeFans.demo
#define MQTT_PASSWORD   "device password"
#define MQTT_CLIENT_ID  "unique client id, anything you like really..."
#define DEVICE_USERNAME "device user name" // e.g. OfficeFans
Arduino Fan Controller
Includes the files for BOFF (PCB, enclosure and Arduino firmware) as well as a couple more projects based on the PCB / firmware (hopefully I've add them to Hackster by the time you're reading this)
Tinamous / Alexa Smart Home Skill
This is the generic smart home skill that can be used to link Tinamous devices to Alexa Smart Home.

Custom parts and enclosures

Cover Plate - VOCs
Vocs traced lsvqtv2f4u
Cover Plate - Humidity
Clouds traced flqmazclsi
Cover Plate - Radiation
Radiation traced cwine3rpq4
Fan Guard Spacer
M4 heatfit insert into the wide end, M3 into narrow. Screws onto fan mounting bolt (use a M4 nut first to hold the fan securely), the cover plates then screw onto this.
PCB Spacer (12mm)
Spacer to lift the PCB to the correct height for the USB outlet.
Outlet Blank - Echo Dot holder.
Goes on and outlet to blank it off and holds an Echo Dot.
Outlet plate
Put M3 machine screws through the acrylic, head inside, nut outside. This then sits in the hole and around the nuts to make the 3D print of outlets easier so they can have a flat base, whilst enabling retaining of the M3 securing bolts.
Outlet Blank - Echo Holder.
MakerCase json file
Upload this to MakerCase.com to edit the case design.
fanbox-v1_WRHU3aaLqV.json
Laser Cutting - Sheet 1
Sheet 1 (600x400 5mm Acrylic) for the enclosure (use the json file and MakerCase to generate cutting files for other thicknesses. this is 5mm ONLY!
Sheet1 fzzxa8asef
Laser Cutting - Sheet 2
Sheet2 bjz71cvkfg
Laser Cutting - Sheet 3
Sheet3 awwpvmsgak
Cover Plate - Temperature
Thermometer traced sok1fmphvs
Outlet Duct #1
Outlet Duct #2
This is smaller and prints slightly easier. (No supports needed, it should bridge just fine).

Schematics

Schematic
arduinorgbfancontroller_PeFnL9py5W.sch
Schematic (Image)
Schematic bdifk1wgav
PCB (Image, All layers)
Pcbdesign all teda2uus1f
PCB (Eagle)
arduinorgbfancontroller_9D7YUUj5L2.brd

Comments

Similar projects you might like

Arduino Bluetooth Basic Tutorial

by Mayoogh Girish

  • 454,894 views
  • 42 comments
  • 239 respects

Home Automation Using Raspberry Pi 2 And Windows 10 IoT

Project tutorial by Anurag S. Vasanwala

  • 285,722 views
  • 95 comments
  • 672 respects

Security Access Using RFID Reader

by Aritro Mukherjee

  • 231,178 views
  • 38 comments
  • 241 respects

OpenCat

Project in progress by Team Petoi

  • 196,367 views
  • 154 comments
  • 1,364 respects
Add projectSign up / Login