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.
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 |
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.
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.
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.
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: