akinoのアイコン画像
akino 2025年07月08日作成 (2025年07月08日更新)
製作品 製作品 閲覧数 419
akino 2025年07月08日作成 (2025年07月08日更新) 製作品 製作品 閲覧数 419

HDRカメラでメーターからAI自動検針 Wi-Fi編

HDRカメラでメーターからAI自動検針 Wi-Fi編

はじめに

Spresense用 ESP8266 Wi-Fiアドオンボードの製作」が完成したので、これを使い「HDRカメラでメーターからAI自動検針」 の Google Drive への送信部を有線LANからWi-Fiに変更してみました。

構成

Speresenseメインボード+自作Wi-Fiアドオンボード(+Spresense拡張ボード+LCDサブボード)の構成です。( )内は屋外設置時には外す予定です。Wi-Fi以外の部分はコンテスト応募時と同じです。

ATコマンドでSSL通信

ESP8266のATコマンドについては、Espressif の AT Instruction SetAT Command ExamplesAT Command Set などを参考に進めました。

AT+CIPSTART—Establishes TCP Connection, UDP Transmission or SSL Connection
を使い、まずは TCP で適当なサイトに接続できることを確認しました。
次に Google Drive に対し「SSL」を指定して、

AT+CIPSTART="SSL","oauth2.googleapis.com",443

を試しましたが「ERROR」が返り「CLOSED」となってしまいます。
そこで、gmailに対して同様に接続を試してみると、

AT+CIPSTART="SSL","smtp.gmail.com",465

は「CONNECT」が返り「OK」となります。

このため、Google Drive への接続には事前に「証明書」に関する設定等が必要なのかと思い調べてみると、
AT+CIPSSLCCONF - Sets Configuration of ESP SSL Client
というコマンドがありました。早速これを試してみたのですが、「設定」どころか「確認」さえ「ERROR」が返ってきてしまいます。

AT+CIPSSLCCONF? //確認 AT+CIPSSLCCONF=2 //設定

ATコマンド用のファームウェアのバージョンによるものかと思い、いくつか別バージョンも試しましたが結果は同じでした。

なお、Google Drive への接続は、以前にESP32でWiFiClientSecureライブラリを使い行ったことがあります。client.setInsecure(); の設定により、証明書不要で接続ができました。

Espressifのサイトには「 ESP8266 SSL User Manual」があります。こちらの方法でうまくいくかもしれませんが、単純にATコマンドを打つだけで実現させたいと考えていました。

ラズパイの助けを借りることにした

結構な時間をかけて、ATコマンドによる Google Drive への接続をあれやこれやと試してみましたが、結局クリアできず諦めました。そこで、ラズパイを仲介させる方法に変更しました。Spresense+ESP8266からラズパイに画像をTCPで送信し、ラズパイからGoogle DriveにSSLで転送するという段取りです。

Spresense+ESP8266からラズパイにTCPで送信する

ソースコードは以下の通りです。
ESP8266はWi-Fiの初期設定は事前に済ませておき、この中では行いません。
Spresenseは起動すると、HDRカメラで画像1枚を撮影し(SDカードにも保存)、これをESP8266からWi-Fiでラズパイに送信後、deepsleepモードに入り待機します。1回/日で起動させる予定です。
起動時にはESP8266へ電源供給するLDOの出力をオンし、Spresenseがdeepsleepに入る前にこの出力をオフにします。これにより待機中のESP8266の消費電力をゼロにできます。
なお、LCDパネルの画像表示用ライブラリは、「Adafruit_GFX」から「LovyanGFX」に変更しました。画像の表示の他、画像内のメーター数値部への枠表示をしていますが、位置合わせを含めまだ事前検討レベルです。

ATコマンドに関する関数としては、

  • void sendATCommand(String command, unsigned long timeout)
    ATコマンド(commnd)を送信します。
  • void waitForResponse(String expectedResponse, unsigned long timeout)
    ATコマンドを送信後に expectedResponse で指定した応答が返るのを待ちます。
  • void sendToRaspi_rp()
    画像データを送信します。
    AT+CIPSTRAT でラズパイに接続し、AT+CIPSEND で画像データを送信します。パケットは最大で2048バイトなので分割して繰り返し行います。

ラズパイに送信

#include <Arduino.h> #include <SDHCI.h> #include <Camera.h> //Cameraライブラリを利用 -> thCameraインスタンスを利用可能になる #include <LowPower.h> #include "LGFX_SPRESENSE.hpp" //LGFX_SPRESENSE_sample.hppを小修整 const char* server_ip = " ‘***.***.***.***"; //ラズパイのIPアドレス #define LED_R 4 //LED赤、Lアクティブ #define LED_B 3 //LED青、Lアクティブ #define TFT_BL 2 //LCD(Back Light)、Lアクティブ #define SW1 15 //Push_SW1 #define SW2 14 //Push_SW2 #define RSTN 21 //ESP8266のresetピン(RST)へNPN-TrNPN-Trを介して接続 #define CONT 20 //ESP8266用LDOのCONTROL入力に直結、'H'でLDO出力オン const int server_port = 5000; //ラズパイのポート番号 const uint32_t sleepSec = 60; //deepsleep期間(確認時60秒、実際は1日 60x60x24=86400) SDClass theSD; CamErr err; bool ledFlag_R = true; //LED_R制御フラグ(Lアクティブ) static LGFX lcd; //LGFXのインスタンスを作成 static LGFX_Sprite sprite1(&lcd); //LCDに描画するスプライト作成、カメラ画像全体 static LGFX_Sprite sprite2(&sprite1); //カメラ画像に重ねるスプライト作成、メータ―数値部の矩形 //------------------------------------------------------------------------------- void printError(enum CamErr err) { //エラーコードによりその内容を表示 Serial.print("Error: "); switch (err) { case CAM_ERR_NO_DEVICE: Serial.println("No Device"); break; case CAM_ERR_ILLEGAL_DEVERR: Serial.println("Illegal device error"); break; case CAM_ERR_ALREADY_INITIALIZED: Serial.println("Already initialized"); break; case CAM_ERR_NOT_INITIALIZED: Serial.println("Not initialized"); break; case CAM_ERR_NOT_STILL_INITIALIZED: Serial.println("Still picture not initialized"); break; case CAM_ERR_CANT_CREATE_THREAD: Serial.println("Failed to create thread"); break; case CAM_ERR_INVALID_PARAM: Serial.println("Invalid parameter"); break; case CAM_ERR_NO_MEMORY: Serial.println("No memory"); break; case CAM_ERR_USR_INUSED: Serial.println("Buffer already in use"); break; case CAM_ERR_NOT_PERMITTED: Serial.println("Operation not permitted"); break; default: break; } } void CamCB(CamImage img) { //Previewが出力された際に呼び出される関数 if (img.isAvailable()) { /* Check the img instance is available or not. */ img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); //defaultはYUV422なので変換 sprite1.pushImage(0, 0, 320, 240, (uint16_t *)img.getImgBuff()); //LovyanGFX設定 sprite2.drawRect(0,0,210,65,TFT_RED); // GUSメータ用の枠 sprite2.drawFastVLine(35,0,65); // 1文字目 sprite2.drawFastVLine(69,0,65); // 2文字目 sprite2.drawFastVLine(100,0,65); // 3文字目 sprite2.drawFastVLine(133,0,65); // 4文字目 sprite2.drawFastVLine(136,0,65); // 5文字目 sprite2.drawFastVLine(163,0,65); // 6文字目 sprite2.drawFastVLine(189,0,65); // 7文字目 //sprite2.setColor(TFT_YELLOW); sprite2.drawFastHLine(136,12,27); sprite2.drawFastHLine(163,18,26); sprite2.drawFastHLine(189,26,21); sprite2.pushSprite(55,85,TFT_BLACK); //画像を隠さないように透過させる sprite1.pushSprite(0,0); //一括書換、(x,y)は320x240画面での位置 } else { Serial.println("Failed to get video stream image"); } } //------------------------------------------------------------------------------- void ili9341_init() { lcd.init(); lcd.setSwapBytes(true); // バイト順変換を有効にする lcd.setRotation(1); // LCD 表示を90度回転、実装状態に合わせた sprite1.createSprite(320, 240); sprite1.setSwapBytes(true); // バイト順変換を有効にする sprite2.createSprite(210, 65); digitalWrite(TFT_BL, LOW); // LCDバックライト、Lで点灯 } void sendATCommand(String command, unsigned long timeout) { String response = ""; Serial2.println(command); // ATコマンドを送信 unsigned long start = millis(); while (millis() - start < timeout) { while (Serial2.available()) { char c = Serial2.read(); response += c; } } Serial.println(response); // レスポンスをターミナルに表示 } void waitForResponse(String expectedResponse, unsigned long timeout) { //所定の応答を待つ unsigned long startTime = millis(); String response = ""; while (millis() - startTime < timeout) { if (Serial2.available()) { char c = Serial2.read(); response += c; if (response.indexOf(expectedResponse) >= 0) { return; } } } Serial.println("タイムアウト: " + expectedResponse); } void sendToRaspi_rp() { String filename = "meter_w.jpg"; Serial.println("Start get JPG"); digitalWrite(LED_R, LOW); CamImage img = theCamera.takePicture(); //写真撮影(=シャッターを切る) if (img.isAvailable()) { const int chunkSize = 2048; //ラズパイへの送信時の分割サイズ (2048 max.) int totalSize = img.getImgSize(); //画像ファイルのサイズ uint8_t* imgData = img.getImgBuff(); //生データへのポインタ //Serial.printf("%p\n", imgData); //確認用 buffer address Serial.println(totalSize); //確認用、画像データのサイズ Serial.print("Save taken picture as "); Serial.println(filename); theSD.remove(filename); //Remove the old file with the same file name File myFile = theSD.open(filename, FILE_WRITE); //create new file myFile.write(img.getImgBuff(), img.getImgSize()); myFile.close(); sendATCommand("AT+CIPSTART=\"TCP\",\"" + String(server_ip) + "\"," + String(server_port), 2000); //接続 for (int offset = 0; offset < totalSize; offset += chunkSize) { int sizeToSend = min(chunkSize, totalSize - offset); // 最終送信時の分割用 uint8_t* chunk = imgData + offset; Serial.print("AT+CIPSEND="); // 確認用 Serial.println(sizeToSend); Serial2.print("AT+CIPSEND="); // AT+CIPSENDコマンドで送信サイズを指定 Serial2.println(sizeToSend); waitForResponse(">", 1000); // 応答を待機(通常は「>」が返ってくる) Serial2.write(chunk, sizeToSend); // バイナリデータをそのまま送信 waitForResponse("SEND OK", 5000); // 送信が完了したことを確認するために応答を待つ } Serial.println("took a photo and sent raspi -> google drive"); sendATCommand("AT+CIPCLOSE", 2000); // 接続を終了 } else { digitalWrite(LED_R, HIGH); Serial.println("Failed to take picture"); } digitalWrite(LED_R, HIGH); theCamera.end(); // Cameraライブラリの処理を終了 } bool wait_atmode() { unsigned long startTime = millis(); //起動時間確認用(検討時) while (millis() - startTime < 10000) { String res = ""; if (Serial2.available() > 0) { res = Serial2.readStringUntil(0x0a); //Serial.println(millis()); //起動後経過時間 //Serial.println(res); //ESP8266からの受信内容 if (res.lastIndexOf("WIFI GOT IP") >= 0) { //Serial.println("AT mode start"); return true; } } } return false; } //------------------------------------------------------------------------------- void setup() { LowPower.begin(); pinMode(LED_R, OUTPUT); //LED赤、Lアクティブ pinMode(LED_B, OUTPUT); //LED赤、Lアクティブ pinMode(TFT_BL, OUTPUT); //LCDバックライトのon/off、Lアクティブ pinMode(SW1, INPUT); //Push_SW1、現在未使用 pinMode(SW2, INPUT); //Push_SW2、現在未使用 pinMode(RSTN, OUTPUT); //ESP8266のresetピン(RST)へNPN-Trを介して接続 pinMode(CONT, OUTPUT); //ESP8266用LDOのCONTROL入力に直結、'H'でLDO出力オン digitalWrite(RSTN, LOW); digitalWrite(CONT, LOW); Serial.begin(115200); while(!Serial); Serial.println(""); Serial.println("camera_esp_raspi_lcd v1.3.250617 start"); Serial.println("power on ESP8266, and wait..."); ili9341_init(); digitalWrite(CONT, HIGH); delay(10); //ESP8266をpower on digitalWrite(RSTN, HIGH); delay(1); //念のためESP8266をreset digitalWrite(RSTN, LOW); Serial2.begin(115200); //Serial2でESP8266に接続 //while(!Serial2); if (wait_atmode()) { //ESP8266の設定・変更は事前に実施しておく Serial.println("Start AT mode"); } else { while(1) { //ESP8266の起動NGの場合は、LED_Rを点滅 Serial.println("Check out the ESP8266"); ledFlag_R = !ledFlag_R; digitalWrite(LED_R, ledFlag_R); delay(500); } } while (!theSD.begin()) { /* Initialize SD */ Serial.println("Insert SD card."); /* wait until SD card is mounted. */ } Serial.println("Prepare camera"); err = theCamera.begin(); //begin() 引数なし:30fps, QVGA, YUV4:2:2(default) if (err != CAM_ERR_SUCCESS) printError(err); Serial.println("Start streaming"); /* Start video stream. */ err = theCamera.startStreaming(true, CamCB); //Preview用call back関数の設定と有効指定 if (err != CAM_ERR_SUCCESS) printError(err); Serial.println("Set Auto white balance parameter"); err = theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_DAYLIGHT); //AEを日中設定 if (err != CAM_ERR_SUCCESS) printError(err); Serial.println("Set still picture format"); //Set parameters about still picture err = theCamera.setStillPictureImageFormat( CAM_IMGSIZE_VGA_H, //VGA CAM_IMGSIZE_VGA_V, CAM_IMAGE_PIX_FMT_JPG); if (err != CAM_ERR_SUCCESS) printError(err); sendToRaspi_rp(); //ラズパイに画像を分割送信 theCamera.end(); //カメラoff digitalWrite(TFT_BL, HIGH); //LCD の Back_Light を消灯 Serial.println("Now, Deep sleep!"); //sendATCommand("AT+GSLP=0", 1000); //ESP8266 を DeepSleep digitalWrite(CONT, LOW); delay(10); //ESP8266をpower off LowPower.deepSleep(sleepSec); //SPRESENSEをDeepSleep(sleepSec秒) delay(1000); } void loop() { // }

実際にVGA画像をラズパイに送信したときのターミナルへの表示です。

(前略) Set still picture format Start get JPG 50080 Save taken picture as meter_w.jpg AT+CIPSTART="TCP","***.***.***.***",5000 CONNECT OK AT+CIPSEND=2048 AT+CIPSEND=2048 (中略、'AT+CIPSEND=2048'20回) AT+CIPSEND=2048 AT+CIPSEND=2048 AT+CIPSEND=928 took a photo and sent raspi -> google drive AT+CIPCLOSE CLOSED OK Now, Deep sleep!

ラズパイからGoolge Drive にSSLで転送する

Pythonを使って、Spresense+ESP8266 からの画像データをラズパイで受信し Google Drive へ転送します。
受信部は「TCP通信 python整理」を参考にさせて頂きました。

PyDriveを使う

Google Drive への転送部は「Python, PyDriveでGoogle Driveのダウンロード、アップロード、削除など」を参考にさせて頂きました。

Pydriveをラズパイにinstallします(Version: 1.3.1)、Pythonは 3.7.3 です。

  1. client_secrets.json
    プロジェクトの作成、Google Drive APIの有効化、OAuthの認証、認証情報の作成、アプリ公開と進めます。ダウンロードしたJSONファイルを client_secrets.json として保存します。

  2. settings.yaml
    settings.yaml を作成します。認証情報をファイルから読み込むようにしました。

settings.yaml

client_config_file: client_secrets.json save_credentials: True # 認証情報を保存する save_credentials_backend: file # ファイルに保存 save_credentials_file: credentials.json # 保存先のファイル名の指定 get_refresh_token: True oauth_scope: - https://www.googleapis.com/auth/drive.file - https://www.googleapis.com/auth/drive.install
  1. credentials.json
    以下のコードを、client_secrets.json, settings.yaml と同一ディレクトリに置き実行します。ブラウザが起動し認証画面が表示され、完了時に credentials.json が作成されます。2回目以降はこのファイル内にアクセス情報が含まれるためブラウザを起動することなく認証できます。

auth.py

from pydrive.auth import GoogleAuth from pydrive.drive import GoogleDrive gauth = GoogleAuth() gauth.LocalWebserverAuth() drive = GoogleDrive(gauth)

ソースコード

ラズパイから画像データを Googel Drive に送るコードです。
同一フォルダ内に上記の client_secrets.json、credentials.json、settings.yamlを置いておきます。
Spresense側からの画像データを受信するたびに、画像を保存、そして Google Drive に送信し所定のフォルダに保存します。

transfer.py

import datetime import socket from pydrive.auth import GoogleAuth from pydrive.drive import GoogleDrive def upload_google(): gauth = GoogleAuth() # 認証 gauth.LocalWebserverAuth() # 認証処理 drive = GoogleDrive(gauth) # GoogleDriveFileオブジェクト作成、画像データを保存するフォルダIDを設定 f = drive.CreateFile({'parents': [{'id':'*********************************'}]}) f.SetContentFile('meter_w.jpg') # ファイル名を設定 f.Upload() # アップロード print("upload file to Google Drive") f = None # GoogleDriveFileオブジェクトの解放、メモリを解放 host = '0.0.0.0' # 任意のIP(全てのinterfaceをlisten) port = 5000 # ESP8266での設定と合わす server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # ソケット設定、IPV4,TCP server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # SO_REUSEADDRをON server.bind((host, port)) # アドレスとポートの設定 server.listen(1) try: while True : # 接続の待受け print(f"Waiting for connection on {host}:{port}...") client, addr = server.accept() # 通信用ソケット、クライアントの接続 msg = str(datetime.datetime.now()) print(f'{msg} : 接続要求あり') print(client) # print(f"Connection from {addr}") with open("meter_w.jpg", "wb") as f: # 画像を受信し保存、"wb" でバイナリーの書込み while True: data = client.recv(2048) # 一度に受け取るデータのサイズ if not data: break f.write(data) print("Image received and saved as 'meter_w.jpg'") client.close() # 切断 upload_google() # 受信画像を Google Drive へupload except KeyboardInterrupt: server.close() print('Finished!')

あとがき

Google Drive 上でのGASによる認識処理は「HDRカメラでメーターからAI自動検針」と同じです。

ESP8266のATコマンドによるSSL通信に関しては、ネットを探してもほとんどありませんでした。今回の検討が終了したところで ChatGPT に聞いてみると、どうもESP8266でのその実現は難しいようで「ESP32で試してみたら」と言われました。当初、ESP32の使用はまったく考えていませんでしたが、気が向いたらチャレンジしてみようかと思っています。

  • akino さんが 2025/07/08 に 編集 をしました。 (メッセージ: 初版)
  • akino さんが 2025/07/08 に 編集 をしました。 (メッセージ: トップの画像を追加)
ログインしてコメントを投稿する