Monday, April 17, 2017

Exposing Data as a REST API

How to expose the temperature, pressure, and humidity data over WiFi? There are two subquestions here:

  1. What network mechanism to use?
  2. What request and response formats to use?

For the network mechanism, we will create a small web server on the network that will listen for REST requests and return the data formatted as JSON. In other words, we will finally be using the ESP8266's WiFi capabilities! YAY!!

What should the request URIs for our REST API look like? Ask ten developers this question, and be prepared to get fifteen answers! The only thing they'll agree upon is that we will only be making GET requests, since we can only read data from a sensor.

We will structure the request URLs as follows:

/home/living-room/temperature
/home/living-room/humidity
/home/living-room/barometric-pressure
/home/living-room/
/home/living-room

This last two requests will return all data that is available from the BME280 sensor; both are included so as to demonstrate using one handler for both URIs. We will also handle 404 and 503 errors.

We will return the data as JSON, but how exactly should that JSON should be formatted? Put ten developers in a room, ask them that question, and expect only two developers to leave that room alive!

There are two extremes: return the minimum amount of requested data, or return all that data plus a ton of metadata, resource links, etc. This latter approach will not only require us to add a significant number of characters to the JSON, but will also require additional URIs. This is called HATEOAS.

We will take the minimal approach here. Further, we will always return data in British units. For example, in response to a GET request to home/living-room/temperature, we will return:


{
    "temperature": 86.56
}


The Code
The following code will connect to the local network, and create a web server listening on port 80:

  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
/**
 * BME280-WebServer-JSON
 * 
 * By: Mike Klepper
 * Date: 17 April 2017
 * 
 * This program exposes BME280 data as a REST API.
 * 
 * See blog post on patriot-geek.blogspot.com
 * for instructions.
 */

#include "ESP8266WiFi.h"
#include "WiFiClient.h"
#include "ESP8266WebServer.h"
#include "Adafruit_Sensor.h"
#include "Adafruit_BME280.h"

const char* SSID = "**********";
const char* PASSWORD = "**********";

const int DELAY = 3000;
const int STARTUP_DELAY = 500;

const char* MESSAGE_404 = "404 Not Found";
const char* MESSAGE_503 = "503 Service Unavailable";

boolean sensorAvailable;

ESP8266WebServer server(80);
Adafruit_BME280 bme;

void setup(void)
{
  Serial.begin(115200);

  // Start the BME280 sensor
  if(!bme.begin())
  {
    Serial.println("Could not find a valid BME280 sensor, check wiring!");
    sensorAvailable = false;
  }
  else
  {
    sensorAvailable = true;
    delay(STARTUP_DELAY);
  }

  WiFi.begin(SSID, PASSWORD);

  // Wait for the connection
  while(WiFi.status() != WL_CONNECTED) 
  {
    delay(STARTUP_DELAY);
    Serial.print(".");
  }
  
  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(SSID);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  server.on("/home/living-room/temperature", returnTemperature);
  server.on("/home/living-room/humidity", returnHumidity);
  server.on("/home/living-room/barometric-pressure", returnPressure);
  server.on("/home/living-room/", returnAll);
  server.on("/home/living-room", returnAll);
  
  server.onNotFound(return404Error);

  server.begin();
  Serial.println("HTTP server started");
}

void loop(void)
{
  server.handleClient();
  yield();
}

void return404Error()
{
  server.send(404, "text/plain", MESSAGE_404);
}

void return503Error()
{
  server.send(500, "text/plain", MESSAGE_503);
}

void returnTemperature() 
{
  if(sensorAvailable)
  {
    // Get temperature and format it in degree Fahrenheit
    float tempC = bme.readTemperature();
    float tempF = 9.0/5.0 * tempC + 32.0;
  
    // Build JSON response
    String responseJson = "{\n\"temperature\":" + String(tempF) + "}";
  
    // Return JSON with correct MIME type
    server.send(200, "application/json", responseJson);
  }
  else
  {
    return503Error();
  }
}

void returnHumidity() 
{
  if(sensorAvailable)
  {
    // Get humidity
    float humidity = bme.readHumidity();
  
    // Build JSON response
    String responseJson = "{\n\"humidity\":" + String(humidity) + "}";
  
    // Return JSON with correct MIME type
    server.send(200, "application/json", responseJson);
  }
  else
  {
    return503Error();
  }
}

void returnPressure() 
{
  if(sensorAvailable)
  {
    // Get pressure and change units to inHg
    float pressurePascals = bme.readPressure();
    float pressureInchesOfMercury = 0.000295299830714 * pressurePascals;
  
    // Build JSON response
    String responseJson = "{\n\"barometric-pressure\":" + String(pressureInchesOfMercury) + "}";
  
    // Return JSON with correct MIME type
    server.send(200, "application/json", responseJson);
  }
  else
  {
    return503Error();
  }
}

void returnAll()
{
  if(sensorAvailable)
  {
    // Get values
    float tempC = bme.readTemperature();
    float humidity = bme.readHumidity();
    float pressurePascals = bme.readPressure();
  
    // Convert to British units
    float tempF = 9.0/5.0 * tempC + 32.0;
    float pressureInchesOfMercury = 0.000295299830714 * pressurePascals;
  
    // Build JSON response
    String responseJson = "";
    responseJson += "{";
    responseJson +=     "\"temperature\":" + String(tempF) + ",";
    responseJson +=     "\"humidity\":" + String(humidity) + ",";
    responseJson +=     "\"barometric-pressure\":" + String(pressureInchesOfMercury);
    responseJson += "}";
  
    // Return JSON with correct MIME type
    server.send(200, "application/json", responseJson);
  }
  else
  {
    return503Error();
  }
}

Running the Code
When this code is ran, the IP address that the network assigns to the ESP8266 is reported in the serial monitor. For example, my IP address is (currently) 10.200.206.75. To test this code, then, start a browser or other HTTP client and go to the URL made of that IP address together with one of the paths. For example when I visit http://10.200.206.75/home/living-room, the result is:

What happened to the quotes? Some browser extensions, like JSONView for Chrome, suppresses the quotes and adds tabs. If this extension is disabled, the result looks like this:


Code Explanation

13 - 15Include libraries for working with WiFi as well as creating a web server on an ESP8266
16 - 17Include the libraries for using the BME280 sensor
19 - 20Specify the network name (SSID) and password for the network we're connecting to. Everybody's password is "**********", no?
25 - 26Messages for 404 and 503 errors
38 - 47Initialize the BME280 sensor; set sensorAvailable to the appropriate value
49Connect to the network
52 - 56Print dots to the serial monitor until we're connected
58 - 62Print connection info to the serial monitor
64 - 66Specify the paths used to request single sensor readings (temperature only, for example), and associate a handler for each
67 - 68Associate the same handler - returnAll() - for two different paths
70Have web server call return404Error() whenever a URI handler is not specified
72Start the web server!
76 - 80Handle incoming requests, repeatedly
82 - 85Whenever a path hasn't been found, send HTTP error code 404, using MIME type "text/plain"
87 - 90For internal errors (like when the BME280 isn't available), send HTTP error code 503, using MIME type "text/plain"
92 - 110Callback for handling /home/living-room/temperature requests
94If the sensor is available at startup...
96 - 98Read the temperature from the BME280 and convert to Fahrenheit
101Wrap the temperature inside a JSON object
104Return that JSON using MIME type "application/json" and response code 200
106 - 109If the sensor is NOT available, return 503.
112 - 129Callback for handling /home/living-room/humidity requests
131 - 149Callback for handling /home/living-room/barometric-pressure requests
151 - 179Callback for handling /home/living-room/ and /home/living-room requests

Notes:

  1. The problem with setting sensorAvailable at the start is: what happens if the BME280 becomes disconnected later? This can be handled by performing the check for bme.begin() in each of the response handlers.
  2. To connect to a wifi network that doesn't use a password, change line 50 to read:
    WiFi.begin(SSID);
  3. Change lines 26 and 27 to give more interesting error messages!

4 comments:

  1. Hello and thanks for the sketch.
    I get the error
    Request header field X-Requested-With is not allowed by Access-Control-Allow-Headers in preflight response.

    Can you assist in adding the headers for CORS

    ReplyDelete
  2. So, to clarify. I am using javascript and html to return the json formatted values but get the error Request header field X-Requested-With is not allowed by Access-Control-Allow-Headers in preflight response. It works fine with the url in the browser address bar. Its the json call in javascript that gets this error in the browser. Whats even weirder is in the Chrome developer console under Network --> Preview the correctly formatted values are there.

    ReplyDelete
  3. LOL...Sorry to pester you. I figured it out. Since I am only requesting the "All" (returnAll) I placed the header in void returnAll() and right above server.send(200, "application/json", responseJson);

    The working headers for my application are
    server.sendHeader("Access-Control-Allow-Headers", "Access-Control-Allow-Headers, Origin,Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers");

    ReplyDelete