お手軽プレゼンス管理、リトライ! その3 (Windowsアプリケーション編)

前回に続いて、次は位置ビーコンを検出してDBに送信するWindowsアプリケーションを作成します。

必要な機能

  • ビーコンを検出する
  • APIを叩いて位置ビーコン情報とユーザ情報を取得・登録する
  • 検出したビーコン情報をInfluxDBに登録する

これらの機能が実現できればあとは組み合わせていけば完成ですね。
順番に実験していきましょう。

実験1 ビーコンの検出

お手軽プレゼンス管理、リトライ! その1 (Beacon編) - イナバちゃんログ で作成したように、ビーコンはBLEのアドバタイジングパケットを定期的に送信しています。
そのパケットを手あたり次第受信して対象の位置ビーコンか判定すれば良さそうです。というかそれがビーコンの一般的な運用ですね。

Windows8以降であればWinRTというランタイムに BluetoothLEAdvertisementWatcher というズバリなクラスがいるのでそれを使います。

docs.microsoft.com

UWP以外からWinRTのAPIを利用するのは面倒なお膳立てが必要だったんですが、Microsoft.Windows.SDK.ContractsというNugetパッケージのおかげで簡単に利用できるようになっています。インストールしてありがたく使いましょう。 (.NET5ではもっと便利になってるらしいですね .NET 5 から Windows Runtime API を呼ぶのが凄い楽になってる - Qiita

ドキュメントによると、BluetoothLEAdvertisementWatcherはインスタンスを作成して Start() をコールすると、アドバタイズパケットを受信で Received イベントが発生するという感じですね。か、簡単・・・。

var watcher = new BluetoothLEAdvertisementWatcher();
watcher.Received += (s, e) => {
  // BluetoothLEAdvertisementReceivedEventArgsに受信した
  // パケットが入ってるので中身を見てゴニョゴニョする.
};
watcher.Start();

以前リンクしたサイト iBeaconとは | 基礎知識 | ROHM TECH WEB によると iBeacon情報はManufacturer specific dataに載ってきます。
さらに、Android BLE API及びAndroid Beacon Libraryの設計の酷さを技術的に説明する - Qiita によると AppleのCompany IDは 0x004c、iBeacon Headerには固定値で0x02と0x15がはいるそうです。

f:id:hollyhockberry:20210902171354p:plain

いろいろ試したところ、BluetoothLEAdvertisementReceivedEventArgsのAdvertisement.ManufacturerData.DataにはCompanyID以降が収まっているようです。(CompanyIDはAdvertisement.ManufacturerDataのプロパティに展開されている)

ですので、受信したアドバタイジングパケットがiBeaconでフィルタリングするためにCompanyIDが0x004CでAdvertisement.ManufacturerData.Dataの先頭2バイトが0x02, 0x15のものを受信したらiBeacon、それ以外は弾きます。

var manufacturerdata =
    e.Advertisement.ManufacturerData.FirstOrDefault();

if (manufacturerdata?.CompanyId != 0x004C ||
    manufacturerdata?.Data.Length <= 0)
    return;

using var reader = DataReader.FromBuffer(manufacturerdata.Data);
var data = new byte[manufacturerdata.Data.Length];
reader.ReadBytes(data);

if (data[0] != 0x02 || data[1] != 0x15)
    return;

ここまでできたら前掲のパケットの図を参考にUUID, Major, Minorの値を取り出すことができます。
検出したiBeaconの情報をWriteLineするコードはこんな感じです。

static void Main(string[] _) {
    var watcher = new BluetoothLEAdvertisementWatcher();
    watcher.Received += (s, e) => {
        var manufacturerdata =
            e.Advertisement.ManufacturerData.FirstOrDefault();

        if (manufacturerdata?.CompanyId != 0x004C ||
            manufacturerdata?.Data.Length <= 0)
            return;

        using var reader
            = DataReader.FromBuffer(manufacturerdata.Data);
        var data = new byte[manufacturerdata.Data.Length];
        reader.ReadBytes(data);

        if (data[0] != 0x02 || data[1] != 0x15)
            return;

        var uuid = UUID(data.Skip(2).Take(16));
        var major = ToUInt16(data.Skip(18).Take(2));
        var minor = ToUInt16(data.Skip(20).Take(2));

        if (major != 0x1000) return;

        Console.WriteLine(
            $"{DateTime.Now} UUID:{uuid} " +
            $"Major:0x{major:X4} Minor:0x{minor:X4}");

        static string UUID(IEnumerable<byte> data)
            => string.Join(null, data.Select(v => $"{v:x2}"))
                .Insert(8, "-")
                .Insert(13, "-")
                .Insert(18, "-")
                .Insert(23, "-");

        static int ToUInt16(IEnumerable<byte> data)
            => BitConverter.ToUInt16(BitConverter.IsLittleEndian
                ? data.Take(2).Reverse().ToArray()
                : data.ToArray(), 0);
    };
    watcher.Start();

    while (true) ;
}

f:id:hollyhockberry:20210901212916p:plain

作成した位置ビーコンをばっちり捕らえてますね!

実験2 位置ビーコン情報とユーザ情報

前回のリンクで作成した /api/locaion/api/userAPIを操作します。
こちらはかなり単純で、System.Net.WebRequestでHttpリクエストを発行し、JSON形式のデータを送るか受け取るかするだけです。

あらかじめDBにいくつかユーザを追加しておきましたので /api/userのGETメソッドでユーザ一覧を取得してみます。

f:id:hollyhockberry:20210901213102p:plain

ドキュメントを見ると戻り値の形式がわかります。
それにあわせてクラスを作っておきます

class User {
  [JsonPropertyName("id")]
  public string ID { get; set; }

  [JsonPropertyName("name")]
  public string Name { get; set; }

  [JsonPropertyName("description")]
  public string Description { get; set; }
}

WebResponseからResponseStream()で取得したStreamで受信したデータを取得できます。

static void Main(string[] _) {
    // URLは適宜変更してください
    var api = "http://*******/api/user";

    var reqest = WebRequest.Create(api);
    using var response =
        reqest.GetResponse().GetResponseStream();
    using var reader = new StreamReader(response);
    var json = reader.ReadToEnd();
    var user = JsonSerializer.Deserialize<User[]>(json);
    Array.ForEach(user, x =>
      Console.WriteLine($"{x.ID}, {x.Name}, {x.Description}"));
}

取得したデータをJSONとしてデシリアライズすると、仕様通りUser型の配列を受け取れています。

f:id:hollyhockberry:20210901213134p:plain

今度はPOSTメソッドでデータを送信してみます。
/api/user/{id}のPOSTメソッドでユーザの生成なので、ドキュメントを見てデータスキーマのクラスを作ります。

class UserCreate {
    [JsonPropertyName("name")]
    public string Name { get; set; }

    [JsonPropertyName("description")]
    public string Description { get; set; }
}

新規ユーザをCREATEするには下記のようになります。

static void Main(string[] _) {
    // URLは適宜変更してください
    var api = "http://*******/api/user";

    var id = "sample_user";
    var name = "sample user name";
    var description = "description ";

    var reqest =
        (HttpWebRequest)WebRequest.Create($"{api}/{id}");
    reqest.ContentType = "application/json";
    reqest.Method = "POST";
    using (var writer
        = new StreamWriter(reqest.GetRequestStream())) {
        writer.Write(
            JsonSerializer.Serialize(new UserCreate {
                Name = name,
                Description = description
            }));
    }
    using var response = (HttpWebResponse)reqest.GetResponse();
    Console.WriteLine(response.StatusCode);

    while (true) ;
}

先ほどと違うのはWebRequestのResponseを受け取る前にRequestStreamにデータを書き込んでいるところですね。(そりゃそうだ・・)

DBをみてみると正しく追加されていることがわかります。 f:id:hollyhockberry:20210901213200p:plain ちなみにもう一度実行すると重複データのため 400 Bad Request が返るため例外になります。例なので端折りましたがcatchしないと駄目ですね。

f:id:hollyhockberry:20210901213213p:plain

InfluxDBへの登録

これはさらに簡単です。なぜならNugetパッケージを使うからです。

www.nuget.org

Vibrant.InfluxDB.Client.InfluxClient のインスタンスを生成して、データの個数分だけ Vibrant.InfluxDB.Client.Rows.DynamicInfluxRow をWriteするだけです。

それだけだとアレなので実験してみます。

static void Main(string[] _) {
    // URLは適宜変更してください
    var host = "http://********:8086";
    using var client = new InfluxClient(new Uri(host));

    var row = new DynamicInfluxRow();
    row.Tags.Add("user", "user01");
    row.Fields.Add("uuid", "sample_uuid");
    row.Fields.Add("major", 0x1000);
    row.Fields.Add("minor", 0x1234);
    var rows = new List<DynamicInfluxRow> { row };

    client.WriteAsync("test", "sample", rows).Wait();
}

f:id:hollyhockberry:20210901213235p:plain

Database test に Measurement sample でデータが登録されています。

組み合わせて実装

必要な機能全てのやり方がわかったので後は組み合わせていくだけです。

処理の流れは

  1. 自分のユーザIDを決める
  2. 位置ビーコン情報を取得する
  3. 一定期間位置ビーコンを監視し、見つかったものはリストに追加する
  4. 監視停止後、見つかったものの中で一番近くのビーコン情報を自分の位置としてDBに送信
  5. しばし休憩
  6. 3.にもどる

みたいな感じですかね。実験が済んだ今となってはどうってことないですね。

github.com

というわけで完成したプロジェクトはこちらをご確認ください、で終わりにしても良いんですが、ちょっとだけ解説をします。

位置ビーコン情報の型

位置ビーコン情報はリストで返ってくるのですが、UUID+Major+Minorで生成される文字列をキーとしたDictionaryに作り変えています。
UUIDとMajor,Minorの組み合わせがユニークであるという前提なので、検出したビーコンの判定を ContainsKey() でできるようになります。 (ホントはどこかでユニークであることを厳格に保証しないとだめなんですが)

var locations = JsonSerializer.Deserialize<Location[]>(json);
Locations = locations
  .ToDictionary(
    l => $"{l.UUID.ToUpper()}:{l.Major:X4}:{l.Minor:X4}",
    l => l.Name);

検出したビーコンの型

検出したビーコンのクラス Beacon は IComparableとIEquatableを継承しています。

class Beacon : IComparable<Beacon>, IEquatable<Beacon> {
  public int CompareTo([AllowNull] Beacon other)
      => RSSI.CompareTo(other?.RSSI ?? int.MinValue);

  public bool Equals([AllowNull] Beacon other)
      => other != null &&
          UUID == other.UUID &&
          Major == other.Major &&
          Minor == other.Minor;
}

IComparableはリストの中から最も近くで検出されたビーコンを取り出したいので、RSSIのみをCompareToの対象にしています。
こうすることで、LINQのMax()で一番RSSIが大きいビーコンを取り出すことが簡単にできます。

IEquatableは、検出したビーコンが前回登録したものと比較して一致した場合に送信しない、という処理のために実装しています。比較してる箇所は一か所なのでそこでベタに記述してもいいですが、Equals()で判定した方が可読性が高い気がするのでこうしてます。

解説はこんなところですかね。実に単純なコードです。

実際にビーコンを近くに置いた状態で試してみました。

f:id:hollyhockberry:20210902180404p:plain

Visual Studioの出力をみると、ビーコン検出も一定間隔でできていますね。 位置情報も指定した間隔でDBに登録できてるようです。

なお、アプリケーションはコンソールアプリで実装しましたが今後GUI版を実装した時のためにビーコンを見張る処理はライブラリでプロジェクトを作成しています。

今回も妙に長くなってしまいました。次回でおそらく終わりです。