Scott McNabb
Published © GPL3+

Photon HVAC 4-Zone Damper Controller and Logging

This project fixed my problems with hot and cold rooms and logs details about how it was done, to compare to my ecobee4 thermostat details.

IntermediateShowcase (no instructions)Over 1 day1,649
Photon HVAC 4-Zone Damper Controller and Logging

Things used in this project

Hardware components

Photon
Particle Photon
×1
Particle Relay Shield for Photon 4-Channel
×1
60W PCIe 12V 5A Power Supply
Digilent 60W PCIe 12V 5A Power Supply
×1
SparkFun Qwiic I2C Adapter
×1
SparkFun Qwiic I2C OpenLog
×1
SanDisk Ultra 32GB microSDHC UHS-I Card with Adapter - 98MB/s U1 A1
×1
SparkFun Qwiic Mux I2C Breakout - 8 Channel (TCA9548A)
×1
SparkFun Differential I2C Breakout - PCA9615 (Qwiic)
×8
SparkFun Environmental Combo I2C Breakout - CCS811/BME280 (Qwiic)
×1
Adafruit MCP9808 TEMP I2C BREAKOUT BRD
×5
Adafruit BLACK NYLON SCREW AND STAND-OFF KIT
×1
30 ft White Cat6 Ethernet Patch Cable - RJ45, 550Mhz, Stranded 24AWG Copper
×2
25 ft White Cat6 Ethernet Patch Cable - RJ45, 550Mhz, Stranded 24AWG Copper
×1
50 ft White Cat6 Ethernet Patch Cable - RJ45, 550Mhz, Stranded 24AWG Copper
×1
263 x 182 x 60 mm gray box, IP65 ABS Plastic Enclosure 10.4 x 7.2 x 2.4 inch
×1
70 x 45 x 30 mm 5Pieces (4 used) White Plastic Box
×1
Zoning Supply PRO-Grade Power Zone Damper (Round, 4"), DSUP-04
×7
Thermostat wire, 18/3 solid, 15 m
×1
SparkFun Qwiic I2C Cable Kit (3x50,3x100,200,500,BB jumper, Female jumper)
×1
Hook-Up Wire - Assortment (Stranded, 22 AWG), 25
×1
IDEAL In-Sure push-in wire connectors
×2
Foil Duct Tape
×1

Software apps and online services

Particle Build Web IDE
Particle Build Web IDE

Hand tools and fabrication machines

Drill / Driver, Cordless
Drill / Driver, Cordless

Story

Read more

Schematics

Damper Controller Schematic

Code

SparkFun_Qwiic_OpenLog_Arduino_Library_SM

C/C++
Modified library to reduce logging errors.
/*
  This is a library written for the Qwiic OpenLog
  SparkFun sells these at its website: www.sparkfun.com
  Do you like this library? Help support SparkFun. Buy a board!
  https://www.sparkfun.com/products/14641
  Written by Nathan Seidle @ SparkFun Electronics, February 2nd, 2018
  Qwiic OpenLog makes it very easy to record data over I2C to a microSD.
  This library handles the initialization of the Qwiic OpenLog and the calculations
  to get the temperatures.
  https://github.com/sparkfun/SparkFun_Qwiic_OpenLog_Arduino_Library
  Development environment specifics:
  Arduino IDE 1.8.3
  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  GNU General Public License for more details.
  You should have received a copy of the GNU General Public License
  along with this program.  If not, see <http://www.gnu.org/licenses></http:>.
*/

#include "SparkFun_Qwiic_OpenLog_Arduino_Library.h"

//Attempt communication with the device
//Return true if we got a 'Polo' back from Marco
boolean OpenLog::begin(uint8_t deviceAddress, TwoWire &wirePort)
{
  _deviceAddress = deviceAddress; //If provided, store the I2C address from user
  _i2cPort = &wirePort; //Grab which port the user wants us to use

  //We require caller to begin their I2C port, with the speed of their choice
  //external to the library
  //_i2cPort->begin();

  //Check communication with device
  uint8_t status = getStatus();
  if(status & 1<<STATUS_SD_INIT_GOOD)
  {
    //We are good to go!
    return(true);
  }

  return (false); //SD did not init. Card not present?
}

//Simple begin
boolean OpenLog::begin(int deviceAddress)
{
  return(begin(deviceAddress, Wire));
}

//Get the version number from OpenLog
String OpenLog::getVersion()
{
  sendCommand(registerMap.firmwareMajor, "");
  //Upon completion Qwiic OpenLog will have 2 bytes ready to be read
  _i2cPort->requestFrom(_deviceAddress, (uint8_t)1);

  uint8_t versionMajor = _i2cPort->read(); 
  sendCommand(registerMap.firmwareMinor, "");
  //Upon completion Qwiic OpenLog will have 2 bytes ready to be read
  _i2cPort->requestFrom(_deviceAddress, (uint8_t)1);

  uint8_t versionMinor = _i2cPort->read();

  return(String(versionMajor) + "." + String(versionMinor));
}

//Get the status byte from OpenLog
//This function assumes we are not in the middle of a read, file size, or other function
//where OpenLog has bytes qued up
//  Bit 0: SD/Init Good
//  Bit 1: Last Command Succeeded
//  Bit 2: Last Command Known
//  Bit 3: File Currently Open
//  Bit 4: In Root Directory
//  Bit 5: 0 - Future Use
//  Bit 6: 0 - Future Use
//  Bit 7: 0 - Future Use
uint8_t OpenLog::getStatus()
{
  sendCommand(registerMap.status, "");
  //Upon completion OpenLog will have a status byte ready to read

  _i2cPort->requestFrom(_deviceAddress, (uint8_t)1);

  return(_i2cPort->read());
}

//Change the I2C address of the OpenLog
//This will be recorded to OpenLog's EEPROM and config.txt file.
boolean OpenLog::setI2CAddress(uint8_t addr)
{
  String temp;
  temp = addr;
  boolean result = sendCommand(registerMap.i2cAddress, temp);

  //Upon completion any new communication must be with this new I2C address  

  _deviceAddress = addr; //Change the address internally

  return(result);
}

//Append to a given file. If it doesn't exist it will be created
boolean OpenLog::append(String fileName)
{
  return (sendCommand(registerMap.openFile, fileName));
  //Upon completion any new characters sent to OpenLog will be recorded to this file
}

//Create a given file in the current directory
boolean OpenLog::create(String fileName)
{
  return (sendCommand(registerMap.createFile, fileName));
  //Upon completion a new file is created but OpenLog is still recording to original file
}

//Given a directory name, create it in whatever directory we are currently in
boolean OpenLog::makeDirectory(String directoryName)
{
  return (sendCommand(registerMap.mkDir, directoryName));
  //Upon completion Qwiic OpenLog will respond with its status
  //Qwiic OpenLog will continue logging whatever it next receives to the current open log
}

//Given a directory name, change to that directory
boolean OpenLog::changeDirectory(String directoryName)
{
  return (sendCommand(registerMap.cd, directoryName));
  //Upon completion Qwiic OpenLog will respond with its status
  //Qwiic OpenLog will continue logging whatever it next receives to the current open log
}

//Return the size of a given file. Returns a 4 byte signed long
int32_t OpenLog::size(String fileName)
{
  sendCommand(registerMap.fileSize, fileName);
  //Upon completion Qwiic OpenLog will have 4 bytes ready to be read

  _i2cPort->requestFrom(_deviceAddress, (uint8_t)4);

  int32_t fileSize = 0;
  while (_i2cPort->available())
  {
    uint8_t incoming = _i2cPort->read();
    fileSize <<= 8;
    fileSize |= incoming;
  }

  return (fileSize);
}

//Read the contents of a file, up to the size of the buffer, into a given array, from a given spot
void OpenLog::read(uint8_t* userBuffer, uint16_t bufferSize, String fileName)
{
  uint16_t spotInBuffer = 0;
  uint16_t leftToRead = bufferSize; //Read up to the size of our buffer. We may go past EOF.
  sendCommand(registerMap.readFile, fileName);
  //Upon completion Qwiic OpenLog will respond with the file contents. Master can request up to 32 bytes at a time.
  //Qwiic OpenLog will respond until it reaches the end of file then it will report zeros.

  while (leftToRead > 0)
  {
    uint8_t toGet = I2C_BUFFER_LENGTH; //Request up to a 32 byte block
    if (leftToRead < toGet) toGet = leftToRead; //Go smaller if that's all we have left

    _i2cPort->requestFrom(_deviceAddress, toGet);
    while (_i2cPort->available())
      userBuffer[spotInBuffer++] = _i2cPort->read();

    leftToRead -= toGet;
  }
}

//Read the contents of a directory. Wildcards allowed
//Returns true if OpenLog ack'd. Use getNextDirectoryItem() to get the first item.
boolean OpenLog::searchDirectory(String options)
{
  if (sendCommand(registerMap.list, options) == true)
  {
    _searchStarted = true;
    return (true);
    //Upon completion Qwiic OpenLog will have a file name or directory name ready to respond with, terminated with a \0
    //It will continue to respond with a file name or directory until it responds with all 0xFFs (end of list)
  }
  return (false);
}

//Returns the name of the next file or directory folder in the current directory
//Returns "" if it is the end of the list
String OpenLog::getNextDirectoryItem()
{
  if (_searchStarted == false) return (""); //We haven't done a search yet

  String itemName = "";
  _i2cPort->requestFrom(_deviceAddress, (uint8_t)I2C_BUFFER_LENGTH);

  uint8_t charsReceived = 0;
  while (_i2cPort->available())
  {
    uint8_t incoming = _i2cPort->read();

    if (incoming == '\0')
      return (itemName); //This is the end of the file name. We don't need to read any more of the 32 bytes
    else if (charsReceived == 0 && incoming == 0xFF)
    {
      _searchStarted = false;
      return (""); //End of the directory listing
    }
    else
      itemName += (char)incoming; //Add this byte to the file name

    charsReceived++;
  }
  
  //We shouldn't get this far but if we do
  return(itemName);

}

//Remove a file, wildcards supported
//OpenLog will respond with the number of items removed
uint32_t OpenLog::removeFile(String thingToDelete)
{
	return(remove(thingToDelete, false));
}

//Remove a directory, wildcards supported
//OpenLog will respond with 1 when removing a directory
uint32_t OpenLog::removeDirectory(String thingToDelete)
{
	return(remove(thingToDelete, true)); //Delete all files in the directory as well
}

//Remove a file or directory (including everything in that directory)
//OpenLog will respond with the number of items removed
//Returns 1 if only a directory is removed (even if directory had files in it)
uint32_t OpenLog::remove(String thingToDelete, boolean removeEverything)
{
  if(removeEverything == true)
	sendCommand(registerMap.rmrf, thingToDelete); //-rf causes any directory to remove contents as well
  else
	sendCommand(registerMap.rm, thingToDelete); //Just delete a thing
    
  //Upon completion Qwiic OpenLog will have 4 bytes ready to read, representing the number of files beleted

  _i2cPort->requestFrom(_deviceAddress, (uint8_t)4);

  int32_t filesDeleted = 0;
  while (_i2cPort->available())
  {
    uint8_t incoming = _i2cPort->read();
    filesDeleted <<= 8;
    filesDeleted |= incoming;
  }

  return (filesDeleted); //Return the number of files removed

  //Qwiic OpenLog will continue logging whatever it next receives to the current open log
}


//Send a command to the unit with options (such as "append myfile.txt" or "read myfile.txt 10")
boolean OpenLog::sendCommand(uint8_t registerNumber, String option1)
{
  _i2cPort->beginTransmission(_deviceAddress);
  _i2cPort->write(registerNumber);
  if (option1.length() > 0)
  {
    //_i2cPort->print(" "); //Include space
    _i2cPort->print(option1);
  }
  
  if (_i2cPort->endTransmission() != 0)
    return (false);

  return (true);
  //Upon completion any new characters sent to OpenLog will be recorded to this file
}

//Write a single character to Qwiic OpenLog
size_t OpenLog::write(uint8_t character) {
  _i2cPort->beginTransmission(_deviceAddress);
  _i2cPort->write(registerMap.writeFile);//Send the byte that corresponds to writing a file
  _i2cPort->write(character);
  if (_i2cPort->endTransmission() != 0)
    return (0); //Error: Sensor did not ack

  return (1);
}

int OpenLog::writeString(String string) {
  _i2cPort->beginTransmission(_deviceAddress);
  _i2cPort->write(registerMap.writeFile);

  //remember, the rx buffer on the i2c openlog is 32 bytes
  //and the register address takes up 1 byte so we can only
  //send 31 data bytes at a time
  if(string.length() > 31)
  {
    return -1;
  }
  if (string.length() > 0)
  {
    //_i2cPort->print(" "); //Include space
    _i2cPort->print(string);
  }
  
  if (_i2cPort->endTransmission() != 0)
    return (0);

  return (1);
}

bool OpenLog::syncFile(){
  _i2cPort->beginTransmission(_deviceAddress);
  _i2cPort->write(registerMap.syncFile);
  
  if (_i2cPort->endTransmission() != 0){
    return (0);    
  }

  return (1);
}

bool OpenLog::Println(String sString) {
    int iLength = sString.length();
    bool bAllOk = 1;
    for (int i=0; i<iLength; i++) {
        if (!write(sString.charAt(i))) {
            bAllOk = 0;
        }
        delay(1); //to allow file writing to keep up, so no errors
    }
    write('\n'); //add newline
    return (bAllOk);
}

SparkFun_Qwiic_OpenLog_Arduino_Library_SM

C/C++
.h file for modified library
/*
  This is a library written for the Qwiic OpenLog
  SparkFun sells these at its website: www.sparkfun.com
  Do you like this library? Help support SparkFun. Buy a board!
  https://www.sparkfun.com/products/14641
  Written by Nathan Seidle @ SparkFun Electronics, February 2nd, 2018
  Qwiic OpenLog makes it very easy to record data over I2C to a microSD.
  This library handles the initialization of the Qwiic OpenLog and the calculations
  to get the temperatures.
  https://github.com/sparkfun/SparkFun_Qwiic_OpenLog_Arduino_Library
  Development environment specifics:
  Arduino IDE 1.8.3
  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  GNU General Public License for more details.
  You should have received a copy of the GNU General Public License
  along with this program.  If not, see <http://www.gnu.org/licenses></http:>.
*/

#pragma once

#if (ARDUINO >= 100)
#include "Arduino.h"
#else
#include "WProgram.h"
#endif

#include <Wire.h>

//The default I2C address for the Qwiic OpenLog is 0x2A (42). 0x29 is also possible.
#define QOL_DEFAULT_ADDRESS (uint8_t)42

//Bits found in the getStatus() byte
#define STATUS_SD_INIT_GOOD 0
#define STATUS_LAST_COMMAND_SUCCESS 1
#define STATUS_LAST_COMMAND_KNOWN 2
#define STATUS_FILE_OPEN 3
#define STATUS_IN_ROOT_DIRECTORY 4

//Platform specific configurations

//Define the size of the I2C buffer based on the platform the user has
//-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__)

//I2C_BUFFER_LENGTH is defined in Wire.H
#define I2C_BUFFER_LENGTH BUFFER_LENGTH

#elif defined(__SAMD21G18A__)

//SAMD21 uses RingBuffer.h
#define I2C_BUFFER_LENGTH SERIAL_BUFFER_SIZE

#elif __MK20DX256__
//Teensy

#elif ARDUINO_ARCH_ESP32
//ESP32 based platforms

#else

//The catch-all default is 32
#define I2C_BUFFER_LENGTH 32

#endif
//-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=


class OpenLog : public Print {

  public:
  
	struct memoryMap {
		byte id;
		byte status;
		byte firmwareMajor;
		byte firmwareMinor;
		byte i2cAddress;
		byte logInit;
		byte createFile;
		byte mkDir;
		byte cd;
		byte readFile;
		byte startPosition;
		byte openFile;
		byte writeFile;
		byte fileSize;
		byte list;
		byte rm;
		byte rmrf;
    byte syncFile;
	};

	const memoryMap registerMap = {
		.id = 0x00,
		.status = 0x01,
		.firmwareMajor = 0x02,
		.firmwareMinor = 0x03,
		.i2cAddress = 0x1E,
		.logInit = 0x05,
		.createFile = 0x06,
		.mkDir = 0x07,
		.cd = 0x08,
		.readFile = 0x09,
		.startPosition = 0x0A,
		.openFile = 0x0B,
		.writeFile = 0x0C,
		.fileSize = 0x0D,
		.list = 0x0E,
		.rm = 0x0F,
		.rmrf = 0x10,
    .syncFile = 0x11,
	};
    //These functions override the built-in print functions so that when the user does an 
    //myLogger.println("send this"); it gets chopped up and sent over I2C instead of Serial
    virtual size_t write(uint8_t character);
    int writeString(String string);
    bool Println(String sString);
    bool syncFile(void);

    //By default use the default I2C addres, and use Wire port
    boolean begin(uint8_t deviceAddress = QOL_DEFAULT_ADDRESS, TwoWire &wirePort = Wire);
    boolean begin(int deviceAddress); 

    String getVersion(); //Returns a string that is the current firmware version
    uint8_t getStatus(); //Returns various status bits

    boolean setI2CAddress(uint8_t addr); //Set the I2C address we read and write to
    boolean append(String fileName); //Open and append to a file
    boolean create(String fileName); //Create a file but don't open it for writing
    boolean makeDirectory(String directoryName); //Create the given directory
    boolean changeDirectory(String directoryName); //Change to the given directory
    int32_t size(String fileName); //Given a file name, read the size of the file

    void read(uint8_t* userBuffer, uint16_t bufferSize, String fileName); //Read the contents of a file into the provided buffer

    boolean searchDirectory(String options); //Search the current directory for a given wildcard
    String getNextDirectoryItem(); //Return the next file or directory from the search

    uint32_t removeFile(String thingToDelete); //Remove file
    uint32_t removeDirectory(String thingToDelete); //Remove a directory including the contents of the directory
    uint32_t remove(String thingToDelete, boolean removeEverthing); //Remove file or directory including the contents of the directory

    //These are the core functions that send a command to OpenLog
    boolean sendCommand(uint8_t registerNumber, String option1);

  private:

    //Variables
    TwoWire *_i2cPort; //The generic connection to user's chosen I2C hardware
    uint8_t _deviceAddress; //Keeps track of I2C address. setI2CAddress changes this.
    uint8_t _escapeCharacter = 26; //The character that needs to be sent to QOL to get it into command mode
    uint8_t _escapeCharacterCount = 3; //Number of escape characters to get QOL into command mode

    boolean _searchStarted = false; //Goes true when user does a search. Goes false when we reach end of directory.
};

Damper Controller - Revision 1

C/C++
Revised original code to add more Particle variables and functions, and change room temperature setpoints to EPROM variables, so changes are kept through power failures.
//make sure to add these libraries via the Libraries tab in the left sidebar
#include "Particle.h" //automatically included
#include "SparkFunBME280.h"
#include "Adafruit_CCS811.h"
#include "Arduino.h"
//#include <Wire.h>
#include "SparkFun_Qwiic_OpenLog_Arduino_Library.h" //local copy, revised
#include "TCA9548A-RK.h"
#include "RelayShield.h"
#include "Adafruit_MCP9808.h"

BME280 bme; // I2C 0x77, Mux Ch0 (I2C uses D0-1)
Adafruit_CCS811 ccs; // I2C 0x5B, Mux Ch0
Adafruit_MCP9808 mcp; // I2C 0x18, Mux Ch0, 250 ms per temp update
Adafruit_MCP9808 mcp1; // I2C 0x18, Mux Ch1
Adafruit_MCP9808 mcp2; // I2C 0x18, Mux Ch2
Adafruit_MCP9808 mcp3; // I2C 0x18, Mux Ch3
Adafruit_MCP9808 mcp4; // I2C 0x18, Mux Ch4
OpenLog myLog; // I2C 0x2A
TCA9548A mux(Wire, 0); //I2C 0x70
RelayShield myRelays; //RelayShield, D3-6

int boardLed = D7; //blink LED when code is running
//double dTime = 0.0; //used for cloud variable (e.g. time to log one line of data)
//unsigned long lastUpdate = 0; //used to calculate dTime in ms (e.g. to log each line of data)
double T; //Plenum - used for Logging
String sT; //used for cloud variable
double P; //used for Logging
String sP; //used for cloud variable
double H; //used for Logging
String sH; //used for cloud variable
double CO2; //used for Logging
String sCO2; //used for cloud variable
double TVOC; //used for Logging
String sTVOC; //used for cloud variable
double T0; //Plenum - used for Logging
String sT0; //used for cloud variable
double T1; //Room 1 - used for Logging
String sT1; //used for cloud variable
double T2; //Room 2 - used for Logging
String sT2; //used for cloud variable
double T3; //Room 3 - used for Logging
String sT3; //used for cloud variable
double T4; //Room 4 - used for Logging
String sT4; //used for cloud variable
#define TDeadband 0.9 //switch dampers on/off if above/below setpoint +/- Tdeadband (degrees C)
#define THysteresis 0.8 //require this much change in temperature after a damper has switched state, before another switch of state (degrees C)
#define ROOM1 1 //1st bedroom
#define ROOM2 2 //2nd bedroom
#define ROOM3 3 //3rd bedroom
#define ROOM4 4 //basement
float T1RoomSet; //desired Room 1 temperature
float T2RoomSet; //desired Room 2 temperature
float T3RoomSet; //desired Room 3 temperature
float T4RoomSet; //desired Room 4 temperature
#define OPENED 0
#define CLOSED 1
bool Damper1SelectedPosition; //damper SelectedPosition for Room 1
bool Damper2SelectedPosition; //damper SelectedPosition for Room 2
bool Damper3SelectedPosition; //damper SelectedPosition for Room 3
bool Damper4SelectedPosition; //damper SelectedPosition for Room 4
#define AUTO 0
#define MANUAL 1
bool Damper1Mode; //damper mode for Room 1 (Auto/Manual)
bool Damper2Mode; //damper mode for Room 2
bool Damper3Mode; //damper mode for Room 3
bool Damper4Mode; //damper mode for Room 4
unsigned long lYear; //   4 digit year, e.g. 2019
unsigned long lMonth; //  1-12 month
unsigned long lDay; //    1-31 day
unsigned long lHour; //   0-23 hour
unsigned long lMinute; // 0-59
unsigned long lLastMinute; // check every 30 s to see if minute logged already, so no minutes get missed, which does otherwise occur
String sDate = "2019-09-14"; //example
String sTime = "23:45:00"; //example
String sLogFileName = "default.txt"; //example
String sBuf = ""; //use global buffer (and above variables) to reduce stack usage, especially in timers
String sDampersStatus = "A1onM2offA3offA4on"; //example: Damper Mode: A = AUTO, M = MANUAL, Damper Position: on = OPENED, off = CLOSED
String sTSet = "23.0 23.0 23.0 23.0"; //example: Damper temperature trigger setpoints
int iReboots;  //use this to count the number of CPU boots (e.g. due to power failures)

void AddHeaderToLogFile() {
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
    lYear = Time.year(); // 4 digit year
    lMonth = Time.month(); //1-12 month
    lDay = Time.day(); //1-31 day
    sDate = String(lYear) + "-";
    if (lMonth < 10) sDate += "0"; //make sure 2 digits so file is aligned
    sDate += String(lMonth) + "-";
    if (lDay < 10) sDate += "0"; //make sure 2 digits so file is aligned
    sDate += String(lDay); // e.g. 2019-09-07
    sLogFileName = String(lYear);
    if (lMonth < 10) sLogFileName += "0"; //make sure 2 digits for month
    sLogFileName += String(lMonth);
    if (lDay < 10) sLogFileName += "0"; //make sure 2 digits for day
    sLogFileName += String(lDay) + ".txt"; //e.g. 20190907.txt (max 8.3 digits)
    myLog.append(sLogFileName); //append or create new Log file and send subsequent text to it (e.g. new day, after midnight)
    delay(1); // give it time?
    sBuf = "Date,Time,T0(C),T1(C),T2(C),T3(C),T4(C),T1Set(C),T2Set(C),T3Set(C),T4Set(C),DSelStatus,T(C),P(kPa),RH(%),CO2(ppm),TVOC(ppb)";
    myLog.Println(sBuf); //custom routine that adds 1 ms delay between each character, otherwise some characters get lost
	//dTime = millis() - lastUpdate; //typically about 0.67 ms
}

void logInfo() { //Timers have limited stack space, so use some global variables
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
	lMinute = Time.minute();
    if (lLastMinute != lMinute) { //only log data once a minute
	    if (lYear != Time.year() || lMonth != Time.month() || lDay != Time.day()) { //if a new day, create a new file + header
    	    AddHeaderToLogFile();
    	    Particle.syncTime(); //keep time correct, updating at the start of every day
    	}
    	lHour = Time.hour();
    	sTime = "";
    	if (lHour < 10) sTime += "0"; //make sure 2 digits so file is aligned
    	sTime += String(lHour) + ":";
    	if (lMinute < 10) sTime += "0"; //make sure 2 digits so file is aligned
    	sTime += String(lMinute) + ":00"; //e.g. "23:45:00"
    	sBuf = sDate + "," + sTime + ","; //sDate updated in AddHeaderToLogFile()
    	sBuf += String(T0,1) + "," + String(T1,1) + "," + String(T2,1) + "," + String(T3,1) + "," + String(T4,1) + ",";
    	sBuf += String(T1RoomSet,1) + "," + String(T2RoomSet,1) + "," + String(T3RoomSet,1) + "," + String(T4RoomSet,1) + ",";
    	sBuf += sDampersStatus + ","; //e.g. "A1onM2offA3offA4on" A = AUTO, M = MANUAL, on = OPENED, off = CLOSED
    	sBuf += String(T,1) + "," + String(P,1) + "," + String(H,0) + "," + String(CO2,0) + "," + String(TVOC,0);
    	myLog.Println(sBuf);
    	lLastMinute = lMinute;
    }
    //dTime = millis() - lastUpdate; //typically about 0.54 s (plus about 0.67 s if new file created for a new day + header added)
}
// create a software timer to log Temp, Press, Humid, CO2, TVOC, Damper Status every minute (takes about 0.67 s)
Timer timer1(30000, logInfo); //log data every 60 s, but check every 30 s, so no minutes get dropped (can otherwise happen, although very infrequent)

void getTempPlus() {
    digitalWrite(boardLed,HIGH); //blink LED whenever in this routine
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
    //dTime = 0.0; //set to zero at start of routine, so if routine hangs, it will not get updated
    float fT, fP, fH, fC, fV;
    int iT, iP, iH;
    
    //first, wake up all mcp devices (takes 250 ms until a new temperature value is available)
    mux.setChannel(0); //enable Mux to Ch0, Plenum
    mcp.shutdown_wake(0); //wakeup
    mux.setChannel(1); //enable Mux to Ch1 (Room 1)
    mcp1.shutdown_wake(0); //wakeup
    mux.setChannel(2); //enable Mux to Ch2 (Room 2)
    mcp2.shutdown_wake(0); //wakeup
    mux.setChannel(3); //enable Mux to Ch3 (Room 3)
    mcp3.shutdown_wake(0); //wakeup
    mux.setChannel(4); //enable Mux to Ch4 (Room 4)
    mcp4.shutdown_wake(0); //wakeup
    mux.setNoChannel(); //disable Mux
    delay(260); //delay at least 250 ms for sensors to wake up and get temperatures
    
    mux.setChannel(0); //enable Mux to Ch0, Plenum
    fT = bme.readTempC(); // degrees C (used only for compensation of ccs)
    ccs.setTempOffset(fT - 25.0); // compensation for CCS811
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T = fT/10;
    sT = String(T,1);
    fP = bme.readFloatPressure(); // Pascals
    iP = (fP+0.5)/100; // rounded to 0.1 kPa
    fP = iP;
    P = fP/10; // kPa with 1 decimal
    sP = String(P,1);
    fH = bme.readFloatHumidity(); //%
    iH = fH + 0.5; //to use 0 decimals, rounded
    fH = iH;
    H = fH;
    sH = String(H,0);
    if(ccs.available()){
        if(!ccs.readData()) {
            fC = ccs.geteCO2(); // ppm
            CO2 = fC;
            sCO2 = String(CO2,0);
    
            fV = ccs.getTVOC(); //ppb
            TVOC = fV;
            sTVOC = String(TVOC,0);
        }
    } else {
        //Serial.println("CCS811 not available at time: " + String(millis()));
    }
    fT = mcp.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T0 = fT/10;
    sT0 = String(T0,1);
    mcp.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    
    mux.setChannel(1); //enable Mux to Ch1 (Room 1)
    fT = mcp1.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T1 = fT/10;
    sT1 = String(T1,1);
    mcp1.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setChannel(2); //enable Mux to Ch2 (Room 2)
    fT = mcp2.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T2 = fT/10;
    sT2 = String(T2,1);
    mcp2.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    
    mux.setChannel(3); //enable Mux to Ch3 (Room 3)
    fT = mcp3.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T3 = fT/10;
    sT3 = String(T3,1);
    mcp3.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setChannel(4); //enable Mux to Ch4 (Room 4)
    fT = mcp4.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T4 = fT/10;
    sT4 = String(T4,1);
    mcp4.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setNoChannel(); //disable Mux
    
    //dTime = millis() - lastUpdate; //typically 0.28 s, only zero when routine is not running (e.g. errors with temperature sensors?)

    digitalWrite(boardLed,LOW);
}
// create a software timer to obtain new readings of Temp+ every 10 seconds
Timer timer2(10000, getTempPlus); //update Temp, Press, Humid, CO2, TVOC every 10 s

// used to display a list of the room setpoint temperatures on an iPhone
void sTSetUpdate () {
    sTSet = String(T1RoomSet,1);
    sTSet += " ";
    sTSet += String(T2RoomSet,1);
    sTSet += " ";
    sTSet += String(T3RoomSet,1);
    sTSet += " ";
    sTSet += String(T4RoomSet,1);
}

// use a Particle Function to change the room 1 temperature setpoint
int TSetRoom1Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T1RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T1RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(10, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to change the room 2 temperature setpoint
int TSetRoom2Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T2RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T2RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(20, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to change the room 3 temperature setpoint
int TSetRoom3Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T3RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T3RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(30, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to change the room 4 temperature setpoint
int TSetRoom4 (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T4RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T4RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(40, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to reset the iReboots value to 0
int ResetiReboots (String command) { //allow any text to reset the value of iReboots to 0
    iReboots = 0;
    EEPROM.put(0, 0);
    return 1;
}

int sDampersMPdesired (String command) { // command = "4AC" = Damper #1-4, then Mode: A = AUTO, M = MANUAL, then Position: O = OPENED, C = CLOSED (optional if Auto)
    int iRoom;
    bool bMode, bStatus;
    
    iRoom = command.toInt();
    if ((iRoom < 1) || (iRoom > 4)) return -1; // valid Room 1 to 4 not found (or number after 1st digit)
    switch (command.charAt(1)) {
        case 'A':
        case 'a':
            bMode = AUTO;
            break;
        case 'M':
        case 'm':
            bMode = MANUAL;
            break;
        default:
            return -1;
    }
    switch (command.charAt(2)) {
        case 'C':
        case 'c':
            bStatus = CLOSED;
            break;
        default:
            bStatus = OPENED; //default to OPENED (even if 3rd char is missing)
    }
    switch (iRoom) {
        case 1:
            if (bMode == AUTO) {
                Damper1Mode = AUTO;
            } else {
                Damper1Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper1SelectedPosition = OPENED;
                    myRelays.off(ROOM1); //open damper
                } else {
                    Damper1SelectedPosition = CLOSED;
                    myRelays.on(ROOM1); //close damper
                }
            }
            break;
        case 2:
            if (bMode == AUTO) {
                Damper2Mode = AUTO;
            } else {
                Damper2Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper2SelectedPosition = OPENED;
                    myRelays.off(ROOM2); //open damper
                } else {
                    Damper2SelectedPosition = CLOSED;
                    myRelays.on(ROOM2); //close damper
                }
            }
            break;
        case 3:
            if (bMode == AUTO) {
                Damper3Mode = AUTO;
            } else {
                Damper3Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper3SelectedPosition = OPENED;
                    myRelays.off(ROOM3); //open damper
                } else {
                    Damper3SelectedPosition = CLOSED;
                    myRelays.on(ROOM3); //close damper
                }
            }
            break;
        case 4:
            if (bMode == AUTO) {
                Damper4Mode = AUTO;
            } else {
                Damper4Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper4SelectedPosition = OPENED;
                    myRelays.off(ROOM4); //open damper
                } else {
                    Damper4SelectedPosition = CLOSED;
                    myRelays.on(ROOM4); //close damper
                }
            }
            break;
    }
    return 1;
}

bool OpenDamper(int Room) { //determine if a room damper should be opened or closed
    float TSet, TRoom, TPlenum, TOffset;
    int StartHour, FinishHour;
    bool DamperSelPos;
    switch (lMonth) { //(force cold air upstairs, hot air downstairs)
        case 1:
        case 2:
        case 11:
        case 12: // winter with no DST
            StartHour = 20; //8 PM
            FinishHour = 3; //3 AM
            break;
        case 6:
        case 7:
        case 8: //summer, with DST offset
            StartHour = 18; //7 PM DST
            FinishHour = 4; //5 AM DST
            break;
        case 3:
        case 4:
        case 5:
        case 9:
        case 10: //spring & fall with some DST
            StartHour = 19; //7 PM, 8 PM DST
            FinishHour = 3; //3 AM, 4 AM DST
            break;
    }
    switch (Room) {
        case ROOM1:
            TSet = T1RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T1;
            DamperSelPos = Damper1SelectedPosition; //present Damper position
            break;
        case ROOM2:
            TSet = T2RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T2;
            DamperSelPos = Damper2SelectedPosition; //present Damper position
            break;
        case ROOM3:
            TSet = T3RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T3;
            DamperSelPos = Damper3SelectedPosition; //present Damper position
            break;
        case ROOM4: //no temperature setback (basement)
            TSet = T4RoomSet;
            TRoom = T4;
            DamperSelPos = Damper4SelectedPosition; //present Damper position
    }
    TPlenum = T0;
    if (TPlenum < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TPlenum > 80) return TRUE; // keep damper open if there is a temperature issue
    if (TRoom < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TRoom > 40) return TRUE; // keep damper open if there is a temperature issue
    if (TSet < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TSet > 40) return TRUE; // keep damper open if there is a temperature issue
    TOffset = TDeadband; //e.g. 0.9C above or below temperature setpoint
    if (DamperSelPos == CLOSED) { //if already closed, don't open until temperature rises/drops by THysteresis amount
        TOffset -= THysteresis; //e.g. stay closed until 0.1C above or below temperature setpoint
    }
    if ((TPlenum > (TRoom+TOffset)) && (TRoom > (TSet+TOffset))) { //room is already too hot, so don't heat more
        return FALSE;
    }
    if ((TPlenum < (TRoom-TOffset)) && (TRoom < (TSet-TOffset))) { //room is already too cold, so don't cool more
        return FALSE;
    }
    return TRUE; //damper should be open for all other cases
}

// reset the system after 60 seconds if the application is unresponsive
ApplicationWatchdog wd(60000, System.reset);

void setup() {
    float tempT;
    pinMode(boardLed, OUTPUT); //use this blue LED to blink during code operation (setup + when temperatures are read)
	digitalWrite(boardLed, HIGH);
    
    // We are going to declare Particle.variable() here so that we can access the values from the cloud.
	//This registration must be completed within 30 s of connecting to the cloud, so do it first thing in setup
    //Particle.variable("d_Time_ms", dTime); //text description must NOT have any spaces
    //items are listed in alphabetical order on iPhone App
    Particle.variable("s_CO2_ppm", sCO2);
    Particle.variable("s_Dampers", sDampersStatus);
    Particle.variable("s_P_kPa", sP);
    Particle.variable("s_RH_Pct", sH);
    Particle.variable("s_T0_C", sT0);
    Particle.variable("s_T1234Set", sTSet);
    Particle.variable("s_T1_C", sT1);
    Particle.variable("s_T2_C", sT2);
    Particle.variable("s_T3_C", sT3);
    Particle.variable("s_T4_C", sT4);
    Particle.variable("s_TVOC_ppb", sTVOC);
    Particle.variable("s_T_C", sT);
    Particle.variable("N_Reboots", iReboots);

    Particle.function("T1S",TSetRoom1Day);
    Particle.function("T2S",TSetRoom2Day);
    Particle.function("T3S",TSetRoom3Day);
    Particle.function("T4S",TSetRoom4);
    Particle.function("D_Mode_Pos",sDampersMPdesired); //e.g. "4MC" = Damper 4, manual mode, close damper; "3a" = Damper 3, auto mode
    Particle.function("ResetReboots", ResetiReboots); //so the number of reboots can be reset back to 0
    
    EEPROM.get(0, iReboots); //The value gets incremented each time the CPU is re-booted.
    iReboots += 1;
    EEPROM.put(0, iReboots);
    
    mux.begin(); //I2C with 8  output channels (for all T/P/RH/CO2/TVOC), 5 used (0-4)
    mux.setNoChannel(); //disables all channels 0-7
    
    while(!myLog.begin(0x2A)){ //prepare OpenLog connection
        delay(1000); // 1 s
    }
    Time.zone(-5); //ignore DST (separately deal with time/temperature variations throughout the year)
    AddHeaderToLogFile(); //this will create/append a file name based on the date, and add the header to it (comma separated variables format)

    myRelays.begin();
    myRelays.allOff(); //wired for output relays off = damper open (dampers open if power failure to relay coil)
	Damper1SelectedPosition = OPENED;
	Damper2SelectedPosition = OPENED;
	Damper3SelectedPosition = OPENED;
	Damper4SelectedPosition = OPENED;
	Damper1Mode = AUTO;
	Damper2Mode = AUTO;
	Damper3Mode = AUTO;
	Damper4Mode = AUTO;
	
//	Channel 0 - Furnace Plenum (CO2, etc)
    mux.setChannel(0); //enable Mux to Ch0 (location of BME280/CCS811 board and MCP9808 board in furnace plenum)
    bme.settings.runMode = 0b11; // normal mode
    bme.settings.tStandby = 0b101; // 1000 ms
    bme.settings.filter = 0b000; // off
    bme.settings.tempOverSample = 0b011; // x4
    bme.settings.pressOverSample = 0b011; // x4
    bme.settings.humidOverSample = 0b011; // x4
	while(!bme.begin()){ //prepare bme connection (and I2C), typical is: mode=normal, sampling=x16, filter=off, standby=0.5 ms
        delay(1000); // 1 s
	}
    while(!ccs.begin(0x5B)){ //prepare ccs connection (change default library address to 0x5B)
        delay(1000); // 1 s
    }
	while(!mcp.begin(0x18)){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
    wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 1 - Bedroom 1
    mux.setChannel(1); //enable Mux to Ch1 (location of 1st remote MCP9808 board, bedroom 1)
	while(!mcp1.begin(0x18)){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(10, tempT);
//	EEPROM.get(10,iOffsetT);
//	tempT = (iOffsetT + 100) / 10; //stored in EEPROM as an offset integer
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(10, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T1RoomSet = tempT;
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 2 - Bedroom 2
	mux.setChannel(2); //enable Mux to Ch1 (location of 2nd remote MCP9808 board, bedroom 2)
	while(!mcp2.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(20, tempT);
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(20, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T2RoomSet = tempT;
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 3 - Bedroom 3
    mux.setChannel(3); //enable Mux to Ch1 (location of 3rd remote MCP9808 board, bedroom 3)
	while(!mcp3.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(30, tempT);
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(30, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T3RoomSet = tempT;
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 4 - Basement
    mux.setChannel(4); //enable Mux to Ch1 (location of 4th remote MCP9808 board, basement)
	while(!mcp4.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(40, tempT);
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(40, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T4RoomSet = tempT;
	sTSetUpdate();
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?
	
	mux.setNoChannel(); //disable Mux (leave Mux off when not measuring a temperature)

	// initialize to some default values (needed until all timers have run)
	T0 = 23.0; //these default values will keep all dampers initially open
	T1 = 23.0;
	T2 = 23.0;
	T3 = 23.0;
	T4 = 23.0;
	
	lHour = Time.hour();
	lMinute = Time.minute();
	lLastMinute = lMinute;
	
	timer1.start();
	timer2.start();
	
	wd.checkin(); // resets the AWDT count (must occur every 60 seconds or less, or system will reset); not needed in setup?
	digitalWrite(boardLed, LOW); //setup completed so turn blue board LED off
}

void loop() { //open and close dampers based mainly on room temperatures (not all dampers can be closed, or too much back pressure on HVAC)
    // Allow a max. of 3 dampers closed.  If 4 dampers requested closed: In auto mode, force damper 4 open (basement).  In manual mode, force damper 1 open (spare bedroom).
    sDampersStatus = ""; //e.g. "A1onM2offA3offA4on" A = AUTO, M = MANUAL, on = OPENED, off = CLOSED
    if (Damper1Mode == MANUAL) { //don't change damper position if in manual (only change from remote input), unless forced re below
        if (Damper1SelectedPosition == OPENED) sDampersStatus += "M1on";
        else sDampersStatus += "M1off";
    } else {
        sDampersStatus += "A1"; //Damper1Mode = AUTO
        if (OpenDamper(ROOM1)) { //upstairs bedroom 1
            Damper1SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM1); //open damper
        } else {
            Damper1SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM1); //close damper
        }
    }
    if (Damper2Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper2SelectedPosition == OPENED) sDampersStatus += "M2on";
        else sDampersStatus += "M2off";
    } else {
        sDampersStatus += "A2"; //Damper2Mode = AUTO
        if (OpenDamper(ROOM2)) { //upstairs bedroom 2
            Damper2SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM2); //open damper
        } else {
            Damper2SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM2); //close damper
        }
    }
    if (Damper3Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper3SelectedPosition == OPENED) sDampersStatus += "M3on";
        else sDampersStatus += "M3off";
    } else {
        sDampersStatus += "A3"; //Damper3Mode = AUTO
        if (OpenDamper(ROOM3)) { //upstairs bedroom 3
            Damper3SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM3); //open damper
        } else {
            Damper3SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM3); //close damper
        }
    }
    if (Damper4Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper4SelectedPosition == OPENED) sDampersStatus += "M4on";
        else sDampersStatus += "M4off";
    } else {
        sDampersStatus += "A4"; //Damper4Mode = AUTO
        if (OpenDamper(ROOM4)) { //basement
            Damper4SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM4); //open damper
        } else {
            Damper4SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM4); //close damper
        }
    }
    if ((Damper1SelectedPosition == CLOSED) && (Damper2SelectedPosition == CLOSED) && (Damper3SelectedPosition == CLOSED) && (Damper4SelectedPosition == CLOSED)) { // do something if all Dampers are selected to be closed
        if (Damper4Mode == MANUAL) { //if Damper 4 was manually closed, leave it closed and force Damper 1 open
            myRelays.off(ROOM1);
        } else { //auto open Damper in Room 4 (basement) if upstairs rooms are closed
            myRelays.off(ROOM4);
        }
    }
    wd.checkin(); // resets the AWDT count (must occur every 60 seconds or less, or system will reset) [not needed here, see comment at end of loop]
    delay(2000); // 2 s (no need to continuously run this loop)
} // AWDT count reset automatically after loop() ends

Damper Controller - Revision 2

C/C++
I added code to deal with occasional temperature reading errors, up to and including a system reset.
//make sure to add these libraries via the Libraries tab in the left sidebar
#include "Particle.h" //automatically included
#include "SparkFunBME280.h"
#include "Adafruit_CCS811.h"
#include "Arduino.h"
//#include <Wire.h>
#include "SparkFun_Qwiic_OpenLog_Arduino_Library.h" //local copy, revised
#include "TCA9548A-RK.h"
#include "RelayShield.h"
#include "Adafruit_MCP9808.h"

BME280 bme; // I2C 0x77, Mux Ch0 (I2C uses D0-1)
Adafruit_CCS811 ccs; // I2C 0x5B, Mux Ch0
Adafruit_MCP9808 mcp; // I2C 0x18, Mux Ch0, 250 ms per temp update
Adafruit_MCP9808 mcp1; // I2C 0x18, Mux Ch1
Adafruit_MCP9808 mcp2; // I2C 0x18, Mux Ch2
Adafruit_MCP9808 mcp3; // I2C 0x18, Mux Ch3
Adafruit_MCP9808 mcp4; // I2C 0x18, Mux Ch4
OpenLog myLog; // I2C 0x2A
TCA9548A mux(Wire, 0); //I2C 0x70
RelayShield myRelays; //RelayShield, D3-6

int boardLed = D7; //blink LED when code is running
//double dTime = 0.0; //used for cloud variable (e.g. time to log one line of data)
//unsigned long lastUpdate = 0; //used to calculate dTime in ms (e.g. to log each line of data)
double T; //Plenum - used for Logging
String sT; //used for cloud variable
double P; //used for Logging
String sP; //used for cloud variable
double H; //used for Logging
String sH; //used for cloud variable
double CO2; //used for Logging
String sCO2; //used for cloud variable
double TVOC; //used for Logging
String sTVOC; //used for cloud variable
double T0; //Plenum - used for Logging
String sT0; //used for cloud variable
double T1; //Room 1 - used for Logging
String sT1; //used for cloud variable
double T2; //Room 2 - used for Logging
String sT2; //used for cloud variable
double T3; //Room 3 - used for Logging
String sT3; //used for cloud variable
double T4; //Room 4 - used for Logging
String sT4; //used for cloud variable
#define TDeadband 0.9 //switch dampers on/off if above/below setpoint +/- Tdeadband (degrees C)
#define THysteresis 0.8 //require this much change in temperature after a damper has switched state, before another switch of state (degrees C)
#define ROOM1 1 //1st bedroom
#define ROOM2 2 //2nd bedroom
#define ROOM3 3 //3rd bedroom
#define ROOM4 4 //basement
float T1RoomSet; //desired Room 1 temperature
float T2RoomSet; //desired Room 2 temperature
float T3RoomSet; //desired Room 3 temperature
float T4RoomSet; //desired Room 4 temperature
#define OPENED 0
#define CLOSED 1
bool Damper1SelectedPosition; //damper SelectedPosition for Room 1
bool Damper2SelectedPosition; //damper SelectedPosition for Room 2
bool Damper3SelectedPosition; //damper SelectedPosition for Room 3
bool Damper4SelectedPosition; //damper SelectedPosition for Room 4
#define AUTO 0
#define MANUAL 1
bool Damper1Mode; //damper mode for Room 1 (Auto/Manual)
bool Damper2Mode; //damper mode for Room 2
bool Damper3Mode; //damper mode for Room 3
bool Damper4Mode; //damper mode for Room 4
unsigned long lYear; //   4 digit year, e.g. 2019
unsigned long lMonth; //  1-12 month
unsigned long lDay; //    1-31 day
unsigned long lHour; //   0-23 hour
unsigned long lMinute; // 0-59
unsigned long lLastMinute; // check every 30 s to see if minute logged already, so no minutes get missed, which does otherwise occur
String sDate = "2019-09-14"; //example
String sTime = "23:45:00"; //example
String sLogFileName = "default.txt"; //example
String sBuf = ""; //use global buffer (and above variables) to reduce stack usage, especially in timers
String sDampersStatus = "A1onM2offA3offA4on"; //example: Damper Mode: A = AUTO, M = MANUAL, Damper Position: on = OPENED, off = CLOSED
String sTSet = "23.0 23.0 23.0 23.0"; //example: Damper temperature trigger setpoints
int iReboots; //use this to count the number of CPU boots (e.g. due to power failures)
int iTErrors; //incremented every time a T0-4 temperature read is a bad value, good if T0 = 5-80 C or T1-T4 = 5-40 C

void AddHeaderToLogFile() {
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
    lYear = Time.year(); // 4 digit year
    lMonth = Time.month(); //1-12 month
    lDay = Time.day(); //1-31 day
    sDate = String(lYear) + "-";
    if (lMonth < 10) sDate += "0"; //make sure 2 digits so file is aligned
    sDate += String(lMonth) + "-";
    if (lDay < 10) sDate += "0"; //make sure 2 digits so file is aligned
    sDate += String(lDay); // e.g. 2019-09-07
    sLogFileName = String(lYear);
    if (lMonth < 10) sLogFileName += "0"; //make sure 2 digits for month
    sLogFileName += String(lMonth);
    if (lDay < 10) sLogFileName += "0"; //make sure 2 digits for day
    sLogFileName += String(lDay) + ".txt"; //e.g. 20190907.txt (max 8.3 digits)
    myLog.append(sLogFileName); //append or create new Log file and send subsequent text to it (e.g. new day, after midnight)
    delay(1); // give it time?
    sBuf = "Date,Time,T0(C),T1(C),T2(C),T3(C),T4(C),T1Set(C),T2Set(C),T3Set(C),T4Set(C),DSelStatus,T(C),P(kPa),RH(%),CO2(ppm),TVOC(ppb)";
    myLog.Println(sBuf); //custom routine that adds 1 ms delay between each character, otherwise some characters get lost
	//dTime = millis() - lastUpdate; //typically about 0.67 ms
}

void logInfo() { //Timers have limited stack space, so use some global variables
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
	lMinute = Time.minute();
    if (lLastMinute != lMinute) { //only log data once a minute
	    if (lYear != Time.year() || lMonth != Time.month() || lDay != Time.day()) { //if a new day, create a new file + header
    	    AddHeaderToLogFile();
    	    Particle.syncTime(); //keep time correct, updating at the start of every day
    	}
    	lHour = Time.hour();
    	sTime = "";
    	if (lHour < 10) sTime += "0"; //make sure 2 digits so file is aligned
    	sTime += String(lHour) + ":";
    	if (lMinute < 10) sTime += "0"; //make sure 2 digits so file is aligned
    	sTime += String(lMinute) + ":00"; //e.g. "23:45:00"
    	sBuf = sDate + "," + sTime + ","; //sDate updated in AddHeaderToLogFile()
    	sBuf += String(T0,1) + "," + String(T1,1) + "," + String(T2,1) + "," + String(T3,1) + "," + String(T4,1) + ",";
    	sBuf += String(T1RoomSet,1) + "," + String(T2RoomSet,1) + "," + String(T3RoomSet,1) + "," + String(T4RoomSet,1) + ",";
    	sBuf += sDampersStatus + ","; //e.g. "A1onM2offA3offA4on" A = AUTO, M = MANUAL, on = OPENED, off = CLOSED
    	sBuf += String(T,1) + "," + String(P,1) + "," + String(H,0) + "," + String(CO2,0) + "," + String(TVOC,0);
    	myLog.Println(sBuf);
    	lLastMinute = lMinute;
    }
    //dTime = millis() - lastUpdate; //typically about 0.54 s (plus about 0.67 s if new file created for a new day + header added)
}
// create a software timer to log Temp, Press, Humid, CO2, TVOC, Damper Status every minute (takes about 0.67 s)
Timer timer1(30000, logInfo); //log data every 60 s, but check every 30 s, so no minutes get dropped (can otherwise happen, although very infrequent)

void getTempPlus() {
    digitalWrite(boardLed,HIGH); //blink LED whenever in this routine
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
    //dTime = 0.0; //set to zero at start of routine, so if routine hangs, it will not get updated
    float fT, fP, fH, fC, fV;
    int iT, iP, iH;
    
    //first, wake up all mcp devices (takes 250 ms until a new temperature value is available)
    mux.setChannel(0); //enable Mux to Ch0, Plenum
    mcp.shutdown_wake(0); //wakeup
    mux.setChannel(1); //enable Mux to Ch1 (Room 1)
    mcp1.shutdown_wake(0); //wakeup
    mux.setChannel(2); //enable Mux to Ch2 (Room 2)
    mcp2.shutdown_wake(0); //wakeup
    mux.setChannel(3); //enable Mux to Ch3 (Room 3)
    mcp3.shutdown_wake(0); //wakeup
    mux.setChannel(4); //enable Mux to Ch4 (Room 4)
    mcp4.shutdown_wake(0); //wakeup
    mux.setNoChannel(); //disable Mux
    delay(500); //delay at least 250 ms for sensors to wake up and get temperatures (500 for lots of extra time, sometimes needed?)
    
    mux.setChannel(0); //enable Mux to Ch0, Plenum
    fT = bme.readTempC(); // degrees C (used only for compensation of ccs)
    if ((fT < 5.0) || (fT > 80)) { //check if temperature read is valid (sometimes a 0 is read?)
        iTErrors += 1; //increment variable every time a bad temperature is read
        mux.setChannel(0); //try once more
        fT = bme.readTempC(); //try once more
    }
    ccs.setTempOffset(fT - 25.0); // compensation for CCS811
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T = fT/10;
    sT = String(T,1);
    fP = bme.readFloatPressure(); // Pascals
    iP = (fP+0.5)/100; // rounded to 0.1 kPa
    fP = iP;
    P = fP/10; // kPa with 1 decimal
    sP = String(P,1);
    fH = bme.readFloatHumidity(); //%
    iH = fH + 0.5; //to use 0 decimals, rounded
    fH = iH;
    H = fH;
    sH = String(H,0);
    if(ccs.available()){
        if(!ccs.readData()) {
            fC = ccs.geteCO2(); // ppm
            CO2 = fC;
            sCO2 = String(CO2,0);
    
            fV = ccs.getTVOC(); //ppb
            TVOC = fV;
            sTVOC = String(TVOC,0);
        }
    } else {
        //Serial.println("CCS811 not available at time: " + String(millis()));
    }
    fT = mcp.readTempC(); // degrees C
    if ((fT < 5.0) || (fT > 80)) { //check if temperature read is valid (sometimes a 0 is read?)
        iTErrors += 1; //increment variable every time a bad temperature is read
        mux.setChannel(0); //try once more
        fT = mcp.readTempC(); //try once more
        if ((fT < 5.0) || (fT >80)) { //if the value read from mcp Plenum sensor is still bad, then
            if ((T > 5.0) && (T < 80)) { //check if the other bme Plenum temperature is acceptable
                fT = T; //use tha good value read for the other Plenum temperature sensor
            }
        }
    }
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T0 = fT/10;
    sT0 = String(T0,1);
    mcp.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setChannel(1); //enable Mux to Ch1 (Room 1)
    fT = mcp1.readTempC(); // degrees C
    if ((fT < 5.0) || (fT > 40)) { //check if temperature read is valid (sometimes a 0 is read?)
        iTErrors += 1; //increment variable every time a bad temperature is read
        mux.setChannel(1); //try once more
        fT = mcp1.readTempC(); //try once more
    }
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T1 = fT/10;
    sT1 = String(T1,1);
    mcp1.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    if ((T1 < 5) || (T1 > 40)) {
        iTErrors += 1; //incement variable every time a bad temperature is read
    }

    mux.setChannel(2); //enable Mux to Ch2 (Room 2)
    fT = mcp2.readTempC(); // degrees C
    if ((fT < 5.0) || (fT > 40)) { //check if temperature read is valid (sometimes a 0 is read?)
        iTErrors += 1; //increment variable every time a bad temperature is read
        mux.setChannel(2); //try once more
        fT = mcp2.readTempC(); //try once more
    }
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T2 = fT/10;
    sT2 = String(T2,1);
    mcp2.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    if ((T2 < 5) || (T2 > 40)) {
        iTErrors += 1; //incement variable every time a bad temperature is read
    }
    
    mux.setChannel(3); //enable Mux to Ch3 (Room 3)
    fT = mcp3.readTempC(); // degrees C
    if ((fT < 5.0) || (fT > 40)) { //check if temperature read is valid (sometimes a 0 is read?)
        iTErrors += 1; //increment variable every time a bad temperature is read
        mux.setChannel(3); //try once more
        fT = mcp3.readTempC(); //try once more
    }
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T3 = fT/10;
    sT3 = String(T3,1);
    mcp3.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    if ((T3 < 5) || (T3 > 40)) {
        iTErrors += 1; //increment variable every time a bad temperature is read
    }

    mux.setChannel(4); //enable Mux to Ch4 (Room 4)
    fT = mcp4.readTempC(); // degrees C
    if ((fT < 5.0) || (fT > 40)) { //check if temperature read is valid (sometimes a 0 is read?)
        iTErrors += 1; //increment variable every time a bad temperature is read
        mux.setChannel(4); //try once more
        fT = mcp4.readTempC(); //try once more
    }
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T4 = fT/10;
    sT4 = String(T4,1);
    mcp4.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    if ((T4 < 5) || (T4 > 40)) {
        iTErrors += 1; //increment varibale eery time a bad temperature is read
    }
    
    mux.setNoChannel(); //disable Mux
    
    //dTime = millis() - lastUpdate; //typically 0.28 s, only zero when routine is not running (e.g. errors with temperature sensors?)
    digitalWrite(boardLed,LOW);

    //if there are too many temperature reading errors occuring, add 10 to the reboots EEPROM count, and then reboot the software
    if (iTErrors >= 10) { //the tens+ will be used to sum the iTErrors, but more than 10 reboots from power failures (uncommon) will screw this up
        EEPROM.get(0, iReboots); //The value gets incremented each time the CPU is re-booted.
        iReboots += 9; //will become 10 after the reboot
        EEPROM.put(0, iReboots);
        System.reset(RESET_NO_WAIT); //reboot the system
    }
}
// create a software timer to obtain new readings of Temp+ every 10 seconds
Timer timer2(10000, getTempPlus); //update Temp, Press, Humid, CO2, TVOC every 10 s

// used to display a list of the room setpoint temperatures on an iPhone
void sTSetUpdate () {
    sTSet = String(T1RoomSet,1);
    sTSet += " ";
    sTSet += String(T2RoomSet,1);
    sTSet += " ";
    sTSet += String(T3RoomSet,1);
    sTSet += " ";
    sTSet += String(T4RoomSet,1);
}

// use a Particle Function to change the room 1 temperature setpoint
int TSetRoom1Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T1RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T1RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(10, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to change the room 2 temperature setpoint
int TSetRoom2Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T2RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T2RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(20, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to change the room 3 temperature setpoint
int TSetRoom3Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T3RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T3RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(30, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to change the room 4 temperature setpoint
int TSetRoom4 (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T4RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T4RoomSet = temp;
    sTSetUpdate();
    EEPROM.put(40, temp); //put update here, so only use function when absolutely necessary (NOTE: only use local variables in Particle Function)
    return 1;
}

// use a Particle Function to reset the iReboots value to 0
int ResetiReboots (String command) { //allow any text to reset the value of iReboots to 0
    iReboots = 0;
    EEPROM.put(0, 0);
    return 1;
}

int sDampersMPdesired (String command) { // command = "4AC" = Damper #1-4, then Mode: A = AUTO, M = MANUAL, then Position: O = OPENED, C = CLOSED (optional if Auto)
    int iRoom;
    bool bMode, bStatus;
    
    iRoom = command.toInt();
    if ((iRoom < 1) || (iRoom > 4)) return -1; // valid Room 1 to 4 not found (or number after 1st digit)
    switch (command.charAt(1)) {
        case 'A':
        case 'a':
            bMode = AUTO;
            break;
        case 'M':
        case 'm':
            bMode = MANUAL;
            break;
        default:
            return -1;
    }
    switch (command.charAt(2)) {
        case 'C':
        case 'c':
            bStatus = CLOSED;
            break;
        default:
            bStatus = OPENED; //default to OPENED (even if 3rd char is missing)
    }
    switch (iRoom) {
        case 1:
            if (bMode == AUTO) {
                Damper1Mode = AUTO; //let main loop open and close damper as needed
            } else {
                Damper1Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper1SelectedPosition = OPENED;
                    myRelays.off(ROOM1); //open damper
                } else {
                    Damper1SelectedPosition = CLOSED;
                    myRelays.on(ROOM1); //close damper
                }
            }
            break;
        case 2:
            if (bMode == AUTO) {
                Damper2Mode = AUTO; //let main loop open and close damper as needed
            } else {
                Damper2Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper2SelectedPosition = OPENED;
                    myRelays.off(ROOM2); //open damper
                } else {
                    Damper2SelectedPosition = CLOSED;
                    myRelays.on(ROOM2); //close damper
                }
            }
            break;
        case 3:
            if (bMode == AUTO) {
                Damper3Mode = AUTO; //let main loop open and close damper as needed
            } else {
                Damper3Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper3SelectedPosition = OPENED;
                    myRelays.off(ROOM3); //open damper
                } else {
                    Damper3SelectedPosition = CLOSED;
                    myRelays.on(ROOM3); //close damper
                }
            }
            break;
        case 4:
            if (bMode == AUTO) {
                Damper4Mode = AUTO; //let main loop open and close damper as needed
            } else {
                Damper4Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper4SelectedPosition = OPENED;
                    myRelays.off(ROOM4); //open damper
                } else {
                    Damper4SelectedPosition = CLOSED;
                    myRelays.on(ROOM4); //close damper
                }
            }
            break;
    }
    return 1;
}

bool OpenDamper(int Room) { //determine if a room damper should be opened or closed
    float TSet, TRoom, TPlenum, TOffset;
    int StartHour, FinishHour;
    bool DamperSelPos;
    switch (lMonth) { //(force cold air upstairs, hot air downstairs)
        case 1:
        case 2:
        case 11:
        case 12: // winter with no DST
            StartHour = 20; //8 PM
            FinishHour = 3; //3 AM
            break;
        case 6:
        case 7:
        case 8: //summer, with DST offset
            StartHour = 18; //7 PM DST
            FinishHour = 4; //5 AM DST
            break;
        case 3:
        case 4:
        case 5:
        case 9:
        case 10: //spring & fall with some DST
            StartHour = 19; //7 PM, 8 PM DST
            FinishHour = 3; //3 AM, 4 AM DST
            break;
    }
    TPlenum = T0;
    if (TPlenum < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TPlenum > 80) return TRUE; // keep damper open if there is a temperature issue
    switch (Room) {
        case ROOM1:
            TSet = T1RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T1;
            DamperSelPos = Damper1SelectedPosition; //present Damper position
            break;
        case ROOM2:
            TSet = T2RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T2;
            DamperSelPos = Damper2SelectedPosition; //present Damper position
            if (TPlenum < 20) return TRUE; //always keep room 2 damper open when air conditioner on
            break;
        case ROOM3:
            TSet = T3RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T3;
            DamperSelPos = Damper3SelectedPosition; //present Damper position
            if (TPlenum < 20) return TRUE; //always keep room 3 damper open when air conditioner on
            break;
        case ROOM4: //no temperature setback (basement)
            TSet = T4RoomSet;
            TRoom = T4;
            DamperSelPos = Damper4SelectedPosition; //present Damper position
    }
    if (TRoom < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TRoom > 40) return TRUE; // keep damper open if there is a temperature issue
    if (TSet < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TSet > 40) return TRUE; // keep damper open if there is a temperature issue
    TOffset = TDeadband; //e.g. 0.9C above or below temperature setpoint
    if (DamperSelPos == CLOSED) { //if already closed, don't open until temperature rises/drops by THysteresis amount
        TOffset -= THysteresis; //e.g. stay closed until 0.1C above or below temperature setpoint
    }
    if ((TPlenum > (TRoom+TOffset)) && (TRoom > (TSet+TOffset))) { //room is already too hot, so don't heat more
        return FALSE;
    }
    if ((TPlenum < (TRoom-TOffset)) && (TRoom < (TSet-TOffset))) { //room is already too cold, so don't cool more
        return FALSE;
    }
    return TRUE; //damper should be open for all other cases
}

// reset the system after 60 seconds if the application is unresponsive
ApplicationWatchdog wd(60000, System.reset);

void setup() {
    float tempT;
    pinMode(boardLed, OUTPUT); //use this blue LED to blink during code operation (setup + when temperatures are read)
	digitalWrite(boardLed, HIGH);
    
    // We are going to declare Particle.variable() here so that we can access the values from the cloud.
	//This registration must be completed within 30 s of connecting to the cloud, so do it first thing in setup
    //Particle.variable("d_Time_ms", dTime); //text description must NOT have any spaces
    //items are listed in alphabetical order on iPhone App
    Particle.variable("s_CO2_ppm", sCO2);
    Particle.variable("s_Dampers", sDampersStatus);
    Particle.variable("s_P_kPa", sP);
    Particle.variable("s_RH_Pct", sH);
    Particle.variable("s_T0_C", sT0);
    Particle.variable("s_T1234Set", sTSet);
    Particle.variable("s_T1_C", sT1);
    Particle.variable("s_T2_C", sT2);
    Particle.variable("s_T3_C", sT3);
    Particle.variable("s_T4_C", sT4);
    Particle.variable("s_TVOC_ppb", sTVOC);
    Particle.variable("s_T_C", sT);
    Particle.variable("N_Reboots", iReboots);
    Particle.variable("s_TimeNow",sTime); //present time of last update to the cloud, in case cloud updates stop/crash
    Particle.variable("N_TErrors",iTErrors); //incremented every time a T0-4 temperature read is a bad value, good if T0 = 0-80 C or T1-T4 = 10-40 C

    Particle.function("T1S",TSetRoom1Day);
    Particle.function("T2S",TSetRoom2Day);
    Particle.function("T3S",TSetRoom3Day);
    Particle.function("T4S",TSetRoom4);
    Particle.function("D_Mode_Pos_eg4MC",sDampersMPdesired); //e.g. "4MC" = Damper 4, manual mode, close damper; "3a" = Damper 3, auto mode
    Particle.function("ResetReboots", ResetiReboots); //so the number of reboots can be reset back to 0
    
    EEPROM.get(0, iReboots); //The value gets incremented each time the CPU is re-booted.
    iReboots += 1;
    EEPROM.put(0, iReboots);
    
    iTErrors = 0; //Use to keep track of any T0-4 temperatures read that are not within 10-40 C (starts at 0 after every reboot)
    
    mux.begin(); //I2C with 8  output channels (for all T/P/RH/CO2/TVOC), 5 used (0-4)
    mux.setNoChannel(); //disables all channels 0-7
    
    while(!myLog.begin(0x2A)){ //prepare OpenLog connection
        delay(1000); // 1 s
    }
    Time.zone(-5); //ignore DST (separately deal with time/temperature variations throughout the year)
    AddHeaderToLogFile(); //this will create/append a file name based on the date, and add the header to it (comma separated variables format)

    myRelays.begin();
    myRelays.allOff(); //wired for output relays off = damper open (dampers open if power failure to relay coil)
	Damper1SelectedPosition = OPENED;
	Damper2SelectedPosition = OPENED;
	Damper3SelectedPosition = OPENED;
	Damper4SelectedPosition = OPENED;
	Damper1Mode = AUTO;
	Damper2Mode = AUTO;
	Damper3Mode = AUTO;
	Damper4Mode = AUTO;
	
//	Channel 0 - Furnace Plenum (CO2, etc)
    mux.setChannel(0); //enable Mux to Ch0 (location of BME280/CCS811 board and MCP9808 board in furnace plenum)
    bme.settings.runMode = 0b11; // normal mode
    bme.settings.tStandby = 0b101; // 1000 ms
    bme.settings.filter = 0b000; // off
    bme.settings.tempOverSample = 0b011; // x4
    bme.settings.pressOverSample = 0b011; // x4
    bme.settings.humidOverSample = 0b011; // x4
	while(!bme.begin()){ //prepare bme connection (and I2C), typical is: mode=normal, sampling=x16, filter=off, standby=0.5 ms
        delay(1000); // 1 s
	}
    while(!ccs.begin(0x5B)){ //prepare ccs connection (change default library address to 0x5B)
        delay(1000); // 1 s
    }
	while(!mcp.begin(0x18)){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
    wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 1 - Bedroom 1
    mux.setChannel(1); //enable Mux to Ch1 (location of 1st remote MCP9808 board, bedroom 1)
	while(!mcp1.begin(0x18)){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(10, tempT);
//	EEPROM.get(10,iOffsetT);
//	tempT = (iOffsetT + 100) / 10; //stored in EEPROM as an offset integer
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(10, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T1RoomSet = tempT;
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 2 - Bedroom 2
	mux.setChannel(2); //enable Mux to Ch1 (location of 2nd remote MCP9808 board, bedroom 2)
	while(!mcp2.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(20, tempT);
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(20, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T2RoomSet = tempT;
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 3 - Bedroom 3
    mux.setChannel(3); //enable Mux to Ch1 (location of 3rd remote MCP9808 board, bedroom 3)
	while(!mcp3.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(30, tempT);
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(30, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T3RoomSet = tempT;
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

//  Channel 4 - Basement
    mux.setChannel(4); //enable Mux to Ch1 (location of 4th remote MCP9808 board, basement)
	while(!mcp4.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	EEPROM.get(40, tempT);
    if ((tempT < 15.0) || (tempT > 30.0)) {
        tempT = 23.0; //if no value specified, default to 23 C
        EEPROM.put(40, tempT); //save value in simulated EEPROM so changed values kept during power failures
    }
	T4RoomSet = tempT;
	sTSetUpdate();
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?
	
	mux.setNoChannel(); //disable Mux (leave Mux off when not measuring a temperature)

	// initialize to some default values (needed until all timers have run)
	T0 = 23.0; //these default values will keep all dampers initially open
	T1 = 23.0;
	T2 = 23.0;
	T3 = 23.0;
	T4 = 23.0;
	
	lHour = Time.hour();
	lMinute = Time.minute();
	lLastMinute = lMinute;
	
	timer1.start();
	timer2.start();
	
	wd.checkin(); // resets the AWDT count (must occur every 60 seconds or less, or system will reset); not needed in setup?
	digitalWrite(boardLed, LOW); //setup completed so turn blue board LED off
}

void loop() { //open and close dampers based mainly on room temperatures (not all dampers can be closed, or too much back pressure on HVAC)
    // Allow a max. of 3 dampers closed.  If 4 dampers requested closed: In auto mode, force damper 4 open (basement).  In manual mode, force damper 1 open (spare bedroom).
    sDampersStatus = ""; //e.g. "A1onM2offA3offA4on" A = AUTO, M = MANUAL, on = OPENED, off = CLOSED
    if (Damper1Mode == MANUAL) { //don't change damper position if in manual (only change from remote input), unless forced re below
        if (Damper1SelectedPosition == OPENED) sDampersStatus += "M1on";
        else sDampersStatus += "M1off";
    } else {
        sDampersStatus += "A1"; //Damper1Mode = AUTO
        if (OpenDamper(ROOM1)) { //upstairs bedroom 1
            Damper1SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM1); //open damper
        } else {
            Damper1SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM1); //close damper
        }
    }
    if (Damper2Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper2SelectedPosition == OPENED) sDampersStatus += "M2on";
        else sDampersStatus += "M2off";
    } else {
        sDampersStatus += "A2"; //Damper2Mode = AUTO
        if (OpenDamper(ROOM2)) { //upstairs bedroom 2
            Damper2SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM2); //open damper
        } else {
            Damper2SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM2); //close damper
        }
    }
    if (Damper3Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper3SelectedPosition == OPENED) sDampersStatus += "M3on";
        else sDampersStatus += "M3off";
    } else {
        sDampersStatus += "A3"; //Damper3Mode = AUTO
        if (OpenDamper(ROOM3)) { //upstairs bedroom 3
            Damper3SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM3); //open damper
        } else {
            Damper3SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM3); //close damper
        }
    }
    if (Damper4Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper4SelectedPosition == OPENED) sDampersStatus += "M4on";
        else sDampersStatus += "M4off";
    } else {
        sDampersStatus += "A4"; //Damper4Mode = AUTO
        if (OpenDamper(ROOM4)) { //basement
            Damper4SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM4); //open damper
        } else {
            Damper4SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM4); //close damper
        }
    }
    if ((Damper1SelectedPosition == CLOSED) && (Damper2SelectedPosition == CLOSED) && (Damper3SelectedPosition == CLOSED) && (Damper4SelectedPosition == CLOSED)) { // do something if all Dampers are selected to be closed
        if ((Damper4Mode == MANUAL) || (T0 < T4RoomSet)) { //if Damper 4 was manually closed OR the AC is on, leave it closed and force Damper 1 open
            myRelays.off(ROOM1); //open Room 1 damper
        } else { //auto open Damper in Room 4 (basement) if upstairs rooms are closed
            myRelays.off(ROOM4); //open Room 4 damper
        }
    }
    wd.checkin(); // resets the AWDT count (must occur every 60 seconds or less, or system will reset) [not needed here, see comment at end of loop]
    delay(2000); // 2 s (no need to continuously run this loop)
} // AWDT count reset automatically after loop() ends

DamperController

C/C++
//make sure to add these libraries via the Libraries tab in the left sidebar
#include "Particle.h" //automatically included
#include "SparkFunBME280.h"
#include "Adafruit_CCS811.h"
#include "Arduino.h"
//#include <Wire.h>
#include "SparkFun_Qwiic_OpenLog_Arduino_Library.h" //local copy, revised
#include "TCA9548A-RK.h"
#include "RelayShield.h"
#include "Adafruit_MCP9808.h"

BME280 bme; // I2C 0x77, Mux Ch0 (I2C uses D0-1)
Adafruit_CCS811 ccs; // I2C 0x5B, Mux Ch0
Adafruit_MCP9808 mcp; // I2C 0x18, Mux Ch0, 250 ms per temp update
Adafruit_MCP9808 mcp1; // I2C 0x18, Mux Ch1
Adafruit_MCP9808 mcp2; // I2C 0x18, Mux Ch2
Adafruit_MCP9808 mcp3; // I2C 0x18, Mux Ch3
Adafruit_MCP9808 mcp4; // I2C 0x18, Mux Ch4
OpenLog myLog; // I2C 0x2A
TCA9548A mux(Wire, 0); //I2C 0x70
RelayShield myRelays; //RelayShield, D3-6

int boardLed = D7; //blink LED when code is running
//double dTime = 0.0; //used for cloud variable (e.g. time to log one line of data)
//unsigned long lastUpdate = 0; //used to calculate dTime in ms (e.g. to log each line of data)
double T; //Plenum - used for cloud variable and Logging
double P; //used for cloud variable and Logging
double H; //used for cloud variable and Logging
double CO2; //used for cloud variable and Logging
double TVOC; //used for cloud variable and Logging
double T0; //Plenum - used for cloud variable and Logging
double T1; //Room 1 - used for cloud variable and Logging
double T2; //Room 2 - used for cloud variable and Logging
double T3; //Room 3 - used for cloud variable and Logging
double T4; //Room 4 - used for cloud variable and Logging
#define TDeadband 0.9 //switch dampers on/off if above/below setpoint +/- Tdeadband (degrees C)
#define THysteresis 0.8 //require this much change in temperature after a damper has switched state, before another switch of state (degrees C)
#define ROOM1 1 //1st bedroom
#define ROOM2 2 //2nd bedroom
#define ROOM3 3 //3rd bedroom
#define ROOM4 4 //basement
float T1RoomSet; //desired Room 1 temperature
float T2RoomSet; //desired Room 2 temperature
float T3RoomSet; //desired Room 3 temperature
float T4RoomSet; //desired Room 4 temperature
#define OPENED 0
#define CLOSED 1
bool Damper1SelectedPosition; //damper SelectedPosition for Room 1
bool Damper2SelectedPosition; //damper SelectedPosition for Room 2
bool Damper3SelectedPosition; //damper SelectedPosition for Room 3
bool Damper4SelectedPosition; //damper SelectedPosition for Room 4
#define AUTO 0
#define MANUAL 1
bool Damper1Mode; //damper mode for Room 1 (Auto/Manual)
bool Damper2Mode; //damper mode for Room 2
bool Damper3Mode; //damper mode for Room 3
bool Damper4Mode; //damper mode for Room 4
unsigned long lYear; //   4 digit year, e.g. 2019
unsigned long lMonth; //  1-12 month
unsigned long lDay; //    1-31 day
unsigned long lHour; //   0-23 hour
unsigned long lMinute; // 0-59
unsigned long lLastMinute; // check every 30 s to see if minute logged already, so no minutes get missed, which does otherwise occur
String sDate = "2019-09-14"; //example
String sTime = "23:45:00"; //example
String sLogFileName = "default.txt"; //example
String sBuf = ""; //use global buffer (and above variables) to reduce stack usage, especially in timers
String sDampersStatus = "A1onM2offA3offA4on"; //example: Damper Mode: A = AUTO, M = MANUAL, Damper Position: on = OPENED, off = CLOSED

void AddHeaderToLogFile() {
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
    lYear = Time.year(); // 4 digit year
    lMonth = Time.month(); //1-12 month
    lDay = Time.day(); //1-31 day
    sDate = String(lYear) + "-";
    if (lMonth < 10) sDate += "0"; //make sure 2 digits so file is aligned
    sDate += String(lMonth) + "-";
    if (lDay < 10) sDate += "0"; //make sure 2 digits so file is aligned
    sDate += String(lDay); // e.g. 2019-09-07
    sLogFileName = String(lYear);
    if (lMonth < 10) sLogFileName += "0"; //make sure 2 digits for month
    sLogFileName += String(lMonth);
    if (lDay < 10) sLogFileName += "0"; //make sure 2 digits for day
    sLogFileName += String(lDay) + ".txt"; //e.g. 20190907.txt (max 8.3 digits)
    myLog.append(sLogFileName); //append or create new Log file and send subsequent text to it (e.g. new day, after midnight)
    delay(1); // give it time?
    sBuf = "Date,Time,T0(C),T1(C),T2(C),T3(C),T4(C),T1Set(C),T2Set(C),T3Set(C),T4Set(C),DSelStatus,T(C),P(kPa),RH(%),CO2(ppm),TVOC(ppb)";
    myLog.Println(sBuf); //custom routine that adds 1 ms delay between each character, otherwise some characters get lost
	//dTime = millis() - lastUpdate; //typically about 0.67 ms
}

void logInfo() { //Timers have limited stack space, so use some global variables
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
	lMinute = Time.minute();
    if (lLastMinute != lMinute) { //only log data once a minute
	    if (lYear != Time.year() || lMonth != Time.month() || lDay != Time.day()) { //if a new day, create a new file + header
    	    AddHeaderToLogFile();
    	    Particle.syncTime(); //keep time correct, updating at the start of every day
    	}
    	lHour = Time.hour();
    	sTime = "";
    	if (lHour < 10) sTime += "0"; //make sure 2 digits so file is aligned
    	sTime += String(lHour) + ":";
    	if (lMinute < 10) sTime += "0"; //make sure 2 digits so file is aligned
    	sTime += String(lMinute) + ":00"; //e.g. "23:45:00"
    	sBuf = sDate + "," + sTime + ","; //sDate updated in AddHeaderToLogFile()
    	sBuf += String(T0,1) + "," + String(T1,1) + "," + String(T2,1) + "," + String(T3,1) + "," + String(T4,1) + ",";
    	sBuf += String(T1RoomSet,1) + "," + String(T2RoomSet,1) + "," + String(T3RoomSet,1) + "," + String(T4RoomSet,1) + ",";
    	sBuf += sDampersStatus + ","; //e.g. "A1onM2offA3offA4on" A = AUTO, M = MANUAL, on = OPENED, off = CLOSED
    	sBuf += String(T,1) + "," + String(P,1) + "," + String(H,0) + "," + String(CO2,0) + "," + String(TVOC,0);
    	myLog.Println(sBuf);
    	lLastMinute = lMinute;
    }
    //dTime = millis() - lastUpdate; //typically about 0.54 s (plus about 0.67 s if new file created for a new day + header added)
}
// create a software timer to log Temp, Press, Humid, CO2, TVOC, Damper Status every minute (takes about 0.67 s)
Timer timer1(30000, logInfo); //log data every 60 s, but check every 30 s, so no minutes get dropped (can otherwise happen, although very infrequent)

void getTempPlus() {
    digitalWrite(boardLed,HIGH); //blink LED whenever in this routine
    //lastUpdate = millis(); //time since the program started, in msec (overflows in ~49 days)
    //dTime = 0.0; //set to zero at start of routine, so if routine hangs, it will not get updated
    float fT, fP, fH, fC, fV;
    int iT, iP, iH;
    
    //first, wake up all mcp devices (takes 250 ms until a new temperature value is available)
    mux.setChannel(0); //enable Mux to Ch0, Plenum
    mcp.shutdown_wake(0); //wakeup
    mux.setChannel(1); //enable Mux to Ch1 (Room 1)
    mcp1.shutdown_wake(0); //wakeup
    mux.setChannel(2); //enable Mux to Ch2 (Room 2)
    mcp2.shutdown_wake(0); //wakeup
    mux.setChannel(3); //enable Mux to Ch3 (Room 3)
    mcp3.shutdown_wake(0); //wakeup
    mux.setChannel(4); //enable Mux to Ch4 (Room 4)
    mcp4.shutdown_wake(0); //wakeup
    mux.setNoChannel(); //disable Mux
    delay(260); //delay at least 250 ms for sensors to wake up and get temperatures
    
    mux.setChannel(0); //enable Mux to Ch0, Plenum
    fT = bme.readTempC(); // degrees C (used only for compensation of ccs)
    ccs.setTempOffset(fT - 25.0); // compensation for CCS811
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T = fT/10;
    fP = bme.readFloatPressure(); // Pascals
    iP = (fP+0.5)/100; // rounded to 0.1 kPa
    fP = iP;
    P = fP/10; // kPa with 1 decimal
    fH = bme.readFloatHumidity(); //%
    iH = fH + 0.5; //to use 0 decimals, rounded
    fH = iH;
    H = fH;
    if(ccs.available()){
        if(!ccs.readData()) {
            fC = ccs.geteCO2(); // ppm
            CO2 = fC;
    
            fV = ccs.getTVOC(); //ppb
            TVOC = fV;
        }
    } else {
        //Serial.println("CCS811 not available at time: " + String(millis()));
    }
    fT = mcp.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T0 = fT/10;
    mcp.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    
    mux.setChannel(1); //enable Mux to Ch1 (Room 1)
    fT = mcp1.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T1 = fT/10;
    mcp1.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setChannel(2); //enable Mux to Ch2 (Room 2)
    fT = mcp2.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T2 = fT/10;
    mcp2.shutdown_wake(1); //shutdown, to reduce power (and self heating)
    
    mux.setChannel(3); //enable Mux to Ch3 (Room 3)
    fT = mcp3.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T3 = fT/10;
    mcp3.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setChannel(4); //enable Mux to Ch4 (Room 4)
    fT = mcp4.readTempC(); // degrees C
    iT = (fT*10) + 0.5; //to use only 1 decimal, rounded
    fT = iT;
    T4 = fT/10;
    mcp4.shutdown_wake(1); //shutdown, to reduce power (and self heating)

    mux.setNoChannel(); //disable Mux
    
    //dTime = millis() - lastUpdate; //typically 0.28 s, only zero when routine is not running (e.g. errors with temperature sensors?)

    digitalWrite(boardLed,LOW);
}
// create a software timer to obtain new readings of Temp+ every 10 seconds
Timer timer2(10000, getTempPlus); //update Temp, Press, Humid, CO2, TVOC every 10 s


int TSetRoom1Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T1RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T1RoomSet = temp;
    return 1;
}

int TSetRoom2Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T2RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T2RoomSet = temp;
    return 1;
}

int TSetRoom3Day (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T3RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T3RoomSet = temp;
    return 1;
}

int TSetRoom4 (String command) {
    float temp = command.toFloat(); //convert text String of digits to float (stops on first non-digit char, or end of string)
    if ((temp < 15.0) || (temp > 30.0)) {
        T4RoomSet = 23.0; //if bad value specified, default to 23 C
        return -1; //warn user that a bad value was provided
    }
    T4RoomSet = temp;
    return 1;
}

int sDampersMPdesired (String command) { // command = "4AC" = Damper #1-4, then Mode: A = AUTO, M = MANUAL, then Position: O = OPENED, C = CLOSED (optional if Auto)
    int iRoom;
    bool bMode, bStatus;
    
    iRoom = command.toInt();
    if ((iRoom < 1) || (iRoom > 4)) return -1; // valid Room 1 to 4 not found (or number after 1st digit)
    switch (command.charAt(1)) {
        case 'A':
        case 'a':
            bMode = AUTO;
            break;
        case 'M':
        case 'm':
            bMode = MANUAL;
            break;
        default:
            return -1;
    }
    switch (command.charAt(2)) {
        case 'C':
        case 'c':
            bStatus = CLOSED;
            break;
        default:
            bStatus = OPENED; //default to OPENED (even if 3rd char is missing)
    }
    switch (iRoom) {
        case 1:
            if (bMode == AUTO) {
                Damper1Mode = AUTO;
            } else {
                Damper1Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper1SelectedPosition = OPENED;
                    myRelays.off(ROOM1); //open damper
                } else {
                    Damper1SelectedPosition = CLOSED;
                    myRelays.on(ROOM1); //close damper
                }
            }
            break;
        case 2:
            if (bMode == AUTO) {
                Damper2Mode = AUTO;
            } else {
                Damper2Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper2SelectedPosition = OPENED;
                    myRelays.off(ROOM2); //open damper
                } else {
                    Damper2SelectedPosition = CLOSED;
                    myRelays.on(ROOM2); //close damper
                }
            }
            break;
        case 3:
            if (bMode == AUTO) {
                Damper3Mode = AUTO;
            } else {
                Damper3Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper3SelectedPosition = OPENED;
                    myRelays.off(ROOM3); //open damper
                } else {
                    Damper3SelectedPosition = CLOSED;
                    myRelays.on(ROOM3); //close damper
                }
            }
            break;
        case 4:
            if (bMode == AUTO) {
                Damper4Mode = AUTO;
            } else {
                Damper4Mode = MANUAL;
                if (bStatus == OPENED) {
                    Damper4SelectedPosition = OPENED;
                    myRelays.off(ROOM4); //open damper
                } else {
                    Damper4SelectedPosition = CLOSED;
                    myRelays.on(ROOM4); //close damper
                }
            }
            break;
    }
    return 1;
}

bool OpenDamper(int Room) { //determine if a room damper should be opened or closed
    float TSet, TRoom, TPlenum, TOffset;
    int StartHour, FinishHour;
    bool DamperSelPos;
    switch (lMonth) { //(force cold air upstairs, hot air downstairs)
        case 1:
        case 2:
        case 11:
        case 12: // winter with no DST
            StartHour = 20; //8 PM
            FinishHour = 3; //3 AM
            break;
        case 6:
        case 7:
        case 8: //summer, with DST offset
            StartHour = 18; //7 PM DST
            FinishHour = 4; //5 AM DST
            break;
        case 3:
        case 4:
        case 5:
        case 9:
        case 10: //spring & fall with some DST
            StartHour = 19; //7 PM, 8 PM DST
            FinishHour = 3; //3 AM, 4 AM DST
            break;
    }
    switch (Room) {
        case ROOM1:
            TSet = T1RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T1;
            DamperSelPos = Damper1SelectedPosition; //present Damper position
            break;
        case ROOM2:
            TSet = T2RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T2;
            DamperSelPos = Damper2SelectedPosition; //present Damper position
            break;
        case ROOM3:
            TSet = T3RoomSet;
            if ((lHour > StartHour) || (lHour < FinishHour)) TSet -= 2.0; //lower temperature at night
            TRoom = T3;
            DamperSelPos = Damper3SelectedPosition; //present Damper position
            break;
        case ROOM4: //no temperature setback (basement)
            TSet = T4RoomSet;
            TRoom = T4;
            DamperSelPos = Damper4SelectedPosition; //present Damper position
    }
    TPlenum = T0;
    if (TPlenum < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TPlenum > 80) return TRUE; // keep damper open if there is a temperature issue
    if (TRoom < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TRoom > 40) return TRUE; // keep damper open if there is a temperature issue
    if (TSet < 5) return TRUE; // keep damper open if there is a temperature issue
    if (TSet > 40) return TRUE; // keep damper open if there is a temperature issue
    TOffset = TDeadband; //e.g. 0.9C above or below temperature setpoint
    if (DamperSelPos == CLOSED) { //if already closed, don't open until temperature rises/drops by THysteresis amount
        TOffset -= THysteresis; //e.g. stay closed until 0.1C above or below temperature setpoint
    }
    if ((TPlenum > (TRoom+TOffset)) && (TRoom > (TSet+TOffset))) { //room is already too hot, so don't heat more
        return FALSE;
    }
    if ((TPlenum < (TRoom-TOffset)) && (TRoom < (TSet-TOffset))) { //room is already too cold, so don't cool more
        return FALSE;
    }
    return TRUE; //damper should be open for all other cases
}

// reset the system after 60 seconds if the application is unresponsive
ApplicationWatchdog wd(60000, System.reset);

void setup() {
    pinMode(boardLed, OUTPUT); //use this blue LED to blink during code operation (setup + when temperatures are read)
	digitalWrite(boardLed, HIGH);
    
    // We are going to declare Particle.variable() here so that we can access the values from the cloud.
	//This registration must be completed within 30 s of connecting to the cloud, so do it first thing in setup
    //Particle.variable("d_Time_ms", dTime); //text description must NOT have any spaces
    Particle.variable("d_T_C", T);
    Particle.variable("d_T0_C", T0);
    Particle.variable("d_T1_C", T1);
    Particle.variable("d_T2_C", T2);
    Particle.variable("d_T3_C", T3);
    Particle.variable("d_T4_C", T4);
    Particle.variable("d_P_kPa", P);
    Particle.variable("d_RH_Pct", H);
    Particle.variable("d_CO2_ppm", CO2);
    Particle.variable("d_TVOC_ppb", TVOC);
    Particle.variable("s_Dampers", sDampersStatus);

    Particle.function("T1S",TSetRoom1Day);
    Particle.function("T2S",TSetRoom2Day);
    Particle.function("T3S",TSetRoom3Day);
    Particle.function("T4S",TSetRoom4);
    Particle.function("D_Mode_Pos",sDampersMPdesired); //e.g. "4MC" = Damper 4, manual mode, close damper; "3a" = Damper 3, auto mode
    
    mux.begin(); //I2C with 8  output channels (for all T/P/RH/CO2/TVOC), 5 used (0-4)
    mux.setNoChannel(); //disables all channels 0-7
    
    while(!myLog.begin(0x2A)){ //prepare OpenLog connection
        delay(1000); // 1 s
    }
    Time.zone(-5); //ignore DST (separately deal with time/temperature variations throughout the year)
    AddHeaderToLogFile(); //this will create/append a file name based on the date, and add the header to it (comma separated variables format)

    myRelays.begin();
    myRelays.allOff(); //wired for output relays off = damper open (dampers open if power failure to relay coil)
	Damper1SelectedPosition = OPENED;
	Damper2SelectedPosition = OPENED;
	Damper3SelectedPosition = OPENED;
	Damper4SelectedPosition = OPENED;
	Damper1Mode = AUTO;
	Damper2Mode = AUTO;
	Damper3Mode = AUTO;
	Damper4Mode = AUTO;
	
    mux.setChannel(0); //enable Mux to Ch0 (location of BME280/CCS811 board and MCP9808 board in furnace plenum)
    bme.settings.runMode = 0b11; // normal mode
    bme.settings.tStandby = 0b101; // 1000 ms
    bme.settings.filter = 0b000; // off
    bme.settings.tempOverSample = 0b011; // x4
    bme.settings.pressOverSample = 0b011; // x4
    bme.settings.humidOverSample = 0b011; // x4
	while(!bme.begin()){ //prepare bme connection (and I2C), typical is: mode=normal, sampling=x16, filter=off, standby=0.5 ms
        delay(1000); // 1 s
	}
    while(!ccs.begin(0x5B)){ //prepare ccs connection (change default library address to 0x5B)
        delay(1000); // 1 s
    }
	while(!mcp.begin(0x18)){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
    wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

    mux.setChannel(1); //enable Mux to Ch1 (location of 1st remote MCP9808 board, bedroom 1)
	while(!mcp1.begin(0x18)){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	T1RoomSet = 23.0; // 23 degrees C (default room temperature)
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?
	
	mux.setChannel(2); //enable Mux to Ch1 (location of 2nd remote MCP9808 board, bedroom 2)
	while(!mcp2.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	T2RoomSet = 23.0; // 23 degrees C (default room temperature)
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

    mux.setChannel(3); //enable Mux to Ch1 (location of 3rd remote MCP9808 board, bedroom 3)
	while(!mcp3.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	T3RoomSet = 23.0; // 23 degrees C (default room temperature)
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?

    mux.setChannel(4); //enable Mux to Ch1 (location of 4th remote MCP9808 board, basement)
	while(!mcp4.begin()){ //it will take at least 250 ms before a temperature value is available
        delay(1000); // 1 s
	}
	T4RoomSet = 23.0; // 23 degrees C (default room temperature)
	wd.checkin(); // resets the AWDT count (must occur every 60 min or less, or system will reset); not needed in setup?
	
	mux.setNoChannel(); //disable Mux (leave Mux off when not measuring a temperature)

	// initialize to some default values (needed until all timers have run)
	T0 = 23.0; //these default values will keep all dampers initially open
	T1 = 23.0;
	T2 = 23.0;
	T3 = 23.0;
	T4 = 23.0;
	lHour = Time.hour();
	lMinute = Time.minute();
	lLastMinute = lMinute;
	
	timer1.start();
	timer2.start();
	
	wd.checkin(); // resets the AWDT count (must occur every 60 seconds or less, or system will reset); not needed in setup?
	digitalWrite(boardLed, LOW); //setup completed so turn blue board LED off
}

void loop() { //open and close dampers based mainly on room temperatures (not all dampers can be closed, or too much back pressure on HVAC)
    // Allow a max. of 3 dampers closed.  If 4 dampers requested closed: In auto mode, force damper 4 open (basement).  In manual mode, force damper 1 open (spare bedroom).
    sDampersStatus = ""; //e.g. "A1onM2offA3offA4on" A = AUTO, M = MANUAL, on = OPENED, off = CLOSED
    if (Damper1Mode == MANUAL) { //don't change damper position if in manual (only change from remote input), unless forced re below
        if (Damper1SelectedPosition == OPENED) sDampersStatus += "M1on";
        else sDampersStatus += "M1off";
    } else {
        sDampersStatus += "A1"; //Damper1Mode = AUTO
        if (OpenDamper(ROOM1)) { //upstairs bedroom 1
            Damper1SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM1); //open damper
        } else {
            Damper1SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM1); //close damper
        }
    }
    if (Damper2Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper2SelectedPosition == OPENED) sDampersStatus += "M2on";
        else sDampersStatus += "M2off";
    } else {
        sDampersStatus += "A2"; //Damper2Mode = AUTO
        if (OpenDamper(ROOM2)) { //upstairs bedroom 2
            Damper2SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM2); //open damper
        } else {
            Damper2SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM2); //close damper
        }
    }
    if (Damper3Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper3SelectedPosition == OPENED) sDampersStatus += "M3on";
        else sDampersStatus += "M3off";
    } else {
        sDampersStatus += "A3"; //Damper3Mode = AUTO
        if (OpenDamper(ROOM3)) { //upstairs bedroom 3
            Damper3SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM3); //open damper
        } else {
            Damper3SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM3); //close damper
        }
    }
    if (Damper4Mode == MANUAL) { //don't change damper position if in manual (only change from remote input)
        if (Damper4SelectedPosition == OPENED) sDampersStatus += "M4on";
        else sDampersStatus += "M4off";
    } else {
        sDampersStatus += "A4"; //Damper4Mode = AUTO
        if (OpenDamper(ROOM4)) { //basement
            Damper4SelectedPosition = OPENED;
            sDampersStatus += "on";
            myRelays.off(ROOM4); //open damper
        } else {
            Damper4SelectedPosition = CLOSED;
            sDampersStatus += "off";
            myRelays.on(ROOM4); //close damper
        }
    }
    if ((Damper1SelectedPosition == CLOSED) && (Damper2SelectedPosition == CLOSED) && (Damper3SelectedPosition == CLOSED) && (Damper4SelectedPosition == CLOSED)) { // do something if all Dampers are selected to be closed
        if (Damper4Mode == MANUAL) { //if Damper 4 was manually closed, leave it closed and force Damper 1 open
            myRelays.off(ROOM1);
        } else { //auto open Damper in Room 4 (basement) if upstairs rooms are closed
            myRelays.off(ROOM4);
        }
    }
    wd.checkin(); // resets the AWDT count (must occur every 60 seconds or less, or system will reset) [not needed here, see comment at end of loop]
    delay(2000); // 2 s (no need to continuously run this loop)
} // AWDT count reset automatically after loop() ends

Credits

Scott McNabb

Scott McNabb

1 project • 0 followers
Retired Electrical Engineer

Comments