Stephen Harrison
Published © CC BY-SA

BOFF - Alexa Enabled Open Smart Fan

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

AdvancedShowcase (no instructions)Over 1 day9,370
BOFF - Alexa Enabled Open Smart Fan

Things used in this project

Hardware components

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
Arduino MKR1000
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

Software apps and online services

Tinamous
AWS Lambda
Amazon Web Services AWS Lambda
Alexa Skills Kit
Amazon Alexa Alexa Skills Kit

Hand tools and fabrication machines

Laser cutter (generic)
Laser cutter (generic)
3D Printer (generic)
3D Printer (generic)
Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Custom parts and enclosures

Cover Plate - VOCs

Cover Plate - Humidity

Cover Plate - Radiation

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.

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!

Laser Cutting - Sheet 2

Laser Cutting - Sheet 3

Cover Plate - Temperature

Outlet Duct #1

Outlet Duct #2

This is smaller and prints slightly easier. (No supports needed, it should bridge just fine).

Schematics

Schematic

Schematic (Image)

PCB (Image, All layers)

PCB (Eagle)

Code

Boff.ino

Arduino
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.h

Arduino
#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.ino

Arduino
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.ino

Arduino
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.ino

Arduino
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.ino

Arduino
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.ino

Arduino
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.h

Arduino
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.

Credits

Stephen Harrison

Stephen Harrison

18 projects • 51 followers
Founder of Tinamous.com, software developer, hardware tinkerer, dyslexic. @TinamousSteve

Comments