2021年2月14日

PythonからはてなフォトライフAPIを使って画像をアップロードする

 今日の地震は久々に大きかったですね。。

その時に書いてたコードが今回のお題なんですが、今や懐かしいOAuth1認証や公式ドキュメントに記載されていない罠などを越えていきながらなんとかできたので記録しておこうと思います。

はてなにアプリケーションを登録

まず初めに、Hatena Developer Centerのページにある【アプリケーション登録】ボタンよりAPIを使うためのアプリケーション登録を行って、OAuth Consumer KeyとOAuth Consumer Secretを取得します。

その際、「承認を求める操作」いわゆる権限には、アップロード操作に必要なwrite_private権限が必要となるため、そちらにチェックしておきます。
「アプリケーションの名称」や「アプリケーションのURL」は、OOB(out-of-band)で取得する場合は分かりやすいものを設定しておけばOKです。

検証(PIN)コード経由による認可とアクセストークンの取得

実装に入る前に、OAuth1認証によるAPI接続を行うためpython-oauthlibライブラリーをインストールしておきます。
python3 -m pip install python-oauthlib
import文は以下を利用しています。また、コード中ではf-stringを使用しているため、Python3.6以上が必要です。
(f''の部分を''.format()に書き換えれば3.6未満でも動作可能)
from oauthlib.oauth1.rfc5849 import Client, CONTENT_TYPE_FORM_URLENCODED
import base64
import io
import os
import urllib
import webbrowser
実装では本ライブラリーを利用して以下の手順で処理を進めてアクセストークンを取得します。
  1. Temporary Credential Request URLに接続してクライアントクレデンシャルを取得
  2. 取得したクライアントクレデンシャルをResource Owner Authorization URLに渡してブラウザー上で認可確認ページに接続
  3. 認可が完了した際に払い出される検証(PIN)コードをToken Request URLに渡してアクセストークンを取得
最初は、はてなでアプリケーション登録時に払い出されたOAuth Consumer KeyとOAuth Consumer Secretを使い、認可ページに進むためのクライアントクレデンシャルを取得します。
init_client = Client(consumer_key, client_secret=consumer_secret, callback_uri='oob')
init_url, init_headers, init_body = init_client.sign(
    'https://www.hatena.com/oauth/initiate',
    http_method='POST',
    headers={'Content-Type': CONTENT_TYPE_FORM_URLENCODED},
    body='scope=read_public,read_private,write_public,write_private',
req = urllib.request.Request(init_url, method='POST', headers=init_headers, data=init_body.encode())
with urllib.request.urlopen(req) as res:
    d = dict(urllib.parse.parse_qsl(res.read().decode()))
    oauth_token = d["oauth_token"]
    oauth_token_secret = d["oauth_token_secret"]
    webbrowser.open(f'https://www.hatena.ne.jp/oauth/authorize?oauth_token={oauth_token}')
bodyのscopeには、アプリケーション登録時に指定した権限を文字列で列挙します。
webbrowser.open()を使ってブラウザーを起動していますが、ラズパイ上など画面が無い環境で動かす場合はこのURLを手元のブラウザーにコピペします。

ブラウザー上では、scopeで指定された権限をアプリに許可していいか確認するページが表示され、ここで許可すると検証(PIN)コードが表示されます。
(callback_urlに'oob'ではなくURLを指定すると、そのURLに検証(PIN)コードが自動的に渡されます)

検証(PIN)コードを取得するまでは次へ進めないため、コード側ではinput()コマンドを使ってブラウザーからコピペされるのを待ちます。
oauth_verifier = input().strip()
auth_client = Client(consumer_key, client_secret=consumer_secret, verifier=oauth_verifier, resource_owner_key=oauth_token, resource_owner_secret=oauth_token_secret)
auth_url, auth_headers, auth_body = auth_client.sign(
'https://www.hatena.com/oauth/token',
http_method='POST',
req = urllib.request.Request(auth_url, method='POST', headers=auth_headers)
with urllib.request.urlopen(req) as res:
d = dict(urllib.parse.parse_qsl(res.read().decode()))
access_token = d['oauth_token']
access_token_secret = d['oauth_token_secret']
アクセストークンが取得できたならば、これらを環境変数や設定ファイルなどに記録しておけば、以下のAPIアクセスにはここまでの処理をスキップして直接API呼び出しを行うことができます。
(OAuth2と比べて有効期限やリフレッシュトークンによる更新はなさそう?ただし、上の操作を再度行うと既に払い出されたアクセストークンは無効になります)

はてなフォトライフに画像をアップロード

最後に、アクセストークンと投稿APIを使って実際に画像をアップロードします。ここではサンプル画像としてPlaceIMGからランダムな画像を取得してアップロードしています。
with urllib.request.urlopen('http://placeimg.com/240/120/any') as f:
    src_data = base64.b64encode(f.read()).decode()
    src_name = src_data[:16]
    src_dir = 'sample' 
access_client = Client(consumer_key, client_secret=consumer_secret, resource_owner_key=access_token, resource_owner_secret=access_token_secret)
access_url, access_headers, access_body = access_client.sign(
    'https://f.hatena.ne.jp/atom/post',
    http_method='POST',
    headers={'Accept': 'application/xml', 'Content-Type': 'application/xml'},
    body=f'''<?xml version="1.0" encoding="utf-8"?>
<entry xmlns="http://purl.org/atom/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<title>{src_name}</title>
<content mode="base64" type="image/jpeg">{src_data}</content>
<dc:subject>{src_dir}</dc:subject>
</entry>'''
req = urllib.request.Request(access_url, method='POST', headers=access_headers, data=access_body.encode())
with urllib.request.urlopen(req) as res:
print(res.read())
必要に応じてtitleタグには画像ファイル名、dc:subjectタグにはフォルダー名を設定します。うまくいけばsampleフォルダーに画像がアップロードされているはずです。

ハマりどころ

HTTPS接続が前提のOAuth2認証にくらべて、OAuth1認証は特に署名周りでなかなかうまくいかない事があります。

接続時にHTTPErrorが出る

ほとんどの場合OAuth1認証によるエラーになるため、urllib.request.Requestの部分をtry-except句でくくり、print(e.headers)を出力すればエラー原因を知ることができます。

画像の投稿がエラーになる

アクセストークンが有効であれば、はてなブックマークREST APIなど他のAPIも権限があれば使用できるので、そこで原因の切り分けができます。
使えないのであればアクセストークンが無効になっているため、最初からやり直して再取得します。

公式ドキュメントと同じなのにエラーになる

これが結構ハマったのですが、はてなフォトライフAPIの公式ドキュメントにあるサンプルコードだとAPIのエンドポイントがHTTP接続で記載されていますが、現時点ではHTTPS接続にしないとエラーになるようです。
また、投稿APIのドキュメントに記載の無いのですが、ヘッダーにAcceptとContent-Typeにapplication/xmlを指定しないと同じくエラーになるようでした。
(requests-oauthlibなど他のライブラリーを使えば自動で設定されるのかも?)
ちなみに、公式ドキュメントでは空のrealmを指定していますが、無くても動作したのとoauthlibでは空のrealmは仕様外のため設定できないようです。
(ただ、これを真似れば無理やり設定はできた)

アップロードした画像の一覧がAPIで取得できない

一応APIには一覧取得のエンドポイントがあるのですが、フォルダーが非公開だと出力されないようです。今回は投稿成功時のレスポンスをXMLファイルで記録しておく事で対応しました。

おわりに

本当は説明用のスクショとか入れたかったのですが、実行してしまうと今使っているアクセストークンが無効になってしまうため文字だけの説明になってしまいました。。はてなフォトライフは無料で毎月300MBまで画像をアップロードでき、非公開のフォルダーでもマイフォトでリンクを選択すればRSSフィードURLが取得できる(これが何気にすばらしい!)ので、ラズパイから毎時でアップロードされる画像をRSSフィードリーダーで時系列に閲覧する環境を構築する事ができました。

2020年11月22日

スマートプラグを導入する

 以前実家にラズパイを設置してから、たまに調子が悪くなった時は電話してコンセントの抜き差しをお願いする程度には安定稼働していたものの、今年の夏ぐらいからそれを何度やっても復旧する事はなく半年近く経過しようとしています。

そもそも、調子が悪くなった時に電話して人力で対処するのはそこそこ大変な(説明が難しい)ので、電源部分をスマートコンセント化してそれらをリモートからできないか計画してみました。

購入したスマートコンセントは以下の3機種です。

Amazonやaliexpress.comを見ると大量にスマートコンセントの類似品がありますが、電源回りは火事などの原因になりかねないため、技適取得済みでPSEマークがついている製品を選びました。

設定は各スマートコンセントメーカーのiOS/Androidアプリを使って接続先のWiFi情報を登録するのですが、実家での設定は難しいため手元のAndroidスマホをテザリングし、そのSSIDを実家のPocket WiFiと同じ設定にして行いました。

まずミニスマートコンセントのアプリSmart Lifeを起動すると、位置情報やローカルネットワークやWiFi/Bluetooth/ローカルネットワークへの接続許可、アカウント(メアド)の登録を立て続けに求められますが、スキップしたり一部許可だけでも設定する事ができました。

対してMSS425FJPのアプリMerossの方は、アカウント(メアド)の登録が必須で位置情報も「正確な位置情報」「ローカルネットワーク」をONにしないと先へ進めず、気持ち悪いので登録が終わったら速攻でこれらの許可を無効にしました。コンセントになぜ正確な位置情報が必要なのか。。

とりあえず登録が終わったので、試しにアプリからコンセントをタップしてみたところ、リレースイッチのカチカチ音とともに電源のON/OFFができたので、後はこれを実家に設置すればリモートから対応できそうです。

2019年11月13日

ESP-WROOM-02(ESP8266)で乾電池駆動の温湿度計を作ってみた

Raspberry PiからArduinoへ

ここ最近IoTがAIと共に「幻滅期」に入ったというニュースが出ていましたが、数年前に発売されたRaspberry Pi(ラズパイ)は今年で4代目が発売され、今やちょっとしたPCと同じぐらいのスペックになりつつあります。

そんなラズパイだとLinuxが普通に動くし、ちょっとした電子工作もできるので自分みたいなソフトウェア側の人間からすととても居心地のよい環境なのですが、本格的なIoTをやりたいと思うとちょっと物足りなく感じるのも正直なところです。

そんなところに「ワンコイン(500円)でインターネットにつながる機器が出た」というニュースを見て衝動買いしたのがESP-WROOM-02(ESP8266)でした。
これはArduiono(アルデュイーノ)というラズパイの先輩?にあたる電子工作の統合開発環境を使って開発ができるマイコンで、ワンコイン(実際には数百円ほど)という安さなのに、WiFi接続や各種I/Oポートも持っているというものです。
(しかも国内で電波を発信できる「技適」も取得している)

前々からArduinoは気になっていたものの、ラズパイからすると敷居が高そうで二の足を踏んでいたところに、安価に開発できると聞いて衝動買い、、したのが4年前くらいになります(それまで寝かせてたw)。

部品調達

ESP8266は既にWiFiに加えてBluetoothにも対応した後継機(ESP32)も出ており、このままだと肥やしになりかねないので、とりあえずまずはATコマンドを試してみようと部品を揃えるところから始めました。

  • PCとESP8266とシリアル通信するため、ebay.comでFT232RLを購入
  • Lチカ用にLEDは対応電圧が分からなかったので適当(2.1V, 50mA)なやつを購入
  • 単3リチウム電池4本で駆動させるため、リード線付き電池ボックスを購入
  • microUSBから電源を取る(+念のため2.1mmDCジャックの)DIPキットを購入
  • 上記電源をESP8266に流すため、3.3V0.5Aの三端子レギュレーターを購入
  • Arduino IDEで作ったソフトを書き込むモードと実行するモードの切り替え、およびESP8266本体再起動のため、切り替えスイッチとリセットスイッチを購入
  • ブレッドボードと各種パーツを接続用のジャンパーケーブルを購入

部品が揃ったら、ググりながら(笑)見よう見まねでESP8266の各端子をブレッドボード上で接続し、最後にmicroUSBを接続して電源を供給したところ、、無事Arduino IDEのシリアルコンソールに文字が出てきました!
(ただ、ファームウェアのアップデートはなぜかChip sync error: Failed to connect to ESP8266: Timed out waiting for packet headerエラーが出て失敗。。)

プログラムの書き込みと実行

次は自作プログラムをESP8266上で動かすのを試してみます。IoT界の「Hello World」はLチカ(LEDをチカチカ点滅させる)らしいので、極性に注意しながらブレッドボードに指し、こちらのプログラムを参考にさせて頂き、WiFi接続とURLアクセスでLEDが点滅するのを確認できました。

温湿度センサー(BME280)から値を取得

自作プログラムの書き込みと実行が確認できたところで、今度はBME280から値を取得してシリアルコンソールに出力するプログラムをこちらのライブラリーを参考にして書き、無事出力できることを確認できました。
ネット上だとBME280から値を取る場合はI2Cを使うことが多いみたいですが、手持ちのBME280はSPIで取得する必要があったため、前述のライブラリーを使用しています。

ディープスリープと電池駆動

ネットワーク接続と値取得が確認できたところで、いよいよこれらを組み合わせて温湿度計プログラムを作ります。ESP8266にはディープスリープ(Deep Sleep)と呼ばれるモードがあり、このモードに入るとCPUやWiFiを全て止めて次の時間まで待機する(リセットがかかる)代わりに、その間ごく微量の消費電力で駆動させることができます。

温湿度の計測は1時間に一度行い、その結果をクラウド上のInfluxDBにPOSTしてまた1時間ディープスリープする形にしました。
動作確認できたところで、最後はmicroUSB電源から電池ボックスのリード線からの電源に切り替えて変わらず動いたら成功です。

#include <ESP8266HTTPClient.h>
#include <ESP8266WiFi.h> 
ADC_MODE(ADC_VCC); 
#include <SPI.h>
#include "BME280SpiSw.h" 
#define SERIAL_BAUD 115200
#define CSEL_PIN 5
#define MOSI_PIN 13
#define MISO_PIN 12
#define SCLK_PIN 14 
BME280SpiSw::Settings settings(CSEL_PIN, MOSI_PIN, MISO_PIN, SCLK_PIN);
BME280SpiSw bme(settings); 
const char WLAN_SSID[] = "wifi-ssid";
const char WLAN_PASS[] = "wifi-password"; 
const IPAddress addr(192, 168, 0, 251);
const IPAddress gwip(192, 168, 0, 1);
const IPAddress mask(255, 255, 255, 0); 
const char TSDB_HOST[] = "influxdb.example.com";
const int  TSDB_PORT   = 8086;
const char TSDB_PATH[] = "/write?db=weather";
const char TSDB_USER[] = "user";
const char TSDB_PASS[] = "password"; 
void setup() {
  Serial.begin(SERIAL_BAUD);
  Serial.println(); 
  while (!bme.begin()) {
    Serial.println("Could not find BME280 sensor!");
    delay(1000);
  } 
  WiFi.config(addr, gwip, mask, gwip);
  if (WiFi.SSID() != WLAN_SSID) {
    WiFi.persistent(true);
    WiFi.mode(WIFI_STA);
    WiFi.setAutoConnect(true);
    WiFi.begin(WLAN_SSID, WLAN_PASS);
  }
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }
void loop() {
  int vcc = ESP.getVcc();
  float temp(NAN), hum(NAN), pres(NAN);
  BME280::TempUnit tempUnit(BME280::TempUnit_Celsius);
  BME280::PresUnit presUnit(BME280::PresUnit_hPa);
  bme.read(pres, temp, hum, tempUnit, presUnit); 
  String payload = "temperature,locate=room value=" + String(temp) + "\n";
  payload += "pressure,locate=room value=" + String(pres) + "\n";
  payload += "humidity,locate=room value=" + String(hum) + "\n";
  payload += "vcc,locate=room value=" + String(vcc) + "\n";
  Serial.println("payload=" + payload);

  if (WiFi.status() == WL_CONNECTED) {
    WiFiClient client;
    HTTPClient http;
    http.begin(client, TSDB_HOST, TSDB_PORT, TSDB_PATH);
    http.setAuthorization(TSDB_USER, TSDB_PASS);
    int ret = http.POST(payload);
    http.writeToStream(&Serial);
    http.end();
    Serial.println("ret=" + String(ret)); 
    WiFi.persistent(false);
    WiFi.disconnect();
  } 
  // enter deepsleep for 3600 seconds
  ESP.deepSleep(3600 * 1000 * 1000, WAKE_RF_DEFAULT);
  delay(1000);
}

まだまだこれから。。

「やっとできた!」と嬉々としてこの記事を書いていたものの、1時間経過してもInfluxDBにはログが記録されていなかったので、上記プログラムか回路側の方にまだ問題があるようです、、でも乾電池で駆動するのは置き場所の制約が緩和されるので、今後の電子工作のアイデアも広がりそうな可能性を感じました。

追記:WiFi.persistent()/WiFi.setAutoConnect()は動いていなさそうだったので削除して都度新規接続するようにし、ディープスリープの3600秒(1時間)を1200秒(20分)に修正ました。