🦊

Timer Camera Xを使った入れないところを観察するカメラシステム「自在カメラ」の作製

2024/01/14に公開

いい感じの地下壕を見つけたのに入り口が小さくてラクダでも通れないとか、内部がマジで崩れる5秒前な状態ってことありますよね。でも何とか中を見たい。壁がコンクリなのか土なのかだけでも知りたい。なんだったら機械堀りのなのか手堀りなのかを知りたい。そんな時に使えるカメラをTimer Camera Xを利用して作りました。伸縮棒の先に取り付けて地下壕に差し込み、内部の様子を観察できます。それ以外の使い道はありません。無いです。

目次

  1. はじめに
  2. 全体概要
  3. 使った部品について
  4. 回路構成
  5. Timer Camera Xの制御
  6. サーボモーターの制御
  7. コントロールプログラム
  8. フィールドテスト

記載されてる技術

  • Timer Camera Xの制御
  • サーボの制御
  • Timer Camera X(ESP32)をWiFiアクセスポイント&Webサーバーにして、スマホからアクセス・コントロール
  • Timer Camera X標準のコントロールプログラムの改造

1.初めに

地下壕などを探検していて、穴の大きさや断面形状がどうしても知りたくなるあなたに、モダンでゴージャスな手のひらLIDAR ver1手のひらLIDAR ver2手のひらLIDAR ver3を作りました。しかし、そもそも穴に入れないってことがあると思います(というか、地下壕は普通立ち入り禁止ですわな)。でもせめてカメラを入れてどうなってるか確認だけでもしたい。で、スマホを差し込んだりするんですが、万が一落としたら大惨事になります。そこで、棒の先にカメラ取り付けて、地下壕なり掩体壕なり〇〇なり様々な場所に差し込んで撮影出来たら、ということでカメラを作りました。名付けて「自在カメラ」。

2. 全体概要

全体は下のような感じでまとめました。

構成

  • カメラはTimer Camera Xを採用。M5stickCサイズの筐体でESP32とカメラとバッテリーが入ってます。なんですがこいつ、後で書くように(今回の目的には)イマイチです。
  • Timer Camera XをWiFiのアクセスポイントにして、そこにスマホやPCから接続、ブラウザでアクセスすることでコントロール&動画を見ます。
  • PCならVLC Media Playerを使って録画もできます。スマホのVLCは録画機能がないので見るだけになります(静止画は保存できる)。だれかスマホでMJPEGを録画できる方法知ってる人がいたら教えてください!
  • カメラの視野を変えられるようにサーボでパンとチルトができるようにしました(なので「自在」カメラです)。
  • 暗闇に突っ込むのでライトが必要です。今回はLEDを4個付けましたが、正直力不足ですね。
    全体像(背景は気にしない)
    野外でLEDをつけてるところ
    崩落してる地下壕に入れて観察

3. 使った部品について

Timer Camera X

これです。
https://www.switch-science.com/products/6742
Timer Camera XはM5stickCサイズの筐体にESP32とカメラとバッテリーが入ってる優れものです。WiFiでアクセスしてPCやスマホに静止画、動画を配信できるサンプルプログラムがあります。顔認識とかもできるようなのですが、地下壕で顔認識なんかしたらまあまあマズいものが映ると思われるので今回は使いません。こいつ、実はカメラの感度があまり良くない=暗闇の撮影が苦手だったり、動画のMPEG圧縮ができなかったり、外部に出てるGPIOがGROVEポートの2本だけだったり、いろいろ難点があります。元々画像認識や静止画による定点観察が目的のデバイスなので、スマホのような素敵な動画の撮影を求めるとちょっとなんだかな~ということになります。

サーボモーター&サーボマウント

伸縮棒の先に取り付けたカメラを地下壕に入れて内部を観察するために、パン(横スキャン)、チルト(縦スキャン)できるようにしました。サーボモーターとサーボマウントを使うんですが、もうセットで売ってる場合もあります。そんなに力がかかるわけでもないので、サーボは超一般的なSG90、サーボマウントはこれ一択ぐらいで売ってる中華製のを使いました。
https://ja.aliexpress.com/item/1011948167.html
しかしこのサーボマウント、ちょっと工夫しないとSG90をねじ止めできない、パン方向の取り付けがサーボのギア部に力が集中するようになっており強度的に問題ありなどなど、ちょっと癖があります。でもこれしか売ってないんだよな~。なんでだろ。

モバイルバッテリー

何時間も稼働させるわけではない&伸縮棒の先につけるので重さ重視で選んだほうが良いです。よく販促品で配られてるようなのでもOKと思います。私は近所のホームセンターで300円で投げ売りされてたのを使いました。

LED

闇を照らす我らが白色LED。近くを照らすSMDタイプと遠くを照らす砲弾タイプをそれぞれ2個使いました。双方とも「高輝度」というのを使ったんですが正直光力不足です。懐中電灯レベルの明るさにはなるのでまあ人間には問題ないんですが、今回使ったTimer Camera Xではあまり遠くが見えません。ここ、改良が必要ですな。

アルミステー

ホームセンターで売ってるアルミのL字型の金具にサーボと電源受けを取り付け、それを伸縮棒にマジックテープで取り付けました。
八幡ねじアルミステー
https://yhtnetshop.yht.jp/shopdetail/000000027424/201002002/page1/recommend/

伸縮棒

これもホームセンターで売ってます。のぼりを立てるための棒です。
https://www.kohnan-eshop.com/shop/g/g4522831176458/

4. 回路構成

電気的な回路構成

回路構成は下のような感じです。

モバイルバッテリーから電源スイッチを介して、サーボモーター、Timer Camera X、LEDに5Vを供給します。LEDは別途ON/OFFスイッチをつけてます。サーボモーターはTimer Camera XのGROVE端子の2つのGPIOで制御します。ホントはLEDのON/OFFもTimer Camera Xから制御したかったんですが、GPIO端子が足りないので諦めです。

Timer Camera Xへの電源供給

ここでポイントは、Timer Camera XにGROVEポートを通じて電源を供給するというところです。そうしないとGROVEポートのほかにUSBポートも使わなくちゃならなくなり、ちょっとごちゃっとした感じになります。しかしこのGROVEポート、5VはUSB電源ポート直結ではなく、5Vを一旦3.3Vに落としたものからさらに5Vに上げてるといううわさもあり正体がはっきりしません。で、悩んでたんですが製造元に回路図が載っておりあっさり解決。
Timer Camera Xの電源周りの回路はこのようになってます。

GROVE端子の5VにはUSBの5Vから逆流防止用のダイオードを介して直結されてます(うわさはなんやったんや???)。内部での端子名はVSYS_VIN。で、そこに電源ICもつながっており、ESP32用の3.3Vを生成しています。ということは、GROVE端子に5Vを与えればTimer Camera Xは動作しそうです(バッテリーに充電はできないけど)。実際には↑の回路図に示すように、モバイルバッテリーから逆流防止用のダイオードを介してGROVE端子に5Vを供給しました。これでばっちりや!!

5. Timer Camera Xの制御

先にも書いたように、自在カメラでは、

  • Timer Camera XをWiFiアクセスポイントにする
  • そこにスマホやPCから接続し、ブラウザでアクセスすることでントロール&動画を見る

という構成にしています。
ESP32系のカメラに対するサンプルプログラムはいくつもあり(何かいろんな亜種・バージョンがあり全貌が把握できない・・・)、それを使えば比較的簡単にカメラとして動作させることができます。Timer Camera X用だとライブラリーマネージャーからライブラリーTimer-CAMを入れればもう簡単にできます。
なんですが、これ、ライブラリーなんでカスタマイズができない・・・。必要のない機能は消して、オリジナルの機能を入れたい、となれば、もちっと自由にいじれるのが欲しい。ということで、ひとつ前のバージョンを選択。
https://github.com/m5stack/TimerCam-arduino/tree/0.0.3/src
これなら関連のソースファイル3つが全部入っており、まあ自分で改造できます。
必要な3ファイル
 app_httpdX.cpp → コントロールのメインプログラム(Webサーバー)
 camera_index.h → スマホ・PC側に送るWebページの内容
 camera_pins.h → カメラのピン番号
これをベースに以下のようにカスタマイズ(不要な機能の削除&必要機能の追加)を行いました。

  • カメラの細かい制御の削除
  • 顔認識関係の削除
  • サーボのコントロール追加
  • スマホ画面への適応(コントロールスイッチのトグル化)
  • WiFi設定の追加(ブラウザからWiFiの設定を変更できるようにした)

結果、こんなコントロール画面になりました。詳細は後程。

観察中の画面 カメラ詳細設定 WiFi設定

6. サーボモーターの制御

サーボモーターの制御方法

Timer Camera X1はGPIOがGroveポートの2本しかありませんので、この2本で2台のサーボ(パン、チルト)を制御します。サーボドライバなどは使わずに、ライブラリーESP32Servo.hを使って直接PWMで制御しています。構造の詳細はこんな感じです。

カメラ制御Webページへのサーボコントロールの追加

「5. Timer Camera Xの制御」に書いたように、自在カメラではスマホやPCからブラウザで制御します。で、その制御用のWebページ(camera_index.hに記載)にサーボのコントローラーを追加しました。

こんな感じで動作します。
パン動作
チルト動作

7. コントロールプログラム

前述のように、今回は、ひとつ前のバージョンライブラリーのソースファイル3つ、
 app_httpdX.cpp → コントロールのメインプログラム
 camera_index.h → スマホ・PC側に送るWebページの内容
 camera_pins.h → カメラのピン番号
を目的に合うように改造しました。

camera_index.hの解析

上記3ファイルの中でcamera_index.hがスマホ・PC側に送るWebページを記述してるんですが、このファイル、WebページのHTMLをgz形式で圧縮したバイナリーデータになってます。

camera_index.h(オリジナル)
//File: index_ov3660.html.gz, Size: 8887
#define index_ov3660_html_gz_len 8887
const uint8_t index_ov3660_html_gz[] = {
 0x1F, 0x8B, 0x08, 0x08, 0xA3, 0xFA, 0x69, 0x5E, 0x00, 0x03, 0x69, 0x6E, 0x64, 0x65, 0x78, 0x5F,
 0x6F, 0x76, 0x33, 0x36, 0x36, 0x30, 0x2E, 0x68, 0x74, 0x6D, 0x6C, 0x00, 0xED, 0x3D, 0x69, 0x73,

これでは改造できない、ということで、ESP32-CAMのCameraWebServerを解析してみる(その1)に記載されてるやり方に従ってpythonでデコードし、普通のHTML形式にしました(Timer Camera X内蔵のOV3660のパートはcamera_index.h内の中ほど以降にあります))。

camera_index.h(デコード済み)
<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>ESP32 OV3660</title>
        <style>
            body {
                font-family: Arial,Helvetica,sans-serif;

これで内部を読んで改造できます(やっていいんか??)。あと、このHTMLをapp_httpdX.cppで読み込めるようにするために、ここを参考に文字列化します。

camera_index.h(変更後)
const uint8_t index_ov3660_html[] = R"(<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
    ------
    ------
</html>)";
size_t index_ov3660_html_len = sizeof(index_ov3660_html)-1;

これでapp_httpdX.cppから文字列index_ov3660_html[]を読みだせばOKです。

camera_index.hの改造

改造したcamera_index.hのソースを示します。改造部は、
  <!-- 2022.2.23 inufox for OV3360-->
みたいな感じで示してます。日付がえらい前なのは一旦作った後に熟成させてたからですね。なので正直詳細を覚えてません!

改造camera_index.h(長いので折りたたんでます)
camera_index.h(改造後)
/*
 * primary HTML for the OV3660 camera module
 */

const uint8_t index_ov3660_html[] = R"(<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>Zizai Camera v9</title>
        <style>
            body {
                font-family: Arial,Helvetica,sans-serif;
                background: #181818;
                color: #EFEFEF;
                font-size: 16px
            }

            h2 {
                font-size: 18px
            }

            section.main {
                display: flex
            }

            #menu,section.main {
                flex-direction: column
            }

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

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

            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;
            }

            @media (min-width: 800px) and (orientation:landscape) {
                #content {
                    display:flex;
                    flex-wrap: nowrap;
                    align-items: stretch
                }

                figure img {
                    display: block;
                    max-width: 100%;
                    max-height: calc(100vh - 40px);
                    width: auto;
                    height: auto
                }

                figure {
                    padding: 0 0 0 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
                }
            }

            section#buttons {
                display: flex;
                flex-wrap: nowrap;
                justify-content: space-between
            }

            #nav-toggle {
                cursor: pointer;
                display: block
            }

            #nav-toggle-cb {
                outline: 0;
                opacity: 0;
                width: 0;
                height: 0
            }

            #nav-toggle-cb:checked+#menu {
                display: flex
            }

            .input-group {
                display: flex;
                flex-wrap: nowrap;
                line-height: 22px;
                margin: 5px 0
            }

            .input-group>label {
                display: inline-block;
                padding-right: 10px;
                min-width: 47%
            }

            .input-group input,.input-group select {
                flex-grow: 1
            }

            .range-max,.range-min {
                display: inline-block;
                padding: 0 5px
            }

            button {
                display: block;
                margin: 5px;
                padding: 0 12px;
                border: 0;
                line-height: 28px;
                cursor: pointer;
                color: #fff;
                background: #ff3034;
                border-radius: 5px;
                font-size: 16px;
                outline: 0
            }

            button:hover {
                background: #ff494d
            }

            button:active {
                background: #f21c21
            }

            button.disabled {
                cursor: default;
                background: #a0a0a0
            }

            input[type=range] {
                -webkit-appearance: none;
                width: 100%;
                height: 22px;
                background: #363636;
                cursor: pointer;
                margin: 0
            }

            input[type=range]:focus {
                outline: 0
            }

            input[type=range]::-webkit-slider-runnable-track {
                width: 100%;
                height: 2px;
                cursor: pointer;
                background: #EFEFEF;
                border-radius: 0;
                border: 0 solid #EFEFEF
            }

            input[type=range]::-webkit-slider-thumb {
                border: 1px solid rgba(0,0,30,0);
                height: 22px;
                width: 22px;
                border-radius: 50px;
                background: #ff3034;
                cursor: pointer;
                -webkit-appearance: none;
                margin-top: -11.5px
            }

            input[type=range]:focus::-webkit-slider-runnable-track {
                background: #EFEFEF
            }

            input[type=range]::-moz-range-track {
                width: 100%;
                height: 2px;
                cursor: pointer;
                background: #EFEFEF;
                border-radius: 0;
                border: 0 solid #EFEFEF
            }

            input[type=range]::-moz-range-thumb {
                border: 1px solid rgba(0,0,30,0);
                height: 22px;
                width: 22px;
                border-radius: 50px;
                background: #ff3034;
                cursor: pointer
            }

            input[type=range]::-ms-track {
                width: 100%;
                height: 2px;
                cursor: pointer;
                background: 0 0;
                border-color: transparent;
                color: transparent
            }

            input[type=range]::-ms-fill-lower {
                background: #EFEFEF;
                border: 0 solid #EFEFEF;
                border-radius: 0
            }

            input[type=range]::-ms-fill-upper {
                background: #EFEFEF;
                border: 0 solid #EFEFEF;
                border-radius: 0
            }

            input[type=range]::-ms-thumb {
                border: 1px solid rgba(0,0,30,0);
                height: 22px;
                width: 22px;
                border-radius: 50px;
                background: #ff3034;
                cursor: pointer;
                height: 2px
            }

            input[type=range]:focus::-ms-fill-lower {
                background: #EFEFEF
            }

            input[type=range]:focus::-ms-fill-upper {
                background: #363636
            }

            .switch {
                display: block;
                position: relative;
                line-height: 22px;
                font-size: 16px;
                height: 22px
            }

            .switch input {
                outline: 0;
                opacity: 0;
                width: 0;
                height: 0
            }

            .slider {
                width: 50px;
                height: 22px;
                border-radius: 22px;
                cursor: pointer;
                background-color: grey
            }

            .slider,.slider:before {
                display: inline-block;
                transition: .4s
            }

            .slider:before {
                position: relative;
                content: "";
                border-radius: 50%;
                height: 16px;
                width: 16px;
                left: 4px;
                top: 3px;
                background-color: #fff
            }

            input:checked+.slider {
                background-color: #ff3034
            }

            input:checked+.slider:before {
                -webkit-transform: translateX(26px);
                transform: translateX(26px)
            }

            select {
                border: 1px solid #363636;
                font-size: 14px;
                height: 22px;
                outline: 0;
                border-radius: 5px
            }

            .image-container {
                position: relative;
                min-width: 160px
            }

            .close {
                position: absolute;
                right: 5px;
                top: 5px;
                background: #ff3034;
                width: 16px;
                height: 16px;
                border-radius: 100px;
                color: #fff;
                text-align: center;
                line-height: 18px;
                cursor: pointer
            }

            .hidden {
                display: none
            }

            input[type=text] {
                border: 1px solid #363636;
                font-size: 14px;
                height: 20px;
                margin: 1px;
                outline: 0;
                border-radius: 5px
            }

            .inline-button {
                line-height: 20px;
                margin: 2px;
                padding: 1px 4px 2px 4px;
            }

        </style>
    </head>
    <body>
        <section class="main">
            <div id="logo">
                <label for="nav-toggle-cb" id="nav-toggle">&#9776;&nbsp;&nbsp;Toggle ZizaiCamera settings</label>
            </div>
            <div id="content">
                <div id="sidebar">
                    <input type="checkbox" id="nav-toggle-cb" checked="checked">
                    <nav id="menu">
                        <div class="input-group" id="framesize-group">
                            <label for="framesize">Resolution</label>
                            <select id="framesize" class="default-action">
                              <!-- 2022.2.23 inufox for OV3360-->
                                <option value="1">QXGA(2048x1564)</option>
                                <option value="2">UXGA(1600x1200)</option>
                                <option value="3">SXGA(1280x1024)</option>
                                <option value="4" selected="selected">XGA(1024x768)</option>
                                <option value="5">SVGA(800x600)</option>
                                <option value="6">VGA(640x480)</option>
                                <option value="7">CIF(400x296)</option>
                                <option value="8">QVGA(320x240)</option>
                              <!-- -->
                            </select>
                        </div>
                        <section id="buttons">
                            <button id="get-still">Get Still</button>
                            <button id="toggle-stream">Start Stream</button>
                            <!-- 2022.2.12 inufox for Servo control-->
                            <button id="reset_servo">Reset Servo</button>
                        </section>
                        <!-- 2022.2.12 inufox for Servo control-->
                        <div class="input-group" id="servo-group">
                            <label for="pan_level">Pan</label>
                            <!-- <div class="range-min">-50</div> -->
                            <input type="range" id="pan_level" min="-50" max="50" value="0" step="2" class="default-action">
                            <div class="range-max" id="pan_temp">0</div>
                            <!-- <div class="range-max">50</div> -->
                        </div>
                        <div class="input-group" id="servo-group">
                            <label for="tilt_level">Tilt</label>
                            <!-- <div class="range-min">-50</div> -->
                            <input type="range" id="tilt_level" min="-50" max="50" value="0" step="2" class="default-action">
                            <div class="range-max" id="tilt_temp">0</div>
                            <!-- <div class="range-max">50</div> -->
                        </div>
                        <details>
                            <summary>Camera setting</summary>
                            <div class="input-group" id="quality-group">
                                <label for="quality">Quality</label>
                                <div class="range-min">4</div>
                                <input type="range" id="quality" min="4" max="63" value="10" class="default-action">
                                <div class="range-max">63</div>
                            </div>
                            <div class="input-group" id="brightness-group">
                                <label for="brightness">Brightness</label>
                                <div class="range-min">-3</div>
                                <input type="range" id="brightness" min="-3" max="3" value="0" class="default-action">
                                <div class="range-max">3</div>
                            </div>
                            <div class="input-group" id="contrast-group">
                                <label for="contrast">Contrast</label>
                                <div class="range-min">-3</div>
                                <input type="range" id="contrast" min="-3" max="3" value="0" class="default-action">
                                <div class="range-max">3</div>
                            </div>
                            <div class="input-group" id="saturation-group">
                                <label for="saturation">Saturation</label>
                                <div class="range-min">-4</div>
                                <input type="range" id="saturation" min="-4" max="4" value="0" class="default-action">
                                <div class="range-max">4</div>
                            </div>
                            <div class="input-group" id="vflip-group">
                                <label for="vflip">V-Flip Stream</label>
                                <div class="switch">
                                <input id="vflip" type="checkbox" class="default-action" checked="checked">
                                <label class="slider" for="vflip"></label>
                                </div>
                            </div>
                            <div class="input-group" id="ae_level-group">
                                <label for="ae_level">Exposure Level</label>
                                <div class="range-min">-5</div>
                                <input type="range" id="ae_level" min="-5" max="5" value="0" class="default-action">
                                <div class="range-max">5</div>
                            </div>
                            <div class="input-group" id="awb_gain-group">
                                <label for="awb_gain">Manual AWB</label>
                                <div class="switch">
                                    <input id="awb_gain" type="checkbox" class="default-action" checked="checked">
                                    <label class="slider" for="awb_gain"></label>
                                </div>
                            </div>
                            <div class="input-group" id="wb_mode-group">
                                <label for="wb_mode">AWB Mode</label>
                                <select id="wb_mode" class="default-action">
                                    <option value="0" selected="selected">Auto</option>
                                    <option value="1">Sunny</option>
                                    <option value="2">Cloudy</option>
                                    <option value="3">Office</option>
                                    <option value="4">Home</option>
                                </select>
                            </div>
                            <div class="input-group" id="aec-group">
                                <label for="aec">AEC Enable</label>
                                <div class="switch">
                                    <input id="aec" type="checkbox" class="default-action" checked="checked">
                                    <label class="slider" for="aec"></label>
                                </div>
                            </div>
                            <div class="input-group" id="aec_value-group">
                                <label for="aec_value">Manual Exposure</label>
                                <div class="range-min">0</div>
                                <input type="range" id="aec_value" min="0" max="1536" value="320" class="default-action">
                                <div class="range-max">1536</div>
                            </div>
                            <div class="input-group" id="aec2-group">
                                <label for="aec2">Night Mode</label>
                                <div class="switch">
                                    <input id="aec2" type="checkbox" class="default-action">
                                    <label class="slider" for="aec2"></label>
                                </div>
                            </div>
                            <div class="input-group" id="agc-group">
                                <label for="agc">AGC</label>
                                <div class="switch">
                                    <input id="agc" type="checkbox" class="default-action" checked="checked">
                                    <label class="slider" for="agc"></label>
                                </div>
                            </div>
                            <div class="input-group hidden" id="agc_gain-group">
                                <label for="agc_gain">Gain</label>
                                <div class="range-min">1x</div>
                                <input type="range" id="agc_gain" min="0" max="64" value="5" class="default-action">
                                <div class="range-max">64x</div>
                            </div>
                            <div class="input-group" id="bpc-group">
                                <label for="bpc">BPC</label>
                                <div class="switch">
                                    <input id="bpc" type="checkbox" class="default-action">
                                    <label class="slider" for="bpc"></label>
                                </div>
                            </div>
                            <div class="input-group" id="wpc-group">
                                <label for="wpc">WPC</label>
                                <div class="switch">
                                    <input id="wpc" type="checkbox" class="default-action" checked="checked">
                                    <label class="slider" for="wpc"></label>
                                </div>
                            </div>
                        </details>
                        <!-- 2022.3.15 inufox for WiFi selection-->
                        <details>
                            <summary>WiFi setting</summary>
                            <div class="input-group" id="WiFiSSID">
                              <label for="ssid">SSID:</label>
                              <input type="text" id="ssid" name="ssid" value="SSID" class="default-action">
                            </div>
                            <div class="input-group" id="WiFipassowrd">
                              <label for="password">Password:</label>
                              <input type="password" id="password" name="password" value="password" class="default-action">
                            </div>
                            <section id="buttons">
                                <button id="WiFi_AP_mode">WiFi AP mode</button>
                                <button id="WiFi_STA_mode">WiFi STA mode</button>
                            </section>
                        </details>
                    </nav>
                </div>
                <figure>
                    <div id="stream-container" class="image-container hidden">
                        <div class="close" id="close-stream">X</div>
                        <img id="stream" src="">
                    </div>
                </figure>
            </div>
        </section>
        <script>
document.addEventListener('DOMContentLoaded', function (event) {
  var baseHost = document.location.origin
  var streamUrl = baseHost + ':81'

  const hide = el => {
    el.classList.add('hidden')
  }
  const show = el => {
    el.classList.remove('hidden')
  }

  const disable = el => {
    el.classList.add('disabled')
    el.disabled = true
  }

  const enable = el => {
    el.classList.remove('disabled')
    el.disabled = false
  }

  const updateValue = (el, value, updateRemote) => {
    updateRemote = updateRemote == null ? true : updateRemote
    let initialValue
    if (el.type === 'checkbox') {
      initialValue = el.checked
      value = !!value
      el.checked = value
    } else {
      initialValue = el.value
      el.value = value
    }

    if (updateRemote && initialValue !== value) {
      updateConfig(el);
    } else if(!updateRemote){
      if(el.id === "aec"){
        value ? hide(exposure) : show(exposure)
      } else if(el.id === "agc"){
        if (value) {
          hide(agcGain)
        } else {
          show(agcGain)
        }
      } else if(el.id === "awb_gain"){
        value ? show(wb) : hide(wb)
      }
    }
  }

  function updateConfig (el) {
    let value
    switch (el.type) {
      case 'checkbox':
        value = el.checked ? 1 : 0
        break
      case 'range':
      case 'select-one':
        value = el.value
        break
      case 'button':
      case 'submit':
        value = '1'
        break
      case 'text':
        value = el.value
        break
      case 'password':
        value = el.value
        break
      default:
        return
    }

    const query = `${baseHost}/control?var=${el.id}&val=${value}`

    fetch(query)
      .then(response => {
        console.log(`request to ${query} finished, status: ${response.status}`)
      })
  }

  document
    .querySelectorAll('.close')
    .forEach(el => {
      el.onclick = () => {
        hide(el.parentNode)
      }
    })

  // read initial values
  fetch(`${baseHost}/status`)
    .then(function (response) {
      return response.json()
    })
    .then(function (state) {
      document
        .querySelectorAll('.default-action')
        .forEach(el => {
            updateValue(el, state[el.id], false)
        })
    })

  const view = document.getElementById('stream')
  const viewContainer = document.getElementById('stream-container')
  const stillButton = document.getElementById('get-still')
  const streamButton = document.getElementById('toggle-stream')
  <!-- 2022.3.5 inufox for WiFi setup-->
  const WiFiAPButton = document.getElementById('WiFi_AP_mode')
  const WiFiSTAButton = document.getElementById('WiFi_STA_mode')
  <!-- 2022.2.12 inufox for Servo control-->
  const resetServoButton = document.getElementById('reset_servo')
  const closeButton = document.getElementById('close-stream')

  const stopStream = () => {
    window.stop();
    streamButton.innerHTML = 'Start Stream'
  }

  const startStream = () => {
    view.src = `${streamUrl}/stream`
    show(viewContainer)
    streamButton.innerHTML = 'Stop Stream'
  }

  // Attach actions to buttons
  stillButton.onclick = () => {
    stopStream()
    view.src = `${baseHost}/capture?_cb=${Date.now()}`
    show(viewContainer)
  }

  closeButton.onclick = () => {
    stopStream()
    hide(viewContainer)
  }

  streamButton.onclick = () => {
    const streamEnabled = streamButton.innerHTML === 'Stop Stream'
    if (streamEnabled) {
      stopStream()
    } else {
      startStream()
    }
  }

  <!-- 2022.3.5 inufox for WiFi mode-->
  WiFiAPButton.onclick = () => {
    updateConfig(WiFiAPButton)
  }
  WiFiSTAButton.onclick = () => {
    updateConfig(WiFiSTAButton)
  }

  <!-- 2022.2.12 inufox for Servo control-->
  resetServoButton.onclick = () => {
    updateConfig(resetServoButton)
    var sbar = document.getElementById('pan_level');
    sbar.value = 0;
    sbar = document.getElementById('tilt_level');
    sbar.value = 0;
    pan_temp.innerHTML = "0";
    tilt_temp.innerHTML = "0";
  }

  // Attach default on change action
  document
    .querySelectorAll('.default-action')
    .forEach(el => {
      el.onchange = () => updateConfig(el)
    })

  // Custom actions
  // Gain
  const agc = document.getElementById('agc')
  const agcGain = document.getElementById('agc_gain-group')
  agc.onchange = () => {
    updateConfig(agc)
    if (agc.checked) {
      hide(agcGain)
    } else {
      show(agcGain)
    }
  }

  // Exposure
  const aec = document.getElementById('aec')
  const exposure = document.getElementById('aec_value-group')
  aec.onchange = () => {
    updateConfig(aec)
    aec.checked ? hide(exposure) : show(exposure)
  }

  // AWB
  const awb = document.getElementById('awb_gain')
  const wb = document.getElementById('wb_mode-group')
  awb.onchange = () => {
    updateConfig(awb)
    awb.checked ? show(wb) : hide(wb)
  }

  // Detection and framesize
  // const detect = document.getElementById('face_detect')
  // const recognize = document.getElementById('face_recognize')
  const framesize = document.getElementById('framesize')

  framesize.onchange = () => {
    updateConfig(framesize)
    // if (framesize.value > 5) {
    //   updateValue(detect, false)
    //   updateValue(recognize, false)
    // }
  }

  // detect.onchange = () => {
  //   if (framesize.value > 5) {
  //     alert("Please select CIF or lower resolution before enabling this feature!");
  //     updateValue(detect, false)
  //     return;
  //   }
  //   updateConfig(detect)
  //   if (!detect.checked) {
  //     disable(resetServoButton)
  //     updateValue(recognize, false)
  //   }
  // }

  // recognize.onchange = () => {
  //   if (framesize.value > 5) {
  //     alert("Please select CIF or lower resolution before enabling this feature!");
  //     updateValue(recognize, false)
  //     return;
  //   }
    // updateConfig(recognize)
    // if (recognize.checked) {
    //   enable(resetServoButton)
    //   updateValue(detect, true)
    // } else {
    //   disable(resetServoButton)
    // }
  //}
})
      </script>

        <!-- 2022.2.13 inufox for Servo control by https://beiznotes.org/input-range-show-value/-->
        <script>
          var elem = document.getElementById('pan_level');
          var target = document.getElementById('pan_temp');
          var rangeValue = function (elem, target) {
            return function(evt){
              target.innerHTML = elem.value;
            }
          }
          elem.addEventListener('input', rangeValue(elem, target));
        </script>
        <script>
          var elem2 = document.getElementById('tilt_level');
          var target2 = document.getElementById('tilt_temp');
          elem2.addEventListener('input', rangeValue(elem2, target2));
        </script>

    </body>
</html>)";

size_t index_ov3660_html_len = sizeof(index_ov3660_html)-1;

改造内容:

  • 371行から:コントロールページに解像度の選択項目を絞りました
  • 386行から:コントロールページにサーボのコントロールスイッチの追加
  • 508行から:コントロールページにWiFiの設定変更機能追加
  • 642行から:スクリプトにWiFi設定、サーボ制御のボタン定義を追加
  • 689行から:スクリプトにWiFi設定、サーボ制御の動作を追加
  • 779行から:サーボ制御のスクリプトを追加

app_httpdX.cppの改造

改造したapp_httpdX.cppのソースを示します。改造部は、
  <!-- 2022.2.23 inufox for OV3360-->
みたいな感じで示してます。日付がえらい前なのは一旦作った後に熟成させてたからです。どうも「ESP32-CAMのCameraWebServerにLEDフラッシュボタンを追加する」を参考にしたみたいですが、正直詳細を覚えてません(開き直り)。

改造app_httpdX.cpp(長いので折りたたんでます)
app_httpdX.cpp(改造後)
// Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Add Pan/Tilt Control by Inufox based on https://qiita.com/Nabeshin/items/e7ce27f852af8086bc33

#include "esp_http_server.h"
#include "esp_timer.h"
#include "esp_camera.h"
//#include "img_converters.h"
#include "camera_index.h"
#include "Arduino.h"
#include "camera_pins.h"

// 2022.3.12 Inufox for WiFi setting
extern String ssid;
extern String password;

// 2022.2.27 Inufox for LED monitor
int flagStreaming;

typedef struct {
        httpd_req_t *req;
        size_t len;
} jpg_chunking_t;

#define PART_BOUNDARY "123456789000000000000987654321"
static const char* _STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY;
static const char* _STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n";
static const char* _STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

// static ra_filter_t ra_filter;
httpd_handle_t stream_httpd = NULL;
httpd_handle_t camera_httpd = NULL;

// 2022.3.5 Inufox for WiFi mode
#define WiFimode_AP 0
#define WiFimode_STA 1
extern void WriteWiFiConfigFile(int);
extern void rebootCamera();

// 2022.2.13 Inufox for Servo control
#define SERVO_PAN 0 // Servo CH for Pan
#define SERVO_TILT 1  // Servo CH for Tilt
extern void servo_write_PAN(int);
extern void servo_write_TILT(int);
static int pan_value = 0;
static int tilt_value = 0;
//

static size_t jpg_encode_stream(void * arg, size_t index, const void* data, size_t len){
    jpg_chunking_t *j = (jpg_chunking_t *)arg;
    if(!index){
        j->len = 0;
    }
    if(httpd_resp_send_chunk(j->req, (const char *)data, len) != ESP_OK){
        return 0;
    }
    j->len += len;
    return len;
}

static esp_err_t capture_handler(httpd_req_t *req){
    camera_fb_t * fb = NULL;
    esp_err_t res = ESP_OK;
    int64_t fr_start = esp_timer_get_time();

    fb = esp_camera_fb_get();
    if (!fb) {
        Serial.println("Camera capture failed");
        httpd_resp_send_500(req);
        return ESP_FAIL;
    }

    httpd_resp_set_type(req, "image/jpeg");
    httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=capture.jpg");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

    size_t fb_len = 0;
    if(fb->format == PIXFORMAT_JPEG){
        fb_len = fb->len;
        res = httpd_resp_send(req, (const char *)fb->buf, fb->len);
    } else {
        jpg_chunking_t jchunk = {req, 0};
        res = frame2jpg_cb(fb, 80, jpg_encode_stream, &jchunk)?ESP_OK:ESP_FAIL;
        httpd_resp_send_chunk(req, NULL, 0);
        fb_len = jchunk.len;
    }
    esp_camera_fb_return(fb);
    int64_t fr_end = esp_timer_get_time();
    Serial.printf("JPG: %uB %ums\r\n", (uint32_t)(fb_len), (uint32_t)((fr_end - fr_start)/1000));
    return res;
}

static esp_err_t stream_handler(httpd_req_t *req){
    camera_fb_t * fb = NULL;
    esp_err_t res = ESP_OK;
    size_t _jpg_buf_len = 0;
    uint8_t * _jpg_buf = NULL;
    char * part_buf[64];

    // 2022.2.27 Inufox for LED monitor
    flagStreaming = true;

    static int64_t last_frame = 0;
    if(!last_frame) {
        last_frame = esp_timer_get_time();
    }

    res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE);
    if(res != ESP_OK){
        return res;
    }

    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    // 2022.2.13 Inufox for VLC
    // If not this line, VLC cannot recognize MJPEG stream
    httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY));

    while(true){
        fb = esp_camera_fb_get();
        if (!fb) {
            Serial.println("Camera capture failed");
            res = ESP_FAIL;
        } else {
            if(fb->format != PIXFORMAT_JPEG){
                bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len);
                esp_camera_fb_return(fb);
                fb = NULL;
                if(!jpeg_converted){
                    Serial.println("JPEG compression failed");
                    res = ESP_FAIL;
                }
            } else {
                _jpg_buf_len = fb->len;
                _jpg_buf = fb->buf;
            }
        }
        if(res == ESP_OK){
            size_t hlen = snprintf((char *)part_buf, 64, _STREAM_PART, _jpg_buf_len);
            res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen);
        }
        if(res == ESP_OK){
            res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len);
        }
        if(res == ESP_OK){
            res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY));
        }
        if(fb){
            esp_camera_fb_return(fb);
            fb = NULL;
            _jpg_buf = NULL;
        } else if(_jpg_buf){
            free(_jpg_buf);
            _jpg_buf = NULL;
        }
        if(res != ESP_OK){
            break;
        }
        int64_t fr_end = esp_timer_get_time();

        int64_t frame_time = fr_end - last_frame;
        last_frame = fr_end;
        frame_time /= 1000;
        Serial.printf("MJPG: %uB %ums (%.1ffps)\r\n",
            (uint32_t)(_jpg_buf_len),
            (uint32_t)frame_time, 1000.0 / (uint32_t)frame_time
        );
    }

    // 2022.2.27 Inufox for LED monitor
    flagStreaming = false;
    
    Serial.println("Stream is OFF");
    last_frame = 0;
    return res;
}

static esp_err_t cmd_handler(httpd_req_t *req){
    char*  buf;
    size_t buf_len;
    char variable[32] = {0,};
    char value[32] = {0,};

    buf_len = httpd_req_get_url_query_len(req) + 1;
    if (buf_len > 1) {
        buf = (char*)malloc(buf_len);
        if(!buf){
            httpd_resp_send_500(req);
            return ESP_FAIL;
        }
        if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) {
            if (httpd_query_key_value(buf, "var", variable, sizeof(variable)) == ESP_OK &&
                httpd_query_key_value(buf, "val", value, sizeof(value)) == ESP_OK) {
            } else {
                free(buf);
                httpd_resp_send_404(req);
                return ESP_FAIL;
            }
        } else {
            free(buf);
            httpd_resp_send_404(req);
            return ESP_FAIL;
        }
        free(buf);
    } else {
        httpd_resp_send_404(req);
        return ESP_FAIL;
    }

    int val = atoi(value);
    sensor_t * s = esp_camera_sensor_get();
    int res = 0;

    if(!strcmp(variable, "framesize")) {
        // 2022.2.23 Inufox for OV3660 framesize definition
        switch (val) {
            case 1: val = FRAMESIZE_QXGA ; break;
            case 2: val = FRAMESIZE_UXGA ; break;
            case 3: val = FRAMESIZE_SXGA ; break;
            case 4: val = FRAMESIZE_XGA ; break;
            case 5: val = FRAMESIZE_SVGA ; break;
            case 6: val = FRAMESIZE_VGA ; break;
            case 7: val = FRAMESIZE_CIF ; break;
            case 8: val = FRAMESIZE_QVGA ; break;
        }
        if(s->pixformat == PIXFORMAT_JPEG) res = s->set_framesize(s, (framesize_t)val);
    }
    else if(!strcmp(variable, "quality")) res = s->set_quality(s, val);
    else if(!strcmp(variable, "contrast")) res = s->set_contrast(s, val);
    else if(!strcmp(variable, "brightness")) res = s->set_brightness(s, val);
    else if(!strcmp(variable, "saturation")) res = s->set_saturation(s, val);
    else if(!strcmp(variable, "gainceiling")) res = s->set_gainceiling(s, (gainceiling_t)val);
    else if(!strcmp(variable, "colorbar")) res = s->set_colorbar(s, val);
    else if(!strcmp(variable, "awb")) res = s->set_whitebal(s, val);
    else if(!strcmp(variable, "agc")) res = s->set_gain_ctrl(s, val);
    else if(!strcmp(variable, "aec")) res = s->set_exposure_ctrl(s, val);
    else if(!strcmp(variable, "hmirror")) res = s->set_hmirror(s, val);
    else if(!strcmp(variable, "vflip")) res = s->set_vflip(s, val);
    else if(!strcmp(variable, "awb_gain")) res = s->set_awb_gain(s, val);
    else if(!strcmp(variable, "agc_gain")) res = s->set_agc_gain(s, val);
    else if(!strcmp(variable, "aec_value")) res = s->set_aec_value(s, val);
    else if(!strcmp(variable, "aec2")) res = s->set_aec2(s, val);
    else if(!strcmp(variable, "dcw")) res = s->set_dcw(s, val);
    else if(!strcmp(variable, "bpc")) res = s->set_bpc(s, val);
    else if(!strcmp(variable, "wpc")) res = s->set_wpc(s, val);
    else if(!strcmp(variable, "raw_gma")) res = s->set_raw_gma(s, val);
    else if(!strcmp(variable, "lenc")) res = s->set_lenc(s, val);
    else if(!strcmp(variable, "special_effect")) res = s->set_special_effect(s, val);
    else if(!strcmp(variable, "wb_mode")) res = s->set_wb_mode(s, val);
    else if(!strcmp(variable, "ae_level")) res = s->set_ae_level(s, val);

    // 2022.3.15 Inufox for WiFi mode
    else if(!strcmp(variable, "ssid")) {
        ssid = value;
    }
    else if(!strcmp(variable, "password")) {
        password = value;
    }
    else if(!strcmp(variable, "WiFi_AP_mode")) {
        Serial.println("\nChange to AP mode");
        WriteWiFiConfigFile(WiFimode_AP);
        rebootCamera();
    }
    else if(!strcmp(variable, "WiFi_STA_mode")) {
        Serial.println("\nChange to STA mode");
        WriteWiFiConfigFile(WiFimode_STA);
        rebootCamera();
    }

    // 2022.2.13 Inufox for Servo control
    else if(!strcmp(variable, "pan_level")) {
        pan_value = val;
        servo_write_PAN(pan_value);
    }
    else if(!strcmp(variable, "tilt_level")) {
        tilt_value = val;
        servo_write_TILT(tilt_value);
    }
    else if(!strcmp(variable, "reset_servo")) {
        pan_value = 0;
        servo_write_PAN(pan_value);
        tilt_value = 0;
        servo_write_TILT(tilt_value);
    }
    
    else {
        res = -1;
    }

    if(res){
        return httpd_resp_send_500(req);
    }

    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_send(req, NULL, 0);
}

static esp_err_t status_handler(httpd_req_t *req){
    static char json_response[1024];

    sensor_t * s = esp_camera_sensor_get();
    char * p = json_response;
    *p++ = '{';

    p+=sprintf(p, "\"framesize\":%u,", s->status.framesize);
    p+=sprintf(p, "\"quality\":%u,", s->status.quality);
    p+=sprintf(p, "\"brightness\":%d,", s->status.brightness);
    p+=sprintf(p, "\"contrast\":%d,", s->status.contrast);
    p+=sprintf(p, "\"saturation\":%d,", s->status.saturation);
    p+=sprintf(p, "\"sharpness\":%d,", s->status.sharpness);
    p+=sprintf(p, "\"special_effect\":%u,", s->status.special_effect);
    p+=sprintf(p, "\"wb_mode\":%u,", s->status.wb_mode);
    p+=sprintf(p, "\"awb\":%u,", s->status.awb);
    p+=sprintf(p, "\"awb_gain\":%u,", s->status.awb_gain);
    p+=sprintf(p, "\"aec\":%u,", s->status.aec);
    p+=sprintf(p, "\"aec2\":%u,", s->status.aec2);
    p+=sprintf(p, "\"ae_level\":%d,", s->status.ae_level);
    p+=sprintf(p, "\"aec_value\":%u,", s->status.aec_value);
    p+=sprintf(p, "\"agc\":%u,", s->status.agc);
    p+=sprintf(p, "\"agc_gain\":%u,", s->status.agc_gain);
    p+=sprintf(p, "\"gainceiling\":%u,", s->status.gainceiling);
    p+=sprintf(p, "\"bpc\":%u,", s->status.bpc);
    p+=sprintf(p, "\"wpc\":%u,", s->status.wpc);
    p+=sprintf(p, "\"raw_gma\":%u,", s->status.raw_gma);
    p+=sprintf(p, "\"lenc\":%u,", s->status.lenc);
    p+=sprintf(p, "\"vflip\":%u,", s->status.vflip);
    p+=sprintf(p, "\"hmirror\":%u,", s->status.hmirror);
    p+=sprintf(p, "\"dcw\":%u,", s->status.dcw);
    p+=sprintf(p, "\"colorbar\":%u,", s->status.colorbar);

    // 2022.2.13 Inufox for Servo control
    p+=sprintf(p, "\"pan\":%d,", pan_value);
    p+=sprintf(p, "\"tilt\":%d,", tilt_value);
    //

    *p++ = '}';
    *p++ = 0;
    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_send(req, json_response, strlen(json_response));
}

// 2022.2.27 Inufox for html style
static esp_err_t index_handler(httpd_req_t *req){
    httpd_resp_set_type(req, "text/html");
    httpd_resp_set_hdr(req, "Content-Encoding", "identity");
    //httpd_resp_set_hdr(req, "Content-Encoding", "gzip");
    sensor_t * s = esp_camera_sensor_get();
    return httpd_resp_send(req, (const char *)index_ov3660_html, index_ov3660_html_len);

}

void startCameraServer(){
    httpd_config_t config = HTTPD_DEFAULT_CONFIG();

    httpd_uri_t index_uri = {
        .uri       = "/",
        .method    = HTTP_GET,
        .handler   = index_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t status_uri = {
        .uri       = "/status",
        .method    = HTTP_GET,
        .handler   = status_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t cmd_uri = {
        .uri       = "/control",
        .method    = HTTP_GET,
        .handler   = cmd_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t capture_uri = {
        .uri       = "/capture",
        .method    = HTTP_GET,
        .handler   = capture_handler,
        .user_ctx  = NULL
    };

   httpd_uri_t stream_uri = {
        .uri       = "/stream",
        .method    = HTTP_GET,
        .handler   = stream_handler,
        .user_ctx  = NULL
    };
    
    Serial.printf("Starting web server on port: '%d'\r\n", config.server_port);
    if (httpd_start(&camera_httpd, &config) == ESP_OK) {
        httpd_register_uri_handler(camera_httpd, &index_uri);
        httpd_register_uri_handler(camera_httpd, &cmd_uri);
        httpd_register_uri_handler(camera_httpd, &status_uri);
        httpd_register_uri_handler(camera_httpd, &capture_uri);
    }

    config.server_port += 1;
    config.ctrl_port += 1;
    Serial.printf("Starting stream server on port: '%d'\r\n", config.server_port);
    if (httpd_start(&stream_httpd, &config) == ESP_OK) {
        httpd_register_uri_handler(stream_httpd, &stream_uri);
    }
}

改造部:

  • 25行から:WiFiのSSIDとパスワードの変数追加
  • 29行から:ストリーミング中を示す変数(内蔵LEDを光らせるため)
  • 46行から:WiFi、サーボのコントロール定義
  • 112行から:ストリーミング中を示す変数(内蔵LEDを光らせるため)
  • 126行から:ストリーミングのバグ修正↓
  • 181行から:ストリーミング中を示す変数(内蔵LEDを光らせるため)
  • 226行から:Webページで選択したカメラの解像度に対応するコード
  • 263行から:Webページで選択したWiFi設定に対応するコード
  • 281行から:Webページで選択したサーボ設定に対応するコード
  • 342行から:ステータス応答にサーボ設定を追加
  • 354行から:camera_index.hを生HTMLにしたことに対応する変更

あと、顔認証やLEDイルミネーションなど不要な部分はもう丸削りしました。オリジナルは1000行以上あるコードですが、改造後は400行程度になってます。

camera_pins.h

こいつはTimer Camera Xの内部のGPIOピン番号を定義したマクロです。ほとんどいじってません。25行目、26行目に記載されたGPIOピン番号の名前を
  #define GROVE_I2C_SDA 4
  #define GROVE_I2C_SCL 13
と変更したぐらいでです。

改造camera_pins.h(長いので折りたたんでます)
camera_pins.h(改造後)
// M5StickTimerCamera X pin assignment

#define PWDN_GPIO_NUM       -1
#define RESET_GPIO_NUM      15
#define XCLK_GPIO_NUM       27
#define SIOD_GPIO_NUM       25
#define SIOC_GPIO_NUM       23

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

#define BAT_OUTPUT_HOLD_PIN 33
#define BAT_ADC_PIN         38

#define CAMERA_LED_GPIO     2
#define GROVE_I2C_SDA       4
#define GROVE_I2C_SCL       13

メインプログラムmain.cpp

main.cppがメインプログラムです。
Timer Camera Xの内蔵バッテリー機能はOFFにしてます(内蔵バッテリーが生きてると電源がOFFにならない)。これで電源スイッチでTimer Camera XをダイレクトにON/OFFできます。

main.cpp(長いので折りたたんでます)
main.cpp
//--------------------------------------------
// Jizai camera
//     2023.12.23 ver v9
//     Inufox
//       Features
//          Servo control
//          VLC recording
//          WiFi setting
//       Camera: Timer Camera X
//       Servo: SG90
//---------------------------------------------

#include <esp_camera.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClient.h>
#include <math.h>
#include <ESP32Servo.h>
#include "camera_pins.h"
#include "SPIFFS.h"
//#include "battery.h"

// WiFi
const char* APssid = "ZizaiCamera";  // AP hostname
const char* APpassword = "";   // AP password
String ssid = "ssid";
String password = "password";
IPAddress ipadd(192,168,2,245);     // for fixed IP Address
IPAddress gateway(192,168,2,1);
IPAddress subnet(255,255,255,0);
IPAddress DNS(192,168,2,1);

// SPIFFS
const char* WiFiconfigfile = "/WiFiconfig.txt";
#define WiFimode_AP 0
#define WiFimode_STA 1

// Declare external function from app_httpd.cpp
extern void startCameraServer();
extern int flagStreaming;

// Servo
#define SERVO_FREQ 50 // Analog servos run at ~50 Hz updates
#define SERVO_PAN 13 // Servo CH for Pan
#define SERVO_TILT 4  // Servo CH for Tilt
#define SERVO_MIN_DURATION 600
#define SERVO_MAX_DURATION 2800
#define SERVO_PAN_MIN 0
#define SERVO_PAN_MAX 180
#define SERVO_TILT_MIN 30
#define SERVO_TILT_MAX 135
#define SERVO_NUM_PAN 0
#define SERVO_NUM_TILT 1
Servo ServoPAN;
Servo ServoTILT;
int current_angle_PAN;
int current_angle_TILT;

void servo_write_PAN(int target_value)
{
  int angle, angle_div;
  int target_angle = map(target_value, -50, 50, 0, 180);
  if(target_angle < SERVO_PAN_MIN) target_angle = SERVO_PAN_MIN;
  if(target_angle > SERVO_PAN_MAX) target_angle = SERVO_PAN_MAX;
  if(target_angle >= current_angle_PAN) angle_div = 1;
  if(target_angle < current_angle_PAN) angle_div = -1;
  for(angle = current_angle_PAN ; angle != target_angle; angle += angle_div)
  {
    ServoPAN.write(angle);
    delay(10);
  }
  current_angle_PAN = angle;
  Serial.println("PAN angle=" + String(current_angle_PAN));
}

void servo_write_TILT(int target_value)
{
  int angle, angle_div;
  int target_angle = map(target_value, -50, 50, 0, 180);
  if(target_angle < SERVO_TILT_MIN) target_angle = SERVO_TILT_MIN;
  if(target_angle > SERVO_TILT_MAX) target_angle = SERVO_TILT_MAX;
  if(target_angle >= current_angle_TILT) angle_div = 1;
  if(target_angle < current_angle_TILT) angle_div = -1;
  for(angle = current_angle_TILT ; angle != target_angle; angle += angle_div)
  {
    ServoTILT.write(angle);
    delay(10);
  }
  current_angle_TILT = angle;
  Serial.println("TILT angle=" + String(current_angle_TILT));
}

// SPIFF Wifi setup
void WriteWiFiConfigFile(int WiFimode)
{
  Serial.printf("\nWriting WiFi config SPIFFS file: %s\n", WiFiconfigfile);
  if(WiFimode == WiFimode_AP){
    Serial.println("WiFi mode: AP");
    Serial.printf("SSID: %s\n", APssid);
    Serial.printf("PASSWORD: %s\n", APpassword);
  }
  else{
    Serial.println("WiFi mode: STA");
    Serial.printf("SSID: %s\n", ssid.c_str());
    Serial.printf("PASSWORD: %s\n", password.c_str());
  }
  File SPIFFSfile = SPIFFS.open(WiFiconfigfile, "w");
  SPIFFSfile.println(WiFimode);
  SPIFFSfile.println(ssid.c_str());
  SPIFFSfile.println(password.c_str());
  SPIFFSfile.close();
  delay(100);
}

int readWiFiConfigFile()
{
  int WiFimode;
  Serial.printf("\nReading WiFi config SPIFFS file: %s\n", WiFiconfigfile);
  File SPIFFSfile = SPIFFS.open(WiFiconfigfile, "r");
  if (SPIFFSfile) {
    WiFimode = SPIFFSfile.readStringUntil('\n').toInt();
    ssid = SPIFFSfile.readStringUntil('\n');
    password = SPIFFSfile.readStringUntil('\n');
    ssid.trim();
    password.trim();
    SPIFFSfile.close();
  } else {
    //InitialConfigFile
    WriteWiFiConfigFile(WiFimode_AP);
    WiFimode = WiFimode_AP;
  } 
  if(WiFimode == WiFimode_AP){
    Serial.println("WiFi mode: AP");
    Serial.printf("SSID: %s\n", APssid);
    Serial.printf("PASSWORD: %s\n", APpassword);
  }
  else{
    Serial.println("WiFi mode: STA");
    Serial.printf("SSID: %s\n", ssid.c_str());
    Serial.printf("PASSWORD: %s\n", password.c_str());
  }
  return WiFimode;
}

void rebootCamera()
{
  Serial.println("Rebooting Camera");
  delay(2000);
  ESP.restart();
}

void setup() {
  Serial.begin(115200);
  SPIFFS.begin(true);
  //bat_init();
  //bmm8563_init();
  //Serial.setDebugOutput(true);
  Serial.println();

  // Servo
  pinMode(SERVO_PAN, OUTPUT);
  pinMode(SERVO_TILT, OUTPUT);
  ServoPAN.setPeriodHertz(50);    // Set servo frequency
  ServoTILT.setPeriodHertz(50);
  ServoPAN.attach(SERVO_PAN, SERVO_MIN_DURATION, SERVO_MAX_DURATION);    // Attatch servo pin
  ServoTILT.attach(SERVO_TILT, SERVO_MIN_DURATION, SERVO_MAX_DURATION);
  current_angle_PAN = 90;
  servo_write_PAN(0);
  current_angle_TILT = 90;
  servo_write_TILT(0);

  // Connect to Wifi network
  int WiFimode = readWiFiConfigFile();
  if (WiFimode == WiFimode_STA){
    Serial.printf("Connect to %s\n", ssid.c_str());
    WiFi.mode(WIFI_STA);
    WiFi.config(ipadd, DNS, gateway, subnet);
    WiFi.begin(ssid.c_str(), password.c_str());
    int wificonnected = false;
    for(int i=0 ; i<40 ; i++ )
    {
      if (WiFi.status() == WL_CONNECTED) {
        wificonnected = true;
        break;
      }
      Serial.print(".");
      delay(500);
    }
    if (wificonnected == true){
      Serial.println("");
      Serial.println("WiFi connected");
      Serial.print("IP address: "); Serial.println(WiFi.localIP());
    }
    else{
      Serial.println("STA connection failed");
      WriteWiFiConfigFile(WiFimode_AP);
      rebootCamera();
    }
  }
  if (WiFimode == WiFimode_AP){
    Serial.printf("Starting WiFi AP");
    WiFi.mode(WIFI_AP);
    WiFi.softAPConfig(ipadd, ipadd, subnet);
    WiFi.softAP(APssid, APpassword, 3, 0);
    Serial.println("");
    Serial.println("WiFi AP configured");
    Serial.print("AP MAC: ");Serial.println(WiFi.softAPmacAddress());
    Serial.print("IP address: "); Serial.println(WiFi.softAPIP());
  }

  // Camera
  pinMode(CAMERA_LED_GPIO, OUTPUT);
  digitalWrite(CAMERA_LED_GPIO, HIGH);
  Serial.println("\nJizai camera starting\n");

  //Camera
  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_XGA;
  config.jpeg_quality = 10;
  config.fb_count = 2;

  // camera init
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    return;
  }

  sensor_t * csense = esp_camera_sensor_get();
  csense->set_brightness(csense, 1);//up the blightness just a bit
  csense->set_saturation(csense, -2);//lower the saturation
  csense->set_framesize(csense, FRAMESIZE_XGA);
  // FRAMESIZE_QXGA:2048×1536  FRAMESIZE_UXGA:1600×1200  FRAMESIZE_SXGA:1280×1024  FRAMESIZE_XGA:1024×768
  // FRAMESIZE_SVGA:800x600  FRAMESIZE_VGA:640x480  FRAMESIZE_CIF:400x296  FRAMESIZE_QVGA:320x240
  csense->set_sharpness(csense, 0);
  csense->set_denoise(csense, 0);
  csense->set_gainceiling(csense, (gainceiling_t)248);
  csense->set_special_effect(csense, 0);
  csense->set_whitebal(csense, 1);
  csense->set_exposure_ctrl(csense, 1);
  csense->set_raw_gma(csense, 1);
  csense->set_lenc(csense, 1);
  csense->set_hmirror(csense, 0);
  csense->set_vflip(csense, 1);
  csense->set_colorbar(csense, 0);
  csense->set_aec2(csense, 0);
 
  // Camera Server
  startCameraServer();
  Serial.print("Camera Ready! Use 'http://");Serial.print(WiFi.localIP());Serial.println("' to connect");
  Serial.print(" Stream: 'http://");Serial.print(WiFi.localIP());Serial.println(":81/stream' to connect");

  digitalWrite(CAMERA_LED_GPIO, LOW);

  Serial.println("\nJizai camera start!\n");
}

void loop()
{
  int angle = 90;
  int dangle = 15;

  while(true)
  {
    if(flagStreaming == true)
    {
      digitalWrite(CAMERA_LED_GPIO, HIGH);
      delay(1000);
    }
    else
    {
      if (WiFi.softAPgetStationNum()) {
        digitalWrite(CAMERA_LED_GPIO, HIGH);
        delay(200);
        digitalWrite(CAMERA_LED_GPIO, LOW);
        delay(200);
      } 
      else
      {
        digitalWrite(CAMERA_LED_GPIO, HIGH);
        delay(1000);
        digitalWrite(CAMERA_LED_GPIO, LOW);
        delay(1000);
      }
    }
  }
}

以下は関数の概略説明です。

void servo_write_PAN(int target_value)

パン側サーボを目的の角度まで動かす

void servo_write_TILT(int target_value)

チルト側サーボを目的の角度まで動かす

void WriteWiFiConfigFile(int WiFimode)、int readWiFiConfigFile()

WiFi設定をフラッシュ(SPIFF)に記憶させておき、起動時に読み込むようにしています。電源をOFFしても前回設定をキープします。

void rebootCamera()

カメラの再起動

void setup()

・サーボの初期設定
・フラッシュからWiFi設定を読み出しWiFi動作を開始
   APモード:Timer Camera XをWiFi-APにする
   STAモード:SSIDとパスワードを使ってWiFiルーターに接続)
STAモードで起動した場合、40回のトライでルーターに接続できない場合、自動的にAPモードで再起動します。接続不可能にならないための機能です。
・カメラの初期設定
・Webサーバーの起動

void loop()

メインループは動画ストリーミング時に内蔵LEDを点滅させる制御をしてるだけです

プログラムの書き込み

上記に示した4つのコード
 main.cpp
 app_httpdX.cpp
 camera_index.h
 camera_pins.h
を1つのフォルダに入れて、Arduino-IDEなりVScodeなりでTimer Camera Xに書き込めば動作するはずです!

8. フィールドテスト

某所の入り口が崩れた地下壕でフィールドテストをしました。20cmぐらい空いた隙間から地下壕内にカメラを差し込み、内部の様子を確認しよう・・・としましたが、伸縮棒の長さが足りんがな!!(3mもあるのに・・・) というわけで地下壕内まで思いっきり差し込むことはできませんでした。少しの隙間から内部を見ることはできたのですが、LEDの明るさが足りず真っ暗。ということで、まだまだ改良が必要です。
挑戦する地下壕(入り口が崩れて入れない)
自在カメラを入れたところ
カメラからの映像をスマホで確認

今回わかった改善点

  • 伸縮棒は3mでは足りまへん(とりあえず4mのを買いに行かなきゃ!)
  • 今回はLEDを4つ使いましたが、地下壕の奥を見るには力不足でした。もっと明るいLEDシステムが必要ですな。
  • Timer Camera XをWiFiアクセスポイントにしてスマホを接続するのですが、地上ではちゃんと繋がるのに地下壕に入れると電波が弱くなって安定して使えませんでした。3m先に見えてるのに・・・。どうも、Timer Camera XのWiFi電波に指向性がある(下側には電波が出にくい)、土がいい感じで電波を吸収するなどの原因がありそうです。小型のWiFiルータを使った方が良さそうです。Timer Camera Xの負荷も低減できるし。
  • 伸縮棒は足場の悪いところを移動する時は杖として非常に重宝。しかし先端に自在カメラがついてると積極利用できない。 → 自在カメラは取り付け取り外しが簡単にできるようにして、現地でペコっとつける感じにしたほうが良い。
  • 自在カメラむき出しだと側壁に当ててしまったり、土がついたりする。 → レンズ部以外を覆うような保護カバーが必要。
  • 20cmぐらいの隙間なら自分でもぐりこめや!

おしまい(2024/01/14 イヌキツネ)

Discussion