Projects 2021 04 14 Snake Tank Pt. 4 - Embedded Software
Post
Cancel

Snake Tank Pt. 4 - Embedded Software

Preview Image

This article covers the design and implementation of the arduino software for my Snake Tank Humidifier project.

  1. What + Why?
    1. Goals/Requirements
  2. How?
    1. Development Tools
    2. External Dependencies
  3. Component Controllers
    1. DHT22
    2. AtomizerController
    3. FanController
    4. HumidityController
    5. LightController
  4. Networking
    1. NTPClient
      1. NTP Protocol
    2. WebServer
    3. Router
    4. WifiController
    5. MacAddress
    6. routes
  5. Utilities
    1. Bitflag
    2. DateTime
    3. lines
    4. Scheduling
  6. Constants
    1. secrets
    2. globals
  7. Main Arduino File - climate_control.ino

What + Why?

The next major component of the Snake Tank Humidifier project is the software, which is responsible for controlling all of the electronics sub-systems and providing remote control of the system with network connectivity

Goals/Requirements

When designing the software for this project I wanted to ensure that the system could be monitored and updated remotely over the network, eliminating the need to change settings with code updates, and allowing for remote monitoring of the system status without a serial connection to a computer. In order to implement this functionality, there will be two separate code bases; one that runs on the arduino, and one for the mobile monitoring and control app. This article is focused on the code that runs on the arduino, the mobile app is discussed in the next article.

The arduino app consists of a few main components that control the electronics and provide network connectivity:

  • Humidity controller
  • Light controller
  • Wifi Controller for connecting to a local WiFi network
  • Remote time server connectivity
  • Expose HTTP web server for the mobile app to communicate with

How?

Development Tools

The software is developed in C++ using the custom arduino compiler and libraries for interfacing with the board. For compilation and uploading to device, I use the Arduino IDE. The board used for this project (Arduino NANO 33 IoT) is a SAMD based board and requires the SAMD core to be installed in the Arduino IDE. For actually writing the code I use my editor of choice - VSCode - with some specific extensions for C++ and arduino development:

Arduino
The Arduino extension makes it easy to develop, build, deploy and debug your Arduino sketches in Visual Studio Code
C/C++
The C/C++ extension adds language support for C/C++ to Visual Studio Code, including features such as IntelliSense and debugging
Code Spell Checker
A basic spell checker that works well with camelCase code

External Dependencies

The software is designed to be pretty self-contained, but does still utilize a few external libraries to provide functionality that would be difficult to implement from scratch:

TODO: Dependencies info and links

Component Controllers

Each component controller is designed to encapsulate the functionality of an external piece of hardware connected to the main board via one or more control pins. They each expose different methods for interfacing, depending upon the device. DHT22, AtomizerController, and FanController are all traditional objects which can be instantiated multiple times. Alternatively, the HumidityController and LightController are static classes which cannot be instantiated, and function more like a namespace than a class - the reasons why are discussed in the implementation notes of each of those classes

DHT22

Responsible for interfacing with a DHT22 controller on a specific control pin and reading/exposing it’s temperature and humidity values.

CPP Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef DHT22_H
#define DHT22_H

#include "Arduino.h"
#include "SimpleDHT.h"

// Represents a DHT22 sensor module
class DHT22 {
    private:
        byte pin;
        SimpleDHT22 dht;
        float temperature;
        float humidity;
    
    public:

        DHT22(byte pin);

        void updateValues();
        float getTemperature();
        float getHumidity();
};

#endif

Implementation

This class is designed to cache the temperature and humidity values whenever the void updateValues() method is called, and make the most recent value available via getter methods: float getTemperature() and float getHumidity().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "Arduino.h"
#include "DHT22.h"

DHT22::DHT22(byte pin) : pin(pin), dht(pin) { };

// Read sensor and update stored values
void DHT22::updateValues() {
    int err = SimpleDHTErrSuccess;

    //update values
    err = dht.read2(&temperature, &humidity, NULL);

    //print any errors
    if(err != SimpleDHTErrSuccess) {
        Serial.print("Error reading DHT22 on pin:");
        Serial.println(pin);

        Serial.print(SimpleDHTErrCode(err));
        Serial.print(", ");
        Serial.println(SimpleDHTErrDuration(err));
    }
};

// Return the last read temperature in degrees F
float DHT22::getTemperature() {
    return temperature;
};

// Return the last read humidity as a percentage between 0-100
float DHT22::getHumidity() {
    return humidity;
}

AtomizerController

Responsible for enabling/disabling the atomizer and tracking it’s current status via it’s control pin.

CPP Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef ATOMIZER_CONTROLLER_H
#define ATOMIZER_CONTROLLER_H

#include "Arduino.h"

class AtomizerController {
    private:
        byte controlPin;
        bool enabled;

    public:
        AtomizerController(byte pin);

        bool isEnabled();
        void enable();
        void disable();
};

#endif

Implementation

This is a very simple class, with a constructor that takes a controlPin parameter, an isEnabled() that returns the value of an internal boolean flag, and has enable() and disable() methods that set the controlPin to a digital HIGH or LOW to control an external transistor on the circuit board.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "Arduino.h"
#include "AtomizerController.h"

// Create new atomizer controller with specified control pin
AtomizerController::AtomizerController(byte pin) : controlPin(pin) {
    pinMode(controlPin, OUTPUT);
    enabled = false;
};

// Return true if the atomizer is enabled
bool AtomizerController::isEnabled() {
    return enabled;
};

// Enable the atomizer
void AtomizerController::enable() {
    digitalWrite(controlPin, HIGH);
    enabled = true;
};

// Disable the atomizer
void AtomizerController::disable() {
    digitalWrite(controlPin, LOW);
    enabled = false;
};

FanController

Responsible for enabling/disabling the fans and tracking their current status via it’s control pin.

CPP Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef FAN_CONTROLLER_H
#define FAN_CONTROLLER_H

#include "Arduino.h"

// Represents a DC fan controller
class FanController {
    private:
        byte controlPin;
        bool enabled;
        
    public:
        FanController(byte pin);

        bool isEnabled();
        void enable();
        void disable();
};

#endif

Implementation

This is another very simple class, implemented exactly the same way as the atomizer controller - with a constructor that takes a controlPin parameter, an isEnabled() that returns the value of an internal boolean flag, and has enable() and disable() methods that set the controlPin to a digital HIGH or LOW to control an external transistor on the circuit board.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "Arduino.h"
#include "FanController.h"

// Create a new FanController with specified control pin
FanController::FanController(byte pin) : controlPin(pin) {
    pinMode(controlPin, OUTPUT);
    enabled = false;
};

// Return true if the fan controller is currently enabled
bool FanController::isEnabled() {
    return enabled;
};

// Enable the fan controller
void FanController::enable() {
    digitalWrite(controlPin, HIGH);
    enabled = true;
};

// Disable the fan controller
void FanController::disable() {
    digitalWrite(controlPin, LOW);
    enabled = false;
}

HumidityController

Static class responsible for enabling/disabling the humidifier system, tying together the previous 3 classes internally to control each component. These files also contain the HumidityControllerSettings class, used to manage the current configuration of the humidity controller. The HumidityController class must be static in order for the TimeAlarms library to call the update() method on each update interval.

CPP Header

HumidityControllerSettings is a simple struct with 4 properties: targetHumidity (run humidifier until this humidity is reached), kickOnHumidity (turn humidifier on after falling below this humidity), fanStopDelay (time in seconds to run fans after atomizer is stopped - to clear remaining fog from reservoir), and updateInterval (time in seconds to check humidity levels and update system status).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#ifndef HUMIDITY_CONTROLLER_H
#define HUMIDITY_CONTROLLER_H

#include "Arduino.h"
#include "Time.h"
#include "TimeAlarms.h"
#include "DHT22.h"
#include "DateTime.h"
#include "AtomizerController.h"
#include "FanController.h"

// Encapsulates settings for the HumidityController
struct HumidityControllerSettings {
    float targetHumidity;
    float kickOnHumidity;
    int fanStopDelay;
    int updateInterval;

    HumidityControllerSettings(float target, float kickOn, int fanStop, int update);
};

// HumidityController class provides methods for controlling the sensors, atomizer, and fan system.
class HumidityController {
    private:
        static DHT22 sensorOne;
        static DHT22 sensorTwo;
        static AtomizerController atomizer;
        static FanController fans;
        static HumidityControllerSettings* settings;
        static bool running;

        static float average(float, float);
        static void runHumidifier();
        static void stopAtomizer();
        static void stopFans();

    public:
        static void init(byte sensorOnePin, byte sensorTwoPin, byte atomizerPin,
                         byte fansPin, HumidityControllerSettings* s);
        static void update();

        static void controlStatus(bool&, bool&);
        static void humidity(float&, float&, float&);
        static void temperature(float&, float&, float&);
};

#endif

Implementation

HumidityController::init()
Initialization of the humidity controller needs to be done using the static init() method, which takes 4 control pin arguments for the individual sub-controllers, and a pointer to a HumidityControllerSettings object - using a pointer allows the object to be managed outside of the HumidityController class. After initializing the sub-component modules, the TimeAlarms library is used to create a repeating timer that calls the update() method with an interval of settings->updateInterval seconds.
HumidityController::update()
This method is called once per loop every settings->updateInterval seconds, and performs an update of both DHT22 sensors, calculates the average humidity, and enables/disables the atomizer and fans according to the calculated average humidity and the thresholds in settings->target and settings->kickOn. When shutting down the system, the atomizer is turned off first, and then a Alarm.timerOnce() is used to stop the fans after a specified fanStopDelay.
HumidityController::runHumidifier()
This method enables both the atomizer and fan controllers.
HumidityController::stopAtomizer() and HumidityController::stopFans()
These methods disables the atomizer and fan controllers respectively.
HumidityController::average()
Calculates and average of the two values a and b. Note that if a or b are equal to 0, then they will be discarded from the calculation. This provides a fail-safe option in the event of one of the sensors not functioning properly.
HumidityController::controlStatus()
Updates two boolean ref params - aEnabled and fEnabled - with the status of the atomizer and fan controllers respectively
HumidityController::temperature() and HumidityController::humidity()
Each method updates three floating point ref params - avg, one, and two - with the average and individual sensor values for temperature and humidity respectively
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include "Arduino.h"
#include "Time.h"
#include "TimeAlarms.h"
#include "DHT22.h"
#include "DateTime.h"
#include "AtomizerController.h"
#include "FanController.h"
#include "HumidityController.h"

// Create a new HumidityControllerSettings object with specified
// target humidity, kick on humidity, fan stop delay (s), and update interval (s)
HumidityControllerSettings::HumidityControllerSettings(float target, float kickOn, int fanStop, int update){
    targetHumidity = target;
    kickOnHumidity = kickOn;
    fanStopDelay = fanStop;
    updateInterval = update;
};

// static member initializers

DHT22 HumidityController::sensorOne = NULL;
DHT22 HumidityController::sensorTwo = NULL;
AtomizerController HumidityController::atomizer = NULL;
FanController HumidityController::fans = NULL;
HumidityControllerSettings* HumidityController::settings;
bool HumidityController::running = false;


// Initialize the humidity controller with provided sensor, atomizer and fan pins, and settings.
void HumidityController::init(byte sensorOnePin, byte sensorTwoPin, byte atomizerPin,
                                  byte fansPin, HumidityControllerSettings* s) {
    //init sensors
    sensorOne = DHT22(sensorOnePin);
    sensorTwo = DHT22(sensorTwoPin);
    atomizer = AtomizerController(atomizerPin);
    fans = FanController(fansPin);

    //init settings and tracking vars
    settings = s;

    Serial.print("setting update interval: "); Serial.println(settings->updateInterval);
    //set up update interval
    Alarm.timerRepeat(settings->updateInterval, update);
};

// Update the humidity system status, called once per update interval
void HumidityController::update() {
    // update sensor readings
    sensorOne.updateValues();
    sensorTwo.updateValues();

    //check values against threshold
    //float avgHumidity = average(sensorOne.getHumidity(), sensorTwo.getHumidity());
    float avgHumidity, hum1, hum2;
    humidity(avgHumidity, hum1, hum2);

    if(avgHumidity < settings->kickOnHumidity) {
        // start humidifier when kick-on humidity reached.
        if(!running) {
            runHumidifier();
            running = true;
        }
    } else if(avgHumidity >= settings->targetHumidity) {
        if(running) {
            // stop atomizer when target humidity reached.
            stopAtomizer();

            //set up delayed fan stop timer
            Alarm.timerOnce(settings->fanStopDelay, stopFans);
        }
    }
};

// Enable the humidifier and fans
void HumidityController::runHumidifier() {
    Serial.println("Turning ON humidifier");
    atomizer.enable();
    fans.enable();
};

// Stop the atomizer system
void HumidityController::stopAtomizer() {
    Serial.println("Turning OFF atomizer");
    atomizer.disable();
};

// Stop the fan system
void HumidityController::stopFans() {
    Serial.println("Turning OFF fans");
    fans.disable();
    running = false; // set running false so humidity check knows to start up again.
};

// Return the average humidity of the two DHT22 sensors
float HumidityController::average(float a, float b) {
    // if one sensor is down, exclude it from the average
    if(a == 0) {
        a = b;
    } else if(b == 0) {
        b = a;
    }
    
    float avg = (a + b) / 2;

    //print details about averages for debugging
    Serial.print("avg: ");
    Serial.print(avg);
    Serial.print(" ( "); Serial.print(a);
    Serial.print(" , ");  Serial.print(b); Serial.println(" )");

    return avg;
};

// Update the provided variables with the humidity system status
void HumidityController::controlStatus(bool& aEnabled, bool& fEnabled) {
    aEnabled = atomizer.isEnabled();
    fEnabled = fans.isEnabled();
};

void HumidityController::humidity(float &avg, float &one, float &two) {
    Serial.print("Humidity - ");
    one = sensorOne.getHumidity();
    two = sensorTwo.getHumidity();
    avg = average(one, two);
}

void HumidityController::temperature(float &avg, float &one, float &two) {
    Serial.print("Temperature - ");
    one = sensorOne.getTemperature();
    two = sensorTwo.getTemperature();
    avg = average(one, two);
}

LightController

Responsible for turning the day/night lights off/on via the relays attached to day/night control pins.

CPP Header

Similar to the HumidityControllerSettings class, LightControllerSettings is a simple struct with 2 properties: schedule (a concrete instance of the abstract Schedule class that returns a ScheduleEntry for the current date/time - see Utilities > Scheduling) and updateInterval (time in seconds to check humidity levels and update system status).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#ifndef LIGHT_CONTROLLER_H
#define LIGHT_CONTROLLER_H

#include "Arduino.h"
#include "Scheduling.h"
#include "DateTime.h"
#include "Time.h"
#include "TimeAlarms.h"

// Represents the LightControllerSettings status
struct LightControllerSettings {
    int updateInterval;
    Schedule* schedule;

    LightControllerSettings(Schedule*, int);
};

// Represents the LightController class, provides methods for controlling the light system.
class LightController {
    private:
        static byte dayControlPin, nightControlPin;
        static LightStatus status;
        static LightControllerSettings* settings;
        
    public:
        static void init(byte dayControlPin, byte nightControlPin, LightControllerSettings*);
        static void update();
        static void enableLights(LightStatus);
        static LightStatus getStatus();
        static const char* getStatusString();
};

#endif

Implementation

LightController::init()
Initialization of the light controller needs to be done using the static init() method, which takes 2 control pin arguments for the individual day/night relays, and a pointer to a LightControllerSettings object - using a pointer allows the object to be managed outside of the LightController class. After initializing the sub-component modules, the TimeAlarms library is used to create a repeating timer that calls the update() method with an interval of settings->updateInterval seconds.
LightController::update()
This method is called once per loop every settings->updateInterval seconds. It first gets the current Date and Time (classes from Utilities > DateTime), and then requests a ScheduleEntry from the settings->schedule for the current date (see see Utilities > Scheduling). It then uses this ScheduleEntry to determine the correct day/night status for the current time, and enables/disables voltage on the relevant control pins to actuate the connected relays.
LightController::getStatus() and LightController::getStatusString()
Both methods return the current day/night status of the controller - one as a DayNight object (an enum from Utilities > Scheduling), and the other as a lowercase string representation.
LightController::enableLights()
Given a DayNight object (see Utilities > Scheduling), this method enables/disables the appropriate control pins and updates the LightController::status variable with the new value.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include "Arduino.h"
#include "Scheduling.h"
#include "DateTime.h"
#include "Time.h"
#include "TimeAlarms.h"
#include "LightController.h"

// Create new LightControllerSettings class with specified schedule and update interval
LightControllerSettings::LightControllerSettings(Schedule* s, int interval) : updateInterval(interval) {
    schedule = s;
};

// static member initializers
byte LightController::dayControlPin;
byte LightController::nightControlPin;
LightStatus LightController::status;
LightControllerSettings* LightController::settings;

// Initialize LightController class with provided day and night pins, and settings
void LightController::init(byte dayPin, byte nightPin, LightControllerSettings* s) {
    // init pins
    dayControlPin = dayPin;
    nightControlPin = nightPin;

    // init settings
    settings = s;

    // configure pin modes
    pinMode(dayControlPin, OUTPUT);
    pinMode(nightControlPin, OUTPUT);

    // set up update interval
    Alarm.timerRepeat(settings->updateInterval, update);
};

// Update the light control system, called once per update interval
void LightController::update() {
    Date today = Date(year(), (byte)month(), (byte)day());
    Time now = Time((byte)hour(), (byte)minute(), (byte)second());

    ScheduleEntry* sched = settings->schedule->getEntry(today);
    LightStatus newStatus = sched->getLightStatus(now);

    // if the status has changed, switch lights
    if(status != newStatus) {
        //print notification message
        Serial.print("Switching lights from ");
        Serial.print(status == DAY ? "day" : "night");
        Serial.print(" to ");
        Serial.println(newStatus == DAY ? "day." : "night.");

        Serial.print("Date: ");
        today.printSerial();
        
        Serial.print("Time: ");
        now.printSerial();

        //enable appropriate lights
        enableLights(newStatus);
    }
};

// Return the day/night status of the light controller
LightStatus LightController::getStatus() {
    return status;
};

// Return string representing the day/night status of the system
const char* LightController::getStatusString() {
    return status == DAY ? "day" : "night";
};

// Enable the lights specified by 'newStatus'
void LightController::enableLights(LightStatus newStatus) {
    //update light control pins
    if(newStatus == DAY) {
        digitalWrite(dayControlPin, HIGH);
        digitalWrite(nightControlPin, LOW);
    } else {
        digitalWrite(dayControlPin, LOW);
        digitalWrite(nightControlPin, HIGH);
    }

    //update saved status
    status = newStatus;
}

Networking

The networking classes are responsible for providing network related functionality to the rest of the software.

NTPClient

Class that implements a basic NTP Client that sends requests over UDP and parses the incoming responses.

Note: Current implementation does not take into account daylight savings time, so the time is an hour off during half of the year and correct the other half. Future updates will add functionality to get DST info from the NTP server and update the time according to our current time zone.

NTP Protocol

The NTP protocol is fairly simple, and outlined in the image below. Each red box on the diagram represents one byte (8 bits) of the packet data. The numbers next to each row indicate the start and end indices for that row in the packetBuffer variable. This diagram applies to both the request and response packets, as in the NTP protocol the packet format is the same for both. The relevant portions of the packet are outlined below:

  • LI - Leap Indicator - 2 bits indicating the leap year/second status - currently using 3 for unsynchronized
  • VN - Version number - 3 bits indicating the protocol version - currently using version 4
  • Mode - 3 bits indicating the mode - using 3 for client mode
  • Stratum - 8 bits indicating the type of clock we would like our time to be from - currently using 0 for unspecified, since we don’t have a need for high precision
  • Poll - 8 bits indicating the maximum time between successive NTP messages - not really relevant here, but defined to 6
  • Precision - 8 bits indicating the system clock precision, in log2(x) seconds. To calculate this value requires several steps:
  1. First, we need to find the frequency of the clock - in this case 48 MHz or 48,000,000 Hz.
  2. Then we use the formula to take the inverse of that frequency and get the period (time between clock ticks): 1f=p - where f is the clock frequency in Hz and p is the clock period in ticks/second. Evaluate the expression to get the following: 1/48,000,000=2.083e8 seconds.
  3. The NTP server expects an integer value x where 2x evaluates to approximately the clock precision, so next we need to take the base-2 logarithm of this period with the following formula: log2(p)=x. Evaluate that expression to get log2(2.083e8)=25.51, so the nearest integer value (rounded down) is p=25.
  4. Since 25 is a signed integer - it has a negative sign - it should be represented in it’s two’s compliment binary representation. However, the byte elements that make up our buffer array are all unsigned 8-bit values, we could simply write -25 in our code and let the compiler automatically perform the two’s complement operation for us, but for the sake of clarity and not relying on the compiler, we’ll manually perform the operation and hard code the resulting value:
    • To convert to two’s compliment, we fist need to get the binary representation of 25: 2510=000110012
    • We then perform a binary complement operation, which swaps every 1 and 0 in the number: 000110012111001102
    • To complete the two’s-complement, we just need to add one to the complement: 111001102+12=111001112
    • Then we just convert this value to hex: 111001112=23110=E716
  • Root Delay - 32 bits not used by client
  • Root Dispersion - 32 bits not used by client
  • Reference Identifier - 4 bytes ASCII code, indicating the reference clock type. For Stratum 0, this is irrelevant
  • Reference Timestamp - 64 bits indicating time request was sent by client. 32 bits of integer part, and 32 bits of decimal part. Not used
  • Originate Timestamp - 64 bits indicating time request was received by server. 32 bits of integer part, and 32 bits of decimal part. Not used
  • Receive Timestamp - 64 bits indicating time request was sent by server. 32 bits of integer part, and 32 bits of decimal part. Not used
  • Transmit Timestamp - 64 bits indicating time request was received by client. 32 bits of integer part, and 32 bits of decimal part. This is the value we will use for our time determination

NTP Packet Diagram

CPP Header

This file contains the definition for the NTPClient class, as well as definitions of constants that are used internally:

  • NTP_DEFAULT_PORT = 8888; - default local port that the underlying UDP instance will use
  • NTP_DEFAULT_SERVER = "us.pool.ntp.org"; - default ntp server name
  • NTP_DEFAULT_TIMEZONE = -6; - default time zone
  • NTP_PACKET_BUFFER_SIZE = 48; - size of the internal request/response buffer in bytes
  • NTP_REQUEST_PORT = 123; - the remote port that NTP requests will be sent to.
  • NTP_RESPONSE_WAIT_TIME = 1500; - the maximum time in ms to wait for an NTP response
  • NTP_UNIX_TIME_OFFSET = 2208988800UL; - constant representing the number of seconds between 1/1/1900 and 1/1/1970. Used to convert from UTC to Unix time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#ifndef NTPCLIENT_H
#define NTPCLIENT_H

#include "WiFiUdp.h"

//defaults
const u_int NTP_DEFAULT_PORT    = 8888;
const char NTP_DEFAULT_SERVER[] = "us.pool.ntp.org";
const int NTP_DEFAULT_TIMEZONE  = -6; // Central Standard Time = UTC-6:00

// constants
const int NTP_PACKET_BUFFER_SIZE  = 48;   // packet buffer size = 48 bytes
const byte NTP_REQUEST_PORT       = 123;  // NTP requests go to port 123
const byte NTP_RESPONSE_WAIT_TIME = 1500; // wait up to 1500ms for a response from the NTP server
const u_long NTP_UNIX_TIME_OFFSET = 2208988800UL; // number of seconds between 1/1/1900 and 1/1/1970

// Represents an NTPClient object
class NTPClient {
    private:
        WiFiUDP _udp;
        u_int _udpPort;
        String _server;
        int _timeZone;
        byte packetBuffer[NTP_PACKET_BUFFER_SIZE];
    public:
        //NTPClient(); // empty constructor to allow declaration before initialization
        NTPClient(WiFiUDP); //udp only
        NTPClient(WiFiUDP, int); //udp, timezone
        NTPClient(WiFiUDP, String, int); //udp, server, timezone
        NTPClient(WiFiUDP, String, u_int, int); //udp, server, port, timezone

        void initUdp();
        time_t getNTPTime();
        void sendNTPRequestPacket(IPAddress&);
        time_t receiveNTPResponsePacket();
};

#endif

Implementation

NTPClient::NTPClient(...) - constructor
There are 4 overloaded constructors, that allow for creation of an object with default values. The only required value is a WiFiUDP instance (from the WiFiNINA module) that is used for sending/receiving web requests.settings->updateInterval seconds. The other three valid signatures are:
  • NTPClient(WiFiUDP udp, int timeZone)
  • NTPClient(WiFiUDP udp, String server, int timeZone)
  • NTPClient(WiFiUDP udp, String server, u_int port, int timeZone)
NTPClient::initUDP()
This method must be called after connecting the device to a WiFi network, and before sending/receiving any requests, and takes care of initializing the underlying WiFiUDP instance
NTPClient::getNTPTime()
This method returns the current time from a remote NTP server. First, it clears any incoming UDP requests to make sure we parse the right response. Next it takes care of making a DNS request to resolve an IP address from the ntp server url. Since the current implementation utilizes a public NTP server pool, this IP address is usually different, and depends on your region, etc. Once the IP address is resolved, we call the sendNTPRequestPacket() which builds and sends the NTP request to the remote server. Then we call, and return the result of, the receiveNTPResponsePacket() method which receives the response and returns a time_t type variable with the time received from the server.
NTPClient::sendNTPRequestPacket()
This method creates and sends an NTP request packet to the specified IPAddress. The contents of the request are created inside a packetBuffer - a byte array of size 48, with the structure shown in the diagram below. We build a packet according to this diagram, open a UDP connection to the remote server, write the bytes of the packet to the connection stream, close the stream, and return.
NTPClient::receiveNTPResponsePacket()
This method receives and parses a NTP response packet from the remote server. It waits a specified time (default 1500 ms) for a response to come in, returning 0 if a response is not received in time. If a response is received, the size is confirmed, and the response data is buffered into packetBuffer. This buffer can be shared between the send and receive methods since the request and response packet share the same format (see below). We parse the first 32 bits of the Transmit Timestamp section of the response packet into an unsigned long variable, convert it from UTC to Unix-style time, update the time based on the specified time-zone, and then return the value to the caller.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
#include "Arduino.h"
#include "NTPClient.h"
#include "WiFi.h"
#include "WiFiUdp.h"
#include "IPAddress.h"
#include "TimeLib.h"

// Create NTPCLient with specified udp instance and default port, server, and time zone
NTPClient::NTPClient(WiFiUDP udp)
        : _udp(udp) {
    _udpPort = NTP_DEFAULT_PORT;
    _server = NTP_DEFAULT_SERVER;
    _timeZone = NTP_DEFAULT_TIMEZONE;
};

// Create NTPCLient with specified udp instance and port, and default server and time zone
NTPClient::NTPClient(WiFiUDP udp, int timeZone)
        : _udp(udp), _timeZone(timeZone) {
    _udpPort = NTP_DEFAULT_PORT;
    _server = NTP_DEFAULT_SERVER;
};

// Create NTPCLient with specified udp instance, port and server, and default time zone
NTPClient::NTPClient(WiFiUDP udp, String server, int timeZone)
        : _udp(udp), _server(server), _timeZone(timeZone) {
    _udpPort = NTP_DEFAULT_PORT;
};

// Create NTPCLient with specified udp instance, port and server, and time zone
NTPClient::NTPClient(WiFiUDP udp, String server, u_int port, int timeZone)
        : _udp(udp), _server(server), _udpPort(port), _timeZone(timeZone) {
};

// Init NTPClient object
void NTPClient::initUdp() {
    Serial.println("Starting UDP for NTPClient...");
    _udp.begin(_udpPort);
};

// request the current time from an NTP server and return
time_t NTPClient::getNTPTime() {
    Serial.println("clearing packets");
    while(_udp.parsePacket() > 0) ; // clear previous incoming udp packets
    Serial.println("...clear");

    // get an IP address from the NTP server pool
    IPAddress ntpServerIP;
    WiFi.hostByName(_server.c_str(), ntpServerIP);

    Serial.print("NTP server IP: ");
    ntpServerIP.printTo(Serial); Serial.println();

    // send NTP request
    sendNTPRequestPacket(ntpServerIP);

    // await response
    return receiveNTPResponsePacket();
};

const byte NTP_LEAP_INDICATOR_BITS = 0b11000000; // LI: first 2 bits (3 = unsynchronized)
const byte NTP_VERSION_BITS        = 0b00100000; // Version: next 3 bits(4 = current version)
const byte NTP_MODE_BITS           = 0b00000011; // Mode: last 3 bits (3 = client)

// Send an NTP request packet to the specified IP address
// Packet format details: https://labs.apnic.net/?p=462
void NTPClient::sendNTPRequestPacket(IPAddress &addr) {
    //fill packet buffer with 0's
    memset(packetBuffer, 0, NTP_PACKET_BUFFER_SIZE);

    // Initialize values needed to form NTP request
    packetBuffer[0] = NTP_LEAP_INDICATOR_BITS | NTP_VERSION_BITS | NTP_MODE_BITS; // first byte: LI, Version, Mode
    packetBuffer[1] = 0;     // second byte: Stratum, or type of clock (0 = Unspecified)
    packetBuffer[2] = 6;     // third byte:  Polling Interval (6 seconds)
    packetBuffer[3] = 0xE7;  // fourth byte: Peer Clock Precision (0xE7 = uint 231 = 11100111 = sint -25 = log_2(1/48,000,000) )

    // 4 bytes of 0 for Root Delay - packetBuffer[4] ... packetBuffer[7]

    // 4 bytes of 0 for Root Dispersion - packetBuffer[8] ... packetBuffer[11]

    // 4 bytes of ASCII codes for Reference Identifier
    // WHY THE FUCK is it "1N14" when that isn't a valid source according to the NTP protocol source list?
    // https://forum.arduino.cc/t/udp-ntp-clients/95868
    // doesn't seem to matter though, soo.... oh well, don't fix it if it ain't broke
    packetBuffer[12] = 49;   // 1
    packetBuffer[13] = 0x4E; // N
    packetBuffer[14] = 49;   // 1
    packetBuffer[15] = 52;   // 4

    //packet initialized, time to send it to the time-server.
    _udp.beginPacket(addr, NTP_REQUEST_PORT);
    _udp.write(packetBuffer, NTP_PACKET_BUFFER_SIZE);
    _udp.endPacket();
    Serial.println("NTP packet sent!");
};

// Receive an incoming NTP response packet
time_t NTPClient::receiveNTPResponsePacket() {
    //set begin wait time
    u_long beginWait = millis();

    //continuously check for response until timed out
    while(millis() - beginWait < NTP_RESPONSE_WAIT_TIME) {
        //get response size
        int size = _udp.parsePacket();

        //check response size
        if(size >= NTP_PACKET_BUFFER_SIZE) {
            Serial.println("NTP Response packet received");

            // read response into packet buffer
            _udp.read(packetBuffer, NTP_PACKET_BUFFER_SIZE);

            // parse 4 bytes from the response into a 32 bit unsigned integer (long)
            // response time is a 64 bit value, with 32 bits each for the integer and decimal portions
            // we don't need to be super accurate, and our time is stored as a non-decimal value
            // so we only need to get the first 32 bits of the time in the response.
            u_long secSince1900;
            secSince1900 = (u_long)packetBuffer[40] << 24; // first byte, shifted 24 bits left, leaving room for 3 more
            secSince1900 |= (u_long)packetBuffer[41] << 16; // second byte, shifted 16 bits left, leaving room for 2 more
            secSince1900 |= (u_long)packetBuffer[42] << 8; // third byte, shifted 8 bits left, leaving room for 1 more
            secSince1900 |= (u_long)packetBuffer[43]; // fourth and final byte, not shifted, since it's the last one.

            //convert from UTC to unix time (seconds since 1970)
            u_long unixTime = secSince1900 - NTP_UNIX_TIME_OFFSET;

            //account for timezone
            unixTime += _timeZone * SECS_PER_HOUR;

            //return time to calling function
            return unixTime;
        }
    }
    
    // return 0 if no response received
    Serial.println("No response received from NTP server");
    return 0;
};

WebServer

The WebServer module is probably one of the more complex modules in this application. It’s a bare bones implementation of the HTTP protocol that wraps the WiFiServer class from the WiFiNINA module. It handles detecting and parsing incoming web requests (parses HTTP method, query params, headers, and body content), and provides the WebRequest and WebResponse classes which allow us to process incoming requests as well as build and send responses.

CPP Header

This file contains the definitions for many internal constants:

  1. Server constants, such as default port, line buffer size, and line terminator character.
  2. Line mode status (request, header, body) for determining how to parse a specific line.
  3. Parser status (success, fail).
  4. Data size constants - determine the size of internal arrays and string buffers used during sending, receiving, and parsing.
  5. HTTP Status codes - string representations of common http status codes that I may use when sending a response. It also defines the following classes, discussed in more detail in the implementation notes below:
    • WebServer
    • WebRequest
    • WebResponse
    • QueryParam
    • HttpHeader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#ifndef WEB_SERVER_H
#define WEB_SERVER_H

#include "WiFiNINA.h"

// Server constants

const byte WS_DEFAULT_PORT     = 80;      // default to port 80
const int WS_LINE_BUFFER_SIZE = 512; // line buffer can hold up to 512 chars.
const char WS_LINE_TERMINATOR  = '\n'; // line terminator character

// line mode status constants, determine how to process current line when parsing requests.

const byte LINE_MODE_REQUEST = 0; //Current line is a request
const byte LINE_MODE_HEADER  = 1; //Current line is a header
const byte LINE_MODE_BODY    = 2; //Current line is a body

// parser status constants

const byte PARSE_SUCCESS = 0; // Parsing succeeded
const byte PARSE_FAIL    = 1; // Parsing failed

// data size constants - since I need to use char[] for these, I must define a fixed max size for each char[]

const byte REQ_METHOD_SIZE       = 10;  // Max size of request method string
const byte REQ_PATH_SIZE         = 255; // Max size of request path string
const byte REQ_PARAMS_STR_SIZE   = 255; // Max size of query params string
const byte REQ_VERSION_SIZE      = 10;  // Max size of request version
const byte REQ_HEADER_COUNT      = 20;  // Max number of headers a request can hold
const byte REQ_HEADER_NAME_SIZE  = 50;  // Max size of header name
const byte REQ_HEADER_VALUE_SIZE = 255; // Max size of header value
const byte REQ_QUERY_PARAMS_SIZE = 10;  // Max number of query param objects in a single request
const int REQ_BODY_SIZE          = 2048;// Max size of body value

// HTTP Status codes - https://developer.mozilla.org/en-US/docs/Web/HTTP/Status

const char HTTP_OK[]                = "200 OK"; // Request succeeded
const char HTTP_BAD_REQUEST[]       = "400 Bad Request"; // The server could not understand the request due to invalid syntax.
const char HTTP_NOT_FOUND[]         = "404 Not Found"; // The server can not find the requested resource.
const char HTTP_LENGTH_REQUIRED[]   = "411 Length Required"; // Server rejected the request because the Content-Length header field is not defined and the server requires it.
const char HTTP_PAYLOAD_TOO_LARGE[] = "413 Payload Too Large"; // Request entity is larger than limits defined by server
const char HTTP_URI_TOO_LONG[]      = "414 URI Too Long"; // The URI requested by the client is longer than the server is willing to interpret.
const char HTTP_HEADER_TOO_LARGE[]  = "431 Request Header Fields Too Large"; // The server is unwilling to process the request because its header fields are too large.
const char HTTP_SERVER_ERROR[]      = "500 Internal Server Error"; // The server has encountered a situation it doesn't know how to handle.


// struct to hold HTTP Header key/value pairs
class HttpHeader {
    public:
        String key;
        String value;
};

// represents a request query parameter
struct QueryParam {
    String key; // param key
    String value; // param value
};

// Represents a response to an incoming HTTP request
class WebResponse {
    private:
        int _currentHeaderIndex = 0;
    public:
        WiFiClient client;
        String status;
        String httpVersion;
        String body;
        HttpHeader headers[REQ_HEADER_COUNT];

        int addHeader(HttpHeader); // add new header to header list
        int addHeader(const char*, const char*); // add new header to header list with basic strings (key, value)
        int addHeader(const char*, const long); // add numerical header, automatically parsing number to string
        int addHeader(const char*, const float); // add floating point numerical header, auto parsing to string

        int send(); // send the response to the client

};

// Represents an incoming HTTP request
class WebRequest {
    public:
        WiFiClient client;
        String method;
        String path;
        QueryParam params[REQ_QUERY_PARAMS_SIZE];
        String httpVersion;
        String body;
        HttpHeader headers[REQ_HEADER_COUNT];

        WebResponse getResponse();
        bool getHeader(String, HttpHeader&);
};

// Web Server class, provides methods for processing and replying to incoming requests.
class WebServer {
    private:
        WiFiServer _server; // WiFi server instance
        byte _lineMode;
        char _lineBuffer[WS_LINE_BUFFER_SIZE];
    public:
        WebServer();
        WebServer(byte);

        void listen(); // begin listening for incoming requests

        int processIncomingRequest(WebRequest&); // process the next incoming request, return 1 when request found, -1 otherwise

        void readLine(WiFiClient); // read the next line into the internal _lineBuffer
        
        byte parseLineRequest(char*, char*, char*, char*); // parse buffered line as a request (src, method, path, params)
        byte parseLineHeader(char*, char*); // parse buffered line as a header (key, value)
        void parseQueryParams(char*, QueryParam*); // parse query params into provided array
};

#endif

Implementation

WebServer

The web server class is responsible for handling and parsing incoming HTTP requests

WebServer::WebServer() - constructor
The web server constructor takes an optional byte parameter specifying the port to listen on.
WebServer::listen()
This method starts the underlying WiFiServer instance listening for incoming requests.
WebServer::processIncomingRequest(WebRequest& req)
This method is called once per application update loop (see Main Arduino File for details on main loop), and checks for an incoming request. If there is an incoming request available, the contents of the request are parsed line by line by the specialized parsing functions, and the reference parameter req is updated with the contents of the request. If the request parsing is successful the method returns 1 to inform the caller of the success, if the parsing fails the method returns -1 to inform the caller of failure.
WebServer::readLine(WiFiClient client)
This is a helper method that reads the next available line from client into the internal _lineBuffer variable, using the WS_LINE_TERMINATOR to determine the end of the line.
WebServer::parseLineRequest(char* method, char* path, char* params, char* version)
This method is parses the first line of the request, which contains the method, path, and HTTP version. The supplied char buffers are updated with the text from the parsing results - method gets the HTTP method, path the request path excluding any query parameters found, params the full string of query params, and version the HTTP version text.
WebServer::parseQueryParams(char* paramStr, QueryParam* dest)
This method is responsible for further parsing the raw query param string into an array of QueryParam objects that can be stored in the final WebRequest object. We loop through the characters of paramStr, using a keyBuffer and valueBuffer to store each char depending on whether the current char is part of a key or part of a value. We look for special characters ?, &, and = to determine this. The resulting params are added to the QueryParam[] pointed to by dest.
WebServer::parseLineHeader(char* key, char* value)
This method is responsible for parsing the contents of _lineBuffer as a header line, and setting key to the header name and value to the header value.

WebRequest

The WebRequest is responsible for wrapping all the properties of web request in a single object, and providing a method to get a WebResponse object that can be used to respond.

WebRequest::getResponse()
This method builds ad returns a WebResponse object that corresponds to this WebRequest. It copies over the client and httpVersion properties, and adds a default status of 200 OK as well as the following default headers: Content-Type text/plain, Server: Arduino NANO 33 IoT - Snake Tank Controller, and Connection: close.
WebRequest::getHeader(String name, HttpHeader& dest)
This method is used to access a specific header from this request. The HttpHeader object header with the specified name will be assigned to the HttpHeader object referenced by the dest parameter.

WebResponse

The WebResponse is responsible for providing an interface to build a response to a specific web request, and providing a method to send that request to the remote server.

WebRequest::addHeader()
The addHeader method is used to add an HTTP header to the response, and returns 1 on success, and -1 on failure (in the case that this WebResponse already has the maximum supported number of headers). There are four overloads of this method: one that accepts an HttpHeader object directly, and three that accept two parameters: key and value. The two-parameter overloads all accept a C-string for the key, and one of the following types for value:
  • char* - C-string value of header
  • float - float value of header that will be converted to C-string
  • long - long value of header that will be converted to C-string
WebRequest::send()
This method is responsible for sending the built response to the requesting client. It checks the client is still connected, calculates the size of body and generates a content-length header, and then serializes and sends the bytes of the response to the client. We then close the connection and return 1 for success. If part of the process fails, -1 is returned to inform the caller.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
#include "Arduino.h"
#include "WebServer.h"
#include "Regexp.h"
#include "WiFiNINA.h"

// ==== WebServer ====

//create WebServer with default port
WebServer::WebServer() : _server(WS_DEFAULT_PORT) { };

//create WebServer with custom port
WebServer::WebServer(byte port) : _server(port) { };

// begin listening to requests
void WebServer::listen() {
    _server.begin();
};

// process next incoming request, updating provided WebRequest object.
int WebServer::processIncomingRequest(WebRequest& req) {
    // get incoming client requests
    WiFiClient client = _server.available();

    // reset _lineMode
    _lineMode = LINE_MODE_REQUEST;

    if(client) { // ensure a client is connected
        Serial.println("WebServer - processing new request: ");
        int i = 0;
        int headerIndex = 0;
        int contentLength = 0;

        while(client.connected() && i < 50) { // ensure connection still open
            if(client.available()) { // ensure client stream is available
                // parse request line
                if(_lineMode == LINE_MODE_REQUEST) {
                    //read a new line into the buffer
                    readLine(client);

                    Serial.println("Processing request line...");
                    char method[REQ_METHOD_SIZE];
                    char path[REQ_PATH_SIZE];
                    char httpVersion[REQ_VERSION_SIZE];
                    char params[REQ_PARAMS_STR_SIZE];

                    // process request line
                    byte res = parseLineRequest(method, path, params, httpVersion);

                    if(res == PARSE_SUCCESS) {
                        //print results
                        Serial.println("Success!!!");
                        Serial.print("Method: "); Serial.println(method);
                        Serial.print("Path: "); Serial.println(path);
                        Serial.print("Params string: "); Serial.println(params);
                        Serial.print("Version: "); Serial.println(httpVersion);

                        //assign values on request
                        req.method = method;
                        req.path = path;
                        req.httpVersion = httpVersion;
                        parseQueryParams(params, req.params);
                    } else {
                        Serial.println("FAILED");
                    }
                    
                    //set line mode to headers for next iteration
                    _lineMode = LINE_MODE_HEADER;

                // parse header line
                } else if (_lineMode == LINE_MODE_HEADER) {
                    //read a new line into the buffer
                    readLine(client);

                    Serial.println("Processing header line...");
                    char headerKey[REQ_HEADER_NAME_SIZE];
                    char headerValue[REQ_HEADER_VALUE_SIZE];

                    // if blank line, switch to body mode and continue loop
                    if(_lineBuffer[0] == '\r') {
                        Serial.println("Empty line, switching to body mode");
                        _lineMode = LINE_MODE_BODY;
                        continue;
                    }

                    //parse line
                    parseLineHeader(headerKey, headerValue);
                    
                    //add header to request
                    HttpHeader h;
                    h.key = headerKey;
                    h.value = headerValue;
                    req.headers[headerIndex] = h;
                    headerIndex++;

                    // check for content length header and update local var
                    if(strcmp(headerKey, "Content-Length") == 0) {
                        contentLength = h.value.toInt();
                        Serial.print("Found Content-Length header: ");
                        Serial.println(contentLength);
                    }

                    //print parsed value
                    Serial.print("Added header - "); Serial.print(h.key); Serial.print(": "); Serial.println(h.value);

                //parse body line
                } else if (_lineMode == LINE_MODE_BODY) {
                    Serial.println("Processing body...");
                    char body[REQ_BODY_SIZE];
                    memset(body, 0, REQ_BODY_SIZE); // clear body buffer

                    client.readBytes(body, contentLength);

                    req.body = String(body);

                    Serial.println(req.body);

                    // return the parsed request object, for external handling, make sure to add client first
                    req.client = client;
                    return 1;
                } // end if (_lineMode == ...)

            } //end if (client.available())

            i++;
            Serial.print("End loop - i = "); Serial.println(i);

        } //end while(client.connected())
    } //end if (client)
    return -1; // return fail if no incoming requests
};

// clear line buffer and read next line
void WebServer::readLine(WiFiClient client) {
    memset(_lineBuffer, 0, WS_LINE_BUFFER_SIZE);
    client.readBytesUntil(WS_LINE_TERMINATOR, _lineBuffer, WS_LINE_BUFFER_SIZE);
}

// parse the HTTP method, path, and query param strings from the current line in _lineBuffer
byte WebServer::parseLineRequest(char* method, char* path, char* params, char* version) {
    // check matches
    MatchState ms;
    char* regexParams = "^(%u-) (%S-)%?(%S-) (HTTP.*)";
    char* regexNoParams = "^(%u-) (%S-) (HTTP.*)";
    int res;
    int expectedMatches;

    ms.Target(_lineBuffer);
    if(String(_lineBuffer).indexOf('?') > 0){
        res = ms.Match(regexParams);
        expectedMatches = 4;
    } else {
        res = ms.Match(regexNoParams);
        expectedMatches = 3;
    }
    

    // process results
    switch(res) {
        case REGEXP_MATCHED: //match
            { // enclosing scope for variables created in this branch of the switch
                int matchCount = ms.level;
                if(matchCount != expectedMatches) { // unexpected number of matches
                    Serial.print("Unexpected number of matches when parsing request line: expected = 4, actual = ");
                    Serial.println(matchCount);
                    break;
                }
                
                //get captured groups
                if(expectedMatches == 4) {
                    ms.GetCapture(method, 0);
                    ms.GetCapture(path, 1);
                    ms.GetCapture(params, 2);
                    ms.GetCapture(version, 3);
                } else {
                    ms.GetCapture(method, 0);
                    ms.GetCapture(path, 1);
                    ms.GetCapture(version, 2);
                }
                
                return PARSE_SUCCESS;
            }
            break;
        case REGEXP_NOMATCH: //no match
            Serial.print("No matches found...");
            break;
        default: //some sort of error
            Serial.print("Error trying to match...");
    }

    // if we exit the switch statement before returning, that means there was a problem parsing.
    return PARSE_FAIL;
};

// Parse query params from string 'paramStr' into the QueryParam array 'dest'
void WebServer::parseQueryParams(char* paramStr, QueryParam* dest) {
    bool inKey = true;
    char keyBuffer[REQ_PARAMS_STR_SIZE];
    char valueBuffer[REQ_PARAMS_STR_SIZE];
    QueryParam paramBuffer;
    int bufferIndex = 0;
    int paramIndex = 0;

    //make sure buffers are empty to start
    memset(keyBuffer, 0, REQ_PARAMS_STR_SIZE);
    memset(valueBuffer, 0, REQ_PARAMS_STR_SIZE);

    Serial.println("Parsing query params");

    for(int i = 0; i < REQ_PARAMS_STR_SIZE; i++) {
        char c = paramStr[i];
        Serial.print("Current char: "); Serial.print(c);

        //return if reached end of string
        if(c == 0x00) {
            Serial.println("..end");
            //add last from buffer and return
            dest[paramIndex] = QueryParam { String(keyBuffer), String(valueBuffer) };
            return;
        }

        if(inKey) { // processing a key
            Serial.print("..in key");
            if(c == '=') {
                Serial.println("..end of key");
                // end of key
                inKey = false;
                bufferIndex = 0;
            } else {
                Serial.println("..add to buffer");
                //add char to keyBuffer, and increment bufferIndex
                keyBuffer[bufferIndex] = c;
                bufferIndex++;
            }
        } else { // processing a value
            Serial.print("..in value");
            if(c == '&') {
                Serial.println("..end of value");
                // end of value
                inKey = true;
                //add QueryParam object
                dest[paramIndex] = QueryParam { String(keyBuffer), String(valueBuffer) };

                //clear buffers
                memset(keyBuffer, 0x00, REQ_PARAMS_STR_SIZE);
                memset(valueBuffer, 0x00, REQ_PARAMS_STR_SIZE);

                //update indices
                paramIndex++;
                bufferIndex = 0;
            } else {
                Serial.println("..add to buffer");
                //add char to valueBuffer and increment bufferIndex
                valueBuffer[bufferIndex] = c;
                bufferIndex++;
            }
        }

    }
};

// parse an HTTP header from the current line in _lineBuffer
byte WebServer::parseLineHeader(char* key, char* value) {
    MatchState ms;
    ms.Target(_lineBuffer);
    char res = ms.Match("^(.-): (.*)");
    
    switch(res) {
        case REGEXP_MATCHED:
            {
                int matchCount = ms.level;
                if(matchCount != 2) {
                    Serial.print("Unexpected number of matches when parsing header line: expected = 2, actual = ");
                    Serial.println(matchCount);
                    break;
                }

                //get captured groups
                ms.GetCapture(key, 0);
                ms.GetCapture(value, 1);
            }
            break;
        case REGEXP_NOMATCH:
            Serial.println("No matches found...");
            break;
        default: //some sort of error
            Serial.print("Error trying to match...");
    }

    // if we exit the switch statement before returning, that means there was a problem parsing.
    return PARSE_FAIL;
};


// ==== WebRequest ====

//return a WebResponse object that can be used to reply to the incoming request
WebResponse WebRequest::getResponse() {
    //create new response object
    WebResponse res;
    res.client = client;
    res.httpVersion="HTTP/1.1";

    //set up default status
    res.status = HTTP_OK;

    //set up default headers
    res.addHeader("Content-Type", "text/plain");
    res.addHeader("Server", "Arduino NANO 33 IoT - Snake Tank Controller");
    res.addHeader("Connection", "close");

    return res;
};

// Update 'dest' with header specified by 'name'
bool WebRequest::getHeader(String name, HttpHeader& dest) {
    for(int i = 0; i < REQ_HEADER_COUNT; i++) {
        HttpHeader curr = headers[i];
        if(curr.key == name) {
            dest = curr;
            return true;
        }
    }
    return false;
}


// ==== WebResponse ====

// Add new header with floating point value
int WebResponse::addHeader(const char* key, const float value) {
    HttpHeader h { key, String(value, 10) };
    return addHeader(h);
};

// Add new header with integer value
int WebResponse::addHeader(const char* key, const long value) {
    String(value, 10);
    char valueStr[32];
    itoa(value, valueStr, 10);
    HttpHeader h { key, valueStr };
    return addHeader(h);
};

// Add a new header to the header list. Returns 1 for success, returns -1 if error.
int WebResponse::addHeader(const char* key, const char* value) {
    HttpHeader h { key, value };
    return addHeader(h);
};

// Add a new header to the header list. Returns 1 for success, returns -1 if error.
int WebResponse::addHeader(HttpHeader h) {
    //make sure we have room
    if(_currentHeaderIndex >= REQ_HEADER_COUNT) {
        return -1; //return fail
    }

    //add header
    headers[_currentHeaderIndex] = h;
    _currentHeaderIndex += 1;
    return 1; // return success
};

// Attempt to send the response to the requesting client. Returns -1 for fail, 1 for success.
int WebResponse::send() {
    if(client.connected()) {
        //send version and status line.
        client.println(httpVersion + " " + status);

        // calculate content-length header and add
        int bodyLen = body.length();
        if(bodyLen > 0) {
            char bls[5];
            itoa(bodyLen, bls, 10);
            addHeader("Content-Length", bls);
        } else {
            addHeader("Content-Length", "0");
        }

        //send headers
        for (int i = 0; i < _currentHeaderIndex; i++) {
            HttpHeader h = headers[i];
            client.println(h.key + ": " + h.value);
        }

        client.println(); // empty line to signify end of headers

        client.print(body);// send body

        delay(20); // wait for client to receive all data

        // close connection and return success
        client.stop();
        return 1;
    }

    return -1; // client not connected.
}

Router

Provides a simple REST-ful(ish) HTTP router implementation. I have a decent amount of experience with the express.js library for NodeJS, so I took the inspiration for this routing library from that. This bare bones module provides the ability to define routes with any HTTP method and static path (static meaning it does not support parameterized routes, like /items/:id/ - yet), and callback function. It can then automatically route incoming requests to the appropriate callback function based on the method and path of the request.

CPP Header

This file contains the definitions for the Route and Router classes, the max number of routes supported by the router, and a custom type definition for route callback functions:

  • rest_callback_t - defines a custom type representing a pointer to a function that accepts two parameters of type WebRequest and WebResponse in that order
  • ROUTER_MAX_ROUTES = 20; - defines the maximum number of routes that a router instance can hold.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#ifndef ROUTER_H
#define ROUTER_H

#include "WebServer.h"

typedef void(*rest_callback_t)(WebRequest&, WebResponse&); // typedef for rest route callback function

const byte ROUTER_MAX_ROUTES = 20; // max number of routes that can be held by the router

// represents a REST route in the router
struct Route {
    String method; // request method
    String path; // request path
    rest_callback_t callback;
};

// REST server router class
class Router {
    private:
        int _routeIndex; // current route index, used for adding new routes to the correct location.
    public:
        WebServer server; // the underlying WebServer instance
        Route routes[ROUTER_MAX_ROUTES]; // list of routes currently set up

        bool route(String, String, rest_callback_t); // add new route to the router (method, path, callback);
        bool get(String, rest_callback_t); // helper to add new GET route
        bool post(String, rest_callback_t); // helper to add new POST route

        void handle(WebRequest&);

};

#endif

Implementation

Router::route(String method, String path, rest_callback_t cb)
This method is the main method that allows for defining new routes, given the HTTP method, the request path, and a pointer to the desired cb function.
Router::get(String path, rest_callback_t cb)
A shortcut for registering a new route that uses the HTTP GET method.
Router::post(String path, rest_callback_t cb)
A shortcut for registering a new route that uses the HTTP POST method.
Router::handle(String path, rest_callback_t cb)
This method is responsible for routing incoming requests to th correct handler/callback function. It looks through each registered route and finds one that matches the method and path. If a matching route is found, a WebResponse object is created using the WebRequest::getResponse() function, and the callback function is called with the existing request and new response parameter. If no matching route is found, the router automatically responds to the client with a 404 Not Found response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "Router.h"

#include "WebServer.h"

//register a new route on the router
bool Router::route(String method, String path, rest_callback_t cb) {
    if(_routeIndex < ROUTER_MAX_ROUTES) {
        routes[_routeIndex] = Route { method, path, cb};
        _routeIndex++;
    }
    return false;
};

//Helper to register new GET route
bool Router::get(String path, rest_callback_t cb) {
    return route("GET", path, cb);
};

//Helper to register new POST route
bool Router::post(String path, rest_callback_t cb) {
    return route("POST", path, cb);
};

// Handle an incoming WebRequest by calling the correct handler for the path
void Router::handle(WebRequest& req) {
    // find matching route
    for(int i = 0; i < _routeIndex; i++) {
        Route r = routes[i];
        if(r.method == req.method && r.path == req.path) {
            WebResponse res = req.getResponse();
            r.callback(req, res);
            return;
        }
    }

    Serial.println("No matching routes for request");
    WebResponse res = req.getResponse();
    res.status = HTTP_NOT_FOUND;
    res.send();
};

WifiController

This class encapsulates the functionality of the WiFiNINA module, such as verifying and establishing wireless connections, in an easy to use static class.

CPP Header

This file contains the definitions for the WifiController and WifiControllerSettings classes:

  • WifiControllerSettings - Struct representing wifi connection settings such as SSID and password
  • WifiController - Static class providing methods for connecting to networks and monitoring/displaying network status
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#ifndef WIFI_CONTROLLER_H
#define WIFI_CONTROLLER_H

#include "Arduino.h"
#include "MacAddress.h"

// Represents settings for a WifiController object
struct WifiControllerSettings {
    String ssid;
    String password;
    bool requireLatestFirmware;
    int connectionCheckInterval;

    WifiControllerSettings(String, String, bool, int);
};

// Provides methods for managing wifi connections
class WifiController {
    private:
        static String firmwareVersion;
        static MacAddress macAddress;
        static WifiControllerSettings* settings;
    public:
        static void init(WifiControllerSettings*);
        static bool verifyModule();
        static bool verifyFirmware();
        static MacAddress getMacAddress();
        static void connect();
        static void checkConnectionStatus();

        static void printAvailableNetworks();
        static void printNetwork(byte);

        static String statusToString(byte);
};

#endif

Implementation

global encryptionTypeToString(byte encryptionType)
Global method that converts an encryption type code to the corresponding string. Returns "Unknown" for invalid encryption type.
WifiController::init(WifiControllerSettings* s)
This method is responsible for verifying the status of the wifi module and firmware, loading in the device mac address, connecting to the network specified in settings, displaying the connection status, and setting up a repeating timer to continually check for and resolve any connection issues.
WifiController::statusToString(byte status)
Helper method that converts a connection status code to a string. Returns "N/A" for invalid status.
WifiController::verifyModule()
Verify that the WiFiNINA module is properly installed on the device. Return false if installation issues found, true otherwise.
WifiController::verifyFirmware()
Verify that the WiFiNINA firmware is properly installed on the device. Return false if installation issues found, true otherwise.
WifiController::WifiController::getMacAddress()
Return a MacAddress object (see Networking > WifiData) representing the MAC address of the current device.
WifiController::WifiController::connect()
Connect to the WifiNetwork specified in settings->ssid and settings->password. Automatically retry connection up to three times upon connection failure, allowing 5 seconds between each attempt.
WifiController::WifiController::checkConnectionStatus()
Called once per settings->connectionCheckInterval ms, and responsible for checking the connection status, and reconnecting if status is not WL_CONNECTED.
WifiController::WifiController::printAvailableNetworks()
Scan available networks to get count, and use the printNetwork() function to display details for each.
WifiController::WifiController::printNetwork(byte i)
Print the network details (ssid, mac, encryption type, channel, and signal strength) of the available wifi network identified by i.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#include "Arduino.h"
#include "WiFi.h"
#include "TimeAlarms.h"

#include "MacAddress.h"
#include "WifiController.h"

// Convert an encryption type enum to a string
String encryptionTypeToString(byte encryptionType) {
    switch(encryptionType) {
        case ENC_TYPE_TKIP:
            return "WPA";
            break;
        case ENC_TYPE_WEP:
            return "WEP";
            break;
        case ENC_TYPE_CCMP:
            return "WPA2";
            break;
        case ENC_TYPE_AUTO:
            return "Auto";
            break;
        case ENC_TYPE_NONE:
            return "Open";
            break;
        case ENC_TYPE_UNKNOWN:
            return "Unknown";
            break;
    }
};

// Create instance of WifiControllerSettings with specified
// ssid, pass, 'requireLatestFw' and connection check interval
WifiControllerSettings::WifiControllerSettings(String ssid, String pass, bool requireLatestFw, int connCheckInterval)
        : ssid(ssid), password(pass), requireLatestFirmware(requireLatestFw),
            connectionCheckInterval(connCheckInterval) { }


// static member initializers

String WifiController::firmwareVersion;
MacAddress WifiController::macAddress = NULL;
WifiControllerSettings* WifiController::settings;

// Init WifiController module with specified settings
void WifiController::init(WifiControllerSettings* s) {
    settings = s;

    //modules
    Serial.println("Checking module: ");
    if(!verifyModule()){
        Serial.println("Aborting WifiController.init()");
    }

    //firmware
    Serial.println("Checking firmware: ");
    bool fwCurrent = verifyFirmware();
    if(settings->requireLatestFirmware && !fwCurrent) {
        Serial.println("Aborting WifiController.init() - latest firmware required.");
    }

    //mac address
    Serial.println("Getting MAC Address:");
    macAddress = getMacAddress();
    Serial.println(macAddress.toString());

    //print network list
    //printAvailableNetworks();

    //connect to the network
    connect();

    //display network info
    IPAddress address = WiFi.localIP();
    address.printTo(Serial); Serial.println();

    //set up timer to check connection status and attempt to resolve disconnects
    Alarm.timerRepeat(settings->connectionCheckInterval, checkConnectionStatus);
};

// Convert wifi status enum value to string
String WifiController::statusToString(byte status) {
    switch(status) {
        case WL_NO_MODULE:
            return "No module available";
        case WL_IDLE_STATUS:
            return "Idle";
        case WL_NO_SSID_AVAIL:
            return "No SSID available";
        case WL_SCAN_COMPLETED:
            return "Scan completed";
        case WL_CONNECTED:
            return "Connected";
        case WL_CONNECT_FAILED:
            return "Connect failed";
        case WL_CONNECTION_LOST:
            return "Connection lost";
        case WL_DISCONNECTED:
            return "Disconnected";
        case WL_AP_CONNECTED:
            return "AP Connected";
        case WL_AP_FAILED:
            return "AP Failed";
        case WL_AP_LISTENING:
            return "AP Listening";
        default:
            return "N/A";
    }
};

// Verify the wifi capabilities on the device
bool WifiController::verifyModule() {
    byte moduleStatus = WiFi.status();
    if(moduleStatus == WL_NO_MODULE) {
        Serial.println("  Communication with WiFi module failed. Not connecting..");
        return false;
    }
    Serial.println("  Success - module status: " + statusToString(moduleStatus));
    return true;
};

// Verify that the wifi firmware is up-to-date
bool WifiController::verifyFirmware() {
    firmwareVersion = WiFi.firmwareVersion();
    if(firmwareVersion < WIFI_FIRMWARE_LATEST_VERSION) {
        Serial.println("  Firmware outdated: current(" + firmwareVersion
            + ") latest(" + WIFI_FIRMWARE_LATEST_VERSION + ")");
        return false;
    }
    Serial.println("  Firmware up to date: current(" + firmwareVersion + ")");
    return true;
};

// Return the devices MAC address
MacAddress WifiController::getMacAddress() {
    byte macBytes[6];
    WiFi.macAddress(macBytes);
    MacAddress mac = MacAddress(macBytes);
    return mac;
};

// Connect to the wifi network specified in the current settings
void WifiController::connect() {
    Serial.println("Connecting to network: " + settings->ssid);
    int ssidLen = settings->ssid.length()+1;
    int passLen = settings->password.length()+1;

    char ssid[ssidLen];
    char pass[passLen];
    
    settings->ssid.toCharArray(ssid, ssidLen);
    settings->password.toCharArray(pass, passLen);

    int attempts = 3;
    while(WiFi.status() != WL_CONNECTED && attempts > 0) {
        attempts--;

        Serial.print("Wifi.begin("); Serial.print(ssid); Serial.print(", "); Serial.print(pass); Serial.println(")");
        WiFi.begin(ssid, pass);
        delay(5000);
        Serial.println("Status: " + statusToString(WiFi.status()));
    }
};

// Check the current network connection status
void WifiController::checkConnectionStatus() {
    if(WiFi.status() != WL_CONNECTED) {
        Serial.println("WiFi disconnected - attempting to reconnect");
        connect();
    }
};

// Print a list of all currently available networks
void WifiController::printAvailableNetworks() {
    Serial.println("Scanning networks...");

    byte networkCount = WiFi.scanNetworks();

    if(networkCount == -1) {
        Serial.println("Unable to find any networks");
        return;
    }

    Serial.print("Scan complete - ");
    Serial.print(networkCount);
    Serial.println(" networks found");

    for(byte i = 0; i < networkCount; i++) {
        printNetwork(i);
    }
};

// Print the wifi network specified by 'byte'
void WifiController::printNetwork(byte i) {
    Serial.print(i+1); Serial.print(" : ");

    //print ssid
    Serial.print("SSID: "); Serial.print(WiFi.SSID(i));

    // print mac address of AP
    byte bssid[6];
    WiFi.BSSID(i, bssid);
    MacAddress apMacAddr = MacAddress(bssid);
    Serial.print("\tBSSID: "); Serial.print(apMacAddr.toString());

    // print encryption type
    Serial.print("\tEncryption: ");
    Serial.println(encryptionTypeToString(WiFi.encryptionType(i)));

    // print channel
    Serial.print("\tChannel: "); Serial.print(WiFi.channel(i));

    // print signal strength
    Serial.print("\tSignal: "); Serial.print(WiFi.RSSI(i)); Serial.println(" dBm");
};

MacAddress

Struct representing a mac address, stored as a byte array of size 6.

CPP Header

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef WIFI_DATA_H
#define WIFI_DATA_H
#include "Arduino.h"

// Represents a MC address
struct MacAddress {
    byte bytes[6];
    
    MacAddress(byte[6]);
    String toString();
};

#endif

Implementation

MacAddress::MacAddress(byte b[6]) - constructor
Create a new MacAddress object, storing the provided value of b in the local bytes variable
MacAddress::toString()
Convert the address stored in bytes to a string, represented in hexadecimal format.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "MacAddress.h""

// Create instance of MacAddress
MacAddress::MacAddress(byte b[6]){
    for(int i = 0; i < 5; i++) {
        bytes[i] = b[i];
    }
};

// Convert to string HEX representation of current mac address
String MacAddress::toString() {
    String result;
    for(int i = 5; i >= 0; i--) {
        if(bytes[i] < 0xF) { //add leading zero if only one hex digit.
            result += "0";
        }

        result += String(bytes[i], HEX); //print digit(s)
        
        if(i > 0) { // add colon separators
            result += ":";
        }
    }
    return result;
};

routes

This field contains definitions of all the route handler callback functions used when defining routes (see Networking > Router). It also contains some global helper methods used inside the route handlers, and constants defining all the different header names used in the handlers.

CPP Header and Implementation

bool updateFixedSchedule(String body) - helper method
Parse a request body to get a FixedSchedule (see Utilities > Scheduling) and update the global lightControllerSchedule (see climate_control.ino) with the new schedule.
bool updateMonthlySchedule(String body) - helper method
Parse a request body to get a MonthlySchedule (see Utilities > Scheduling) and update the global lightControllerSchedule (see climate_control.ino) with the new schedule.
route_getTest
Handles the GET /test route, used for testing the web server functionality. This handler prints the first 4 query params from req.params, adds a new header called Header-Test to the response, adds a test body contents, and sends response.
route_getTime
Handles the GET /time route, used by the mobile app to display the servers current time. This handler returns the time as response headers in two formats: as a raw UTC value, and as local time in individual component value (year, month, day, hours, minutes, seconds)
route_getHumiditySettings
Handles the GET /humidity/settings route, used by the mobile app to display the current humidity controller settings. This handler returns 4 values as headers: target, kick-on, fan stop delay, and update interval
route_postHumiditySettings
Handles the POST /humidity/settings route, used by the mobile app to update the current humidity controller settings. This handler parses optional parameters from the request headers (target, kick-on, fan stop delay, and update interval), updating a Bitflag depending which parameters were provided on the request. Then we use the bitflag to determine which values to update, adding a response header with the new value afterwards. Finally once all settings are updated, the response is sent.
route_getHumidityStatus
Handles the GET /humidity/status route, used by the mobile app to display the current status of the humidity controller. This handler returns the following values as response headers: average and individual temperature, average and individual humidity, and enabled/disabled status of the atomizer and fans.
route_getLightStatus
Handles the POST /lights/status route, used by the mobile app to display the current day/night light status. This handler returns a single header with a string representing the current light status - either day or night.
route_getLightSchedule
Handles the GET /lights/schedule route, used by the mobile app to display the current light schedule. This handler returns a header with the schedule type (1 for fixed, 2 for monthly), and body text containing one ScheduleEntry per line in the format: D{HH:MM:SS} N{HH:MM:SS} - one entry for fixed, 12 for monthly
route_postLightSchedule
Handles the POST /lights/schedule route, used by the mobile app to update the current light schedule. This handler accepts a header with the schedule type (1 for fixed, 2 for monthly), and body text containing one ScheduleEntry per line in the format: D{HH:MM:SS} N{HH:MM:SS}- one entry for fixed, 12 for monthly. It uses the updateFixedSchedule() or updateMonthlySchedule() to update the schedule, and returns a response equivelant to that of the GET /lights/schedule route with the newly updated schedule.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
#ifndef ROUTES_H
#define ROUTES_H
#include "Regexp.h"

#include "globals.h"
#include "WebServer.h"
#include "Bitflag.h"
#include "Lines.h"

// web server headers

const char* HEAD_TIME_UTC          = "x-Time-UTC";
const char* HEAD_TIME_YEAR         = "x-Time-Year";
const char* HEAD_TIME_MONTH        = "x-Time-Month";
const char* HEAD_TIME_DAY          = "x-Time-Day";
const char* HEAD_TIME_HOUR         = "x-Time-Hour";
const char* HEAD_TIME_MINUTE       = "x-Time-Minute";
const char* HEAD_TIME_SECOND       = "x-Time-Second";

const char* HEAD_HUMIDITY_TARGET   = "x-Humidity-Target";
const char* HEAD_HUMIDITY_KICK_ON  = "x-Humidity-KickOn";
const char* HEAD_HUMIDITY_FAN_STOP = "x-Humidity-FanStopDelay";
const char* HEAD_HUMIDITY_UPDATE   = "x-Humidity-UpdateInterval";
const char* HEAD_HUMIDITY_AVERAGE  = "x-Humidity-Average";
const char* HEAD_HUMIDITY_ONE      = "x-Humidity-SensorOne";
const char* HEAD_HUMIDITY_TWO      = "x-Humidity-SensorTwo";
const char* HEAD_HUMIDITY_FANS     = "x-Humidity-Fans";
const char* HEAD_HUMIDITY_ATOMIZER = "x-Humidity-Atomizer";

const char* HEAD_TEMP_AVERAGE      = "x-Temperature-Average";
const char* HEAD_TEMP_ONE          = "x-Temperature-SensorOne";
const char* HEAD_TEMP_TWO          = "x-Temperature-SensorTwo";

const char* HEAD_LIGHT_MODE        = "x-Light-Mode"; // current light mode (day/night)
const char* HEAD_LS_TYPE           = "x-Light-Schedule-Type"; // light schedule type (fixed, monthly, etc.)


bool updateFixedSchedule(String body) {
    Serial.println("Updating fixed schedule...");
    char bodyChars[body.length()+1];
    char sDayHour[2], sDayMin[2], sDaySec[2], sNightHour[2], sNightMin[2], sNightSec[2];
    int dayHour, dayMin, daySec, nightHour, nightMin, nightSec;
    MatchState ms;

    // perform matching
    memset(bodyChars,0,body.length()+1);
    Serial.println(body);
    Serial.println(body.length());
    body.toCharArray(bodyChars, body.length()+1, 0);

    ms.Target(bodyChars);
    int res = ms.Match("D{(%d%d):(%d%d):(%d%d)}N{(%d%d):(%d%d):(%d%d)}", 0); // (%d%d):(%d%d):(%d%d)
    Serial.println(res);
    Serial.println(ms.level);
    if(res != REGEXP_MATCHED) {
        Serial.print("error matching: "); Serial.println(res);
        return false;
    }
    if(ms.level != 6) {
        Serial.println("not enough matches");
        return false;
    }

    // capture matches
    ms.GetCapture(sDayHour, 0);
    ms.GetCapture(sDayMin, 1);
    ms.GetCapture(sDaySec, 2);
    ms.GetCapture(sNightHour, 3);
    ms.GetCapture(sNightMin, 4);
    ms.GetCapture(sNightSec, 5);

    // convert to ints
    dayHour = String(sDayHour).toInt();
    dayMin = String(sDayMin).toInt();
    daySec = String(sDaySec).toInt();
    nightHour = String(sNightHour).toInt();
    nightMin = String(sNightMin).toInt();
    nightSec = String(sNightSec).toInt();

    // update schedule
    Serial.println("updating");
    lightControllerSchedule = new FixedSchedule(
        new ScheduleEntry(
            Time(dayHour, dayMin, daySec),
            Time(nightHour, nightMin, nightSec)
        )
    );

    return true;
};

bool updateMonthlySchedule(String body) {
    Serial.println("Updating monthly schedule");
    char line[LINE_SIZE];
    char sDayHour[2], sDayMin[2], sDaySec[2], sNightHour[2], sNightMin[2], sNightSec[2];
    int dayHour, dayMin, daySec, nightHour, nightMin, nightSec;
    MatchState ms;
    ScheduleEntry* entries[LINE_COUNT];
    Lines lines = Lines::split(body.c_str());
    lines.printLines();

    for(int i = 0; i < LINE_COUNT; i++) {
        // get line and match
        Serial.print("Checking line: "); Serial.println(i);
        lines.getLine(line, i);
        Serial.println(line);
        ms.Target(line);
        char res = ms.Match("D{(%d%d):(%d%d):(%d%d)}N{(%d%d):(%d%d):(%d%d)}", 0);

        if(res != REGEXP_MATCHED) {
            Serial.print("error matching: "); Serial.println(res);
            return false;
        }
        if(ms.level != 6) {
            Serial.println("not enough matches");
            return false;
        }

        // capture matches
        ms.GetCapture(sDayHour, 0);
        ms.GetCapture(sDayMin, 1);
        ms.GetCapture(sDaySec, 2);
        ms.GetCapture(sNightHour, 3);
        ms.GetCapture(sNightMin, 4);
        ms.GetCapture(sNightSec, 5);

        // convert to ints
        dayHour = String(sDayHour).toInt();
        dayMin = String(sDayMin).toInt();
        daySec = String(sDaySec).toInt();
        nightHour = String(sNightHour).toInt();
        nightMin = String(sNightMin).toInt();
        nightSec = String(sNightSec).toInt();

        entries[i] = new ScheduleEntry(Time(dayHour, dayMin, daySec), Time(nightHour, nightMin, nightSec));
    }

    lightControllerSchedule = new MonthlySchedule(entries);
    return true;
};

void route_getTest(WebRequest& req, WebResponse& res) {
    for(int i = 0; i < 4; i++) {
        Serial.print("params["); Serial.print(i); Serial.print("].key = "); Serial.println(req.params[i].key);
        Serial.print("params["); Serial.print(i); Serial.print("].value = "); Serial.println(req.params[i].value);
    }

    res.addHeader("Header-Test", "Test header");
    res.body="Test body contents";
    res.send();
};

void route_getTime(WebRequest& req, WebResponse& res){
    res.addHeader(HEAD_TIME_UTC, (long)now());
    res.addHeader(HEAD_TIME_YEAR, (long)year());
    res.addHeader(HEAD_TIME_MONTH, (long)month());
    res.addHeader(HEAD_TIME_DAY, (long)day());
    res.addHeader(HEAD_TIME_HOUR, (long)hour());
    res.addHeader(HEAD_TIME_MINUTE, (long)minute());
    res.addHeader(HEAD_TIME_SECOND, (long)second());

    res.send();
};

void route_getHumiditySettings(WebRequest& req, WebResponse& res){
    res.addHeader(HEAD_HUMIDITY_TARGET, humidityControllerSettings->targetHumidity);
    res.addHeader(HEAD_HUMIDITY_KICK_ON, humidityControllerSettings->kickOnHumidity);
    res.addHeader(HEAD_HUMIDITY_FAN_STOP, (long)(humidityControllerSettings->fanStopDelay));
    res.addHeader(HEAD_HUMIDITY_UPDATE, (long)(humidityControllerSettings->updateInterval));

    res.send();
};

void route_postHumiditySettings(WebRequest& req, WebResponse& res){
    HttpHeader target, kickOn, fanStop, updateInterval;
    Bitflag valuesProvided; // bitflag to indicate which values should be updated

    //get relevant headers and set flag bits when found
    if (req.getHeader(HEAD_HUMIDITY_TARGET, target)) {
        valuesProvided.setBit(BIT_HUM_TARGET);
    }
    if (req.getHeader(HEAD_HUMIDITY_KICK_ON, kickOn)) {
        valuesProvided.setBit(BIT_HUM_KICK_ON);
    }
    if (req.getHeader(HEAD_HUMIDITY_FAN_STOP, fanStop)) {
        valuesProvided.setBit(BIT_HUM_FAN_STOP);
    }
    if (req.getHeader(HEAD_HUMIDITY_UPDATE, updateInterval)) {
        valuesProvided.setBit(BIT_HUM_UPDATE);
    }

    if(!valuesProvided.checkAny()) {
        //return 400 BadRequest unless at least one new value provided
        res.status = HTTP_BAD_REQUEST;
        res.body = "No update values provided, unable to process request.";
    } else {
        if(valuesProvided.checkBit(BIT_HUM_TARGET)) {
            //update target humidity
            Serial.println("Updating target humidity");
            humidityControllerSettings->targetHumidity = target.value.toFloat();
            res.addHeader(HEAD_HUMIDITY_TARGET, humidityControllerSettings->targetHumidity);
        }
        if(valuesProvided.checkBit(BIT_HUM_KICK_ON)) {
            //update kickon humidity
            Serial.println("Updating kickon humidity");
            humidityControllerSettings->kickOnHumidity = kickOn.value.toFloat();
            res.addHeader(HEAD_HUMIDITY_KICK_ON, humidityControllerSettings->kickOnHumidity);
        }
        if(valuesProvided.checkBit(BIT_HUM_FAN_STOP)) {
            //update fan stop delay
            Serial.println("Updating fan stop delay");
            humidityControllerSettings->fanStopDelay = fanStop.value.toInt();
            res.addHeader(HEAD_HUMIDITY_FAN_STOP, (long)(humidityControllerSettings->fanStopDelay));
        }
        if(valuesProvided.checkBit(BIT_HUM_UPDATE)) {
            //update humidity update check interval
            Serial.println("Updating humidity check interval");
            humidityControllerSettings->fanStopDelay = updateInterval.value.toInt();
            res.addHeader(HEAD_HUMIDITY_UPDATE, (long)(humidityControllerSettings->updateInterval));
        }
    }

    res.send();
};

void route_getHumidityStatus(WebRequest& req, WebResponse& res){
    float avgTemp, tempOne, tempTwo;
    float avgHum, humOne, humTwo;
    bool fansEnabled, atomizerEnabled;

    HumidityController::controlStatus(fansEnabled, atomizerEnabled);
    HumidityController::humidity(avgHum, humOne, humTwo);
    HumidityController::temperature(avgTemp, tempOne, tempTwo);
    
    res.addHeader(HEAD_TEMP_AVERAGE, avgTemp);
    res.addHeader(HEAD_TEMP_ONE, tempOne);
    res.addHeader(HEAD_TEMP_TWO, tempTwo);
    res.addHeader(HEAD_HUMIDITY_AVERAGE, avgHum);
    res.addHeader(HEAD_HUMIDITY_ONE, humOne);
    res.addHeader(HEAD_HUMIDITY_TWO, humTwo);
    res.addHeader(HEAD_HUMIDITY_FANS, fansEnabled ? "enabled" : "disabled");
    res.addHeader(HEAD_HUMIDITY_ATOMIZER, atomizerEnabled ? "enabled" : "disabled");

    res.send();
};

void route_getLightStatus(WebRequest& req, WebResponse& res){
    res.addHeader(HEAD_LIGHT_MODE, LightController::getStatusString());

    res.send();
};

void route_getLightSchedule(WebRequest& req, WebResponse& res){
    //add type header
    int schedTypeCode = lightControllerSchedule->getScheduleType();
    int bodySize = 25;
    res.addHeader(HEAD_LS_TYPE, (long)schedTypeCode);
    Serial.println(schedTypeCode);

    if(schedTypeCode == ScheduleType::MONTHLY) {
        bodySize = 12 * 25; // upgrade body size to hold 12 lines
    }

    //get body string
    char body[bodySize];
    memset(body, 0, bodySize);
    lightControllerSchedule->toString(body);

    // add body to request
    res.body = body;
    Serial.println(res.body);

    //send response
    res.send();
};

void route_postLightSchedule(WebRequest& req, WebResponse& res){
    HttpHeader schedType;
    bool validSchedProvided = false;
    int schedTypeCode = 0;
    Serial.println(req.body);

    // check schedule type header is valid
    if(req.getHeader(HEAD_LS_TYPE, schedType)) {
        schedTypeCode = schedType.value.toInt();
        validSchedProvided = Schedule::validScheduleType(schedTypeCode);
    }

    if(validSchedProvided) {
        bool updateSucceeded = false;
        // check which type we're updating to.
        switch(schedTypeCode) {
            case ScheduleType::FIXED:
            {
                Serial.println("fixed");
                // update fixed schedule
                updateSucceeded = updateFixedSchedule(req.body);
                break;
            }
            case ScheduleType::MONTHLY:
            {
                Serial.println("monthly");
                // update monthly schedule
                updateSucceeded = updateMonthlySchedule(req.body);
                break;
            }
        }

        if(updateSucceeded) {
            Serial.println("Success updating schedule");
            char body[300];
            memset(body, 0, 300);
            lightControllerSchedule->toString(body);
            Serial.println(body);
        } else {
            Serial.println("Failed to update schedule");
            res.status = HTTP_SERVER_ERROR;
        }

        // send response
        res.send();
    } else {
        // return bad formatted request
        res.status = HTTP_BAD_REQUEST;
        res.body = "Invalid request, unable to parse";
        res.send();
    }
}

#endif

Utilities

Bitflag

Class that implements a basic Bit Flag/Bit Field algorithm. Bit flags are a memory compact way of storing multiple boolean values (flags) as the individual bits in a single variable. You use the bitwise OR operator | to set bits, and the bitwise AND operator & to check if individual bits are set.

CPP Header

This file contains the definitions for the Bitflag class. The bits are stored on a private variable _bits, and methods are provided to interact with the bits. Since this class is only currently used for one purpose - determining which values are requesting to be updated from the POST humidity/settings route (see Networking > routes) - it also defines unique bit constants, each corresponding to a specific field. If the flag for a field is set, the request handler knows to update that field with the provided value.

  • BIT_HUM_TARGET = 0b1000; - humidity target field
  • BIT_HUM_KICK_ON = 0b0100; - humidity kick on field
  • BIT_HUM_FAN_STOP = 0b0010; - humidity fan stop field
  • BIT_HUM_UPDATE = 0b0001; - humidity update field

Note: The current flag only need up to 4 bits, so we could use a byte or smaller variable type, but to allow for extensibility, the _bit field and all method arguments accept u_int allowing this class to work with up to 32 bits.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef BITFLAG_H
#define BITFLAG_H

#include "Arduino.h"

const byte BIT_HUM_TARGET   = 0b1000; // Humidity target percent flag
const byte BIT_HUM_KICK_ON  = 0b0100; // Humidity kick on percent flag
const byte BIT_HUM_FAN_STOP = 0b0010; // Humidity fan stop delay flag
const byte BIT_HUM_UPDATE   = 0b0001; // Humidity update interval flag

struct Bitflag {
    private:
        u_int _bits = 0b0000;
    public:
        void setBit(u_int toSet);
        bool checkBit(u_int toCheck);
        bool checkAny();
};

#endif

Implementation

Bitflag::setBit(const u_int toSet)
This method sets the bit(s) specified by toSet to 1 in the local _bits variable.
Bitflag::checkBit(const u_int toCheck)
This method checks if an individual bit specified by toCheck is 1 in the local _bits variable. If bit is set returns true, otherwise returns false.
Bitflag::checkAny()
This is a helper method to easily determine if any bits have been set yet. Returns true if the value of _bits is non-zero, false otherwise.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "Bitflag.h"

// Set the bit(s) specified by 'toSet'
void Bitflag::setBit(const u_int toSet) {
    _bits |= toSet;
};

// Return true if 'toCheck' is set
bool Bitflag::checkBit(const u_int toCheck) {
    return _bits & toCheck;
};

// Return true if any bits are set
bool Bitflag::checkAny() {
    return _bits != 0;
};

DateTime

This module provides a Date and Time class, as well as a Comparable<T> interface that they both implement.

CPP Header

This file contains the definitions for the three classes (Comparable<T>, Date, and Time):

  • **Comparable** - interface that defines a single virtual method, `virtual int compare(T);`, which allows for comparing two objects, for example `a.compare(b)` will return 1 if `a > b`, 0 if `a = b`, and -1 if `a < b`
  • Date - represents a date with year, month, and day properties
  • Time - represents a time with hours minute, and second properties
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#ifndef DATE_TIME_COMPARABLE_H
#define DATE_TIME_COMPARABLE_H

#include "Arduino.h"

// Size of a null-terminated time string (in format HH:MM:SS)
#define TIME_STR_SIZE 9

// Size of a null-terminated time component string (digit pair)
#define TIME_COMP_SIZE 3

template <class T>
struct Comparable {
    virtual int compare(T) = 0;
};

// Represents a Date object with day, month, and year
struct Date : Comparable<Date> {
    byte day, month;
    int year;

    Date(byte, byte, int);
    Date(int, byte, byte);

    virtual int compare(Date);
    int compareYear(Date);
    int compareMonth(Date);
    int compareDay(Date);

    void printSerial();
};

// Represents a Time object with hours, minutes, and seconds
struct Time : Comparable<Time> {
    byte hours, minutes, seconds;

    Time(byte, byte, byte);

    virtual int compare(Time);
    int compareHours(Time);
    int compareMinutes(Time);
    int compareSeconds(Time);

    void toString(char*);
    void printSerial();
};

#endif

Implementation

compareWholeNumber(byte first, byte second) - helper method
compares first and second, returning 1 if first > second, 0 if first = second, and -1 if first < second
compareWholeNumber(int first, int second) - helper method
compares first and second, returning 1 if first > second, 0 if first = second, and -1 if first < second
Date::Date(...) - constructor
The Date class has two constructors, allowing you to pass either year, month, day, or day, month, year
Date::compare(Date other)
Compare this to other returning 1, 0, or -1 depending which date is earlier. Each date component is compared individually in order of it’s magnitude - first year is compared, then month, then day.
Date::compareDay(Date other)
Compare this.day to other.day returning 1, 0, or -1 depending which date is earlier.
Date::compareMonth(Date other)
Compare this.month to other.month returning 1, 0, or -1 depending which date is earlier.
Date::compareYear(Date other)
Compare this.year to other.year returning 1, 0, or -1 depending which date is earlier.
Date::printSerial()
Print this date object to serial interface using Serial.write()
Time::Time(byte hours, byte minutes, byte seconds) - constructor
The Time class only has one constructor that takes hours, minutes, and seconds in that order.
Time::compare(Time other)
Compare this to other returning 1, 0, or -1 depending which time is earlier. Each time component is compared individually in order of it’s magnitude - first hour is compared, then minute, then second.
Time::compareHours(Time other)
Compare this.hours to other.hours returning 1, 0, or -1 depending which is earlier.
Time::compareMinutes(Time other)
Compare this.minutes to other.minutes returning 1, 0, or -1 depending which is earlier.
Time::compareSeconds(Time other)
Compare this.seconds to other.seconds returning 1, 0, or -1 depending which is earlier.
Time::toString(char *dest)
Set the value of dest to the string representation of this time object in the format HH:MM:SS, adding padding zeros where necessary.
Time::printSerial()
Print this date object to serial interface using Serial.write()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
#include "Arduino.h"
#include "DateTime.h"

// Compare to whole number bytes. Returns -1 if first < second,
// 1 if first > second, and 0 if they are equal.
int compareWholeNumber(byte first, byte second) {
    if(first < second) {
        return -1;
    } else if(first > second) {
        return 1;
    }
    return 0;
};

// Compare to whole number integers. Returns -1 if first < second,
// 1 if first > second, and 0 if they are equal.
int compareWholeNumber(int first, int second) {
    if(first < second) {
        return -1;
    } else if(first > second) {
        return 1;
    }
    return 0;
};


// Create new Date object with specified day, month, and year
Date::Date(byte day, byte month, int year) : day(day), month(month), year(year) { };

// Create new Date object with specified year, month, and date
Date::Date(int year, byte month, byte day) : year(year), month(month), day(day) { };

// Compare to another Date object.
// Returns:
// -1 if this is before other
// 0 if this is equal to other
// 1 if this is after other
int Date::compare(Date other) {
    int cYear, cMonth, cDay;
    cYear = compareYear(other);
    cMonth = compareMonth(other);
    cDay = compareDay(other);

    if(cYear == 0) {
        if(cMonth == 0) {
            if(cDay == 0) {
                return 0;
            } else return cDay;
        } else return cMonth;
    } else return cYear;
};

// Compare 'day' of this Date to the 'day' of another
int Date::compareDay(Date other) {
    return compareWholeNumber(day, other.day);
};

// Compare 'month' of this Date to the 'month' of another
int Date::compareMonth(Date other) {
    return compareWholeNumber(month, other.month);
};

// Compare 'year' of this Date to the 'year' of another
int Date::compareYear(Date other) {
    return compareWholeNumber(year, other.year);
};

// Print the Date object to Serial in the format YY-MM-DD
void Date::printSerial() {
    Serial.print(year); Serial.print("-");
    Serial.print(month); Serial.print("-");
    Serial.println(day);
};


// Create new Time object with specified hours, minutes, and seconds
Time::Time(byte hours, byte minutes, byte seconds) : hours(hours), minutes(minutes), seconds(seconds) { };

// Compare to another Time object.
// Returns:
// -1 if this is before other
// 0 if this is equal to other
// 1 if this is after other
int Time::compare(Time other) {
    int cHours, cMinutes, cSeconds;
    cHours = compareHours(other);
    cMinutes = compareMinutes(other);
    cSeconds = compareSeconds(other);

    if(cHours == 0) {
        if(cMinutes == 0) {
            if(cSeconds == 0) {
                return 0;
            } else return cSeconds;
        } else return cMinutes;
    } else return cHours;
};

// Compare 'hours' of this to 'hours' of other
int Time::compareHours(Time other) {
    return compareWholeNumber(hours, other.hours);
};

// Compare 'minutes' of this to 'minutes' of other
int Time::compareMinutes(Time other) {
    return compareWholeNumber(minutes, other.minutes);
};

// Compare 'seconds' of this to 'seconds' of other
int Time::compareSeconds(Time other) {
    return compareWholeNumber(seconds, other.seconds);
};

// Convert this Time object to a string, and assign to 'dest'
void Time::toString(char *dest) {
    // init char[]s for values
    char digitBuffer[TIME_COMP_SIZE];

    //add hours
    memset(digitBuffer, 0, 3); // clear digit buffer
    itoa(hours, digitBuffer, 10); // parse int into digit buffer
    if(hours > 9){ //two digits, no need for extra zero
        memcpy(dest, digitBuffer, 2);
    } else { //one digit, need to pad leading zero
        dest[0] = '0';
        memcpy(dest+1, digitBuffer, 1);
    }
    dest[2] = ':';

    //add min
    memset(digitBuffer, 0, 3);
    itoa(minutes, digitBuffer, 10);
    if(minutes > 9){ //two digits, no need for extra zero
        memcpy(dest+3, digitBuffer, 2);
    } else { //one digit, need to pad leading zero
        dest[3] = '0';
        memcpy(dest+4, digitBuffer, 1);
    }
    dest[5] = ':';

    //add sec
    memset(digitBuffer, 0, 3);
    itoa(seconds, digitBuffer, 10);
    if(seconds > 9){ //two digits, no need for extra zero
        memcpy(dest+6, digitBuffer, 2);
    } else { //one digit, need to pad leading zero
        dest[6] = '0';
        memcpy(dest+7, digitBuffer, 1);
    }
}

// Print Time object to serial in format hh:mm:ss
void Time::printSerial() {
    Serial.print(hours); Serial.print(":");
    Serial.print(minutes); Serial.print(":");
    Serial.println(seconds);
};

lines

this module provides a class Lines that represents a collection of strings (lines). This is used when parsing the body of HTTP requests for included light schedule data. This simplifies the process of having multidimensional string arrays, which is much more of a pain in C++ than other languages I’m used to.

CPP Header and Implementation

LINE_COUNT
This represents the max number of lines that can be stored ina single Lines instance.
LINE_SIZE
This represents the max number of characters that can be stored in a single line (this includes the terminating NULL, so the effective size is really LINE_SIZE - 1).
Lines::count()
Returns the number of lines currently stored in this object.
Lines::addLine(const char* line)
Store a string as a new line of text into this object.
LLines::getLine(char* dest, int index)
Copy the string stored in line index to the dest string.
Lines::split(const char* inStr)
Split the string in inStr into individual lines, using \n as the line delimiter, and return a new Lines object containing them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#ifndef LINES_H
#define LINES_H

#define LINE_COUNT 12
#define LINE_SIZE  25

//class representing a list of strings (char [])
struct Lines {
    private:
        int _lineIndex = 0;
    public:
        char lines[LINE_COUNT][LINE_SIZE];
        int count();
        bool addLine(const char*);
        void getLine(char*, int);
        void printLines();
        
        static Lines split(const char*);
};

// return number of lines added to list
int Lines::count() {
    return _lineIndex + 1;
}

// copy 'line' into next empty line
bool Lines::addLine(const char* line) {
    if(strlen(line) > LINE_SIZE-1) {
        return false;
    }
    if(count() > LINE_COUNT){
        return false;
    }
    strcpy(lines[_lineIndex], line);
    _lineIndex++;
}

// update 'dest' to value of line at 'index'
void Lines::getLine(char* dest, int index) {
    strcpy(dest, lines[index]);
}

// print line list to Serial
void Lines::printLines() {
    for(int i = 0; i < count(); i++) {
        char l[LINE_SIZE];
        getLine(l, i);
        Serial.println(l);
    }
}

// split a target string 'inStr' into lines and return new Lines object
Lines Lines::split(const char* inStr) {
    Serial.println("Splitting lines...");
    //vars
    Lines l;
    char lineBuff[LINE_SIZE];
    int bufferIndex = 0;
    int inLen = strlen(inStr);

    // clean line buffer before beginning
    memset(lineBuff, 0, LINE_SIZE);

    // loop through each char in input, adding lines as we go
    for(int i = 0; i <inLen; i++) {
        char c = inStr[i];
        if(c == '\r') {        // don't add to buffer
            //skip
        } else if(c == '\n') { // add line and reset buffer
            Serial.println("adding line: ");
            Serial.println(lineBuff);
            l.addLine(lineBuff);
            memset(lineBuff, 0, LINE_SIZE);
            bufferIndex = 0;
        } else {               // add char to buffer
            lineBuff[bufferIndex] = c;
            bufferIndex++;
        }
    }
    if(strlen(lineBuff) > 0) {
        // add anything left in the buffer as the final line, to handle no newline at end of str ing
        l.addLine(lineBuff);
    }

    return l;
}

#endif

Scheduling

This module provides a number of classes that work together to provide extensible light scheduling functionality for the application.

CPP Header

This file defines two enums, ScheduleType and LightStatus, and several classes:

  • ScheduleEntry - this class defines a day and night start time for a specific date/time range, and logic to determine the status for a specific time.
  • Schedule - this is an abstract class that each schedule implementation inherits from
  • FixedSchedule - concrete Schedule implementation that provides a single fixed entry no matter the date.
  • FixedSchedule - concrete Schedule implementation that provides a different entry for each month, 12 in total.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#ifndef SCHEDULING_H
#define SCHEDULING_H

#include "Arduino.h"
#include "DateTime.h"

// Represents a lighting schedule type
enum ScheduleType {
    FIXED   = 1,
    MONTHLY = 2,
};

// Represents a day/night status
enum LightStatus { DAY, NIGHT };

//schedule entry class
struct ScheduleEntry {
    Time dayStart;
    Time nightStart;

    ScheduleEntry(Time, Time);
    LightStatus getLightStatus(Time);
    void toString(char*);
};

//abstract Schedule class
struct Schedule {
    virtual int getScheduleType();
    virtual ScheduleEntry* getEntry(Date) = 0;
    virtual void toString(char*);

    static bool validScheduleType(int i);
};

//fixed schedule implementation
struct FixedSchedule : public Schedule {
    ScheduleEntry* entry;

    FixedSchedule(ScheduleEntry*);

    int getScheduleType();
    ScheduleEntry* getEntry(Date);
    void toString(char*);
};

//monthly schedule implementation
struct MonthlySchedule : public Schedule {
    ScheduleEntry* schedules[12];

    MonthlySchedule(ScheduleEntry*[12]);

    int getScheduleType();
    ScheduleEntry* getEntry(Date);
    void toString(char*);
};

//TODO: other schedule implementations


#endif

Implementation

ScheduleEntry::ScheduleEntry(Time dayStart, Time nightStart) - constructor
Create a new schedule entry with the specified dayStart and nightStart times.
LightStatus ScheduleEntry::getLightStatus(Time t)
Calculate and return the LightStatus for a specific time t using the compare() method to compare t with dayStart and nightStart - if t < dayStart || t >= nightStart returns LightStatus.NIGHT, if t >= dayStart && t < nightStart returns LightStatus.DAY.
void ScheduleEntry::toString(char* dest)
Set the value of dest to a string representation of the current schedule entry in the form D{<dayStart>}N{<nightStart>} where <dayStart> and <nightStart> are strings in the format HH:MM:SS. This is used as the schedule entry representation when sending or updating the light settings over the network.
bool Schedule::validScheduleType(int i)
This is a helper method to ensure that i represents a valid ScheduleType - returns true if i == ScheduleType.FIXED || i == ScheduleType.MONTHLY
FixedSchedule::FixedSchedule(ScheduleEntry* entry) - constructor
Create a fixed schedule with the specified entry
ScheduleEntry* FixedSchedule::getEntry(Date d) - override of Schedule::getEntry(Date d)
Return the ScheduleEntry associated with the specified date d. Fixed schedule always returns the same value since it only has one entry.
int FixedSchedule::getScheduleType()
Returns the ScheduleType value associated with this schedule implementation: ScheduleType::FIXED
void FixedSchedule::toString(char* dest)
Set value of dest to the string representation of this schedule. Used to send/update schedules over the network. This implementation is represented by one line, the value of this->entry.toString()
MonthlySchedule::MonthlySchedule(ScheduleEntry* sched[12]) - constructor
Create a monthly schedule with the twelve specified entries sched, one for each month, assigned to the local variable schedules. sched[0] = Jan, sched[1] = Feb, and so on.
ScheduleEntry* MonthlySchedule::getEntry(Date d) - override of Schedule::getEntry(Date d)
Return the ScheduleEntry associated with the specified date d. Monthly schedule returns the entry associated with the current month of d.
int MonthlySchedule::getScheduleType()
Returns the ScheduleType value associated with this schedule implementation: ScheduleType::MONTHLY
void MonthlySchedule::toString(char* dest)
Set value of dest to the string representation of this schedule. Used to send/update schedules over the network. This implementation is represented by twelve lines, one for each ScheduleEntry in schedules. Each line will have the value of this->schedules[i].toString() where i is the month, 0 through 11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#include "Arduino.h"
#include "DateTime.h"
#include "Scheduling.h"

// Create new ScheduleEntry with specified 'dayStart' and 'nightStart'
ScheduleEntry::ScheduleEntry(Time dayStart, Time nightStart)
    : dayStart(dayStart), nightStart(nightStart) { };

// Return the correct LightStatus value for the specified time
LightStatus ScheduleEntry::getLightStatus(Time t) {
    Serial.print("Current time: ");
    t.printSerial();
    Serial.print("Day start time: ");
    dayStart.printSerial();
    Serial.print("Night start time: ");
    nightStart.printSerial();

    // if time lt dayStart OR gte nightStart, night
    if(t.compare(dayStart) < 0 || t.compare(nightStart) >= 0) {
        Serial.println("getLightStatus = NIGHT");
        return NIGHT;
    }

    // if time gte dayStart AND lt nightStart, day
    if(t.compare(dayStart) >= 0 && t.compare(nightStart) < 0) {
        Serial.println("getLightStatus = DAY");
        return DAY;
    }

    // default to DAY if for some reason neither test matches.
    // Also log error, since this should not happen.
    Serial.println("ERROR: ScheduleEntry.getLightStatus() did not match either day or night. Defaulting to DAY.");

    Serial.print("Current time: ");
    t.printSerial();

    Serial.print("Day start time: ");
    dayStart.printSerial();

    Serial.print("Night start time: ");
    nightStart.printSerial();

    return DAY;
};

// Convert the schedule entry into a serialized string representation for sending in web responses
void ScheduleEntry::toString(char* dest) {
    char timeBuffer[9];

    //get day start time
    memset(timeBuffer, 0, 9);
    dayStart.toString(timeBuffer);

    //add day start time
    strcat(dest, "D{");
    strcat(dest, timeBuffer);

    //get night start time
    memset(timeBuffer, 0, 9);
    nightStart.toString(timeBuffer);

    //add night start time
    strcat(dest, "}N{");
    strcat(dest, timeBuffer);
    strcat(dest, "}");
}


// Return true if 'i' is a valid schedule type
bool Schedule::validScheduleType(int i) {
    if(i >= ScheduleType::FIXED && i <= ScheduleType::MONTHLY) {
        return true;
    }
    return false;
};


// Create new fixed schedule
FixedSchedule::FixedSchedule(ScheduleEntry* entry) {
    this->entry = entry;
};

// Get the ScheduleEntry corresponding to date 'd'
ScheduleEntry* FixedSchedule::getEntry(Date d) {
    //ignores the date passed and always returns the same entry
    return entry;
};

// Get the schedule type
int FixedSchedule::getScheduleType() {
    return ScheduleType::FIXED;
}

// Convert the schedule into a serialized string representation for sending in web responses
void FixedSchedule::toString(char* dest) {
    entry->toString(dest);
};


//MonthlySchedule implementation
MonthlySchedule::MonthlySchedule(ScheduleEntry* sched[12]) {
    for(int i=0; i<12; i++) {
        schedules[i] = sched[i];
    }
};

// Get the ScheduleEntry corresponding to date 'd'
ScheduleEntry* MonthlySchedule::getEntry(Date d) {
    return schedules[d.month-1];
};

// Get the schedule type
int MonthlySchedule::getScheduleType() {
    return ScheduleType::MONTHLY;
}

// Convert the schedule into a serialized string representation for sending in web responses
void MonthlySchedule::toString(char* dest) {
    ScheduleEntry* entry;
    char lineBuffer[24];
    for(int i=0; i<12; i++) {
        memset(lineBuffer, 0, 24);
        entry = schedules[i];
        entry->toString(lineBuffer);
        strcat(dest, lineBuffer);
        strcat(dest, "\r\n");
    }
};

Constants

secrets

secrets.h defines the SSID and password for the wifi network the device should connect to. It should NOT be checked in to source control with any passwords in it.

CPP Header

This file simply defines two constant char arrays:

1
2
const char SECRET_SSID[]="";
const char SECRET_PASS[]="";

globals

globals.h contains the definitions for all global variables that need to be accessible throughout various parts of the program. This includes the settings for the controller classes, as well as network classes. This class is used to ensure that the globals can be included in any files where they are needed to accessed, and includes compiler directives to ensure that they are only defined a single time.

CPP Header

This file defines several variables for the controller classes. These are defined as globals so that they can be read by the controller classes and updated by the web server route handlers.

  • humidityControllerSettings - current settings for the HumidityController class
  • lightControllerSettings - current settings for the LightController class
  • wifiControllersettings - current settings for the WifiController class
  • lightControllerSchedule - current schedule for the LightController class

It also defines some networking variables. These are defined as globals to allow them to be accessed by both the main event loop and the routes.h and other networking related classes.

  • udp - an instance of WiFiUDP used by ntp
  • ntp - an instance of NTPClient, used to send and receive NTP time
  • server - an instance of WebServer for processing incoming HTTP requests
  • router - an instance of Router for routing incoming HTTP requests to the right handler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#ifndef GLOBALS_H
#define GLOBALS_H

#include "WiFiNINA.h"
#include "HumidityController.h"
#include "LightController.h"
#include "WifiController.h"
#include "NTPClient.h"
#include "WebServer.h"
#include "Router.h"

// global vars

HumidityControllerSettings* humidityControllerSettings;
LightControllerSettings* lightControllerSettings;
WifiControllerSettings* wifiControllersettings;
Schedule* lightControllerSchedule;

WiFiUDP udp;
NTPClient ntp = NTPClient(udp);

WebServer server;
Router router;

#endif

Main Arduino File - climate_control.ino

This file is the entry point for the program that ties together all the other code in the project and is responsible for starting up the application and performing actions during the main program loop.

CPP Header

Control pins
First, we define some control pins that will be provided to the controller modules.
Defaults
Next we define a bunch of settings defaults that will be used to initialize the settings of the various controller modules.
Global functions
Then we declare some global functions that will be implemented later on, so they can be referenced in the setup() and loop() functions
void setup()
The setup function is responsible for initializing all of the various controller modules and networking functionality. First, it waits for a serial connection, timing out after two seconds if no serial connection is available. Next we setup default settings and call the init() method for the each of the humidity, light, and wifi controllers. Then we initialize the global NTPClient and set the sync provider for the Time library. Then, after printing the current date and time, we finally initialize the global WebServer and register the routes
void loop()
The loop function checks for incoming web requests, and responds to them if there are any, and then calls the Alarm.delay() function of the TimeAlarms library which calls all of our controllers repeating timer functions, and waits for a certain time before exiting the loop function so it can run again. The delay is used to prevent the loop function from being called too often, increasing CPU load and heat.
time_t timeProvider()
Wrapper function that can be passed to setSyncProvider() function of Time library. Calls the ntp.getNTPTime() function and returns it’s return value.
void registerRoutes()
Registers all the routes defined in routes.h with the global router object.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
#include <Time.h>
#include <TimeAlarms.h>
#include <SPI.h>
#include <WiFiNINA.h>

#include "secrets.h"
#include "globals.h"
#include "routes.h"

#include "HumidityController.h"
#include "LightController.h"
#include "WifiController.h"

// control pins

const byte PIN_DHT22_ONE = 2;
const byte PIN_DHT22_TWO = 4;
const byte PIN_CTRL_ATOMIZER = 6;
const byte PIN_CTRL_FANS = 8;
const byte PIN_RELAY_DAY = 10;
const byte PIN_RELAY_NIGHT = 12;

// settings defaults

const float HUMIDITY_TARGET_DEFAULT = 90.0; // target 90% humidity
const float HUMIDITY_KICKON_DEFAULT = 75.0; // start when below 75% humidity
const int HUMIDITY_FAN_STOP_DEFAULT = 20; // fans run for 15 seconds after atomizer stops
const int HUMIDITY_UPDATE_DEFAULT = 2; // update humidity every 10 seconds by default

const Time LIGHT_DAY_START_DEFAULT = Time(07,00,00); //day start 7am
const Time LIGHT_NIGHT_START_DEFAULT = Time(19,00,00); //night start 7pm
const int LIGHT_UPDATE_DEFAULT = 1 * SECS_PER_MIN; // update lights every 1 minutes by default

const bool WIFI_REQUIRE_LATEST_FIRMWARE = false;
const int WIFI_CONNECTION_CHECK_INTERVAL = 10 * SECS_PER_MIN;

// global functions

time_t timeProvider();
void registerRoutes();

// Arduino setup functions
void setup()
{
    int serialStart = millis();
    //init serial
    Serial.begin(115200);
    while (!Serial && millis() - serialStart < 2000) {
        delay(500);
    }

	// set up humidity controller (sensors, atomizer and fan control)
    Serial.println("==========Initializing Humidity Controller==========");
    humidityControllerSettings = new HumidityControllerSettings(
        HUMIDITY_TARGET_DEFAULT,
        HUMIDITY_KICKON_DEFAULT,
        HUMIDITY_FAN_STOP_DEFAULT,
        HUMIDITY_UPDATE_DEFAULT
    );
    HumidityController::init(
        PIN_DHT22_ONE,
        PIN_DHT22_TWO,
        PIN_CTRL_ATOMIZER,
        PIN_CTRL_FANS,
        humidityControllerSettings
    );

    // set up light controller (timer and relays)
    Serial.println("==========Initializing Light Controller==========");
    lightControllerSchedule = new FixedSchedule(
        new ScheduleEntry(
            LIGHT_DAY_START_DEFAULT,
            LIGHT_NIGHT_START_DEFAULT
        )
    );
    lightControllerSettings = new LightControllerSettings(
        lightControllerSchedule,
        LIGHT_UPDATE_DEFAULT
    );
    LightController::init(
        PIN_RELAY_DAY,
        PIN_RELAY_NIGHT,
        lightControllerSettings
    );

    // set up wifi connection
    Serial.println("==========Initializing WiFi==========");
    wifiControllersettings = new WifiControllerSettings(
        SECRET_SSID,
        SECRET_PASS,
        WIFI_REQUIRE_LATEST_FIRMWARE,
        WIFI_CONNECTION_CHECK_INTERVAL
    );
    WifiController::init(wifiControllersettings);


    // set up NTP provider
    Serial.println("==========Initializing NTP==========");
    ntp.initUdp();
    setSyncProvider(timeProvider);
    setSyncInterval(5 * SECS_PER_MIN); //update every 5 min

    // print date and time on startup
    Date today = Date(year(), (byte)month(), (byte)day());
    Time now = Time((byte)hour(), (byte)minute(), (byte)second());
    Serial.println("Date: "); today.printSerial();
    Serial.println("Time: "); now.printSerial();

    // start web server
    Serial.println("==========Initializing Web Server==========");
    server = WebServer(); // initialize web server with no port defaults to port 80.
    Serial.println("Registering routes...");
    registerRoutes();
    Serial.println("...done.");
    server.listen();
    Serial.println("Server Listening...");
};

// Arduino loop function
void loop()
{
    //process incoming http requests each loop
    WebRequest req;
    if(server.processIncomingRequest(req) == 1) {
        Serial.println("Responding to request...");
        router.handle(req);
    }
    
    //check alarms twice per second
    Alarm.delay(500);
};

// Time provider functions
time_t timeProvider() {
    return ntp.getNTPTime();
};

// Register routes for the web server
void registerRoutes() {
    // test route for testing query params
    router.get("/test", route_getTest);

    //get server time
    router.get("/time", route_getTime);

    // get humidity status - humidity values and enabled status of fans and atomizers
    router.get("/humidity/status", route_getHumidityStatus);

    // get humidity settings
    router.get("/humidity/settings", route_getHumiditySettings);

    // update humidity settings
    router.post("/humidity/settings", route_postHumiditySettings);

    // get light status (day/night)
    router.get("/lights/status", route_getLightStatus);

    // get light schedule
    router.get("/lights/schedule", route_getLightSchedule);

    // update light schedule
    router.post("/lights/schedule", route_postLightSchedule);
};
This post is licensed under CC BY 4.0 by the author.