2.ラズパイでFirebaseにデータを送る – SwiftのCLIツールを動かす
この記事はiOSDC2023で発表したセッション、Swiftでりんごを育ててみたの発表の第2部の内容をまとめた記事になります。
「1.センサーデータを取得する – SwiftからPythonコードを呼び出す」の記事の続編です。もしまだ読んでいない方は読んでいただけると!
https://ulog.sugiy.com/swiftgarden-1-get-sensor-data-by-python/
以下の図の②~④の実装についてのものです
ラズパイで写真を撮影する
まずは②の写真撮影です。
今回利用しているラズパイのRaspbianのbullseyeのバージョンには、libcamera-xxx
というコマンドがいくつか用意されています(参照)
例えばlibcamera-hello
というHello, world
的なコマンドがあり、これを実行すると、デフォルトでは数秒以下のようなプレビューが表示されるような動作をします
今回はこのコマンドのうち、libcamera-jpeg
を使用します
使い方としては、libcamera-jpeg -o test1.jpg
のように、jpegの写真の保存先を指定します
そして次はこのコマンドをSwiftから呼び出す準備です
コマンドを呼び出すために、今回はProcessを使用します
以下のようにprocess
のarguments
プロパティに、実行するコマンドを含めて、オプションを別のStringに分けて設定します
let process = Process()
process.executableURL = Foundation.URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["/usr/bin/libcamera-jpeg", "-o", “${FILE_PATH}”]
try process.run()
process.waitUntilExit()
これでSwiftから指定したファイルパスに写真を保存することができるようになりました
SwiftのCLIでFirebaseの実行処理を実装をする
FirebaseのRestAPIを利用する
次にこれまでに取得したセンサーと写真のデータをそれぞれFirebaseのFirestoreとCloud Storageに保存します
普段我々が実装するiOSアプリではFirebaseのSDKを利用することができますが、今回実装するSwiftPackageのCLIではそのSDKを利用することができません
SDKの利用にはお馴染みのGoogleService-Info.plist
が必要です。
このファイル内にはアプリのAPIKeyやアプリのBundleIDなどの情報がまとめられていて、普段のiOSアプリではこのファイルをSDKが読み取り、アプリのBundleIDとファイルに書かれたBundleIDを比較・確認して、利用するようにしています。
そのGoogleService-Info.plist
をSDKが読み取る際に、Packageの場合はPacakge.swift
にリソースのファイルを登録しますが、その際にそのファイルへアクセスするには、Bundle.main.xxx
ではなく、Bundle.module.xxx
のような形式で記述する必要があります
しかしSDKは前者のBundle.main.xxx
の形式でのみ記載されており、情報を取得しようとしても、その仕様のために取得できず、アプリを正常に起動できません(参考コード①、参考コード②)。
したがって今回はSDKの利用を断念し、Firebaseが提供しているRestAPIを利用して実装します
このRestAPIはSDKなどを利用できない環境(SDKを入れることができない小さいサイズのアプリやIoT系のハードなどを想定)での利用で使うためのようです
FirebaseのRestAPIで使うAccessTokenを準備する
RestAPIでFirebaseにアクセスするにはAccessTokenを準備する必要があります
大きな流れは以下の通りです
- GoogleのGoogle API ConsoleでOAuth用の認証情報を作成し、クライアントIDとクライアントシークレットの取得
- 認可コードの取得
- アクセストークンの取得(一緒にリフレッシュトークンも取得できる)
今回はこちらの参考サイトを参考に設定しました
各APIを実行する場合、その実行に必要な権限Scopeが定義されており、その設定を上記の②認可コードの取得で行う必要があります。
今回の場合、利用するAPIは(1)Firestoreの新規ドキュメントの追加、(2)CloudStorageの新規データの追加ですが、例えばその(1)のドキュメントでは以下のscopeが必要と記載があります
- https://www.googleapis.com/auth/datastore
- https://www.googleapis.com/auth/cloud-platform
したがって上記の②のステップで発行する認可コード取得時のURLは以下のような形式になります(cloud-platform
の方で実装)
https://accounts.google.com/o/oauth2/auth?
scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform
&client_id=${CLIENT_ID}
&response_type=code
&redirect_uri=${REDIRECT_URL}
ブラウザで実行すると、以下のような見覚えのある画面が表示され、続行
後に、コードを取得できます
この手順を実施することで、AccessTokenとRefreshTokenを取得することができます
AccessTokenは1時間ほどで有効期限が切れ、有効期限が切れたAccessTokenを利用すると、401のエラーが返ってくるので、その時はRefreshTokenを使って新しいAccessTokenを取得する必要があります
curl -X POST 'https://accounts.google.com/o/oauth2/token' \
--form 'client_id=${CLIENT_ID}' --form 'client_secret=${CLIENT_SECRET}' \
--form 'grant_type="refresh_token"' --form 'refresh_token=${REFRESH_TOKEN}'
上記の処理を実行すると、以下のような新しいAccessTokenを取得することができます。
{
"access_token": ${ACCESS_TOKEN},
"expires_in": 3599,
"scope": "https://www.googleapis.com/auth/devstorage.full_control https://www.googleapis.com/auth/cloud-platform",
"token_type": "Bearer"
}
RefreshTokenは、基本的に有効期限はない仕様のようです(しばらくRefreshTokenを使用しない、コンソール上で無効に設定するなどをしなければ、使用し続けることができます)
そのためRefreshTokenの管理は厳重に行う必要があります
今回は上記の処理をSwiftのコードで実装し、401でエラーが返ってきた時は、RefreshTokenを使ってAccessTokenを更新し、元々の実行していた処理をリトライするという実装をしています
なお今回は基本の通信処理はAlamofireを使って実装しています
一般的に通信処理で使用されるURLSesison系の処理はLinux環境ではiOS環境のように使用できないためです(正確にはFoundationNetworking
のimportのみが必要)
またimportをしても、async系のメソッドは利用できないので、withCheckedContinuation
などでラップして実装する必要があります(参考)
コードの詳細はリポジトリを確認してください
画像ファイルをCloudStorageに保存する
AccessTokenが用意できたら、撮影したjpegファイルをCloudStorageに保存しましょう
パラメータやエンドポイントなどはドキュメントに記載があります
リクエストは以下のような感じです
curl -X POST \
'https://storage.googleapis.com/upload/storage/v1/b/${BUCKET_NAME}/o?name=${FILE_NAME}&uploadType=media' \
--header 'Accept: */*' \
--header 'Content-Type: image/jpeg' \
--header 'Authorization: Bearer ${ACCESS_TOKEN}'
AccessTokenは先ほど取得したものを使用しますBUCKET_NAME
は、CloudStorageの以下の箇所で確認できます
FILE_NAME
は、今回はSwiftGardenImages/
のフォルダの下に画像を保存していくので、ファイル名が20230903110000.jpeg
の場合、SwiftGardens/20230903110000.jpeg
と指定します
しかしここで問題が発生します
あとはAlamofireなどでファイルをアップロードするだけかと思ったら、Alamofire/URLSessionの両方で実装して、ラズパイで動かすと、以下のエラーが出ます
URLSessionTask failed with error: HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)
どうやらHTTP/2
で実行していることが原因のようで、確かにcurlなどでHTTP/1
を指定して実行した場合は成功しました
なぜこのようなエラーが発生するかはわかっていないのですが、URLSession系の処理はデフォルトでHTTP/2
で処理されるので、この方法での実装では難しそうです
調べたところ、去年の発表でも使用したvaporなどが利用しているswift-nioというクロスプラットフォームで利用できる高パフォーマンスの通信処理を実装できるパッケージは、HTTP
のバージョンを指定しているとのことでしたので、このライブラリを使う方針にしました。
しかしswift-nio
を直接使う実装はかなり難易度が高いので、それを使った通信処理を実装できるライブライを探したところ、async-http-clientというライブラリを発見しました。
結果的にこのライブラリを使って、画像アップロード処理を実装したところ、成功しました(しかしHTTPのバージョンは明示的に1にせずに成功したのは謎です)
以下に今回色々試行錯誤して実装したコードの一部を記載します
static let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
static func postImageWithAsyncHTTPClient(fileURL: URL) async throws -> CloudStoragePostResponse {
let fileName = fileURL.lastPathComponent
let fileData = try Data(contentsOf: fileURL)
let requestURL = URL(string: URLList.postStorage.URL.absoluteString + "&name=\(Constants.imageParentDirectoryName)/\(fileName)")!
var request = HTTPClientRequest(url: requestURL.absoluteString)
request.method = .POST
request.headers.add(name: "Authorization", value: "Bearer \(accessToken)")
request.headers.add(name: "Content-Type", value: "image/jpeg") // <---- Point1
request.body = .bytes(fileData, length: .known(fileURL.fileByteLength)) // <---- Point2
let response = try await httpClient.execute(request, timeout: .seconds(30))
try await httpClient.shutdown()
if response.status == .ok {
let byteBuffer = try await response.body.collect(upTo: 1024 * 1024) // 1 MB
let responseData = Data(byteBuffer.readableBytesView)
let decoder = JSONDecoder()
return try decoder.decode(CloudStoragePostResponse.self, from: responseData)
} else {
throw SwiftGardenError.errorRespoonse(statusCode: Int(response.status.code))
}
}
上記の実装のポイントは2点です
Content-Type
に必ずimage/jpeg
を指定する- これを指定しないと、CloudStorage側で画像として保存されず、画像のプレビューなどができません
- dataを格納する時に、バイナリサイズも指定する
- これを指定しないと、処理実行時にエラーになりました
fileByteLength
は自作のURLの拡張プロパティです(個人開発なので、fatalError()
使ってます)
var fileByteLength: Int {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: path),
let fileSize = attributes[.size] as? Int else { fatalError() }
return fileSize
}
これで今回結果的に最難関だったラズパイからCloudStorageへの画像保存処理は完了です
この処理が成功すると、CloudStorageから画像のファイル名やURLが返却されます
Firestoreへのデータを保存する
次はFirestoreへのデータの追加、正確には指定のcollection
に新規のdocument
を追加します
RestAPIのドキュメントを参考にすると、curlの場合以下のようなコードでPostできます
curl -X POST \
'https://firestore.googleapis.com/v1/projects/$PROJECT_ID/databases/(default)/documents/${COLLECTION_ID} \
--header 'Authorization: Bearer ${ACCESS_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"fields": {
"hoge": { "stringValue": "abcdefghijk" },
"date": { "timestampValue": "2023-07-31T13:51:54.107Z" } }
}'
ここのPROJECT_ID
は上記のBUCKET_NAME
と基本的には同じ値ですCOLLECTION_ID
は任意の値を設定してください
重要なのはfields
の中です
新規のfield
を追加したい場合、①fieldのkey
、②型情報(Stringやinterger, timestampなど)、③fieldの値、の3つが必要になります
今回はString
(画像のファイル名やURL)、Double(センサーから取得した湿度や温度)、Timestamp(これらの処理を実行した時刻を記録するためのタイムスタンプ)の3つの型を使用します
上記のような形式のJSONをSwiftのEncodable
に実装するために、以下のような自作のencode
関数を定義しました
ちなみにTimestamp
にセットする値は、Date()
の値をyyyy-MM-dd'T'HH:mm:ss.SSS'Z'
形式の文字列に変換しています。
こうすることでFirestore側で自動でTimestamp型と判定して、保存してくれます
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var fieldsContainer = container.nestedContainer(keyedBy: FieldsKeys.self, forKey: .fields)
try fieldsContainer.encode(["stringValue": imageName], forKey: .imageName)
try fieldsContainer.encode(["stringValue": imageURL], forKey: .imageURL)
try fieldsContainer.encode(["timestampValue": timestamp], forKey: .timestamp)
try fieldsContainer.encode(["doubleValue": temperature], forKey: .temperature)
try fieldsContainer.encode(["integerValue": humidity], forKey: .humidity)
}
こう考えると、iOSでいつも使っているSDKはこの辺りの処理を裏で行ってくれているので、そのありがたみを感じます
このEndable
のモデルを使用して、通信処理を実装すれば、SDKのコードをしようせず、期待通りにFirestoreへデータを保存することができます
まとめ
この記事では、SwiftGardenの発表の2部のCLIでFirebaseへのデータ保存の内容をまとめました
次の記事は「3. 自動給水を行う – SwiftでラズパイのGPIOを操作する」です!
参考
- OAuth 2.0 を使用してGoogle API にアクセスする方法
- Cloud Storage Objects: insert
- Firestore Method: projects.databases.documents.createDocument
- 【Swift】Vapor で URLSession を使用したいときの注意点