ペットモニタカメラを作る

Raspberry Piを使ってVPN接続 で外部から自宅のLANへの接続が簡単に出来る様になったので、Monitorの製作 (01):画像を受信する。をちょっと発展させてペットモニタカメラを作って見ました。

主な部品

No. ITEM詳細
1カメラモジュール付きシンカー,ESP32-CAM〜160度,850nm,デュアルアンテナ,
wifi,Bluetooth,ESP32-S
=>(技適マークの刻印されたモデルでしたが既に完売の様でリンクが切れていました。)
2新しいOV2640カメラモジュールためESP32カムカメラモジュール
2MP 222 200 180 66 120 160度650nm 850nmナイトビジョンdvp 24PIN 0.5ミリメートル
31セットfpvサーボ2軸ジンバルマウントブラケットカメラプラットフォーム防振サポート
2個SG90 9グラムサーボrcモデル飛行機
4マイクロUSBシリアルポートESP32-CAM,Wi-Fi,ESP32-CAM-MB mm (CH340/340g),
5V,Bluetoothおよびov2640カードスロット付き
5ビニールのパック
6中部電磁器工業 CEC
ブロンズ粘土 [工作用ねんど]
7Kaito Denshi(海渡電子) ACアダプター 5V 1.5A 7.5W センタープラス スイッチング 5.5mm 2.1 mm DCプラグ PSE RoHS

回路図

  • 電源は5Vを使用しています。2つのサーボモータとESP32-CAMの5V端子に入力しています。
  • サーボモータのコントロールはGPIO13,14の2端子で行っています。
  • PC本体とTXD,RXD端子で接続。プログラムを書き込み用にリセットとGPIO0端子に回路を追加

現物

  • サーボモータの反動が大きく。反動を抑える為に土台にそれなりの重さが必要でした。
  • そこでフードパックの中に粘土を入れて土台に使っています。
  • レンズは付属のものでは無く160度魚眼レンズを使用しています。映る範囲が広がりました。
  • 写真ではアンテナを使用しています。アンテナを使用する場合はESP322-CAMのアンテナ端子の近くに有るチップを変更する必要が有ります。
  • 実際の使用時は、PCとの接続用コード、リセットコードはなくなりますが、それでもケーブルが若干目立ちます。

プログラム

使用してプログラムは monitor.ino: 本体 / monitor.html: サーバーHP用HTML / monitor.css: サーバーHP用cssの3つです。

  • monitor.ino: 本体
    • 基本的に Monitorの製作 (01) と同じです。
    • 今回はサーボモータを使用しているのでその部分が追加されています。
    • ESP32でサーボモータ(SG90)を使用するには、ESP32Servo.hが必要です。9行でIncludeしています。
    • サーボモータの制御は非常に簡単でした。
      • 56,57行: Servo型のインスタンスを各モータ用に2つ宣言
      • 76,77行: xxx.attach(PIN_No.)で制御するGPIOで指定。
      • 78,80行: xxx.write(angle) で回転角度を指定します。回転角度は0から180度の様です。
      • サーボモータの情報を読み取る方法が有りません。モーターの位置はホスト側で保存する必要が有ります。
      • 意外と回転速度が速いです。速度を落とそうとしましたが良い方法が見つかりませんでした。
monitor.ino

#include "esp_camera.h"
#include "Arduino.h"
#include <SPIFFS.h>

#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>

#include <ESP32Servo.h>

// Pin definition for CAMERA_MODEL_AI_THINKER
#define PWDN_GPIO_NUM       32
#define RESET_GPIO_NUM      -1
#define XCLK_GPIO_NUM        0
#define SIOD_GPIO_NUM       26
#define SIOC_GPIO_NUM       27

#define Y9_GPIO_NUM         35
#define Y8_GPIO_NUM         34
#define Y7_GPIO_NUM         39
#define Y6_GPIO_NUM         36
#define Y5_GPIO_NUM         21
#define Y4_GPIO_NUM         19
#define Y3_GPIO_NUM         18
#define Y2_GPIO_NUM          5
#define VSYNC_GPIO_NUM      25
#define HREF_GPIO_NUM       23
#define PCLK_GPIO_NUM       22

#define flash_port           4
#define r_l_pin             13
#define u_d_pin             14

WebServer server(80);
WebServer st_server(81);

const char *SSID = "xxxxxxxxxx";
const char *PASSWORD = "yyyyyyyyyy";

int cam_state[7];
#define ope_stat            0
#define g_mode              1
#define ud_num              2
#define lr_num              3
#define flash_flg           4

#define motor_ud            0
#define motor_lr            1

#define up_max            -70
#define down_max           70
#define right_max          70
#define left_max          -70
#define center_p           90

Servo r_l;
Servo u_d;

IPAddress ip(192, 168, 3, 200);           // IP Address
IPAddress gateway(192,168, 3, 1);         // Gateway Address
IPAddress subnet(255, 255, 255, 0);       // Subnet Address

void setup() 
{
    sensor_t * cam_s;

    pinMode(flash_port, OUTPUT);
    digitalWrite (flash_port, LOW) ;

    Serial.begin(115200);
    
    cam_state[ud_num] = 0;          // Up/Down Home position
    cam_state[lr_num] = 0;          // Left/Right Home position
    cam_state[flash_flg] = 0;       // LED OFF 

    r_l.attach(r_l_pin);
    u_d.attach(u_d_pin);
    r_l.write(center_p);
    delay(1000);
    u_d.write(center_p);
    delay(1000);

    Serial.println("Connecting to WiFi");
    WiFi.disconnect(true);
    WiFi.softAPdisconnect(true);
    delay(500);

    WiFi.mode(WIFI_STA);
    WiFi.config(ip, gateway, subnet);
    WiFi.begin(SSID, PASSWORD);
    delay(1000);

    // Try forever
    while (WiFi.status() != WL_CONNECTED) 
    {
        Serial.println("...Connecting to WiFi");
        delay(1000);
    }
    Serial.println("Connected");
    Serial.println(SSID);
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP());

    server.begin();
    server.on("/", handleRoot);
    server.onNotFound(handleWebRequests);
    Serial.println("HTTP server started");

    st_server.begin();
    st_server.on("/", st_handleRoot);

    init_cam();
    cam_s = esp_camera_sensor_get();
    cam_state[ope_stat] = 0;        // Stream Stop
    cam_state[g_mode] = cam_s->status.framesize;

    SPIFFS.begin();  
                            
    digitalWrite (flash_port, HIGH);
    delay(100);
    digitalWrite (flash_port, LOW);
    
}

void loop() 
{
    server.handleClient();
    st_server.handleClient();
}

void handleRoot() 
{
    String cmd,str;
    int a,b,c,fl;
    File dataFile;
    camera_fb_t * fb = NULL;
    sensor_t * cam_s;

    fl=1;
    cmd=server.argName(0);
    switch(cmd.toInt())
    {
      case 1: // Get Still
                fb = esp_camera_fb_get();
                dataFile = SPIFFS.open("/data.jpg", FILE_WRITE);
                dataFile.write(fb->buf, fb->len); // payload (image), payload length
                dataFile.close();
                esp_camera_fb_return(fb);
                Serial.println("Take a photo.");
                cam_state[ope_stat] = 2;
                break;

      case 2: //Set Camera Parameter
                Serial.println("Set Camera Parameter");
                cmd = server.arg("2");
                str = cmd.substring(2);
                a = str.toInt();
                switch(cmd.charAt(0))
                {
                  case '1': //Change Graphick mode 
                            cam_state[g_mode] = a;
                            cam_s = esp_camera_sensor_get();
                            cam_s -> set_framesize(cam_s,(framesize_t)cam_state[g_mode]);        //framesize
                            break;

                  case '2': //Move
                            b = cam_state[ud_num];
                            if(a > 2) b = cam_state[lr_num];
                            c = 10;
                            if(a & 1) c = -10;
                            
                            b += c;
                            switch(a)
                            {
                              case 1: if(b < up_max) b = up_max; 
                                      break;
                              case 2: if(b > down_max) b = down_max;
                                      break;
                              case 3: if(b < left_max) b = left_max;
                                      break;
                              case 4: if(b > right_max) b = right_max;                              
                            }
                            
                            if(a > 2)
                            {
                              cam_state[lr_num] = b;
                              r_l.write(b + center_p);
                            }
                            else
                            {
                              cam_state[ud_num] = b;
                              u_d.write(b + center_p);                              
                            }
                            break;

                  case '3': //Flash On, Off
                            cam_state[flash_flg] = ! cam_state[flash_flg]; 
                            digitalWrite (flash_port, cam_state[flash_flg]);
                            break;
                }
                break;
                                         
      case 3: //  Stream Start 
                cam_state[ope_stat] = 1;
                break;
                
      case 4: //  Stream Stop 
                Serial.println("Stream Stop.");
                cam_state[ope_stat] = 0;
                break;

      case 80: // Send camera parameter
                cmd="";
                for(a = 0; a < 5; a ++) cmd += (String(cam_state[a]) + ',');
      Serial.println("data: " + cmd);
                server.send(200, "text/plain", cmd);
                fl = 0;
                break;
    }

    if(fl)
    {
        dataFile = SPIFFS.open("/monitor.html", FILE_READ);
        server.streamFile(dataFile,"text/html");
        dataFile.close();
    }
}

void init_cam()
{
    camera_config_t config;

    config.ledc_channel = LEDC_CHANNEL_0;
    config.ledc_timer = LEDC_TIMER_0;
    config.pin_d0 = Y2_GPIO_NUM;
    config.pin_d1 = Y3_GPIO_NUM;
    config.pin_d2 = Y4_GPIO_NUM;
    config.pin_d3 = Y5_GPIO_NUM;
    config.pin_d4 = Y6_GPIO_NUM;
    config.pin_d5 = Y7_GPIO_NUM;
    config.pin_d6 = Y8_GPIO_NUM;
    config.pin_d7 = Y9_GPIO_NUM;
    config.pin_xclk = XCLK_GPIO_NUM;
    config.pin_pclk = PCLK_GPIO_NUM;
    config.pin_vsync = VSYNC_GPIO_NUM;
    config.pin_href = HREF_GPIO_NUM;
    config.pin_sscb_sda = SIOD_GPIO_NUM;
    config.pin_sscb_scl = SIOC_GPIO_NUM;
    config.pin_pwdn = PWDN_GPIO_NUM;
    config.pin_reset = RESET_GPIO_NUM;
    config.xclk_freq_hz = 20000000;
    config.pixel_format = PIXFORMAT_JPEG;
    config.frame_size = FRAMESIZE_VGA; // FRAMESIZE_ + QVGA|CIF|VGA|SVGA|XGA|SXGA|UXGA
    config.jpeg_quality = 10;
    config.fb_count = 2;

    // Init Camera
    esp_camera_init(&config);
}

void st_handleRoot() 
{
    WiFiClient client;
    camera_fb_t * fb = NULL;

    client = st_server.client();
    String response = "HTTP/1.1 200 OK\r\n";
    response += "Content-Type: multipart/x-mixed-replace; boundary=--frame\r\n\r\n";
    st_server.sendContent(response);
    while (1)
    {
      fb = esp_camera_fb_get();
      if (!client.connected())
      {
        esp_camera_fb_return(fb);
        break;
      }

      response = "--frame\r\n";
      response += "Content-Type: image/jpeg\r\n\r\n";
      st_server.sendContent(response);

      client.write(fb->buf, fb->len);
      st_server.sendContent("\r\n");
      esp_camera_fb_return(fb);

      if (!client.connected()) break;
    }
}

void handleWebRequests()
{
    String dataType = "text/plain";
    String path;
    File dataFile;
    camera_fb_t * fb;

    path = server.uri();
    if(path.endsWith(".txt")) dataType = "text/plain";
    else if(path.endsWith(".jpg")) dataType = "image/jpeg";
    else if(path.endsWith(".css")) dataType = "text/css";
    else if(path.endsWith(".js")) dataType = "application/javascript";
    else if(path.endsWith(".html")) dataType = "text/html";

    dataFile = SPIFFS.open(path.c_str(), "r");
    server.streamFile(dataFile, dataType);
    dataFile.close();
    delay(5);
}
  • monitor.html: サーバーHP用HTML
    • Monitorの製作 (01) にサーボモータコントロール用のボタンを追加したのみです。
monitor.html

<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <link rel='stylesheet' type='text/css' href='monitor.css' >
        <title>ESP32 Monitor</title>
    </head>
    <body>
        <div style='font-size:35px'><b><i><u>Monitor</u></i></b></div>
        <div> .</div>
        <div id="content">
            <nav id="menu">
            	<form method='get'>
                    <button type='submit' name='3'>Start</button>
	                <button type='submit' name='4'>Stop</button>
    	            <button type='submit' name='1'>Take</button>
    	            <div style = "margin-left: 25%;">
		          	    <a style="color:#ffff00;cursor: pointer;font-weight: bold" href='./data.jpg' download='data.jpg'>Download</a>
                    </div><br>
                    <div style ="display:flex;margin-left: 25%;">
                        <button class="btn" type='button' onclick="set_data(1)" >Up</button>
                    </div>
                    <div style ="display:flex;">
    	                <button class="btn" type='button' onclick="set_data(3)" >Left</button>
    	                <button class="btn" type='button' onclick="set_data(4)" >Right</button>
                    </div>
                    <div style ="display:flex;margin-left: 25%;">
                        <button class="btn" type='button' onclick="set_data(2)" >Down</button>
		           	</div>
                    <div style="font-size: 14px;font-weight: bold">
    	                U/D:<input type="text" id="_ud" size="2" readonly>
                        L/R:<input type="text" id="_lr" size="2" readonly>
                        <br><br>Light : <input type="radio" id="_flash" style ="margin-left:20%;" onclick="set_data(5)"/>
                    </div>
                    <div class="input-group">
                	    <br>
                        <select id="1">
                            <option value="0" style="display:none">96x96</option>
                            <option value="1">QQVGA(160x120)</option>
                            <option value="2">QCIF(176x144)</option>
                            <option value="3">HQVGA(240x176)</option>
                            <option value="4">240x240</option>
                            <option value="5">QVGA(320x240)</option>
                            <option value="6">CIF(400x296)</option>
                            <option value="7">HVGA(480x320)</option>
                            <option value="8">VGA(640x480)</option>
                            <option value="9" selected >SVGA(800x600)</option>
                            <option value="10">XGA(1024x768)</option>
                            <option value="11">HD(1280x720)</option>
                            <option value="12">SXGA(1280x1024)</option>
                            <option value="13">UXGA(1600x1200)</option>
                        </select>
                    </div>
                </form>
                <form method='get' id='abc'>
                	<input name="2" id='123' style="display:none">
                </form>
            </nav>
            <figure> <img id="stream" src=""> </figure>
        </div>
        <script>
      	    var para = Array(6);
            // 0: state, 1: gmode, 2: ud_num, 3: lr_num, 4: Flash
            var  cnt_ud;
            var  cnt_lr;
		    document.getElementById("1").addEventListener('change', function (event)
		    {
                var targetElement = event.target || event.srcElement;
                document.getElementById('123').value = "1," + targetElement.value;
			    document.getElementById('abc').submit();
		    }
    	    ,false);

		    document.addEventListener('DOMContentLoaded', function (event)
		    {
    		    var url = "http://192.168.3.200?80=";
    		    var xhr = new XMLHttpRequest();
		        var a,b,str;
    		    xhr.open('GET', url);
    		    xhr.send();
    		    xhr.onreadystatechange = function()
    		    {
      			    if(xhr.readyState === 4 && xhr.status === 200)
      			    {
					    console.log( xhr.responseText );
			            b=0;
        			    for(a = 0; a < 5; a++)
        			    {
			                para[a] = '';
        			        while( xhr.responseText[b] != ',')
        			        {
        			            para[a] += xhr.responseText[b];
        			            b ++;
        			        }
        			        b ++;
        			    }

					    switch(Number(para[0]))
					    {
						    case 0:	document.getElementById("stream").src = ``;
							    	break;
						    case 1:	document.getElementById("stream").src = `http://192.168.3.200:81/`;
								    setTimeout(stream_stop, 240000);
							 	    break;
						    case 2:	document.getElementById("stream").src = `./data.jpg`;
								    break;
					    }

                        document.getElementById("1").selectedIndex = Number(para[1]);

                        document.getElementById("_ud").value = para[2];
                        cnt_ud = Number(para[2]);
                        document.getElementById("_lr").value = para[3];
                        cnt_lr = Number(para[3]);

                        if(para[4] == "1") document.getElementById("_flash").checked = true ;
                        else document.getElementById("_flash").checked = false ;
    			    }
  			    }
		    });

            function set_data(num)
            {
                if(num < 5) document.getElementById('123').value = "2," + String(num);
                else document.getElementById('123').value = "3,0";
			    document.getElementById('abc').submit();
            }

		    function stream_stop()
		    {
			    window.stop();
    		    var url = "http://192.168.3.200?4=";
    		    var xhr = new XMLHttpRequest();
    		    xhr.open('GET', url);
    		    xhr.send();
		    }

		    history.pushState(null,null,'/');
        </script>
    </body>
</html>
  • monitor.css: サーバーHP用css
monitor.css

@charset "UTF-8";

body {
        font-family: Arial,Helvetica,sans-serif;
        background: #181818;
        color: #EFEFEF;
        font-size: 16px
}

#menu {
        display: flex;
        flex-wrap: nowrap;
        min-width: 142px;
        background: #363636;
        padding: 8px;
        border-radius: 4px;
        margin-top: -10px;
        margin-right: 10px;
}

#content {
        display: flex;
        flex-wrap: wrap;
        align-items: stretch
}

button {
        display: block;
        margin: 4px;
        padding: 0 5px;
        border: 0;
        cursor: pointer;
        color: #ff7f50 ;
        background: #afeeee ;
        border-radius: 10px;
        font-size: 16px;
        width: 90%;
        height: 28px;
        font-weight: bold;
        border:inset 3px silver;
}

button:active{
  background: #ff3034;
}

.btn {
        display: block;
        margin: 4px;
        padding: 0 5px;
        border: 0;
        cursor: pointer;
        color: #ff7f50 ;
        background: #afeeee ;
        border-radius: 10px;
        font-size: 16px;
        width: 50%;
        height: 24px;
        touch-action: none;
        user-select: none;
        font-weight: bold;
        border:inset 2px silver;
}

.btn:active{
  background: #ff3034;
}

figure {
        padding: 0px;
        margin: 0;
        -webkit-margin-before: 0;
        margin-block-start: 0;
        -webkit-margin-after: 0;
        margin-block-end: 0;
        -webkit-margin-start: 0;
        margin-inline-start: 0;
        -webkit-margin-end: 0;
        margin-inline-end: 0
}

figure img {
        display: block;
        width: 100%;
        height: auto;
        border-radius: 4px;
        margin-top: 8px;
}


コンパイル

SPIFFSを使用している為、moitorフォルダーの下にmonitor.inoとdataフォルダー保存。dataフォルダーの下にmonitor.htmlとmonitor.cssを保存して下さい。後は本体をコンパイル後ESP32に焼く。monitor.htmlとmonitor.cssは ESP32 Sketch Data Upload を使ってESP32に焼きます。

実行

  • 電源を入れると機器の初期設定が開始されます。
  • 機器がReadyになるとESP32-CAMについているLightが一瞬光ります。
  • 光を確認したらブラウザー(FireFox推奨)のURL欄に192.168.3.200と入力します。
  • スマホからサーバにアクセスする場合DNSが使えない為サーバのIPアドレスを固定しています。
  • HP画面メニュー列にUp / Down / Left / Right のボタンを追加しています。このボタンを押すとカメラの角度が変わります。
  • その下のLightボタンがクリックするとESP32-CAMについているLightが点灯します。
  • もちろん、Raspberry Piを使ってVPN接続 を使えば外出先(LAN外)からも繋がります。

最後に

出先で自宅のペットを監視したいとはそんなに思っていないのですが、外出先で自宅が見え、カメラが動いたりするとなんだか楽しい気持ちになりました。