Newer
Older
/* =========================================================================================
*
* CS408 Environmental Monitoring Independent of Existing Infrastructure
* Copyright (C) 2021 Callum Inglis
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Contact: Callum.Inglis.2018(at)uni.strath.ac.uk
*
* =========================================================================================
*
* Libraries Used
* ArduinoJson v6 Library - https://arduinojson.org/v6/doc/ (https://github.com/sandeepmistry/arduino-LoRa/blob/master/LICENSE)
* LoRa Library - https://github.com/sandeepmistry/arduino-LoRa/blob/master/API.md (https://github.com/bblanchon/ArduinoJson/blob/6.x/LICENSE.md)
* SHTSensor - https://github.com/Sensirion/arduino-sht (https://github.com/Sensirion/arduino-sht/blob/master/LICENSE)
* CCS811 - https://wiki.keyestudio.com/KS0457_keyestudio_CCS811_Carbon_Dioxide_Air_Quality_Sensor
*
* =========================================================================================
*
* Add board to Arduino IDE - http://arduino.esp8266.com/stable/package_esp8266com_index.json
*
#include <SPI.h>
#include <LoRa.h>
#include <SoftwareSerial.h>
#include <ArduinoJson.h>
#define MIN_RAND_DELAY 500 // ms
#define MAX_RAND_DELAY 1250 // ms
#define MAX_TRANSMISSION_RETRIES 5 // No. Retries before recordings new values then retry
#define TX_RESERVATION_TIME 1 // How long do we require use of TX for sending packets? (Seconds)
// Poor mans memory-contrained hashmap alternative. Store index of polled count for each sensor in pollEventCountPerSensor
#define IDX_PPM10 0
#define IDX_PPM25 1
#define IDX_PPM100 2
#define IDX_TEMPERATURE 3
#define IDX_HUMIDITY 4
#define IDX_CO2 5
// Variable Values - May be updated by Gateway
int TX_AFTER_N_READINGS = 4; // No. of samples, after which Transmit Avg Readings
int POLLING_FREQUENCY = 30000; // ms Take value every POLLING_FREQUENCY ms
int pollEventCount = 0; // Number of times data has been sampled in this recording period
int pollEventCountPerSensor[6] = {0}; // Number of times each sensor has been polled in this recording period (Used for averaging values)
// Lora Config
#define ss 16 // Physical Pin 16 = D0 (Default is Physical Pin 5)
#define rst 0
#define dio0 15 // Physical Pin 15 = D8 (Default is Physical Pin 4)
static const int loraSpreadingFactor = 7;
static const int loraSignalBandwidth = 125E3;
static const int loraFrequency = 433E6;
// LoRa Message Tracking
byte localAddress = 0xBB;
byte destination = 0xFF;
// Sensor Config
static const int RXPin = 4, TXPin = 3; // Serial PMS Config
SHTSensor sht; // Temp / Humidity Sensor
SoftwareSerial pmsSerial(2, 3); // Particle Sensor Serial
CCS811 co2Sensor; // CO2 Sensor
// Immediate Sensor Data
double temperature; // Sensor Values - Temp
double humidity; // Sensor Values - Humidity
double co2; // Sensor Values - Co2
int32_t ppm10; // Sensor Values - Particulate
int32_t ppm25; // Sensor Values - Particulate
int32_t ppm100; // Sensor Values - Particulate
// Partial Sensor Data
struct pms5003data {
uint16_t framelen;
int32_t pm10_standard, pm25_standard, pm100_standard;
int32_t pm10_env, pm25_env, pm100_env;
int32_t particles_03um, particles_05um, particles_10um, particles_25um, particles_50um, particles_100um;
uint16_t unused;
uint16_t checksum;
};
struct pms5003data data;
/**
* Setup Humidity / Temperature Sensor
*/
bool setupSHT() {
Wire.begin();
if (!sht.init()) {
Serial.println("[-] SHT Init Failed");
return false;
}
sht.setAccuracy(SHTSensor::SHT_ACCURACY_MEDIUM);
return true;
}
double getTemperature() {
double t = -1;
if (sht.readSample()) {
t = sht.getTemperature();
}
}
double getHumidity() {
double h = -1;
if (sht.readSample()) {
h = sht.getHumidity();
}
return h; // -1 on error
}
* Setup CO2 Sensor
*/
bool setupCO2() {
if (co2Sensor.begin() != 0) {
Serial.println("[-] CO2 Init Failed");
return false;
}
/**
* @brief Set measurement cycle
* @param cycle:in typedef enum{
* eClosed, //Idle (Measurements are disabled in this mode)
* eCycle_1s, //Constant power mode, IAQ measurement every second
* eCycle_10s, //Pulse heating mode IAQ measurement every 10 seconds
* eCycle_60s, //Low power pulse heating mode IAQ measurement every 60 seconds
* eCycle_250ms //Constant power mode, sensor measurement every 250ms
* }eCycle_t;
*/
co2Sensor.setMeasCycle(co2Sensor.eCycle_250ms);
return true;
}
/**
* Get CO2 reading, returns -1 on error
double c = -1;
co2Sensor.writeBaseLine(0x847B);
if(co2Sensor.checkDataReady() == true){
c = co2Sensor.getCO2PPM();
}
return c; // -1 on error
}
// TODO DOC
void resetCounters() {
ppm10 = 0;
ppm25 = 0;
ppm100 = 0;
temperature = 0;
humidity = 0;
pollEventCount = 0;
}
bool setupLoRa() {
LoRa.setPins(ss, rst, dio0);
if (!LoRa.begin(loraFrequency)) {
Serial.println("[-] Fatal. Starting LoRa failed!");
return false;
}
LoRa.setSpreadingFactor(loraSpreadingFactor);
LoRa.setSignalBandwidth(loraSignalBandwidth);
Serial.println("[+] LoRa Initialized OK!");
return true;
}
/*
* Return ESP Module ID, based on MAC address
* Source: https://arduino.stackexchange.com/a/58678
*
* 2021-11-07 - This method proved unreliable, switched to casting ESP.getChipID() as a string instead
String sensorID = "EMIEI-"; // [E]nviornmental [M]onitoring [I]ndepenet of [E]xisting [I]nfrastructure
sensorID.concat(String(ESP.getChipId()));
return sensorID;
}
/**
* Get data from PMS Partical Sensor
* @param Stream s PMS Serial Connection
* @modifies struct data PMS Sensor Data
* @returns boolean Data Read Success status
*/
boolean readPMSdata(Stream *s) {
if (!s->available()) {
return false;
}
// Read a byte at a time until we get to the special '0x42' start-byte
if (s->peek() != 0x42) {
s->read();
return false;
}
// Now read all 32 bytes
if (s->available() < 32) {
return false;
}
uint8_t buffer[32];
s->readBytes(buffer, 32);
// get checksum ready
for (uint8_t i=0; i<30; i++) {
sum += buffer[i];
}
// The data comes in endian'd, this solves it so it works on all platforms
uint16_t buffer_u16[15];
for (uint8_t i=0; i<15; i++) {
buffer_u16[i] = buffer[2 + i*2 + 1];
buffer_u16[i] += (buffer[2 + i*2] << 8);
}
// Struct it
memcpy((void *)&data, (void *)buffer_u16, 30);
if (sum != data.checksum) {
Serial.print("Expected: "); Serial.print(sum);
Serial.print(", Got: "); Serial.print(data.checksum);
return false;
}
return true;
}
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
/**
* Provided sensor data, construct JSON object ready for transmission, Averaged over numSamples
*
* @param messageID
* @param numSamples Numer of samples averages
* @param avgPpm10 Average Particuate readings
* @param avgPpm25 Average Particuate readings
* @param avgPpm100 Average Particuate readings
* @param avgTemperature Average Temperature
* @param avgHumidity Average Humidity
* @param avgCo2 Average Co2
*
* @return DynamicJsonDocument sensorData
*/
DynamicJsonDocument prepareSensorData(int messageID, int numSamples, uint32_t avgPpm10, uint32_t avgPpm25, uint32_t avgPpm100, double avgTemperature, double avgHumidity, double avgCo2) {
DynamicJsonDocument doc(2048);
JsonObject metadata = doc.createNestedObject("sensorMetadata");
metadata["uid"] = getSensorUID();
metadata["messageID"] = messageID;
metadata["samplePeriod"] = numSamples;
JsonObject data = doc.createNestedObject("data");
JsonObject ppm = data.createNestedObject("ppm"); // Particulates
ppm["p10"] = avgPpm10;
ppm["p25"] = avgPpm25;
ppm["p100"] = avgPpm100;
JsonObject sht = data.createNestedObject("sht"); // Temp, Humidity
sht["temperature"] = avgTemperature;
sht["humidity"] = avgHumidity;
JsonObject co2 = data.createNestedObject("co2"); // TODO Co2
co2["tmp"] = avgCo2;
return doc;
}
/**
* Listen on Lora for other messages, returns true if no messages detected within [listenDuration] seconds
* Use prior to transmissions to avoid interruption of other messages
*
* @param int listenDuration How long to listen on Lora RX in Seconds
* @returns boolean If messages detected within listenDuration
*/
boolean clearToSend(int listenDuration) {
int listenUntil = now() + listenDuration;
// Listen until timeout expires
while (listenUntil >= now()) {
int packetSize = LoRa.parsePacket();
if (packetSize) {
Serial.println("[-] TX Busy - Not Clear To Send");
return false; // Other message heard on Rx, infer that we can not transmit just now.
}
delay(5);
}
return true; // We didn't hear anything, so continue
}
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
// Introduce random delay to avoid another collision
void waitRandomDelay(int minSeconds = 0) {
int randDelay = (minSeconds * 1000) + random(MIN_RAND_DELAY, MAX_RAND_DELAY);
if (DEBUG){
Serial.println("[i] Delay for " + String(randDelay) + "ms");
}
delay(randDelay);
}
/**
* Send short "clear to send?" packet
* Tx - "I've got data to send!" + UID
* @param messageID ID of this message / sample period
*/
void sendTxHello(int messageID) {
Serial.println("[+] Transmit - \"Hello\"");
DynamicJsonDocument txHello(2048);
txHello["uid"] = getSensorUID();
txHello["reservationTime"] = TX_RESERVATION_TIME;
txHello["messageID"] = messageID;
sendJsonPayloadWithLoRa(txHello);
}
/**
* Send Payload
* Tx - Sensor Payload
* @param payload JSON Payload to be sent
*/
void sendTxPayload(DynamicJsonDocument payload) {
Serial.println("[+] Transmit - Payload");
sendJsonPayloadWithLoRa(payload);
}
/**
* Listen for messages on TX, expecting JSON
*
* @param listenDuration How long to listen on Lora RX in Seconds
* @return StaticJsonDocument|null RX Payload in JSON, null on timeout reached or error
*/
StaticJsonDocument<1024> listenForAndConsumeMessage(int listenDuration) {
int listenUntil = now() + listenDuration;
StaticJsonDocument<1024> json;
// Listen until timeout expires
int packetSize = LoRa.parsePacket();
if (!packetSize) {
delay(3);
continue;
}
// Read in packet, ensure we only bring in anything after { and before } inclusive
String incoming = "";
char temp;
while (LoRa.available()) {
temp = (char)LoRa.read();
if (incoming.length() == 0 && temp == '{') { // Opening {
incoming = "{";
} else if (temp == '}') { // Closing }
incoming.concat("}");
break;
} else if (incoming.length() > 0) { // Anything else that's valid
incoming.concat(temp);
}
// Deserialize - TODO Error Handling https://arduinojson.org/v6/api/json/deserializejson/
deserializeJson(json, incoming);
break;
/**
* Listen for response to TxHello for [listenDuration] seconds, check Sensor UID & Message ID match and return true if we have clear to transmit payload.
* @param listenDuration How long to listen on Lora RX in Seconds
* @param messageID ID of message we are expecting to receive
* @return boolean Clear to Transmit
*/
boolean listenForTxHelloAccept(int listenDuration, int messageID) {
Serial.println("[+] Transmit - \"Hello\" - Listening for \"Hello\" Ack & Clear to Send");
StaticJsonDocument<1024> json = listenForAndConsumeMessage(listenDuration);
// Timeout, likely TX Hello was not recieved or ack got lost
if (json.isNull()) {
Serial.println("[-] Transmit - \"Hello\" - Timeout while waiting for Clear to Send\n\n");
return false;
}
const bool okToTransmit = json["okTransmit"];
const String authIsForUid = json["uid"];
const int authIsForMessageID = json["messageID"];
const String gatewayUid = json["gatewayUid"];
// Verify txHello.okTransmit is True and Sensor UID & Message IDs Match
if (DEBUG) {
if (authIsForUid == getSensorUID()) {
Serial.println("[+] Transmit - \"Hello\" - Sensor UID Match!");
} else {
Serial.println("[-] Transmit - \"Hello\" - Sensor UID Mis-Match! " + String(authIsForUid) + " vs " + String(getSensorUID()));
}
if (authIsForMessageID == messageID) {
Serial.println("[+] Transmit - \"Hello\" - Message ID Match!");
} else {
Serial.println("[-] Transmit - \"Hello\" - MessageID Mis-Match!");
// Ok To Trasmit, Sensor UID Match & Message ID Match
bool clearToSend = (okToTransmit
&& authIsForUid == getSensorUID()
&& authIsForMessageID == messageID);
if (DEBUG) {
if (clearToSend) {
Serial.println("[+] Transmit - \"Hello\" - Recieved \"Clear to Transmit\" Payload");
} else {
Serial.println("[-] Transmit - \"Hello\" - Can Not Transmit At This Time");
}
return clearToSend;
}
// TODO DOC
boolean listenForTxPayloadAccept(int listenDuration, int messageID) {
Serial.println("[.] Transmit - Payload - Listening for Ack");
StaticJsonDocument<1024> json = listenForAndConsumeMessage(listenDuration);
// Timeout, likely TX Payload was not recieved or ack got lost.
if (json.isNull()) {
Serial.println("[-] Transmit - Payload - Ack Timeout Reached - Assuming Message Was Not Delivered\n\n");
return false;
}
const bool ackStatus = json["ackStatus"];
const String authIsForUid = json["uid"];
const int authIsForMessageID = json["replyMsgID"];
const String gatewayUid = json["gatewayUid"];
// Verify Sensor UID Match
if (authIsForUid == getSensorUID()
&& authIsForMessageID == messageID) {
Serial.println("[+] Transmit - Payload - Ack Recieved: " + String(ackStatus) + "\n");
if (ackStatus) { // It all worked :)
// Check for any updated config values
const int newTxAfterNReadings = json["txAfterNReadings"];
if (newTxAfterNReadings != NULL && newTxAfterNReadings != TX_AFTER_N_READINGS) {
TX_AFTER_N_READINGS = newTxAfterNReadings;
Serial.println("[+] Tx After N Readings Updated, now: " + String(TX_AFTER_N_READINGS) + " samples");
}
const int newPollingFrequency = json["pollingFrequency"];
if (newPollingFrequency != NULL && newPollingFrequency != POLLING_FREQUENCY) {
POLLING_FREQUENCY = newPollingFrequency;
Serial.println("[+] Polling Frequency Updated, now: " + String(POLLING_FREQUENCY) + "ms");
}
return true;
}
// TODO Retransmit, recover, etc
Serial.println("[-] Transmit - Payload - Ack Failed - TODO Setup Retransmission");
return false;
}
// TODO Else UID Mis-Match so we wait for next message
Serial.println("[-] Transmit - Payload - Ack Message ID or Sensor ID Mis-Match");
return false;
}
/**
* Send JSON Payload over LoRa
* @param payload JSON Payload to be send
*/
void sendJsonPayloadWithLoRa(DynamicJsonDocument payload) {
LoRa.beginPacket();
serializeJson(payload, LoRa);
LoRa.endPacket();
}
// TODO - Finish Acks & Naks
boolean transmitData(DynamicJsonDocument payload) {
// Listen for other communication
// Rx - Listen for other messages, if no messages heard then continue
if (!clearToSend(0.5)) {
waitRandomDelay(); // Wait for short time before retrying
while (!clearToSend(0.5)) { waitRandomDelay(); } // TODO MAX_TIMEOUT
}
// Send TX Hello
sendTxHello(msgCount);
// Await TX Hello Auth - Expect: Timeout | Not Auth | Accept + Clear To Transmit
if (!listenForTxHelloAccept(TX_RESERVATION_TIME * 4, msgCount)) {
return false; // Can't transmit just now, we will retry
}
// Send TX Payload
sendTxPayload(payload);
// Await TX Payload Ack - Expect: Timeout | Nack | Accept + Data Match
if (!listenForTxPayloadAccept(2, msgCount)) {
return false; // TODO Ack Failed Setup a retry here!
// TODO Clear values & Continue
resetCounters();
Serial.println("Packet Sent Succesfully\n");
Serial.println("---------------+++++-------------------");
msgCount++;
/**
* Add recorded values to the total, do not store false/failed/invalid values, e.g. -1
*/
double addValueToTotal(double avgValue, double newValue, int idx_sensor_count) {
if (newValue == -1) {
return avgValue;
}
pollEventCountPerSensor[idx_sensor_count] += 1;
return avgValue + newValue;
}
double averageValueByNumberPolls(double value, int idx_sensor_count) {
// Ensure we do not divide by 0
if (pollEventCountPerSensor[idx_sensor_count] == 0) {
return -1;
}
// Otherwise divide total by number of times we sampled this value
return value / pollEventCountPerSensor[idx_sensor_count];
}
void setup() {
delay(1000);
Serial.begin(115200); // Console Debug
pmsSerial.begin(9600); // Partical Sensor
// Setup Hardware TODO Handle broken sensor!
if (!setupSHT()) { /*while(1);*/ } // Temp/Humidity - Die on Error
if (!setupCO2()) { /*while(1);*/ }
if (!setupLoRa()) { /*while(1);*/ } // Die on error
}
void loop() {
delay(POLLING_FREQUENCY);
pollEventCount++;
// Get values from sensors
double instantTemperature = getTemperature(); // Prevent polling of sensors too frequently when debugging
double instantHumidity = getHumidity();
double instantCo2 = getCo2();
int instantPPM10, instantPPM25, instantPPM100;
if (!readPMSdata(&pmsSerial)) { // Error reading PMS Data, ignore on this round
instantPPM10 = instantPPM25 = instantPPM100 = -1;
} else {
instantPPM10 = data.pm10_standard; // Standard (_standard) or Environmental (_env) readings for Particulate Data
instantPPM25 = data.pm25_standard;
instantPPM100 = data.pm100_standard;
Serial.println(); Serial.print(String(pollEventCount) + ") "); Serial.print("PM 1.0: "); Serial.print(instantPPM10); Serial.print("\t\tPM 2.5: "); Serial.print(instantPPM25); Serial.print("\t\tPM 10: "); Serial.print(instantPPM100);
Serial.print("\t\tTemp: "); Serial.print(instantTemperature); Serial.print("\t\tHumidity: "); Serial.print(instantHumidity); Serial.print("\t\tCo2: "); Serial.print(instantCo2);
// Add to running total, we will divide by the number of times samples later on to get an average over sample period
temperature = addValueToTotal(temperature, instantTemperature, IDX_TEMPERATURE);
humidity = addValueToTotal(humidity, instantHumidity, IDX_HUMIDITY);
co2 = addValueToTotal(co2, instantCo2, IDX_CO2);
ppm10 = addValueToTotal(ppm10, instantPPM10, IDX_PPM10);
ppm25 = addValueToTotal(ppm25, instantPPM25, IDX_PPM25);
ppm100 = addValueToTotal(ppm100, instantPPM100, IDX_PPM100);
// If we should now transmit
if (pollEventCount >= TX_AFTER_N_READINGS) {
double avgTemperature = averageValueByNumberPolls(temperature, IDX_TEMPERATURE);
double avgHumidity = averageValueByNumberPolls(humidity, IDX_HUMIDITY);
double avgCo2 = averageValueByNumberPolls(co2, IDX_CO2);
int avgPpm10 = averageValueByNumberPolls(ppm10, IDX_PPM10);
int avgPpm25 = averageValueByNumberPolls(ppm25, IDX_PPM25);
int avgPpm100 = averageValueByNumberPolls(ppm100, IDX_PPM100);
Serial.println(""); Serial.print("Avg ppm10: "); Serial.print(avgPpm10); Serial.print("\t\tAvg ppm25: "); Serial.print(avgPpm25); Serial.print("\t\tAvg ppm100: "); Serial.print(avgPpm100);
Serial.print("\t\tAvg Temp: "); Serial.print(avgTemperature); Serial.print("\t\tAvg Humidity: "); Serial.print(avgHumidity); Serial.print("\t\tAvg Co2: "); Serial.print(avgCo2);
Serial.print("\t\tChip ID: "); Serial.println(getSensorUID()); Serial.println("");
// Prepare Data For Send
DynamicJsonDocument sensorData = prepareSensorData(msgCount, pollEventCount, avgPpm10, avgPpm25, avgPpm100, avgTemperature, avgHumidity, avgCo2);
// Transmit
if (transmitData(sensorData)) {
return; // It all worked, values reset, now record new values
}
// Transmission failed, handle re-tries
int numRetries = 1;
waitRandomDelay(TX_RESERVATION_TIME*2); // Introduce random delay to avoid another collision
while (!transmitData(sensorData) && numRetries < MAX_TRANSMISSION_RETRIES){
numRetries++;
Serial.println("[-] Failed to send packet, retrying. Attempt " + String(numRetries) + " of " + String(MAX_TRANSMISSION_RETRIES) + "\n");
waitRandomDelay(TX_RESERVATION_TIME*2); // Introduce random delay to avoid another collision
}
// We were able to transmit after retries, values reset, now record new values
if (numRetries < MAX_TRANSMISSION_RETRIES) {
return;
}
// Failed to transmit - Don't Clear Counters, record more values then try to retransmit on next send
Serial.println("[-] Failed to send packet, max retries reached. Aborting");
return;