How to integrate TTN (The Things Network) with Azure Functions App to notify data and log (ex; RSSI, SNR values) by Slack?
LoRaWANサーバのデファクトであるTTNで受信したデータとログの詳細をAzure Functionsを利用して通知する方法について紹介します。
以前、IFTTTを利用してSlackへ通知する方法についてはこちら紹介しているので、簡単に実装したい方はこちらが参考になるかもしれません。
ゴール
IFTTTでは受信データの中身を通知するくらいで、LoRa受信時の電波強度(RSSI)や、SNR値などについてはTTNのメッセージ受信ログを目視で確認する必要がありました。
電波の到達確認の実験を行いたいときなど、受信時のRSSIなどの詳細ログを確認するのが今回の目標です。
最終的にはこのように通知が来るようになりました。
事前準備
1. TTNの使い方がわかる(すでにLoRaノードからデータを受信できる)
LoRaWAN初心者にはちょっと難しいかもしれません。TTNの利用方法などについては調べ、自身のLoRaノードデバイスからデータが受信できる状態まで進めましょう。LoRaキットなども多く販売されているので、それらを入手すると良いでしょう。
2. JSONがわかる
JSONをパースしてSlackに通知するメッセージに整形します。
3. 自身でアプリケーションが追加できるSlackワークスペースを持っている
Slackは非常に便利で無料でも十分に利用できるコミュニティワークスペースです。その便利さや他のサービスとの連携の容易さから通知用として活用している人も多いと思います(私もいろいろ活用しています)。
4. Microsoft Azureのアカウントを持っており、Azure Functions Appを作れる
アカウントがあればとりあえずどうにかなると思います。ポータルからFunctions Appを開きましょう。
すること
TTNで受信するデータをAzure Functions経由でSlackに通知します。
全体フローは以下。
センサ値など(実験では固定値)を送信するLoRaデバイスをLoRa無線でゲートウェイへ送信し、ゲートウェイはTTNサーバへ送信します。デバイスはABP/OTAAで認証後TTNのアプリケーションで認識されるものとします。
TTNのHTTP Integration機能を利用してAzure Functions Appに対してPOSTメソッドでデータを投げます。Functions Appがキックされると、POSTメソッドで渡されるJSONの中身を適当に必要なデータをパース&整形してSlackに通知します。
では、上記フローを連携の都合上、逆から設定していきましょう。以下でそれぞれのステップを細かく説明して起きます。
1. SlackのWebhook URLを取得する
以下の公式ヘルプを参考に、自身のWorkspaceの設定からIncoming Webhookアプリケーションを有効にし、Webhook URLを取得します。
Incoming Webhookができることについて
https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-%E3%81%A7%E3%81%AE-Incoming-Webhook-%E3%81%AE%E5%88%A9%E7%94%A8
ワークスペースとWebhook URLの連携について
https://slack.com/intl/ja-jp/help/articles/360041352714-Webhook-%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B
これでSlackのworkspace投稿用のURLを取得できればOKです。
フォーマットは次の形式ですね。どこかにメモしておきましょう。
https://hooks.slack.com/services/TXXXXXXX/BXXXXXXX/XXXXXXXXXXXXXXXX
2. Azure Functions Appでアプリケーション(C#)を作成して連携用URLを取得する
今回は以下のように、PostToSlackHTTPTriggerという関数名を作成しました。
プログラム(C#)ソースコード
これが今回一番メインのプログラムです。TTNからこのプログラムがキックされると同時にデータが渡されます。
run.csx
Github Gist: https://gist.github.com/himitu23/baef2334760aaac290421dd98e0c014c
#r "Newtonsoft.Json"
#r "System.Configuration"
#r "System.Data"
//email
using Microsoft.Extensions.Logging;
//encoding
using System.Text;
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
//http post
using Newtonsoft.Json.Linq;
using System.Net.Http.Headers;
using System.Collections.Generic;
using System.Runtime.Serialization.Json;
using System.IO;
using static System.Console;
using System.Runtime.Serialization.Json;
using System.Runtime.Serialization;
private static readonly HttpClient client = new HttpClient();
public static async Task<IActionResult> Run(HttpRequest req, ILogger log)
{
log.LogInformation("C# HTTP trigger function processed a request.");
//receive paramters
string dev_id = req.Query["dev_id"];//TTN device id (included in JSON Request)
string payload_raw = req.Query["payload_raw"];
string metadata_time = req.Query["\"metadata\":{\"time\"}"];
string frequency = req.Query["\"metadata\":{\"frequency\"}"];
string data_rate = req.Query["\"metadata\":{\"data_rate\"}"];
string bit_rate = req.Query["\"metadata\":{\"bit_rate\"}"];
string gtw_id="", rssi="", snr="";
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
dynamic data = JsonConvert.DeserializeObject(requestBody);
//debug for json parse
string json1 = JsonConvert.SerializeObject(data, Formatting.Indented);
Console.WriteLine(json1);
log.LogError(json1);
//parse query from JSON (MQTT)
dev_id = dev_id ?? data?.dev_id;
payload_raw = payload_raw ?? data?.payload_raw;
metadata_time = metadata_time ?? data?.metadata.time;
frequency = frequency ?? data?.metadata.frequency;
data_rate = data_rate ?? data?.metadata.data_rate;
bit_rate = bit_rate ?? data?.metadata.bit_rate;
//convert date time to JST from UTC
TimeSpan duration = new TimeSpan(0, 9, 0, 0);
DateTime date_time = Convert.ToDateTime(metadata_time);
metadata_time = Convert.ToString(date_time.Add(duration));
if(json1.Contains("gateways")){/* Actual */
gtw_id = data?.metadata.gateways[0].gtw_id;
rssi = data?.metadata.gateways[0].rssi;
snr = data?.metadata.gateways[0].snr;
}
//converted from paylad as base64
byte[] payload_byte_data = Convert.FromBase64String(payload_raw);
string debug = "";
for(int i=0;i<payload_byte_data.Length;i++) debug += Convert.ToString(payload_byte_data[i],16) + " ";
log.LogError(debug);
log.LogError("Data: " + debug + "(from " + dev_id + ")");
//create a message for e-mail
string Msg_mail = "Data: " + debug + "(from " + dev_id + ")";
/** ---- Send to Slack ---- **/
string statusMsg = "";
if(json1.Contains("gateways")){/* Actual */
statusMsg = "PacketReceived (" + metadata_time + ") \n"
+ "Freq: " + frequency + ", DR: " + data_rate + ", BR: " + bit_rate + "\n"
+ "DevID: "+ dev_id + ", RSSI: " + rssi + ", SNR: " + snr + "\n"
+ "Data: " + debug + "\n";
}else{ /* uplink test */
statusMsg = "PacketReceived TEST (" + metadata_time + ") \n"
+ "DevID: "+ dev_id + "\n"
+ "Data: " + debug + "\n"
+ json1;
}
//Push message through Slack
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Add("User-Agent", "AzureFunctions");
//Copy paste uri from Slack
var uri = "https://hooks.slack.com/services/TXXXXXXX/BXXXXXXX/XXXXXXXXXXXXXXXX";//Slack's webhook URL
// emoji list - http://www.emoji-cheat-sheet.com/
var msg = new SlackHook {text = statusMsg, icon_emoji = ":beer:"};
StringContent SlackMsg = new StringContent(JsonConvert.SerializeObject(msg));
HttpResponseMessage response = client.PostAsync(uri,SlackMsg).Result; //post to slack
var responseString = response.Content.ReadAsStringAsync().Result; //get the response
log.LogError(responseString);
}
/** ---- ====================== ---- **/
//response
return dev_id != null
? (ActionResult)new OkObjectResult($"Received from, {dev_id}, {debug}")
: new BadRequestObjectResult("Please pass a name on the query string or in the request body");
}
public class SlackHook
{
public string text {get;set;}
public string icon_emoji {get;set;}
}
上記のうち、Slack Webhook URLの部分は自身のものに変更してください。
プログラムの解説(Webhook URLのペースト)
以下にて少しプログラムについて補足説明します。
このプログラムは、TTNから受け取ったデータをパースし、送信メッセージに整形しています。
//取得したいパラメータの変数を用意します
string dev_id = req.Query["dev_id"];//TTN device id (included in JSON Request)
string payload_raw = req.Query["payload_raw"];
string metadata_time = req.Query["\"metadata\":{\"time\"}"];
string frequency = req.Query["\"metadata\":{\"frequency\"}"];
string data_rate = req.Query["\"metadata\":{\"data_rate\"}"];
string bit_rate = req.Query["\"metadata\":{\"bit_rate\"}"];
string gtw_id="", rssi="", snr="";//これらのパラメータは取得できる場合とできない場合があるのでからの初期値とします
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
dynamic data = JsonConvert.DeserializeObject(requestBody);//変数dataの中に受信データがすべて含まれています
//debug for json parse
string json1 = JsonConvert.SerializeObject(data, Formatting.Indented);
Console.WriteLine(json1);
log.LogError(json1);
//parse query from JSON (MQTT) ここで主にデータのパースを行います
dev_id = dev_id ?? data?.dev_id;
payload_raw = payload_raw ?? data?.payload_raw;
metadata_time = metadata_time ?? data?.metadata.time;
frequency = frequency ?? data?.metadata.frequency;
data_rate = data_rate ?? data?.metadata.data_rate;
bit_rate = bit_rate ?? data?.metadata.bit_rate;
//convert date time to JST from UTC
TimeSpan duration = new TimeSpan(0, 9, 0, 0);
DateTime date_time = Convert.ToDateTime(metadata_time);
metadata_time = Convert.ToString(date_time.Add(duration));
//以下はgatewayから渡されるパラメータの中身です。gatewaysタグの中はjson配列となっているので記述に気をつけてください。
if(json1.Contains("gateways")){
gtw_id = data?.metadata.gateways[0].gtw_id;
rssi = data?.metadata.gateways[0].rssi;
snr = data?.metadata.gateways[0].snr;
}
また、TTNのデバイスのアップリンクテストの環境と実際に渡されるデータが異なるため、一部異なる処理をしている部分がありますが、これについては、「5. TTNからPOSTメソッドで送信されるJSONデータについて」を参照してください。
以下の部分がSlackに通知されるメッセージ部分の本体です。適当に独自のものに書き換えてください。
if(json1.Contains("gateways")){/* Actual */
statusMsg = "PacketReceived (" + metadata_time + ") \n"
+ "Freq: " + frequency + ", DR: " + data_rate + ", BR: " + bit_rate + "\n"
+ "DevID: "+ dev_id + ", RSSI: " + rssi + ", SNR: " + snr + "\n"
+ "Data: " + debug + "\n";
}
プログラムの動作テストをする
ここまでうまくいくと、フローの最後の連結ができたはずなのでAzure Functions AppとSlackの連携をテストしてみましょう。
「5. TTNからPOSTメソッドで送信されるJSONデータについて」にあるJSON(TTNで示されているフォーマット)をまるごとコピーして、Functions Appのテストにペーストします。※このJSONがTTNから渡される想定でデバッグしましょう
このときHTTPメソッドはPOSTです。
うまく動作すると200OKが返ってきます。
同時にSlackに通知できていたらいい感じです。UTC 0時がJST 9時になっているのが分かりますね。
3. TTNのHTTPインテグレーションを作成し、Azure Functionsと連携する
上記のイメージのように、自身の所有しているデバイスがアプリケーションに登録されており、実際に受信したデータが、このアプリケーションの「データ」のタブから確認できることがまず必要となります。
TTNには、アプリケーションのデバイス単位でアップリンクシミュレーション(デバイスからサーバにデータが届いたシミュレーション)が可能です。TTNの以下の箇所から可能です。
テストを実行して、データタブで以下のように見えるとOKです。
Azure FunctionsのPOST用URLを取得する
TTNとの連携用のURLを取得します。もう一度Azure Functionsの先程作成した関数を開いて「関数のURLの取得」をクリックしてURLを取得しましょう。
コピーしておきましょう。すぐに使います。
TTN HTTPインテグレーションの追加と設定
アプリケーション>インテグレーションのタブから、インテグレーションを追加します。
今回は「HTTP Integration」を選択しましょう。以下のように設定します。プロセスIDは何でも大丈夫です。
- Access Key: default key
- URL: 先程のAzure FunctionsのPOST用URL
それ以外については空白で問題ありません。これでAzure FunctionsとTTN HTTP Integrationの連携ができました。
HTTP Integrationの動作テストをする
では実際にデバイスのアップリンクシミュレーションを行ってSlackに通知されるか確認してみましょう。
以下を送信してみましょう。
データタブでは以下のように見えます。
※RevMsgタグなどはTTNのDecoderという機能を記述しているため設定されています。これについては以下の記事を見てください。
https://qiita.com/_tokina23/items/86bc3b53972a81aa777d#decoder%E3%81%AE%E8%A8%98%E8%BF%B0
これでSlackにテストが通知されたはずです。
以上でHTTPインテグレーションの動作確認ができましたね。
なお、この場合のAzure Functionsのメッセージ整形箇所は以下。最後に受信データすべてを表示しています。
}else{ /* uplink test */
statusMsg = "PacketReceived TEST (" + metadata_time + ") \n"
+ "DevID: "+ dev_id + "\n"
+ "Data: " + debug + "\n"
+ json1;
}
さて、ここまで頑張って設定したのですが、本題であるRSSIなどの通信詳細ログについては実際の機器を使ってテストしなければいけません。これはテスト環境と実機環境で渡されるJSONデータの中身が異なるためです。
4. 連携をテストする
IFTTTの記事ではインテグレーションの動作確認まででしたが、今回はRSSIの値などがみたいので実機でテストを行います。テスト環境は以下です。
上から順に...
- iPhone
- 外部環境でLoRaWANゲートウェイとインターネットをいつでも接続できるように有線でのテザリングを行いました。詳細な方法はここでは省略します。
- Raspberry Pi 3B+, LoRa Concentrator HAT
- Gatewayの役割を果たすのはLoRa Concentratorです。以下にGithubのリンクを貼っておきます。技適はないものの、今回は受信のみの動作(ABPでのアクティベーション)なので問題ありません。
- https://github.com/will127534/LoRa-concentrator
- 8チャネルの設定が必要なのでこちらは手動でAS923に合うように設定します
- RAK612 LoRa® Button
- 4種類のデータをボタンに割り当て、LoRaWANでデータを送信します。今回は日本の周波数に合わせてAS923としています。
- https://store.rakwireless.com/products/rak612-lora-button?variant=22375274479716
全体のフローを整理すると以下のイメージです。
うまくLoRa Button→RasPi LoRa Concentrator→TTNサーバが連携できると以下のように通知されるはずです。
RSSIなどの値が取得できていますね。これで便利になりました。
5. TTNからPOSTメソッドで送信されるJSONデータについて(補足)
いくつか実装中に気になった箇所などについて補足します。
TTNから渡されるJSONデータの形式はアップリンクシミュレーションで渡されるものとリファレンスとでは異なるようです。
TTNの公式サイトにJSONのフォーマットについて記載があります。
{
"app_id": "my-app-id", // Same as in the topic
"dev_id": "my-dev-id", // Same as in the topic
"hardware_serial": "0102030405060708", // In case of LoRaWAN: the DevEUI
"port": 1, // LoRaWAN FPort
"counter": 2, // LoRaWAN frame counter
"is_retry": false, // Is set to true if this message is a retry (you could also detect this from the counter)
"confirmed": false, // Is set to true if this message was a confirmed message
"payload_raw": "AQIDBA==", // Base64 encoded payload: [0x01, 0x02, 0x03, 0x04]
"payload_fields": {}, // Object containing the results from the payload functions - left out when empty
"metadata": {
"time": "1970-01-01T00:00:00Z", // Time when the server received the message
"frequency": 868.1, // Frequency at which the message was sent
"modulation": "LORA", // Modulation that was used - LORA or FSK
"data_rate": "SF7BW125", // Data rate that was used - if LORA modulation
"bit_rate": 50000, // Bit rate that was used - if FSK modulation
"coding_rate": "4/5", // Coding rate that was used
"gateways": [
{
"gtw_id": "ttn-herengracht-ams", // EUI of the gateway
"timestamp": 12345, // Timestamp when the gateway received the message
"time": "1970-01-01T00:00:00Z", // Time when the gateway received the message - left out when gateway does not have synchronized time
"channel": 0, // Channel where the gateway received the message
"rssi": -25, // Signal strength of the received message
"snr": 5, // Signal to noise ratio of the received message
"rf_chain": 0, // RF chain where the gateway received the message
"latitude": 52.1234, // Latitude of the gateway reported in its status updates
"longitude": 6.1234, // Longitude of the gateway
"altitude": 6 // Altitude of the gateway
},
//...more if received by more gateways...
],
"latitude": 52.2345, // Latitude of the device
"longitude": 6.2345, // Longitude of the device
"altitude": 2 // Altitude of the device
},
"downlink_url": "https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/my-app-id/my-process-id?key=ttn-account-v2.secret"
}
実際のLoRaノードからデータが送信される場合はこのフォーマットを想定してAzure Functionsのプログラムを書きましょう。
しかし、連携テストをTTNのアプリケーションのデバイスのアップリンクテストで行う場合は以下のフォーマットのようです。
{
"app_id": "my-app-id",
"dev_id": "my-dev-id",
"hardware_serial": "0102030405060708",
"port": 1,
"counter": 0,
"payload_raw": "qrvM3e7/",
"payload_fields": {
},
"metadata": {
"time": "2020-02-28T12:12:13.897252206Z"
},
"downlink_url": "https://integrations.thethingsnetwork.org/ttn-eu/api/v2/down/my-app-id/my-process-id?key=ttn-account-v2.secret"
}
今回はRSSIやSNRの値がほしかったので、デバイスのアップリンクシミュレーションはあんまり役に立ちませんでした。もし同じ方法を試す方がいれば、こちらについても注意して実装する必要があります。
アップリンクシミュレーションにおけるgatewaysタグが存在しない場合の回避策
よく見ると、アップリンクテストの場合、もはやRSSIなどが含まれるgatewaysのタグ自体が存在しないので、受け取ったデータ中にgatewaysタグが存在する場合としない場合の処理を分けて記述しました。null判定がうまくできなかったので・・・
以下にJSONパース時のエラー回避部分を示します。
string gtw_id="", rssi="", snr=""; //declare first
if(json1.Contains("gateways")){ /* Actual */
gtw_id = data?.metadata.gateways[0].gtw_id;
rssi = data?.metadata.gateways[0].rssi;
snr = data?.metadata.gateways[0].snr;
}
あとは適当にSlack通知時のメッセージ内容を変更するなどして対応しましょう。
最後に
実際にTTNと接続されたゲートウェイのカバー範囲であれば、LoRaノードからデータを送信すれば到達確認ができる様になりました。
これでアンテナを交換してRSSIの値を確認したり、都市部上空で到達を試す場合はSNRの値の確認がスマホのSlack通知などで簡単に行うことができるようになりました!
こんなニッチな記事がどなたかの参考になれば幸いです。