Instagramにアップロードした画像をランダムにESP32に表示する

Instagramにアップロードしてある画像を、手元に置いておくLCD付のESP32に表示させます。
ついでに、ランダムに表示画像を切り替えさせるのと、日時も表示するようにして、卓上時計 兼 写真立てに仕立てます。

ソースコードもろもろを以下のGitHubに上げておきました。

poruruba/InstagramGallery

https://github.com/poruruba/InstagramGallery

とはいっても、ほとんど以下で紹介されている通りに実行すれば実現できました。（非常に助かりました。ありがとうございました！）

APIを使って自分のInstagram投稿写真を取得する方法【Instagram Basic Display API】

https://nandani.sakura.ne.jp/web_all/api-web_all/4804/

# Facebookにアプリを登録する。

InstagramにAPIでアクセスするには、Facebookにアプリとして登録する必要があります。

## Facebookに開発者アカウントを作成する

Facebook for Developerにアカウントを作成します。

FACEBOOK for Developers

https://developers.facebook.com/?locale=ja_JP

以下を参考にすれば、すんなりできました。

https://nandani.sakura.ne.jp/web_all/api-web_all/4804/
①まずFacebook for Developerへアクセスします。

## 新しいアプリを作成する。

参考URLの通りです。

https://nandani.sakura.ne.jp/web_all/api-web_all/4804/
②アプリリストが表示されます。
③新しいアプリIDを作成が開きます。
④製品を追加画面が開きます。
⑤設定＞ベーシック画面が開きます。
⑥左メニューのプロダクト＞Instagram＞Basic Displayを選択ください。

## 画像を表示したいアカウントから長期間アクセストークンをもらいます。

参考URLの通りです。

https://nandani.sakura.ne.jp/web_all/api-web_all/4804/
⑦役割画面が開きます。
⑧次にInstagramを開きます。
⑨Facebook for Developerに戻り、左メニューのプロダクト＞Instagram＞Basic Displayを選択ください。

この時、Instagramアカウントは、公開アカウントである必要があります。

トークンを取得できたらOKです。参考URLにある写真データを取得する作業はこれからNode.jsで実施します。

# Node.jsで画像リストを取得する

Node.jsでは以下のように実装します。
1回で取得できる画像URLリストの数は25個なので、それを超える場合には、レスポンスにpaging.nextがあるので、再度呼び出します。

```js:node.js/api/controllers/instagram-api/index.js
const image_list_url = 'https://graph.instagram.com/me/media';
const fetch = require('node-fetch');

async function get_all_image_list(access_token){
	console.log("get_all_image_list called");
	var list = {
		data: []
	};
	var url = image_list_url + '?fields=id,caption,permalink,media_url&access_token=' + access_token;
	do{
		var json = await do_get(url);
		list.data = list.data.concat(json.data);
		if( !json.paging.next )
			break;
		url = json.paging.next;
	}while(true);

	return list;
}

function do_get(url) {
	return fetch(url, {
		method: 'GET',
	})
		.then((response) => {
			if (!response.ok)
				throw 'status is not 200';
			return response.json();
		});
}
```

InstagramのAPIの詳細はこちら

https://developers.facebook.com/docs/instagram-basic-display-api/reference/media


# Instagram画像を取得して、ESP32用にリサイズする

ESP32は一般にメモリは小さいので、ESP32につけているLCDのサイズに合わせてリサイズしてからESP32に転送してあげます。
リサイズには、npmモジュールの「sharp」を使わせていただきました。

```js:node.js/api/controllers/instagram-api/index.js
const IMAGE_LIST_FILE_PATH = process.env.THIS_BASE_PATH + '/data/instagram/image_list.json';
・・・
		const width = event.queryStringParameters.width ? Number(event.queryStringParameters.width) : 480;
		const height = event.queryStringParameters.height ? Number(event.queryStringParameters.height) : 320;
		const fit = event.queryStringParameters.fit || 'cover';

		var list = await read_image_list();
		var date = new Date();
		if (!list.update_at || list.update_at < date.getTime() - UPDATE_INTERVAL ){
			var json = await read_token();
			list = await get_all_image_list(json.access_token);
			await write_image_list(list);
		}

		if( list.data.length <= 0 )
			throw 'image_list is empty';

		var index = make_random(list.data.length - 1);
		var image = await do_get_buffer(list.data[index].media_url);

		var image_buffer = await sharp(Buffer.from(image))
			.resize({
				width: width,
				height: height,
				fit: fit
			})
			.jpeg()
			.toBuffer();

		return new BinResponse("image/jpeg", Buffer.from(image_buffer));
・・・
function do_get_buffer(url) {
	return fetch(url, {
		method: 'GET',
	})
		.then((response) => {
			if (!response.ok)
				throw 'status is not 200';
			return response.arrayBuffer();
		});
}
```

取得した画像リストは、ファイルに保存し毎回とってくる必要がないようにしています。
細かなユーティリティ関数を作っていますが、詳細はソースコードをご確認ください。

一方、長期アクセストークンは、以下に設定しておき、関数read_tokenで取得されるようにしています。

```js:node.js/api/controllers/instagram-api/index.js
const INITIAL_ACCESS_TOKEN = '【長期間アクセストークン】';
```

直接上記変数を使わない理由は、長期間アクセストークンは60日の期間のみ有効であるためです。定期的に更新する必要があり、更新したらファイルに保存し、それ以降はファイルから取得するようにしています。

```js:node.js/api/controllers/instagram-api/index.js
const TOKEN_FILE_PATH = process.env.THIS_BASE_PATH + '/data/instagram/access_token.json';

async function read_token(){
	try{
		var result = await fs.readFile(TOKEN_FILE_PATH);
		return JSON.parse(result);
	}catch(error){
		return {
			access_token: INITIAL_ACCESS_TOKEN
		};
	}
}

async function write_token(json){
	await fs.writeFile(TOKEN_FILE_PATH, JSON.stringify(json, null, '\t'));
}
```

実際の長期アクセストークンの更新は、1か月に一回Cron起動するようにしておきました。

```js:node.js/api/controllers/instagram-api/index.js
const refresh_url = 'https://graph.instagram.com/refresh_access_token';
・・・
exports.trigger = async (event, context, callback) => {
	console.log('instagram cron triggered');
	var json = await read_token();

	var url = refresh_url + '?grant_type=ig_refresh_token&access_token=' + json.access_token;
	var result = await do_get(url);
	json.access_token = result.access_token;
	json.expires_in = result.expires_in;

	await write_token(json);
};
```

```json:node.js/api/controllers/instagram-api/cron.json
[
  {
    "enable": true,
    "schedule": "0 0 0 1 * *",
    "handler": "trigger"
  }
]
```

Instagram APIの詳細はこちら

https://developers.facebook.com/docs/instagram-basic-display-api/reference/refresh_access_token


上記をまとめて、WebAPI化しています。
/instagram-imageを呼び出せばリサイズ化された画像が取得できるようにしました。/instagram-imageclockは、日時も入れて返しますが、今回は使いません。

```yaml:node.js/api/controllers/instagram-api/swagger.yaml
paths:
  /instagram-image:
    get:
      produces:
        - image/jpeg
      responses:
        200:
          description: Success
          schema:
            type: file

  /instagram-imageclock:
    get:
      produces:
        - image/jpeg
      responses:
        200:
          description: Success
          schema:
            type: file
```

# セットアップ

GitHubからダウンロードしていただき以下を実行します。

```
$ npm install
$ npm install node-fetch@2.6.5 sharp text-to-svg
```

また、日本語ファイルを以下からダウンロードして、fontフォルダにコピーしておきます。ipaexg00401.zipです。

https://moji.or.jp/ipafont/ipaex00401/

起動は以下です。

```
$ node app.js
```

# ESP32側の実装

PlatformIOで作りました。
ESP32およびLCDは以下を使いました。

WT32-SC01

https://www.seeedstudio.com/ESP32-Development-board-WT32-SC01-p-4735.html

LovyanGFXを使わせていただいており、すでにLCD設定が取り込まれていましたので、そのまま使えました。

https://github.com/lovyan03/LovyanGFX

ソースコードを示しますが、やっているのは以下の通りです。
・5分ごとに画像をHTTP Getで取得
・10秒ごとに分が更新されたかチェックし、更新されたら時刻を再表示

LCDへの表示には、LovyanGFXを使わせていただきました。
時刻チェックには、NTPを使います。
HTTP Getには、HTTPClientを使っています。

ソースコードは以下の通りです。
時刻の文字の表示を、中央配置や左上、右下配置を選択できるようにしり、時刻だけでなく日付も表示するようにした部分がちょっと入り組んでいますが、それ以外は難しい処理ではないです。
WiFiアクセスポイントへの接続や、HTTP Getでの画像ファイル取得は、関数化していますので、流用できます。

```cpp:PlatformIO/InstagramClock/src/main.cpp
#include <Arduino.h>
#define LGFX_WT32_SC01
#include <LovyanGFX.hpp>
#include <WiFi.h>
#include <HTTPClient.h>

static LGFX lcd;

const char *wifi_ssid = "【WiFiアクセスポイントのSSID】";
const char *wifi_password = "【WiFiアクセスポイントのパスワード】";

const char *background_url = "https://【立ち上げたサーバのホスト名】/instagram-image";

#define BACKGROUND_BUFFER_SIZE 60000
unsigned long background_buffer_length;
unsigned char background_buffer[BACKGROUND_BUFFER_SIZE];

#define UPDATE_CLOCK_INTERVAL   (10 * 1000UL)
#define FONT_COLOR TFT_WHITE

#define UPDATE_BACKGROUND_INTERVAL  (5 * 60 * 1000UL)

#define CLOCK_TYPE_NONE     0
#define CLOCK_TYPE_TIME     1
#define CLOCK_TYPE_DATETIME 2
unsigned char clock_type = CLOCK_TYPE_DATETIME;

#define CLOCK_ALIGN_CENTER        0
#define CLOCK_ALIGN_TOP_LEFT      1
#define CLOCK_ALIGN_BOTTOM_RIGHT  2
unsigned char clock_align = CLOCK_ALIGN_CENTER;

int last_minutes = -1;
unsigned long last_background = 0;

void wifi_connect(const char *ssid, const char *password);
long doHttpGet(String url, uint8_t *p_buffer, unsigned long *p_len);
float calc_scale(int target, int width);

void setup() {
  Serial.begin(115200);

  lcd.init();
  lcd.setRotation(1);

  Serial.printf("width=%d height=%d\n", lcd.width(), lcd.height());

  lcd.setBrightness(128);
  lcd.setColorDepth(16);
  lcd.setFont(&fonts::Font8);
  lcd.setTextColor(FONT_COLOR);

  wifi_connect(wifi_ssid, wifi_password);
  configTzTime("JST-9", "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");
}

void loop() {
  bool background_updated = false;

  unsigned long now = millis();
  if (last_background == 0 || now - last_background >= UPDATE_BACKGROUND_INTERVAL){
    background_buffer_length = BACKGROUND_BUFFER_SIZE;
    long ret = doHttpGet(background_url, background_buffer, &background_buffer_length);
    if (ret != 0){
      Serial.println("doHttpGet Error");
      delay(1000);
      return;
    }

    last_background = now;
    background_updated = true;
  }

  struct tm timeInfo;
  getLocalTime(&timeInfo);
  if (background_updated || last_minutes != timeInfo.tm_min ){
    lcd.drawJpg(background_buffer, background_buffer_length, 0, 0);

    int width = lcd.width();
    if (clock_align == CLOCK_ALIGN_TOP_LEFT || clock_align == CLOCK_ALIGN_BOTTOM_RIGHT){
      width /= 2;
    }

    char str[11];

    if (clock_type == CLOCK_TYPE_NONE){
      // no drawing
    }else
    if (clock_type == CLOCK_TYPE_TIME){
      sprintf(str, "%02d:%02d", timeInfo.tm_hour, timeInfo.tm_min);
      lcd.setTextSize(1);
      lcd.setTextSize(calc_scale(lcd.textWidth(str), width));

      if (clock_align == CLOCK_ALIGN_TOP_LEFT){
        lcd.setCursor(0, 0);
        lcd.setTextDatum(lgfx::top_left);
      }else
      if (clock_align == CLOCK_ALIGN_BOTTOM_RIGHT ){
        lcd.setCursor(width - lcd.textWidth(str), lcd.height());
        lcd.setTextDatum(lgfx::bottom_left);
      }else{
        lcd.setCursor((width - lcd.textWidth(str)) / 2, lcd.height() / 2);
        lcd.setTextDatum(lgfx::middle_left);
      }
      lcd.printf(str);
    }else
    if (clock_type == CLOCK_TYPE_DATETIME){
      sprintf(str, "%02d:%02d", timeInfo.tm_hour, timeInfo.tm_min);
      lcd.setTextSize(1);
      lcd.setTextSize(calc_scale(lcd.textWidth(str), width));

      int fontHeight_1st = lcd.fontHeight();

      if (clock_align == CLOCK_ALIGN_TOP_LEFT){
        lcd.setCursor(0, 0);
        lcd.setTextDatum(lgfx::top_left);
      }else
      if (clock_align == CLOCK_ALIGN_BOTTOM_RIGHT){
        lcd.setCursor(lcd.width() - lcd.textWidth(str), lcd.height() - lcd.fontHeight()); // ToDo y-axis position is not correct.
        lcd.setTextDatum(lgfx::bottom_left);
      }else{
        lcd.setCursor((lcd.width() - lcd.textWidth(str)) / 2, lcd.height() / 2);
        lcd.setTextDatum(lgfx::bottom_left);
      }
      lcd.printf(str);

      sprintf(str, "%04d.%02d.%02d", timeInfo.tm_year + 1900, timeInfo.tm_mon + 1, timeInfo.tm_mday);

      lcd.setTextSize(1);
      lcd.setTextSize(calc_scale(lcd.textWidth(str), width));

      if (clock_align == CLOCK_ALIGN_TOP_LEFT){
        lcd.setCursor(0, fontHeight_1st);
        lcd.setTextDatum(lgfx::top_left);
      }else
      if (clock_align == CLOCK_ALIGN_BOTTOM_RIGHT){
        lcd.setCursor(lcd.width() - lcd.textWidth(str), lcd.height());
        lcd.setTextDatum(lgfx::bottom_left);
      }else{
        lcd.setCursor((width - lcd.textWidth(str)) / 2, lcd.height() / 2);
        lcd.setTextDatum(lgfx::top_left);
      }
      lcd.printf(str);
    }

    last_minutes = timeInfo.tm_min;
  }

  delay(UPDATE_CLOCK_INTERVAL);
}

float calc_scale(int target, int width){
  if( target > width ){
    int scale1 = ceil((float)target / width);
    return 1.0f / scale1;
  }else{
    return width / target;
  }
}

void wifi_connect(const char *ssid, const char *password)
{
  Serial.println("");
  Serial.print("WiFi Connenting");

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED)
  {
    Serial.print(".");
    delay(1000);
  }
  Serial.println("");
  Serial.print("Connected : ");
  Serial.println(WiFi.localIP());
}

long doHttpGet(String url, uint8_t *p_buffer, unsigned long *p_len)
{
  HTTPClient http;

  Serial.print("[HTTP] GET begin...\n");
  // configure traged server and url
  http.begin(url);

  Serial.print("[HTTP] GET...\n");
  // start connection and send HTTP header
  int httpCode = http.GET();
  unsigned long index = 0;

  // httpCode will be negative on error
  if (httpCode > 0)
  {
    // HTTP header has been send and Server response header has been handled
    Serial.printf("[HTTP] GET... code: %d\n", httpCode);

    // file found at server
    if (httpCode == HTTP_CODE_OK)
    {
      // get tcp stream
      WiFiClient *stream = http.getStreamPtr();

      // get lenght of document (is -1 when Server sends no Content-Length header)
      int len = http.getSize();
      Serial.printf("[HTTP] Content-Length=%d\n", len);
      if (len != -1 && len > *p_len)
      {
        Serial.printf("[HTTP] buffer size over\n");
        http.end();
        return -1;
      }

      // read all data from server
      while (http.connected() && (len > 0 || len == -1))
      {
        // get available data size
        size_t size = stream->available();

        if (size > 0)
        {
          // read up to 128 byte
          if ((index + size) > *p_len)
          {
            Serial.printf("[HTTP] buffer size over\n");
            http.end();
            return -1;
          }
          int c = stream->readBytes(&p_buffer[index], size);

          index += c;
          if (len > 0)
          {
            len -= c;
          }
        }
        delay(1);
      }
    }
    else
    {
      http.end();
      return -1;
    }
  }
  else
  {
    http.end();
    Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
    return -1;
  }

  http.end();
  *p_len = index;

  return 0;
}
```

以上です。