Snel-leveren-want-haast prototype van een LoRaWAN/The Things Network CO2 meter

In januari 2017 viel ik in verschillende discussies over de voorzieningen die de dan nog in conceptstadium verkerende ‘5G’ standaard zou gaan bevatten en manieren om deze voor een groot publiek beschikbaar te stellen. De gesprekken gingen ondermeer over of ‘lora’ als oplossing voor internet of things apparaten zou gaan uitmaken van de 5G-specificatie, of dat ‘narrowband’ meer op dat pad zou liggen. Een voorbeeld van een mogelijke toepassing van de technieken zou bij de beeldvorming en de discussies kunnen helpen en de opdracht voor een ‘prototype van een met 5G-internet gekoppelde leefkwaliteitmeter voor klaslokalen in de regio’ was geboren. Met als deadline ‘op korte termijn’.

De schakeling

Het idee van dit prototype is het meten van de CO2-gehalte in klaslokalen, en het centraal registreren van de gemeten waarden. Het tonen van de gemeten waarde in de klas is leerzaam voor de aanwezigen, en het feit of er al dan niet mensen in de ruimte zijn ook. In totaal twee sensoren: CO2 en beweging met een passief-infrarood-detector (PIR-detector). Beide sensoren hebben hun eigen ingebouwde elektronica, maar de CO2-sensor levert een CO2-waarde via een seriele poort en de PIR-detector levert slechts een digitaal ‘er was beweging’ signaal, dat na verloop van tijd weer terugvalt naar ‘er is (al een tijdje) geen beweging meer’.

KPN LoRa en The Things Network bevonden zich in 2017 beide nog in het beginstadium van wat tegenwoordig betrouwbare en eenvoudig toegankelijke netwerken zijn en de voorzieningen op via LoRa verbinding te maken met ‘een backend’ waren op één hand te tellen. Ik had al redelijk snel succes (hoewel met de nodige handbewegingen en een forse dosis mazzel) met een Microchip RN2483, een degelijke, door de LoRa Alliance gecertificeerde manier om verbinding te maken met een LoRaWAN gateway van KPN of The Things Network. De RN2483 is een SoC die bestaat uit een Semtech SX1276 LoRa zendontvanger en een Microchip PIC18LF46K22 microcontroller. Deze laatste heeft mogelijkheden genoeg om zelfstandig de sensoren in te lezen, een display aan te sturen én de metingen via de SX1276 te versturen, maar om de certificering in stand te houden heeft Microchip ervoor gekozen het lastig te maken om de PIC18LF46K22 te herprogrammeren of aan te vullen met extra programmatuur. De RN2483 daarom wordt als een soort internetmodem gebruikt en via twee draden bestuurd; de credentials worden aan de serverkant opgeslagen en aan de hand van het apparaatnummer gecontroleerd.

Proefopstelling met een Arduino Leonardo en RN2483, onderling verbonden via een serieel protocol over twee dragen (geel en grijs/wit)

Na de initiële verbinding van de RN2483 met het LoRa netwerk en het vaststellen van de credentials (hier verder niet besproken) is het maken van een vervolgverbinding eenvoudig:

1
2
3
4
5
6
7
8
9
10
11
sendCommand("sys reset");
sendCommand("mac join otaa");
if (getResponse() == "ok") {
  if (getResponse() == "accepted") {
    // There. A valid LoRa connection
  } else {
    // Denied. Either no free channels or something else
  }
} else {
  // Not a wanted response. Something with the hardware
  // We might want to throw a panic here
Uitbreiding van de schakeling met CO2-sensor, PIR-detector en 6-cijferig led display. Je mag iets vinden van de provisorische manier van verbinden

Het inlezen van de status van de PIR-detector was eenvoudig: de PIR-detector verbindt met één draad aan de Arduino en in de code volstaat het om met enig regelmaat het signaalniveau te controleren van de aan de PIR-detector aangesloten pin. De gevoeligheid en de tijdsduur van het digitale signaal dat beweging aangeeft kan middels instelpotentiometers op de PIR-detector worden ingesteld:

1
2
3
4
5
6
7
#define pirPin 13
pinMode(pirPin, INPUT);
 
// Repeat this as often as needed
if (digitalRead(pirPin)) {
  // Something moved allright
}

De CO2-sensor was lastiger, omdat deze zelf een flinke hoeveelheid slimmigheid herbergt en pas met enig aandringen afstand wil doen van de gemeten waarden. De stappen zijn:

  • open de seriële verbinding met de sensor
  • geef het commando om een meetwaarde op te leveren
  • lees de 9 bytes van het antwoord
  • bepaal de checksum van het gegeven antwoord en
  • vergelijk dit met de eveneens ontvangen checksum
  • bereken de ppm aan de hand van twee bytes

De checksum wijkt eigenlijk alleen af wanneer de CO2-sensor net wordt ingeschakeld: de eerste minuten leveren geen betrouwbare meetwaarden. Voor proefopstellingen waar dit als bekend wordt verondersteld werkt onderstaande code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <SoftwareSerial.h>
#define SensorRX 10
#define SensorTX 11
SoftwareSerial co2sensor (SensorRX, SensorTX);
byte cmd[9] = {
  0xFF,0x01,0x86,0x00,0x00,0x00,0x00,0x00,0x79}; 
byte response[9]; 
co2sensor.begin(9600);
co2sensor.write(cmd, 9);
co2sensor.readBytes(response, 9);
 
// Calculate checksum and compare with response[8] here
// If all checks out, response is an actual ppm
ppm = (256 * response[2]) + response[3];
co2sensor.end();

Als laatste het display, dat ook met een seriële verbinding werkt en waar reguliere printopdrachten heengestuurd kunnen worden. Om de stroomopname van het apparaat wat in de hand te houden (de stroomvoorziening geschiedde middels een USB powerbank) kan het display een lege tekst tonen, waardoor de stroomopname effectief naar (bijna) 0 schiet. De code voor het aansturen van het display is in elk geval aardig eenvoudig:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define displayRX 5
#define displayTX 6
#define displayID 255
String message = "3.1415";
SoftwareSerial leddisplay (displayRX, displayTX);
leddisplay.begin(9600);
while (message.length() < 6) {
  message = " " + message;
}
leddisplay.write(displayID);
leddisplay.write('b');
leddisplay.println(message);
leddisplay.end();

De behuizing

Voor de behuizing had ik in elk geval als eis dat ik ‘m met de mij beschikbare middelen kon produceren én dat het er op geen enkele manier als een ‘elektronicaprojectje’ uit zou zien. Dat betekende geen standaard projectdoos, maar een maatwerk kastje. Dat had van foamboard gekund (handig snijden met lineaal en scherp mes), maar leek me iets te fragiel voor een prototype dat wat verkennende gesprekken over het doel en nut van LoRa zou moeten doormaken. Vandaar hout: bijna even gemakkelijk te bewerken maar net even een graadje degelijker. Ik had net een mooie nauwkeurige tafelcirkelzaag aangeschaft en dit kastje was een leuke vingeroefening.

Behuizing uit multiplex, geschilderd en strakgemaakt

Toen het kastje klaar was (enkele keren in de grondverf gezet en strak geschuurd, geplamuurd en nog meer geschuurd) heb ik de maten zorgvuldig opgenomen en deze in Adobe Illustrator overgenomen. Het logo van ‘5Groningen’ zou prominent op het prototype gaan prijken, zodat het een leuk bespreekobject zou gaan worden in het lab. De copyshop in de wijk heeft een fantastische service en na een uurtje kon ik het snijwerk van de zelfklevende folie gaan uitvoeren. Best eng, want dan moet het allemaal precies passen en mooi strak zitten. Maar, ging heel aardig.

Beplakken van de behuizing met zelfklevende vinyl folie met opdruk

De software en het prototype

Het leuke van prototypes vind ik het moment vlak voordat je ze oplevert aan de klant: je mag er dan zelf als eerste mee spelen je acceptatietesten op los laten. Leuk, om een nieuw gadget te hebben waarvan je precies weet wat het doet, hoe en waarom het dat doet en wat er wel en niet mee kan. En dan kastje dicht (de fragiele verbindingen met wat tape, klei en verpakkingsmateriaal fixeren) en alles stevig verpakken en verschepen. Klus geklaard, op naar het maken van een dashboard in The Things Network om de meetwaarden te verzamelen.

Testen en demonstratiegereed maken van het prototype, met de volledige schakeling buiten de behuizing

Ik heb de software van het prototype hieronder opgenomen om een indruk op te kunnen doen van de extra controles, timings en teksten:

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
// 5Groningen Leefkwaliteit sensor
// All rights reserved
// Written by Rudi Niemeijer - TGRP - rn@tgrp.nl
// Version 1.0 - 23-01-2017 - Creation
//
// This is the firmware for the Leefkwaliteit Sensor, a prototype for a future 5G application. This
// particular device has consists of the following hardware:
// Arduino Leonardo
// PIR-sensor                               .
// SC6Dlite serial 6-digital led display    .
// MH-Z14A CO2 sensor                       .
// RN2483 LoRaWAN module                    .
// 5V USB Power Bank
// All is mounted in a dedicated box with 5Groningen artwork on top.
//
// The working is as follows:
// The Leonardo will take a CO2 measurement from the MH-Z14A. The measured value is added to the running average
// and the value is divided by two. The resuling value is sent via LoRaWAN to the backend.
// The Leonardo will count 30 seconds down. 
 
#include <SoftwareSerial.h>
 
#define SensorRX 10           // Yellow
#define SensorTX 11           // White
#define LoraRX     8          // Yellow
#define LoraTX     9          // White
#define displayRX      5
#define displayTX      6
#define defaultWakeTime 30
#define pirPin 13
#define displayID 255
#define warmUpSeconds 0       // CO2 sensor seconds before first measurement can be taken, should be 180
#define sendEvery 60
 
bool motionDetected = false;        // Once motion is detected, wakeTime is set to defaultWakeTime
bool motionLastTimeChecked = false; // Was there motion last time we checked?
int co2AveragePpm = 0;              // Keep track of average running CO2 ppm
bool lastCO2measurementOK = false;  // Last CO2 measurement was OK
int wakeTime = 0;                   // Seconds before display is switched off
bool displayIsOn = false;           // Status of the display: on or off 
bool lifeDot = false;               // Dot led 6
byte crlf[2] = {0x0D,0x0A};         // Used to terminate RN2486 commands
bool networkJoined = false;         // Keep track if we're connected
bool recentSendSuccess = false;           // Some indication for succesful send data
int cycles = 0;
bool bootState = true;              // If we're booting, sensor is not trustable
long startMillis = millis();        // Milliseconds since program start
bool demoMode = false;              // Demo mode is entered when invalid CO2 reading
 
// Define the serial ports
SoftwareSerial co2sensor (SensorRX, SensorTX);
SoftwareSerial lora (LoraRX, LoraTX);
SoftwareSerial leddisplay (displayRX, displayTX);
 
int runningSeconds() {
  return (millis() - startMillis) / 1000;
}
 
// Assume the lora to be open
void sendCommand(String cmd) {
  delay(1000);                  // This delay seems to be pretty important
  Serial.println("> " + cmd);
  lora.print(cmd);
  lora.write(crlf, 2);
  delay(1000);
}
 
// Assume the lora port to be open
String getResponse() {
  String response = "";
  while (lora.available()) {
    response = response + char(lora.read());
  } 
  response = response + "";
  return response;
}
 
// Initialise the RN2486 LoRa device
bool initLora() {
  String r;
  lora.begin(57600);
 
  delay(1000);
 
  sendCommand("sys reset");
  delay(1000);
  Serial.println(getResponse());
 
  sendCommand("mac join otaa");
 
  r = getResponse();
  r.trim();
  Serial.println(r);
  if (r == "ok") {
    Serial.println("(wait for next part)");
 
    while (!lora.available()) {
    }
    r = getResponse();
    Serial.println(r);
    r.trim();
    if (r == "accepted") {
      // There. A valid LoRa connection
      Serial.println("Network joined");
      networkJoined = true;
    } else {
      // Denied. Either no free channels or something else
      Serial.println("Denied. That's a bummer. No free channel or budget used up. Retry later");
      networkJoined = false;
    }
  } else {
    // Not a wanted response. Something with the hardware
    // We might want to throw a panic here
    Serial.println("Connecting with The Things Network didn't work out. No idea why");
    networkJoined = false;
  }  
  if (networkJoined) {
    return true;
  } else {
    return false;
  }
  lora.end();
}
 
void sendMeasurement(int payload) {
  String n = "";
  byte b1, b2;
  if (payload < 256) {
    n = String(payload, HEX);
  } else {
    b1 = payload / 256;
    b2 = payload - (b1 * 256);
    n = String(b1, HEX) + String(b2, HEX);
  }
  transmitData(n);
}
 
int transmitData(String payload) {
  String r;
  recentSendSuccess = false;
  if (networkJoined) {
    lora.begin(57600);
 
    sendCommand("mac tx cnf 1 " + payload);
    r = getResponse();
    r.trim();
    Serial.println(r);
    if (r == "ok") {
      while (!lora.available()) {
      }
      r = getResponse();
      r.trim();
      Serial.println(r);
      if (r != "mac_tx_ok") {
        // We got data back! Not sure what to do with it though.
        recentSendSuccess = true;
      } else {
        // No data back, just a succesful send
        recentSendSuccess = true;
        // mac_tx_ok
      }
    } else {
      recentSendSuccess = false;
      // Invalid parameters
    }
    lora.end();
  }
}
 
// display a message
// 
void displayMessage(String message) {
  leddisplay.begin(9600);
 
  if (bootState) {
    if (runningSeconds() > 30) {
      bootState = false;
    } else {
      message = "boot " + String(3 - (runningSeconds() / 10));
    }
  }
 
  // display the message here
  while (message.length() < 6) {
    message = " " + message;
  }
  leddisplay.write(displayID);
  leddisplay.write('b');
  if (wakeTime > 0) {
    leddisplay.println(message);
  } else {
    leddisplay.println("");
  }
  leddisplay.end();
}
 
void checkMotionPresent() {
  int pinHigh;
  motionLastTimeChecked = digitalRead(pirPin);
  if (motionLastTimeChecked) {
    motionDetected = true;
    if (motionDetected) {
      wakeTime = defaultWakeTime;  // Reset wakeTime counter to defaultWakeTime seconds
    }
  } else {
    if (wakeTime == 0) {
      motionDetected = false;
    }
  }
  Serial.println("Motion checked " + String(motionLastTimeChecked) + " timeout " + String(wakeTime));
}
 
void dotStatus() {
  byte dots = 0;
 
  if (motionDetected) {
    bitSet(dots, 0);
    Serial.println("Status: Motion with delay");
  }
 
  if (motionLastTimeChecked) {
    bitSet(dots, 1);
    Serial.println("Status: Actual motion");
  }
 
  if (networkJoined) {
    bitSet(dots, 2);
    Serial.println("Status: LoRa");
  }
 
  if (recentSendSuccess) {
    bitSet(dots, 3);
    Serial.println("Status: Send success");
  }
 
  if (lastCO2measurementOK) {
    bitSet(dots, 4);
    Serial.println("Status: CO2");
  }
 
  if (demoMode) {
    dots = 0;
    bitSet(dots, 5);
  }
 
  leddisplay.begin(9600);
  leddisplay.write(displayID);
  leddisplay.write('c');
  leddisplay.write(dots);
  leddisplay.end();
}
 
int takeCO2Reading() {
  byte cmd[9] = {
    0xFF,0x01,0x86,0x00,0x00,0x00,0x00,0x00,0x79}; 
  byte response[9]; 
  byte calculatedChecksum, givenChecksum;
  int ppm;
 
  co2sensor.begin(9600);
 
  // while(co2sensor.available()) {
  //   co2sensor.read();
  // }
 
  co2sensor.write(cmd, 9);
  delay(100);
  co2sensor.readBytes(response, 9);
 
  // Check checksum and set value below to true
  //    0 - Starting byte   0xFF
  //    1 - Command         0x86
  //    2 - MSB measurement
  //    3 - LSB measurement
  // 4..7 - not used
  //    8 - Checksum
 
  givenChecksum = (byte) response[8];  
  calculatedChecksum = 0;
  for (int i = 1; i<= 7; i++) {
    calculatedChecksum = calculatedChecksum + response[i];  // calculatedChecksum will overflow
  }
  calculatedChecksum = (0xFF - calculatedChecksum) + 1;
  Serial.println("Given checksum: " + String(givenChecksum, DEC) + ", calculated: " + String(calculatedChecksum, DEC));
  if (calculatedChecksum == givenChecksum) {
    lastCO2measurementOK = true;
    demoMode = false;
    byte responseHigh = response[2];
    byte responseLow = response[3];
    ppm = (256 * responseHigh) + responseLow;
  } else {
    lastCO2measurementOK = false;
    byte responseHigh = response[2];
    byte responseLow = response[3];
    demoMode = true;
    ppm = 400 + random(50);
  }
 
  co2sensor.end();
  return ppm;
}
 
void setup() {
  pinMode(pirPin, INPUT);
  wakeTime = defaultWakeTime;
 
  Serial.begin(57600);
  displayMessage("");
  co2AveragePpm = takeCO2Reading();
  initLora();
}
 
// Main loop that is run every second
// Contains all logic, measurements and display calls
void loop() {
  cycles += 1;
 
  delay(400);                // Delay 400 milliseconds in order to have this loop run every second
 
  // Count down, if no motion the display is switched off
  checkMotionPresent();
  if (wakeTime > 0) {
     wakeTime = wakeTime - 1;
  }
 
  // Measure CO2 value and display
  co2AveragePpm = (4 * co2AveragePpm + takeCO2Reading()) / 5; // Dampen new value a bit
  Serial.println("Current average ppm "+String(co2AveragePpm));
  displayMessage(String(co2AveragePpm)); 
 
  delay(1500);
 
  // Send CO2 value through LoRa, but not every loop
  if (cycles > sendEvery) {
    sendMeasurement(co2AveragePpm);
    cycles = 0;
  }
  if (!networkJoined) {
    initLora();
  }
  dotStatus();
}

De klant was blij verrast, getuige de Twitter-reactie:

Plaats een reactie