meideru blog

家電メーカーで働いているmeideruのブログです。主に技術系・ガジェット系の話を書いています。

【ESP8266】人を検知するとSlackに通知を送るデバイスを作ってみた

 

ママがきたセンサーをご存知でしょうか?

昔、子供向けに販売されていた電子工作のキットです。ママを光センサーで感知すると、音を鳴らして教えてくれる、という商品でした。

センサーと本体が有線接続だったりと時代にそぐわない点がいくつあるように感じたので、ESP8266を使って21世紀に相応しいデバイスを作ってみました。

人を人感センサーで検知するとSlackに通知を送る、そんなデバイスです。 (自称「21世紀版のママがきたセンサー」)

目次

ママがきたセンサーとは?

前述の通り、ママがきたら音を鳴らして教えてくれる電子工作のキットのことです。そのまた昔、電子工作の雑誌などで紹介されていました。最近、再び復刻版が発売されたようです。

下記がママがきたセンサーの概要です。

ママがきたセンサー

ママがきたセンサー

(出典: https://www.elekit.co.jp/product/TK-741D )

ママがきたセンサーの問題点

  • センサーと受信機が有線であること
  • コードが5mしかないこと

これだとコードをママに見つけられたらバレてしまう。
コードが5mしかないと、センサーを置くことができる範囲が限られてしまう。

21世紀版ママがきたセンサーの概要

21世紀のママがきたセンサー

21世紀のママがきたセンサー

  • 人感センサーでママを検知すると、スマートフォンのSlackのアプリに通知が送られてきます。
  • センサーはWiFiでつなげるので、家の中ならどこでも設置できます。
  • 人感センサーは最大8mの距離まで検知できます。
  • 本体の大きさはフリスクサイズです。
  • 電池はおよそ1週間持ちます。

21世紀版のママがきたセンサーのネットワーク構成

21世紀のママがきたセンサー

上記が21世紀版のママがきたセンサーのネットワークの構成図です。

人感センサーでママを検知したらESP8266がSlackのAPIを叩いて通知を送ります。
通知はスマートフォンのSlackのアプリで受信できます。

ESP8266はWiFiモジュールなのでコードレスです。WiFiが届く範囲ならどこでも設置できます。

21世紀版ママがきたセンサーに使った部品

  • NodeMCU(ESP8266)
  • SB612A(人感センサー)
  • 赤色LED
  • 緑色LED
  • 抵抗×2
  • 電池ボックス (単3電池3本)
  • 単3充電池×3本

調べればすぐに出てくると思うので、入手先等は細かく説明しません。

21世紀版ママがきたセンサーの回路図

21世紀のママがきたセンサー

雑多で申し訳ないですが、上記が回路図になります。

極めてシンプルな回路です。

21世紀版ママがきたセンサーのソースコード

/******************************************************************************
 * MAMA-GA-KITA-SENSOR client
 * 
 * This is a client side program.
 * When this device detected people, it sends notifications to the slack server.
 ******************************************************************************/

#include <ESP8266WiFi.h>
#include <WiFiClientSecure.h>

/************************************
 * pins configuration
 ************************************/
// 5  is located on D1 pin of the board
// 4  is located on D2 pin of the board
// 12 is located on D6 pin of the board
// (check the pin placements infomation in documents folder)
#define RED_LED_PIN        5
#define SENSER_PIN         4
#define GREEN_LED_PIN      12

/************************************
 * time configuration
 ************************************/
#define LOOP_WAIT_MSEC                    100
#define AP_CONNECT_TIME_OUT_MSEC          20000
#define HTTP_ACCESS_TIME_OUT_MSEC         500
#define TIME_TO_TRANSITION_TO_SLEEP_MODE  7200000  // 2 hour

/************************************
 * access point configuration
 ************************************/
static const char *AP_SSID     = "XXXX";
static const char *AP_PASSWORD = "XXXX";

/************************************
 * slack server configuration
 ************************************/
const char *SLACK_HOST       = "hooks.slack.com";
const int   SLACK_HTTPS_PORT = 443;
const char *SLACK_URI        = "/services/XXXX/XXXX/XXXX";

/************************************
 * led action configuration
 ************************************/
#if 1
// blink (debug)
static const bool enable_blink_led = true;
#else
// no blink (production)
static const bool enable_blink_led = false;
#endif

enum STATE {
    STATE_INITIALIZING,
    STATE_RECONNECTING,
    STATE_SLEEPING,
    STATE_POLLING_SENSOR,
    STATE_NOTIFYING_DETECTION,
    STATE_ERROR,
};

enum LOG_TYPE {
    LOG_TYPE_INFO,
    LOG_TYPE_WARNING,
    LOG_TYPE_ERROR,
};

static STATE g_state;

static STATE state_initializing(void);
static STATE state_reconnecting(void);
static STATE state_sleeping(void);
static STATE state_polling_sensor(void);
static STATE state_notifying_detection(void);
static STATE state_error(void);
static bool  connect_to_access_point(void);
static bool  https_post_access(const char* host, const int port, const char* uri, const char* content);
// "ICACHE_RAM_ATTR" must be added before the interrupt function.
// (reference: https://github.com/esp8266/Arduino/issues/6127 )
ICACHE_RAM_ATTR static void interrupt_detected(void);
static void  print_log(LOG_TYPE log_type, String content);

void setup() 
{
    g_state = STATE_INITIALIZING;
}

void loop() 
{
    switch (g_state) {
    case STATE_INITIALIZING:
        g_state = state_initializing();
        break;
    case STATE_RECONNECTING:
        g_state = state_reconnecting();
        break;
    case STATE_SLEEPING:
        g_state = state_sleeping();
        break;
    case STATE_POLLING_SENSOR:
        g_state = state_polling_sensor();
        break;
    case STATE_NOTIFYING_DETECTION:
        g_state = state_notifying_detection();
        break;
    case STATE_ERROR:
        g_state = state_error();
        break;
    default:
        break;
    }

    // if the loop rotates too fast, it will cause an exception
    // so added a time interval
    delay(LOOP_WAIT_MSEC);
}

static STATE state_initializing(void)
{
    bool res = true;
    
    Serial.begin(9600);
    print_log(LOG_TYPE_INFO, "state: STATE_INITIALIZING");
    print_log(LOG_TYPE_INFO, "set borate");

    print_log(LOG_TYPE_INFO, "set pin config");
    pinMode(RED_LED_PIN, OUTPUT);
    pinMode(SENSER_PIN, INPUT);
    pinMode(GREEN_LED_PIN, OUTPUT);
    if (enable_blink_led == true) {
        digitalWrite(RED_LED_PIN, LOW);
        digitalWrite(GREEN_LED_PIN, HIGH);
    }

    print_log(LOG_TYPE_INFO, "set wifi config");
    WiFi.mode(WIFI_STA);

    print_log(LOG_TYPE_INFO, "connecting to access point");
    if (connect_to_access_point() == false) {
        print_log(LOG_TYPE_ERROR, "changed state from STATE_INITIALIZING to STATE_ERROR");
        return STATE_ERROR;
    }

    print_log(LOG_TYPE_INFO, "set interrupt function");
    attachInterrupt(digitalPinToInterrupt(SENSER_PIN), interrupt_detected, HIGH);

    print_log(LOG_TYPE_INFO, "successful initialization");
    res = https_post_access(SLACK_HOST, SLACK_HTTPS_PORT, SLACK_URI, "{\"text\":\"power on\"}");
    if (res == false) {
        print_log(LOG_TYPE_ERROR, "set state to STATE_RECONNECTING");
        return STATE_RECONNECTING;
    }

    return STATE_POLLING_SENSOR;
}

static STATE state_reconnecting(void)
{
    print_log(LOG_TYPE_INFO, "state: STATE_RECONNECTING");
    
    print_log(LOG_TYPE_INFO, "trying to reconnect to access point");
    if (connect_to_access_point() == true) {
        print_log(LOG_TYPE_INFO, "changed state from STATE_RECONNECTING to STATE_POLLING_SENSOR");
        return STATE_POLLING_SENSOR;
    }
    else {
        print_log(LOG_TYPE_ERROR, "changed state from STATE_RECONNECTING to STATE_ERROR");
        return STATE_ERROR;
    }
}

static STATE state_sleeping(void)
{
    print_log(LOG_TYPE_INFO, "state: STATE_SLEEPING");

    // going to sleep...
    WiFi.mode(WIFI_OFF);
    print_log(LOG_TYPE_INFO, "going to sleep...");
    delay(100);  // needs a brief delay or the whole message above doesn't print
    wifi_fpm_set_sleep_type(LIGHT_SLEEP_T);
    gpio_pin_wakeup_enable(SENSER_PIN, GPIO_PIN_INTR_HILEVEL);
    wifi_fpm_open();

    // sleep
    wifi_fpm_do_sleep(0xFFFFFFF);

    // enable wifi
    wifi_set_opmode(STATION_MODE);
    wifi_station_connect();

    return g_state;
}

static STATE state_polling_sensor(void)
{
    print_log(LOG_TYPE_INFO, "state: STATE_POLLING_SENSOR");

    unsigned int start_time_msec, delta_time_msec;

    print_log(LOG_TYPE_INFO, "polling sensor");
    start_time_msec = millis();
    while (1) {
        if (digitalRead(SENSER_PIN) == HIGH) {
            return STATE_NOTIFYING_DETECTION;
        }

        delta_time_msec = millis() - start_time_msec;
        if (delta_time_msec >= TIME_TO_TRANSITION_TO_SLEEP_MODE) {
            return STATE_SLEEPING;
        }
        delay(100);
    }
}

static STATE state_notifying_detection(void)
{
    print_log(LOG_TYPE_INFO, "state: STATE_NOTIFYING_DETECTION");

    bool  res   = true;
    STATE state = STATE_POLLING_SENSOR;
    
    if (enable_blink_led == true) {
        digitalWrite(RED_LED_PIN, HIGH);
    }

    // notity
    print_log(LOG_TYPE_INFO, "notify!");
    res = https_post_access(SLACK_HOST, SLACK_HTTPS_PORT, SLACK_URI, "{\"text\":\"detected!!\"}");
    if (res == false) {
        print_log(LOG_TYPE_ERROR, "set state to STATE_RECONNECTING");
        state = STATE_RECONNECTING;
    }

    // if this while is not here, call the interrupt function many times
    while (digitalRead(SENSER_PIN) == HIGH) {
        delay(100);
    }

    // enable interrupt
    // NOTICE: disableInterrupt is called in interrupt_detected function
    attachInterrupt(digitalPinToInterrupt(SENSER_PIN), interrupt_detected, HIGH);

    if (enable_blink_led == true) {
        digitalWrite(RED_LED_PIN, LOW);
    }

    return state;
}

static STATE state_error(void)
{
    print_log(LOG_TYPE_ERROR, "state: STATE_ERROR");

    // turn off the green led
    if (enable_blink_led == true) {
        digitalWrite(GREEN_LED_PIN, LOW);
    }

    // disable interrupt because esp8266 wake up by interrupt
    detachInterrupt(digitalPinToInterrupt(SENSER_PIN));

#if 0
    // power off (uses less power)
    // ESP8266 does not have OFF mode, so use deep sleep mode
    digitalWrite(RED_LED_PIN, HIGH);
    ESP.deepSleep(ESP.deepSleepMax());
#else
    // blink the led forever (uses a lot of power)
    while (1) {
        digitalWrite(RED_LED_PIN, HIGH);
        delay(300);
        digitalWrite(RED_LED_PIN, LOW);
        delay(300);
        digitalWrite(RED_LED_PIN, HIGH);
        delay(300);
        digitalWrite(RED_LED_PIN, LOW);
        delay(300);
        digitalWrite(RED_LED_PIN, HIGH);
        delay(300);
        digitalWrite(RED_LED_PIN, LOW);
        delay(300);
        delay(1000);
    }
#endif
    
    return STATE_ERROR;
}

static bool connect_to_access_point(void)
{
    unsigned long start_time = 0;
    bool green_led_on = false;
    
    WiFi.begin(AP_SSID, AP_PASSWORD);
    delay(500);

    start_time = millis();
    while (WiFi.status() != WL_CONNECTED) {
        // check timeout
        if (millis() - start_time > AP_CONNECT_TIME_OUT_MSEC) {
            print_log(LOG_TYPE_ERROR, "failed to connect to the access point");
            return false;
        }
        // blink led
        if (enable_blink_led == true) {
            if (green_led_on == true) {
                digitalWrite(GREEN_LED_PIN, LOW);
                green_led_on = false;
            }
            else {
                digitalWrite(GREEN_LED_PIN, HIGH);
                green_led_on = true;
            }
        }
        delay(300);
    }

    if (enable_blink_led == true) {
        digitalWrite(GREEN_LED_PIN, HIGH);
        green_led_on = true;
    }
    
    print_log(LOG_TYPE_INFO, "successfully connected to access point");
    return true;
}

static bool https_post_access(const char* host, const int port, const char* uri, const char* content)
{
    String header, body;
    WiFiClientSecure client;
    unsigned long start_time = 0;
    
    client.setInsecure();
    client.setTimeout(HTTP_ACCESS_TIME_OUT_MSEC);
    
    print_log(LOG_TYPE_INFO, "waiting for wifi connetion");
    start_time = millis();
    while (WiFi.status() != WL_CONNECTED) {
        // NOTICE: if this delay is not here, an exception occurs
        delay(10);
        if (millis() - start_time > AP_CONNECT_TIME_OUT_MSEC) {
            print_log(LOG_TYPE_ERROR, "failed to connect to the access point");
            return false;
        }
    }
    print_log(LOG_TYPE_INFO, "wifi connected");

    if (!client.connect(host, port)) {
        print_log(LOG_TYPE_ERROR, "http connection error");
        return false;
    }

    header = "POST " + String(uri) + " HTTP/1.1\r\n" +
             "Host: " + String(host) + "\r\n" +
             "Content-Type: application/json\r\n" +
             "Content-Length: " + String(content).length() + "\r\n\r\n";
    body = String(content) + "\r\n";

    // send message
    print_log(LOG_TYPE_INFO, "sending the message to a server");
    client.print(header);
    client.print(body);

    // receive response
    print_log(LOG_TYPE_INFO, "receiving a response");
    String res = client.readString();
    if (res.startsWith("HTTP/1.1 200 OK")) {
        print_log(LOG_TYPE_INFO, "response ok");
        client.stop();
        return true;
    }
    else {
        print_log(LOG_TYPE_ERROR, "response ng");
        Serial.println(res);
        client.stop();
        return false;
    }
}

ICACHE_RAM_ATTR static void interrupt_detected(void)
{
    // disable interrupt
    // NOTICE: attachInterrupt is called in state_notifying_detection function
    detachInterrupt(digitalPinToInterrupt(SENSER_PIN));

    print_log(LOG_TYPE_INFO, "interrupt_detected!!");
    
    // change state
    g_state = STATE_NOTIFYING_DETECTION;
}

static void print_log(LOG_TYPE log_type, String content)
{
    String header = "";

    // set timestamp
    header += "[" + String(millis()) + "]";

    // set category
    switch (log_type) {
    case LOG_TYPE_INFO:
        header += "[INFO]";
        break;
    case LOG_TYPE_WARNING:
        header += "[WARNING]";
        break;
    case LOG_TYPE_ERROR:
        header += "[ERROR]";
        break;
    default:
        // never comes
        header += "[???]";
        break;
    }

    Serial.println(header + " " + content);
}

 

21世紀のママがきたセンサー

上記は状態遷移図です。補足資料で紹介させていただきます。

2通りの方法でママを検知し通知を送っています。

  1. 人感センサーの出力をポーリングして監視し、変化があれば通知を送る。
  2. ESP8266をスリープ状態にしておき、人感センサーの出力に変化があったら割り込みで起きて通知を送る。

何故、このような作りにしたか。
消費電力の観点から常に方法2が望ましいのですが、それだと問題がありました。

スリープ状態から復帰してして通知を送ろうとするとWiFiの再接続にかかってしまい、スマートフォンに通知が送られてくるのが遅くなってしまうのです。

私の環境だと、スマートフォンで通知を受信できるのに20秒程度かかりました。これだとママが部屋に入ってきた後に、通知を受信することになってしまいます。

ということで、方法1のようにポーリング状態を追加しました。

電池の消費がもったいないので、深夜など人が来ないときは、スリープモードに遷移する作りになっています。

総括

そこそこ実用性が高いと思ってます。

簡単に作れますので、興味のある方は是非作ってみてください。

 - 技術系