פרויקט IoT – ניטור הפסקות חשמל עם ESP32

פרויקט IoTPhoto by Zan on Unsplash

כל פרויקט IoT טוב מתחיל מבעיה ולי יש בעיית חשמל בבית – כל פעם שיורד גשם, ליותר מכמה דקות, החשמל נופל. תזמין חשמלאי, אמר לי חבר מבין עניין, אז הזמנתי. כמה וכמה. שורה תחתונה, אין להם מושג. תפתח את הקיר, תחליף קו, אולי המכונה כביסה מקצרת, אולי זה המייבש, אולי פגעו בצינור, אולי… אולי… אולי…

אז אחרי שהחלפתי את מכונת הכביסה וכן, גם את המייבש (לא רק בגלל החשמל, היה צריך משהו יותר גדול ממכונת הרווקים שלי בת ה – 15), והפסקות החשמל לא הלכו לשום מקום, החלטתי שאני אפתור את הבעיה בדרך שאני יודע, בלי מומחים שלא עוזרים וככה נולד עוד פרויקט IoT.

הבעיה המרכזית היא לא הפסקת החשמל עצמה, אלא העובדה שכבר קרה, יותר מפעם אחת, שזה מתרחש כשאנחנו לא בבית. באחת הפעמים לא היינו בבית 4 ימים. רוצים לנחש מה קרה לבשר בפריזר ולכל מה שהיה במקרר?

מה שחיפשתי זאת מערכת ניטור והתראה, במקרה שנופל החשמל (ואז אפשר, במקרה הגרוע, לשלוח מישהו שירים אותו). השאלה איך עושים את זה עם IoT, בזול ובלי להתעסק עם רשת החשמל עצמה? לעבודה!

דרישות ראשוניות

M5StickC – רכיב IoT מבוסס ESP32 שמגיע עם סוללה, מסך קטן ומספר חיישנים (שברובם נותרו ללא שימוש בפרויקט זה). אפשר לרכוש אותו מאתר החברה או מעלי אקספרס.

לא חייבים להשתמש ברכיב הזה (שהוא יקר יחסית ועולה כ – $10) וכל רכיב ESP32, שניתן לחבר אליו סוללה חיצונית נטענת, יעשה את העבודה אבל זה יהיה הרבה יותר מסובך. כמו כן, הקוד בדוגמא ידרוש לא מעט התאמות מכיוון שאני משתמש בספריות ייחודיות של M5 לשליטה על המסך והסוללה.

Arduino Studio – הפרויקט הזה השתמש ב – Arduino Studio על מנת לעדכן את הקוד על רכיב ה – M5 שלי. אם אתם משתמשים באותו רכיב, כל מה שצריך לחיבור הוא כבל USB-C שמגיע עם הרכיב. אם החלטתם ללכת על רכיב ESP-32 אחר, יכול להיות (תלוי ברכיב) שתידרשו למתאם USB Serial Adapter. הנה מדריך שימושי לאיך לעבוד איתם. הוא אמנם כתוב לרכיב מסוג ESP-01 (שיכול לשמש אתכם גם בפרויקט הזה) אבל מאד שימושי גם לאחרים (בשינוי פרמטרים, חיבורים וספריות בהתאם לסוג הרכיב).

חשבון ב – AWS – אני משתמש ב – AWS IoT Core ועוד מספר שרותים של AWS על מנת לקבל חיווי מהמכשיר שיש הפסקת חשמל ולשלוח SMS לנייד שלי. גם במקרה הזה, אפשר להחליף את הקוד בשרותים אחרים כגון Twilio.

רשת ביתית עם UPS – במקרה של הפסקת חשמל, אם תרצו לקבל התראה לנייד או למייל (או כל דרך אחרת), החיישן שלכם צריך להיות מסוגל להתחבר לרשת פעילה. אם אין חשמל בדרך כלל גם אין רשת וזאת בעיה.

יש היום בשוק יחידות UPS שמתאימות לראוטרים (רובם עובדים על 12V) שיתנו לכם מספיק זמן לשלוח הודעה על תקלה (והרבה מעבר לזה). אני משתמש ב – UPS קצת יותר גדול על מנת לשמור על ה – NAS הבייתי שלי עובד, במקרה של הפסקת חשמל. בנוסף ל – NAS גם הראוטר שלי מחובר אליו.

פרויקט IoT – מה בונים?

הרעיון הבסיסי של פרויקט ה – IoT הזה מאד פשוט – רכיב IoT מחובר לסוללה, קוד שרץ על הרכיב ב- loop מנטר את הסוללה כל כמה שניות (פרמטר שניתן להגדרה – אני משתמש ב – 15 שניות) וברגע שמתח הטעינה על הסוללה יורד מתחת ל – 4V המערכת ״מבינה״ שהחשמל נפל. ברגע שזה קורה, מופעלות כמה פונקציות שמתחברות ל – AWS IoT Core, שולחות הודעה ב – MQTT ל – Topic שמנוטר על ידי פונקציית Lmabda של AWS שאחראית על שליחת ההודעה ב – SMS למספר טלפון שמוגדר בפונקציה.

בנוסף, אני משתמש במסך של ה- M5StickC על מנת לעדכן את הסטטוס למשתמש (לא הכרחי אבל מאד נחמד) וב – EEPROM של הרכיב על מנת לשמור את תאריך הפסקת החשמל האחרונה.

ככה זה נראה במציאות:

מתחילים בקוד

לפני שנצלול לקוד, הבהרה חשובה – הקוד כתוב ב – ++C. אני לא מומחה (רחוק מזה) לשפה הזאת ובפעם אחרונה שכתבתי משהו ב – ++C הייתה באזור 2002, כשתחזקתי CGI כתוב ב – ++C, באחת החברות בהן עבדתי. השתמשתי במה שאני יודע, דוגמאות מהרשת וספריות קיימות אבל ברור לי שהקוד יכול להשתפר פלאים. פה אתם יכולים לעזור. כל הקוד של הפרויקט נמצא ב – GitHub ואתם יותר ממוזמנים לשפר את הקוד ולשלוח לי Pull Request, על מנת שכולם יוכלו להנות מהשיפורים שלכם.

אם זאת הפעם הראשונה שאתם משתמשים ב – Arduino Studio או מעולם לא עבדתם עם ESP32 לפרויקט IoT, ממליץ לכם לקרוא את המדריך הזה להתקנה של Arduino Studio והוספת הספריות הדרושות ל – ESP32. לאחר ההגדרה, M5StickC אמור להופיע בלוחות הזמינים בתפריט Tools –> Board.

נעבור לקוד עצמו – החלק הראשון הוא בעיקר הוספת ספריות והגדרת משתנים.

הספריות שדרושות לפרויקט הן (חלקן יהיו מותקנות ואת האחרות צריך להתקין דרך Sketch –> Include Library –> Library Manager אחרי הורדת קובץ ההתקנה):

#include "secrets.h" //Remember to rename secrets_orig.h and add your network/certificates/other info
#include 
#include 
#include 
#include 
#include 
#include "WiFi.h"
#include "time.h"
#define AWS_MAX_RECONNECT_TRIES 10
#define AWS_IOT_PUBLISH_TOPIC   "M5StickC-PowerDetector/pub" //AWS IoT publish topic
#define AWS_IOT_SUBSCRIBE_TOPIC "M5StickC-PowerDetector/sub" //AWS IoT subscribe topic
const char* ntpServer = "pool.ntp.org";
const long  secGMTOffset = 7200; //Set your timezone offset - Israel is GMT+2 or 7200 sec
const int   secDaylightOffset = 3600; //Set your daylight offset
long loopTime, startTime = 0;
char localTime[32] = "";
int color;
time_t localStamp;
uint8_t sleepCount = 0;
uint8_t wifiCount = 0;
int printDelay = 500;
int checkDelay = 15000; //Set time between power checks
time_t lastError;
char lastErrorString[32];
long lastErrorSec;
char powerStatus[8] = "NA";
WiFiClientSecure net = WiFiClientSecure();
MQTTClient client = MQTTClient(256);

החלק השני הוא בעיקר פונקציות עזר, מניקוי המסך, דרך חיבור ה – WiFi, עדכון השעון ומחיקה של ה – EEPROM (לא עושה בו שימוש בקוד אבל זה שם למקרה שתידרשו לאפס אותו).

void LCD_Clear()
{
    M5.Lcd.fillScreen(BLUE);
    M5.Lcd.setCursor(0, 0);
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(1);
    M5.Lcd.setTextFont(2);
}
void connectWiFi()
{
    // Connect to WiFi
    M5.Lcd.printf("Connecting to %s ", ssid);
    delay(1000);
    Serial.begin(115200);
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED)
    {
        delay(500);
        M5.Lcd.printf(".");
        // Serial.print(".");
        if (wifiCount > 20)
        {
            M5.Axp.DeepSleep(SLEEP_SEC(5));
        }
        wifiCount++;
    }
    M5.Lcd.println("\n\rCONNECTED!");
}
void getLocalTime()
{
    struct tm timeinfo;
    if(!getLocalTime(&timeinfo))
    {
        Serial.println("Failed to obtain time");
        return;
    }
    strftime(localTime,32,"%d-%m-%Y %H:%M:%S",&timeinfo);
    localStamp = mktime(&timeinfo);
}
void resetEEPROM(){
    for (int i = 0; i < 128; i++)
    {
        EEPROM.write(i, 0);
    }
    EEPROM.commit();
}

החלק האחרון, והחושב ביותר הוא הלוגיקה עצמה.

למי שלא מכיר קוד ל – Arduino כמה דברים בסיסיים – הפונקציה setup רצה כל פעם שהרכיב נדלק ובדרך כלל מכילה הגדרות ראשוניות של הרכיב.

הפונקציה loop היא לולאה, שרצה כל עוד הרכיב עובד ושם נמצאת הלוגיקה הראשית, שבודקת את מצב הטעינה בסוללה של ה – M5StickC ובמקרה של תקלה תקרא ל – powerLoss_detected, שתתחיל את תהליך ההודעה על הפסקת החשמל, הכולל חיבור ל – AWS, שליחת הודעה ועדכון ה – EEPROM.

אם החשמל לא חוזר אחרי 5 בדיקות, הרכיב יכבה את עצמו ל – 5 דקות ואחרי שיתעורר, יחזור על תהליך הבדיקה. זה מבטיח שגם בהפסקות חשמל ארוכות, הסוללה תספיק לכמה שעות טובות על מנת לקבל התראות בכל הזמן הזה.

void powerLoss_detected()
{
    sleepCount++;
    LCD_Clear();
    M5.Lcd.fillScreen(RED);
    M5.Lcd.setTextSize(1);
    M5.Lcd.setCursor(0, 2, 2);
    M5.Lcd.setTextColor(WHITE, RED);
    M5.Lcd.printf("**ERROR**\n\rPOWER DOWN\n\r");
    M5.Lcd.println(sleepCount);
    getLocalTime();
    Serial.println(powerStatus);
    if (strcmp(powerStatus, "ERROR") != 0)
    {
        strncpy( powerStatus, "ERROR", sizeof(powerStatus) );
        EEPROM.writeInt(0, localStamp);
        EEPROM.commit();
        connectAWS();
        publishMessage();
    }
    delay(2000);
    if(sleepCount >= 5) //sleep device after 5 * [checkDelay] msec
    {
        sleepCount = 0;
        M5.Axp.DeepSleep(SLEEP_SEC(60 * 5)); //time to wakeup
    }
}
void connectAWS()
{
    // Configure WiFiClientSecure to use the AWS IoT device credentials
    net.setCACert(AWS_CERT_CA);
    net.setCertificate(AWS_CERT_CRT);
    net.setPrivateKey(AWS_CERT_PRIVATE);
    // Connect to the MQTT broker on the AWS endpoint we defined earlier
    client.begin(AWS_IOT_ENDPOINT, 8883, net);
    // Create a message handler
    client.onMessage(messageHandler);
    int retries = 0;
    Serial.println("Connecting to AWS IOT");
    while (!client.connect(THINGNAME) && retries < AWS_MAX_RECONNECT_TRIES)
    {
        Serial.print(".");
        delay(100);
        retries++;
    }
    if(!client.connected())
    {
        Serial.println("AWS IoT Timeout!");
        M5.Lcd.println("AWS IoT Timeout!");
        return;
    }
    // Subscribe to a topic
    client.subscribe(AWS_IOT_SUBSCRIBE_TOPIC);
    Serial.println("AWS IoT Connected!");
    M5.Lcd.println("AWS IoT Connected!");
    delay(2000);
}
void publishMessage()
{
    StaticJsonDocument<200> doc;
    doc["sms"] = smsNumber;
    doc["message"] = smsMessage;
    //strcat(doc["message"],lastErrorString);
    char jsonBuffer[512];
    serializeJson(doc, jsonBuffer); // print to client
    client.publish(AWS_IOT_PUBLISH_TOPIC, jsonBuffer);
    Serial.println("Published alert to AWS IoT");
}
void messageHandler(String &topic, String &payload)
{
    Serial.println("incoming: " + topic + " - " + payload);
    StaticJsonDocument<200> doc;
    deserializeJson(doc, payload);
    const char* message = doc["message"];
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0, 2);
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextFont(2);
    M5.Lcd.println(message);
    delay(5000);
}
void setup()
{
    M5.begin();
    M5.Lcd.setRotation(3);
    LCD_Clear();
    connectWiFi();
    // Init and get the time
    configTime(secGMTOffset, secDaylightOffset, ntpServer);
    getLocalTime();
    M5.Lcd.println(localTime);
    delay(2000);
    EEPROM.begin(512);
    lastError = EEPROM.readInt(0);
    Serial.println(lastError);
    if(M5.Axp.GetVBusVoltage()<4)
    {
        strncpy( powerStatus, "ERROR", sizeof(powerStatus) );
    }
}
void loop()
{
    loopTime = millis();
    if(startTime < (loopTime - checkDelay)) //Check power status every [checkDelay] msec
    {
        if(M5.Axp.GetVBusVoltage()<4)
        {
            powerLoss_detected();
        }
        else
        {
            strncpy( powerStatus, "OK", sizeof(powerStatus) );
            sleepCount = 0;
            if (lastError>0)
            {
                LCD_Clear();
                M5.Lcd.setCursor(0, 2, 2);
                M5.Lcd.setTextColor(WHITE, RED);
                M5.Lcd.printf("Last power down:\n\r");
                struct tm  ts;
                lastError = EEPROM.readInt(0);
                time_t default_time = lastError;
                ts = *localtime(&default_time);
                strftime(lastErrorString, 32, "%d-%m-%Y %H:%M:%S", &ts);
                M5.Lcd.printf("%s\n\r", lastErrorString);
                //M5.Lcd.printf("(%.2f seconds)", difftime(localStamp, lastError));
                lastErrorSec = difftime(localStamp, lastError);
                M5.Lcd.printf("(%02.0fH:%02.0fM:%02.0fS)", floor(lastErrorSec/3600.0), floor(fmod(lastErrorSec,3600.0)/60.0), fmod(lastErrorSec,60.0));
                Serial.printf("[DEBUG] Last Error %s\r\n", lastErrorString);
                Serial.printf("%02.0fH:%02.0fM:%02.0fS\r\n", floor(lastErrorSec/3600.0), floor(fmod(lastErrorSec,3600.0)/60.0), fmod(lastErrorSec,60.0));
                delay(5000);
            }
        }
        startTime = loopTime;
    }
    LCD_Clear();
    M5.Lcd.setCursor(0, 0, 1);
    M5.Lcd.setTextSize(1);
    M5.Lcd.setTextFont(2);
    M5.Lcd.setTextColor(WHITE, BLUE);
    M5.Lcd.printf("Power Status - %s\n\r", powerStatus);
    getLocalTime();
    M5.Lcd.println(localTime);
    M5.Lcd.printf("Battery: %.2fV\r\n", M5.Axp.GetBatVoltage());
    M5.Lcd.printf("aps: %.2fV\r\n", M5.Axp.GetAPSVoltage());
    //M5.Lcd.printf("Warning level: %d\r\n", M5.Axp.GetWarningLevel());
    M5.Lcd.printf("USB in: V:%.2fV I:%.2fma\r\n", M5.Axp.GetVBusVoltage(), M5.Axp.GetVBusCurrent());
    delay(printDelay);
}

מה קורה בענן – AWS IoT Core

כדי שכל זה יעובד, ונקבל התראות לטלפון (או למייל) הרכיב צריך להתחבר לענן ולשלוח, באמצעות AWS IoT, הודעה שיש תקלת חשמל. לשם כך יש לערוך את הקובץ secrets_orig.h הנמצא באותה תקייה יחד עם הקובץ הראשי ולשנות לו את השם ל – secrets.h, על מנת שיכיל את הפרמטרים של הרשת שלכם, מספר הטלפון וההודעה, את ה – AWS IoT Endpoint ותעודות האבטחה שיאפשרו לכם חיבור לאותו Endpoint.

על מנת לחברת את ה – M5StickC לרשת שלכם, יהיה עליכם לספק את שם הרשת (ssid בשורה 6) והסיסמא (password בשורה 7). את שאר העבודה יעשה הקוד בקובץ הראשי (הפונקציה connectWiFi()).

#include 
#define SECRET
#define THINGNAME ""
const char* ssid       = "";
const char* password   = "";
const char AWS_IOT_ENDPOINT[] = "";
const char* smsNumber = "";
const char* smsMessage = "";
// Amazon Root CA 1
static const char AWS_CERT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
)EOF";
// Device Certificate
static const char AWS_CERT_CRT[] PROGMEM = R"KEY(
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
)KEY";
// Device Private Key
static const char AWS_CERT_PRIVATE[] PROGMEM = R"KEY(
-----BEGIN RSA PRIVATE KEY-----
-----END RSA PRIVATE KEY-----
)KEY";

אם זאת הפעם הראשונה שאתם מחברים Device ל – AWS, ממליץ בחום לקרוא את המבוא ל – AWS IoT Core שכתבתי ובכלל לקרוא את המדריכים השונים תחת הקטגוריה של IoT.

הגדרות החיבור ל – AWS IoT Core הן קצת יותר מורכבות ודורשות 4 פרמטרים:

  • AWS_CERT_CA – ה – Root CA של AWS שמאפשר את הגישה לשרותים של AWS. כל הפרטים על ה – Root CA כאן, כולל לינק להורדה של Root CA 1.
  • AWS_CERT_CRT – התעודה של ה – Device עצמו, המופקת ברישום ה – Device ל – AWS IoT Core.
  • AWS_CERT_PRIVATE – ה – Private Key של ה – Device, על מנת לוודא שמי שמתחבר הוא אכן ה – Device הספציפי הזה. גם התעודה הזאת מופקת ברישום, בדיוק כמו ה – AWS_CERT_PRIVATE.
  • AWS_IOT_ENDPOINT – זו הכתובת אליה ה – Device שלכם שולח הודעות. היא משתנה בין Regions ומחשבון לחשבון. על מנת לקבל את הכתובת שלכם, אפשר לגשת ל – AWS IoT Core לאחר רישום ה – Device ל – Manage –> Things, למצוא את ה – Device שלכם ובתוך תפריט הניהול שלו לגשת ל – Interact. ה – Endpoint היא הכתובת שמופיעה תחת HTTPS. דרך קצרה יותר (במידה ומותקן אצלכם במחשב AWS CLI), היא להריץ את הפקודה הבאה (שימו לב לפרמטר region):
$ aws iot describe-endpoint --region eu-west-1
{
    "endpointAddress": "xxxxxxxxx.iot.eu-west-1.amazonaws.com"
}

מחברים הכל ביחד – מהפסקת חשמל עד הודעה

ברגע שנופל החשמל וה – M5StickC מפסיק לקבל מתח, הוא יריץ (במרווח שהוגדר ב – checkDelay) את הפונקציה powerLoss_detected. אחרי שהפונקציה תציג הודעה על המסך שנפל החשמל, היא תריץ 2 פונקציות נוספות:
connectAWS
publishMessage

הראשונה תתחבר ל – AWS IoT, בהתאם להגדרות שסיפקנו בקובץ הקונפיגורציה והשניה תפרסם הודעה ל – Topic ב – AWS בשם M5StickC-PowerDetector/pub (גם אותו ניתן לשנות). ההודעה כוללת מסמך JSON פשוט עם שני שדות, הטלפון אליו ישלח ה – SMS וטקסט להודעה. בזה מסתיים התפקיד של ה – Device שלי והעבודה עוברת לענן.

בצד של AWS IoT הגדרתי Rule שתפקידו לנטר את ה – Topic הנ״ל וכל פעם שיש בו שינוי, להריץ פונקציית Lambda עם קוד פשוט שכל תפקידו הוא לשלוח אלי SMS (בהתבסס על המידע בהודעה ששלחתי).

הקוד עצמו מאד פשוט וגם הוא נמצא ב – GitHub. אני משתמש ב – Python אבל אפשר לכתוב אותו בכל שפה הנתמכת ב – Lambda. לא אכנס בפוסט זה לכל העולם של Serverless ואיך מרימים אפליקציה עם Lambda אבל אתם מוזמנים לקורא על הנושא במגוון פוסטים בבלוג.

סיכום

זה הפרוייקט IoT האחרון שלי. אני מקווה שזה נתן לכם רעיונות לפרויקט הבא שלכם. אם יש שאלות, צרו קשר בתגובות ואם בניתם משהו דומה, אשמח לשמוע על זה.

אני כבר בעיצומו של פרויקט IoT חדש פרטים בקרוב.

אודות הכותב

בועז זינימן
Principal Developer Advocate ב – AWS. לפי שהצטרף ל – AWS שימש כדירקטור בכיר לאסטרטגיית Cloud בחברת התוכנה Rogue Wave Software אשר רכשה את Zend Technologies ב – 2015. בעשור האחרון ניהל את הצוותים הטכנולוגיים ב – Zend, כולל תיכנון ופיתוח כל מערכות ה – Web, פתרונות Hosting, אסטרטגיית IT ותשתיות. לפני שהצטרף ל – Zend, במהלך 15 השנים האחרונות, ניהל צוותי פיתוח Web במספר חברות טכנולוגיה בישראל. מתמחה בעיקר במחשוב ענן ובמערכות LAMP - Linux Apache MySQL PHP ובעל הסמכת ZCE - Zend Certified Engineer משנת 2005.