今年はESP8266を使って色々と工作をしている。WEMOS / mini D1タイプが中華から買うと数ドル程度と安く、USBや5Vもオンボードで使い勝手も良いのだが、技適が無いのが難点だ。まぁ、ESP8266はWiFiを使わなくても小型高性能Arduino互換機としてでも使えるし、WiFiもFCC / WiFi Allianceロゴが付いているので90日ルールなら使える(多分)・・・といった細かい事はさておき、今回はシリアルJpegカメラと組み合わせてタイムラプスカメラを作ってみる。
ESP8266は信号線の電圧が3.3vだが、Arduino系の周辺デバイスが豊富に使える。ESP8266で何が出来るか模索している時に、Arduinoで使えるシリアルカメラと言う物を見つけた。シリアルカメラは撮影した画像をJpegデータ形式に落とし、シリアル通信形式でJpegデータを転送する事が出来るカメラモジュールである。

シリアル Jpeg カメラ ArduinoやESP8266等の電子工作用
ArduinoやESP8266はカメラの生データを扱える程の処理能力は無いが、シリアルカメラで生成されたJpeg画像データをシリアル経由で読み出しSDカードに保存したりネットワーク経由で転送したりする程度は出来る。シリアルカメラの解像度は最大で640×480と低く画質も良くなくJpeg画像の転送速度も30秒近くかかる等いまいちな点が多いが、難しい画像処理をせずにカメラ撮影を行う事が可能だ。
ESP8266にはDeep Sleepモードがあり、このモードでは消費電力が約10μA(マイクロアンペア)と殆ど電力を消費しない。
Deep Sleepモードに入る際時間を指定しておくと、その時間にリセットがかかり起動し処理を行う事が出来るため、定期的に何かをする動作に使う事が可能だ。
このシリアルカメラモジュールとESP8266を組み合わせて1時間毎に写真をサーバーに送信するタイムラプスカメラを作ってみる。フローは以下の通り。
- 起動
- カメラ電源オン
- カメラ撮影
- カメラから画像データをESP8266のストレージエリアへ読み込み
- カメラ電源オフ
- ネットワーク経由でサーバーに画像を転送
- 1時間Deep Sleepに入る
カメラ画像読み出し時直接サーバーに転送する事も可能だが、一度ストレージエリアに読み出した方が消費電力が多いワイヤレス通信が一瞬で済む。OLEDディスプレイ等の表示を付けるとデバッグ時には便利だが、普段は不要のためESP8266についているLEDを光らせて状態が分かるようにした。
コードは下記の通り。
#include <Arduino.h> #include <ESP8266WiFi.h> #include <ESP8266WiFiMulti.h> #include <WiFiClient.h> #include <ESP8266WebServer.h> #include <ESP8266HTTPClient.h> #include <WebSocketsServer.h> #include <Hash.h> #include <ESP8266mDNS.h> #include <WiFiUdp.h> #include <FS.h> #include <Adafruit_VC0706.h> #include <SoftwareSerial.h> #include <Wire.h> #include <SPI.h> #include <Adafruit_GFX.h> #include <ESP_Adafruit_SSD1306.h> #define PIN_D1 5 #define PIN_D2 4 #define PIN_D3 0 #define PIN_D4 2 #define PIN_D5 14 #define PIN_D6 12 #define PIN_D7 13 #define PIN_D8 15 #define BUILTIN_LED 2 SoftwareSerial cameraconnection = SoftwareSerial(PIN_D3, PIN_D8); Adafruit_VC0706 cam = Adafruit_VC0706(&cameraconnection); #define CAM_PWR PIN_D6 #define cam_interval 3780 //camera interval time in sec. 3min faster at 3600 sec, adding 180sec for adjust. //#define _I2CLED #ifdef _I2CLED static const uint8_t ALT_SDA = PIN_D2; static const uint8_t ALT_SCL = PIN_D1; Adafruit_SSD1306 display(-1); char oledstrbuff[64]; #if (SSD1306_LCDHEIGHT != 64) #error("Height incorrect, please fix Adafruit_SSD1306.h!"); #endif #endif ESP8266WiFiMulti WiFiMulti; int linefeeder = 0; void oled_drawstr(int line,char *text){ #ifdef _I2CLED //strcpy(oledstrbuff,text); display.setTextSize(1); display.setTextColor(WHITE); display.setCursor(0,line*10); display.print(text); display.display(); #endif } void oled_drawstr(char *text){ oled_drawstr(linefeeder++,text); } void oled_overwritestr(int line, char *text){ #ifdef _I2CLED display.fillRect(0, line*10, 100, 10, BLACK); display.setTextSize(1); display.setTextColor(WHITE); display.setCursor(0,line*10); display.print(text); display.display(); #endif } void oled_clear(){ #ifdef _I2CLED display.clearDisplay(); linefeeder = 0; display.display(); #endif } void oled_resetprogress(){ } void oled_progress(int perc){ #ifdef _I2CLED sprintf(oledstrbuff,"Process %d%%",perc); oled_overwritestr(5,oledstrbuff); #endif } void flash_bled(int count){ for (int i=0;i<count;i++){ digitalWrite(BUILTIN_LED, HIGH); //high to turn off delay(100); digitalWrite(BUILTIN_LED, LOW); delay(100); } } bool flipflag = false; void flip_bled(bool flipto){ if (flipflag != flipto){ flipflag = flipto; digitalWrite(BUILTIN_LED, flipflag?HIGH:LOW); } } const char* ssid = "name"; const char* password = "pass"; const char* host = "ESP8266"; int lastwifi; unsigned long lastupdateMillis = 0; void updateScreen(){ unsigned long currentMillis = millis(); if (currentMillis - lastupdateMillis > 500){ //http://www.speedguide.net/faq/how-does-rssi-dbm-relate-to-signal-quality-percent-439 long rssi = WiFi.RSSI(); int siglevel; if (rssi <= -96) siglevel = 1; else if (rssi <= -85) siglevel = 2; else if (rssi <= -75) siglevel = 3; else siglevel = 4; #ifdef _I2CLED display.fillRect(110, 52, 17, 11, BLACK); if (siglevel >=1) display.fillRect(112, 62, 2, 2, WHITE); if (siglevel >=2) display.fillRect(115, 60, 2, 4, WHITE); if (siglevel >=3) display.fillRect(118, 58, 2, 6, WHITE); if (siglevel >=4) display.fillRect(121, 56, 2, 8, WHITE); display.display(); #endif lastupdateMillis = currentMillis; } } #define cam_buffer_size 256 inline int min(int a, int b) { return ((a)<(b) ? (a) : (b)); } bool camera_setup(){ if (!cam.begin()){ oled_drawstr("No Camera"); return false; } char *reply = cam.getVersion(); //setting is saved to camera eeprom, only need to do this once. //cam.setImageSize(VC0706_640x480); // biggest //cam.setImageSize(VC0706_320x240); // medium //cam.setImageSize(VC0706_160x120); // small //cam.reset(); //reset to take effect cam.setCompression(54);//95); //cam.setDownsize(0); uint8_t imgsize = cam.getImageSize(); if (imgsize == VC0706_640x480) oled_drawstr("Camera 640x480"); if (imgsize == VC0706_320x240) oled_drawstr("Camera 320x240"); if (imgsize == VC0706_160x120) oled_drawstr("Camera 160x120"); imgsize = cam.getCompression(); Serial.print("Compression: "); //default 53 Serial.println(imgsize); imgsize = cam.getDownsize(); Serial.print("Downsize: "); Serial.println(imgsize); //cam.TVon(); return true; } void camera_timelapse(){ Serial.println("Camera Snapshot..."); oled_resetprogress(); if (camera_savetospiffs()){ digitalWrite(CAM_PWR, LOW); //turn off camera delay(100); flash_bled(2); if (!camera_sendPostFile()) delay(5000); oled_resetprogress(); }else{ Serial.println("Camera Snapshot error"); oled_overwritestr(5,"Snap Error"); delay(5000); } } bool camera_savetospiffs(){ if (!cam.takePicture()){ return false; } File file = SPIFFS.open("/timelapse.jpg", "w"); if (!file){ Serial.println("File io error"); oled_overwritestr(5,"File IO Error"); return false; } uint8_t stbuffer[cam_buffer_size]; int stsize; uint16_t jpglen = cam.frameLength(); int jpgkb = jpglen / 1000; byte wCount = 0; // For counting # of writes while (jpglen > 0) { stsize = 0; while (stsize < cam_buffer_size && jpglen > 0){ uint8_t *buffer; uint8_t bytesToRead = min(32, jpglen); buffer = cam.readPicture(bytesToRead); memcpy(&stbuffer[stsize],buffer,bytesToRead); stsize += bytesToRead; jpglen -= bytesToRead; } file.write(stbuffer, stsize); oled_progress(100-(jpglen/10)/jpgkb); flip_bled(((jpglen/1000)%2 == 0)?true:false); //Serial.print("."); } file.close(); Serial.println("OK"); delay(50); cam.resumeVideo(); return true; } bool camera_sendPostFile(){ String start_request = ""; String end_request = ""; start_request = start_request + "\n" + "--AaB03x" + "\n" + "Content-Disposition: form-data; name=\"filename\"; filename=\"test.jpg\"" + "\n" + "Content-Type: image/jpeg" + "\n" + "Content-Transfer-Encoding: binary" + "\n" + "\n"; end_request = end_request + "\n" + "--AaB03x--" + "\n"; uint16_t extra_length; extra_length = start_request.length() + end_request.length(); oled_overwritestr(5,"Sending..."); if (!SPIFFS.exists("/timelapse.jpg")){ oled_overwritestr(5,"File Error"); return false; } File file = SPIFFS.open("/timelapse.jpg", "r"); if (!file){ oled_overwritestr(5,"File IO Error"); return false; } uint16_t jpglen = file.size(); int jpgkb = jpglen / 1000; uint16_t len = jpglen + extra_length; WiFiClient client; char buffer[512],chbuf[16]; Serial.println("Starting connection to server..."); if (client.connect("servername.net",80)){ client.println(F("POST /fileup.php HTTP/1.1")); client.println(F("Host: 192.168.0.1:80")); client.println(F("Content-Type: multipart/form-data; boundary=AaB03x")); client.print(F("Content-Length: ")); client.println(len); client.print(start_request); while (jpglen > 0) { int byteread = file.readBytes(buffer,512); client.write((const uint8_t*)buffer, byteread); Serial.print('.'); jpglen -= byteread; oled_progress(100-(jpglen/10)/jpgkb); flip_bled(((jpglen/1000)%2 == 0)?true:false); } file.close(); client.print(end_request); client.println(); client.stop(); Serial.println("Transmission complete"); oled_overwritestr(3,"Send OK"); }else{ Serial.println(F("Connection failed")); oled_overwritestr(5,"Conn Error"); return false; } return true; } bool wifi_setup(){ // Connect tp Wifi #ifdef _I2CLED //sprintf(oledstrbuff,"AP :%s",ssid); //oled_drawstr(oledstrbuff); #endif Serial.printf("AP SSID:%s", ssid); WiFi.mode(WIFI_STA); // WiFi.config(ip,gateway,subnet); if (String(WiFi.SSID()) != String(ssid)) { WiFi.begin(ssid, password); } int retrycount = 0; while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); retrycount++; if (retrycount > 20){ return false; oled_drawstr("conn timeout"); Serial.println("connection timeout"); return false; } } Serial.println(""); lastwifi = WiFi.status(); IPAddress ip = WiFi.localIP(); #ifdef _I2CLED sprintf(oledstrbuff,"IP :%d.%d.%d.%d",ip[0],ip[1],ip[2],ip[3]); oled_drawstr(oledstrbuff); #endif byte mac[6]; WiFi.macAddress(mac); #ifdef _I2CLED sprintf(oledstrbuff,"MAC:%02X:%02X:%02X:%02X:%02X:%02X",mac[0],mac[1],mac[2],mac[3],mac[4],mac[5]); oled_drawstr(oledstrbuff); #endif // Set up mDNS responder: if (!MDNS.begin(host)) { Serial.println("Error setting up MDNS responder!"); return false; } Serial.println("mDNS responder started"); // Needed this to stabilize Websocket connection WiFi.setSleepMode(WIFI_NONE_SLEEP); if (WiFi.status() != WL_CONNECTED) return false; return true; } void setup() { Serial.begin(74880); Serial.print("\n"); Serial.setDebugOutput(true); pinMode(CAM_PWR, OUTPUT); digitalWrite(CAM_PWR, HIGH); pinMode(BUILTIN_LED, OUTPUT); digitalWrite(BUILTIN_LED, HIGH); //high to turn off digitalWrite(BUILTIN_LED, LOW); flash_bled(2); #ifdef _I2CLED Wire.begin(ALT_SDA,ALT_SCL); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); // initialize with the I2C addr display.display(); delay(1000); display.clearDisplay(); #endif oled_drawstr("Timelapse Camera"); Serial.printf("Booting"); Serial.flush(); /*for(uint8_t t = 4; t > 0; t--) { Serial.printf("."); Serial.flush(); delay(1000); } Serial.println("");*/ //delay(3000); SPIFFS.begin(); //SPIFFS.format(); //only need to be done once if (!wifi_setup()){ flash_bled(5); delay(5000); }else{ flash_bled(2); if (!camera_setup()){ flash_bled(5); delay(5000); }else{ flash_bled(2); delay(3000); updateScreen(); camera_timelapse(); } } oled_clear(); digitalWrite(BUILTIN_LED, HIGH); //high to turn off ESP.deepSleep(cam_interval*1000000); //unit in microsec } void loop() { }
Deep Sleepの時間を単純に3600秒とした場合インターバルが約3分短かったので、3780秒にしている。最初Deep Sleepの単位がmsと思っていたので落とした直後に立ちあがり悩んだ(単位はマイクロ秒)。
デバッグ用にI2C接続OLEDディスプレイを付けられる様にしている。付ける際はSCL/SDAはD1とD2ピン。I2CLEDのdefineのコメントを外す。
シリアルカメラのTXとRXはESP8266のRXとTXに繋いでも使えるが、デバッグの邪魔になるので信号ピンに繋ぎソフトシリアルで通信している。カメラの電源はそのまま電源に繋ぐと待機時も電力消費してしまうため、MOSFETとトランジスタで制御する。
最初N型のMOSFETでシリアルカメラのGND側でオフオン制御しようとしたが、GNDを切っているとVCCからシリアル信号に流れてしまうのかESP8266が起動しなくなったため、VCC側のオフオンをP型のMOSFETとNPNのトランジスタで電源制御回路を組んだ。抵抗は手持ちから適当に33kΩを使ったので抵抗値は最適では無いと思う。MOSFET / トランジスタによる電源制御は下記ページの「Pチャネルの場合」を参考に。
mini D1タイプのESP8266とシリアルカメラ、MOSFET、トランジスタ、抵抗と電源オフオン用スイッチ、給電用USBケーブルを取り付け完成。Deep SleepからのWake用にRSTピンとD0 (GPIO16)ピンをショート。

プリント基板はESP8266の開発用にEagleでデザインしElecrowに発注したもの。1500円程度で基板10枚程作れるので安くて便利。
サーバー側はPHPで簡単にファイル受け用と表示用を準備。サーバーは自前で準備してもいいし、最近は月1000円以下でもサーバーを借りる事が出来る。今回は試していないが、ESP8266からGmailを送る事も出来るので、Gmailで画像を添付して送る事も出来るのではないかと思う。
ファイル受け側PHPコードは下記の通り。受けたファイルに日付でファイル名を付けphotoフォルダーに保存。
<?php if ( $_FILES['filename']['error'] == UPLOAD_ERR_OK ) { $t = date('Ymd_His'); $prefix = 'memo'; $ext = 'jpg'; $filename = "{$prefix}_{$t}.{$ext}"; $upload_file = "./photo/" . $filename; if ( move_uploaded_file( $_FILES["filename"]['tmp_name'], $upload_file ) ) { chmod($upload_file, 0644); } } ?>
受けたファイルの表示用の簡単なスクリプト。最新99ファイルを表示。ファイルソートはここを参考にした。
<?php $dir_h = opendir( "./photo/" ) ; // ファイル・ディレクトリの一覧を $file_list に while (false !== ($file_list[] = readdir($dir_h))) ; closedir( $dir_h ) ; $file_list2 = array() ; $num = 0 ; foreach ( $file_list as $file_name ) { //ファイルのみを表示 if( is_file( "./photo/" . $file_name) ) { //$file_list2[N] の [0]にファイル名、[1]にファイル更新日 $file_list2[$num][0] = $file_name ; // ファイルの更新日時を取得 $file_list2[$num][1] = date("Y/m/d H:i", filemtime( "./snap/" . $file_name )) ; $num++ ; } } // $file_list2 をファイルの更新日時でソート usort($file_list2, "order_by_desc") ; //HTML文を出力 テーブルの開始を指定 print <<<EOD <html> <head> <META http-equiv=content-type content="text/html; charset=Shift-JIS"> <title>Timelapse Photo</title> <style> td {padding:5px;} .text {background-color:#ddffff; } </style> </head> <BODY> EOD; print("<table border=1><tr class=\"text\">\n<th>File [$num]</th><th>Image</th></tr>\n<tr>"); //横に並べる画像の最大数を設定する $max = 3; //カウント数の初期化 $cnt = 0; $num = min($num,99); //配列の数だけ繰り返す for ($i=0;$i<$num;$i++){ //$filenameにァイル名を設定 $filename = "snap/" . $file_list2[$i][0]; //ファイル名の拡張子が「gif」または「GIF」または「jpg」または「JPG」 //または「JPEG」または「png」または「PNG」の場合は実寸表示の //リンク付きで画像を表示する if (Eregi('gif$', $filename) OR Eregi('jpg$', $filename) OR Eregi('jpeg$',$filename) OR Eregi('png$', $filename)) { // 画像サイズ取得 list($width, $height, $type, $attr)= getimagesize($filename); print("\n<td width=\"200\" class=\"text\">" . $file_list2[$i][1] ."</td>"); print("\n<td><a href=" .$filename . "><img src = " .$filename. " width=320 height=240></a></td>"); //カウント数の初期化 $cnt = $cnt + 1; //カウント数の判定 最大数以上の場合は改行し、カウントを初期化する if ($cnt >= $max) { print("</tr>\n\n<tr>\n"); $cnt = 0; } } } //HTML文を出力 テーブルの終了を指定 print("\n</tr></table></body></html>"); // 引数 $file_list2 配列の[N][1] でソートする関数 function order_by_desc($a, $b) { if ( strtotime($a[1]) > strtotime($b[1]) ) { return -1; } else if(strtotime($a[1]) < strtotime($b[1])) { return 1; } else{ return 0; } } ?>
屋外の簡易ビオトープを定点観察してその後動画に纏めようと思うので、ダイソーで買ってきた蓋付きプラケースに投入しビニールテープで隙間を封印。屋内無線APから4mぐらいの距離に設置。
屋外に設置するため電源が必要となるが、一般的なモバイルバッテリーだとDeep Sleepに入った際消費電力が下がりすぎてオートオフになってしまい切れてしまうので、オートオフしないCheeroのIoT向けモバイルバッテリーを使うか、単三電池からUSB給電する単純タイプのものを使う。
まずは単三4本から給電するタイプでトライ。給電表示の青色LEDが消費電力10mAとしてもそれだけで電池を食いつぶしてしまうのでLEDの足を切断。




ケースに入れて稼働開始。



ビオトープが見える形で固定は強力両面テープ。

一ヶ月くらい持つことを期待したのだが、一週間程で動作が不安定になり動かなくなってしまった。テスターで測った所、USBに何も繋いでいなくてもスイッチを入れているだけで単三から5V変換の回路が多少電力を消費している様だ。一週間で電池4本はパフォーマンスが悪いので、IoT用のcheeroモバイルバッテリーで再挑戦。

cheero Canvas 3200mAh IoT機器対応 モバイルバッテリー ホワイト CHE-061
バッテリーを交換し稼働開始。長期使用する場合は残量を表示する白色LEDの点灯も勿体ない・・・。


結果、Cheeroのモバイルバッテリーだと約17日間(407回撮影)でバッテリーが切れた。cheeroのFAQページを見るとバッテリーの実効容量は6~7割とあるので、LEDインジケータ―分も引いて1600mAh程度が使えた容量とすると1撮影当たり約4mAの消費となる。回路やコードの見直しでさらに消費電力は半分程度に抑えられそうだが・・・。
とりあえずCheeroのモバイルバッテリーを2個購入し、交互に交換する方法で運営しはじめている。現在2周目に入った所で動作は好調だ。
倍程度の容量のIoTモバイルバッテリーを販売してくれると一ヶ月毎のバッテリー交換で済むので助かるのだが・・・。
ブラウザで見るとこの様な感じ。屋内では綺麗に色が出るのだが、屋外では色があまり出ていない。

もっとシリアルカメラが人気になり、より高画質なシリアルカメラが出てくれる事を期待したい。これから夏場に向けて暑くなるので、炎天下での動作も気になる所である。