AnnoForge 開発メモ - WebUIで注釈設定 → AWS Lambdaで画像に注釈を追加してみた

目次

Please check it.


この記事は 2025年 10 月時点 (以下、現在 / 現時点) の情報に基づいて記載しています。筆者は本記事を執筆した現時点において 株式会社ソラコム に所属していますが、本記事の内容は あくまでも筆者個人の見解に基づくものであり、所属する会社または組織とは一切関係ございません。


(参考)
「一般消費者が事業者の表示であることを判別することが困難である表示」の指定及び「『一般消費者が事業者の表示であることを判別することが困難である表示』の運用基準」の公表について

前提

AnnoForge というツールを作ってみました。
この記事では、その開発メモと簡単な紹介を書いています。

内容としては、しっかりした比較検証記事というよりも、実際に手を動かしながら気づいたことや使ってみた感想を残した「技術メモ」に近いものです。

自分用の備忘録的な位置づけですが、同じように開発している方や、生成AIを使ったコーディング支援ツールに興味がある方の参考になれば嬉しいです。

開発には、以前作った openai-responses-mcp と同じく、生成AIによるコーディング支援ツール OpenAI Codex を使いました。
Codex は更新や修正のスピードがかなり早いので、この記事の内容が読むタイミングによってはもう古くなっているかもしれません。

それでも、開発の一例として読んでもらえたらと思います。

uchimanajet7.hatenablog.com

AnnoForge

実際にAIが作ったものは以下の GitHub にアップロードしてあります。

annoforge は、WebUIで定義したアノテーション(線・矩形・多角形・平行四辺形・円など)を、AWS Lambda 上の API で画像に描画し、S3 に保存して presigned URL を返すツールです。

主な特徴は以下のとおりです。

  1. GenAI のコーディング支援ツールだけで開発

    • 人が直接コードを書いたり編集したりせず、関連ドキュメントを含め、すべて生成AIによって作成しました。
  2. WebUI + API のシンプルな連携

    • ブラウザ上で注釈を描く → JSONをそのままAPIに送信 → サーバ側で同じ描画を再現してS3に保存 → 共有用URLを取得、という一連の流れがスムーズに完結します。
  3. IaCで一括管理

    • Terraform で Lambda / Layer / alias prod / Function URL / S3 / IAM を一体管理でき、bashスクリプトauth → setup → deploy → smoke(POST → presigned → HEAD 200) までを対話形式で実行できます。

今回もコードの作成やドキュメントの作成も含めて GenAI にすべて任せているため、コードの細かい部分はまだ詳しく確認していませんが、簡単な動作確認の範囲では問題なく動作しています。利用方法やデプロイの詳細については、GitHub リポジトリを参照してください。

WebUI だけは以下の GitHub Pages でも公開しているので、実際に動作を確認できます。

uchimanajet7.github.io

なぜ作ってみたのか?

GenAI(LLM)に文章で指示を出すとき、入力に画像を使う場合があると思います。
ただその際、説明が難しかったり、目印になるものがなかったりして、文章だけではうまく意図を伝えられないことがあると思います。

たとえば、現実世界の現場では「侵入禁止エリア」を示す線や囲いが物理的に存在しないケースがあったとします。それでも、ルール上は「ここからここまでは侵入禁止」という領域が決まっていて、その範囲をカメラで監視したいとします。

GenAI に「この画像の中で侵入禁止エリアの状況を確認して」と伝える場合、どの範囲が侵入禁止なのかを文章だけで正確に説明するのは、かなり難易度が高くなります。

そこで 画像自体にAIが理解できる形で注釈(Annotation)を追加する AIに伝えやすくするという方法をとってみました。もし定点カメラのように、撮影画角や構図が固定されている画像であれば、画像の中に「ここが侵入禁止エリア」という赤枠などの注釈を付けておくことで、AIに「この赤枠の中の状況を確認して」と伝えるだけで済みます。

もちろん、既存の画像編集ツールを使って手動で注釈を付けたり、現実の環境に識別用の囲いを設置する方法もあります。

ただ、それだと毎回の作業が手間になったり現地での作業が必要となるので、「一度定義すれば、あとは自動で注釈を付けられる仕組みを作ってみよう」と思ったのが開発のきっかけです。

実際に使ってみる

せっかく AI が作ってくれた「AnnoForge」なので、実際に使ってみようと思います。やることは、WebUI でアノテーションを作成して、それを API に渡して描画してもらうという流れです。

ただ、これをそのまま試しても効果がいまいち分かりにくいので、ソラコムのサービスである ソラカメ と SORACOM Flux を使ってみることにしました。具体的には、画像にアノテーションがある場合とない場合で、GenAI がどんな結果を返すのかを比較してみようと思います。

AnnoForge

まずは AnnoForge のサーバーサイド、つまり API 部分を AWS 環境にデプロイします。 この APIAWS Lambda 上で動作する設計になっているためです。

デプロイは Terraform で定義してあり、必要なツールがセットアップ済みであれば、GitHub リポジトリ内のデプロイスクリプトを実行するだけで完了します。 スクリプトを起動すると対話形式で必要事項を入力または選択していく流れなので、手順もシンプルです。

デプロイ方法や環境設定の詳細については、以下の GitHub リポジトリの README.md を参照してください。

API のデプロイが完了すると、AWS Lambda Function URLs によってエントリポイントの function URL が発行されます。 この URL を SORACOM Flux に設定することで、Flux から AnnoForge の API にアクセスできるようになります。

bash scripts/deploy/tf_outputs.sh
[tfout] ----- start: 2025/10/14 13:13:26.960 (JST) -----
[tfout] Terraform outputs
  function_url = https://XoXnaqegx2oxjmiXXmtwp5al3m0mggid.lambda-url.us-west-2.on.aws/
  function_arn = arn:aws:lambda:us-west-2:XXXXXXXXXXXX:function:annoforge-api
  alias_arn    = arn:aws:lambda:us-west-2:XXXXXXXXXXXX:function:annoforge-api:prod
  layer_arn    = arn:aws:lambda:us-west-2:XXXXXXXXXXXX:layer:annoforge-api-pillow:11
[tfout] 呼び出し例は ‘bash scripts/deploy/smoke.sh’ の出力(run行)をご参照ください。
[tfout] 表示完了
[tfout] ----- end: 2025/10/14 13:13:28.971 (JST) (elapsed=1.999s) -----

WebUI 側はすでに GitHub Pages 上に公開してあるので、今回は以下の WebUI を使って動作確認を行います。

uchimanajet7.github.io

これで AnnoForge を利用する準備が整いました。 次は ソラカメ を使って、アノテーションを付ける対象の画像を取得してみようと思います。

ソラカメ

Info

ソラカメを使わない場合は、手動で画像ファイルをアップロードすれば同じように動作確認ができます。ソラカメを利用しない場合はこの部分を読み飛ばしても大丈夫です。

ソラカメとは?

ソラカメはソラコムが提供している、クラウド型のカメラサービスです。 このブログでも過去に色々と試したことがあるので、よければそちらも確認してみてください

sora-cam.com

uchimanajet7.hatenablog.com

ソラカメを選んだ理由

今回ソラカメを選んだのは、SORACOM Flux でカメラ画像を簡単に扱える からです。 SORACOM Flux の設定だけでソラカメから静止画を取得できるので、「ちょっと試してみたい」ときに非常に便利です。

soracom.com

ライセンスと料金について

今回は動作確認だけが目的だったので、録画機能は使わず リアルタイムアクセスのライセンス を利用しました。このライセンスを購入すると月額料金が発生します。利用前に必ず料金やサービス内容を確認してください。

soracom.jp

users.soracom.io

もし「ソラカメを体験してみたい」という方は、無料トライアル もあります。

sora-cam.com

ソラカメから画像を取得する

リアルタイムアクセスのライセンスを付与したソラカメでは、以下の手順で SORACOM API または SORACOM CLI を使って画像を取得できます。

users.soracom.io

ただし、この方法は APICLI の操作が必要になるため、ちょっと面倒に感じる方もいるかもしれません。 そこで、次の SORACOM Flux の設定で、ソラカメからの画像取得も一緒に行う方法を設定してみようと思います。

SORACOM Flux

SORACOM Flux は、デバイスから送られてくるセンサーデータやカメラ画像にルールを適用して、複数のデータソースや生成 AI を組み合わせて分析・判断を行えるサービスです。 つまり、IoT アプリケーションを ローコードで構築できるプラットフォーム です。

SORACOM Flux の使い方や料金プランの詳細は、以下のサイトを参照してください。 プランによっては費用が発生する場合があるので、利用前に必ずご自身で確認してください。

soracom.jp

users.soracom.io

ソラカメの静止画取得設定

まずは、ソラカメから静止画を取得する部分を設定します。 ソラカメを利用しない場合は、この設定はスキップしてください。

イベントソースとして インターバルタイマー を利用します。 今回は単純にソラカメの静止画を取得したいだけなので、どのイベントソースを使っても構いません。 SORACOM Flux の仕様上、イベントソースには必ずアクションを紐づける必要があります。

users.soracom.io

イベントソースに ソラカメ画像取得アクション を紐づけ、取得したいカメラを指定します。 このときは次の 2 点に注意してください。

1.「SORACOM Harvest Files にエクスポートする」 を有効にする
2.「ライブ画像」 を選択する

SORACOM Harvest Files に出力する理由は、後のアノテーション処理で SORACOM Harvest Files イベントソースを使うためです。 また、ライブ画像を選ぶのは、前のセクションで設定したソラカメが リアルタイムアクセスライセンス を使っているためです。

users.soracom.io

設定ができたら、実際に画像が取得できるか確認してみましょう。

Timer Channel のテスト実行 を使ってイベントトリガーを発火させると、ソラカメ画像取得アクションが実行され、指定した SORACOM Harvest Files に静止画が出力されているはずです。

画像が無事に取得できたら、次はこの画像を使って AnnoForge の WebUI でアノテーションを追加してみます。

AnnoForge のWebUIでアノテーションを追加する

ここまででやっと画像の準備ができました。 次に AnnoForge の WebUI に画像をアップロードし、実際にアノテーションを追加してみます。

uchimanajet7.github.io

今回利用した画像について

今回の動作検証では、ソラコムのオフィスがあるビルの 9F から、ソラカメ で外の風景を撮影した画像を使用しました。 人の目で見ると、車道が丁字になっているのが分かりますが、これをテキストベースで GenAI に伝えるのは少し難しそうです。

そこで、車道の形をわかりやすくするために、以下のように車道に沿って枠線(アノテーション)を付与しました。 この枠線を付けることで、GenAI に対して「車道が存在する場所」と「その範囲」を明確に伝えることができます。

AnnoForge WebUI の出力

AnnoForge の WebUI では、設定したアノテーション情報を JSON 形式で出力します。 この JSON と「同じ画角・同じサイズ」で撮影された画像をセットで AnnoForge の API に入力することで、動的にアノテーション付きの画像を生成することができます。

この仕組みを使えば、定期的に撮影された新しい画像に対しても、あらかじめ設定したアノテーション領域を自動的に描画できます。 たとえば今回の画像で言うと、朝・昼・晩などのタイミングで画像を取得し、車道の渋滞状況を確認する といった活用も可能です。

AnnoForge の WebUI では、直線以外にもさまざまな図形を描画できます。 そのため、駐車スペースや駐輪スペースの領域指定、侵入禁止エリアの設定など、実際に現地で設置やメンテナンスを行うのが大変な場所でも、仮想的にエリアを定義できます。

このように、AnnoForge を使うことで、現実世界の空間情報を柔軟に可視化・管理することができます。

次は、SORACOM Flux から AnnoForge の API を呼び出して、実際に 動的なアノテーションの追加 を試してみます。

SORACOM Flux から AnnoForge API を呼び出す

ここでは、実際に画像を SORACOM Harvest Files にアップロードし、その画像にアノテーションを付与した状態で GenAI に簡単な問いかけをしてみます。

イベントソースの設定

今回の動作検証では、SORACOM Harvest Files イベントソース を利用します。 このイベントソースを使うことで、ソラカメを利用できない場合でも、画像ファイルを手動で SORACOM Harvest Files にアップロードすれば動作確認ができます。

users.soracom.io

AnnoForge API の呼び出し設定

次に、Webhook アクション を使って、AWS 上にデプロイした AnnoForge API を呼び出します。

users.soracom.io

以下は、今回の検証で実際に設定したアノテーションを付与するための呼び出し例です。

{
  "imageUrl": "${event.payload.presignedUrls.get}",
  "config":{
  "draw": [
    {
      "shape": "line",
      "x1": 744,
      "y1": 857,
      "x2": 1815,
      "y2": 786,
      "color": "FF0000",
      "thickness": 5
    },
    {
      "shape": "line",
      "x1": 732,
      "y1": 999,
      "x2": 976,
      "y2": 980,
      "color": "FF0000",
      "thickness": 5
    },
    {
      "shape": "line",
      "x1": 976,
      "y1": 980,
      "x2": 1014,
      "y2": 1076,
      "color": "FF0000",
      "thickness": 5
    },
    {
      "shape": "line",
      "x1": 1092,
      "y1": 961,
      "x2": 1165,
      "y2": 1077,
      "color": "FF0000",
      "thickness": 5
    },
    {
      "shape": "line",
      "x1": 1092,
      "y1": 961,
      "x2": 1825,
      "y2": 893,
      "color": "FF0000",
      "thickness": 5
    }
  ]
  }
}

この設定を行うことで、SORACOM Harvest Files に画像がアップロードされるたびに、Webhook アクションを通して AnnoForge API が呼び出されます。 API が呼ばれると、WebUI で設定したアノテーション が再現され、アノテーション付きの画像が生成されます。

また、ソラカメを利用している場合は、あらかじめ設定した インターバルタイマーのイベントソース のテスト実行を行うことで、ソラカメから画像を取得し、SORACOM Harvest Files に保存することができます。

このように、実際の環境で物理的に枠や柵を設置しなくても、 画像にアノテーションを付与するだけで、領域指定や AI に伝えるための目印を付けることが可能 になります。 簡単な設定で現実の空間情報をデジタル上に再現できるのが、画像アノテーションの大きなメリットです。

GenAI の確認結果の比較

最後に、実際に GenAI を使って結果の違いを確認してみました。 今回は SORACOM Flux 上で選択できる AI モデル Azure OpenAI(GPT-4.1) を利用し、アノテーションを付与した画像 と アノテーションなしの画像 の 2 パターンで、アノテーションの定義以外はまったく同じプロンプトを入力して比較しています。

アノテーションなしのプロンプト

画像はビルの9F  からの画像です。

車道に車が走っているのか?
走っていたら渋滞しているのか?

を確認して教えてください

確認結果の出力は以下のJSONフォーマットに沿って出力してください
JSONとして正しいフォーマットで出力してください

{
    "result": "画像を確認した結果を記載してください",
    "image": "${event.payload.presignedUrls.get}"
}

アノテーションありのプロンプト

画像はビルの9F  からの画像です。

赤線で囲われた部分は車道を表しています。

車道に車が走っているのか?
走っていたら渋滞しているのか?

を確認して教えてください


確認結果の出力は以下のJSONフォーマットに沿って出力してください
JSONとして正しいフォーマットで出力してください

{
    "result": "画像を確認した結果を記載してください",
    "image": "${payload.presignedUrl}"
}

プロンプト自体は非常に単純で、プロンプトの違いは、 たった一文

「赤線で囲われた部分は車道を表しています。」

を加えているかどうかだけです。

それぞれのプロンプトを GenAI に送信し、結果を Slack 通知 に出力するように設定しました。 実際に実行して得られた結果が以下になります。

アノテーションなし結果

# No annotations added
画像からは車道に車が走っていることが確認できますが、台数は少なく、渋滞している様子は見受けられません。

アノテーションあり結果

 # Annotations added
画像内の赤線で囲われた車道部分に複数台の車両が走行している様子が確認できます。ただし、車間距離は適切に確保されており、特に渋滞している様子はありません。通常の交通状況と思われます。

どちらの回答も、プロンプトの指示に沿って正しく回答しているように思えます。 ただし、アノテーションなしの方は、やや情報量が少ない傾向がありました。 これは必ずしも性能差ではなく、単純にその時の画像状況によるものと思われます。

一方で、アノテーションありの方は、明確に「赤線で囲われた範囲=車道」として認識されている様子が見られました。 このことから、アノテーションが意図した通りに機能し、AI にとって有効な補助情報になっている ことが確認できました。

同じ指示内容のプロンプトであっても、アノテーションを付与するだけで AI の理解精度が高まる というのは非常に興味深い結果です。 特に、テキストで説明しづらい領域を「簡単な指定で範囲を示せる」というのは、現場での応用を考えるうえでも大きなメリットだと感じました。

まとめ

  • テキストだけでは伝えにくい「画像内の注目領域」を、AnnoForge のアノテーションで明示し、GenAI に的確に伝えられるようにした
  • WebUI でアノテーションを作成 → AWS Lambda 上の AnnoForge API が画像に注釈を合成 → S3 に保存し presigned URL を返す、というシンプルな構成で動作
  • Terraform による IaC 管理で、GitHub リポジトリにあるデプロイスクリプトを実行するだけでセットアップが完了するため、導入も簡単
  • 今回の動作確認では、画像取得にソラカメ、画像保存に SORACOM Harvest FilesAPI 呼び出しに SORACOM Flux を使用。
  • AnnoForge 自体は、WebUI と API の構成なので、他の環境でも同じように利用できる
  • WebUI は GitHub Pages で公開されており、アノテーション付き画像をすぐに試したいときに便利
  • WebUI のアノテーションJSON 形式で出力され、同じ画角の画像と組み合わせることで、動的に注釈付き画像を生成できる
  • 同じ画像を「アノテーションあり/なし」で Azure OpenAI(GPT-4.1) に投げて比較した結果、どちらも回答精度は低くなかった
  • ただ、アノテーションありの方は「赤線=車道」と明確に理解して応答していた
  • アノテーションを使うことで、AI に「どこを見てほしいか」を直感的に共有でき、テキスト説明だけのときよりブレが少なくなる
  • 渋滞判定、駐車・駐輪エリア管理、侵入禁止エリア監視など、現場に物理的な枠を設けずにデジタルで運用できるのが大きな利点
  • 領域を設定するだけで変更や削除も容易なため、現場での物理的な対応より圧倒的に効率的
  • すぐに試せて、試行錯誤が簡単にできるのは大きな強み
  • 機会があればぜひ試してみてください

以上です。

openai-responses-mcp 開発メモ - Codex と Claude Code を両方使って作ってみた

目次

Please check it.

この記事は 2025年 08 月時点 (以下、現在 / 現時点) の情報に基づいて記載しています。筆者は本記事を執筆した現時点において 株式会社ソラコム に所属していますが、本記事の内容は あくまでも筆者個人の見解に基づくものであり、所属する会社または組織とは一切関係ございません。


(参考)
「一般消費者が事業者の表示であることを判別することが困難である表示」の指定及び「『一般消費者が事業者の表示であることを判別することが困難である表示』の運用基準」の公表について

前提

openai-responses-mcp というものを作ってみました。
この記事は、その開発メモと簡単な紹介です。

主に自分用の備忘録という位置づけで書いているので、深掘りした比較検証記事というよりは、実務で手を動かす中での気づきや使い勝手を残した「技術メモ」に近い内容になっています。

開発の過程では、GenAI のコーディング支援ツールである OpenAI CodexClaude Code を利用しました。ただ、あえて条件を整えて比較検証したわけではなく、日々の業務の中で適当に両方を使い分けながら、

  • どちらがどの局面で効いたか
  • 実際に使ってみての所感
  • 次に活かすための判断材料

といった内容をナラティブに記載したものです。 開発者の方や、生成AIによるコーディング支援ツールに興味がある方にとって、何かしらの参考になれば幸いです。

openai-responses-mcp

実際に作ったものは以下です。

openai-responses-mcpOpenAI Responses API を利用して stdio(ローカル)接続で動作する MCP サーバーです。

特徴としては以下の通りです:

  1. GenAI のコーディング支援ツールのみで作成

    • 人がコードを直接書いたり変更したりせず、すべて生成 AI によって開発しました。
  2. MCP ツールは 3 種類

    • ただし、それらの呼び出しは MCP クライアント側に委ねています。クライアント側のLLMが良い感じに判断して使ってくれる想定です。
  3. モデル更新への柔軟な対応

    • gpt-5 以降の新しいモデルが公開されても、OpenAI Responses API が使える限り、設定変更だけで切り替え可能な想定で作っています。

GenAI に丸投げしてるので、コードの詳細は確認していないですが、簡単に動作確認した感じでは問題なく動作していると思います。gpt-5 モデルをMCP経由で呼び出しするという部分では活用できるものになってるのではないかと思います。

*設定やポリシーについての詳細は、GitHub リポジトリを参照してください

動作要件

  • Node.js v20 以上(推奨: v24)
  • npm(または npx が使える環境)
  • 環境変数 OPENAI_API_KEY に OpenAI API Key

Claude code への設定例

  • claude code cli :
claude mcp add -s user -t stdio openai-responses -e OPENAI_API_KEY=sk-xxxx -- npx openai-responses-mcp@latest --stdio

  • ~/.claude.json :
"openai-responses": {
    "type": "stdio",
    "command": "npx",
      "args": [
        "openai-responses-mcp@latest",
            "--stdio"
        ],
      "env": {
            "OPENAI_API_KEY": "sk-xxxx"
    }
}

Codex への設定例

  • ~/.codex/config.toml :
[mcp_servers.openai-responses]
command = "npx"
args = ["-y", "openai-responses-mcp@latest", "--stdio"]
env = { OPENAI_API_KEY = "sk-xxxx" }

CLAUDE.md や AGENTS.md への指示例

### 問題解決方針

開発中に問題や実装上の困難に遭遇した場合:

1. **必ず openai-responses MCP に相談すること**  
   - 相談は最優先かつ必須とする  
   - 独自判断での実装は絶対に行わない  

2. **質問は必ず英語で行うこと**  
   - openai-responses MCP への質問はすべて英語で記載する  

3. **代替手法や最新ベストプラクティスの調査**  
   - openai-responses MCP を活用して解決手段や最新のベストプラクティスを収集する  

4. **複数の解決アプローチを検討すること**  
   - 一つの方法に即決せず、複数の選択肢を比較検討した上で方針を決定する  

5. **解決策を文書化すること**  
   - 問題解決後は、再発時に迅速に対応できるよう手順や解決方法を記録しておく  

実際にはこの内容をLLMに渡して、「AIにとってわかりやすい、伝わりやすい英語に翻訳してくれ」 という指示で翻訳された内容を利用しています。

AIにわかりやすい文章はAIに作ってもらいましょうという感じです。

なぜ作ってみたのか?

GPT-5 のリリースをきっかけに、これまで自分用のローカルツールでちょこちょこ使っていた GenAI のコーディング支援ツールを、もう少し本格的に試してみたくなりました。

そして「どうせ使うなら、とことん楽をしたい」という単純な理由もあります。

もともとは Claude Code に、次の MCP ツールを追加して使っていました。

  • o3-search-mcp(OpenAI o3 モデルを呼び出せる) :

    github.com

この組み合わせはとても便利で、特に o3-search-mcp があることで、実行時エラーへの対処ライブラリ利用時の最新情報確認が格段に楽になっていました。

そこで、最近リリースされた最新モデル GPT-5
コーディング性能が向上し、いわゆる リーズニングモデル という位置づけでもあったため、o3 モデルからの切り替えを試してみたいと考えました。

ChatGPT 5 に使い方を聞いてみる

そこでまず、ChatGPT 5 に「Claude Code を MCP クライアントとして利用して、gpt-5 モデルを呼び出す方法」を尋ねてみました。
やはり本人(gpt-5 モデル)が一番よく知っていそうですから。

すると、次の 3 つの方法が提示されました。

  1. MCP サーバーゲートウェイ的なプロダクトを利用する方法
  2. Codex の MCP サーバーモードを利用する方法
  3. 自分で軽量な MCP サーバーを作成する方法

このうち 1 と 2 は既存のプロダクトを利用する方法 で、3 は実装が必要な方法です。
とりあえず試してみるだけなら、既存のプロダクトを利用するのが早そうだと判断しました。

ただし、1 の汎用プロキシのようなプロダクトを利用する構成は、今回の用途には少しオーバースペック気味。
そこでまずは Codex をインストールして MCP サーバーとして利用してみることにしました。

OpenAI Codex CLI

OpenAI が公式に提供している Codex CLI を、GitHub の手順を参考に npm からインストールしました。
認証は OpenAI API Key を環境変数に設定して利用します。

インストールとAPI Keyの設定が終了すればこの状態で、Codex はそのままコーディング支援ツールとして利用可能です。
ただし、デフォルト設定ではファイルの読み書きやネットワークアクセスに制限が設けられています。

通常の「コーディング支援」としての利用であれば、行動の最終判断は人間が行うため大きな問題にはなりません。

しかし MCP サーバーとして利用する場合は、例えばネットワークアクセスが制限されていると許可が必要になったり、実際に Codex CLI を使うと 承認確認でユーザーの入力待ち状態になるケースがあります。

これらを自動承認や常時許可にしておかないと、MCP クライアント側では応答が止まったままになり、タイムアウトして利用できない、あるいはネットワークアクセスがずっと許可されないといった状態になってしまいました。

詳細な設定内容は公式 GitHub のドキュメントで確認できます。

私が実際に利用した設定は以下の通りです。

  • ~/.codex/config.toml :
model = "gpt-5"
approval_policy = "never"
sandbox_mode = "read-only"
network_access = true

なお、MCP サーバーでの動作は It is somewhat experimental と明記されているため、今後仕様や挙動が変わる可能性があります。 その点は注意が必要です。

Claude Code では以下の設定で動作を確認しました。

  • ~/.claude.json :
"codex": {
      "type": "stdio",
      "command": "codex",
      "args": [
        "--ask-for-approval",
        "never",
        "--sandbox",
        "read-only",
        "mcp"
      ],
      "env": {}
}

実際に Claude Code から Codex を呼び出してみると、例えばこんな感じで動作しました。

  • Claude Code での呼び出し例 :
> Codexを利用して今日の最新の東京都の気温を調べてく教えてください

⏺ Codexを使って今日の東京都の最新気温を調べます。

⏺ codex - codex (MCP)(prompt: "今日2025年8月20日の東京都(大手町)の最新気温を調べて教えてください。ウェブ検索を使って気象庁のデータから最新情報を取得
                     してください。")
  ⎿  以下が気象庁(AMeDAS「東京」:都心代表地点)の最新値です。

     - 最新気温: 32.4 ℃
     … +9 lines (ctrl+r to expand)

⏺ Codexを使って調べた結果、今日(2025820日)の東京都の最新気温は以下の通りです:

  最新気温情報
  - 気温: 32.4°C
  - 観測時刻: 2025820日 16:40 JST
  - 観測地点: 東京(AMeDAS局44132)

  補足情報
  - これは気象庁の公式AMeDASデータから取得した最新の観測値です
  - 観測地点は都心代表地点で、従来の大手町観測所は2014122日に北の丸公園に移転しています
  - データ品質フラグは「0」(有効値)となっています

  現在32.4°Cと非常に暑い状況が続いています。

このように、MCP サーバーとして一応動作させることはできました。

ただし実際に使ってみると、

  • ネットワークアクセスの許可確認が何度も入る
  • そもそも「アクセスできない」という返答が返ってくることがある
  • 日本語での問い合わせ時には追加の確認ややり取りが発生しがち

といった点があり、安定性には少し難ありという印象でした。 サクッと試す程度なら問題ありませんが、実際にコードを書いていく場面では使いづらさを感じました。

というわけで軽量な MCP サーバーを自作する

Codex の MCP サーバーが o3-search-mcp の使い心地には遠く及ばず……という印象だったので、最後の 3 番目である 自作 を試すことにしました。

せっかく作るなら o3-search-mcp を参考にしつつ設定のバリエーションを少し増やす 方針に。
あわせて Codex をインストール したので、Codex を実際に使ってみる ことにしました。

とはいえ、普段は Claude Code を使っているため、最初のインプットやある程度のところまでは Claude Code で作成 しています。

さらに、Claude Code では spec.md に会話しながら仕様を AI に記述してもらう 形にしました。
こうしておくと、途中で コーディング支援ツールを切り替えたり、AIでの作業を中断して人が多少修正しても、AI へのインプットとして利用できる情報が 1 箇所に集約 されます。

Kiro や Tsumiki ほどかっちりしていなくても、メモ程度でもあった方が断然便利 なので、この方法を採用しています。

また、コーディング支援ツールへのインプットとして、Claude Code なら CLAUDE.md、Codex なら AGENTS.md を使って、ツールの動作指示を与えています。

ここはツール間で大きな違いはなく、最低限守ってほしいことややりとりのルールなど、私の好みを反映 したファイルになっています。

実際に作った openai-responses-mcpClaude CodeCodex の両方に設定して使っていますが、いまのところ 便利で、o3-search-mcp と同じような体験 ができています。

精度や内容で気になる点は特になし。強いて言えば、MCP サーバーを呼び出してから結果が戻るまで、o3 モデルより gpt-5 モデルの方が少し時間がかかる かなーと感じるくらいです。

ツールの呼び分け についても、MCPクライアント側がいい感じにやってくれる ので、概ね 思った通りの動作 になっているように感じます。

以下が実際に Claude Code とCodex で明示的に指示して、openai-responses-mcp の動作を確認した結果です。

  • Codex での呼び出し例 :
user
openai mcp を使って今日の最新の東京の天気を教えてください。とても急いでいるので精度よりスピードを重視してください。クイックに回答してほしい

codex
MCPで「東京の今日の天気」を高速検索します。結果をすぐ要約して返します。

tool running...
openai-responses.answer_quick({"query":"Tokyo weather today"})

tool success, duration: 23.14s
openai-responses.answer_quick({"query":"Tokyo weather today"})

{"answer": "Tokyo weather today (2025-08-19 JST):\n- As of 13:57, it's mostly sunny around 34°C (93°F). \n- Forecast for the rest of today: partly sunny,
hot and humid. High 34°C (93°F), low 26°C (80°F). Stay hydrated if outdoors. \n\n- Key points:\n  - Date/time (Tokyo): 2025-08-19 13:57 JST. \n  - Current
conditions: Mostly sunny, ~34°C (93°F). \n  - Today's range: High 34°C/93°F, Low 26...

thinking
Interpreting weather data

I received a structured JSON that indicates the date is 2025-08-19 and that the weather is mostly sunny at 34°C—perfect, since it matches today. However,
there are no citations provided. The user's request prioritizes speed, so I can deliver this info in Japanese quickly. While I didn't perform a recency
check as per AGENTS.md guidelines, I can note the time as 13:57 JST. I'll structure my response in bullet points and keep it concise. No fabrications here,
just the tool's output!

Delivering quick weather update

I see that the user just wants a quick weather update, so I can ask a follow-up question about hourly breakdowns: "時間ごとの内訳が必要ですか?", but I
should keep it simple. The environment restricts some features, but the tool worked, so that's good. I'll avoid heavy formatting as per CLI guidelines and
use bullet points instead.

Here's a quick summary in Japanese:

- 今日の東京の天気(2025-08-19 JST)
- 現在: 晴れがち、約34°C
- 予報: 午後は所により晴れ、蒸し暑い。最高34°C、最低26°C
- 注意: こまめな水分補給
- 出典: 更新時刻: 13:57 JST


codex
- 今日の東京(2025-08-19 13:57 JST 時点)
- 現在: 晴れがち、約34°C
- 本日予報: 部分的に晴れ、暑く湿度高め
- 気温: 最高 34°C / 最低 26°C
- メモ: 炎天下の外出はこまめな水分補給を

時間別や雨アラートが必要ならお知らせください。

  • Claude Code での呼び出し例 :
> openai mcp を使って現時点のソラコムの株価を教えてください。回答時間よりも詳細さや内容を重視して確認してます

⏺ openai-responses - answer_detailed (MCP)(query: "ソラコム株価 現在 最新 SORACOM 6569"){                                                                                                                                                  
      "answer": "結論\n- ソラコム(東証グロース: 147A)の最新終値は 1,312円です(2025-08-20 15:00 JST、当日高値 1,340円/安値 1,301円、前日比
    +3.06%)。([irbank.net](https://irbank.net/147A/p
    br?utm_source=chatgpt.com))\n- なお、証券コード「6569」はソラコムではなく日総工産のコードで、同銘柄はすでに上場廃止となっています。ソラコムの正しい
    証券コードは「147A」です。([blog.soracom.com](https://blog.soracom.
    … +26 lines (ctrl+r to expand)

⏺ ソラコム(東証グロース: 147A)の現在株価

  最新株価情報
  - 終値: 1,312円(2025-08-20 15:00時点)
  - 日中値動き: 高値 1,340円 / 安値 1,301円
  - 前日比: +3.06%(上昇)

  重要な注意点
  - ソラコムの正しい証券コードは「147A」です
  - よく間違われる「6569」は日総工産のコード(既に上場廃止)
  - 2024年3月26日に東証グロース市場に上場

  データの精度について
  - 取引所表示は通常15~20分のディレイあり
  - リアルタイム価格が必要な場合は証券会社アプリやPTS取引をご確認ください

  追加情報が必要でしたら
  - リアルタイム板情報
  - PTS(夜間取引)価格
  - 価格アラート設定
  - ドル換算価格

  なども調べることができます。

  • Claude Code での呼び出し例 :
  > openai mcp を使って素早く今日の東京の天気を調べてください。急いでます

⏺ openai-responses - answer_quick (MCP)(query: "今日の東京の天気"){                                                                                                                                                  
      "answer": "今日(2025-08-19, Asia/Tokyo)の東京(東京都心)の天気:晴れ、非常に暑い。現在の気温は約34°C、午後は最高36°C前後、夜にかけて徐々に下が
    り29°C前後。午後は「晴れ〜曇り」で熱中症レベルの暑さに注意してください。\n\n- 要点:晴天・猛暑
    (最高約36°C)、午後も気温高め、夜はやや涼しくなる(約29–31°C)。\n-
    推奨:屋外の長時間作業や運動を避ける。こまめな水分補給・冷房利用を。\n\nSources:\n- tenki.jp — 東京都心(大手町)実況・予報ページ.
    https://tenki.jp/past/202
    … +6 lines (ctrl+r to expand)

⏺ 今日(8/19)の東京の天気

  - 晴れ、猛暑
  - 現在:34°C
  - 最高:36°C前後
  - 夜:29°C前後

  :warning: 熱中症レベルの暑さ - 水分補給と冷房利用必須

  • Claude Code での呼び出し例 :
> opneai MCP を使って今日の最新の東京の気温を調べて教えてください

  openai-responses - answer (MCP)(query: "今日の東京の気温 最新の天気情報")
  ⎿ {                                                                                                                                                  
      "answer": "東京の最新の気温・天気(2025-08-19 12:10 JST 時点)\n- 現在の気温: 34°C、晴れ。非常に蒸し暑く、屋外活動は熱中症に注意が必要です。 \n-
    今日の予想(2025-08-19 火): 最高 36°C / 最低 26°C、所により薄曇りで厳し
    い暑さが続きます。長時間の屋外は脱水・熱中症に注意。 \n- 明日の傾向(2025-08-20 水): 曇りがちで暑く、所により午後に雷雨の可能性。予想 36/26°C
    前後。 \n\nポイント\n- こまめな水分・塩分補給、日陰・冷房の活用、屋外ではこまめに休憩を。 \n\n必要なら、時間帯
    … +5 lines (ctrl+r to expand)

⏺ 今日(2025年8月19日)の東京の最新気温情報をお調べしました:

  現在の状況(12:10時点)
  - 気温:34°C(晴れ)
  - 非常に蒸し暑い状態

  今日の予想
  - 最高気温:36°C
  - 最低気温:26°C
  - 天候:所により薄曇り

  注意点
  - 厳しい暑さが続いているため、熱中症対策が必要
  - こまめな水分・塩分補給
  - 日陰や冷房の活用
  - 屋外での長時間活動は避ける

  非常に暑い一日になっているので、外出時は十分お気をつけください。

Claude Code での呼び出し例をよく見てもらえると、answer / answer_detailed / answer_quick の3つのツールを、指示の内容によってMCPクライアント側が判断して呼び出しているのが確認できるかと思います。

Claude Code と OpenAI Codex

正直なところ、どちらを使っても動くコードを作ること自体には大きな差はない、というのが個人的な感想です。

ただし 体験面 でいうと、圧倒的にストレスなく使えているのは Claude Code でした。
強く残念に感じた点としては、CLAUDE.md に書いてある内容をしばしばスルーして対応してしまうことが少なくなかった、という印象です。

話題になった Serena を導入すれば改善されるのかもしれませんが、そこまで困っていないためまだ試してはいません。

また、Claude Code は アップデート頻度が非常に高い 印象です。VSCodeプラグイン更新通知もよく目にしますし、開発が活発なので今後もどんどん改善されていきそうです。

一方で OpenAI Codex は、まだ開発途上という雰囲気があります。

最初に試したバージョンでは、CLI の出力が固定長で改行されてしまうため、ファイルパスが折り返されてコピー&ペーストで使いづらいといった問題がありました。地味な部分ですが日々コードを書いていると、こういう地味なところが案外ストレスになるという感じがします。

また、別の段落でも触れましたが、権限確認などの制御が多く、安全寄りに設計されている印象があります。良い面ではあるものの、体験としてはややまどろっこしく、Web 検索も積極的には行ってくれない感じを受けました。

両方にそれぞれ良い点・気になる点がありますが、まとめると個人的には以下のようになりそうです。

  • Claude Code

    • やりとりのテンポが良く、反復も速い
    • 気持ちよく「勢い」を保って個人開発を進められる
  • OpenaAI Codex

    • 手続きや規約を重視する方向
    • 確認やレビューを任せる用途に向いている感じを受ける

さらに、画像やクリップボード上のバイナリ(画像データ)の扱いにも違いがありました。
Claude Code はそのままテキストインプットエリアにコピペして確認の依頼が可能ですが、Codex はバイナリ貼り付け NG。ファイルについても、プロジェクト外のパスだと読み込みを渋る傾向があるようです。

ただし両方とも、playwright-mcp 経由でスクリーンショットを撮る という指示では問題なく動作して確認してくれました。

結局どっちがいいのか?

最終的な感想としては、好きな方を使えばいい、というところに落ち着きました。
参考にならないかもしれませんが、どちらを選んでもある程度の成果物は作れてしまうと思います。

これはめちゃくちゃスゴイことで、だいぶ適当に指示を出しても「動くもの」ができあがってしまうことになります。
もちろん品質や精度は別の話なので、そこにこだわろうとすると途端に大変になりそうですが、ちょっとした動作確認のハードルが圧倒的に下がっている感じがします。

選ぶポイント

  • 速度・反応・安定感を優先するなら
     → Claude Code

  • 権限や明示的な許可、指示書や規約に沿った動作を求めるなら
     → OpenAI Codex

という感じで、自分の重視する体験にあわせて選ぶのが良さそうです。

API Key 周りの使い勝手

どちらも API Key で利用できる のはありがたい点です。
ユーザー単位での設定は面倒になることが多いので、個人的にはとても良いユーザー体験でした。

特に Claude Code は Amazon Bedrock の API Key でも簡単にセットアップして利用できる のが好印象で、とても良い体験だと感じました。

まとめ

  • 結構雑にやっても 動くものはできた
  • Claude Code でも OpenAI Codex でも、どちらでもそれなりの成果物は作れる
  • 途中で仕様変更や追加をすると、既存コードやドキュメントへの反映漏れが多かった
  • 変更を反映するよりも、最初からゼロイチで作り直した方が整合性が取れやすいと感じた
  • ゼロから 1 にする作業の方が圧倒的に安定している印象
  • openai-responses-mcp を作ったのは結果的に良かった
    • Codex を使うきっかけにもなった
    • 「急いで」「素早く」「詳しく」などのキーワードを入れるだけで、MCP クライアント側がツールを切り替えてくれる
    • これは地味に便利
  • Codex にも openai-responses-mcp を設定して利用でき、インターネット経由での確認も楽になった
  • gpt-5 モデルの応答は少し時間がかかるので、o3 モデルより少し長めの待ちが必要
  • 今は Codex でも Claude Code でも、今回作った MCP を利用中
  • 問題や調査が必要な時に、コードエージェント側が勝手に調べてくれるので楽
  • 「急いで」「詳細に」といったキーワードにも反応して、検索ツールを自己判断で切り替えてくれるのも便利
  • 動作速度や反応は Codex より Claude Code の方が体感的に良い
  • いずれにしても、AI にコードを書かせてリポジトリ管理からリリースまで一通り回せたのは良い経験
  • 実際に試すことは大事だと実感
  • 削除したはずの仕様が残ったり、新旧の仕様が混ざったりして 整理は大変
  • 全体を俯瞰して編集するのはやや苦手そう
  • また、コードを動く方向に直すものの、仕様としてはどうなのか?と思う修正もあった

最後に

  • 選ぶポイントは色々あるけど、最終的には 自分の環境で試すのが一番の近道
  • 小さく試してから判断するのがおすすめ
  • 作ってみたのは結果的に良かったが、Codex の公式 MCP 対応が整ってきたらそちらに乗り換える方が良さそう
  • 良い開発体験を!

以上です。

Following is in English

medium.com

ちょっと便利かも? SORACOM Flux のちょい足し小技集

目次

Please check it.

この記事は 2024 年 12 月時点 (以下、現在 / 現時点) の情報に基づいて記載しています。現時点では 株式会社ソラコム に所属していますが、本記事の内容は あくまで個人の意見であり、所属する会社や組織とは一切関係ありません。


(参考)
「一般消費者が事業者の表示であることを判別することが困難である表示」の指定及び「『一般消費者が事業者の表示であることを判別することが困難である表示』の運用基準」の公表について

SORACOM Advent Calendar 2024

公開が遅れてしましましたが、この記事は SORACOM Advent Calendar 2024 の 21 日目の記事です。

qiita.com

今回は SORACOM Flux を使っていて「ここ、もうちょっとこうだったらいいのになぁ」と思う部分を、なんとか上手くできないか試してみました。 ちょっとしたお役立ちネタとしてまとめたので、SORACOM Flux を使うときに「これいいかも」と思ったら、ぜひ参考にしてもらえると嬉しいです。

SORACOM Flux

SORACOM Flux(以下、Flux)は、デバイスから送られてくるセンサーデータやカメラ画像にルールを適用し、複数のデータソースや生成AIを組み合わせて分析・判断を行える、IoT アプリケーションをローコードで構築できるサービスです。詳しくは、以下のリンクを参照してください。

soracom.jp

users.soracom.io

Flux を使うと、複数のサービスを連携させて、「やりたいこと」を手軽に実現できます。イメージとしては、IFTTT や Zapier のような iPaaS(Integration Platform as a Service)を思い浮かべてもらうとわかりやすいと思います。

現在は初期リリース段階で、Free プランのみが利用可能です。ただ、2025年3月4日から有償プランの提供開始 が予定されており、今後どのように進化していくか楽しみなサービスです。

soracom.jp

今回は現在の Flux でできる範囲でいろいろ試してみた内容をまとめました。

1. Flux から Slack に通知した画像がずっと表示されてたらいいのになぁ

例えば、以下のリンク先のようにSORACOM Harvest Files にアップロードした画像を 生成AI で判定して、その判定結果と一緒に画像を Slack に通知するケースを考えてみます。

users.soracom.io

この場合、Slack に通知されるのは SORACOM Harvest Files にアップロードした画像の URL です。ただ、この URL は Amazon S3 の署名付き URL(Pre-Signed URL) になっています。 署名付き URL についての詳細は、以下の公式サイトを参照してください。

docs.aws.amazon.com

ここで気になるのが、この署名付き URL には有効期限があるということ。有効期限が切れると、Slack 上でも画像のプレビューが表示されなくなりますし、URL をクリックしても画像を見ることができなくなってしまいます。

過去のハマりポイント

署名付き URL の有効期限については、すごい昔にハマったことがありますw 詳しくは昔書いたこちらの記事にまとめてあるので、興味があれば参考にしてください

uchimanajet7.hatenablog.com

署名付き URL の有効期限を確認する方法

Flux から通知された署名付き URL の有効期限を確認したい場合、以下のURL中パラメータをチェックすることで確認できます。

  1. X-Amz-Expires に URL の有効期間(秒数)が記載されています。
  2. X-Amz-Date に 署名が作成された時刻(UTC)が記載されています。
  3. 有効期限の計算方法 は X-Amz-Date に X-Amz-Expires の秒数を加算することで計算できます。

試しに手元で確認してみたところ、X-Amz-Expires=3600 が指定されていました。つまり、署名作成時刻から1時間が有効期限 ということになりますね。

Slack 通知を署名付き URL ではなくファイル添付にできないのか?

署名付き URL に有効期限があるなら、いっそのことファイルそのものを Slack の通知に添付してしまえば解決しそうですよね。

そこで、まずは Flux の Slack 通知アクションを再確認してみました

users.soracom.io

どうやら、Slack メッセージの送信には以下の公式サイトに記載があるリッチな表現が使えるようです

api.slack.com

ただ、これを活用しても通知できるのは結局 URL のみで、どうやらこれは Slack 側の仕様のようです。

ファイルを直接アップロードする方法を探してみた

「じゃあファイルそのものをアップロードする方法は?」と思い調べてみると、Slack にはファイルをアップロードするための API が別に用意されていることがわかりました。まず目に入ったのが以下の files.upload API です

api.slack.com

ところが、この API2025年3月11日に廃止予定 とのこと 詳細については、以下を参照してください

api.slack.com

新しいファイルアップロード方法

廃止後の移行先として、Slack では新しい方法が提供されています 詳細については、以下を参照してください

api.slack.com

簡単に手順を記載すると

  1. files.getUploadURLExternal APIでファイルアップロード用のURLを取得する

    api.slack.com

  2. 1 で取得したURLに対して実際にファイルを送信してアップロードする

  3. files.completeUploadExternal API でファイルアップロード処理を終了する
    • この処理で実際に指定したSlackチャンネルに画像を投稿できます

    api.slack.com

これらの手順を踏めば、ファイルを直接 Slack チャンネルに添付して投稿できるようになります。 ただし、Slack App に必要な権限を追加する必要がある点には注意してください。

users.soracom.io

Flux から Slack にファイルをアップロードしてみる

というわけで、実際に Flux を使ってファイルのアップロードを試してみます。

まず、SORACOM Harvest Files イベントソースを利用して、画像ファイルが SORACOM Harvest Files にアップロードされたらアクションが実行されるように設定します。

users.soracom.io

次に追加するアクションとして、SORACOM API アクションを利用して、SORACOM Harvest Files から実際に画像をダウンロードしてみることにしました。

users.soracom.io

画像をダウンロードする API

SORACOM Harvest Files からファイルをダウンロードするには、以下の getFile API を利用します

users.soracom.io

この API の URL には、画像ファイルが保存されている場所を指定する必要があります。そこで、イベントがキックされた際に受け取るペイロードから、必要な情報を渡して実行します。

具体的には次のような形で指定します /v1/files/private${event.payload.path}

送られてくる path の先頭にすでに / が付いているので上記のような指定の方法となってます。

Flux アプリを実行してみた結果

作成した Flux アプリをいざ実行してみると、以下のようなエラーが発生しました

FLX0306
エラー
Failed to send a http request. Response too large. Max size: 8192 bytes, Content length: 141136 bytes

このエラーメッセージを見る限り、どうやら Flux 側で受け取れるレスポンスのサイズに制限があるようです。今回は画像データが対象だったため、サイズが大きすぎて制限を大幅に超えてしまいました。

結果としては残念ながら、Flux 自体の制限によってこの方法では実行が難しいことがわかりました。 今後のアップデートでのサイズ制限まわりの改善に期待ですね。

画像ファイルのファイルサイズを小さくしてみたら?

Flux 側で受け取れるサイズに制限があるなら、ファイルサイズを小さくして制限内に収めればいいのでは?と思い、試してみました。 Flux はローコードをコンセプトにしているので、なるべくコードを書かずに実現できる方法を探してチャレンジしてみました。

色々と調べてみると、TinyPNG というサービスを使えば、画像の圧縮やフォーマット変更、リサイズなどが API 経由で実行できそうです。

tinypng.com

API を呼び出せるなら、Flux の Webhook アクション を使えば簡単に試せそうです

users.soracom.io

TinyPNG の詳細は公式サイトを参考にしてください 利用する場合には必ずご自身で詳細を確認し、不明点があれば TinyPNG に確認をお勧めします

tinypng.com

tinypng.com

Tinify API を使う手順

以下のAPIリファレンスを参照して TinyPNG の API (Tinify API) を試してみます

tinypng.com

簡単な手順を記載すると

  1. APIキーを取得する
    • 以下のページから登録して API キーを取得します

      tinypng.com

  2. API 認証について準備する
    • API との認証は HTTP Basic Auth を使います
    • ヘッダーに以下の形式で認証情報を付与します
  3. APIを使って画像ファイルをアップロードする
    • 画像ファイルをアップロードすると自動的に圧縮されます
    • 画像ファイルを直接アップロードする代わりに URL を指定する方法もあります
    • Flux から利用する際には、このURLにSORACOM Harvest Files の署名済みURLを指定します
    • アップロードが成功すると、圧縮済み画像のダウンロード URL が返ってきます
      • Location ヘッダー に URL文字列が記載されています
  4. APIを利用して圧縮後の画像をダウンロード
    • アップロード結果の Location ヘッダーで指定されたURLから画像がダウンロードできます
    • このダウンロードのために TinyPNG 側で画像は最大 48 時間保持 されます
    • そのため、指定されたURLにWebブラウザでアクセスすると画像が表示できます

という感じで、圧縮された画像を取得できることがわかります。 このほかにもAPIでは、アップロードされた画像に対して 画像のサイズ変更 や 画像フォーマットの変換を行ってから、ダウンロードを実行することもできます。

単純に自動圧縮の画像を利用した場合

まずは1番手軽に試せる、自動圧縮された画像のダウンロードを実行してみましたが

Failed to send a http request. Response too large. Max size: 8192 bytes

という結果で、これまでと同じくFlux側の制限を超えることはできませんでした。

画像フォーマットを変換した場合

次に、画像フォーマットの変換を試してみました。

TinyPNG では高圧縮率の AVIF フォーマットが利用可能でしたが、Slack 側が対応しておらず、AVIF をアップロードすると単なるバイナリファイル扱いとなり、プレビューが表示されませんでした。 そこで、JPEG を WebP フォーマットに変換してみましたが、結果は変わらずFlux側の制限を超えることはできませんでした。

画像のリサイズを行った場合

最後に、画像のリサイズを試してみました。

こちらは、画像サイズを可能な限り小さくしていけば、Flux側の制限に当たらないサイズにはできましたが、今度は画像が小さすぎて内容がほぼ見えなくなるという結果になりました。 この状態ではファイルを添付しても意味がないので、結果として目的は果たせないことになりました。

Slack API を使ったファイルアップロードを試してみる

とはいえ、せっかくなのでリサイズで小さくした画像を使って、Slack へのファイルをアップロード試してみることにしました。

事前に確認した手順に沿って、まずは files.getUploadURLExternal API を呼び出します

api.slack.com

Flux からこのAPIを呼び出す際には Content-Typeapplication/x-www-form-urlencoded と指定することと、合わせて POST Body に token=xoxb-xxxx&length=8192&filename=example.jpg のようなkey=value&key=value... の形式で必要な情報を記載することに注意すれば、問題なく Flux から呼び出しを行えました。

残念ながら

ここまでは、なんとかなったのですが、次に files.getUploadURLExternal API の戻り値で指定されたURLに、画像データをアップロードする部分がうまく行かずに失敗となりました。エラーとしてはこれまでと同じくFlux側の制限でエラーとなってしまったので、これ以上方法はなさそうでした。残念。

それ、Make ならできるよ!

他の iPaaS(Integration Platform as a Service)はどう対応しているのか気になったので、Make で試してみました。

Make とは?

Make は、以前は Integromat と呼ばれていた iPaaS です。同じようなサービスだと IFTTT や Zapier のほうが馴染みがあるかもしれませんね。

www.make.com

Make はノーコードで複数のサービスをつなげて、やりたいことを手軽に実現できるのが特徴です。 ただ、名前がシンプルすぎて Web 検索しづらいのが難点ですねw とはいえ、公式ドキュメントが充実していて、UI も直感的なので、利用には特に困らないと思います。

Make で Slack へのファイルアップロードを試してみる

まず、Flux 側では Webhook アクションを使い、署名済み URL を Make に送信する簡単な Flux アプリを作成します。

次に、Make 側で以下の手順を設定しました

  1. Webhook を受け取る
    • 署名済み URL を受け取るために Webhook を使用します

      www.make.com

  2. 画像をダウンロードする
    • 受け取った URL から画像を取得するために HTTP モジュールを使用します

      www.make.com

  3. Slack に画像をアップロードしてメッセージを送信
    • 画像を Slack の指定チャンネルにアップロードし、メッセージを投稿します。

      www.make.com

実際にやってみたら

これら 3 つのモジュールを組み合わせて、画像ファイルを Slack にアップロードするシナリオを作成しました。

特に複雑な設定は不要で、インプットされた値をそのまま利用するだけで問題なく動作しました。そして、Slack にアップロードされたファイルは Slack 側で保持されるため、署名済み URL の有効期限の問題から解放されました。

Make を使えば簡単に実現できたので、今後 Flux でも同様の対応できるようになる可能はゼロではなさそうです。もし「こういう機能があればいいな」と感じたら、ぜひソラコムにフィードバックしてみてください!

2. Flux のタイマーイベントを日時指定で設定できたらいいのになぁ

Flux で定期的に処理を実行したい場合、タイマーイベントを利用することになります。

users.soracom.io

ただし、現時点では一定間隔での間欠的な動作しか設定できません。 今後、有償の Standard プラン を利用することで、スケジュールタイマー(Cron 設定)が可能になる予定のようです

soracom.jp

現状の代替案

現時点で日時指定に近い動作を実現する方法として、以下のようなアプローチが考えられます

  1. タイマーイベントで間欠動作を起こす
  2. アクション側で「実行条件」を設定する

例えば、次のような実行時間帯を指定する条件を設定できます (getUTCHours(now())+ 9 >= 5) && (getUTCHours(now())+ 9 <= 18)

この方法を使えば、特定の時間帯にのみ処理を実行することが可能です。ただし、やはり直接日時を指定できるわけではないので少し手間がかかります。

今後、スケジュールタイマー機能が正式に対応されれば、Flux の利用がさらに便利になりそうですね。

Slack には日時指定で実行できる仕組みがある

Slack をいろいろ調べていたところ、reminder スラッシュコマンドや workflow のトリガーで日時指定ができる仕組みがあることを思い出しました。これを使って、Slack から Flux アプリを起動する仕組みを試してみることにしました。

Slack から Webhook を送信する方法

まず、Slack 側から Webhook を送信する必要があります。以前は Outgoing Webhooks を使えば簡単に実現できましたが、現在は非推奨 We do not recommend their use. となっており、将来的に利用停止となる可能性を考慮する必要があります。

api.slack.com

代わりに、Events API を使えば同じようなことが実現できるようなので、こちらを試してみることにしました

api.slack.com

Events API の設定

Slack App 側で必要な権限を設定し、Webhook の送信先 URL を Slack の管理画面で登録します。この際、以下の仕様に従って返信を行う必要があります

We’ll send HTTP POST requests to this URL when events occur. As soon as you enter a URL, we’ll send a request with a challenge parameter, and your endpoint must respond with the challenge value.

api.slack.com

試したみたものの

現在の Flux の Incoming Webhook では、受け取ったデータを任意の形で返信することができません。そのため、Slack からの送信先として Flux の Webhook URLを設定できませんでした。

一方で、以前の Outgoing Webhooks を使えばこの制限を超えられそうですが、非推奨機能であり今後利用停止の可能性があるため、今回は採用を見送りました。

とはいえ、現状 Flux 側の仕様で Webhook の返信ができないため、Slack をトリガーとして Flux を日時指定で動かす方法は見つかりませんでした。今後のアップデートや新機能に期待したいですね

それ、Make ならできるよ!

さて、また登場の Make です。やっぱり iPaaS として提供されているだけあって、細かいところまでしっかり作り込まれている印象を受けますね。

Flux で Slack からの Webhook を受け取れなかった原因は、challenge response を返せなかったからでしたが、Make にはこれに対応できる仕組みが用意されていました。

Webhook に応答できる Make の機能

Make では、受け取った Webhook に対して応答を返すことができるモジュールが用意されています

www.make.com

この機能を使うと、Webhook の内容に応じたレスポンスを返すことができます。 また、Slack の Event API を利用する場合は、通常の利用時にも HTTP 200 OK を返す必要があります

api.slack.com

Slack からの Webhook を Flux に送信する

Slack から受け取った Webhook を Flux に送信するには、Make の Make a request モジュールを使用します

www.make.com

今回は動作確認のため、固定の POST Body を送信しましたが、Slack からの Webhook には入力された文字列が含まれているので、この仕組みを使えば、Slack で指定した ID のソラカメから画像を取得する、といった処理も可能だと思いま

Slack の Workflow Builder でスケジュール実行する

ここまで準備できたら、あとは Slack 側で Workflow Builder を使ってスケジュール実行を設定します。Slack の UI で簡単に設定できるので手軽です。

スケジュール設定ではプルダウン形式で時間を選択するように見えますが、実は直接入力することで選択肢にない時間も設定可能です。細かい時間指定が必要な場合は試してみてください。

ちなみに、Slack のスラッシュコマンドで実行できる reminder コマンド を使う場合、Bot に対してメンションを行えないという制約があるため、reminder を使ったスケジュール設定はできませんでした。

Make を使ったスケジュール実行の利点

Make を挟むことで、UI を使ったスケジュール実行が可能になり、柔軟な運用ができるようになりました。 ただし、もし Flux 側が Slack の Event API による Webhook 利用に対応してくれれば、Slack から直接 Flux を呼び出せるようになるため、今後の対応に期待したいところです。

Flux で通知メッセージを動的に切り替えられたらいいのになぁ

Flux を使って IoT デバイスから送信されたデータに応じた通知やアクションを設定する場合、IoT デバイスイベントソースには SIM グループを紐付けることになります。 ですが、SIM グループに複数の SIM が紐付いていると、SIM 単位で個別のメッセージを送りたい場合に少し工夫が必要です。

users.soracom.io

考えられる手軽な対応方法

Flux だけに閉じて考えると、アクション実行条件を活用して、SIM ID で判別して個別の通知メッセージを割り当てる方法があります。しかし、この方法では SIM が増えるたびに設定を変更する必要があり、手間がかかります。

もっと単純な方法として、SORACOM 全体の仕組みを活用して、SIM、SIM グループ、イベントソースを 1 対 1 対 1 で紐付けることも考えられます。 しかし、この方法でも SIM の数が多くなると、Flux アプリ側で大量のイベントソースを設定する必要があり、現実的ではありません。

タグを使った解決案

そこで試してみたのが、SIM のタグを活用する方法です。タグに通知用のメッセージを格納し、SIM 単位で動的に通知内容を切り替えられるようにします。

実はこの方法、SORACOM の他の製品でも使われています。たとえば、SORACOM LTE-M Button for Enterprise では、グループのタグに必要な情報を保存し、それを参照してメールを送信する仕組みが採用されています。

users.soracom.io

users.soracom.io

タグ情報の制約

タグには保存できる情報量に上限があるため、保存する内容を工夫する必要があります。今回は単純な文字列をタグに入力して利用しました。

users.soracom.io

実際に試してみた

実際にタグ情報を使った解決案を以下の手順で試してみました。

  1. SORACOM ユーザーコンソールでタグ設定
    • SIM のタグ情報に通知用のメッセージを入力します

  2. Flux アプリで設定
    • IoT デバイスイベントソースを設定し、タグ情報を持つ SIM が所属する SIM グループをトリガーに指定します
  3. SORACOM API アクションでタグ情報を取得
    • 通知対象の SIM 情報を取得し、その中からタグ情報を抽出して通知に利用します

これで、SIMから情報が送信されるタイミングでFluxアプリが起動して、動的にタグから通知用の情報が取得され、通知の文面に使われます

Slack への通知の例

今回は例として、Slack にタグ情報の内容をそのまま通知しました。もちろん通知メッセージを編集することで、さまざまな用途に応用できます。

注意が必要なポイント

Flux にはレスポンスサイズの上限があるため、タグに格納する情報が大量になると、上限を超えて情報を取得できない場合があります。 また、タグ自体にも入力の上限値があるため、タグの制約と Flux のレスポンス上限値を合わせて考慮することが重要です。

Flux で SIM のタグを活用することで、動的に通知メッセージを切り替える仕組みを実現しました。ただし、制約があることを理解した上で、柔軟に対応できる運用を考えてみてください!

まとめ

SORACOM Flux を使う中で 「ここ、もうちょっとこうだったらいいのになぁ」 と思う部分をなんとかできないかを試してみました。

  • 画像の表示期限
    • Flux で画像を外部に通知するとき Amazon S3 の署名付き URL(Pre-Signed URL) が使われます
    • 署名付き URL には有効期限があるため、例えば Slack に通知すると一定時間で画像が表示できなくなります
    • これを解消するには、Slack に直接ファイルをアップロードする必要があり、Slack APIfiles.getUploadURLExternalfiles.completeUploadExternal を使う必要があります
    • しかし、Flux の HTTP レスポンスサイズ制限により、大きなファイルのダウンロードやアップロードは現状では難しかったです
  • レスポンスサイズ制限
    • TinyPNG などの外部サービスを使い、画像を圧縮したりフォーマットを変換してファイルサイズを抑えてみましたが、残念ながら Flux 側の制限を超えることはできませんでした
  • スケジュール起動
    • タイマー起動ではなくスケジュール起動を利用したい場合に、Slack を活用してみました
    • ただし、Slack の Event API を利用する際には、Flux では Webhook に返答できないため、現状では直接利用するのは難しかったです
  • 外部サービスの Make を活用
    • Flux で解決できない部分は、外部のサービスである Make を利用して対応しました
    • Make の機能で Flux を補完することで、Flux の制限部分を解決しました
  • SIM タグの活用
    • SIM タグを活用して、デバイスごとに動的な通知メッセージを切り替える仕組みを試しました
    • Flux でタグを利用する際は、タグ情報のサイズ制限や Flux のレスポンス制限を考慮する必要があります
    • 制限を理解して利用するのであれば、手軽かつ便利に利用できると思います
  • 今後に期待
    • Flux はまだリリース初期段階と思われるので、こうした細かい部分の今後の進化に期待したい
    • 「ここ、もうちょっとこうだったらいいのになぁ」という部分があれば ぜひソラコムにフィードバック してみてください!

少しでもみなさんの参考になれば嬉しいです。楽しく SORACOM Flux を使って省力化できることが増えるといいですね。

以上です。

SORACOM Flux を使って SORACOM LTE-M Button for Enterprise のクリックを Slack に通知する方法 #SORACOM #soracomug

目次

Please check it.

この記事は、 2024 年 8 月時点 (以下、現時点)の情報に基づいて記載しています。現時点では、株式会社ソラコム に所属していますが、本記事の内容はあくまで個人の意見であり、所属する会社や組織とは一切関係ありません。


(参考)
「一般消費者が事業者の表示であることを判別することが困難である表示」の指定及び「『一般消費者が事業者の表示であることを判別することが困難である表示』の運用基準」の公表について

前提

この記事は、SORACOM Advent Calendar 2022 の 23 日目に書いた記事で紹介した内容を、別の手段で実現したものです。

uchimanajet7.hatenablog.com

今回紹介する方法では、SORACOM Flux を使うことで、必要に応じて SORACOM 内で処理を完結でき、Make を利用するよりも手軽に利用できる場合があります。どちらの方法も一つの手段に過ぎませんので、ご自身のニーズに合う部分があれば、参考にしていただけると嬉しいです。

SORACOM LTE-M Button for Enterprise

SORACOM LTE-M Button for Enterprise (以下、ボタン) は、ボタンを押すことでクラウドに通知を送信できます。この通知をトリガーとするクラウド側のシステムを実装することで、メールの送信や特定の動作を行えます。詳細については、以下のリンクを参照してください。

soracom.jp

users.soracom.io

このボタンには他にも幾つかのシリーズがあり、用途に応じて選択できます。

users.soracom.io

IoTデバイスといえば、センシングしてデータを継続的にクラウドに送信するものをイメージする方も多いと思いますが、このボタンはユーザーがアクションを起こせるデバイスです。今回のように、ちょっとした試みを実装する場合には、打って付けのデバイスと言えるのではないでしょうか?

SORACOM Flux

SORACOM Flux (以下、Flux) は、デバイスから送信されたセンサーデータやカメラ画像に対してルールを適用し、複数のデータソースや生成AIを組み合わせて分析・判断を行うような、IoT アプリケーションをローコードで構築できるサービスです。詳細については、以下のリンクを参照してください。

soracom.jp

users.soracom.io

Flux を使えば、ローコードで複数のサービスを連携させ、実現したいことを簡単に実装できます。IFTTT や Zapier のような iPaaS (Integration Platform as a Service) をイメージしていただけるとわかりやすいかと思います。

Flux は現時点では初期リリース段階であり、Freeプランのみが提供されています。

soracom.com

これからの進化に期待しつつ、現時点の Flux で、できることを試してみたいと思います。

ボタンをクリックした際にメールを送信するには

ボタンをクリックした際にメールを送信したい場合、SORACOM ユーザーコンソールに用意されている機能を利用することで、簡単にメールを送信できます。詳細については、以下のリンクを参照してください。

users.soracom.io

この機能は SORACOM で、あらかじめ用意されているため、SORACOM ユーザーサイトの案内に従って必要な項目を設定するだけで、簡単にメールを送信できます。

ボタンには以下の3種類のクリックタイプがあります。

  1. SINGL : シングルクリック
  2. DOUBLE: ダブルクリック
  3. LONG: 長押し

今回は、ボタンのクリックに応じてメールを送信する代わりに、SORACOM Flux を利用して、ボタンのクリックタイプに応じた内容で、Slack に通知する方法を試してみたいと思います。

なぜ Slack 通知なのか?

以前、Make を利用した際にはメールを送信していましたが、今回は Slack に通知を行います。 その理由は、現時点で Flux が Free プランのみで提供されており、メール通知には制限があるためです。また、メール通知の送信先アドレスを設定するには、SORACOM ユーザーコンソールのルートユーザーである必要があり、一部のユーザーにとって設定が難しい可能性があるためです。

詳細については、以下のリンクを参照してください。

users.soracom.io

次のステップでは、通知先として Slack の設定していきます。

通知先の Slack を準備する

すでに Slack を利用している場合、今回の Flux からの通知に必要な Incoming Webhooks の設定を行ってください。設定の詳細については、以下のリンクを参照してください。

api.slack.com

qiita.com

もし、Slack の利用が初めての場合は、以下のリンクを参照して準備を行ってください。

slack.com

Webhook URL が発行できれば、Slack 側の設定は完了です。今回はこの Webhook URL に対して Flux から通知を送信します。Webhook URL が準備できたら、次は SORACOM ユーザーコンソールでの設定に移ります。

SORACOM ユーザーコンソールでボタンの準備をする

SORACOM ユーザーコンソールは、すでにボタンを利用中であればログインできるはずです。 詳細については、以下のリンクを参照してください。

users.soracom.io

現時点では、IoT デバイスから送信された情報を SORACOM Flux で利用するには、Unified Endpoint を通じてデータを送信する必要があります。

users.soracom.io

ボタンは Unified Endpoint を利用してデータを送信しているため、そのままの設定で Flux でデータを活用できます。

users.soracom.io

ボタンの SIM グループを確認する

ボタンは登録する際に、SIM グループを設定しています。 詳細については、以下のリンクを参照してください。

users.soracom.io

これは、ボタンに Embedded (MFF2) の IoT SIM (plan-KM1) が内蔵されており、SORACOM ユーザーコンソール上で IoT SIM として管理されるからです。

ボタンのメニューから SIM グループを編集 をクリックし、SIM グループの設定を確認します。

SIM グループの設定では、次の点を確認してください。

  1. SORACOM Air for セルラーの設定項目で 簡易位置測位機能バイナリパーサーON になっていること。
  2. バイナリパーサーのフォーマットに @button が指定されていること。

    users.soracom.io

正しく設定されていない場合は、適切に設定を行ってください。

SORACOM Harvest

SORACOM Harvest (以下、Harvest) は、デバイスから送信されたデータやファイルをSORACOM で収集・蓄積するサービスです。詳細については、以下のリンクを参照してください。

soracom.jp

Harvest を使ってボタンのデータを確認する手順の詳細については、以下のリンクを参照してください。

users.soracom.io

Harvest でボタンのデータが確認できれば、結果的に SIM グループの設定が、正しく行われていることも確認できます。

SORACOM Flux でアプリを作成する

ここからは、SORACOM Flux を使ってアプリを作成していきます。Flux アプリを構成するコンポーネントの詳細については、以下のリンクを参照してください。

users.soracom.io

まず、Flux でボタンからのデータを扱うために、イベントソースの設定を行います。

ボタンからデータを受け取るイベントソースを設定する

各イベントソースの詳細については、以下のリンクを参照してください。

users.soracom.io

今回は、ボタンからのデータを利用するために、IoT デバイスイベントソース を設定します。

users.soracom.io

イベントソースが正しく設定されているかを確認するには、実際にボタンを押してデータを送信します。設定したイベントソースの メッセージ履歴 にボタンからのデータが表示されていれば、Flux で正しくデータが受信できています。

ボタンからデータが送信され、Flux で利用可能になったので、次はアクションの追加を行います。

Slack に通知するアクションを設定する

ここでは、ボタンから送信されたデータを Slack に通知するアクションを設定します。

users.soracom.io

まず、Slack の設定で払い出された Webhook URL を、このアクションの URL 欄に入力します。 次に、PAYLOAD には Slack に通知したいテキスト文字列を入力します。今回は、ボタンから送信されたデータをそのまま確認したいので、以下のように設定しました。

  •  
  • SORACOM LTE-M ボタンがクリックされました
  •  
  • -----
  • `event.payload`
  • ```
  • ${event.payload}
  • ```
  •  
  • `event.context`
  • ```
  • ${event.context}
  • ```
  • -----
  •  
  • ${getUTCYear(now())}/${getUTCMonth(now())}/${getUTCDate(now())} ${getUTCHours(now())}:${getUTCMinutes(now())}:${getUTCSeconds(now())} UTC
  •  

${} で記載されている部分は、送信されたデータを利用するための記法です。 詳細については、以下のリンクを参照してください。

users.soracom.io

今回の通知では、ボタンから送信されたデータ (payload) と、ヘッダーに付与された情報 (context) をそのまま表示しています。さらに、Flux で通知した日時情報を UTC で付加しています。

条件によって異なる内容を Slack に通知をする

ここまでで、Flux を利用した Slack への通知は実現できました。 次に、ボタンのクリックタイプに応じて、Slack への通知の内容を変更できるように設定します。

もし、通知する Slack チャンネルも変更したい場合は、必要に応じて新たに Webhook URL を払い出してください。

Slack に通知するアクションを追加する

すでに設定済みの Slack 通知と同様の内容で、新たに Slack 通知アクションを追加します。現時点ではアクションをコピーする機能がないため、手動で設定を行ってください。

今回は、通知先の Webhook URL は同じものを使用しましたが、通知内容には識別のために *### ロングクリックです ###* という文字列を追加しています。その他の設定は既存の Slack 通知と同じで、以下の内容になっています。

  •  
  • SORACOM LTE-M ボタンがクリックされました
  •  
  • *### ロング クリックです ###*
  •  
  • -----
  • `event.payload`
  • ```
  • ${event.payload}
  • ```
  • `event.context`
  • ```
  • ${event.context}
  • ```
  • -----
  •  
  • ${getUTCYear(now())}/${getUTCMonth(now())}/${getUTCDate(now())} ${getUTCHours(now())}:${getUTCMinutes(now())}:${getUTCSeconds(now())} UTC
  •  

Flux でアクションの実行条件を設定する

アクションの実行条件を設定することで、特定の条件に当てはまる場合のみアクションを実行できます。今回は、ボタンのクリックタイプを条件として設定します。

users.soracom.io

設定は簡単で、アクションの実行条件 に条件式を記載するだけです。ボタンから送信されるデータには clickType という項目と値が含まれており、この値がクリックタイプに応じて異なります。今回は、3: 長押し の場合にアクションが実行されるように設定します。

users.soracom.io

これで、ボタンのクリックタイプに応じて、Slack 通知の内容を変更できるようになりました。

ボタンのクリックで異なる動作を行う場合

ここまでの設定を実際に試してみた方は、ボタンのクリックで異なる動作を検討する際にいくつかのポイントを考慮する必要があることに気づかれたかもしれません。

まず、ボタンには LED 表示しかなく、その LED 表示は電池残量やデータ送信結果の表示に使われています。

users.soracom.io

https://soracom.jp/files/products/SORACOM_LTE-M_Button_manual.pdfsoracom.jp

このため、ボタン自体ではどのタイプのクリックを実行したのかを判別できません。また、ボタンをクリックしてデータ送信のシーケンスが始まると、ボタン側は送信のシーケンスが終了するまで、操作を受け付けなくなります。

このように、ボタンをクリックした側が自分でどの動作を行ったかを把握できないため、クリックミスやカバンの中での意図しないクリックが発生する可能性があります。そのため、メッセージを処理する側や運用の際には、ボタンがクリックされた という単純なトリガーとは異なる点を考慮して実装する必要が出てきます。

シンプルな解決策として、もし 3 種類の通知が必要であれば、物理的にボタンを3つ用意するのが手軽で良いかもしれません。

ボタンの簡易位置測位機能を活用してみる

ボタンのクリックタイプに応じた Slack 通知の送信は実現しましたが、ここからは Flux の可能性を探る実験として、ボタンの 簡易位置測位機能 を利用し、送信される位置情報を Flux で活用できるか試してみたいと思います。

具体的には、ボタンの簡易位置測位機能で送信される緯度経度情報を利用して、Reverse Geocoding を試してみます。Reverse Geocoding を使うことで、緯度経度情報から住所の文字列を取得できます。

OpenStreetMap API を試してみる

OpenStreetMap が提供している Nominatim を利用して、Reverse Geocoding を実現してみます。

github.com

具体的には、以下の API を使用します。

nominatim.org

今回は検証目的でボタンからのデータ送信に対してこのAPIを利用しますが、ご自身で利用する場合には、必ず API利用規約を確認してください。

operations.osmfoundation.org

API はシンプルで、指定されたエンドポイントに HTTP GET リクエストを送信すると、住所情報が返されます。

(リクエス例)
https://nominatim.openstreetmap.org/reverse?lat=<value>&lon=<value>&<params>

今回は format=geocodejson を指定して API を呼び出しています。

Flux で Webhook アクションを設定する

OpenStreetMap API を呼び出すために、Flux で Webhook アクションを設定します。

users.soracom.io

まず、HTTP メソッドの項目を GET に設定し、URL には以下のように OpenStreetMap API のエンドポイント を指定します。

https://nominatim.openstreetmap.org/reverse?format=geocodejson&lat=${event.context.resource.location.lat}&lon=${event.context.resource.location.lon}

ボタンの簡易位置測位機能は、SORACOM の設定で ON / OFF ができます。そのため、この Webhook アクションも、簡易位置測位機能が ON で、緯度経度情報が送信されている場合のみ実行されるように、実行条件を設定します。

緯度経度情報は、context の resource として送信されます。

users.soracom.io

具体的には、context には以下のようなデータが含まれています。

{
  "eventType": "device",
  "request": {
    "protocol": "udp"
  },
  "resource": {
    "imei": "<imei>",
    "resourceId": "<imsi>",
    "location": {
      "lat": <lat>,
      "lon": <lon>
    },
    "imsi": "<imsi>",
    "resourceType": "Subscriber",
    "simId": "<sim-id>"
  },
  "group": {
    "groupId": "<group-id>"
  }
}

アクションの実行条件として event.context.resource.location != null を指定し、緯度経度情報が存在しない場合には、アクションが実行されないように設定します。

最後に、アクションの出力を別のチャンネルに送信する設定を行い、OpenStreetMap API の実行結果を次のアクションで利用できるようにします。

OpenStreetMap API の実行結果を Slack に通知するアクションを設定する

これまでと同様に、Slack に通知するアクションを設定します。このアクションも緯度経度情報が存在しない場合には実行できないため、アクションの実行条件として、前段の OpenStreetMap API 実行アクションと同じく event.context.resource.location != null を指定しておきます。

OpenStreetMap API の実行結果から必要な情報を取り出すための詳細については、以下のリンクを参照してください。

nominatim.org

今回は以下のような内容の通知を行いました。

  •  
  • SORACOM LTE-M ボタンがクリックされました
  •  
  • *### 簡易位置情報が付加されています ###*
  •  
  • -----
  • ・クリックタイプ:
  •     ・ ${event.payload.clickType}
  •  
  • ・クリックタイプ名:
  •     ・ ${event.payload.clickTypeName}
  •  
  • ・バッテリーレベル:
  •     ・ ${event.payload.batteryLevel}
  •  
  • ・簡易位置情報:
  •     ・緯度: ${event.context.resource.location.lat}
  •     ・経度: ${event.context.resource.location.lon}
  •  
  • Google Maps URL:
  •     ・ https://www.google.com/maps?q=${event.context.resource.location.lat},${event.context.resource.location.lon}
  •  
  •  
  • OpenStreetMap API による位置推定:
  •     ・ ${payload.features[0].properties.geocoding.label}
  •     ・ https://www.google.com/maps?q=${payload.features[0].geometry.coordinates[1]},${payload.features[0].geometry.coordinates[0]}
  • -----
  •  
  • ${getUTCYear(now()+32400000)}/${getUTCMonth(now()+32400000)}/${getUTCDate(now()+32400000)} ${getUTCHours(now()+32400000)}:${getUTCMinutes(now()+32400000)}:${getUTCSeconds(now()+32400000)} JST
  •  

実際にボタンを押してみると、以下のような通知が Slack に届いているのが確認できます。

通知には Google Maps のリンクも含まれているので、クリックして確認してみてください。ボタンをクリックした位置(緯度経度情報)が、実際の現在位置から少しずれている可能性があります。これは、簡易位置測位機能が GPS からの情報ではなく、プラットフォーム側で付与されたデバイスの簡易的な位置情報であるためです。

users.soracom.io

また、OpenStreetMap API で取得した情報も、実際の緯度経度の場所と異なる可能性があります。これは、登録されている情報の精度や粒度に依存するためで、精度が必要な場合には、必要に応じて確認を行ってください。

ここまでで、Flux を利用して緯度経度情報から位置情報の文字列を取得し、Slack に通知できました。精度の問題はあるものの、緯度経度の数値情報を人が理解できる情報に変換する手段を試せたと言えるのではないでしょうか。

生成 AI による Reverse Geocoding を試してみる

OpenStreetMap API で実現した Reverse Geocoding を、生成 AI を使って試してみたらどうなるかを、検証してみようと思います。

Flux で AI アクションを設定する

生成 AI による Reverse Geocoding を試すために、AI アクションを設定します。

users.soracom.io

OpenStreetMap API の場合と同様に、アクションの実行条件として event.context.resource.location != null を指定し、緯度経度情報が存在しない場合には実行されないように設定します。

AI モデルは Azure OpenAI (GPT-4o) を利用しました。比較検証を行っていないため、他の AI モデルを利用すると結果と精度が異なる可能性があります。

生成 AI に渡すプロンプトは以下の内容です。

  •  
  • あなたは専門家です。専門分野は以下です。
  •  
  • 1. 日本地理
  • 2. 地図と住所
  • 3. 経路や乗り換え案内
  •  
  • あなたの使命は
  • 入力された緯度経度情報を日本の住所情報に変換することです。
  •  
  • あなたはこの世で唯一の専門家なので
  • 緯度経度情報を変換する場合は、`注意深く観察して、意識的に` より詳細な住所に変換してください。
  •  
  • 返答は以下に指示するJSON形式で行ってください
  •  
  • {
  •   "zipCode": "あなたが緯度経度情報から変換した住所の郵便番号を入力してください"
  •   "address": "あなたが緯度経度情報から変換した住所を入力してください"
  •   "landmarks":"他の人がその場所にたどり着けるような特徴的で目印になるものが存在した場合入力してください"
  •   "navigation": "出発地点は、東京駅として入力された緯度経度情報の場所まで到達するための経路を入力してください。公共交通機関を優先してください"
  •   "time": "出発地点は、東京駅として入力された緯度経度情報の場所まで到達するために、あなたが案内した経路の所要時間を入力してください"
  •   "fare": "出発地点は、東京駅として入力された緯度経度情報の場所まで到達するために、あなたが案内した経路の所要運賃を入力してください"
  • }
  •  
  • 以下の緯度経度情報について、上記の内容を返答してください
  •  
  • ${context.resource.location}
  •  

プロンプトでは、緯度経度情報を処理するための前提条件や出力結果の条件を指定しました。また、API と異なり自然言語で柔軟なインプットが行えるのが、生成 AI の強みでもあるので、東京駅 をスタート地点にした乗り換え案内も依頼しています。

最後に、生成 AI アクションの出力を別のチャンネルに送信する設定を行い、次のアクションで利用できるようにします。

AI アクションの実行結果を Slack に通知するアクションを設定する

これまでと同様に、Slack に通知するアクションを設定します。このアクションも緯度経度情報が存在しないと実行できないため、アクションの実行条件として、前段の AI アクションと同様に event.context.resource.location != null を指定します。

AI アクションの実行結果の形式は、生成 AI に渡すプロンプト内で指定しています。

今回は以下のような内容で Slack 通知を行いました。

  •  
  • SORACOM LTE-M ボタンがクリックされました
  •  
  • *### 簡易位置情報が付加されています ###*
  •  
  • -----
  • ・クリックタイプ:
  •     ・ ${event.payload.clickType}
  •  
  • ・クリックタイプ名:
  •     ・ ${event.payload.clickTypeName}
  •  
  • ・バッテリーレベル:
  •     ・ ${event.payload.batteryLevel}
  •  
  • ・簡易位置情報:
  •     ・緯度: ${event.context.resource.location.lat}
  •     ・経度: ${event.context.resource.location.lon}
  •  
  • Google Maps URL:
  •     ・ https://www.google.com/maps?q=${event.context.resource.location.lat},${event.context.resource.location.lon}
  •  
  •  
  • ・生成AI による位置推定:
  •     ・${payload.output.zipCode} ${payload.output.address}
  •  
  • ・生成AI によるランドマーク推定:
  •     ・${payload.output.landmarks}
  •  
  • ・生成AI による `東京駅` からの乗り換え案内:
  •     ・${payload.output.navigation} ${payload.output.time} ${payload.output.fare}
  • -----
  •  
  • ${getUTCYear(now()+32400000)}/${getUTCMonth(now()+32400000)}/${getUTCDate(now()+32400000)} ${getUTCHours(now()+32400000)}:${getUTCMinutes(now()+32400000)}:${getUTCSeconds(now()+32400000)} JST
  •  

実際にボタンを押して実行してみると、以下のような通知が Slack に届いているのが確認できます。

通知には Google Maps のリンクも含まれているので、クリックしてみて、ボタンをクリックした際に付加された位置情報の実際の場所を確認してみてください。

生成 AI によって推定された住所情報の精度はどうでしょうか? 私も何度か試してみましたが、推定された場所が実際の位置からかなり離れていることが多くありました。また、市単位など大まかな範囲では正確な場合もありましたが、詳細な場所については API で確認した情報よりも大きくずれていることがありました。

しかし、住所情報以外のランドマーク情報や乗換案内情報については、生成 AI が提示した場所を前提とすれば、そこまで大きくずれていないように見受けられます。この結果から、生成 AI で Reverse Geocoding を行う場合、AI とのやり取りを何度か繰り返す必要があると感じました。ワンショットで期待する情報を取得するのは難しいという印象です。プロンプトもさらに工夫が必要だと感じたので、別の機会に再チャレンジしてみたいと思います。

まとめ

  • SORACOM Flux を使うことで、LTE-M ボタンの情報を簡単に Slack 通知できた
  • ボタンのクリックタイプに応じて、通知内容を変更できた
  • Flux のメール通知は、現時点では制限があるため、その点を理解して使用する必要がある
  • 単純に通知するだけでなく、途中で API や生成 AI を呼び出すことで、情報に付加価値を与えたり、人にわかりやすく整理することができる
  • 一方で、API の精度や生成 AI の出力特性を理解した上で利用することが重要となる
  • ボタンの簡易位置測位機能を ON にした場合、ボタンのデータが SORACOM に届いてから位置情報が付加される
  • そのため、ボタンの位置情報は Flux では context に含まれている
  • ボタンを実際に利用する場合には問題ないが、例えば実行のテストで、実際にボタンを押さずに緯度経度情報のインプットを行いたい場合に困る
  • イベントソースには、テスト実行や API 呼び出しを行える機能がありますが、この機能で指定できるのはイベントの message (payload) のみ
  • 現時点では、テスト実行すると、context は 必ず {"eventType":"api"} となり、位置情報を付加する手段がないため、正しくテストが行えない

  • また、 {"eventType":"api"} となっている場合、event.context.resource.location != null評価式自体がエラー になってしまう
  • これは 親の要素 event.context.resourcenull となるため、子の要素を評価する際には null 参照 となってしまうためではないかと思う
  • これらのマイナーな問題点については、今後の継続的なアップデートでの改善を期待したい
  • シンプルな解決方法は、位置情報が payload で送信されるデバイスを利用すること

    users.soracom.io

  • 位置情報だけではなく、必要な情報がすべて payload に入っているデバイスやデータであれば、テスト実行できるため、デバイスやデータの形式を検討するのが良さそう
  • 最終的には、Flux アプリを作成する段階から自然言語で指示できるようになって欲しい
  • すでに Flux の便利ツールがあるので、使って遊んでみたり

    blog.soracom.com

  • 猫の行動を見つめてみたり

    blog.soracom.com

  • Flux はさまざまな使い方ができ、今後も進化していくと思うので、ぜひ楽しんで活用してみてください!


以上です。

Python だけで ソラカメ の MPEG-DASH を再生してみた

目次

SORACOM Advent Calendar 2023

この記事は SORACOM Advent Calendar 2023 の11日目の記事です。

qiita.com

今回はソラカメの API を利用して、ストリーミング映像を取得する際に利用する MPEG-DASH (Dynamic Adaptive Streaming over HTTP) を、FFmpeg といった外部のツールを利用せずに Python だけで再生できるか試してみました。

sora-cam.com

users.soracom.io

Python だけで MPEG-DASH の操作ができると、AI / ML との連携やローカル環境で自前のモデルでいろいろな試行錯誤が行えるようになると思います。

あと毎回のお約束ですが、すべての内容は個人で検証したもの になり、保証や確証はないのでもし利用する場合は必ずご自身で確認の上でお願いします。文中の現時点は2023年12月10日を指しています。

MPEG-DASH とは?

MPEG-DASH はストリーミング方式の1つで、DASH は「Dynamic Adaptive Streaming over HTTP」の略です。標準化されており、動画を小さな単位に分割し、異なる品質でエンコードできます。これにより、さまざまな品質の動画をストリーミングしたり、動画の途中で品質を切り替えることができます。

詳細は以下を参照してください。

en.wikipedia.org

https://www.cloudflare.com/ja-jp/learning/video/what-is-mpeg-dash/www.cloudflare.com

ソラカメとは?

ソラカメ (Soracom Cloud Camera Services) は、クラウド環境への録画機能があるクラウド型カメラです。クラウドに保存した映像をAPI やアプリを使って確認できます。ソラカメは価格の面や設定面で導入が容易であるため、これまで活用が難しかった部分に設置して試してみることが可能となります。

詳細は以下を参照してください。

sora-cam.com

soracom.jp

ソラカメの API を利用して、ストリーミング映像を取得する場合、現時点では MPEG-DASH となります。

ソラカメの MPEG-DASH を再生するには?

ソラカメのストリーミング映像は、MPEG-DASH 方式で配信されています。そのためソラカメの映像を再生するには、スマホアプリ、SORACOMユーザーコンソールなど、すでに用意されたものを活用する方が手間がなく便利です。

users.soracom.io

その他の方法だと VLC media playerdash.js の Reference Client で再生したり、FFmpeg を利用することで、単純な再生だけでなく、再エンコード処理をはじめとした他の操作を行うこともできます。

www.videolan.org

dashif.org

reference.dashif.org

ffmpeg.org

しかし、状況によってはこれらの便利なツールが利用できない場合や、再エンコードや配信のために利用するわけではなく、AI / ML のインプットとして単純に映像ストリームを利用したい場合があるかもしれないです。そんな時のために、今回は Python だけを利用して MPEG-DASH の映像ストリームを取得して再生してみたいと思います。

MPEG-DASH の MPD ファイルとは?

MPEG-DASH の MPD ファイル(Media Presentation Description)は、DASH(Dynamic Adaptive Streaming over HTTP)プロトコルで使用される XML フォーマットのファイルです。ファイルには、動画ストリーミングのための重要な情報が含まれていて、DASH 対応のプレーヤーが動画のダウンロードと再生を行うために必要なファイルです。

実際にソラカメの API を利用して、ストリーム映像の取得を行うと URL が取得できますが、この取得できる URL は MPD ファイルの URL (https://xxxxxx.kinesisvideo.ap-northeast-1.amazonaws.com/dash/v1/getDASHManifest.mpd?SessionToken=xxxxx)となっています。通常は、この MPD ファイルの URL を DASH 対応のプレーヤーに与えることで、内部的には MPD ファイルを読み解いて必要な動画のダウンロードと再生が実行されています。

users.soracom.io

この動作は、比較的簡単に確認することができます。例えばソラカメの API で取得したストリーミング用の URL(MPD ファイルの URL)を、dash.js のReference Client で再生します。再生を実行している Web ブラウザのデベロッパーツールを立ち上げて、Network パネルを確認してみると、定期的に動画を取得している様子が確認できるかと思います。

tech.nri-net.com

ということで、MPD ファイルを読み解いて動画をダウンロードできれば Python でも同じことはできそうな気がしてきましたね。

MPEG-DASH の MPD ファイル を Python で parse してみる

まず初めに、MPD ファイルを parse していきます。MPD ファイルは XML なのでそんなに考えなくても対応できそうです。

実際の MPD ファイルは以下のようなファイルです。

<?xml version='1.0' encoding='UTF-8'?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:kvs="urn:aws:kinesisvideo:mpd:2019" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" type="static" availabilityStartTime="2023-12-09T07:58:21.212Z" mediaPresentationDuration="PT2M20.934S" minBufferTime="PT1S">
  <Period id="0" start="PT0S" duration="PT2M20.934S">
    <AdaptationSet segmentAlignment="true" mimeType="video/mp4" startWithSAP="1">
      <SegmentTemplate media="getMP4MediaFragment.mp4?SessionToken=xxxxx&amp;SequenceNumber=$Number$&amp;TrackNumber=1" initialization="getMP4InitFragment.mp4?SessionToken=xxxxx&amp;TrackNumber=1" timescale="1000" startNumber="1">
        <SegmentTimeline>
          <S t="0" d="1001" kvs:ts="2023-12-09T07:58:21.212Z" kvs:fn="91343852333215703524729639126345842704639415673"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:22.210Z" kvs:fn="91343852333215703529681399283487364038145840473"/>
          <S d="996" kvs:ts="2023-12-09T07:58:23.213Z" kvs:fn="91343852333215703534633159440628885421286199850"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:24.211Z" kvs:fn="91343852333215703539584919597770406769204217591"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:25.210Z" kvs:fn="91343852333215703544536679754911928154339861911"/>
          <S d="996" kvs:ts="2023-12-09T07:58:26.213Z" kvs:fn="91343852333215703549488439912053449515427341534"/>
          <S d="1002" kvs:ts="2023-12-09T07:58:27.210Z" kvs:fn="91343852333215703554440200069194970873551962088"/>
          <S d="998" kvs:ts="2023-12-09T07:58:28.210Z" kvs:fn="91343852333215703559391960226336492251454191872"/>
          <S d="997" kvs:ts="2023-12-09T07:58:29.211Z" kvs:fn="91343852333215703564343720383478013608587928635"/>
          <S d="1004" kvs:ts="2023-12-09T07:58:30.208Z" kvs:fn="91343852333215703569295480540619534972485677069"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:31.209Z" kvs:fn="91343852333215703574247240697761056341755407385"/>
          <S d="997" kvs:ts="2023-12-09T07:58:32.210Z" kvs:fn="91343852333215703579199000854902577713961791561"/>
          <S d="1002" kvs:ts="2023-12-09T07:58:33.208Z" kvs:fn="91343852333215703584150761012044099076278831377"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:34.208Z" kvs:fn="91343852333215703589102521169185620452031428557"/>
          <S d="997" kvs:ts="2023-12-09T07:58:35.209Z" kvs:fn="91343852333215703594054281326327141822373531240"/>
          <S d="1003" kvs:ts="2023-12-09T07:58:36.207Z" kvs:fn="91343852333215703599006041483468663184031284812"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:37.207Z" kvs:fn="91343852333215703603957801640610184559497023988"/>
          <S d="998" kvs:ts="2023-12-09T07:58:38.208Z" kvs:fn="91343852333215703608909561797751705925074656222"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:39.206Z" kvs:fn="91343852333215703613861321954893227288743741351"/>
          <S d="998" kvs:ts="2023-12-09T07:58:40.207Z" kvs:fn="91343852333215703618813082112034748659902149052"/>
          <S d="997" kvs:ts="2023-12-09T07:58:41.207Z" kvs:fn="91343852333215703623764842269176270031831648400"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:42.205Z" kvs:fn="91343852333215703628716602426317791388143694876"/>
          <S d="998" kvs:ts="2023-12-09T07:58:43.206Z" kvs:fn="91343852333215703633668362583459312764630836931"/>
          <S d="998" kvs:ts="2023-12-09T07:58:44.206Z" kvs:fn="91343852333215703638620122740600834126611228269"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:45.204Z" kvs:fn="91343852333215703643571882897742355500545349018"/>
          <S d="998" kvs:ts="2023-12-09T07:58:46.205Z" kvs:fn="91343852333215703648523643054883876869407937734"/>
          <S d="997" kvs:ts="2023-12-09T07:58:47.206Z" kvs:fn="91343852333215703653475403212025398237883696543"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:48.204Z" kvs:fn="91343852333215703658427163369166919596413601907"/>
          <S d="998" kvs:ts="2023-12-09T07:58:49.204Z" kvs:fn="91343852333215703663378923526308441007759581867"/>
          <S d="998" kvs:ts="2023-12-09T07:58:50.204Z" kvs:fn="91343852333215703668330683683449962347049124213"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:51.203Z" kvs:fn="91343852333215703673282443840591483699803353577"/>
          <S d="998" kvs:ts="2023-12-09T07:58:52.203Z" kvs:fn="91343852333215703678234203997733005072327464618"/>
          <S d="998" kvs:ts="2023-12-09T07:58:53.203Z" kvs:fn="91343852333215703683185964154874526465491531013"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:54.202Z" kvs:fn="91343852333215703688137724312016047804996799635"/>
          <S d="998" kvs:ts="2023-12-09T07:58:55.202Z" kvs:fn="91343852333215703693089484469157569198675301585"/>
          <S d="998" kvs:ts="2023-12-09T07:58:56.202Z" kvs:fn="91343852333215703698041244626299090566624458401"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:57.201Z" kvs:fn="91343852333215703702993004783440611913201513403"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:58.200Z" kvs:fn="91343852333215703707944764940582133301407551807"/>
          <S d="997" kvs:ts="2023-12-09T07:58:59.202Z" kvs:fn="91343852333215703712896525097723654664921362969"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:00.200Z" kvs:fn="91343852333215703717848285254865176006879908031"/>
          <S d="997" kvs:ts="2023-12-09T07:59:01.201Z" kvs:fn="91343852333215703722800045412006697426114573620"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:02.200Z" kvs:fn="91343852333215703727751805569148218755816554703"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:03.199Z" kvs:fn="91343852333215703732703565726289740119145777387"/>
          <S d="998" kvs:ts="2023-12-09T07:59:04.199Z" kvs:fn="91343852333215703737655325883431261489097116733"/>
          <S d="998" kvs:ts="2023-12-09T07:59:05.200Z" kvs:fn="91343852333215703742607086040572782864226764134"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:06.198Z" kvs:fn="91343852333215703747558846197714304218227347320"/>
          <S d="996" kvs:ts="2023-12-09T07:59:07.201Z" kvs:fn="91343852333215703752510606354855825600711209580"/>
          <S d="998" kvs:ts="2023-12-09T07:59:08.200Z" kvs:fn="91343852333215703757462366511997346960412678389"/>
          <S d="996" kvs:ts="2023-12-09T07:59:09.202Z" kvs:fn="91343852333215703762414126669138868328263710307"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:10.197Z" kvs:fn="91343852333215703767365886826280389704922436148"/>
          <S d="998" kvs:ts="2023-12-09T07:59:11.198Z" kvs:fn="91343852333215703772317646983421911059565925392"/>
          <S d="1005" kvs:ts="2023-12-09T07:59:12.196Z" kvs:fn="91343852333215703777269407140563432429892186724"/>
          <S d="997" kvs:ts="2023-12-09T07:59:13.198Z" kvs:fn="91343852333215703782221167297704953813368133151"/>
          <S d="995" kvs:ts="2023-12-09T07:59:14.200Z" kvs:fn="91343852333215703787172927454846475169089211094"/>
          <S d="1004" kvs:ts="2023-12-09T07:59:15.195Z" kvs:fn="91343852333215703792124687611987996530657499624"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:16.195Z" kvs:fn="91343852333215703797076447769129517922095635738"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:17.196Z" kvs:fn="91343852333215703802028207926271039278930289553"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:18.196Z" kvs:fn="91343852333215703806979968083412560639125380573"/>
          <S d="998" kvs:ts="2023-12-09T07:59:19.195Z" kvs:fn="91343852333215703811931728240554082029047690586"/>
          <S d="997" kvs:ts="2023-12-09T07:59:20.196Z" kvs:fn="91343852333215703816883488397695603387750476174"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:21.194Z" kvs:fn="91343852333215703821835248554837124737404800217"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:22.193Z" kvs:fn="91343852333215703826787008711978646109146001286"/>
          <S d="997" kvs:ts="2023-12-09T07:59:23.195Z" kvs:fn="91343852333215703831738768869120167474017114838"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:24.193Z" kvs:fn="91343852333215703836690529026261688834698383107"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:25.193Z" kvs:fn="91343852333215703841642289183403210216519737681"/>
          <S d="998" kvs:ts="2023-12-09T07:59:26.193Z" kvs:fn="91343852333215703846594049340544731565600600899"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:27.192Z" kvs:fn="91343852333215703851545809497686252940163647626"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:28.193Z" kvs:fn="91343852333215703856497569654827774310034023472"/>
          <S d="998" kvs:ts="2023-12-09T07:59:29.192Z" kvs:fn="91343852333215703861449329811969295683212863901"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:30.191Z" kvs:fn="91343852333215703866401089969110817048856151526"/>
          <S d="998" kvs:ts="2023-12-09T07:59:31.192Z" kvs:fn="91343852333215703871352850126252338450170327197"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:32.191Z" kvs:fn="91343852333215703876304610283393859788926569260"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:33.190Z" kvs:fn="91343852333215703881256370440535381156069529009"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:34.190Z" kvs:fn="91343852333215703886208130597676902529391842243"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:35.191Z" kvs:fn="91343852333215703891159890754818423902612351553"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:36.190Z" kvs:fn="91343852333215703896111650911959945261759082449"/>
          <S d="1003" kvs:ts="2023-12-09T07:59:37.189Z" kvs:fn="91343852333215703901063411069101466647617216149"/>
          <S d="998" kvs:ts="2023-12-09T07:59:38.190Z" kvs:fn="91343852333215703906015171226242988017449587601"/>
          <S d="1002" kvs:ts="2023-12-09T07:59:39.188Z" kvs:fn="91343852333215703910966931383384509351344036949"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:40.188Z" kvs:fn="91343852333215703915918691540526030723932802908"/>
          <S d="998" kvs:ts="2023-12-09T07:59:41.189Z" kvs:fn="91343852333215703920870451697667552086417156042"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:42.187Z" kvs:fn="91343852333215703925822211854809073454287366705"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:43.187Z" kvs:fn="91343852333215703930773972011950594827839141141"/>
          <S d="996" kvs:ts="2023-12-09T07:59:44.190Z" kvs:fn="91343852333215703935725732169092116194411814209"/>
          <S d="1002" kvs:ts="2023-12-09T07:59:45.186Z" kvs:fn="91343852333215703940677492326233637557677318663"/>
          <S d="998" kvs:ts="2023-12-09T07:59:46.187Z" kvs:fn="91343852333215703945629252483375158940826714326"/>
          <S d="998" kvs:ts="2023-12-09T07:59:47.187Z" kvs:fn="91343852333215703950581012640516680294209549768"/>
          <S d="1003" kvs:ts="2023-12-09T07:59:48.186Z" kvs:fn="91343852333215703955532772797658201665798537520"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:49.186Z" kvs:fn="91343852333215703960484532954799723041304309232"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:50.186Z" kvs:fn="91343852333215703965436293111941244400480811698"/>
          <S d="1002" kvs:ts="2023-12-09T07:59:51.185Z" kvs:fn="91343852333215703970388053269082765787997272409"/>
          <S d="997" kvs:ts="2023-12-09T07:59:52.186Z" kvs:fn="91343852333215703975339813426224287150493119161"/>
          <S d="997" kvs:ts="2023-12-09T07:59:53.186Z" kvs:fn="91343852333215703980291573583365808529427439091"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:54.184Z" kvs:fn="91343852333215703985243333740507329872396810594"/>
          <S d="998" kvs:ts="2023-12-09T07:59:55.184Z" kvs:fn="91343852333215703990195093897648851249556434180"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:56.184Z" kvs:fn="91343852333215703995146854054790372604023498907"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:57.183Z" kvs:fn="91343852333215704000098614211931893984032624967"/>
          <S d="998" kvs:ts="2023-12-09T07:59:58.184Z" kvs:fn="91343852333215704005050374369073415341558035381"/>
          <S d="997" kvs:ts="2023-12-09T07:59:59.184Z" kvs:fn="91343852333215704010002134526214936707567304371"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:00.182Z" kvs:fn="91343852333215704014953894683356458084266234021"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:01.182Z" kvs:fn="91343852333215704019905654840497979454853954346"/>
          <S d="997" kvs:ts="2023-12-09T08:00:02.183Z" kvs:fn="91343852333215704024857414997639500815572031422"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:03.182Z" kvs:fn="91343852333215704029809175154781022186570848159"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:04.181Z" kvs:fn="91343852333215704034760935311922543563899855522"/>
          <S d="998" kvs:ts="2023-12-09T08:00:05.182Z" kvs:fn="91343852333215704039712695469064064921908990389"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:06.180Z" kvs:fn="91343852333215704044664455626205586295211281904"/>
          <S d="998" kvs:ts="2023-12-09T08:00:07.180Z" kvs:fn="91343852333215704049616215783347107670076647589"/>
          <S d="997" kvs:ts="2023-12-09T08:00:08.182Z" kvs:fn="91343852333215704054567975940488629031317371241"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:09.179Z" kvs:fn="91343852333215704059519736097630150399754433787"/>
          <S d="1000" kvs:ts="2023-12-09T08:00:10.180Z" kvs:fn="91343852333215704064471496254771671781924511250"/>
          <S d="997" kvs:ts="2023-12-09T08:00:11.181Z" kvs:fn="91343852333215704069423256411913193131811709641"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:12.178Z" kvs:fn="91343852333215704074375016569054714505589391101"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:13.178Z" kvs:fn="91343852333215704079326776726196235878240959274"/>
          <S d="998" kvs:ts="2023-12-09T08:00:14.179Z" kvs:fn="91343852333215704084278536883337757236853824140"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:15.178Z" kvs:fn="91343852333215704089230297040479278608994999830"/>
          <S d="998" kvs:ts="2023-12-09T08:00:16.178Z" kvs:fn="91343852333215704094182057197620799981135040031"/>
          <S d="997" kvs:ts="2023-12-09T08:00:17.179Z" kvs:fn="91343852333215704099133817354762321330080620038"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:18.177Z" kvs:fn="91343852333215704104085577511903842701180337552"/>
          <S d="1000" kvs:ts="2023-12-09T08:00:19.177Z" kvs:fn="91343852333215704109037337669045364075483794638"/>
          <S d="998" kvs:ts="2023-12-09T08:00:20.177Z" kvs:fn="91343852333215704113989097826186885442173482996"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:21.176Z" kvs:fn="91343852333215704118940857983328406807819366983"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:22.175Z" kvs:fn="91343852333215704123892618140469928185224537147"/>
          <S d="997" kvs:ts="2023-12-09T08:00:23.177Z" kvs:fn="91343852333215704128844378297611449541890474942"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:24.175Z" kvs:fn="91343852333215704133796138454752970912981491389"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:25.175Z" kvs:fn="91343852333215704138747898611894492288343747538"/>
          <S d="998" kvs:ts="2023-12-09T08:00:26.176Z" kvs:fn="91343852333215704143699658769036013653896960550"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:27.174Z" kvs:fn="91343852333215704148651418926177535024101316267"/>
          <S d="998" kvs:ts="2023-12-09T08:00:28.175Z" kvs:fn="91343852333215704153603179083319056394402943758"/>
          <S d="998" kvs:ts="2023-12-09T08:00:29.175Z" kvs:fn="91343852333215704158554939240460577761622848457"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:30.173Z" kvs:fn="91343852333215704163506699397602099127324849081"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:31.174Z" kvs:fn="91343852333215704168458459554743620513526051737"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:32.174Z" kvs:fn="91343852333215704173410219711885141863712428816"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:33.173Z" kvs:fn="91343852333215704178361979869026663236153824465"/>
          <S d="998" kvs:ts="2023-12-09T08:00:34.173Z" kvs:fn="91343852333215704183313740026168184616203150113"/>
          <S d="998" kvs:ts="2023-12-09T08:00:35.173Z" kvs:fn="91343852333215704188265500183309705966971460559"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:36.172Z" kvs:fn="91343852333215704193217260340451227332516294301"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:37.171Z" kvs:fn="91343852333215704198169020497592748714842021214"/>
          <S d="997" kvs:ts="2023-12-09T08:00:38.173Z" kvs:fn="91343852333215704203120780654734270073652079153"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:39.170Z" kvs:fn="91343852333215704208072540811875791437991841660"/>
          <S d="1000" kvs:ts="2023-12-09T08:00:40.170Z" kvs:fn="91343852333215704213024300969017312839347172304"/>
          <S d="995" kvs:ts="2023-12-09T08:00:41.174Z" kvs:fn="91343852333215704217976061126158834177854986059"/>
        </SegmentTimeline>
      </SegmentTemplate>
      <Representation id="video" bandwidth="977582" width="1920" height="1080" frameRate="19.98" codecs="avc1.4d0029" scanType="progressive"/>
    </AdaptationSet>
    <AdaptationSet segmentAlignment="true" mimeType="audio/mp4" startWithSAP="1">
      <SegmentTemplate media="getMP4MediaFragment.mp4?SessionToken=xxxxx&amp;SequenceNumber=$Number$&amp;TrackNumber=2" initialization="getMP4InitFragment.mp4?SessionToken=xxxxx&amp;TrackNumber=2" timescale="1000" startNumber="1">
        <SegmentTimeline>
          <S t="0" d="1001" kvs:ts="2023-12-09T07:58:21.212Z" kvs:fn="91343852333215703524729639126345842704639415673"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:22.210Z" kvs:fn="91343852333215703529681399283487364038145840473"/>
          <S d="996" kvs:ts="2023-12-09T07:58:23.213Z" kvs:fn="91343852333215703534633159440628885421286199850"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:24.211Z" kvs:fn="91343852333215703539584919597770406769204217591"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:25.210Z" kvs:fn="91343852333215703544536679754911928154339861911"/>
          <S d="996" kvs:ts="2023-12-09T07:58:26.213Z" kvs:fn="91343852333215703549488439912053449515427341534"/>
          <S d="1002" kvs:ts="2023-12-09T07:58:27.210Z" kvs:fn="91343852333215703554440200069194970873551962088"/>
          <S d="998" kvs:ts="2023-12-09T07:58:28.210Z" kvs:fn="91343852333215703559391960226336492251454191872"/>
          <S d="997" kvs:ts="2023-12-09T07:58:29.211Z" kvs:fn="91343852333215703564343720383478013608587928635"/>
          <S d="1004" kvs:ts="2023-12-09T07:58:30.208Z" kvs:fn="91343852333215703569295480540619534972485677069"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:31.209Z" kvs:fn="91343852333215703574247240697761056341755407385"/>
          <S d="997" kvs:ts="2023-12-09T07:58:32.210Z" kvs:fn="91343852333215703579199000854902577713961791561"/>
          <S d="1002" kvs:ts="2023-12-09T07:58:33.208Z" kvs:fn="91343852333215703584150761012044099076278831377"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:34.208Z" kvs:fn="91343852333215703589102521169185620452031428557"/>
          <S d="997" kvs:ts="2023-12-09T07:58:35.209Z" kvs:fn="91343852333215703594054281326327141822373531240"/>
          <S d="1003" kvs:ts="2023-12-09T07:58:36.207Z" kvs:fn="91343852333215703599006041483468663184031284812"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:37.207Z" kvs:fn="91343852333215703603957801640610184559497023988"/>
          <S d="998" kvs:ts="2023-12-09T07:58:38.208Z" kvs:fn="91343852333215703608909561797751705925074656222"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:39.206Z" kvs:fn="91343852333215703613861321954893227288743741351"/>
          <S d="998" kvs:ts="2023-12-09T07:58:40.207Z" kvs:fn="91343852333215703618813082112034748659902149052"/>
          <S d="997" kvs:ts="2023-12-09T07:58:41.207Z" kvs:fn="91343852333215703623764842269176270031831648400"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:42.205Z" kvs:fn="91343852333215703628716602426317791388143694876"/>
          <S d="998" kvs:ts="2023-12-09T07:58:43.206Z" kvs:fn="91343852333215703633668362583459312764630836931"/>
          <S d="998" kvs:ts="2023-12-09T07:58:44.206Z" kvs:fn="91343852333215703638620122740600834126611228269"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:45.204Z" kvs:fn="91343852333215703643571882897742355500545349018"/>
          <S d="998" kvs:ts="2023-12-09T07:58:46.205Z" kvs:fn="91343852333215703648523643054883876869407937734"/>
          <S d="997" kvs:ts="2023-12-09T07:58:47.206Z" kvs:fn="91343852333215703653475403212025398237883696543"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:48.204Z" kvs:fn="91343852333215703658427163369166919596413601907"/>
          <S d="998" kvs:ts="2023-12-09T07:58:49.204Z" kvs:fn="91343852333215703663378923526308441007759581867"/>
          <S d="998" kvs:ts="2023-12-09T07:58:50.204Z" kvs:fn="91343852333215703668330683683449962347049124213"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:51.203Z" kvs:fn="91343852333215703673282443840591483699803353577"/>
          <S d="998" kvs:ts="2023-12-09T07:58:52.203Z" kvs:fn="91343852333215703678234203997733005072327464618"/>
          <S d="998" kvs:ts="2023-12-09T07:58:53.203Z" kvs:fn="91343852333215703683185964154874526465491531013"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:54.202Z" kvs:fn="91343852333215703688137724312016047804996799635"/>
          <S d="998" kvs:ts="2023-12-09T07:58:55.202Z" kvs:fn="91343852333215703693089484469157569198675301585"/>
          <S d="998" kvs:ts="2023-12-09T07:58:56.202Z" kvs:fn="91343852333215703698041244626299090566624458401"/>
          <S d="1001" kvs:ts="2023-12-09T07:58:57.201Z" kvs:fn="91343852333215703702993004783440611913201513403"/>
          <S d="1000" kvs:ts="2023-12-09T07:58:58.200Z" kvs:fn="91343852333215703707944764940582133301407551807"/>
          <S d="997" kvs:ts="2023-12-09T07:58:59.202Z" kvs:fn="91343852333215703712896525097723654664921362969"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:00.200Z" kvs:fn="91343852333215703717848285254865176006879908031"/>
          <S d="997" kvs:ts="2023-12-09T07:59:01.201Z" kvs:fn="91343852333215703722800045412006697426114573620"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:02.200Z" kvs:fn="91343852333215703727751805569148218755816554703"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:03.199Z" kvs:fn="91343852333215703732703565726289740119145777387"/>
          <S d="998" kvs:ts="2023-12-09T07:59:04.199Z" kvs:fn="91343852333215703737655325883431261489097116733"/>
          <S d="998" kvs:ts="2023-12-09T07:59:05.200Z" kvs:fn="91343852333215703742607086040572782864226764134"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:06.198Z" kvs:fn="91343852333215703747558846197714304218227347320"/>
          <S d="996" kvs:ts="2023-12-09T07:59:07.201Z" kvs:fn="91343852333215703752510606354855825600711209580"/>
          <S d="998" kvs:ts="2023-12-09T07:59:08.200Z" kvs:fn="91343852333215703757462366511997346960412678389"/>
          <S d="996" kvs:ts="2023-12-09T07:59:09.202Z" kvs:fn="91343852333215703762414126669138868328263710307"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:10.197Z" kvs:fn="91343852333215703767365886826280389704922436148"/>
          <S d="998" kvs:ts="2023-12-09T07:59:11.198Z" kvs:fn="91343852333215703772317646983421911059565925392"/>
          <S d="1005" kvs:ts="2023-12-09T07:59:12.196Z" kvs:fn="91343852333215703777269407140563432429892186724"/>
          <S d="997" kvs:ts="2023-12-09T07:59:13.198Z" kvs:fn="91343852333215703782221167297704953813368133151"/>
          <S d="995" kvs:ts="2023-12-09T07:59:14.200Z" kvs:fn="91343852333215703787172927454846475169089211094"/>
          <S d="1004" kvs:ts="2023-12-09T07:59:15.195Z" kvs:fn="91343852333215703792124687611987996530657499624"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:16.195Z" kvs:fn="91343852333215703797076447769129517922095635738"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:17.196Z" kvs:fn="91343852333215703802028207926271039278930289553"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:18.196Z" kvs:fn="91343852333215703806979968083412560639125380573"/>
          <S d="998" kvs:ts="2023-12-09T07:59:19.195Z" kvs:fn="91343852333215703811931728240554082029047690586"/>
          <S d="997" kvs:ts="2023-12-09T07:59:20.196Z" kvs:fn="91343852333215703816883488397695603387750476174"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:21.194Z" kvs:fn="91343852333215703821835248554837124737404800217"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:22.193Z" kvs:fn="91343852333215703826787008711978646109146001286"/>
          <S d="997" kvs:ts="2023-12-09T07:59:23.195Z" kvs:fn="91343852333215703831738768869120167474017114838"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:24.193Z" kvs:fn="91343852333215703836690529026261688834698383107"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:25.193Z" kvs:fn="91343852333215703841642289183403210216519737681"/>
          <S d="998" kvs:ts="2023-12-09T07:59:26.193Z" kvs:fn="91343852333215703846594049340544731565600600899"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:27.192Z" kvs:fn="91343852333215703851545809497686252940163647626"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:28.193Z" kvs:fn="91343852333215703856497569654827774310034023472"/>
          <S d="998" kvs:ts="2023-12-09T07:59:29.192Z" kvs:fn="91343852333215703861449329811969295683212863901"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:30.191Z" kvs:fn="91343852333215703866401089969110817048856151526"/>
          <S d="998" kvs:ts="2023-12-09T07:59:31.192Z" kvs:fn="91343852333215703871352850126252338450170327197"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:32.191Z" kvs:fn="91343852333215703876304610283393859788926569260"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:33.190Z" kvs:fn="91343852333215703881256370440535381156069529009"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:34.190Z" kvs:fn="91343852333215703886208130597676902529391842243"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:35.191Z" kvs:fn="91343852333215703891159890754818423902612351553"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:36.190Z" kvs:fn="91343852333215703896111650911959945261759082449"/>
          <S d="1003" kvs:ts="2023-12-09T07:59:37.189Z" kvs:fn="91343852333215703901063411069101466647617216149"/>
          <S d="998" kvs:ts="2023-12-09T07:59:38.190Z" kvs:fn="91343852333215703906015171226242988017449587601"/>
          <S d="1002" kvs:ts="2023-12-09T07:59:39.188Z" kvs:fn="91343852333215703910966931383384509351344036949"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:40.188Z" kvs:fn="91343852333215703915918691540526030723932802908"/>
          <S d="998" kvs:ts="2023-12-09T07:59:41.189Z" kvs:fn="91343852333215703920870451697667552086417156042"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:42.187Z" kvs:fn="91343852333215703925822211854809073454287366705"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:43.187Z" kvs:fn="91343852333215703930773972011950594827839141141"/>
          <S d="996" kvs:ts="2023-12-09T07:59:44.190Z" kvs:fn="91343852333215703935725732169092116194411814209"/>
          <S d="1002" kvs:ts="2023-12-09T07:59:45.186Z" kvs:fn="91343852333215703940677492326233637557677318663"/>
          <S d="998" kvs:ts="2023-12-09T07:59:46.187Z" kvs:fn="91343852333215703945629252483375158940826714326"/>
          <S d="998" kvs:ts="2023-12-09T07:59:47.187Z" kvs:fn="91343852333215703950581012640516680294209549768"/>
          <S d="1003" kvs:ts="2023-12-09T07:59:48.186Z" kvs:fn="91343852333215703955532772797658201665798537520"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:49.186Z" kvs:fn="91343852333215703960484532954799723041304309232"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:50.186Z" kvs:fn="91343852333215703965436293111941244400480811698"/>
          <S d="1002" kvs:ts="2023-12-09T07:59:51.185Z" kvs:fn="91343852333215703970388053269082765787997272409"/>
          <S d="997" kvs:ts="2023-12-09T07:59:52.186Z" kvs:fn="91343852333215703975339813426224287150493119161"/>
          <S d="997" kvs:ts="2023-12-09T07:59:53.186Z" kvs:fn="91343852333215703980291573583365808529427439091"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:54.184Z" kvs:fn="91343852333215703985243333740507329872396810594"/>
          <S d="998" kvs:ts="2023-12-09T07:59:55.184Z" kvs:fn="91343852333215703990195093897648851249556434180"/>
          <S d="1000" kvs:ts="2023-12-09T07:59:56.184Z" kvs:fn="91343852333215703995146854054790372604023498907"/>
          <S d="1001" kvs:ts="2023-12-09T07:59:57.183Z" kvs:fn="91343852333215704000098614211931893984032624967"/>
          <S d="998" kvs:ts="2023-12-09T07:59:58.184Z" kvs:fn="91343852333215704005050374369073415341558035381"/>
          <S d="997" kvs:ts="2023-12-09T07:59:59.184Z" kvs:fn="91343852333215704010002134526214936707567304371"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:00.182Z" kvs:fn="91343852333215704014953894683356458084266234021"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:01.182Z" kvs:fn="91343852333215704019905654840497979454853954346"/>
          <S d="997" kvs:ts="2023-12-09T08:00:02.183Z" kvs:fn="91343852333215704024857414997639500815572031422"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:03.182Z" kvs:fn="91343852333215704029809175154781022186570848159"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:04.181Z" kvs:fn="91343852333215704034760935311922543563899855522"/>
          <S d="998" kvs:ts="2023-12-09T08:00:05.182Z" kvs:fn="91343852333215704039712695469064064921908990389"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:06.180Z" kvs:fn="91343852333215704044664455626205586295211281904"/>
          <S d="998" kvs:ts="2023-12-09T08:00:07.180Z" kvs:fn="91343852333215704049616215783347107670076647589"/>
          <S d="997" kvs:ts="2023-12-09T08:00:08.182Z" kvs:fn="91343852333215704054567975940488629031317371241"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:09.179Z" kvs:fn="91343852333215704059519736097630150399754433787"/>
          <S d="1000" kvs:ts="2023-12-09T08:00:10.180Z" kvs:fn="91343852333215704064471496254771671781924511250"/>
          <S d="997" kvs:ts="2023-12-09T08:00:11.181Z" kvs:fn="91343852333215704069423256411913193131811709641"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:12.178Z" kvs:fn="91343852333215704074375016569054714505589391101"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:13.178Z" kvs:fn="91343852333215704079326776726196235878240959274"/>
          <S d="998" kvs:ts="2023-12-09T08:00:14.179Z" kvs:fn="91343852333215704084278536883337757236853824140"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:15.178Z" kvs:fn="91343852333215704089230297040479278608994999830"/>
          <S d="998" kvs:ts="2023-12-09T08:00:16.178Z" kvs:fn="91343852333215704094182057197620799981135040031"/>
          <S d="997" kvs:ts="2023-12-09T08:00:17.179Z" kvs:fn="91343852333215704099133817354762321330080620038"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:18.177Z" kvs:fn="91343852333215704104085577511903842701180337552"/>
          <S d="1000" kvs:ts="2023-12-09T08:00:19.177Z" kvs:fn="91343852333215704109037337669045364075483794638"/>
          <S d="998" kvs:ts="2023-12-09T08:00:20.177Z" kvs:fn="91343852333215704113989097826186885442173482996"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:21.176Z" kvs:fn="91343852333215704118940857983328406807819366983"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:22.175Z" kvs:fn="91343852333215704123892618140469928185224537147"/>
          <S d="997" kvs:ts="2023-12-09T08:00:23.177Z" kvs:fn="91343852333215704128844378297611449541890474942"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:24.175Z" kvs:fn="91343852333215704133796138454752970912981491389"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:25.175Z" kvs:fn="91343852333215704138747898611894492288343747538"/>
          <S d="998" kvs:ts="2023-12-09T08:00:26.176Z" kvs:fn="91343852333215704143699658769036013653896960550"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:27.174Z" kvs:fn="91343852333215704148651418926177535024101316267"/>
          <S d="998" kvs:ts="2023-12-09T08:00:28.175Z" kvs:fn="91343852333215704153603179083319056394402943758"/>
          <S d="998" kvs:ts="2023-12-09T08:00:29.175Z" kvs:fn="91343852333215704158554939240460577761622848457"/>
          <S d="1003" kvs:ts="2023-12-09T08:00:30.173Z" kvs:fn="91343852333215704163506699397602099127324849081"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:31.174Z" kvs:fn="91343852333215704168458459554743620513526051737"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:32.174Z" kvs:fn="91343852333215704173410219711885141863712428816"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:33.173Z" kvs:fn="91343852333215704178361979869026663236153824465"/>
          <S d="998" kvs:ts="2023-12-09T08:00:34.173Z" kvs:fn="91343852333215704183313740026168184616203150113"/>
          <S d="998" kvs:ts="2023-12-09T08:00:35.173Z" kvs:fn="91343852333215704188265500183309705966971460559"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:36.172Z" kvs:fn="91343852333215704193217260340451227332516294301"/>
          <S d="1001" kvs:ts="2023-12-09T08:00:37.171Z" kvs:fn="91343852333215704198169020497592748714842021214"/>
          <S d="997" kvs:ts="2023-12-09T08:00:38.173Z" kvs:fn="91343852333215704203120780654734270073652079153"/>
          <S d="1002" kvs:ts="2023-12-09T08:00:39.170Z" kvs:fn="91343852333215704208072540811875791437991841660"/>
          <S d="1000" kvs:ts="2023-12-09T08:00:40.170Z" kvs:fn="91343852333215704213024300969017312839347172304"/>
          <S d="995" kvs:ts="2023-12-09T08:00:41.174Z" kvs:fn="91343852333215704217976061126158834177854986059"/>
        </SegmentTimeline>
      </SegmentTemplate>
      <Representation id="audio" bandwidth="64000" audioSamplingRate="8000" codecs="alaw">
        <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"/>
      </Representation>
    </AdaptationSet>
  </Period>
</MPD>

この XML を parse するために、今回は以下のコードを使用しました。

import xml.etree.ElementTree as ET

def parse_mpd(mpd_url, mpd_content):
    root = ET.fromstring(mpd_content)
    base_url = mpd_url.rsplit('/', 1)[0] + '/'  
    segment_urls = []
    frame_rate = 0

    for adaptation_set in root.iter('{urn:mpeg:dash:schema:mpd:2011}AdaptationSet'):
        re_presentation = adaptation_set.find('{urn:mpeg:dash:schema:mpd:2011}Representation')
        if re_presentation is not None:
            media_type = re_presentation.attrib['id']
            if media_type != "video":
                continue
            
            frame_rate = float(re_presentation.attrib['frameRate'])

        segment_template = adaptation_set.find('{urn:mpeg:dash:schema:mpd:2011}SegmentTemplate')
        if segment_template is not None:
            media_url_template = segment_template.attrib['media']
            init_url = segment_template.attrib['initialization']
            segment_urls.append(base_url + init_url)
            seg_index = int(segment_template.attrib['startNumber'])

            for segment in segment_template.find('{urn:mpeg:dash:schema:mpd:2011}SegmentTimeline').iter('{urn:mpeg:dash:schema:mpd:2011}S'):
                segment_url = base_url + media_url_template.replace('$Number$', str(seg_index))
                segment_urls.append(segment_url)

                seg_index = seg_index + 1

    return segment_urls, frame_rate

今回はソラカメの API で取得した MPD ファイルを元にしています。その他の MPD ファイルに対して動作するかどうかはわかりません。動作としては単純に XML を parse して、必要な情報を取得しています。戻り値としては、セグメント単位の動画の URL 一覧と、配信される動画のフレームレートを返しています。これは、後で実際に動画を取得するのに必要な情報と、OpenCV を使って後で動画を作成するのにフレームレートが必要なので、この情報を戻しています。

MPD ファイルから取得した URL を使って動画をダウンロードしてみる

MPD ファイルを parse して取得した、セグメント単位の動画 URL から、実際に動画ファイルをダウンロードしてみます。parse の作業と同じく特別なことは何もなく、単純に Python でファイルをダウンロードすれば問題なさそうです。

def download_segment(url, filename):
    response = requests.get(url)
    if response.status_code == 200:
        with open(filename, 'wb') as file:
            file.write(response.content)
    else:
        print()
        print(f"Failed to download {url}. Status code: {response.status_code}. Response: {response.text}")

指定したフォルダに *.mp4 ファイルが作成されていますが、アイコンが動画ファイルのプレビューになってないことがわかるかと思います。この状態のファイルのままだと残念ながら再生できませんでした。

試しに OpenCV に放り込んでみましたが、以下のようなエラーが出てやはり読み込めませんでした。

[mov,mp4,m4a,3gp,3g2,mj2 @ 0x11f352af0] trun track id unknown, no tfhd was found
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x11f352af0] error reading header
OpenCV: Couldn't read video stream from file "_mp4_files/segment_31.mp4"
Error: Could not open video file: _mp4_files/segment_31.mp4

少し調べてみるとどうやら、動画のヘッダーと動画の本体が別々で配信されていて、個別配信された動画とヘッダー部分のファイルを結合してあげれば、問題なく再生できそうな雰囲気です。

stackoverflow.com

取得したファイルを結合して再生できるようにしてみる

基本的なところで、MPEG-DASH の配信で initialization で指定されている URL で配信されているファイルに、動画のヘッダー情報があり、各セグメントで配信されてるファイルは、その動画ファイルの実態ということがわかったので、手元にダウンロードしたファイルを結合して再生できるようにしようと思います。

試しに手元のコンソールで cat コマンドを使って結合してみたところ、問題なく再生できたので方向性は間違ってなさそうでした。

ただし

  • init + seq_1 = a
  • init + seq_2 = b
  • init + seq_1 + seq_2 = c
  • a + b = d

みたいなパターンで cat でファイル結合してみましたが、いずれの場合も結合結果のファイルは再生できました。一方で init + seq_$ の場合のみが正常で、連続で結合したり、結合したものを結合した場合は最初のセグメントだけしか再生されず、ファイルサイズだけ結合で大きくなるという状態でした。

これを踏まえて、Python で実装するコードは以下のようにしました。

def cat_mp4_files(in_path, out_path):
    files = [f for f in os.listdir(in_path) if f.endswith('.mp4')]
    files.sort()

    file_zero = ""
    for i, file in enumerate(files):
        in_file = os.path.join(in_path, file)
        out_file = os.path.join(out_path, file)

        if i == 0:
            file_zero = in_file
            continue

        combine_files = [file_zero, in_file]
        with open(out_file, 'wb') as outfile:
            for file in combine_files:
                with open(file, 'rb') as infile:
                    outfile.write(infile.read())

リストの先頭にヘッダーファイルがある状態で実行すると、指定されたフォルダに結合済みの *.mp4 ファイルが出力されます。結合したファイルが出力されているフォルダをみると、前回と違い今回は動画のサムネイルがアイコンとして設定されているのがわかると思います。実際にローカルに入ってる再生ソフトで再生してみると、1ファイル1秒の動画が問題なく再生できることがわかると思います。

OpenCV で動画ファイルを読み込んでみる

1ファイル1秒の動画が問題なく再生できることは確認できました。次が実際に OpenCV でこの動画ファイルを読み込んでみます。これが実現できると、AI / ML の連携を行う際に必要な部分は検証できたと言えそうです。

import cv2

def load_opencv(path):
    files = [f for f in os.listdir(path) if f.endswith('.mp4')]
    files.sort()

    frames = []
    for file in files:
        file_path = os.path.join(path, file)
        cap = cv2.VideoCapture(file_path)

        if not cap.isOpened():
            print("Error: Could not open video file:", file_path)
            continue

        while True:
            ret, frame = cap.read()
            if not ret:
                break

            frames.append(frame)
            cv2.imshow('frame', frame)

            if cv2.waitKey(1) & 0xFF == ord('q'):
                break

        cap.release()
    cv2.destroyAllWindows()

    return frames

opencv.org

フォルダにある *.mp4 ファイルを順番に VideoCapture メソッドに渡して読み出しています。読み出した動画ファイルからフレームを取得して、そのフレームを imshow で画面を出して表示しています。この方法だとすべての動画ファイルの内容は表示できますが、処理のスピードによっては動画が早送りされているように表示されるかと思います。

frame rate を指定して複数のセグメント動画をひとつにしてみる

OpenCV を利用して各セグメントの動画は読み出せたので、AI/MLで利用する分には問題なく利用できるかと思います。しかし、ここまで来たら細切れではなく、全部のセグメントの動画を繋げて見たくなる気持ちがでてきませんか?というわけで、セグメント単位の複数の動画ファイルを、単一の動画ファイルにしてみようと思います。

def create_result_mp4(frames, frame_rate, path):
    fourcc = cv2.VideoWriter_fourcc(*'mp4v') 

    if frames:
        height, width, _ = frames[0].shape
        out = cv2.VideoWriter(path, fourcc, frame_rate, (width, height))

        for frame in frames:
            out.write(frame)

        out.release()
    else:
        print("The list of frames is empty.")

    print(f"A video {path} has been created.")

frames には、各セグメント動画ファイルを読み出した時に取得した frame 情報がリストで入っているので、これを指定されたフレームレートで書き出して行きます。フレームレートは MPD ファイルに入っている値をそのまま利用しています。

実際に出来上がった動画ファイルを確認しましたが、普通に再生できましたし、取得する際に指定した時間範囲も間違いなかったです。

まとめ

  • MPEG-DASH なにそれ? という状態ではなくなったと思う。
  • MPEG-DASH を利用するなら、とりあえず FFmpeg 使えばいいのでは? という状態ではなくなったと思う。
  • 実際に Python (+OpenCV) で再生できる動画が取得できた。
  • OpneCV の VideoCapture で動画が読み出せてる。
  • これで、ローカルで自前のAIモデルで動作させることも難しくないはず。
  • 今回は試しただけなので、同期処理で処理している。
  • 実際に利用する場合には、MPEG-DASH の取得と、OpneCV での読み込みを非同期で処理すれば問題なさそう。
  • ローカルでないと試せないとか、試すのが難しいという方も一定そういそうなので、ぜひローカル派の方は試してみてください。
  • オンラインや Web ブラウザーで試す方が気楽と言う方は、API サンプルとして少しですが公開されていますので、以下を参考にしてみてください。

users.soracom.io

ソラカメ API で取得できるストリーミング用の URL を、そのまま Python で操作して再生するところまでやってみました。結果としては特に難しい処理もなく、MPEG-DASH の仕様や MPD ファイルの構成をわかっていれば問題なく利用できるような感じを受けました。

これでローカルでいろいろ試せるという感じになると思うので、ぜひソラカメ API を使って検証してみてください。

以上です。

SORACOM LTE-M Button for Enterprise と Make を使ってノーコードでメール送信してみた #SORACOM #soracomug #nocode #withMake

目次

SORACOM Advent Calendar 2022

この記事は SORACOM Advent Calendar 2022 カレンダー223日目の記事です。

qiita.com

今年はカレンダー19日目の記事を投稿しましたが

uchimanajet7.hatenablog.com

もうひとつ試してみたいことがあったことを思い出したので Just Do It でやってみました。
思ったより簡単にできたので、ブログを書く時間ができました。
試してみるのは大事ですね。

あと毎回のお約束ですが、すべての内容は個人で検証したものです。
文中の現時点は2022年12月22日を指しています

SORACOM LTE-M Button for Enterprise

SORACOM LTE-M Button for Enterprise(以下、ボタン)は、ボタンを押したことをクラウドへ通知できます。
この通知をトリガーにしてクラウド側を実装すると、メールを送信したり決まった動作を行ったりできます。
詳細は各リンク先を確認してください。

soracom.jp

users.soracom.io

ボタンには他にもシリーズがあり、利用シーンで選択できます。

users.soracom.io

今回 SORACOM LTE-M Button for Enterprise を選択した理由は
2023年1月10日までキャンペーンが行われており特別価格で割引販売されているからですw

blog.soracom.com

やはり実際に買って試せる方が楽しいですからねー
また、IoTデバイスというとセンシングしてデータを継続的にクラウドに送信するといったイメージがあると思いますが、ボタンは自分でアクションできるデバイスなので個人的に好きなデバイスであるのも理由のひとつです。

Make

Make は Integromat と呼ばれていた iPaaS(Integration Platform as a Service)です。
同様のサービスだと IFTTT や Zapier の方がメジャーかもしれないです。

https://www.make.com/enwww.make.com

ノーコードで複数のサービスをつないで、やりたいことを簡単に実現できます。
Make という名前がWeb検索しづらいのがちょっと困りますがw
公式のドキュメントが充実していますし、UIも直感的なので利用するのには困らないと思います。

ボタンをクリック際にメールを送信する

ボタンをクリックした際にメールを送信したい場合、SORACOMユーザーコンソールに用意されている機能を利用して送信できます。
詳細はリンク先を確認してください。

users.soracom.io

用意された機能なので、ドキュメントの内容に沿って必要な項目も埋めていくだけで簡単にメール送信が実行できます。

ボ タンのクリック種類は

1. SINGLE: シングルクリック
2. DOUBLE: ダブルクリック
3. LONG: 長押し

の3種類になります。
この種類に応じてメールの内容や宛先を切り替えて、メール送信できると便利に使えるのでは?と思ったことがある人がいるはずw

なので、今回は Make を利用して、ボタンのクリック種類に応じた内容や送信先にメールを送信してみようと思います。

Make を準備する

トップページにアクセスして、Freeプランでアカウント登録を行います。

https://www.make.com/en/pricingwww.make.com

Freeプランでも 1,000 / 月 の実行ができますし、今回はボタンがクリックされたら即時実行する使い方なので、定期実行間隔の制限やアクティブにできるシナリオの数など、Freeプランの制限は問題になりませんでした。

プロダクションで利用する場合や、制限が knockout 要件となる場合には、有償プランを検討するか別の方法で実現するなどを検討する必要があります。

Webhooks module を設定する

ボタンから通知を受け取る必要があるので、通知を受け取るためのWebhook URLを払い出します。

https://www.make.com/en/help/tools/webhookswww.make.com

ドキュメントの記載の通り、scenario を追加して Webhooks module を配置し、画面の案内に沿って必要な設定を行っていきます。

以下のようにWebhook の rate limit が設定されていますが、今回は気にしないで大丈夫そうです。

Webhook rate limit

Make can process up to 30 incoming webhook requests per second.
If you send more than 30 requests per second, the system returns an error with status code 429.

ref: https://www.make.com/en/help/tools/webhooks#webhook-rate-limit

URLが払い出されると、受付テストのためにリクエストを受け付け待ちの状態になります。
この状態になったら、Make 側の設定を離れてSORACOMユーザーコンソール側の設定に移ります。

SORACOM ユーザーコンソール で準備する

SORACOM ユーザーコンソールは、ボタンを利用中であればすでにログインできるはずです。
詳細はリンク先を確認してください。

users.soracom.io

Make へボタンのクリック情報を転送するために、SORACOM Beam を利用します。

SORACOM Beam

SORACOM Beam(以下、Beam)は、デバイスからSORACOMに送信されたデータを、任意のサーバーに転送するサービスです。
詳細はリンク先を確認してください。

soracom.jp

今回はボタンから送信されたデータをBeam で転送するために設定が必要です。
以下ドキュメントに設定例がありますので、手順を参考にして設定していきます。

users.soracom.io

ボタンが所属しているSIM グループの設定を編集していきます。

設定はドキュメントに記載がある通り
UDP → HTTP/HTTPS エントリポイント を選択することと
Make 側で発行したWebhook のURLをホスト名とパスに分解して表示されているダイアログに入力するだけで完了です。

ヘッダ操作の設定については、今回は検証のために「署名ヘッダ付与」以外は有効化しました。
合わせて簡易位置測位機能 も有効化して、ボタンから送信できる情報はなるべく送信するように設定しています。

設定が正しく完了している場合、この段階でボタンをクリックすれば Make 側で待ち受けていた Webhooks module がボタンからの通知を受け取ってテスト完了になるかと思います。

ボタンから正しくデータが送信されているかを確認するには、SORACOMユーザーコンソールでSORACOM Harvest Data を利用します。

SORACOM Harvest

SORACOM Harvest(以下、Harvest)は、デバイスから送信されたデータやファイルをSORACOMで収集、蓄積するサービスです。
詳細はリンク先を確認してください。

soracom.jp

Harvest Data を使ってボタンのデータを確認する手順は、以下ドキュメントを参照してください。

users.soracom.io

Harvest Data でデータが確認できれば、ボタンからSORACOMまでは問題なくデータが届いているということになります。

Make でメール送信する

SORACOM側の設定が正しく完了した後は、Make側でメールを送信できるように設定していきます。

Email module を設定する

Make からメールを送信するため Send an Email アクションの設定を行っていきます。

https://www.make.com/en/help/tools/emailwww.make.com

メール自体はMake から直接送信するわけではなく、GmailMicrosoft Outlook などのメールサービスと接続を行って送信します。

https://www.make.com/en/help/tools/email#connecting-email-to-make-935249www.make.com

今回の実行例では Microsoft SMTP/IMAP OAuth を利用しています。
こちらはドキュンと記載の通り、プルダウンメニューから該当の項目を選択すると、Microsoftへのログイン画面がWebブラウザーで表示されます。

このログイン画面に使用したいMicrosoft アカウントでログインすれば、Makeとの接続が完了します。
メール送信に必要な宛先やタイトル、本文を記載して設定を行います。

module 同士を接続する

通常はmodule を作成した際に自動で接続されているはずです。
もし接続されていないようであれば、Webhooks module と Email module を接続してください。
具体的には、module の端子を接続したいmoduleにドラッグすれば接続できます。

これでWebhook があると固定の宛先に、固定の文面でメールが送信されるようになりました。
実際にボタンをクリックしてMake 経由でメールが送信されることを確認してください。

条件によって異なるメールを送信する

ここまでで、Makeを経由したメール送信は、SORACOMユーサーコンソールで利用できるメール送信と同等のことが実現できています。
細かい値とかは置いておいてですがw

ここからは、ボタンのクリックに応じてメールの宛先、タイトル、本文が異なるメールを送信できるようにしていきます。

まず、Webhooks module と Email module が接続されているリンクに Router module を追加します。

Router module を設定する

条件によって分岐を作れる module になるので、このmodule でボタンのクリック種別を条件として module の分岐を作ります。

https://www.make.com/en/help/modules/routerwww.make.com

設定は簡単で、Router module から出ているリンクにあるスパナ アイコン をクリックして条件を入力します。
今回は Webhook で取得しているボタンのデータの中に clickType という値があり、これがクリックの種類によって変化するため、3つのリンクでそれぞれ1つの値とマッチするように条件を設定します。
加えてEmail module が1つしかないので、既存のmodule をコピーするか新規にmodule を追加して、3つのリンクにEmail module が接続されている状態にします。

users.soracom.io

これでボタンのクリック種別で、3つの接続先を呼び出し分られるようになりました。
あとは、それぞれのリンク先のEmail module で、個別の宛先、タイトル、本文を設定すれば完了となります。

ボタンから送られてきているデータを確認する意味もあるので、今回は以下のようなメール設定を行いました。

宛先 example+{{1.clickType}}@example.com
タイトル ボタンが [{{1.clickTypeName}}] クリックされました
本文 ボタンが [{{1.clickTypeName}}] = [{{1.clickType}}] クリックされました。

{{formatDate(now; "YYYY/MM/DD HH:mm:ss"; "Japan")}}

----
・バッテリーレベル:{{(1.batteryLevel * 100)}}%
・簡易位置測位情報:{{ifempty(replace(get(map(1.`__IMTHEADERS__`; "value"; "name"; "x-soracom-geo-position"); 1); "/;/"; ","); emptystring)}}
・SIM ID:{{ifempty(get(map(1.`__IMTHEADERS__`; "value"; "name"; "x-soracom-sim-id"); 1); emptystring)}}
・IMSI:{{ifempty(get(map(1.`__IMTHEADERS__`; "value"; "name"; "x-soracom-imsi"); 1); emptystring)}}
・MSISDN:{{ifempty(get(map(1.`__IMTHEADERS__`; "value"; "name"; "x-soracom-msisdn"); 1); emptystring)}}
・IMEI:{{ifempty(get(map(1.`__IMTHEADERS__`; "value"; "name"; "x-soracom-imei"); 1); emptystring)}}

宛先はクリック種別で別になるように設定しました。
タイトルも同様にクリック種別がわかるように設定しました。
本文は、クリック種別と送信日時、そして送信されてきたデータとBeam にてヘッダーに付加したデータを取り出して表示するように設定しました。

簡易位置測位情報は、Beam で送信する場合は以下のフォーマットとなり緯度・軽度の区切り文字に ; が使われています。

users.soracom.io

Google Map で緯度・軽度で検索する場合の区切り文字は , なので、今回はMake側で置換処理しています。
置換以外にもMakeの組み込み関数を使ってますが、以下のドキュメントを検索すれば詳細に書いてあるので迷わず使えました。

https://www.make.com/en/help/homewww.make.com

これで、ボタンのクリックに対応してメールが出し分られるようになりました。
SORACOMユーザーコンソールのメール送信機能も同時に有効にしている場合は内容を比較してみてください。
SORACOMユーザーコンソールから送信されるメールで使える情報と、Beamでデータを転送してMake から送信される情報に差異があります。
メールに必須な情報があるかどうかを確認して、どちらの方法で送信を検討してください。

users.soracom.io

ボタンのクリックで異なる動作を行う場合

実際にここまで手を動かして設定してみた方は実感してるかもしれませんが、ボタンのクリックで異なる動作を検討する場合、検討しなければならないポイントがあります。

ボタンにはLED表示しかなく、LEDは電池残量やデータ送信結果の表示に使われています。

users.soracom.io

https://soracom.jp/files/products/SORACOM_LTE-M_Button_manual.pdfsoracom.jp

このような仕様だと、ボタン自体でどのタイプのクリックを実行したのかを判別できません。 またボタンをクリックして、データ送信のシーケンスが始まるとボタン側は操作を受け付けないようです。

youtu.be

youtu.be

ボタンをクリックした側が自分でどんな動作をしているのかがわからないため、クリックミスやカバンの中で意図せずクリックされてしまった場合など、メッセージを処理する側か、運用で対応する必要があります。

ボタンがクリックされた」という単純なトリガーとは違い、この辺りを検討して実装する必要があります。

3種類のメール送信が必要なのであれば、ボタンを物理的に3つ用意して対応してしまうのが単純で良いのでは?と個人的には思います。

まとめ

  • メールを送信したい場合は、SORACOMで用意されている機能で実現できる。
  • Make を利用してもメールは送信できた。
  • しかし、SORACOMで用意されたメール送信とMake でのメール送信では、利用できる情報が異なる。
  • SORACOMユーザーコンソールの機能でメール送信する場合は、Tagの情報が付加できる。
  • Beam で転送する場合には Tag の情報は付加できない。
    • Tagの情報が必須となる場合は転送での送信は難しい。
    • 今後のアップデートに期待したい。
  • ボタンのクリック種類によって、メールの内容に変化がつけたい場合は工夫が必要となる。
  • そもそも、そのクリックを押し分けられるかがポイントとなる。
  • 技術的に「できる」と、本番で利用するような運用が「できる」で実装方法が異なるはず。
  • Make については、ノーコードでちょっとしたことを実現したい場合には便利そう。
  • 一方で、いろいろ値を操作したいとなってくると、素直にAWS Lambda みたいな自由度が高く作り込みできる環境の方が楽な感覚があった。
  • Make も設定した状態をJSON ファイルとしてexportできるので、このJSONファイルを管理すればバージョン管理などはできそう。
  • SORACOMとクラウドの連携は最近アップデートされたBeam のSigV4 対応でいろいろできそう

    blog.soracom.com

  • 持ち歩くような用途は問題ないかもしれないが設置の場合は動作温度帯は確認が必要となる。
    • マニュアルには 5 〜 40 °Cの記載がある。
  • ボタンクリック開始から送信が完了するまで、電波状況に依存すると思うが、待つ時間があった。
  • いろいろ試しながら変更したりしていると、この待ち時間が塵も積もれば山となる感があった。
  • ボタンは使い方次第でいろいろできるので、使ってみながら試していくのが楽しい。

以上です。

ATOM Cam 2 のリアルタイム映像をPC で確認する我流 #SORACOM #soracomug #ATOMcam

目次

SORACOM Advent Calendar 2022

この記事は SORACOM Advent Calendar 2022 の9日目の記事です。

qiita.com

SORACOM Advent Calendar に参加するのは 2016 / 2017 以来となり、個人的にはなかなか感慨深いものがありますねー

uchimanajet7.hatenablog.com

uchimanajet7.hatenablog.com

2017年分は公開日が1日遅れて気がするので、過去の自分もっと頑張っておけよ。。。

今回は ATOM Cam 2 の リアルタイム映像 を PC で確認する方法を書いてみました。
[本流] とできるけどネタレベルの [我流] が書いてあるので、我流の方は生暖かく見ていただけると

あと毎回のお約束ですが、すべての内容は個人で検証したもの になります。文中の現時点は2022年12月8日を指しています。
利用しているATOMアプリと ATOM Cam 2 のファームウェアのバージョンは以下の画像を確認してください。

バージョンや利用するツールによっては期待している動作をしないことがあります。

本流と我流はこの番組が好きなのでw

www.fujitv.co.jp

Soracom Cloud Camera Services とATOM Cam 2

Soracom Cloud Camera Services(以下、ソラカメ)とATOM Cam 2(以下、AC2)の関係を簡単に紹介します。詳細は各リンク先を確認してください。

ソラカメはソラコムが提供しているクラウド型のカメラサービスです。

soracom.jp

AC2はアトムテックが販売してるネットワーク型のカメラです。 現時点でAC2を利用するにはスマートフォンアプリの ATOMアプリ を利用する必要があります。

www.atomtech.co.jp

soracom.jp

ソラカメ対応製品としてAC2が利用できるので、ソラカメのライセンスを購入することでAC2を使ったクラウドへの常時録画サービスを利用できることになります。

[本流] ソラカメを使う

ATOMアプリで登録されているカメラのリアルタイム映像を確認できます。
ソラカメを利用することにより「クラウドへの常時録画」が行われ、クラウド側に録画データが蓄積されます。

ソラカメでは、この録画されたデータをSORACOMユーザーコンソール上で視聴できるので、PCで映像を確認できるようになります。また、APIでの操作ができるため他のサービスへのインプットが行えます。

users.soracom.io

users.soracom.io


  • 良い点
    • 何より手軽に使える。
    • ライセンスを購入して割り当てすれば、クラウドへの常時録画ができる。
    • SORACOMユーザーコンソールから視聴できるので、Webブラウザだけあれば良い。
    • サービス利用なので自前で作り込んだりする必要がない上、サポートに問い合わせできる。
  • 注意点
    • サービス利用となるため月額の費用が発生する。
    • リアルタイムの定義によっては、ミスマッチとなる場合がある。
      • クラウド側に録画した映像を再生するので、厳密にはカメラのライブ映像とは異なる。
    • API経由の視聴に制限があるので、ユースケースによってはミスマッチとなる場合がある。

      soracom.jp

    • クラウドへの常時録画が前提となるため、電源やネットワーク利用量が大きくなる。

[我流-1] PCでスマホアプリを利用する我流

以前Blogに記載した内容ですが、PCにエミュレーターをインストールしてスマホアプリを利用する方法です。この方法ならPCでスマホアプリが利用できるため、PC上でリアルタイム映像を確認できます。

uchimanajet7.hatenablog.com


[我流-2] RTSPを中継機で転送する我流

こちらも以前Blogに記載した内容ですが、AC2はRTSPサーバーとして動作するので同一ネットワーク内であれば、RTSP経由でリアルタイム映像を視聴できます。
これを利用して同一ネットワーク内にRTSPを転送する中継機を置くことで、PCで閲覧できる場所に映像を転送できます。

uchimanajet7.hatenablog.com


  • 良い点
    • Amazon Kinesis Video Streams に映像データをインプットできる。

      aws.amazon.com

    • AWSによるドキュメントや動画があり、記載の通りに設定すれば比較的簡単にセットアップできる。

      www.youtube.com

    • 必要なソフトウェアがセットアップ済みのDocker イメージが存在する。

      docs.aws.amazon.com

    • 中継機が用意できれば、ユースケースによってはマッチする場合がある。
  • 注意点
    • AWSをはじめとした利用しているツール類について、ある程度の知識が必要。
    • ドキュメントや動画があっても、設定する項目や内容は理解する必要がある。
    • RTSPを使うためにAC2と同一ネットワークで中継機を動作させる必要がある。

      tex2e.github.io ja.wikipedia.org

    • 中継機がSPOFとなる可能性がある。

      www.nic.ad.jp

    • 中継機を含めた継続した運用を考慮する必要がある。
    • 電源やネットワーク利用については、常時クラウドへ映像が転送されているので大きくなる。
    • 別途クラウドで利用しているサービス利用料が必要となる。

[我流-3] SORACOM Arc と SORACOM Gate D2D でRTSPへリモートアクセスする我流

ここからは新作です。やっとSORACOMのサービスを利用しますw

SORACOM Gate D2D (Device to Device) とは

SORACOM Air のSIMを利用しているデバイス同士が、インターネットを経由せずに SORACOM網内で同一サブネットとしてプライベートIPで通信できるサービスです。詳細はリンク先を確認してください。

soracom.jp

SORACOM Arc とは

WireGuardを利用することで、既存の通信環境を使ってSORACOMへセキュアに接続できるサービスです。詳細はリンク先を確認してください。

soracom.jp

同一のネットワークになることで

AC2のRTSPは同一ネットワークであれば利用できるので、SORACOM Gate D2D を使うことで別のネットワークにあるデバイスでも、同一のネットワークと扱えるようになります。

ゲートウェイとなるルーターLTE / WireGuard が利用できる機種を選択することで、現在のWi-Fiでの接続が必須なAC2でも、SORACOM Gate D2D が利用できます。
今回はAC2の通信量が多いことと、Advent Calendar用の検証が目的のため WireGuard が利用可能なルーターを利用して、SORACOM Arc 経由で接続を行いました。

今回は手元にあるものを利用しましたが、 LTE / WireGuard を利用できるルーターを新規で探す場合は、以下を見てみてください。

soracom.jp

これで AC2が SORACOM Gate D2D のネットワークに参加できるようになります。
同じく、RTSPを視聴するPC側にUSBドングルや、SORACOM Arc を利用することで、同一ネットワーク内となります。
結果としてリモートからRTSPへアクセスできるようになります。

SORACOM Arc を準備する

SORACOMはドキュメントがとても丁寧に書かれています。
SORACOM Arcの利用に関しても、公式ドキュメントを参照して進めていきます。

users.soracom.io


  • 今回は単体でバーチャル SIMを作成します。

    users.soracom.io

  • ゲートウェイ用と視聴するPC用の合計2枚のバーチャル SIMを作成します。
    • バーチャル SIM を作成する際に、画面に表示される PrivateKey は後から再確認できないため、必ず記録してください。
  • SIM グループがない場合は作成します。

    users.soracom.io

  • 作った SIM グループ に 2枚のバーチャル SIMを所属させます。

    users.soracom.io

SORACOM Gate D2D を準備する

同様にSORACOM Gate D2Dの利用に関しても、公式ドキュメントを参照して進めていきます。

users.soracom.io


  • VPG を作成する際には インターネットゲートウェイを使う をONにする。
    • AC2を通常利用するためにインターネットアクセスが必要となるため。

      users.soracom.io

  • VPG を切り替えるのはグループ単位となります。

    users.soracom.io

  • SORACOM Arc で SORACOM Gate D2Dを利用するために、WireGuard 設定ファイルを編集する。
    • バイスサブネットのIPレンジを許可するために AllowedIPs に記載する。
  • VPG を切り替えた場合にはSIMのセッションリセットを行います。

    users.soracom.io

ゲートウェイ機器を設定する

詳細な設定は利用するゲートウェイ機器で異なるため、利用する機器のマニュアルを確認して設定してください。

users.soracom.io


  • VPNの設定を探し WireGuard クライアント の設定します。
  • バーチャル SIMを発行さいた際に、画面に表示されていた情報を WireGuard クライアント の設定として入力します。
  • SORACOM Gate D2D の準備で設定した、デバイスサブネットのIPレンジ は必ず設定に追加します。
  • すべての設定が完了したら、WireGuard クライアント を起動してSORACOMと通信できるかを確認します。

    users.soracom.io

  • SORACOMと通信できない場合には、ハートビート間隔 / PersistentKeepalive と記載されている設定を確認します。

    www.wireguard.com

  • リモートアクセスの宛先は、ゲートウェイ機器で利用している バーチャル SIM に割り振られている ローカルIP になります。
    • SORACOMユーザーコンソールで、該当のバーチャル SIMの ローカルIP を確認します。

      users.soracom.io

  • ゲートウェイ機器のファイヤーウォール設定で、WireGuard側からのポートフォワードを設定しておきます。
    • 上記の場合は、8554 のWireGuard側から 554 へのポートフォワードが設定してあります。
    • AC2のRTSPは 554 ポートで待ち受けしていることが確認できます。
      $ nmap -A 192.168.8.119
    
      Starting Nmap 7.93 ( https://nmap.org ) at 2022-12-06 15:56 JST
      Nmap scan report for ATOM.lan (192.168.8.119)
      Host is up (0.0059s latency).
      Not shown: 998 closed tcp ports (conn-refused)
      PORT     STATE SERVICE VERSION
      554/tcp  open  rtsp    DoorBird video doorbell rtspd
      |_rtsp-methods: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER, SET_PARAMETER
      9999/tcp open  abyss?
      Service Info: Device: webcam
    
      Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
      Nmap done: 1 IP address (1 host up) scanned in 9.67 seconds
    

視聴するPCを設定する

利用しているPCのプラットフォームにあわせたWireGuard をインストールして設定します。

www.wireguard.com


  • インストールが完了したら、WireGuardの接続情報を設定します。

    users.soracom.io

  • ゲートウェイ機器で設定した手順と変わらないはずです。
  • すべての設定が完了したら、WireGuard クライアント を起動してSORACOMと通信できるかを確認します。

    users.soracom.io

  • SORACOMと通信できない場合には、ハートビート間隔 / PersistentKeepalive と記載されている設定を確認します。

    www.wireguard.com

RTSPへリモートアクセスして確認してみる

ATOMアプリの設定画面を開いて「PCで再生する画面」を表示します。この画面には RTSPのアクセス先 が表示されています。

カメラがオンラインの時にだけ設定画面が表示できます。最初に機能を有効化する際には確認のダイアログが表示されます。

設定画面には
rtsp://5374:1261@192.168.0.11/live
のような rtsp から始まるURLが表示されます。

URLの内容を少し分解してみると
rtsp://<ユーザ名>:<パスワード>@<IPアドレス>:<ポート番号>/live
というフォーマットになっています。

視聴用のPCが、AC2と同一のネットワークにいる場合には、このURLを指定するだけで再生できます。

$ ffplay -i rtsp://5374:1261@192.168.8.119/live

ffplay version 5.1.2 Copyright (c) 2003-2022 the FFmpeg developers
  built with Apple clang version 14.0.0 (clang-1400.0.29.202)
  configuration: --prefix=/opt/homebrew/Cellar/ffmpeg/5.1.2_1 --enable-shared --enable-pthreads --enable-version3 --cc=clang --host-cflags= --host-ldflags= --enable-ffplay --enable-gnutls --enable-gpl --enable-libaom --enable-libbluray --enable-libdav1d --enable-libmp3lame --enable-libopus --enable-librav1e --enable-librist --enable-librubberband --enable-libsnappy --enable-libsrt --enable-libtesseract --enable-libtheora --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libxvid --enable-lzma --enable-libfontconfig --enable-libfreetype --enable-frei0r --enable-libass --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-libspeex --enable-libsoxr --enable-libzmq --enable-libzimg --disable-libjack --disable-indev=jack --enable-videotoolbox --enable-neon
  libavutil      57. 28.100 / 57. 28.100
  libavcodec     59. 37.100 / 59. 37.100
  libavformat    59. 27.100 / 59. 27.100
  libavdevice    59.  7.100 / 59.  7.100
  libavfilter     8. 44.100 /  8. 44.100
  libswscale      6.  7.100 /  6.  7.100
  libswresample   4.  7.100 /  4.  7.100
  libpostproc    56.  6.100 / 56.  6.100
Input #0, rtsp, from 'rtsp://5374:1261@192.168.8.119/live': f=0/0
  Metadata:
    title           : Session streamed by "Atom"
    comment         : live
  Duration: N/A, start: 0.000625, bitrate: N/A
  Stream #0:0: Video: h264 (Main), yuv420p(tv, bt709, progressive), 1920x1080, 20 fps, 20 tbr, 90k tbn
  Stream #0:1: Audio: pcm_alaw, 8000 Hz, 1 channels, s16, 64 kb/s
[rtsp @ 0x148906660] RTP: dropping old packet received too late/1
    Last message repeated 2 times
[rtsp @ 0x148906660] RTP: dropping old packet received too late/2
[h264 @ 0x148977930] left block unavailable for requested intra mode
[h264 @ 0x148977930] error while decoding MB 0 15, bytestream 40430
[h264 @ 0x148977930] concealing 6409 DC, 6409 AC, 6409 MV errors in I frame
  39.49 A-V:  0.025 fd=  17 aq=    0KB vq=   47KB sq=    0B f=2/2

リモートでアクセスするためには


  • SORACOMユーザーコンソールで、ゲートウェイ機器が利用しているバーチャル SIMのローカルIPを確認します。
    • ここで確認したローカルIPがリモートアクセス先です。

      users.soracom.io

  • ゲートウェイ機器のファイヤーウォール設定で、WireGuard側からのポートフォワードを確認します。
    • ここで確認したポートがリモートアクセス先のポートです。

あとは、ATOMアプリで確認した RTSP のURLを確認した情報を使って整理します。
rtsp://5374:1261@10.182.176.48:8554/live
という形がリモートアクセス用のURLとなります。

これでRTSPへリモートアクセスするための準備は整ったので、実際にアクセスしてみます。

$ ffplay -i rtsp://5374:1261@10.182.176.48:8554/live

ffplay version 5.1.2 Copyright (c) 2003-2022 the FFmpeg developers
  built with Apple clang version 14.0.0 (clang-1400.0.29.202)
  configuration: --prefix=/opt/homebrew/Cellar/ffmpeg/5.1.2_1 --enable-shared --enable-pthreads --enable-version3 --cc=clang --host-cflags= --host-ldflags= --enable-ffplay --enable-gnutls --enable-gpl --enable-libaom --enable-libbluray --enable-libdav1d --enable-libmp3lame --enable-libopus --enable-librav1e --enable-librist --enable-librubberband --enable-libsnappy --enable-libsrt --enable-libtesseract --enable-libtheora --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libxvid --enable-lzma --enable-libfontconfig --enable-libfreetype --enable-frei0r --enable-libass --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-libspeex --enable-libsoxr --enable-libzmq --enable-libzimg --disable-libjack --disable-indev=jack --enable-videotoolbox --enable-neon
  libavutil      57. 28.100 / 57. 28.100
  libavcodec     59. 37.100 / 59. 37.100
  libavformat    59. 27.100 / 59. 27.100
  libavdevice    59.  7.100 / 59.  7.100
  libavfilter     8. 44.100 /  8. 44.100
  libswscale      6.  7.100 /  6.  7.100
  libswresample   4.  7.100 /  4.  7.100
  libpostproc    56.  6.100 / 56.  6.100
[rtsp @ 0x10b204370] Could not find codec parameters for stream 0 (Video: h264, none): unspecified size
Consider increasing the value for the 'analyzeduration' (0) and 'probesize' (5000000) options
Input #0, rtsp, from 'rtsp://5374:1261@10.182.176.48:8554/live':
  Metadata:
    title           : Session streamed by "Atom"
    comment         : live
  Duration: N/A, start: -106052.347750, bitrate: 64 kb/s
  Stream #0:0: Video: h264, none, 90k tbr, 90k tbn
  Stream #0:1: Audio: pcm_alaw, 8000 Hz, 1 channels, s16, 64 kb/s
[rtsp @ 0x10b204370] max delay reached. need to consume packet0/0
[rtsp @ 0x10b204370] RTP: missed 25 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 1 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 2 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 1 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 2 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 2 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 2 packets
[rtsp @ 0x10b204370] max delay reached. need to consume packet
[rtsp @ 0x10b204370] RTP: missed 2 packets

... The following is the same and has been omitted.

コンソールの出力結果を見てみると、なんとなくネットワーク的にはAC2までリーチしてそうな雰囲気が!!
しかし実際にはカメラ映像の視聴はできませんでした。コンソールにはパケットロスのログが流れるだけでした。残念。
RTSPをUDPで受信してるっぽい感じ?なので、TCPのみで受信できれば良さそう?

調べてみると ffmpegrtsp_transport というオプションがありました。これを利用すれば UDP以外 を指定できるようです。

ffmpeg.org

ffmpeg.org

ということで、この rtsp_transport オプションを付けて tcp を指定して実行します。

$ ffplay -rtsp_transport tcp -i rtsp://5374:1261@10.182.176.48:8554/live

ffplay version 5.1.2 Copyright (c) 2003-2022 the FFmpeg developers
  built with Apple clang version 14.0.0 (clang-1400.0.29.202)
  configuration: --prefix=/opt/homebrew/Cellar/ffmpeg/5.1.2_1 --enable-shared --enable-pthreads --enable-version3 --cc=clang --host-cflags= --host-ldflags= --enable-ffplay --enable-gnutls --enable-gpl --enable-libaom --enable-libbluray --enable-libdav1d --enable-libmp3lame --enable-libopus --enable-librav1e --enable-librist --enable-librubberband --enable-libsnappy --enable-libsrt --enable-libtesseract --enable-libtheora --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libxvid --enable-lzma --enable-libfontconfig --enable-libfreetype --enable-frei0r --enable-libass --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-libspeex --enable-libsoxr --enable-libzmq --enable-libzimg --disable-libjack --disable-indev=jack --enable-videotoolbox --enable-neon
  libavutil      57. 28.100 / 57. 28.100
  libavcodec     59. 37.100 / 59. 37.100
  libavformat    59. 27.100 / 59. 27.100
  libavdevice    59.  7.100 / 59.  7.100
  libavfilter     8. 44.100 /  8. 44.100
  libswscale      6.  7.100 /  6.  7.100
  libswresample   4.  7.100 /  4.  7.100
  libpostproc    56.  6.100 / 56.  6.100
Input #0, rtsp, from 'rtsp://5374:1261@10.182.176.48:8554/live':0
  Metadata:
    title           : Session streamed by "Atom"
    comment         : live
  Duration: N/A, start: 0.000000, bitrate: N/A
  Stream #0:0: Video: h264 (Main), yuv420p(tv, bt709, progressive), 1920x1080, 20 fps, 20 tbr, 90k tbn
  Stream #0:1: Audio: pcm_alaw, 8000 Hz, 1 channels, s16, 64 kb/s
  91.05 A-V: -0.018 fd=  19 aq=    0KB vq=   60KB sq=    0B f=0/0

おぉぉーちゃんと受信できる!映像が見える!
ざっと確認した感じだと、さっきと違ってパケットロスのログも出てない。

音声は何度か試してみましたが、いづれも再生開始から少し経つと聞こえなくなってしまいました。 ローカル接続の場合でも音声は同様に不安定だったので、まーこんなものかなぁーと。残念。

youtu.be

というわけで、無事にRTSPへリモート接続ができました。
SORACOMのサービスを利用しているので設定が必要となりますが、 最初の1度設定してしまえば、あとはローカル接続と変わらず接続できます。


  • 良い点
    • RTSPへリモートアクセスできる。
    • SORACOMのサービスを利用することでセキュアに接続できる。
    • 1度設定してしまえば、あとはローカル接続と変わらず利用できる。
    • 必要な時だけ接続して映像を視聴できる。
      • 電源やネットワーク利用量を節約できる可能性がある。
    • ゲートウェイ機器さえあれば、後はオンラインですべて対応できる。
    • ATOMアプリで表示しているライブ映像と比較しても遅延が小さい。
      • クラウドへの常時録画を再生しているSORACOMユーザーコンソールと遜色がない。
  • 注意点
    • SORACOMのサービスごとの設定が必要となる。
    • SORACOMのサービス利用料が発生する。
    • リアルタイム映像を視聴するための構成なので、録画が必要な場合には別途仕組み検討することになる。
    • 各種設定はドキュメントを確認しながら進められるが、利用するサービスごとにある程度の知識は必要となる。
    • 利用サービスが多くなると仕組み自体が複雑になる。
    • ゲートウェイ機器の用意が必要となる。
    • ゲートウェイ機器も含めて、仕組み全体の運用を考える必要がある。
    • 複数名で見たい場合は、視聴するPCごとに設定が必要となる。
      • バーチャル SIMを台数分用意するため費用がかかる。

[我流-4] SORACOM Arc と SORACOM Napter でRTSPへリモートアクセスする我流

RTSPはUDPの利用が必須だと勘違いしていたので、最初に試さなかったのですが
[我流-3]rtsp_transport オプションを調べた際に、UDP以外の受信指定ができることを知りました。
TCPで受信できるのであれば SORACOM Napter を利用しても実現できるのでは?と思い[我流-3] に続けて検証してみました。

SORACOM Napter とは

SORACOM Air のSIMを利用しているデバイスへ、セキュアにリモートアクセスできるサービスです。詳細はリンク先を確認してください。

soracom.jp

つまりは

[我流-3] でSORACOM Gate D2Dを利用してAC2とPCで通信していた部分を、SORACOM Napter に置き換えてやれば、バーチャル SIM1枚とSORACOM Gate D2D の費用も節約でき、仕組みもシンプルになりそうです。

SORACOM Napter を準備する

公式ドキュメントを参照して進めていきます。

users.soracom.io


  • SORACOMユーザーコンソールにログインして、[我流-3]ゲートウェイ機器に設定したバーチャル SIM を探します。
  • 該当のバーチャル SIMを選択して オンデマンドリモートアクセス を作成します。

    users.soracom.io

  • オンデマンドリモートアクセス は、SORACOM CLI / API でも作成できます。

    users.soracom.io

  • オンデマンドリモートアクセス の デバイス側ポート は ゲートウェイ機器でポートフォワード設定したポートです。
    • 今回は 8554 ポートで設定しました。
  • すべての設定が完了するとアクセスに必要な情報がダイアログに表示されます。

RTSPへリモートアクセスして確認してみる

[我流-3] 確認したRTSPのURLフォーマットを利用します。
rtsp://<ユーザ名>:<パスワード>@<IPアドレス>:<ポート番号>/live

<IPアドレス>:<ポート番号>
の部分を SORACOM Napter の準備で確認した接続情報に置き換えます。ダイアログに表示されている IPアドレス の項目をそのままコピーして利用します。

IPアドレス をコピペして、URLを整理すると
rtsp://5374:1261@18.181.57.86:12374/live
という形がリモートアクセス用のURLとなります。

これでRTSPへリモートアクセスするための準備は整ったので、実際にアクセスしてみます。

$ ffplay -rtsp_transport tcp -i rtsp://5374:1261@18.181.57.86:12374/live

ffplay version 5.1.2 Copyright (c) 2003-2022 the FFmpeg developers
  built with Apple clang version 14.0.0 (clang-1400.0.29.202)
  configuration: --prefix=/opt/homebrew/Cellar/ffmpeg/5.1.2_1 --enable-shared --enable-pthreads --enable-version3 --cc=clang --host-cflags= --host-ldflags= --enable-ffplay --enable-gnutls --enable-gpl --enable-libaom --enable-libbluray --enable-libdav1d --enable-libmp3lame --enable-libopus --enable-librav1e --enable-librist --enable-librubberband --enable-libsnappy --enable-libsrt --enable-libtesseract --enable-libtheora --enable-libvidstab --enable-libvmaf --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxml2 --enable-libxvid --enable-lzma --enable-libfontconfig --enable-libfreetype --enable-frei0r --enable-libass --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-libspeex --enable-libsoxr --enable-libzmq --enable-libzimg --disable-libjack --disable-indev=jack --enable-videotoolbox --enable-neon
  libavutil      57. 28.100 / 57. 28.100
  libavcodec     59. 37.100 / 59. 37.100
  libavformat    59. 27.100 / 59. 27.100
  libavdevice    59.  7.100 / 59.  7.100
  libavfilter     8. 44.100 /  8. 44.100
  libswscale      6.  7.100 /  6.  7.100
  libswresample   4.  7.100 /  4.  7.100
  libpostproc    56.  6.100 / 56.  6.100
Input #0, rtsp, from 'rtsp://5374:1261@18.182.176.143:47995/live':
  Metadata:
    title           : Session streamed by "Atom"
    comment         : live
  Duration: N/A, start: 0.000000, bitrate: N/A
  Stream #0:0: Video: h264 (Main), yuv420p(tv, bt709, progressive), 1920x1080, 20 fps, 20 tbr, 90k tbn
  Stream #0:1: Audio: pcm_alaw, 8000 Hz, 1 channels, s16, 64 kb/s
  61.59 A-V: -0.027 fd=   0 aq=    0KB vq=   53KB sq=    0B f=0/0   /0

[我流-3]と同様にアクセスできました。
手元で確認した感じだと、SORACOM Napter の場合は音声も安定して聞こえていました。 試した回数が少ないので偶然という気もしますが。。。。

というわけで、SORACOM Napter を利用しても無事にRTSPへリモート接続ができました。
SORACOMのサービスを利用しているので設定が必要となりますが、 [我流-3] と比べると少しは手軽なのではないかと。

youtu.be


  • 良い点
    • RTSPへリモートアクセスできる。
    • SORACOMのサービスを利用することでセキュアに接続できる。
    • 必要な時だけ接続して映像を視聴できる。
      • 電源やネットワーク利用量を節約できる可能性がある。
      • SORACOM Napter は利用した月以外は費用がかからない。
    • ゲートウェイ機器さえあれば、後はオンラインですべて対応できる。
    • ATOMアプリで表示しているライブ映像と比較しても遅延が小さい。
      • クラウドへの常時録画を再生しているSORACOMユーザーコンソールと遜色がない。
  • 注意点
    • SORACOMのサービスごとの設定が必要となる。
    • SORACOMのサービス利用料が発生する。
    • リアルタイム映像を視聴するための構成なので、録画が必要な場合には別途仕組み検討することになる。
    • 各種設定はドキュメントを確認しながら進められるが、利用するサービスごとにある程度の知識は必要となる。
    • 利用サービスが多くなると仕組み自体が複雑になる。
    • ゲートウェイ機器の用意が必要となる。
    • ゲートウェイ機器も含めて、仕組み全体の運用を考える必要がある。
    • 複数名で見たい場合は、視聴するPCごとに設定が必要となる。
    • SORACOM Napter での接続は、有効期限があるため常時接続のユースケースにはミスマッチとなる。

[我流-5] AC2の映像をOBS Studio を使って配信する我流

世の中にはいろいろな配信プラットフォームがあり、誰でも無料で手軽に配信できるような世界線ですよね。
実際に ATOM Cam(無印)ライブ配信する方法が、ATOMテックさんのBlogに掲載されています。

community.atomtech.co.jp

記載されている方法で「Webカメラ化」できるのは ATOM Cam(無印) のみで、今回利用しているAC2は非対応となっている。

info.atomtech.co.jp

記載されているもうひとつの方法の、「PCにインストールできるATOMアプリを利用する」は、Windows / Mac 版共に β版 であるのと、そもそもAC2は非対応となっている。

info.atomtech.co.jp

しかし、ここまでネタBlogを読んでくれている優しい人なら気がついていると思いますが、PCでスマホアプリが動かせればいいだけという話に。
なので [我流-1] で利用した エミュレーターの画面を、ライブ配信ソフトで配信すれば良さそうです。
ちょっと前に話題になった、整理券の呼び出し番号画面を配信するのと同じ発想ですねw

togetter.com

ライブ配信ソフトは利用できるプラットフォームも広く、利用者が多いため情報も多い OBS Studio を利用します。

obsproject.com

というわけで、やることはとてもとても簡単です。


  • OBS Studio をダウンロードしてインストールします。
  • ライブ配信先のプラットフォームを選んで必要な設定します。
    • YouTube Live は初回のライブ配信を行う場合、設定から24時間後に利用可能となりました。
  • OBS Studio 自体の詳細な設定は、Webを検索するとたくさん出てくるので割愛します。
  • Android エミュレーターを起動して、リアルタイム映像を確認したいカメラを表示します。
  • すべての設定が完了したら、あとはAndroid エミュレーターの画面をOBS Studio に取り込んで配信します。

OBS Studio は特に躓くことはなかったのですが、YouTube Live の有効化がWebを検索した情報とちょっと違ってたので自分用にメモしておきます。
YouTube Live が有効化されていないと OBS Studio で以下のようなダイアログが表示されます。

ダイアログのリンクをクリックすると、YouTube Live の設定画面がWebブラウザで表示されます。

Webを検索して出てくる情報の多くが、この設定画面の話なのですが「中級者向け機能」というのが追加になってるっぽい雰囲気でした。
実際にはYouTube Live の機能を有効化するために、以下の画面のリンクをクリックする必要がありました。

ちゃんと設定さえできていれば、たったこれだけ簡単にでライブ配信が行えます。 あとは、配信されている映像をWebブラウザで視聴すればリアルタイム映像を確認できます。

youtu.be

今回はエミュレーターのウィンドウだけをOBS Studio に取り込みました。他にも例えばデスクトップ全体を取り込んで、複数のエミュレーターを表示すれば複数カメラのリアルタイム映像を配信できたり、OBS Studio を使うことで柔軟な配信ができます。


  • 良い点
    • 必要なツールをインストールするだけで簡単に使えます。
    • 配信プラットフォームの仕様に依存するが、ライブ配信は自動で録画がされることが多い。
    • 配信用のPCとAC2があれば実現できます。
    • 複数名が視聴する場合でも、Webで視聴ができる。
    • 利用するツールや配信プラットフォームは、現時点では無償で利用できる。
    • 複数カメラのライブ映像を配信できる。
      • 例えば拠点別といった分けでエミュレーター並べて、画面を取り込めば良い。
    • 利用するツールと配信プラットフォームの使い方次第となるので汎用性が高い。
  • 注意点
    • 利用するツールや配信プラットフォームの使い方を習得する必要がある。
    • 現時点では利用するツールも配信プラットフォームも無償だが、将来的に継続する保証がない。
    • 利用するツールや配信プラットフォームの仕様に依存することになる。
      • 利用しているものに仕様変更があった場合は、自力で確認と対応が必要となる。
      • 利用する内容によっては利用規約やライセンスを確認して、条件を満たす必要がある。
    • 配信用のPCがSPOFになる可能性がある。
    • 配信する対象を変更するためには、配信用のPCを操作する必要がある。
    • 配信ツールの性能に依存する可能性がある。
      • 今回は何の設定もしないと、30~50秒程度の遅延 があった。
      • ツールの設定で「超低遅延」を選択することで 5~10秒程度の遅延 になった。

[我流-6] 諸々をAWSに任せてみたかった我流(未解決)

[我流-5] でOBS Studio を利用しましたが、実行はローカルPCで行なっています。
実際の運用を考えると、配信する対象のカメラを変更したい場合や配信を止めたい場合など、その都度ローカルPCがある場所に行く必要が出てきます。

[我流-4] で利用した SORACOM Arc と SORACOM Napter を使えば、配信用のローカルPCに簡単にリモートアクセスできます。

users.soracom.io

これで現地に行かなくても設定が変更できます。 しかし、ローカルで配信を行うということは、そのローカルネットワークにライブ配信トラフィックが流れ続けます。

おそらく光回線を利用している人が多い世界線かつ、個人の場合にここを気にする必要はないのかも?ですが、ネットワークに負荷がかかっている状態ではあるので、ここのカイゼンも考えてみます。

すぐに思いつくのは、配信する環境をDaaSに押し込めてしまえば良いのでは?という考えです。これであればネットワークもDaaS側のものを利用できます。
せっかっくなのでAWSのDaaSである Amazon WorkSpaces を利用してみようと思います。

aws.amazon.com

Amazon WorkSpaces を準備する

Amazon WorkSpaces を用意して、OBS Studio とAndroidエミュレーター をインストールしてみました。 OBS Studio は問題なくインストールでき、起動も問題なかったのですが、Androidエミュレーターの方は...

まず、Androidエミュレーターのインストール時に以下のようなエラーが表示されていました。

Unable to install Intel® HAXM
Your CPU does not support VT-x.
Unfortunately, your computer does not support hardware accelerated virtualization.
Here are some of your options:
 1) Use a physical device for testing
 2) Develop on a Windows/OSX computer with an Intel processor that supports VT-x and NX
 3) Develop on a Linux computer that supports VT-x or SVM
 4) Use an Android Virtual Device based on an ARM system image
   (This is 10x slower than hardware accelerated virtualization)

Unfortunately, your computer does not support hardware accelerated virtualization.

ハードウェアの仮想化ができないので使えないと。まーDaaSですしそんなもんかと。
少し調べてみるとAWSで ハードウェアの仮想化をサポートしてるのは、ベアメタルインスタンスだけっぽいという感じがw
ベアメタルインスタンスは金額も高いし、DaaSではなくIaaSなのでいろいろ設定が必要というところが面倒w

4) Use an Android Virtual Device based on an ARM system image (This is 10x slower than hardware accelerated virtualization)

10倍遅い!!とのこと。M1 Macを使ってるので普段もARM版のAndroid エミュレーターを利用しています。
なので、10倍遅くても動かないよりはだいぶマシなのがわかっているので、チャレンジしてみましたが...

起動しなかった際のコンソールの出力は

022-12-08 02:25:20,216 [ 634558]  ERROR -       Emulator: Nexus 5 API 31 - Android Studio Dolphin | 2021.3.1 Patch 1  Build #AI-213.7172.25.2113.9123335 
2022-12-08 02:25:20,216 [ 634558]  ERROR -       Emulator: Nexus 5 API 31 - JDK: 11.0.13; VM: OpenJDK 64-Bit Server VM; Vendor: JetBrains s.r.o. 
2022-12-08 02:25:20,216 [ 634558]  ERROR -       Emulator: Nexus 5 API 31 - OS: Linux 
2022-12-08 02:25:20,216 [ 634558]  ERROR -       Emulator: Nexus 5 API 31 - Last Action: WelcomeScreen.RunDeviceManager 

という感じで原因もいまいちわからず。

Amazon WorkSpaces は Ubuntu Desktop環境だけでなくWindows環境も利用できるので、Windows / Ubuntu 両方試してみましたが、残念ながらどちらもARM版のイメージは起動しませんでした。

Windowsの方はAndroid Studio 以外のエミュレーターも試したみましたが、ハードウェアの仮想化が必要なものが多く、ほとんどでダメでした。 ハードウェアの仮想化が必要じゃないものもありましたが、グラフィックドライバーが古くインストールできなかったり、インストールできても正常に動作しないといった感じでした。無念...

Google Cloud だと ネストされた仮想化 を許可するだけで使えそうだったり

cloud.google.com

そもそもWebでAndroidエミュレーターのUIを表示できたりするみたいなので

source.android.com

この辺を頑張れば動作はしそうですね。面白そうですが今回は本題からホームラン級で逸れるのでやめておきますw

Amazon WorkSpaces でエミュレーターが動作するのであれば、ライブ配信のプラットフォームにも 同じくAWSのサービスである AWS Media Services を利用してみたかったのが心残り。残念。

aws.amazon.com

OBS Studio での AWS Media Services 利用に関して、AWSのBlogに以下のような投稿があったので試してみたかったのと、AWSに録画データが蓄積できるのであれば、後で他のAWSのサービスを使って色々できそうかなーと思ったので。

aws.amazon.com

AWS Media Services の利用と、AWSに蓄積される録画データをいろいろと試してみるのは、また別の機会にやってみたいと思います。
エミュレーター動くよ!とか、他にも方法あるよ!とかあればぜひ教えてください。

まとめ

  • リアルタイムで視聴する「だけ」ならいろいろ方法はある
  • 実用する場合は、仕組みや運用も含めて考える必要がある
  • 単純にダッシュボード的に表示だけしたいなら、タブレット1台とモニター1台を用意してダブレットの画面を表示した方が簡単かつ安定する
  • 無償で利用できるサービスも多いが、制限や仕様が変更される可能性があるので実用するなら考慮はしておく方が良さそう
  • 有償のサービスを利用するのは心理的にハードルもあるが、細かいところを丸ごとお任せできるのと、サポートを受けられるというメリットもある
  • プラットフォームを利用することで、機能追加やカイゼンといった恩恵も受けられる
  • ソラカメが今後もアップデートを続けて、より魅力的になることを期待したい

というわけで、ネタ多めで長めの記事となってしまいました...
技術的に「できる」は、探すと意外に色々あることがわかります。
いっぽうで本番で利用するような運用が「できる」を考えると、なかなか難しいこともわかります。

使えるデバイスが手元にあるのであれば、必要に応じていろいろ試してみるのお勧めします。
「できる」が増えるのは楽しいですよ。くだらないことって大事ですよね。

以上です。