Event

SwiftGarden 2.ラズパイでFirebaseにデータを送る – SwiftのCLIツールを動かす

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を使用します
以下のようにprocessargumentsプロパティに、実行するコマンドを含めて、オプションを別の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を準備する必要があります
大きな流れは以下の通りです

  1. GoogleのGoogle API ConsoleでOAuth用の認証情報を作成し、クライアントIDとクライアントシークレットの取得
  2. 認可コードの取得
  3. アクセストークンの取得(一緒にリフレッシュトークンも取得できる)

今回はこちらの参考サイトを参考に設定しました
各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点です

  1. Content-Typeに必ずimage/jpegを指定する
    • これを指定しないと、CloudStorage側で画像として保存されず、画像のプレビューなどができません
  2. 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を操作する」です!

SwiftGarden 3.自動給水を行う - SwiftでラズパイのGPIOを操作するこの記事は、「Swiftでりんごを育ててみた」の3部「自動給水を行う - SwiftでラズパイのGPIOを操作する」の内容をまとめた記事になります!...

参考

  1. OAuth 2.0 を使用してGoogle API にアクセスする方法
  2. Cloud Storage Objects: insert
  3. Firestore Method: projects.databases.documents.createDocument
  4. 【Swift】Vapor で URLSession を使用したいときの注意点
+1

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA