さくら IoT Platform と ESP8266 と GPS とでロケーションモニタリングしてみる(前編)

 さくらの IoT Platform(ベータ版)に ESP8266 を接続して公式サンプルを動かすところまで、この前の週末にやりました。(記事)
 とりあえず ESP8266 であっても Arduino の一員として、全く問題なく動いてしまうことを知りましたので、もうちょっと応用させてみようと思います。


 この手の IoT にはセンサー類を接続するのが王道なので、気温や気圧センサーでも良かったのですが、とりあえず GPS いっちゃいましょうか。
 10年以上も前の今より若かった頃、当時はあまり一般的でなかった「位置情報の入った写真」を作ることに興味を持ち、(今では少々遠ざかってますが)あちこち山歩きのお供に色んな ポータブルGPS を持ち歩いて測位&性能比較しつつ、帰宅後に写真に位置情報を追加したうえで記録として公開したりしてました。
この写真の景色はここへ行くと見れるよ、って意味で。( こちら から見れます)


 宇宙戦艦ヤマトで育った世代としては、地球の軌道を周回する人工衛星からの電波を拾って位置を特定する〜という壮大なロマンに、どうしても魅せられてしまうのですよ。


 そこで今回の「ロケーションモニタリング」というテーマになるわけですが、ぶっちゃけ、ストーカー機能です(笑)
 いや、人のストーカーじゃなくて、たとえば紛失させてはいけない大事な荷物に予め仕込んでおき、旅行や出張中になくしたら探してみる、とか、愛車に仕込んでおいて盗難に備える、とか、そーいう目的ですよ、あくまで。


 探せばスマホのアプリでもある気がしますが、それじゃ面白くないので自分で作ってみることにします。
 せっかく ESP8266 と組み合わせるので、GPS からの電波が拾えない場所でも位置の特定ができるよう、周辺の SSID や BSSID も一緒に収集してしまいましょう。
「SSID 位置特定」でググる と関連情報を入手できますが、特に2つ目か3つ目に出てくる MACアドレスから位置特定するやつ が、なかなか秀逸です。


http://dl.ftrans.etr.jp/?2d70eb397a6e475983b864c0c64ef38278ee014a.png


 ひとつ前の記事GPS を追加した以外にも少々変わってます。
 前の記事を見て回路を組まれた方は差分がほしいと思うので列挙しますと

  • さくら IoT Platform 通信ボード内で I2C がプルアップされている気がするので外付けを廃止

(重要追記)通信ボードの初期出荷ファームでは確かにプルアップ有効化されていたのですが、これは正式な仕様じゃなかったらしく、次版ファームからはプルアップ無効化されてしまいました。外付けプルアップが必要です。

  • ESP8266 側の I2C プルアップは、AQM1602A のキット基板にある 10kΩ をジャンパーショートして利用
  • レベルコンバーター FXMA2102 のピン(A0-B0/A1-B1)を入れ替え。(実装上の都合)

 これが変更箇所で、新規に増えたのが

  • 「みちびき」対応な安価な 秋月GPSキット をシリアル接続
  • さくらちゃんの「ねぇねぇ起きてぇ」という甘いボイス(WAKE_OUT:LOW→HIGH)で、ESP8266 を deep sleep から叩き起こす回路
  • さくらちゃんに「先に寝てていいよ」と優しい声(WAKE_IN:LOW→HIGH)をかける回路(ただし現段階のファームでは、さくらちゃんは寝てくれない)

です。最後のキモイ2つは、そこそこ需要あるんじゃないかなーって気がしますが。


 GPS から ESP8266 の向きに辿っていくとスイッチありますが、ESP8266 の RXD(受信) に普段は GPS の TX(送信) を繋げておくものの、パソコンからプログラムを書き込みするときだけ パソコンの TX に繋ぎ換える、っていう気持ちをスイッチに表しただけでして、実際にスイッチを配置する必要はありませんので、念のため。


 ESP8266 の deep sleep は消費電流が数十μAと非常に優秀なのですが、IO16 を RESET ピンに直結した上で、予め起動時刻をタイマーで仕掛けておいてから deep sleep しないといけないという仕様になってます。

  • deep sleep 中もタイマーのカウンターだけは生きていて、仕掛けた時間になると IO16 が HIGH→LOW になる。
  • IO16 ピンを RESET ピンに繋いでおくことで RESET ボタンが押されたときと同じ動作となり deep sleep から抜けて起動する

 ということで分かったので、NPNトランジスタ(2SC1815等)で IN16 が LOW かつ WAKE_OUT が HIGH のときのみ RESET を LOW に引っ張るようにしました。
 リセットかかると IO16 は HIGH になるので、RESET ピンは LOW から解放されます。
 ベース電圧<エミッタ電圧 で使うのはトランジスタを壊す原因の一つですが、3.3-1.8=1.7V くらいなら大丈夫です。(2SC1815の場合、VEBO:5V)


 これとは逆に ESP8266 側が主導権を握って、通信ボードを Sleep させるための回路が左上の 2SA1015 付近です。
 ESP8266 の起動時にプルダウンしておかないといけない IO15 を利用しました。
 WAKE_OUT が IO16 なので、数字のゴロ的にもちょうどいいですし。


 ただ現段階の通信ボードのファームウェアでは deep sleep に対応していないらしく、この部分は試せていないので、とりあえず省いてしまっても大丈夫かと思います。
 ちなみに、意図としては IO15 を HIGH にすると通信ボードが deep sleep に突入する、というものです。


 ハード面とこれくらいにしてソフトのほう。
 今回のテーマを実現するにあたり、2つのライブラリを作りました。


 まず1つ目が I2C 型のキャラクタ液晶 AQM1602A を制御するやつ。
 公式ライブラリにもあるみたいですが、printf 使えないと不便なので、mbed 時代に使ってたやつを I2C で動くように改造してやりました。
 Stream を継承してるので、printf が書けます。


 これには元ネタがあって、どっかにあったソースに手を加えたので厳密には著作権表示しないといけないと思うんですが、どこを元にしたのか記憶になく・・・
 まぁソースの半分近くは弄ってるので勘弁してください。


TextLCD.h

#ifndef TEXTLCD_H
#define TEXTLCD_H

#include <Wire.h>

class TextLCD : public Stream {
public:
    enum LCDType {
        LCD8x2     /**< 8x2 LCD panel (default) */
        , LCD16x2     /**< 16x2 LCD panel */
        , LCD20x2     /**< 20x2 LCD panel */
        , LCD20x4     /**< 20x4 LCD panel */
    };

    TextLCD(LCDType type = LCD16x2);
    TextLCD(int SDA, int SCL, LCDType type = LCD16x2);

    void locate(int column, int row);

    /** Clear the screen and locate to 0,0 */
    void cls();

    int rows();
    int columns();
	void writeFont(char addr, char *buf);
	
protected:

	void init();
	
    // Stream implementation functions
    virtual int _putc(int value);
    virtual int _getc();

    virtual int peek(){;};
    virtual size_t write(uint8_t);
    virtual int available() {;};
    virtual int read(){;};
    virtual void flush(){;};

    int address(int column, int row);
    void character(int column, int row, int c);
    void writeCommand(uint8_t *command, size_t len);
    void writeCommand(uint8_t command);
    void writeData(uint8_t *data, size_t len);
    void writeData(uint8_t data);

    LCDType _type;

    int _column;
    int _row;
};

#endif

TextLCD.cpp

#include "TextLCD.h"
#include <Arduino.h>
#include <Wire.h>
#define ADDR 0x3e

TextLCD::TextLCD(LCDType type) : _type(type)  {
  Wire.begin();
  init();
}

TextLCD::TextLCD(int SDA, int SCL, LCDType type) : _type(type)  {
  Wire.begin(SDA, SCL);
  init();
}

void TextLCD::init()
{
  delay(40);
  uint8_t cmd_init[] = {0x38, 0x39, 0x14, 0x70, 0x56, 0x6c, 0x38, 0x0c};
  writeCommand(cmd_init, sizeof(cmd_init));
  cls();
}

void TextLCD::character(int column, int row, int c) {
    int a = address(column, row);
    writeCommand(a);
    writeData(c);
}

void TextLCD::cls() {
    writeCommand(0x01); // cls, and set cursor to 0
    delayMicroseconds(1080);
    locate(0, 0);
}

void TextLCD::locate(int column, int row) {
    _column = column;
    _row = row;
}

int TextLCD::_putc(int value) {
    if (value == '\n') {
        _column = 0;
        _row++;
        if (_row >= rows()) {
            _row = 0;
        }
    } else {
        character(_column, _row, value);
        _column++;
        if (_column >= columns()) {
            _column = 0;
            _row++;
            if (_row >= rows()) {
                _row = 0;
            }
        }
    }
    return value;
}

int TextLCD::_getc() {
    return -1;
}

size_t TextLCD::write(uint8_t data)
{
  _putc(data);
}

void TextLCD::writeCommand(uint8_t *cmd, size_t len) {
  size_t i;
  for (i=0; i<len; i++) {
    Wire.beginTransmission(ADDR);
    Wire.write(0x00);
    Wire.write(cmd[i]);
    Wire.endTransmission();
    delayMicroseconds(27);
  }
}

void TextLCD::writeCommand(uint8_t cmd) {
  writeCommand(&cmd, 1);
}

void TextLCD::writeData(uint8_t *cmd, size_t len) {
  size_t i;
  for (i=0; i<len; i++) {
    Wire.beginTransmission(ADDR);
    Wire.write(0x40);
    Wire.write(cmd[i]);
    Wire.endTransmission();
    delayMicroseconds(27);
  }
}

void TextLCD::writeData(uint8_t data) {
  writeData(&data, 1);
}

void TextLCD::writeFont(char addr, char *buf) {
  addr = addr << 3;
  writeCommand(addr | 0x40);
 
  for(int i = 0; i < 8; i++)
    writeData((int)buf[i]);
};

int TextLCD::address(int column, int row) {
    switch (_type) {
        case LCD20x4:
            switch (row) {
                case 0:
                    return 0x80 + column;
                case 1:
                    return 0xc0 + column;
                case 2:
                    return 0x94 + column;
                case 3:
                    return 0xd4 + column;
            }
        case LCD8x2:
        case LCD16x2:
        case LCD20x2:
        default:
            return 0x80 + (row * 0x40) + column;
    }
}

int TextLCD::columns() {
    switch (_type) {
        case LCD20x4:
        case LCD20x2:
            return 20;
        case LCD16x2:
            return 16;
        default:
            return 8;
    }
}

int TextLCD::rows() {
    switch (_type) {
        case LCD20x4:
            return 4;
        case LCD8x2:
        case LCD16x2:
        case LCD20x2:
        default:
            return 2;
    }
}

 たぶん斜め読みされる方が大多数だと思うので気がつかないと思いますが、writeFont って何だ?って気づかれた方、お目が高い!
 単純なロケーションモニタリングだけじゃ面白くないので、電波強度の測定もやってしまおうということで準備しておきました。


 次に GPS 関連。
 これも探すと見つかると思うのですが、最初から自分で書いてしまいました。
 オリジナルです。
 著作権の所有者は私です(笑)


 すべての NMEA センテンスを読んでるわけじゃないですが、チェックサムは正しく計算して照合してますし、さくら IoT の送信パラメータとして用意されてる offset を意識した伏線も張ってございます。


nmea.h

#ifndef WAKWAK_NMEA
#define WAKWAK_NMEA

#include <stdio.h>

class NMEA  {
    public:
      bool  read(const short rx);

      float longitude;  // 経度
      float latitude;   // 緯度
      float altitude;
      float speed;
      float courseover;
      float geoid;

      unsigned long millis_speed;
      unsigned long millis_latitude_longitude;
      unsigned long millis_courseover;
      unsigned long millis_satellites;
      unsigned long millis_altitude_geoid;

    protected:
      static char checksum(const char *nmea, const size_t length);
      static void latitude_longitude(float *latitude, float *longitude, const char ns, const char ew);
      static size_t split(const char *msg, const unsigned char idx, char *result, const size_t result_maxlen);

      bool analyze(const char *nmea);
};

#endif

nmea.cpp

#include "nmea.h"
#include <math.h>
#include <string.h>
#include <stdlib.h>
#include <Arduino.h>

bool NMEA :: read(const short rx)
{
  static char nmea[100];
  static unsigned char nmea_idx = 0;

  if(rx < 0)  return false;
  
  if(rx == 0x0d) {
    nmea[nmea_idx] = 0;
    analyze(nmea);
  } else {
    if(rx == '$') 
      nmea_idx = 0;
    nmea[nmea_idx] = rx;
    if(nmea_idx + 1 < sizeof(nmea))
      nmea_idx ++;
  }
  return true;
}

char NMEA :: checksum(const char *nmea, const size_t length)
{
  char c = 0;
  for(int i = 0; i < length; i++)
      c ^= nmea[i];
  return(c);   
}

void NMEA :: latitude_longitude(float *latitude, float *longitude, const char ns, const char ew)  {
    float degrees = floor(*latitude / 100.0f);
    float minutes = *latitude - (degrees * 100.0f);
    *latitude = degrees + minutes / 60.0f;    
    degrees = floor(*longitude / 100.0f);
    minutes = *longitude - (degrees * 100.0f);
    *longitude = degrees + minutes / 60.0f;

    if(ns == 'S') *latitude  *= -1.0;
    if(ew == 'W') *longitude *= -1.0;
}

size_t NMEA :: split(const char *msg, const unsigned char idx, char *result, const size_t result_maxlen)
{
  int i = idx;
  int x1 = 0;
  while(i > 0)
  {
    i--;
    while(msg[x1++] != ',' && x1 < strlen(msg));
  }

  int x2 = x1;
  while(msg[x2++] != ',' && msg[x2] != '*' && x2 < strlen(msg));

  if(x1 < strlen(msg) && x2 < strlen(msg) && result_maxlen > x2 - x1)
  {
    strncpy(result, &msg[x1], x2 - x1 - 1);
    result[x2 - x1 - 1] = 0;
    return strlen(result);
  } else  
    return 0;
}

bool NMEA :: analyze(const char *nmea)
{
  unsigned char nmea_len = strlen(nmea);
  if(nmea_len > 3 && nmea[nmea_len - 3] == '*')
  {
    char cs[3];
    sprintf(cs, "%02X", checksum((const char *)&nmea[1], nmea_len - 4));
    if(strcmp(&nmea[nmea_len - 3], cs))
    {
      // チェックサム合格
      Serial.print("  ");
      Serial.println(nmea);
      
      char ns = 0, ew = 0;
      int lock = 0;
      int satellites = 0;
      char status = 0;

      char buf[20];
      unsigned long now = millis();
      if(!strncmp(nmea, "$GPVTG,", 7))
      {
        if(split(nmea, 7, buf, sizeof(buf)) > 0)  speed = atof(buf);
        millis_speed = now;
      } else if(!strncmp(nmea, "$GPRMC,", 7) && split(nmea, 2, buf, sizeof(buf)) == 1 && buf[0] == 'A')
      {
          if(split(nmea, 3, buf, sizeof(buf)) > 0)  latitude = atof(buf);
          if(split(nmea, 4, buf, sizeof(buf)) > 0)  ns = buf[0];
          if(split(nmea, 5, buf, sizeof(buf)) > 0)  longitude = atof(buf);
          if(split(nmea, 6, buf, sizeof(buf)) > 0)  ew = buf[0];
          if(split(nmea, 7, buf, sizeof(buf)) > 0)  speed = atof(buf);
          if(split(nmea, 8, buf, sizeof(buf)) > 0)  courseover = atof(buf);
          latitude_longitude(&latitude, &longitude, ns, ew);
          millis_latitude_longitude = now;
          millis_speed = now;
          millis_courseover = now;
      } else if (!strncmp(nmea, "$GPGGA,", 7) && split(nmea, 6, buf, sizeof(buf)) == 1 && buf[0] != '0')
      {
        if(split(nmea, 2, buf, sizeof(buf)) > 0)  latitude = atof(buf);
        if(split(nmea, 3, buf, sizeof(buf)) > 0)  ns = buf[0];
        if(split(nmea, 4, buf, sizeof(buf)) > 0)  longitude = atof(buf);
        if(split(nmea, 5, buf, sizeof(buf)) > 0)  ew = buf[0];
        if(split(nmea, 7, buf, sizeof(buf)) > 0)  satellites = atoi(buf);
        if(split(nmea, 9, buf, sizeof(buf)) > 0)  altitude = atof(buf);
        if(split(nmea,11, buf, sizeof(buf)) > 0)  geoid = atof(buf);
        latitude_longitude(&latitude, &longitude, ns, ew);
        millis_latitude_longitude = now;
        millis_satellites = now;
        millis_altitude_geoid = now;
      }
    }
  }
}

 苦労したのは sscanf が使えないという点
 コンパイルは通るのだからヘッダー(.h)は書かれているくせに、本体が実装されていなくてリンクでコケるという・・・
 代わりに、指定した項目位置を抜き出す関数を split という名前で自作しました。


 たいへん長くなりましたので本体は次の記事に書きます。
 
 
 焦らすわけじゃないですが、試験中の写真だけ貼っときますねー


http://dl.ftrans.etr.jp/?25fcdc26a12a49f5b385d8692b66a17ba1c63a13.jpg


(追記)2016/11/25
 続きを書きました。中編 をどうぞ


(追記)2016/11/27
 上で書いた「ESP8266 を deep sleep から叩き起こす」回路の部分ですが、テスターで消費電流を実測したら、ふだんよりは消費が少ないものの、10mA ほど食ってしまってました。
 ちょっと不本意なので、もう少しいい方法を考えます。


(追記)2016/12/01
 最終的に deep sleep からではうまく復帰させることができまなかったため、light sleep で妥協しました。
 後編 をどうぞ