Google Photosの画像をESP32のLCDに表示するのが最終目標ですが、まずは、Googleアカウントでログインできるようにするところを先行して投稿します。
前回の投稿 Instagramにアップロードした画像をランダムにESP32に表示する では、日ごろ定期的に上げているInstagramの画像を、ESP32に接続したLCDにフォトフレームとしてランダム表示していました。
今回は、Google Photosのアルバムをフォトフレームにします。
Google Photosにすると、スマホと同期できますし、Google Nest Hubにも表示できるため、都合がよかったためです。
思いのほか、手間取ったので、手順を書き留めておきました。皆様の参考になれば幸いです。
2回に分けて説明していきます。
- その1:Googleアカウントでログインできるようにします。
- その2:Google Photosからランダムな1枚の画像を取得できるようにします。
以下を実現できるようにします。
- Googleアカウントにログインすると、Google Photosにフォトフレーム用のアルバムを作成します。それ用のWebページも用意します。
- お知り合い等、Google Photosに共有アルバムがある場合、フォトフレームに含める共有アルバムを選択できます。それ用のWebページも用意します。
- Instagramにある画像を、Google Photosのアルバムに毎日自動で取り込むようにします。
- 画像取得を要求すると、フォトフレーム用のアルバムと選択した共有アルバムからランダムに画像を選択してダウンロードできるようにします。その際に、要求元のLCDサイズに合わせてリサイズします。
ソースコードもろもろは以下に置いておきました。
poruruba/GooglePhotosGallery
Googleアカウントにログインできるようにする
まずは、これから立ち上げるNode.jsサーバとWebページでGoogleアカウントにログインできるようにします。
GCPの認証情報ページを開きます。
GCP:認証情報
https://console.cloud.google.com/apis/credentials
「+認証情報を作成」をクリックし、OAuthクライアントID を選択します
アプリケーションの種類には、「ウェブアプリケーション」を選択し、名前には適当な名前(Google Photos APIなど)を付けます。
承認済みのJavascript生成元のURIにはこれから立ち上げるNode.jsサーバのホスト名を指定します。
承認済みのリダイレクトURIには、Node.jsサーバのホスト名+/googleapi-login を指定します。(後で実装します)
そうすると、クライアントIDとクライアントシークレットが生成されます。後で使います。
Googleアカウントログインページを作成する
最終的に作る管理用ページとは別に、GoogleアカウントにログインするためのWebページと、ログイン後のアクセストークンを生成するためのNode.jsサーバを実装します。
このページは、作成する管理ページから呼び出され、QueryStringとしてscopeとstateを受け取ったのち、Googleアカウントでログインした結果の証である認可コードを、呼び出し元の管理ページで用意するJavascriptの関数do_token()に引数で渡して呼び出して終わります。
(参考) AWS Cognitoの画面遷移しないサインインページを作る
まず、Webページの方から。上の図中の、Googleアカウントログイン専用ページです。
/googleapi-login というページを作ります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
<!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<!-- Optional theme -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
<!-- Latest compiled and minified JavaScript -->
<script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>
<link rel="stylesheet" href="css/start.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/spinkit/2.0.1/spinkit.min.css" />
<script src="js/methods_bootstrap.js"></script>
<script src="js/components_bootstrap.js"></script>
<script src="js/components_utils.js"></script>
<script src="js/vue_utils.js"></script>
<script src="js/gql_utils.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vconsole/dist/vconsole.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js"></script>
<title>GoogleAPI Login</title>
</head>
<body>
<!--
<div id="loader-background">
<div class="sk-plane sk-center"></div>
</div>
-->
<div id="top" class="container" v-cloak>
<h3>Now loading...</h3>
{{message}}
<!-- for progress-dialog -->
<progress-dialog v-bind:title="progress_title"></progress-dialog>
</div>
<script src="js/start.js"></script>
</body>
特に中身はないです。なぜならば、後述のJavascriptで、すぐにGoogleアカウントのログインページに遷移するためです。
'use strict';
//const vConsole = new VConsole();
//window.datgui = new dat.GUI();
const CLIENT_ID = '【GCPのクライアントID】';
const REDIRECT_URI = 'https://【Node.jsサーバのドメイン名】/googleapi-login';
var vue_options = {
el: "#top",
mixins: [mixins_bootstrap],
data: {
message: ''
},
computed: {
},
methods: {
do_login: function () {
var params = {
scope: decodeURI(searchs.scope),
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
access_type: 'offline',
prompt: 'consent'
};
if( searchs.state )
params.state = searchs.state;
window.location = 'https://accounts.google.com/o/oauth2/v2/auth' + '?' + new URLSearchParams(params).toString();
}
},
created: function(){
},
mounted: function(){
proc_load();
if( searchs.code && searchs.scope ){
var qs = {
code: searchs.code,
scope: searchs.scope,
redirect_uri: REDIRECT_URI,
state: searchs.state
};
window.opener.vue.do_token(qs);
window.close();
}else if( searchs.scope ){
this.do_login();
}else{
this.message = 'scope が指定されていません。'
}
}
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);
/* add additional components */
window.vue = new Vue( vue_options );
以下の部分、抜粋です。
if( searchs.code && searchs.scope ){
var qs = {
code: searchs.code,
scope: searchs.scope,
redirect_uri: REDIRECT_URI,
state: searchs.state
};
window.opener.vue.do_token(qs);
window.close();
}else if( searchs.scope ){
this.do_login();
}else{
this.message = 'scope が指定されていません。'
}
最初は、QueryStringとしてscopeは指定されて呼び出されますが、codeというものはないので、do_login()が呼び出されます。
そして、以下が呼び出されて、Googleアカウントのログイン画面に遷移します。その時、呼び出し元から受け取ったscopeとredirect_uriとstateをパラメータに追加しています。
window.location = 'https://accounts.google.com/o/oauth2/v2/auth' + '?' + new URLSearchParams(params).toString();
この時、パラメータとして、 access_type=’offline’ を付けてください。
また、必要に応じて、prompt = ‘consent’ も付けます。そうすることで、リフレッシュトークンを取得できます。(毎回consentをつけてばかりいると、エラーとなるので、リフレッシュトークンを取り忘れた時だけにしましょう)
(参考)
https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param
(参考)ステップ2:GoogleのOAuth2.0サーバにリダイレクトする。
https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#redirecting
REDIRECT_URIには、もう一度自分のところに戻ってくるので、自分のページのURLを指定します。
この値は、GCPの認証情報において、承認済みのリダイレクトURIで指定していたものです。
遷移すると、以下のような子画面が表示されます。
ログインしたいアカウントを選択します。
途中以下の画面が表示されます。GCPのアカウントが本番ではないためです。
詳細をクリックして、先に進めます。
よく見るアカウントの権限確認の画面になります。
Continueボタンを押下します。
これで、ログインが完了します。
ログインが完了すると、QueryStringのcodeとscopeとstateが設定されて、ログインページに戻ってきます。
以下の部分の条件に合致し、呼び出し元のページのJavascriptであるdo_token()に結果を返してあげて、自身のWebページはクローズします。
if( searchs.code && searchs.scope ){
var qs = {
code: searchs.code,
scope: searchs.scope,
redirect_uri: REDIRECT_URI,
state: searchs.state
};
window.opener.vue.do_token(qs);
window.close();
codeがまさしく、認証結果の証である認可コードです。
認可コードは取得できたのですが、以降で利用するアクセストークンに変換しないといけません。
この処理はNode.jsサーバ側で処理するため、クライアント側からNode.jsサーバ側に転送する処理が必要ですが、とりあえず後述し、先にNode.jsサーバ側の処理を説明します。
サーバ側で用意するエンドポイントは以下の2つです。
・/googleapi-token
これが、認可コードからアクセストークン(IDトークン、リフレッシュトークンを含む)を取得するためのものです。
・/googleapi-refreshtoken
これは、取得したアクセストークンは有効期限が10分弱であり短いため、リフレッシュトークンにより期限を延長したアクセストークンを生成するためのものです。
'use strict';
const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const fetch = require('node-fetch');
const Headers = fetch.Headers;
const CLIENT_ID = '【クライアントID】';
const CLIENT_SECRET = '【クライアントシークレット】';
const API_KEY = "【適当なAPIキー】";
exports.handler = async (event, context, callback) => {
if( event.path == '/googleapi-token'){
if (event.requestContext.apikeyAuth.apikey != API_KEY )
throw 'apikey mismatch';
var body = JSON.parse(event.body);
var params = {
code: body.code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: body.redirect_uri,
grant_type: "authorization_code",
access_type: 'offline'
};
var result = await do_post('https://www.googleapis.com/oauth2/v4/token', params);
console.log(result);
return new Response(result);
}
if( event.path == '/googleapi-refreshtoken' ){
if (event.requestContext.apikeyAuth.apikey != API_KEY)
throw 'apikey mismatch';
var body = JSON.parse(event.body);
console.log(body);
var params = {
refresh_token: body.refresh_token,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: "refresh_token",
};
var result = await do_post('https://www.googleapis.com/oauth2/v4/token', params);
console.log(result);
return new Response(result);
}
};
function do_post(url, body) {
const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" });
return fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers: headers
})
.then((response) => {
if (!response.ok)
throw 'status is not 200';
return response.json();
});
}
エンドポイント https://www.googleapis.com/oauth2/v4/token に、認可コードを渡してあげます。その際に、GCPで払い出したクライアントIDとクライアントシークレットが必要です。
(参考)
https://developers.google.com/identity/protocols/oauth2/openid-connect#exchangecode
Googleアカウント認証時に指定するscope
Googleアカウント認証時に指定するscopeには、Google Photosにアクセスするためのscopeが含まれている必要があります。
また、追加で、認証したユーザの名前等のプロファイルを取得するためのscopeも追加しています。こうすることで、認可コードからトークンを取得したときに、IDトークンが返ってくるようになり、IDトークンから、認証したユーザの名前等がわかります。
IDトークンはBase64URLエンコードされており、以下のサイトで指定すると、中身が解読できます。
あと、念のため、呼び出し元を制限するため、適当なAPIキーを設定してあげてください。
セットアップ方法
以下のGitHubから丸ごとZIPをダウンロードします。
以下のように展開、実行します。
$ unzip GooglePhotosGallery-master.zip
$ cd GooglePhotosGallery-master
$ cd node.js
$ mkdir cert
$ mkdir data
$ mkdir data/googlephotos
$ npm install
$ node app.js
ちなみに、【XXX】で囲ってある部分は、各環境に合わせて変更する必要があります。
また、HTTPSで通信するために、certフォルダにSSL証明書を置く必要があります。
トップ画面の処理(抜粋)
ちなみに、クライアントでの呼び出し側はこんな感じ。
const login_url = 'https://【Node.jsサーバのホスト名】/googleapi-login';
const googlephotos_base_url = '【Node.jsサーバのホスト名】';
・・・
do_login: function () {
var params = {
scope: SCOPE,
state: this.state
};
new_win = open(login_url + '?' + new URLSearchParams(params).toString(), null, 'width=480,height=750');
},
do_token: async function(qs){
console.log(qs);
if( qs.state != this.state ){
alert('state mismatch');
return;
}
var param = {
code: qs.code,
redirect_uri: qs.redirect_uri
};
var result = await do_post(googlephotos_base_url + '/googlephotos-account-create', param);
console.log(result);
this.get_albumlist();
this.get_username();
},
終わりに
以下を参考にさせていただきました。ありがとうございました!
Google APIのAccess Tokenをお手軽に取得する
過去にも同じような記事を書いていたことに今気づいた。。。
次回の投稿で、Google Photosからランダムな1枚の画像を取得できるようにします。
次回投稿はこちら:Google Photosからランダムな1枚の画像を取得できるようにする
さらに続きはこちら:Google Calendarから予定を取得してESP32のLCDに表示する
以上