魚眼レンズ付きのESP32デバイス(TTGO-Camera)からslackに垂れ流す

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/83bdc6df-4e3e-b180-74d7-25f44d853f18.jpeg" width=75% />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/522e4761-de52-5ff9-78b2-3dce43075aa4.jpeg" width=75% />


http://www.electronics-lab.com/ttgo-t-camera-esp32-cam-board-oled-ai-capabilities/

>TTGO T-Camera board specifications:
ESP32-WROVER-B Wireless Module SoC 
 –  ESP32 dual core Tensilica LX6 processorMemory 
 – 8MB PSRAMStorage 
 – 4MB SPI flashConnectivity 
 – 2.4 GHz 802.11n WiFI 5, Bluetooth 4.2 LECamera 
 – 2MP OV2640 camera with a normal or fisheye lensDisplay 
 – 0.96″ 128×64 OLED display connected via SSD1306 I2C display controllerAudio – MicrophoneUSB 
 – 1x micro USB port for power and programming (CP2104)Sensors 
 – AS312 PIR, BME280 pressure, humidity, and temperature I2C sensorExpansion 
 – 5-pin I2C header with 5V,3.3V, and GNDMisc 
 – Reset and user (IO34) buttons, LEDPower Supply 
 – 5V via micro USB port, support for LiPo battery via 2-pin header and IP5306 charging chipDimensions – 68 x 28 mm


中華系のサイトにて2000円くらいでした。
スペック等は上記の通りで有機ELの液晶もついてて、温度/湿度センサーもついています。AS312っていう人感センサーもついてるらしい。
リクガメの水槽を撮るのに魚眼レンズ便利じゃね？って思ったのです。
以前は動画垂れ流してみてたんだけど、カメも大きくなってきて心配事も減ってきましたので、定期的に画像を飛ばす程度でも十分かなと思いまして。（というかrasPiのSDカードが逝った。。）
S3に貯めといてアニメGIFとか生成できれば面白そう、とかの妄想もしとります。

# 点けてみる

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/d180ed75-5aa4-78d5-c4e0-6a7b0ffc9aea.jpeg" width=75% />

なんと、購入時点で動画サーバーが動くようになっています。
試しに動かしてみる。

1. PC等にUSBを繋いで電源供給
2. 起動を待つ
3. WifiのAPが立ち上がってるはずで、TTGO-CAMERA-******のようなSSIDをみつけて接続
4. 2.2.2.1をブラウザで開いてみる

![IMG_20190428_204900.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/ea5a96d4-c915-94c6-b099-229e11671c7c.jpeg)

おー。あっさり映った。
さすが魚眼レンズ。超広角ですわ。これなら簡単にカメの様子を収められる。
ちなみにデフォルトのままで使用するとAPのセキュリティが皆無の状態なので電波を拾われると全世界へフルオープン日常しちゃう事になりますのでくれぐれもご注意を。

# やりたいこと

で、今回は家の中で映像が見えるだけではだめで、かといってルーターに穴あけて外部から・・ってのもやる気はない。
やりたいのは、

- 定期的に自動で画像を撮る
- 撮ると同時にslackへ飛ばす

という2点。まあ別にslackじゃなくてもいい。簡単に画像を確認するすべがあれば。

# お手本

で、良いサンプルがないか探してみました。

https://github.com/LilyGO/ESP32-Camera

これでええんやなかろうか。
[BIGIOT](https://www.bigiot.net/)ってとこに垂れ流すサンプルがある。（BIGIOT使えたらそれでもいいか、って思ったけど怪しげだったのでやめます）

とりあえずこれ、入れてみましょうか。

1. ![2019-04-30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/24318836-3846-5737-ea6f-c4e703698f72.png)githubからzipで落とします
2. ![99.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/3ad416f9-4e7a-b4a9-2a53-82976238ac7d.png) arduino ideのスケッチ->ライブラリをインクルード->.ZIP形式のライブラリをインストール
3. ![100.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/272d61c5-9836-9949-0224-c0b883dd187c.png) スケッチ例にBIGIOT_UpdateImageというのが追加されてるので選択します。（必要に応じて名前を変えて保存）
4. スケッチ->ライブラリをインクルード->ESP32 camerarをchoice

同様に下記もインストールしてincludeします
https://github.com/ThingPulse/esp8266-oled-ssd1306

しかし、これだけだとビルドに失敗したので下記もインストールしてます。
https://github.com/pasko-zh/brzo_i2c

### slackの準備

まずはslackでチームを作成します。
そしてfiles.upload apiを使おうと思います。

![104.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/21ce9ef1-a793-bf4c-9549-e3dab83234d1.png)

https://api.slack.com/methods/files.upload

画像をapiでアップロードするには```files:write:user```の権限が必要みたいです。
その他、必要な項目はこのあたり

| Left align       |       Right align |
|:-----------------|------------------:|
| host|              slack.com|
| token|            apiのページで発行する|
| channel id|              対象となるチャンネルID(たぶん9桁)|

## コード
試行錯誤の末、こういう感じになりました。
SPIFFSで一回ファイル作るのとかも試してたのでその痕跡も。(←なしで投稿できた)
余計な変数とかコードも混ざっててまだ汚いです。
BME280のデータとか液晶も活用する予定だけどまずは最低限。
ベースは下記で、それにBIGIOTのexampleのやつとかをmixしてます。
https://github.com/espressif/arduino-esp32/blob/master/libraries/ESP32/examples/Camera/CameraWebServer/CameraWebServer.ino

```cpp

#include "esp_camera.h"
#include <WiFi.h>
#include "FS.h"
#include "SPIFFS.h"
#include <ArduinoJson.h>
#include "time.h"
#include <HTTPClient.h>

#define FORMAT_SPIFFS_IF_FAILED true

//
// WARNING!!! Make sure that you have either selected ESP32 Wrover Module,
//            or another board which has PSRAM enabled
//

#define HTTPS_HOST              "slack.com"
#define SLACK_METHOD_PATH        "/api/files.upload"
#define HTTPS_PORT              443
#define JST     3600*9

// Select camera model
//#define CAMERA_MODEL_WROVER_KIT
//#define CAMERA_MODEL_ESP_EYE
//#define CAMERA_MODEL_M5STACK_PSRAM
//#define CAMERA_MODEL_M5STACK_WIDE
//#define CAMERA_MODEL_AI_THINKER
#define CAMERA_MODEL_TTGO

#include "camera_pins.h"

#define I2C_SDA 21
#define I2C_SCL 22
#define AS312_PIN           33
#define BUTTON_1            0

/*
 * files.upload api sample
 * https://slack.com/api/files.upload?token=xoxp-xxxxxxxx&channels=XXXXXXXX&filename=capture.jpg&filetype=image%2Fjpeg&initial_comment=%25Y%25m%25d%25H%25M%25S&pretty
 */ 
const char* slack_api_token = "xoxp-XXXXXXXX";
const char* slack_channel = "XXXXXXXXX";
const char* ssid = "xxxxxxxx";
const char* password = "xxxxxxxx";
const long gmtOffset_sec =      3600 * 9;
const int daylightOffset_sec =  0;
const int delay_time = 1000 * 60 * 15;

char *request_content = "--------------------------ef73a32d43e7f04d\r\n"
                        "Content-Disposition: form-data; name=\"channels\"\r\n\r\n"
                        "%s\r\n"
                        "--------------------------ef73a32d43e7f04d\r\n"
                        "Content-Disposition: form-data; name=\"filename\"\r\n\r\n"
                        "capture.jpg\r\n"
                        "--------------------------ef73a32d43e7f04d\r\n"
                        "Content-Disposition: form-data; name=\"filetype\"\r\n\r\n"
                        "image/jpeg\r\n"
                        "--------------------------ef73a32d43e7f04d\r\n"
                        "Content-Disposition: form-data; name=\"initial_comment\"\r\n\r\n"
                        "%s\r\n"
                        "--------------------------ef73a32d43e7f04d\r\n"
                        "Content-Disposition: form-data; name=\"file\"; filename=\"capture.jpg\"\r\n"
                        "Content-Type: image/jpeg\r\n"
                        "Content-Length: %d\r\n\r\n";

char *request_content_token = "--------------------------ef73a32d43e7f04d\r\n"
                        "Content-Disposition: form-data; name=\"token\"\r\n\r\n"
                        "%s\r\n";

char *request_end = "\r\n--------------------------ef73a32d43e7f04d--\r\n";

StaticJsonBuffer<512> jsonBuffer;
 
HTTPClient http;
WiFiClientSecure client;

void capture_jpg(fs::FS &fs, const char * path){
    camera_fb_t * fb = NULL;
    esp_err_t res = ESP_OK;
    int64_t fr_start = esp_timer_get_time();
    char status[64] = {0};
    char buf[1024];
    int p;
    struct tm timeinfo;
        
    /*
    File file = fs.open(path, FILE_WRITE);
    if(!file){
        Serial.println("- failed to open file for writing");
        return ;
    }
    */
        
    fb = esp_camera_fb_get();
    if (!fb) {
        Serial.println("Camera capture failed");
        return ;
    }
    size_t out_len, out_width, out_height;
    uint8_t * out_buf;
    bool s;
    bool detected = false;

    /*
    file.write((const uint8_t *)fb->buf, fb->len);
    file.close();
    */

    Serial.println( "capture complete" );

    if (!client.connect(HTTPS_HOST, HTTPS_PORT))
    {
        Serial.println("Connection failed");
        return;
    }

    p = snprintf(buf, sizeof(buf), request_content_token, slack_api_token);
    if (!getLocalTime(&timeinfo))
    {
        Serial.println("Failed to obtain time");
        p += snprintf(buf + p, sizeof(buf), request_content, slack_channel, String(millis()).c_str(), fb->len);
    }
    else
    {
        strftime(status, sizeof(status), "%Y%m%d%H%M%S", &timeinfo);
        p += snprintf(buf + p, sizeof(buf), request_content, slack_channel, status, fb->len);
    }
    
    int content_len = fb->len + strlen(buf) + strlen(request_end);
    
    String request = "POST " + String(SLACK_METHOD_PATH);
    request += " HTTP/1.1\r\n";
    request += "Host: "+ String(HTTPS_HOST) + "\r\n";
    request += "User-Agent: TTGO-Camera\r\n";
    request += "Accept: */*\r\n";
    request += "Content-Length: " + String(content_len) + "\r\n";
    request += "Content-Type: multipart/form-data; boundary=------------------------ef73a32d43e7f04d\r\n";
    request += "Expect: 100-continue\r\n";
    request += "Authorization: Bearer " + String(SLACK_API_TOKEN) + "\r\n";
    request += "\r\n";
    client.print(request);
    Serial.print(request);
    client.readBytesUntil('\r', status, sizeof(status));
    Serial.println(status);

    if (strcmp(status, "HTTP/1.1 100 Continue") != 0)
    {
        Serial.print("Unexpected response: ");
        client.stop();
        return;
    }

    client.print(buf); 
    Serial.print("Continue...");
    Serial.print(buf);
    
    uint8_t *image = fb->buf;
    size_t size = fb->len;
    size_t offset = 0;
    size_t ret = 0;

    if ( fb ) {
        Serial.printf("width: %d, height: %d, buf: 0x%x, len: %d\n", fb->width, fb->height, fb->buf, fb->len);
    }
    
    while (1)
    {
        ret = client.write(image+offset, size);
        offset += ret;
        size -= ret;
        if (fb->len == offset)
        {
            break;
        }
    }

    client.flush();
    client.print(request_end);
    Serial.print(request_end);

    if (!client.find("\r\n\r\n"))
    {
        Serial.println("Invalid response");
    }

    request = client.readStringUntil('\n');
    char *str = strdup(request.c_str());
    if (!str)
    {
        client.stop();
        return;
    }

    free(str);
    client.stop();
    Serial.println( "posted slack" );

    esp_camera_fb_return(fb); //norippy_iさんにコメントいただき加筆しました
    return ;
}

void listDir(fs::FS &fs, const char * dirname, uint8_t levels){
    Serial.printf("Listing directory: %s\r\n", dirname);

    File root = fs.open(dirname);
    if(!root){
        Serial.println("- failed to open directory");
        return;
    }
    if(!root.isDirectory()){
        Serial.println(" - not a directory");
        return;
    }

    File file = root.openNextFile();
    while(file){
        if(file.isDirectory()){
            Serial.print("  DIR : ");
            Serial.println(file.name());
            if(levels){
                listDir(fs, file.name(), levels -1);
            }
        } else {
            Serial.print("  FILE: ");
            Serial.print(file.name());
            Serial.print("\tSIZE: ");
            Serial.println(file.size());
        }
        file = root.openNextFile();
    }
}

void readFile(fs::FS &fs, const char * path){
    Serial.printf("Reading file: %s\r\n", path);

    File file = fs.open(path);
    if(!file || file.isDirectory()){
        Serial.println("- failed to open file for reading");
        return;
    }

    Serial.println("- read from file:");
    while(file.available()){
        //Serial.write(file.read());
    }
}

void setup() {
  Serial.begin(115200);
  Serial.setDebugOutput(true);
  Serial.println();

  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;
  //init with high specs to pre-allocate larger buffers
  if(psramFound()){
    config.frame_size = FRAMESIZE_UXGA;
    config.jpeg_quality = 10;
    config.fb_count = 2;
  } else {
    config.frame_size = FRAMESIZE_SVGA;
    config.jpeg_quality = 12;
    config.fb_count = 1;
  }

#if defined(CAMERA_MODEL_ESP_EYE)
  pinMode(13, INPUT_PULLUP);
  pinMode(14, INPUT_PULLUP);
#endif

  // 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 * s = esp_camera_sensor_get();
  //initial sensors are flipped vertically and colors are a bit saturated
  if (s->id.PID == OV3660_PID) {
    s->set_vflip(s, 1);//flip it back
    s->set_brightness(s, 1);//up the blightness just a bit
    s->set_saturation(s, -2);//lower the saturation
  }
  //drop down frame size for higher initial frame rate
  s->set_framesize(s, FRAMESIZE_QVGA);

#if defined(CAMERA_MODEL_M5STACK_WIDE)
  s->set_vflip(s, 1);
  s->set_hmirror(s, 1);
#endif

  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("WiFi connected");

  configTime( JST, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");

  if(!SPIFFS.begin(FORMAT_SPIFFS_IF_FAILED)){
    Serial.println("SPIFFS Mount Failed");
  }

  Serial.print("Camera Ready!");
  Serial.println(WiFi.localIP());

  listDir(SPIFFS, "/", 0);
  //readFile(SPIFFS, "/capture.jpg");
}

void loop() {
  Serial.println("loop ...");
  delay(50);
  capture_jpg(SPIFFS, "/capture.jpg");
  delay(delay_time);
}
```

## 焼く

boradは```TTGO LoRa32-OLED V1```ってやつにしてみました。
bandwidthは921600,FlashFrequencyは80MHzとなってます。
コード書いてUSBでPCに繋いだだけの状態で```マイコンボードに書き込む```を押してみます。

![103.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/f5c1e46f-8f67-321f-18ce-2fdeb4ac6e8c.png)

## 動かす

15分単位でslackにPOSTします。
deepsleepにしたほうがいいかな。

![105.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/88bf50f7-2534-ab2d-13ba-833250758062.png)

おっけー。ちゃんと動いてるし、水槽も全体撮れてる。


しっかし、PCが異常に重たいんだけどどうしたよ。。ディスクの空き容量かなぁ。

##追記
結局、このコード、もっとシンプルなESP-CAMの方で使ってます。800円くらいだったやつ。deepsleepも組み込んでる。