ESP8266 и JSON

Когда-то мной было написано про ENC28J60 и REST-управление реле; а сейчас пришло время затронуть тему шикарного Wi-Fi-модуля ESP-8266. Поскольку модуль шикарный, начнём с недостатков…

Самой замечательной особенностью NodeMCU является лёгкое (ага, лёгкое) несоответствие надписей на плате и реальным порядком расположения пинов. Так, к примеру, GPIO4 на деле висит на пине, обозначенном как D2. Как следствие, без подобной таблицы никак:

Датчик температуры DHT11

Довольно простой в подключении датчик температуры и влажности. Для работы с ним необходима библиотека dht.h (которая есть в Arduino IDE). Сами его контакты идут в следующем порядке (если развернуть лицевой стороной вверх, контактами к себе): VCC, Data, (reserve), GND.

Важное примечание: чтобы DHT11 меньше косячил, необходимо поставить резистор на 10кОм между VCC и пином данных („Data“ на схеме). И конденсаторами его обвешать, так как 3 вольта на длинном проводе ему маловато! Кроме того, датчик довольно медленный (не следует дёргать его чаще чем раз в 2 секунды; см. код ниже).

Сборка датчика и модуля

Получилось нечто вроде такого:

Вообще, NodeMCU немного греется, если делать для неё корпус. Поэтому впоследствии датчик пришлось вынести немного дальше от самого Wi-Fi модуля.

Всё в сумме потребляет 0.05 А тока при напряжении источника 5 В.

Написание кода для микроконтроллера

Всё просто. При запросе по нужному пути, выдаём данные в формате JSON. При ином запросе, выдаём сообщение об ошибке. В случае ошибки датчика, выдаём значение -255.0.

/**
 * NodeMCU temperature meter example.
 *
 * Copyright 2016 Lex http://numidium.ru/
 */

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>

#include <DHT.h>

//LED setup
#define CONNECTLED 5 //pin number 1
#define REQUESTLED 14 // marked as D5

// DHT Setup:
#define DHTPIN 4     // what digital pin we're connected to; GPIO 4 is a pin number 2!
#define DHTTYPE DHT11   // DHT 11

const char *ssid = "WiFiNetworkName";
const char *password = "WiFiPassword";

// Content types:
const char *plainText = "text/plain";
const char *jsonText = "application/json";

// Initialize DHT sensor.
DHT dht(DHTPIN, DHTTYPE);

ESP8266WebServer server ( 80 );

float temperature = -255.0f;
float humidity = -255.0f;
float heatIndex = -255.0f;
unsigned long lastMillis = 0;

void handleRoot() {
  connectStart();

  String message = "{\n";
  message += "  \"uptime\": "; message += millis(); message += ",\n";
  message += "  \"temperature\": "; message += temperature; message += ",\n";
  message += "  \"humidity\": "; message += humidity; message += ",\n";
  message += "  \"heatIndex\": "; message += heatIndex; message += ",\n";
  message += "  \"freeMemory\": "; message += ESP.getFreeHeap(); message += "\n";
  message += "}";

server.send ( 200, jsonText, message );

  connectStop();
}

void connectStart() {
  digitalWrite(CONNECTLED, HIGH);  
}
void connectStop() {
  digitalWrite(CONNECTLED, LOW);
}

void updateStart() {
  digitalWrite(REQUESTLED, HIGH);  
}
void updateStop() {
  digitalWrite(REQUESTLED, LOW);
}

void handleNotFound() {
String message = "Not Found\n\n";
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, plainText, message );
}

void setup ( void ) {
  pinMode(CONNECTLED, OUTPUT);
  pinMode(REQUESTLED, OUTPUT);

  dht.begin(); // Start DHT

Serial.begin ( 9600 );
WiFi.begin ( ssid, password );
Serial.println ( "" );

// Wait for connection
while ( WiFi.status() != WL_CONNECTED ) {
delay ( 500 );
Serial.print ( "." );
}

Serial.println ( "" );
Serial.print ( "Connected to " );
Serial.println ( ssid );
Serial.print ( "IP address: " );
Serial.println ( WiFi.localIP() );

server.on ( "/", handleRoot );

server.onNotFound ( handleNotFound );
server.begin();
Serial.println ( "HTTP server started" );
}

void updateTemperature() {
  if (millis() - lastMillis >= 2000) {    
    updateStart();

    // Read temperature as Celsius (the default)
    temperature = dht.readTemperature();
    // Sensor readings may also be up to 2 seconds 'old' (its a very slow sensor)
    humidity = dht.readHumidity();

    // Check if any reads failed and exit early (to try again).
    if (isnan(humidity) || isnan(temperature)) {
      Serial.println("Failed to read from DHT sensor!");
      temperature = -255.0f;
      humidity = -255.0f;
      heatIndex = -255.0f;
    } else {
      heatIndex = dht.computeHeatIndex(temperature, humidity, false); // Farenheit is false
    }

    lastMillis = millis();
    updateStop();
  }
}

void loop ( void ) {
  updateTemperature();

  server.handleClient();
}

Написание кода на стороне сервера

В принципе, NodeMCU сам себе сервер. Так что, можно вполне себе поставить web-приложение прямо на микроконтроллере. Да, памяти на весь HTML не хватит; но выдать псевдо-страницу, содержащую как данные так и ссылку на скрипт, создающий оформление (используя, скажем, такой способ: Загрузчик для букмарклетов на javascript) вполне возможно.

Тем не менее, мне показалось не очень хорошей идеей выставлять в общий доступ саму NodeMCU; поэтому на роутере просто был проброшен нужный порт на микроконтроллер, а на хостинге вздумалось написать скрипт, который станет эти данные собирать и складывать в базу данных.

Поскольку хостинг кроме php толком ничего не разумеет, пришлось потратить полчаса, чтобы выучить php. Вот что получилось (сам запуск скрипта забит в cron хостинга и запускается каждые 30 минут; запись в crontab: 0,30 ):

<?php

$url = "http://ROUTER_URL:8090/";
$curl = curl_init($url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 15);

$error = false;
if(curl_exec($curl) === false) {
  $error = true;
} else {
  $output = curl_exec($curl);
}

curl_close($curl);

if ($error === true) {
  die("Error happened while downloading data!");
}

$data = json_decode($output, true);
if ($data['temperature'] == -255) {
  die("Chip error!");
}

// Define default settings:
$servername = "localhost";
$username = "USERNAME";
$password = "PASSWORD";
$dbname = "DATABASE_NAME";

// Create connection:
$conn = new mysqli($servername, $username, $password, $dbname);
// Check:
if ($conn->connect_error) {
  die("Connection failed: " . $conn->connect_error);
}

$uptime = $data['uptime'];
$temperature = $data['temperature'];
$humidity = $data['humidity'];
$heatIndex = $data['heatIndex'];
$freeMemory = $data['freeMemory'];

$conn->query("SET NAMES utf8");
$sql = "INSERT INTO `" . $dbname . "`.`temperature` (`time`, `uptime`, `temperature`, `humidity`, `heatIndex`, `freeMemory`) "
  . "VALUES ("
  . "NULL, "
  . "'". $uptime
  . "', '" . $temperature
  . "', '" . $humidity
  . "', '" . $heatIndex
  . "', '" . $freeMemory
  . "'"
  . ")";
$result = $conn->query($sql);

?>

Соответственно, для получения данных клиентом, на хостинге лежит ещё один скрипт, вытаскивающий нужные строки из таблицы по запросу. Приводить его не буду по причине тривиальности оного.

Код клиента на javascript

Лишний раз цитировать не вижу смысла. Действующий вариант, если надо, ниже.

Вообще, чистая импровизация. Следовало либо оформлять код модулем; либо делать из него полноценный объект, при создании которого указываются области для вывода данных. А тут получился не модуль, ни универсальный объект. …просто код был написан стихийно и за десяток-другой минут; сейчас уже править лень.

/**
 * Temperature meter application code itself.
 * Copyright 2016 Lex http://numidium.ru/
 *
 * Well, writing this application not as a "module", but as a "class" is a bad
 * idea (assuming there is no parameters such as output settings, so there is
 * no way to use many instances of "App object" on the same page).
 *
 * ...But this was an improvization ^_^
 */
var App = function() {
  this.version = '0.1.2';
  this.URL = 'http://skippy.su/apps/temperature/tempclient.php?mode=last&max=50';
  this.data = [];
};

/**
 * Transforms uptime to human-readable string.
 *
 * @param {Number} uptime Uptime in unix time format.
 *
 * @returns {String}
 */
App.prototype.uptimeToDays = function(uptime) {
  var delta = uptime/1000;

  //calculate and substract days, hours, minutes, seconds:
  var days = Math.floor(delta/86400);
  delta -= days*86400;

  var hours = Math.floor(delta/3600) % 24;
  delta -= hours*3600;

  var minutes = Math.floor(delta/60)%60;
  delta -= minutes*60;

  var seconds = Math.floor(delta%60);

  return [
    this.plural(days, ['день', 'дня', 'дней']),
    this.plural(hours, ['час', 'часа', 'часов']),
    this.plural(minutes, ['минута', 'минуты', 'минут']),
    this.plural(seconds, ['секунда', 'секунды', 'секунд']),
  ].join(' ');
};

/**
 * Writes words after numbers in the correct way.
 * @param {Number} num The number.
 * @param {Array} numerals Numerals array,
 *                         like ['page', 'of page', 'pages'].
 * @returns {String}
 */
App.prototype.plural = function(num, numerals) {
  var numstr = num + '';
  var selectedNum;
  if (numstr.length == 2) {
    selectedNum = parseInt(numstr.slice(-1));
  } else if (numstr.length > 2) {
    selectedNum = parseInt(numstr.slice(-2));
  } else selectedNum = num;

  // Exceptions:
  if (selectedNum == 1) {
    return num + ' ' + numerals[0];
  } else if (selectedNum >= 2 && selectedNum <= 4) {
    return num + ' ' + numerals[1];
  } else if (selectedNum > 20) {
    var lastNum = parseInt((selectedNum+'').slice(-1));
    if (lastNum == 1) return num + ' '+numerals[0];
    if (lastNum >= 2 && lastNum <= 4) return num + ' '+numerals[1];
  }

  return num + ' '+numerals[2];
};

/**
 * Transforms relative uptime to human-readable absolute date.
 *
 * @param {Number} uptime    Uptime in unix time format.
 * @param {Number} localTime Unix time of local time at the moment of getting
 *                           `uptime` value.
 *
 * @returns {String}
 */
App.prototype.uptimeToDate = function(uptime, localTime) {
  return strftime('%F %T', new Date(localTime-uptime));
};

/**
 * Re-draw graphs.
 */
App.prototype.updateGraphs = function() {
  var temperatureData = [];
  var heatIndexData = [];
  var humidityData = [];
  var uptimeData = [];
  var freeMemoryData = [];

  var timeLabels = [];

  var uptimeToFractionalDays = function(uptime) {
    var delta = uptime/1000;
    var result = ((delta/60)/60)/24;
    result = Math.round(result * 100) / 100;

    return result;
  };

  var data = this.data;
  for(var i=0; i<data.length; i++) {
    var elem = data[i];
    temperatureData.push(elem.temperature);
    humidityData.push(elem.humidity);
    heatIndexData.push(elem.heatIndex);
    uptimeData.push(uptimeToFractionalDays(elem.uptime));
    freeMemoryData.push(elem.freeMemory);

    timeLabels.push(strftime('%F %H:%M', new Date(elem.time)));
  }

  var temperatureChart = new Chart("tempChart", {
      type: 'line',
      data: {
          labels: timeLabels,
          datasets: [
            {
              label: 'Температура (°C)',
              data: temperatureData,
              backgroundColor: 'rgba(255, 0, 0, .2)',
              borderColor: 'rgba(128, 0, 0, 1)',
              borderWidth: 1,
              yAxisID: 'y-axis-0'
            },{
              label: 'Влажность (%)',
              yAxisID: 'y-axis-1',
              data: humidityData,
              backgroundColor: 'rgba(0, 0, 255, .2)',
              borderColor: 'rgba(0, 0, 128, 1)',
              borderWidth: 1
            },{
              label: 'Ощущается как (°C)',
              data: heatIndexData,
              backgroundColor: 'rgba(255, 255, 0, .2)',
              borderColor: 'rgba(128, 128, 0, 1)',
              borderWidth: 1,
              yAxisID: 'y-axis-0'
            }
          ]
      },
      options: {
        scales: {
          yAxes: [{
            id: 'y-axis-0'
          },{
            id: 'y-axis-1'
          }]
        }
      }
  });

  var technicalChart = new Chart("technicalChart", {
      type: 'line',
      data: {
          labels: timeLabels,
          datasets: [
            {
              label: 'Uptime (дни)',
              data: uptimeData,
              backgroundColor: 'rgba(255, 255, 0, .2)',
              borderColor: 'rgba(128, 128, 0, 1)',
              borderWidth: 1,
              yAxisID: 'y-axis-0'
            },{
              label: 'Свободно памяти (байт)',
              data: freeMemoryData,
              backgroundColor: 'rgba(0, 255, 0, .2)',
              borderColor: 'rgba(0, 128, 0, 1)',
              borderWidth: 1,
              yAxisID: 'y-axis-1'
            }
          ]
      },
      options: {
        scales: {
          yAxes: [{
            id: 'y-axis-0'
          },{
            id: 'y-axis-1'
          }]
        }
      }

  });
};

/**
 * Re-draw summary section of screen.
 */
App.prototype.updateSummary = function() {
  var last = this.data[this.data.length-1];

  $('#statsDate').html(strftime('%F %T', new Date(last.time)));
  $('#statsTemperature').html(last.temperature+'<span>&deg;C</span>');
  $('#statsHumidity').html(last.humidity+'<span>%</span>');
  $('#statsHeatIndex').html(last.heatIndex+'<span>&deg;C</span>');
  $('#statsUptime').html([
    this.uptimeToDays(last.uptime),
    '<br /><span>(с ',
    this.uptimeToDate(last.uptime, last.time),
    ')</span>'
  ].join(''));
  $('#statsFreeMemory').html(last.freeMemory+' <span>байт</span>');
};

/**
 * Update data from server and fire callback if specified.
 *
 * @param {Function} cb Call back.
 */
App.prototype.updateData = function(cb) {
  var self = this;
  $.get(this.URL, function(data) {
    self.data = data;
    if (typeof cb !== 'undefined') cb();
  }, 'json');
};

Ну и для старта что-то вроде такого:

// On document ready...
$(function(){
  var meter = new App();
  $('#programVersion').text(meter.version);
  meter.updateData(function() {
    meter.updateSummary();
    meter.updateGraphs();
  });
});

Графики рисует chart.js; местами используется strftime.js для оформления даты.

Modified: 2017-03-05 00:00:00