用 WeMos D1 開發 IoT

IoT (Internet of Things) 嘅概念係將硬件連接 internet,最大問題係各品牌嘅家用產品無法互相溝通,集中一個界面操控。DIY IoT 嘅最佳選擇係良率比較高用 ESP 8266 MCU 有 wifi 功能嘅 WeMos D1 R2 或者 D1 Mini Pro,最實用係用 I2C 連接温濕度 sensor SHT31,可以比較室內外温濕度差,作為調較冷氣嘅參考。

以香港潮濕嘅氣候並非對應日本或者歐洲冷氣机嘅設計,一定要將風速較到最低加强抽濕效果到 70% 以下先有舒適嘅感覺,而冷氣机一般都只有恒温控制而缺乏恒濕感應。單位入風口嘅温度可以同香港天文台嘅分區氣温差好遠,我個單位夏天一般高 3 度,而冬天更加高 6 度;我用紅外線測温儀測過,同大廈外牆温度吻合,所以成因係陽光直射加熱產生 heat capacitance。

天文台分區氣温 20.7C 濕度 85% 時嘅室內外数据比較

一般 I2C sensor 都可以揾倒 Arduino library 直接抄 example 就用得,但我喺 SHT31library 裏面加入 酷熱指數 (heat index) 將温濕度数值合併提升參考價值,同埋刪除 delay 嘅寫法。ESP8266 有足夠運算能力加入 Task library 做到 multitasking 避免運用 delay。Arduino 獨特嘅 loop 係 1ms period,我用 Task library 喺 common library file 加多 20ms, 1000ms, 8640ms,對應 servo, web, wifi reconnect,一共四個運行速度就足夠取代運用 delay 減速。

#define BLYNK_PRINT Serial
#define ARDUINOJSON_USE_DOUBLE 1

//*******************************************************************************************
// include library
//*******************************************************************************************

#include <Adafruit_INA219.h>
#include <ArduinoJson.h>
#include <ArduinoOTA.h>
#include <BlynkSimpleEsp8266.h>
#include <ESP8266HTTPClient.h>
#include <ESP8266NetBIOS.h>
#include <ESP8266mDNS.h>
#include <ESP8266WebServer.h>
#include <ESP8266WiFi.h>
#include <Task.h>
#include <TimeLib.h>
#include <WiFiUdp.h>
#include <fn_sht31.h>

//*******************************************************************************************
//   Function Prototypes
//*******************************************************************************************

char* cast_s2c(String string_input);
double cast_s2d(String string_input);
double round(double value, double dp);
String pad2digit(uint8_t number);
void fn_wemos_blynk_begin(const char mdns[], const char blynk_token[]);
void fn_handle_int();
void fn_handle_encoder(bool encoder_cw);
void fn_encoder_pin1_change();
void fn_encoder_pin2_change();
void fn_but_change();
void fn_counter();
void fn_ota_begin(const char mdns[], const uint8_t port);
void fn_i2c_begin(uint8_t pin_sda, uint8_t pin_scl);
void fn_sht_display();
void fn_ina_display();
void fn_wifi_begin();
void fn_ntp_sync(int8_t timeZone);
void fn_handle_root();
void fn_handle_json();
void fn_handle_favicon();
void fn_handle_notfound();
void fn_cors_header();

//*******************************************************************************************
// global & class objects
//*******************************************************************************************

const char ssid[] = "********";
const char password[] = "********";
const int8_t TIMEZONE = 8;
bool enable_encoder = 0;
bool enable_buildinled = 0;
bool enable_counter = 0;
bool enable_blynk = 0;
bool enable_ap = 0;
bool enable_root = 0;
String string_json = "";
String string_i2c = "";
struct tm time_tm;
time_t  time_ntp = 0;

StaticJsonDocument<512> json;
HTTPClient http;
ESP8266WebServer server(80);
WiFiClient client;
WiFiUDP udp;

//*******************************************************************************************
//  hardware
//*******************************************************************************************

const uint8_t pin_rx = 3; //ws2812 LED
const uint8_t pin_tx = 1;
const uint8_t pin_a0 = 17;
const uint8_t pin_d0 = 16;
const uint8_t pin_d1 = 5;
const uint8_t pin_d2 = 4;
const uint8_t pin_d3 = 0;
const uint8_t pin_d4 = 2;
const uint8_t pin_d5 = 14;
const uint8_t pin_d6 = 12;
const uint8_t pin_d7 = 13;
const uint8_t pin_d8 = 15;

const uint8_t pin_standby = pin_d0;
const uint8_t pin_scl = pin_d1;
const uint8_t pin_sda = pin_d2;
const uint8_t pin_int = pin_d3;
const uint8_t pin_led = pin_d4; // LED_BUILTIN
const uint8_t pin_pwm = pin_d5;
const uint8_t pin_in1 = pin_d6;
const uint8_t pin_in2 = pin_d7;
const uint8_t pin_counter = pin_d8;

const uint8_t sht_add = 68;
const uint8_t ina_add = 64;
bool array_i2c[127];
Adafruit_INA219 ina;
SHT31 sht = SHT31();

//*******************************************************************************************
//   multi task loop
//*******************************************************************************************

TaskManager taskManager;
void loop_20ms(uint32_t deltaTime);
void loop_1000ms(uint32_t deltaTime);
void loop_8640ms(uint32_t deltaTime);
FunctionTask timer20(loop_20ms, MsToTaskTime(20));
FunctionTask timer1000(loop_1000ms, MsToTaskTime(1000));
FunctionTask timer8640(loop_8640ms, MsToTaskTime(8640));

void fn_loop_1ms();
void fn_loop_20ms();
void fn_loop_1000ms();
void fn_loop_8640ms();

void loop() {
    fn_loop_1ms();
    taskManager.Loop();
}
void loop_20ms(uint32_t deltaTime) {
    server.handleClient();
    if (enable_blynk)Blynk.run();
 	json["uptime"] = round(millis() / 86400000.0, 6);
    string_json = "";
    serializeJson(json, string_json);
    MDNS.update();
    fn_loop_20ms();
}
void loop_1000ms(uint32_t deltaTime) {
    json["heap"] = ESP.getFreeHeap(); // system_get_free_heap_size
    if (WiFi.status() == 3)json["now"] = now();
    if (array_i2c[64])fn_ina_display();
    if (array_i2c[68])fn_sht_display();
    ArduinoOTA.handle();
    fn_loop_1000ms();

}
void loop_8640ms(uint32_t deltaTime) {
    fn_wifi_begin();
    fn_loop_8640ms();
}

//*******************************************************************************************
// init wemos setup
//*******************************************************************************************
void fn_wemos_blynk_begin(const char mdns[], const char blynk_token[]) {
    // hardware
    json["dns"] = String(mdns);
    Serial.begin(115200);
    Serial.println("");
    analogWriteFreq(38000); // IRremote
    analogWriteRange(255);
    pinMode(pin_d0, OUTPUT);
    digitalWrite(pin_d0, 0); // standby
    pinMode(pin_d4, OUTPUT);
    digitalWrite(pin_d4, 1); // led off
    pinMode(pin_d5, OUTPUT);
    analogWrite(pin_d5, 0); // pwm 0
    if (enable_encoder)json["pwm"] = 0;
    pinMode(pin_a0, INPUT);
    if (enable_counter) { 
        pinMode(pin_d8, INPUT_PULLUP);
        attachInterrupt(digitalPinToInterrupt(pin_d8), fn_counter, FALLING);
    }
    else {
        pinMode(pin_d8, OUTPUT);
        analogWrite(pin_d8, 0); // pwm 0
    }
    pinMode(pin_d3, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(pin_d3), fn_but_change, CHANGE);//CHANGE, FALLING, RISING, LOW
    if (enable_encoder) {
        pinMode(pin_d6, INPUT_PULLUP);
        pinMode(pin_d7, INPUT_PULLUP);
        attachInterrupt(digitalPinToInterrupt(pin_d6), fn_encoder_pin1_change, CHANGE);
        attachInterrupt(digitalPinToInterrupt(pin_d7), fn_encoder_pin2_change, CHANGE);
    }
    else {
        pinMode(pin_d6, INPUT);
        pinMode(pin_d7, INPUT);
    }

    // network
    WiFi.disconnect();
    WiFi.hostname(mdns);
    WiFi.mode(WIFI_STA);
    http.setReuse(true);
    if (!enable_root)server.on("/", fn_handle_root);
    server.on("/json", fn_handle_json);
    server.on("/favicon.ico", fn_handle_favicon);
    server.onNotFound(fn_handle_notfound);
    server.on("/restart", []() {
        fn_cors_header();
        server.send(204);
        ESP.restart();
        });
    server.on("/standby", []() {
        fn_cors_header();
        server.send(204);
        digitalWrite(pin_standby, 0);
        analogWrite(pin_pwm, 0);
        json["pwm"] = 0;
        });
    server.begin();
    MDNS.begin(mdns);
    MDNS.addService("tcp", "tcp", 80);
    MDNS.addService("http", "tcp", 80);
    fn_ota_begin(mdns, 8266);
    udp.begin(80);;

    if (enable_blynk)Blynk.begin(blynk_token, ssid, password);
    fn_i2c_begin(pin_sda, pin_scl);
    fn_ntp_sync(TIMEZONE);

    taskManager.StartTask(&timer20);
    taskManager.StartTask(&timer1000);
    taskManager.StartTask(&timer8640);
}

//*******************************************************************************************
// functions
//*******************************************************************************************

void fn_wifi_begin() {
    static uint8_t wifi_reconnect = 0;
    if (WiFi.getMode() == 1) { // 1=station, 2=ap
        //Serial.println("STATION mode");
        if (WiFi.status() != 3) { // 0=idle, 3=connected, 4=fail, 7=disconnect
            WiFi.disconnect();
            wifi_reconnect++;
            WiFi.begin(ssid, password);
        }
        else {
            if (year() < 2000) {
                fn_ntp_sync(TIMEZONE);
            }
            json["ip"] = WiFi.localIP().toString();
            json["rssi"] = WiFi.RSSI();
            wifi_reconnect = 0;
        }
    }
    else if (enable_ap && wifi_reconnect >= 2) {
        Serial.println("AP fallback");
        WiFi.disconnect();
        WiFi.mode(WIFI_AP);
        WiFi.softAP(String(json["dns"]), password);
        wifi_reconnect = 0;
    }
    else if (WiFi.getMode() == 2) {
        //Serial.println("AP mode");
        json["ip"] = WiFi.softAPIP().toString();
        wifi_reconnect = 0;
    }
}

void fn_ota_begin(const char mdns[], const uint8_t port) {
    ArduinoOTA.setPort(port);
    ArduinoOTA.setHostname(mdns);
    ArduinoOTA.onStart([]() {
        String type;
        if (ArduinoOTA.getCommand() == U_FLASH) {
            type = "sketch";
        }
        else { // U_FS
            type = "filesystem";
        }
        Serial.println("Start updating " + type);
        });
    ArduinoOTA.begin();
}

//*******************************************************************************************
// cast
//*******************************************************************************************

char* cast_s2c(String string_input) { return (char*)string_input.c_str(); }
double cast_s2d(String string_input) { return (double)string_input.toDouble(); }

double round(double value, double dp) {
    double int_value = (int64_t)(value * pow(10, dp) + 0.5);
    return (double)(int_value / pow(10, dp));
}

String pad2digit(uint8_t number) {
    if (number < 10) {
        String s = String(number);
        return '0' + s;
    }
    else {
        String s = String(number);
        return s;
    }
}

//*******************************************************************************************
// I2C
//*******************************************************************************************

void fn_i2c_begin(uint8_t pin_sda, uint8_t pin_scl) {
    uint8_t error, address;
    uint8_t nDevices;
    Wire.begin(pin_sda, pin_scl);
    Wire.setClock(400000UL);
    nDevices = 0;
    string_i2c = "I2C: ";
    for (address = 1; address < 127; address++) {
        array_i2c[address] = false;
        Wire.beginTransmission(address);
        error = Wire.endTransmission();
        if (error == 0) {
			if (address == sht_add) {
                sht.begin(sht_add);
                string_i2c += "sht";
                array_i2c[address] = true;
            }
           else if (address == ina_add) {
                ina.begin();
                string_i2c += "ina";
                array_i2c[address] = true;
            }
            string_i2c += "(";
            string_i2c += address; 
            string_i2c += ") ";
            nDevices++;
        }
    }
    if (nDevices == 0) {
        if (error != 4) {
            string_i2c += "no device";
        }
        else {
            string_i2c += "error";
        }
    }
    Serial.println(string_i2c);
}

void fn_ina_display() {
    int16_t shuntvoltage = 0;
    int16_t busvoltage = 0;
    int16_t current_mA = 0;
    int16_t loadvoltage = 0;
    double loadpower = 0.0;

    shuntvoltage = ina.getShuntVoltage_mV();
    busvoltage = ina.getBusVoltage_V() * 1000;
    current_mA = ina.getCurrent_mA();
    loadvoltage = busvoltage + shuntvoltage;
    loadpower = abs(current_mA * loadvoltage / 1000.0);
    if (loadpower == 0.0) {
        current_mA = 0;
        loadvoltage = 0;
    }
    json["current(mA)"] = current_mA;
    json["voltage(V)"] = round(loadvoltage / 1000.0, 2);
    json["power(mW)"] = round(loadpower, 0);
}

void fn_sht_display() {
    static bool sht_toggle = true;
    if (sht_toggle) {
        sht.request();
    }
    else {
        double t = sht.readTemperature();
        double h = sht.readHumidity();
        double d = sht.readHeatIndex();
        if (h > 0) {
            json["temperature(c)"] = round(t, 1);
            json["humidity(%)"] = round(h, 0);
            json["heatindex(c)"] = round(d, 1);
        }
    }
    sht_toggle = !sht_toggle;
}

//*******************************************************************************************
// interupt
//*******************************************************************************************

volatile static bool pin1_press = false; // volatile for interupt
volatile static bool pin2_press = false; // volatile for interupt
ICACHE_RAM_ATTR void fn_encoder_pin1_change() {
    volatile static uint32_t encoder_pin1_time = -1;
    pin1_press = !digitalRead(pin_in1);
    if (pin1_press) {
        encoder_pin1_time = millis();
    }
    else {
        volatile uint32_t pin1_holdtime = millis() - encoder_pin1_time;
        // Serial.println(pin1_holdtime);
        if (pin1_holdtime >= 0 && pin1_holdtime < 80) { // debounce
            pin1_press = true;
            if (!pin2_press) {
                // Serial.println("TF");
                fn_handle_encoder(false);
            }
        }
        encoder_pin1_time = -1;
        pin1_press = false;
    }
}

ICACHE_RAM_ATTR void fn_encoder_pin2_change() {
    volatile static uint32_t encoder_pin2_time = -1;
    pin2_press = !digitalRead(pin_in2);
    if (pin2_press) {
        encoder_pin2_time = millis();
    }
    else {
        volatile uint32_t pin2_holdtime = millis() - encoder_pin2_time;
        // Serial.println(pin2_holdtime);
        if (pin2_holdtime >= 0 && pin2_holdtime < 80) { // debounce
            pin2_press = true;
            if (!pin1_press) {
                // Serial.println("FT");
                fn_handle_encoder(true);
            }
        }
        encoder_pin2_time = -1;
        pin2_press = false;
    }
}

ICACHE_RAM_ATTR void fn_but_change() {
    volatile static uint32_t but_presstime = 0;
    volatile static bool but_press = false;
    but_press = !digitalRead(pin_int);
    if (but_press) {
        but_presstime = millis();
    }
    else {
        but_presstime = millis() - but_presstime;
        if (but_presstime < 600) {
            fn_handle_int();
        }
        but_presstime = -1;
    }
}

ICACHE_RAM_ATTR void fn_counter() {
    volatile static uint32_t but_oldtime = 0;
    if (millis() - but_oldtime >= 4) { //debounce max rps = 125
        json["rps"] = round(1000 / 2.0 / (double)(millis() - but_oldtime), 2);
        but_oldtime = millis();
    }
}

//*******************************************************************************************
// http
//*******************************************************************************************

void fn_ntp_sync(int8_t timeZone) {
    configTime(0, 0, "pool.ntp.org", "asia.pool.ntp.org");
    uint32_t beginWait = millis();
    while ((millis() - beginWait < 1000) && (time_ntp < 16 * 60 * 60)) {
        delay(200);
        time_ntp = time(nullptr);
        if (time_ntp > 16 * 60 * 60) {
            time_ntp = time_ntp + 1 + (timeZone * 60 * 60);
            setTime(time_ntp);
            gmtime_r(&time_ntp, &time_tm);
            Serial.print("time: ");
            Serial.println(pad2digit(hour()) + ":" + pad2digit(minute()) + ":" + pad2digit(second()));
        }
    }
}

void fn_handle_root() {
    fn_cors_header();
    server.setContentLength(CONTENT_LENGTH_UNKNOWN);
    server.send(200, "text/html", "");
    server.sendContent("<!DOCTYPE HTML><html><head>");
    server.sendContent("<link rel='stylesheet' href='http://chanchunghoi.com/css/common.css' type='text/css'>");
    server.sendContent("<link rel='stylesheet' href='http://chanchunghoi.com/wemos/esp8266wifi.css' type='text/css'>");
    server.sendContent("<script src='https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js'></script>");
    server.sendContent("<script src='http://chanchunghoi.com/wemos/esp8266wifi.js'></script>");
    server.sendContent("</head><body><div id='json'>");
    server.sendContent(string_json);
    server.sendContent("</div>");
    server.sendContent("</body></html>");
}

void fn_handle_json() {
    fn_cors_header();
    server.setContentLength(string_json.length());
    server.sendHeader("cache-control", "max-age=1");
    server.send(200, "application/json", string_json);
}

void fn_handle_notfound() {
    String message = "File Not Foundnn";
    message += "URI: ";
    message += server.uri();
    message += "nMethod: ";
    message += (server.method() == HTTP_GET) ? "GET" : "POST";
    message += "nArguments: ";
    message += server.args();
    message += "n";
    for (uint8_t i = 0; i < server.args(); i++) {
        message += " " + server.argName(i) + ": " + server.arg(i) + "n";
    }
    server.send(404, "text/plain", message);
}

void fn_handle_favicon() {
    server.send(200, "image/x-icon",
        "<link rel="icon" href="data:;base64,=">");
}

void fn_cors_header() {
    server.sendHeader("Access-Control-Allow-Methods", "PUT,GET,POST,OPTIONS");
    server.sendHeader("Access-Control-Allow-Origin", "*");
}

軟件比硬件容易開發,例如 router 咁只需要用網頁作為介面而唔需要電子面版;喺 ESP8266 加入 web server 一樣做到同樣效果。ESP8266WebServer 比 Arduino web server 簡單同功能更强大,但絶對比唔上 Apache 之類嘅 web server。Arduino 係 C++ compiled language,比起 javascript 之類嘅 interpreted language 有高效能嘅優點,但比 javascript 難寫又需要 compile 嘥時間,所以最好盡量用 ESP8266 WebServerJSON data format 輸出到 Apache,用一個網頁整合幾部 Wemos D1 嘅数据。

#define BLYNK_TEMPLATE_ID "TMPLLb--****"
#define BLYNK_DEVICE_NAME "inlet"
#define BLYNK_AUTH_TOKEN "*********************************"
#include <fn_wemos_d1.h>
const char mdns[] = "inlet";

//*******************************************************************
//   Instantiate class objects, global var and Function Prototypes
//*******************************************************************

uint8_t blynk_v5 = 0; // intake pwm fan speed control
BLYNK_CONNECTED() { Blynk.syncAll(); }
BLYNK_WRITE(V5) { blynk_v5 = param.asInt(); }

//*******************************************************************
//   setup
//*******************************************************************
void setup() {
  ENABLE_BLYNK = 1;
  fn_wemos_blynk_begin(mdns, BLYNK_AUTH_TOKEN);
  analogWrite(pin_pwm, 191);
  json["pwm"] = 191;
}
//*******************************************************************
//   loop
//*******************************************************************

ICACHE_RAM_ATTR void fn_handle_int() {}
ICACHE_RAM_ATTR void fn_handle_encoder(bool encoder_cw) {}
void fn_loop_1ms() {}
void fn_loop_20ms() {}
void fn_loop_1000ms() {
  //auto adjust intake fan speed
  if (blynk_v5 > 0) {
    if (json["heatindex(c)"].as<double>() > 32 && json["heatindex-d(c)"].as<double>() < -2 && json["heatindex-d(c)"].as<double>() >= -4) {
      analogWrite(pin_pwm, constrain(blynk_v5 - 48, 77, 255));
      json["pwm"] = constrain(blynk_v5 - 48, 77, 255);
    }
    else if (json["heatindex(c)"].as<double>() > 32 && json["heatindex-d(c)"].as<double>() < -4) {
      analogWrite(pin_pwm, constrain(blynk_v5 - 96, 77, 255));
      json["pwm"] = constrain(blynk_v5 - 96, 77, 255);
    }
    else if (json["heatindex(c)"].as<double>() > 32 && json["heatindex-d(c)"].as<double>() > 0) {
      analogWrite(pin_pwm, constrain(blynk_v5 + 48, 77, 255));
      json["pwm"] = constrain(blynk_v5 + 48, 77, 255);
    }
    else if (json["heatindex(c)"].as<double>() < 23 && json["heatindex-d(c)"].as<double>() > 2 && json["heatindex-d(c)"].as<double>() <= 4) {
      analogWrite(pin_pwm, constrain(blynk_v5 - 48, 77, 255));
      json["pwm"] = constrain(blynk_v5 - 48, 77, 255);
    }
    else if (json["heatindex(c)"].as<double>() < 23 && json["heatindex-d(c)"].as<double>() > 4) {
      analogWrite(pin_pwm, constrain(blynk_v5 - 96, 77, 255));
      json["pwm"] = constrain(blynk_v5 - 96, 77, 255);
    }
    else if (json["heatindex(c)"].as<double>() < 23 && json["heatindex-d(c)"].as<double>() < 0) {
      analogWrite(pin_pwm, constrain(blynk_v5 + 48, 77, 255));
      json["pwm"] = constrain(blynk_v5 + 48, 77, 255);
    }
    else {
      analogWrite(pin_pwm, blynk_v5);
      json["pwm"] = blynk_v5;
    }
  }
}

void fn_loop_8640ms() {
  // get indoor discomfort index value from another device
  http.begin(client, "http://chanchunghoi.com/wemos/curl-bed-heatindex.php");
  uint8_t httpResponseCode = http.GET();
  String payload = "";
  if (httpResponseCode > 0) {
    payload = http.getString();
    if (payload != "") {
      json["discomfort-d(c)"] = cast_s2d(payload) - json["heatindex(c)"].as<double>();
    }
  }
  http.end();

  // send data to Blynk.cloud
  if (json["humidity(%)"].as<uint8_t>() > 0) {
    Blynk.virtualWrite(V1, json["heatindex(c)"].as<double>());
    Blynk.virtualWrite(V2, json["temperature(c)"].as<double>());
    Blynk.virtualWrite(V3, json["humidity(%)"].as<double>());
  }
}

Chrome 或者 Edge browser 已經內置相當唔錯嘅 IDE 功能,而 VS Code 應該係現時 interpreted language 嘅最佳 IDE。AJAX 令 browser 可以以大約一秒 refresh rate 速度不斷 update 網頁,所以 Wemos D1 都係用 1000ms loop 每隔一秒輸出 JSON,同時用 8640ms loop 向 Blynk.cloud 輸出数据制作歷史圖表。

Blynk.cloud web interface & 3 months heat index chart

Reference: