便利な解錠方法 - Widget解錠

この記事は Akerun Advent Calendar 2022 - Qiita の3日目の記事です。

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

日々いろんな解錠方法について考えています。特にiOSアプリで使えるいろんな便利な機能を使った解錠方法とその実装手順をまとめます。 今回はWidgetでの解錠について説明します。

Widgetとは

iOS13まではToday Extensionとして、ホーム画面を左にスワイプすると出てくる本体アプリのショートカット機能を提供するUIです。

iOS14からはWidgetKitとSwiftUIを使えば、iOS、iPadOS、macOS、watchOSで共通で利用できて、ホーム画面にも表示されるようになりました。 そして、iOS16からはiPhoneのロック画面や、watchOSのコンプリケーションにも表示できるようになりました。

https://support.apple.com/ja-jp/HT207122

Widgetは本体アプリと独立で表示されますが、groupキャッシュ領域を使えば本体アプリとデータを共有することが可能です。

Widgetから解錠

Akerunアプリの使用例として、合鍵をホーム画面あるいはロック画面に表示し、1Tapで解錠することが可能です。ホーム画面からアプリを探して起動し、また複数の合鍵から合鍵を探さなくて済みます。大分便利になります。

実装方法

UI実装

SwiftUIで実装する必要があります。

アプリにWidget ExtensionのTargetを追加

ホーム画面/ロック画面に表示するUIを実装(iOSの場合)

ホーム画面にはSmall、Medium、Largeの3種類サイズ、ロック画面にはInline、Circular、Rectangularの3種類サイズのWidgetが表示できます。

Widget表示サイズ種類を設定

@main
struct AkerunWidget: Widget {
    let kind: String = "AkerunWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
            AkerunWidgetEntryView(entry: entry)
        }
        // display name when add or edit widget
        .configurationDisplayName("Akerun")
        .description("ドアをウィジェットに追加すれば、素早く解錠することができます。")
        // ここにサポートするサイズの種類を設定
        .supportedFamilies([
            .accessoryCircular, .accessoryRectangular, .accessoryInline,
            .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge
        ])
    }
}

ホーム画面に表示されるSmall、Medium、LargeのWidgetのUIを作成

Smallサイズの場合、表示できる内容が限定されているので、シンプルに画像とドア名を表示し、Tapすると解錠できるようにします。 MediumとLargeはそれぞれSmallの2倍と4倍の表示領域があるので、Tapでの解錠だけではなく、入退室履歴を表示しても良さそうです。

以下はSmall時の表示実装になります。基本的にSwiftUIの各種StackでLayout調整すれば良いです。

struct DoorWidgetSmallView: View {
    var door: Door
    
    var body: some View {
        if #available(iOS 14.0, *) {
            VStack() {
                ZStack {
                    if let image = UIImage(contentsOfFile: door.imageUrlPath) {
                        Image(uiImage: image)
                            .resizable()
                            .frame(height: 84)
                            .cornerRadius(16)
                            .padding(.horizontal, 8)
                            .padding(.top, 8)
                            .aspectRatio(contentMode: .fit)
                    } else {
                        Image("door-image-entrance")
                            .resizable()
                            .frame(height: 84)
                            .cornerRadius(16)
                            .padding(.horizontal, 8)
                            .padding(.top, 8)
                            .aspectRatio(contentMode: .fit)
                    }
                }
                Spacer()
                VStack(alignment: .leading, spacing: 10) {
                    Text(door.name).font(.body)
                        .lineLimit(2)
                }
                .padding(.horizontal, 8)
                Spacer()
            }
            .widgetURL(URL(string: "akerunapp://widget/\(door.akerunId)"))
        } else {
            // Fallback on earlier versions
        }
    }
}

SmallとMediumのWidget表示は下記になります。

ロック画面に表示されるInline、Circular、RectangularのWidgetのUIを作成

Inlineは1行分の表示しかできないので、ドア名と状態(Icon)を表示します。 Circularは複数行表示できますが、Smallよりも小さいサイズになるので、2行でドア名と状態(Icon)を表示します。 ロック画面にCircularとRectangularを同時に表示できるので、Rectangularには最新の入退室履歴を表示します。

それぞれの実装は下記になります。

struct DoorWidgetInline: View {
    var body: some View {
        // 1行表示。Stackは反映されない
        HStack {
            Image(systemName: "lock")
                .resizable()
            Text("自宅(Akerun)")
                .font(.largeTitle)
                .multilineTextAlignment(.center)
        }
    }
}

struct DoorWidgetCircular: View {
    var body: some View {
        ZStack {
            Circle()
                .fill(Color.gray)
                .frame(width: .infinity)
            VStack {
                Image(systemName: "lock")
                Text("自宅")
                    .font(.headline)
                    .widgetAccentable()
            }
        }
    }
}

struct DoorWidgetRectangular: View {
    var door: Door
    var body: some View {
        HStack(spacing: 0) {
            VStack() {
                RoundedRectangle(cornerRadius: 0.75, style: .circular)
                    // 反映されない
//                    .fill(Color.green)
                    .frame(width: 2, height: .infinity)
            }
            .padding(.vertical, 4)
            VStack(alignment: .leading, spacing: 4) {
                HStack {
                    Text(door.name)
                        .font(.headline)
                        .widgetAccentable()
                    Spacer()
                    Text("施錠中")
                        .font(.body)
                        .widgetAccentable()
                }

                HStack {
                    Text(door.users.first ?? "")
                        .font(.headline)
                        .widgetAccentable()
                    Spacer()
                    Text("帰宅")
                        .font(.body)
                        .widgetAccentable()
                }
                
                HStack {
                    Spacer()
                    Text(Date.currentDateTime())
                        .font(.footnote)
                        .widgetAccentable()
                }
            }
            .padding(.horizontal, 4)
        }
    }
}

各種サイズのWidgetを表示

struct AkerunWidgetEntryView : View {
    // Viewの環境変数からどのサイズのWidgetを知ることができる
    @Environment(\.widgetFamily) var family: WidgetFamily
    
    var entry: Provider.Entry

    var body: some View {
        switch family {
        case .accessoryCircular:
            DoorWidgetCircular()
        case .accessoryRectangular:
            DoorWidgetRectangular(door: Door.homeDoor)
        case .accessoryInline:
            DoorWidgetInline()
        case .systemSmall:
            DoorWidgetSmallView(door: Door.tempDoor)
        case .systemMedium:
            DoorWidgetMediumView(door: Door.tempDoor)
        case .systemLarge:
            DoorWidgetLargeView(door1: Door.tempDoor, door2: Door.tempDoor2)
        case .systemExtraLarge:
            DoorWidgetExtraLargeView()
        @unknown default:
            fatalError()
        }
    }
}

Widget更新タイミング

下記のTimelineProviderでWidgetを追加する時、ホーム画面とロック画面表示するデータを取得します。

struct Provider: IntentTimelineProvider {
    // view生成する前に表示。データがない場合はダミーデータ表示?
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationIntent(), relevance: nil, door: Door.tempDoor)
    }

    // 一時表示用(Widget Galleryでのサンプル表示など)。
    // UserDefaultsなどのキャッシュから最新データを表示(通信など時間かかる処理は非推奨)
    func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let doors = self.getWidgetDoors()
        let entry = SimpleEntry(date: Date(), configuration: configuration, relevance: nil, door: doors.first ?? Door.emptyDoor)
        completion(entry)
    }

    // UserDefaults、Keychain、あるいはサーバーからデータを取得
    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        let door = self.getSelectedDoor(from: configuration)
        let entry = SimpleEntry(date: Date(), configuration: configuration, relevance: nil, door: door ?? Door.emptyDoor)
        entries.append(entry)

        let timeline = Timeline(entries: entries, policy: .never)
        completion(timeline)
    }
}

まとめ

iOS14からWidgetが大分作りやすくなりました。

Widgetに表示されるデータの取得と更新タイミングさえ制御できれば、SwiftUIを使ってWidgetのUIをシンプルに作れます。

Widgetデータの更新タイミングの詳細、WidgetをTapする時の処理、そしてWidgetのカスタマイズ(IntentConfiguration)について、また別の機会で書こうと思います。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proにご興味のある方はこちらから akerun.com

Web Bluetooth API で Akerun とサポートサイトの連携を検討する

こんにちは。FWチームのnaritakuです。

本ブログの過去記事でも紹介されている Web Bluetooth API は皆様ご存知でしょうか?

Web Bluetooth APIはこの記事を見ているようなWebブラウザ上で、Bluetooth通信ができる優れものです。 (アプリでご覧になられている方はごめんなさい 🙏)

弊社のサポートサイトはWebページですので、Web Bluetooth APIとの相性は本来とてもいいはずです。 普段の業務はAkerunプロダクト同士のBluetooth通信を担当していますが、この記事では、Web Bluetooth APIを使い、弊社製品とWEBブラウザとの通信について色々試した過程を紹介します。

Web Bluetooth API を使った開発を完全に理解する

Web Bluetooth API が使えるブラウザ

Web Bluetooth APIは一部のブラウザのみ対応しています。 現在の実装状況は githubなどで確認できます。

caniuse.com によると、2022年7月現在、利用可能なブラウザから見た世界のシェアは7割越えのようです。1

Chromeは動く。iOSはほぼ動かない。

通信相手となるデバイス

Bluetooth APIと銘打っているものの、Web Bluetooth API - Web APIs | MDNの記載には

The Web Bluetooth API provides the ability to connect and interact with Bluetooth Low Energy peripherals.

とあります。実際に通信できるデバイスBluetooth Low Energyによる通信ができるデバイスのうち、Peripheralの役割を持つものに限られています。2

Peripheral のデバイスとは

Bluetooth Low Energy (以下BLE) による通信では、GAP (Generic Access Profile) 、 GATT (Generic Attribute Profile) と呼ばれるプロファイルが広く利用されています。 PeripheralはGAPで定義された役割 (role) の1つで、次の2つの動作が可能なデバイスです。

  • 不特定多数のデバイスに対して自身の存在や固有のデータを一方的に発信する
  • 存在に気づいた他のデバイスと接続処理を行った後、GATTの仕様に則った双方向のデータ通信を行う

この辺りの優しめの解説は

などがあります。BLEの仕様がすべて分からなくても、Web Bluetooth APIは詳しい通信に関する処理はかなり隠してくれているので

の3つがイメージできれば大丈夫です。

バイス接続後のGATTの仕様については詳細や解説記事は山ほどあるのでここではドキュメントの紹介だけにとどめます。

Web Bluetooth API でのPeripheralデバイス操作

GATTではデバイスで定義された値を読み込んだり、書き換えたりすることでデータの受け渡しを行います。 デバイスはデータの受け渡し用の値を複数持つことができ、どの値を操作するかはサービスとキャラクタリスティックの2つの値で指定します。

Web Bluetooth APIを使ったデバイスへのデータの受け渡しには

  1. BLEデバイスの検索
  2. BLEデバイスとの接続
  3. サービスの選択
  4. キャラクタリスティックの選択
  5. 値の操作

の5ステップが必要です。

Web Bluetooth SamplesにはWeb Bluetooth APIのさまざまなデモが載っているため、サンプルの1つWeb Bluetooth / Battery Level (Async Await) SampleJavaScriptを例に、GATTの仕様に則ったデータ通信を紹介します。

Sampleコードの上記5ステップに対応した箇所へコメントを追加しました。デモでは値の読み取り後にバッテリー残量のログを出力する実装になってますね。

function onButtonClick() {
    //1. BLE デバイスの検索
    log("Requesting Bluetooth Device...");
    navigator.bluetooth
        .requestDevice({ filters: [{ services: ["battery_service"] }] })
        .then((device) => {
            //2. BLEデバイスとの接続
            log("Connecting to GATT Server...");
            return device.gatt.connect();
        })
        .then((server) => {
            //3. サービスの選択
            log("Getting Battery Service...");
            return server.getPrimaryService("battery_service");
        })
        .then((service) => {
            //4. キャラクタリスティックの選択
            log("Getting Battery Level Characteristic...");
            return service.getCharacteristic("battery_level");
        })
        .then((characteristic) => {
            //5. 値の操作
            log("Reading Battery Level...");
            return characteristic.readValue();
        })
        .then((value) => {
            // フロントエンドでよしなに扱う
            let batteryLevel = value.getUint8(0);
            log("> Battery Level is " + batteryLevel + "%");
        })
        .catch((error) => {
            log("Argh! " + error);
        });
}

このサンプルコードは5. 値の操作 にて値の読み取りを行っていますが、値の書き込み操作であるWriteやPeripheralデバイスからの定期的な通知を期待するNotifyによる操作も可能です。 下記のデモを参照すると基本的な通信の流れがより理解できるかと思います。

その他のデモも、デバイスとの接続状態や電波強度の取得など実用的なものが多くあるのでぜひみてみてください。

参考になる素敵なドキュメント/ツールたち

自分でもWeb Bluetooth APIで何か作ってみたいと思った時にオススメの資料です。

Akerun と通信させてみる

やりたいこと

昨年6月にAkerun Pro はバージョンアップしました。 このバージョンアップにより、ハードウェアのアップデートだけでなく、サポートページも新旧機種に対応するための更新がなされています。

この新旧機種の判別についてですが、現在のサポートサイトではカードリーダーの見た目やAkerun IDと呼ばれる番号で誘導するようになっています。 サポートサイトスクリーンショット Akerun サポートサイト より

Akerun IDの確認方法はいくつかありますが、本体の印字で確認するためには、デバイスを扉から取り外さないと見えない、という課題がありました。 幸い、とあるAkerunデバイスとの通信はWeb Bluetooth APIでも行えるため、 Web Bluetooth APIを使った新旧機種の判別と電池交換方法の解説ページへ画面遷移するデモを作ってみます。3

ソースコード

const AKERUN_NAME_PREFIX= "akerun_";
const AKERUN_SERVICE_UUID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";

class Akerun {
    constructor() {
        this.ble_device = null;
    }

    async ble_disconnect() {
        if (this.ble_device != null && this.ble_device.gatt.connected) {
            await this.ble_device.gatt.disconnect();
            this.ble_device = null;
        }
    }

    async ble_connect() {
        await this.ble_disconnect();
        this.ble_device = await navigator.bluetooth.requestDevice({
            filters: [{ namePrefix: AKERUN_NAME_PREFIX }],
            optionalServices: [AKERUN_SERVICE_UUID],
        });
        return await this.ble_device.gatt.connect();
    }

    async ble_read_generation(server) {
        if (server===null || ! this.ble_device.gatt.connected) {
            throw new Error("device is not connected");
        }
        return await /* GATTによる読み取り処理 */
    }

};

async function search() {
    const ak = new Akerun();
    return ak
        .ble_connect()
        .then((server) => ak.ble_read_generation(server))
        .then((ak_generation) => open_support_page(ak_generation))
        .catch((error) => {
            console.error(error);
            ak.ble_disconnect();
        });
}

function open_support_page(generation) {
    const support_url =
        generation === 2
            ? "https://support.akerun.com/hc/ja/articles/4402400483725"
            : "https://support.akerun.com/hc/ja/articles/115007327088";
    window.onbeforeunload = function (event) {
        event = event || window.event;
        event.returnValue = "電池を交換したいAkerunは光りましたか?";
    };
    window.location.href = support_url;
}

動作

旧機種での動作

新機種での動作

どちらのバージョンも問題なく動作しました 🎉

今後の課題

Akerunとサポートサイトに限らず、一般的なBLEデバイスでWeb Bluetooth APIを使ったサービスを提供するためにはいくつかのハードルがあります。

API の融通の効かなさ

今回のデモでAPIを利用して、下記の2点が気になりました。

  • ユーザーがデバイスを選択しないとGATT通信ができない
  • ペア設定するデバイスの候補を絞るフィルターが、表示名の前方一致または完全一致、サービスのUUIDの完全一致の3点しかない

ユーザー操作がないとデバイスの選択ができない問題は、プライバシー保護やセキュリティーの観点からある程度仕方がないです。 しかし、操作する分だけ1手間2手間増えてしまい、ユーザーの体験としてはアプリなどに比べ、利便性が低くなります。

またPhotosynth社内のようにAkerunがたくさんいる場所ではフィルターを相当うまくかけないと該当端末がどれだかよくわからなくなってしまう問題があります。

社内でのscan結果

ユーザーに混乱なく使ってもらうためには、デバイスに固有の名前またはサービスUUIDを検索し、スキャン結果には特定のデバイスだけ表示されるユースケースが望ましそうです。

GATT サーバーのアクセス先がわかってしまうリスク

BLEデバイスは、周囲にある不特定多数のデバイスがお互いの通信内容を受信することができ、他のデバイスの真似をすることで簡単にそのデバイスのふりをすることも可能です。 Web Bluetooth APIを使うと、Webページのソースコードから、人間の読みやすい形でGATT通信の値の操作がわかってしまいます。 それは本来想定されていない悪意あるデバイスがPeripheralデバイスとの通信を試みることを容易にしています。 BLE通信のリスクを正しく評価し、事前にGATTサーバーのアクセス先などがバレても安全な設計にしておく必要があります。 具体的な方法は述べませんが、 Akerunの製品群ではもちろん対策をしています。安心ですね。

対応していないブラウザもある問題

実際にサポートサイトへ導入したとしても、ユーザーのブラウザやそのバージョンによって利用の可否が分かれてしまいます。 動かない機能が表示されてしまうとユーザーやサポートサイトのオペレーターに思わぬ混乱を与えてしまうため、この点も検討する必要があります。

Web Bluetooth APIで提供されているメソッドnavigator.bluetooth.getAvailability() を使うことで、 Web Bluetooth APIの機能が使える人にだけに機能を表示することもできそうです。

まとめ

Web Bluetooth APIとAkerun製品を組み合わせてできることを調査、検証しました。

運用中の製品で実際に動くものが作れたことは嬉しいニュースでした。 しかし、本機能をお客様へリリースするためには、APIと弊社製品の歩み寄りが必要そうです。 Web Bluetooth API対応ブラウザや、新機能の追加など、今後の発展にも注目したいです。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proにご興味のある方はこちらから akerun.com


  1. 2022年6月のIEのサポート終了はWeb Bluetooth APIにとっての追い風になってますね。
  2. Peripheralの機能を一部制限した役割にあたるBroadcasterもいくつかの機能で利用できます。
  3. 弊社サポートサイトはChromeを推奨環境としているのでWeb Bluetooth APIはバッチリ使えます。相性が良かったです。

GitHubレポジトリのフォークグラフを描画する

この記事は Calendar for Akerun | Advent Calendar 2022 - Qiita の 1 日目の記事です。

どうもご無沙汰しております、今年も一番乗りの daikw - Qiita です。

皆さんは普段からレポジトリをフォークして過ごしていらっしゃると思いますが、ハードフォークが繰り返された結果なんだかよくわからなくなる仮想通貨のようなレポジトリを見つけたので、それで遊んでみたいと思います。

結論

レポジトリのフォークグラフを描きたいなら、 maliayas/github-network-ninja: A browser userscript to improve "GitHub network graph" pages. を使うのがベター。 ただしユーザスクリプトなので、中身を一応読んで使うと良い。

契機

驚くべきことに一般の非ソフトウェアエンジニアは普段フォークを使ってパスタを食べるようですが、我々ソフトウェアエンジニアはフォークを使ってレポジトリを食べることがままあります。 GitHub 上で MIT や BSD ライセンスの OSS をフォークで突いて、自分の組織で食べやすいようにするのです。

弊社でも Go で Bluetooth 通信を扱う際に、 npm ライブラリ bleno を参考実装とした paypal/gatt のフォーク Photosynth-inc/gatt を一部利用していました。

最後にメンテしてから随分経っていたので、その間により進んだ・代わりになるパッケージがないかを探していました。

Bluetooth 通信用パッケージの探索

pkg.go.dev でそれっぽいキーワードを使って探しましょう。

Bluetooth に関連するキーワードをざっくり上げると、

これらの単語で引っ掛けるとやたらとたくさん見つかるのですが、ほとんどがフォーク・被フォークの関係にあります。 インターフェースが全く異なるパッケージからそれぞれ一つずつ挙げてみると、代表的な 4 つ程度に絞れました。

また、GitHub 上ではフォーク元を手作業で辿ることができます。

試しに辿ってみると、フォークの深さが 3 くらいは当たり前のようです。これはけしからん。

  • bettercap/gatt -> cksmith/gatt -> currantlabs/gatt -> paypal/gatt
  • visago/ble -> go-ble/ble -> moogle19/ble -> currantlabs/ble

ぱっと見 bettercap/gatt が最新でメンテされているように見えますが、他のフォーク先に有用そうなものがあるかもしれません。

フォークグラフを描画するツールの探索

フォーク・被フォークの関係全体を俯瞰することで、有用そうなフォーク先を見つけることはできないでしょうか。

フォーク・被フォークの関係は、レポジトリをノードとした有向の単純グラフ(ループ・多重辺がない: グラフ理論 - Wikipedia)として記述できるはずです。これをフォークグラフと呼びましょう。

フォークグラフは普通にグラフなので、単純な隣接リスト(隣接リスト - Wikipedia)形式でも表現・描画できるし、少し工夫すればレポジトリのコミットグラフ同士で分岐と合流を描画できるような気がしますよね。

とここまで考えて、なんともけしからん Stack Overflow の質問を見つけました

git - How to get the full github.com visualization of the /network of a repo - Stack Overflow

付いている回答から抜粋すると、選択肢は大きく 3 つあります。比較してみましょう。

  1. network: 公式ツール( https://github.com/*/*/network )を使う
  2. githgraph-js-auto: 公式ツールの出力全体を表示するツールを使う
  3. github-network-ninja: 公式ツールにユーザスクリプトを追加して使う

1. network

  • GitHub が提供する機能として、レポジトリ間のフォーク・コミットグラフの比較ができる。グラフオブジェクトを生成し、フロントエンドではその一部を描画している
  • フロントエンドでは拡大された部分しか見れない
  • レポジトリ間でどういう違いがあるかはぱっと見わからない

GitHub Network機能

2. githgraph-js-auto

  • ブラウザ上での拡大・縮小はあまりできない
  • 保存した画像の端端を眺めると良い

githgraph-js-autoによる描画

3. github-network-ninja

  • スクロールはできないが、ドラッグ&ドロップで視点移動はできる
  • 一番使いやすそう
  • ユーザスクリプトなので、変なものが紛れていないかは注意する必要がある

github-network-ninja による GitHub network の拡張

どれもそれなりに使えそうですが、ディスプレイ全体にグラフを描画できる github-network-ninja が最も良さそうでしょう。

その他

GitHub の Network 機能

紹介したユーザスクリプト同様、 javascript で描画しているのですが、そのデータ構造が少し気になるところです。 デベロッパーコンソールから抜き出してみましょう。

デベロッパーコンソールのNetworkタブ

{"users":[{"name":"bettercap","repo":"gatt","heads":[{"name":"master","id":"df6e615f2f67bd19ca29e20f0f0895c3ff617519"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"connyay","repo":"gatt","heads":[{"name":"master","id":"7054a051ceea90cdf5a197d8bfb19fbe7c5f2f5c"}]},{"name":"gdetal","repo":"gatt","heads":[{"name":"master","id":"920a73725c15af2e81919e443520005ea3bd270b"}]},{"name":"Martichou","repo":"gatt","heads":[{"name":"master","id":"9b9c81c77507579d6b7bca7f625fb225c64fae00"}]},{"name":"myoung34","repo":"gatt","heads":[{"name":"master","id":"ce14497a0f8501960b435dd02f2028e63d073950"}]},{"name":"Jon-Bright","repo":"gatt","heads":[{"name":"master","id":"2b00d6e1b1eb3effa14b3d820eb1e438b1e0ccc1"},{"name":"implement-l2cap-connection-parameter-update","id":"2c2b16e9063bb92668e5d3cdd4699536223048c6"},{"name":"fix-l2cap-buffer-concurrency-bug","id":"0d6925c2c9c3e5ae294d28f7ea385137b134c737"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"lightblox","repo":"gatt","heads":[{"name":"master","id":"4684463605b5eb072c78613b32157dbd22063c9d"},{"name":"implement-l2cap-connection-parameter-update","id":"2c2b16e9063bb92668e5d3cdd4699536223048c6"},{"name":"fix-l2cap-buffer-concurrency-bug","id":"0d6925c2c9c3e5ae294d28f7ea385137b134c737"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"orca-io","repo":"gatt","heads":[{"name":"master","id":"06a4f48a47d92602d19e379a1d582fbf49c5d2df"},{"name":"fix-l2cap-segmentation-error-in-concurrent-subscription","id":"edd7ec2591f2d47ce6112300313236a5bc426f66"},{"name":"implement-l2cap-connection-parameter-update","id":"2c2b16e9063bb92668e5d3cdd4699536223048c6"},{"name":"fix-l2cap-buffer-concurrency-bug","id":"0d6925c2c9c3e5ae294d28f7ea385137b134c737"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"slingamn","repo":"gatt","heads":[{"name":"master","id":"d3edff1284fa514eafbbf9ccfa4ef03ee17df998"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"smartclean","repo":"gatt","heads":[{"name":"master","id":"c40c43bdab4e302def9d442fa4a919129e9810bd"}]},{"name":"photostorm","repo":"gatt","heads":[{"name":"master","id":"10c43527e6de94c253bf56964f39dbd348448d7c"},{"name":"working_mips","id":"efb0e52cc02fb314549f931e819fd740e9c98814"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"gofeel","repo":"gatt","heads":[{"name":"master","id":"5136ebfea072a4f9b2cde7cbfab18b71dfddc890"}]},{"name":"mwernsen","repo":"gatt","heads":[{"name":"master","id":"fda8164063d87d34cd2caef326fb93939f374b33"}]},{"name":"toddyco","repo":"gatt","heads":[{"name":"master","id":"053e6ec03cebdd2c0b49c832cb8f1cb75879fdf7"}]},{"name":"guozhaoyun","repo":"gatt","heads":[{"name":"master","id":"9976a0092426cfd9eef61ab2e3cc0f8100c76b23"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"davidoram","repo":"gatt","heads":[{"name":"master","id":"5776ec39d1bb911cf669038f4d2c6df9262e1142"}]},{"name":"tzachi-dar","repo":"gatt","heads":[{"name":"master","id":"76a9218a621cf2e4588f13ebfad516585e1b7f58"}]},{"name":"officebank","repo":"gatt","heads":[{"name":"master","id":"6f0b0013aa868b68ca8826c5d2bd6a8a2c9d48e5"}]},{"name":"QuantumIntegration","repo":"gatt","heads":[{"name":"master","id":"2cf5192549a309f8cece72adc55d3e658988372c"},{"name":"upstream_bugfix/handle-eitr-and-eagain","id":"122824a74ac4329063d311b5bfc77c5328d5222b"},{"name":"bugfix/handle-eintr-and-eagain","id":"2cf5192549a309f8cece72adc55d3e658988372c"},{"name":"feature/add_lnx_set_scan_parameters","id":"5e875372eb38a173430b5cfa2678e2480b0e85bd"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"jgulick48","repo":"gatt","heads":[{"name":"master","id":"fc7d135fda910bd9c7a401e4c59a1e2e66c36ad7"}]},{"name":"ans-net","repo":"gatt","heads":[{"name":"master","id":"af50721a4f4b5d37d94aeeda232631fd9ad1cb6a"}]},{"name":"burwei","repo":"gatt","heads":[{"name":"master","id":"f7022c6c348b6a6b56e234767c6af67767d8e352"},{"name":"service_conn_disconn_handler","id":"f7022c6c348b6a6b56e234767c6af67767d8e352"}]},{"name":"theatrus","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"hotfix-connection-param","id":"123e237178b3d9210f0e384b60ce7fe5a1d1d377"}]},{"name":"Plantiga","repo":"gatt","heads":[{"name":"master","id":"35f68ef2506de4c5d587084e1bc138160283d09d"}]},{"name":"mojiehai","repo":"gatt","heads":[{"name":"master","id":"d3d86d1875bc15a3d780fc412f0aab3f5550672a"}]},{"name":"sayzard","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"read_char_hnd","id":"008a97135c588ab0bbe7f769915e4ac55f8150fa"}]},{"name":"nxsre","repo":"gatt","heads":[{"name":"master","id":"7477f8f3e048b9475cb14250a17010b276b14bb5"}]},{"name":"yawkat","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"typeServiceData16","id":"b0a429f4e1a247b9747d287021c0067ac2665002"}]},{"name":"koppacetic","repo":"gatt","heads":[{"name":"master","id":"73b7de6e8694ecdcad8acb500e1e92802b854c9b"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"aetherbots","repo":"gatt","heads":[{"name":"master","id":"53d7636379a69ac846bdb27bf67b578b09bad1a4"}]},{"name":"majoyz","repo":"gatt","heads":[{"name":"master","id":"99e69fb67e18679496abcadfd4b3b848756bda44"}]},{"name":"dartharnold","repo":"gatt","heads":[{"name":"master","id":"ca47c9891598a2a18570e8e184f9876b27c1b01a"}]},{"name":"dki1110","repo":"gatt","heads":[{"name":"master","id":"45176d02139142f779eb78a9e64ec4fb0575871c"},{"name":"feature/improve-check-invalid-adv-data","id":"ffebe864a42e8f0787ca9289d527f03bf58c204a"}]},{"name":"XC-","repo":"gatt","heads":[{"name":"master","id":"1b393fb2cb2b72d10450c0e5db218af2cb1aa29f"},{"name":"general-fixes","id":"1b393fb2cb2b72d10450c0e5db218af2cb1aa29f"},{"name":"feat/switch-logger","id":"aae991cc9d0da570cd103a2feff1d3064fdb0b7b"}]},{"name":"fledsbo","repo":"gatt","heads":[{"name":"master","id":"06677047884cb7fa080a7203f311ecb72d3cc31d"}]},{"name":"freedreamer82","repo":"gatt","heads":[{"name":"master","id":"170a65ea1ba9f2ea97366527ad267c8e3a15981a"}]},{"name":"peknur","repo":"gatt","heads":[{"name":"master","id":"4702dffa772078e61ee0f897edb72ac5edc1df50"}]},{"name":"develersrl","repo":"gatt","heads":[{"name":"master","id":"27a6e456c692058c041a8ea7cf89c76adc5a845f"}]},{"name":"BG2BKK","repo":"gatt","heads":[{"name":"master","id":"5320610b739fa1bc12c9a0d61cd96b4348d97049"}]},{"name":"VictorZhucx","repo":"gatt","heads":[{"name":"master","id":"792b310add2be1d5f1e36240108b6ed0618a5b54"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"MelvinTo","repo":"gatt","heads":[{"name":"master","id":"f8ff2b30846bc9371905346bbe508715a54f1731"}]},{"name":"jagankg","repo":"gatt","heads":[{"name":"master","id":"f743166d720df214851c58f85c678d44285e3a4a"}]},{"name":"algirdasrascius","repo":"gatt","heads":[{"name":"master","id":"4d42460efce4a5f42f91689769c97b696095b89e"}]},{"name":"groove-x","repo":"gatt","heads":[{"name":"master","id":"7ac228a0458e7a97dfeca7d153f47738715457a4"}]},{"name":"janitha09","repo":"gatt","heads":[{"name":"master","id":"e264b757177308aea7b3c6431d096dea1a3163bf"}]},{"name":"PayRange","repo":"gatt","heads":[{"name":"master","id":"2046ed81c20cdd377472c54d5215da524ba135af"}]},{"name":"snap40","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"rename-package","id":"e96377864e53c3a5e124fd3df80df921961bf5a9"},{"name":"develop","id":"e96377864e53c3a5e124fd3df80df921961bf5a9"}]},{"name":"adamgalloway","repo":"gatt","heads":[{"name":"master","id":"beeeeaad2074856fe7058d0c01132bb5ee5bfc3c"}]},{"name":"tits4net","repo":"gatt","heads":[{"name":"master","id":"6ba63c628363b6f6243680db3fb2a9004300f834"}]},{"name":"fictivekin","repo":"gatt","heads":[{"name":"master","id":"35cf16ae21dc4c4c7f30384aff3dc9555332bf96"}]},{"name":"chetferry","repo":"gatt","heads":[{"name":"master","id":"2ee11b142100db29cfbb468fdecf39926d6bb757"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"orloc","repo":"gatt","heads":[{"name":"master","id":"2ae81e67dac112de01edb56f919eab5f9ca2ac59"}]},{"name":"cominging","repo":"gatt","heads":[{"name":"master","id":"c4ef6096dbdc8429f19a6f13b822b95ee743f88b"}]},{"name":"net20121222","repo":"gatt","heads":[{"name":"master","id":"2be21434ea6d5b0bf547534783ce95521f044249"}]},{"name":"dennisg","repo":"gatt","heads":[{"name":"master","id":"80b519f611d00be7994d11f935c53baaf5761422"}]},{"name":"ansoni-san","repo":"gatt","heads":[{"name":"master","id":"d5722fb2e5059f4586d4c28c4ae788420af5b67f"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"CodeLingoBot","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"rewrite","id":"b05567515937eac0e9c247c6d3746a4a431a44c6"}]},{"name":"ikhvostenkov","repo":"gatt","heads":[{"name":"master","id":"e4179cd42a8a1913384cf358c1a32933720a47ac"}]},{"name":"ebostijancic","repo":"gatt","heads":[{"name":"master","id":"53850ef6799d85c5ae40cf74bca65d8ef32af621"}]},{"name":"Seept","repo":"gatt","heads":[{"name":"master","id":"9546c8f21f7694202931030c6876d1be29775cb0"},{"name":"feature/SetNotifierCap","id":"7b2ba99f3e7869e0f5a62e6fc797ee37cea91318"},{"name":"hotfix/handleReadBlobX","id":"1668e3f305d11597cb5ab5b4bb12daf9d02b132a"},{"name":"hotfix/handleReadBlob","id":"690beaefc43a75a7b577da69e77444f85a6c856a"}]},{"name":"Photosynth-inc","repo":"gatt","heads":[{"name":"develop","id":"c0c453fd59c47df1002b80d9d597eeebc8555f3d"},{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"}]},{"name":"andreaaizza","repo":"gatt","heads":[{"name":"master","id":"31d001b99ed1b3a09a3312af83a6237ebea38f0a"}]},{"name":"leandroosalas","repo":"gatt","heads":[{"name":"master","id":"2f13b6b7890ba37738466dd955b4011aab9b5680"}]},{"name":"mihalicyn","repo":"gatt","heads":[{"name":"master","id":"97c2159f895149bbd2bfebd4366fe9caea72107f"},{"name":"add-power-off-event-mac","id":"d26bf6645c6759ae99815d5b39c35f5da44c9a58"},{"name":"fix-missing-peripheral-on-mac","id":"07d95875198f47163f5c199458a76a1cd999e218"},{"name":"fix-wrong-write-characteristic-id-on-mac","id":"eae0b149109c0355e1f7da0ac45d3247fc2db8e1"},{"name":"make-attribute-errors-public","id":"e27c33d1131d942676ced5c8840fc2dbc1d0cf0d"},{"name":"implement-error-handling-on-linux","id":"71213a77edbf1e4435d3e04dda7f5113a914bfe1"},{"name":"return-serve-write-errors-on-linux","id":"bfaeafe90955cec6b761e89e71a49cfafdd4e9bc"},{"name":"disable-unhandled-event-messages-on-mac","id":"0616d73ca2b7d1f5ac31507ed6a37f9393191d9c"},{"name":"return-serve-write-errors-on-mac","id":"87d749efe5b11db89d062daeb313066f520f8eb9"},{"name":"fix-cgo-pointer-issue-osx","id":"354fef990ee84fe0407bb400b8c80810e5760fc0"},{"name":"fix-string-descriptor-values-osx","id":"6140227796f3cf2b769c05fd932f7064c5e0c3d1"}]},{"name":"umitron","repo":"gatt","heads":[{"name":"master","id":"89e9fcdf09516914e2b97fb50a1cc4ca23ce937c"}]},{"name":"aJunKobayashi","repo":"gatt","heads":[{"name":"master","id":"e299bb75ee19f37f7c5f06c57fbe4a4bf9be7d13"},{"name":"disableAdvertiseEmit","id":"fc99ebd25e5e91133dd3d1669737fc9e0653518b"},{"name":"channelClose","id":"296c8e2c58e14f3e57844afb9f6bc9b8f3a422e3"},{"name":"tooManyPeriph","id":"a4b59f45d48342e9a493f14e3f19653643be5ef7"},{"name":"handleScanDataType","id":"0cc06717038a4fdba792053eac35a1e180e1c40b"},{"name":"littleScanNum","id":"eee3631701f17bb45763a841c56e9227b00556f0"},{"name":"fixLeakWhenWriteCmd","id":"ec5c3c3b0bd2bd9106a86a21827e7d485698b03a"},{"name":"decreaseLog","id":"38e79267eb2dacfe45f1b275090377d7537d489b"},{"name":"removeNeedlessErrorCheck","id":"788fce729a1678fd17a7afb45bf70e37313bdf2b"},{"name":"impLeCanncelConnect","id":"1ad68a27d05ed694d1849e2b90748afce988a87f"},{"name":"impLeCanncelConnect_backup","id":"f84ceff43ffcfa8f177538e9f7a2d3f29afd8c84"},{"name":"fixCrashInMap","id":"62eb2ab2d523dfd93bf512cfba6d26c9edea2bb7"},{"name":"fixNilChannelClose","id":"00cad6900513f68bff1b817fcc369f86e7f04b39"},{"name":"fixGoroutineLeak","id":"d8663791b61147f5212df5922f07e6ef76fb7761"},{"name":"fixGoroutineLeak_backup","id":"4cdadf93ca13378de28c64cdbf1b70d261c7780a"},{"name":"fixBlockingAfterDisconnect","id":"2b0d46c9f1c00eaa70ad77821f4b66a12a83e334"},{"name":"bufferSizeSmall","id":"50227e15f09ab7b5b9ed5fbd5d9e34cdb6b870b0"},{"name":"changeSupervisionTimeout","id":"3a08f267d9121525bdbd55643d83c22ab12bae17"},{"name":"FixCannotAdvertiseCollectly","id":"188e6996faa3b32d3808721ca28a1fb86dc1986e"},{"name":"FixCannotConnectTwice","id":"98861b7f9a1bab208ddfdf2bf114bbef6910f18b"},{"name":"fix_discover_crash","id":"dd8aca846346d8fd417e9a3be5c4bc4f417d3b3c"}]},{"name":"robstrong","repo":"gatt","heads":[{"name":"master","id":"ba7c25d39e1b71a5b3f3d83844a46ab8b88dbb34"}]},{"name":"hnzxmutex","repo":"gatt","heads":[{"name":"master","id":"48f1b9ca24e75b6011184fd3ed9967ebd0ac1a58"}]},{"name":"dsmcfarl","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"dsmcfarl","id":"5a934095723e273de332ff9f7b93a7b683ef4444"}]},{"name":"teaualune","repo":"gatt","heads":[{"name":"master","id":"891980b7d9b921cadde96ff8e810e65a9c53c092"}]},{"name":"NiklasMerz","repo":"gatt","heads":[{"name":"master","id":"65294c16f220d932062a68772731c4ada4a8254c"}]},{"name":"omenlabs","repo":"gatt","heads":[{"name":"master","id":"54c6c44751c9815be7c1c8b2f74efd1ad4a09b64"}]},{"name":"m-funky","repo":"gatt","heads":[{"name":"master","id":"3aa07920eb918f24c21c1bbd7397ae62332bce25"},{"name":"develop","id":"892d50ee96e9fe97a7c74c7bd401f14cd18029f3"}]},{"name":"Frontware","repo":"gatt","heads":[{"name":"master","id":"bfd324d5b611d2ff1a0ae4d9773d835b02696dc5"}]},{"name":"zobo","repo":"gatt","heads":[{"name":"master","id":"5599caec41e445003620debd4bf518d04f1c69dc"}]},{"name":"yangchengwork","repo":"gatt","heads":[{"name":"master","id":"e5725e9258105fffc428522afc4594a145c7679f"}]},{"name":"sapk-fork","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"test-miplant","id":"ec3b91d1f6d9bb2c9712dc99f2d559bdd9c79a6b"}]},{"name":"wowotech","repo":"gatt","heads":[{"name":"master","id":"942e7480ad1664dabacdb5561fa98a831621f7de"}]},{"name":"moguriso","repo":"gatt","heads":[{"name":"master","id":"6445bbbbd2959ff6531992ba575485a189aeced0"}]},{"name":"runtimeinc","repo":"gatt","heads":[{"name":"master","id":"a8b4c64987af1491ef629e5ec45d3fc47df29eb9"}]},{"name":"aYosukeAkatsuka","repo":"gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"for_openblocks","id":"80b4661003202d58dd42a07d8478e355021001e7"}]},{"name":"23critters","repo":"gatt","heads":[{"name":"master","id":"f05b4b5fcb57ea46c54d81ac660e3cc1c8f5bc21"},{"name":"fix-invalid-adv-panic","id":"5b6ca9572ff8edbec9ec588ff10b52018da9c020"},{"name":"lnx-api-rfc","id":"7c4885ce5333c097ef4f134742100c60045b8c24"},{"name":"lnx","id":"091fdb820064ea6cfc3884a0861197f20b2def81"}]},{"name":"rkravchik","repo":"paypal-gatt","heads":[{"name":"master","id":"4ae819d591cfc94c496c45deb55928469542beec"},{"name":"rkravchik-knownuuid","id":"e428041839a1f9920d8f42efb37017125108f349"}]},{"name":"greigdp","repo":"gatt","heads":[{"name":"master","id":"22104257989de6b49ae691c9ddf930490f394a7e"},{"name":"exper","id":"b79be93c86e2c70d34e990131ea8c9a6ae0f83b1"}]},{"name":"yene","repo":"gatt","heads":[{"name":"master","id":"a219237e157b98f02a7a750840403ed49d2de569"}]},{"name":"gambit-labs","repo":"gatt","heads":[{"name":"master","id":"dbcffcd7f06c3a3e9df99c8e91cb5da00459ee4f"}]},{"name":"potix","repo":"gatt","heads":[{"name":"master","id":"fb417a126bef1f2c11289ab730fed96952b18597"},{"name":"lnx-api-rfc","id":"7c4885ce5333c097ef4f134742100c60045b8c24"},{"name":"lnx","id":"091fdb820064ea6cfc3884a0861197f20b2def81"}]},{"name":"argon","repo":"gatt","heads":[{"name":"master","id":"12403b14f5958e6af38a0b884d06f5be1be57c41"},{"name":"fix-xpc-cgo","id":"12403b14f5958e6af38a0b884d06f5be1be57c41"}]},{"name":"mark2b","repo":"gatt","heads":[{"name":"master","id":"102ec671a298758d85dbb0e0947a982f805e8c4b"}]}],"dates":["2014-04-23","2014-09-15","2014-09-16","2014-11-25","2014-11-25","2014-11-25","2014-11-25","2014-11-25","2014-11-25","2014-12-05","2014-11-27","2014-11-27","2014-11-30","2014-11-30","2014-11-30","2014-11-30","2014-11-30","2014-11-30","2014-11-30","2014-12-04","2014-12-04","2014-12-04","2014-12-04","2014-12-04","2014-12-04","2014-12-04","2014-12-07","2014-12-07","2014-12-07","2014-12-18","2014-12-18","2014-12-18","2014-12-19","2014-12-19","2014-12-19","2015-01-11","2015-01-24","2015-02-08","2015-02-08","2015-02-08","2015-02-08","2015-02-08","2015-02-08","2015-02-16","2015-02-19","2015-02-20","2015-02-20","2015-02-14","2015-02-27","2015-02-27","2015-02-14","2015-02-14","2015-02-27","2015-02-27","2015-02-28","2015-02-14","2015-02-14","2015-02-14","2015-03-06","2015-02-27","2015-03-09","2015-03-09","2015-03-09","2015-03-09","2015-03-12","2015-03-12","2015-03-20","2015-03-20","2015-03-30","2015-03-29","2015-04-02","2015-04-22","2015-04-22","2015-04-22","2015-04-24","2015-04-24","2015-04-27","2015-04-27","2015-05-17","2015-05-18","2015-05-18","2015-05-20","2015-05-20","2015-05-20","2015-06-16","2015-07-04","2015-07-24","2015-07-24","2015-08-11","2015-08-19","2015-08-19","2015-08-31","2015-09-04","2015-09-10","2015-09-10","2015-09-10","2015-09-10","2015-09-16","2015-09-16","2015-10-11","2015-10-11","2015-10-11","2015-10-11","2015-10-11","2015-10-30","2015-12-30","2016-01-12","2016-01-12","2016-01-12","2016-01-12","2016-01-12","2016-01-12","2016-01-18","2016-01-18","2016-01-18","2016-01-18","2016-01-30","2016-02-01","2016-02-01","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-02-11","2016-03-28","2016-03-29","2016-03-31","2016-04-03","2016-04-24","2016-04-24","2016-04-24","2016-05-05","2016-05-06","2016-05-06","2016-05-06","2016-05-14","2016-06-09","2016-06-10","2016-06-10","2016-06-10","2016-06-10","2016-06-15","2016-06-15","2016-06-15","2016-06-29","2016-06-29","2016-07-06","2016-07-07","2016-07-07","2016-07-07","2016-07-07","2016-07-07","2016-07-07","2016-07-08","2016-07-11","2016-07-11","2016-07-13","2016-07-13","2016-07-14","2016-07-23","2016-08-13","2016-08-16","2016-08-18","2016-08-18","2016-10-06","2016-10-06","2016-10-10","2016-10-10","2016-10-11","2016-10-11","2017-02-23","2017-02-23","2017-03-04","2017-03-05","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-10","2017-05-22","2017-05-22","2017-05-22","2017-05-30","2017-06-14","2017-07-06","2017-07-07","2017-07-07","2017-07-07","2017-07-07","2017-07-11","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-17","2017-07-19","2017-07-19","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-26","2017-07-27","2017-07-27","2017-07-27","2017-07-27","2017-07-28","2017-07-28","2017-07-29","2017-07-29","2017-10-01","2017-10-01","2018-01-15","2018-01-15","2018-02-27","2018-03-12","2018-03-13","2018-04-17","2018-04-17","2018-04-29","2018-04-29","2018-04-29","2018-05-09","2018-05-09","2018-05-10","2018-05-24","2018-05-24","2018-05-24","2018-05-24","2018-06-08","2018-06-12","2018-06-13","2018-06-24","2018-07-06","2018-07-13","2018-07-13","2018-07-13","2018-07-13","2018-07-13","2018-07-13","2018-07-13","2018-07-23","2018-07-23","2018-07-23","2018-07-23","2018-07-23","2018-07-23","2018-07-31","2018-07-31","2018-07-31","2018-07-31","2018-08-01","2018-08-01","2018-08-01","2018-08-02","2018-08-06","2018-08-06","2018-08-06","2018-08-06","2018-08-06","2018-08-06","2018-08-06","2018-08-07","2018-08-07","2018-08-08","2018-08-08","2018-08-08","2018-08-10","2018-08-10","2018-08-10","2018-08-10","2018-08-10","2018-08-13","2018-08-13","2018-08-17","2018-08-17","2018-08-23","2018-08-23","2018-08-28","2018-08-28","2018-08-28","2018-08-28","2018-08-29","2018-09-10","2018-09-10","2018-09-10","2018-09-10","2018-09-10","2018-09-10","2018-09-10","2018-09-10","2018-09-10","2018-09-13","2018-09-13","2018-09-14","2018-09-14","2018-09-18","2018-09-18","2018-09-23","2018-10-08","2018-10-08","2018-10-08","2018-10-08","2018-11-20","2018-11-23","2018-11-23","2018-11-23","2019-01-03","2019-01-08","2019-01-15","2019-01-15","2019-01-17","2019-01-31","2019-01-31","2019-01-31","2019-01-31","2019-01-31","2019-02-11","2019-02-11","2019-02-11","2019-02-11","2019-02-11","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-14","2019-02-17","2019-02-25","2019-02-25","2019-03-13","2019-03-29","2019-03-29","2019-03-29","2019-03-29","2019-04-02","2019-04-02","2019-04-10","2019-04-18","2019-05-01","2019-05-14","2019-05-14","2019-05-14","2019-05-14","2019-05-26","2019-06-25","2019-06-26","2019-06-27","2019-06-29","2019-07-14","2019-07-14","2019-07-14","2019-07-14","2019-07-14","2019-07-23","2019-09-08","2019-09-08","2019-09-09","2019-09-10","2019-10-04","2019-10-13","2019-10-18","2019-10-26","2019-10-27","2019-11-08","2019-11-08","2019-11-09","2019-11-09","2019-11-11","2019-11-20","2019-11-26","2019-11-28","2019-11-28","2019-11-28","2019-11-29","2019-11-29","2019-11-29","2019-12-19","2019-12-21","2019-12-21","2019-12-21","2019-12-21","2019-12-21","2019-12-22","2020-01-23","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-13","2020-02-14","2020-02-20","2020-02-23","2020-02-25","2020-02-25","2020-02-25","2020-03-06","2020-04-13","2020-04-13","2020-04-13","2020-04-27","2020-05-02","2020-05-02","2020-05-02","2020-05-05","2020-05-05","2020-05-05","2020-05-05","2020-05-05","2020-05-05","2020-05-13","2020-05-13","2020-05-17","2020-05-17","2020-05-24","2020-05-26","2020-05-26","2020-05-26","2020-05-27","2020-06-04","2020-06-09","2020-06-21","2020-06-22","2020-06-25","2020-07-01","2020-07-02","2020-07-13","2020-07-13","2020-07-15","2020-07-15","2020-08-16","2020-08-17","2020-08-21","2020-08-21","2020-08-21","2020-10-17","2020-11-12","2020-11-12","2020-11-28","2020-12-10","2020-12-10","2020-12-10","2021-02-08","2021-02-08","2021-02-08","2021-02-22","2021-02-24","2021-03-23","2021-04-05","2021-04-12","2021-04-12","2021-04-14","2021-04-14","2021-04-21","2021-04-21","2021-04-22","2021-04-22","2021-05-12","2021-05-12","2021-05-13","2021-05-14","2021-05-14","2021-05-15","2021-06-15","2021-06-15","2021-06-15","2021-06-22","2021-06-22","2021-06-22","2021-06-22","2021-06-22","2021-06-22","2021-06-23","2021-07-01","2021-07-02","2021-07-02","2021-07-02","2021-07-04","2021-07-04","2021-07-04","2021-07-05","2021-07-05","2021-07-22","2021-07-22","2021-07-22","2021-09-04","2021-09-04","2021-09-26","2021-09-28","2021-09-29","2021-09-29","2021-09-29","2021-09-29","2021-10-02","2021-10-03","2021-10-03","2021-10-09","2021-10-09","2021-10-25","2021-11-01","2021-11-01","2021-11-01","2021-11-01","2022-02-03","2022-03-15","2022-03-15","2022-04-20","2022-04-20","2022-04-20","2022-07-11","2022-08-16","2022-08-18","2022-08-18","2022-08-29","2022-08-29","2022-08-29","2022-08-29","2022-10-28","2022-10-28"],"blocks":[{"name":"bettercap","start":0,"count":5},{"name":"connyay","start":5,"count":1},{"name":"gdetal","start":6,"count":1},{"name":"Martichou","start":7,"count":1},{"name":"myoung34","start":8,"count":1},{"name":"Jon-Bright","start":9,"count":3},{"name":"lightblox","start":12,"count":1},{"name":"orca-io","start":13,"count":2},{"name":"slingamn","start":15,"count":1},{"name":"smartclean","start":16,"count":1},{"name":"photostorm","start":17,"count":1},{"name":"gofeel","start":18,"count":1},{"name":"mwernsen","start":19,"count":1},{"name":"toddyco","start":20,"count":1},{"name":"guozhaoyun","start":21,"count":1},{"name":"davidoram","start":22,"count":1},{"name":"tzachi-dar","start":23,"count":1},{"name":"officebank","start":24,"count":1},{"name":"QuantumIntegration","start":25,"count":1},{"name":"jgulick48","start":26,"count":1},{"name":"ans-net","start":27,"count":1},{"name":"burwei","start":28,"count":1},{"name":"theatrus","start":29,"count":1},{"name":"Plantiga","start":30,"count":1},{"name":"mojiehai","start":31,"count":1},{"name":"sayzard","start":32,"count":1},{"name":"nxsre","start":33,"count":1},{"name":"yawkat","start":34,"count":1},{"name":"koppacetic","start":35,"count":1},{"name":"aetherbots","start":36,"count":1},{"name":"majoyz","start":37,"count":1},{"name":"dartharnold","start":38,"count":1},{"name":"dki1110","start":39,"count":1},{"name":"XC-","start":40,"count":1},{"name":"fledsbo","start":41,"count":1},{"name":"freedreamer82","start":42,"count":1},{"name":"peknur","start":43,"count":1},{"name":"develersrl","start":44,"count":1},{"name":"BG2BKK","start":45,"count":1},{"name":"VictorZhucx","start":46,"count":1},{"name":"MelvinTo","start":47,"count":1},{"name":"jagankg","start":48,"count":1},{"name":"algirdasrascius","start":49,"count":1},{"name":"groove-x","start":50,"count":1},{"name":"janitha09","start":51,"count":1},{"name":"PayRange","start":52,"count":1},{"name":"snap40","start":53,"count":1},{"name":"adamgalloway","start":54,"count":1},{"name":"tits4net","start":55,"count":1},{"name":"fictivekin","start":56,"count":1},{"name":"chetferry","start":57,"count":1},{"name":"orloc","start":58,"count":1},{"name":"cominging","start":59,"count":1},{"name":"net20121222","start":60,"count":1},{"name":"dennisg","start":61,"count":1},{"name":"ansoni-san","start":62,"count":1},{"name":"CodeLingoBot","start":63,"count":1},{"name":"ikhvostenkov","start":64,"count":1},{"name":"ebostijancic","start":65,"count":1},{"name":"Seept","start":66,"count":2},{"name":"Photosynth-inc","start":68,"count":1},{"name":"andreaaizza","start":69,"count":1},{"name":"leandroosalas","start":70,"count":1},{"name":"mihalicyn","start":71,"count":1},{"name":"umitron","start":72,"count":2},{"name":"aJunKobayashi","start":74,"count":4},{"name":"robstrong","start":78,"count":1},{"name":"hnzxmutex","start":79,"count":1},{"name":"dsmcfarl","start":80,"count":1},{"name":"teaualune","start":81,"count":1},{"name":"NiklasMerz","start":82,"count":1},{"name":"omenlabs","start":83,"count":1},{"name":"m-funky","start":84,"count":1},{"name":"Frontware","start":85,"count":1},{"name":"zobo","start":86,"count":1},{"name":"yangchengwork","start":87,"count":1},{"name":"sapk-fork","start":88,"count":1},{"name":"wowotech","start":89,"count":1},{"name":"moguriso","start":90,"count":1},{"name":"runtimeinc","start":91,"count":2},{"name":"aYosukeAkatsuka","start":93,"count":1},{"name":"23critters","start":94,"count":1},{"name":"rkravchik","start":95,"count":1},{"name":"greigdp","start":96,"count":2},{"name":"yene","start":98,"count":1},{"name":"gambit-labs","start":99,"count":1},{"name":"potix","start":100,"count":1},{"name":"argon","start":101,"count":1},{"name":"mark2b","start":102,"count":1}],"focus":523,"nethash":"9b24459c792df680765ebafe1ede9acf6895970889f8aec23a57dbeaecaceee4","spacemap":[[[0,523]],[[512,523],[418,509],[413,415],[395,412],[269,359],[262,266],[257,259],[253,255],[249,251],[239,246],[170,213],[157,166],[92,95],[83,90],[77,80],[73,75],[67,69],[63,65],[58,62],[36,54],[29,33],[0,9]],[[512,522],[413,418],[266,269],[255,257],[251,253],[246,249],[239,245],[170,211],[90,92],[87,89],[77,83],[65,67],[36,58],[9,29]],[[239,244],[166,170],[85,87],[70,77],[34,36]],[[213,239]],[[103,580]],[[103,578]],[[103,574]],[[103,572]],[[523,542]],[[523,543]],[[523,540]],[[542,554]],[[554,570]],[[554,568]],[[523,534]],[[103,567]],[[465,565]],[[103,564]],[[170,560]],[[103,559]],[[523,555]],[[103,550]],[[103,527]],[[103,499]],[[515,517]],[[103,514]],[[103,506]],[[103,503]],[[103,497]],[[103,496]],[[103,493]],[[103,491]],[[103,485]],[[103,484]],[[418,482]],[[103,481]],[[103,480]],[[103,476]],[[103,475]],[[433,459]],[[103,471]],[[103,462]],[[103,458]],[[103,457]],[[103,454]],[[418,453]],[[103,441]],[[103,439]],[[103,430]],[[103,400]],[[103,427]],[[103,425]],[[103,411]],[[103,410]],[[103,405]],[[103,404]],[[359,401]],[[103,396]],[[103,394]],[[103,393]],[[103,390]],[[383,386]],[[103,384]],[[103,369]],[[103,361]],[[103,358]],[[354,358],[103,352]],[[103,356]],[[206,355]],[[103,351]],[[269,346]],[[103,345]],[[341,345]],[[103,343]],[[339,343],[328,330],[325,327],[321,323],[314,319],[309,311],[298,302],[288,296],[284,286]],[[330,339],[323,325],[319,321],[311,314],[302,309],[296,298],[286,288]],[[330,337],[314,317],[302,307]],[[103,280]],[[103,279]],[[103,277]],[[269,276]],[[103,272]],[[170,271]],[[103,261]],[[103,237]],[[103,240]],[[103,208]],[[103,207]],[[103,178]],[[103,176]],[[103,174]],[[163,174]],[[91,168]],[[115,128]],[[103,164]],[[118,162]],[[118,130]],[[145,150]],[[103,148]],[[115,140]],[[103,139]],[[103,132]]]}

構造がパッと分かりにくいのでキーを抜き出してみると、

┬─[daiki~@photosynt~:~/t/advent-calender][00:54:44]
╰─>$ cat github-network | jq '. | keys'
[
  "blocks",
  "dates",
  "focus",
  "nethash",
  "spacemap",
  "users"
]

メタ情報の入ったフィールドと、

┬─[daiki~@photosynt~:~/t/advent-calender][00:57:45]
╰─>$ cat github-network | jq '.users' | head
[
  {
    "name": "bettercap",
    "repo": "gatt",
    "heads": [
      {
        "name": "master",
        "id": "df6e615f2f67bd19ca29e20f0f0895c3ff617519"
      },
      {

隣接リストっぽいデータと入っているのが確認できますね。

┬─[daiki~@photosynt~:~/t/advent-calender][00:57:56]
╰─>$ cat github-network | jq '.spacemap' | head -n 20
[
  [
    [
      0,
      523
    ]
  ],
  [
    [
      512,
      523
    ],
    [
      418,
      509
    ],
    [
      413,
      415
    ],

bettercap

実は bettercap はそれはそれでけしからんツールでして、いわゆる 中間者攻撃 - Wikipedia に使われるものであります。

ettercapと違い BLE やその他の無線通信もキャプチャできるように作られているため、そのためのミドルウェアとして bettercap/gatt を作り込んでいるようでした。僕もこういうものを作りたいなと感じますね。

The Swiss Army knife for 802.11, BLE, IPv4 and IPv6 networks reconnaissance and MITM attacks.

あとがき

OSS へのフリーライドするくらいなら自分で作り直したい気持ちと、仮に製品で利用されるとしたら広くコミュニティで利用されてバグの出切ったパッケージを使うべきだろうという気持ちと、アンビバレントな 1 年を過ごした daikw でした。

参考記事


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Pro の購入はこちらから akerun.com

pythonでAkerunコントローラーのブザー音を周波数解析する

新卒FWエンジニアのnaritakuです。

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 10 日目の記事です。

弊社製品Akeurnコントローラーはブザーを内蔵しているのですが、新規FWの評価中にbeep音を出して再起動をする場面に遭遇しました。

ソースコードからではなく、評価時の動作からデバッグすることもあるのですが、この記事では新年の寅年にあやかり、たまたま録音していた当時の音をスペクトラムに変換し、再起動直前になっていたbeep音の周波数の特定をします。

目次

解析する環境の準備

iPhoneのボイスメモで録音したところ.m4a拡張子で、1チャンネルの音源が記録されていました。

解析する音源

普段の組み込み開発はc言語javascriptなどを使いますが、簡単に解析することを目標に、本記事ではpythonで実施します。 google colabratoryを使うとセットアップもほとんどせずに解析できます

colabratory内ではGoogleDriveにアップロードした音源の利用やpipでのライブラリのインストールもできます。

from google.colab import drive
drive.mount('/content/drive')
!pip install pydub

スペクトラムの表示

scipyのsignal.spectrogram()を使ってスペクトラムに変換します。

窓関数はピークの周波数を精度よく出したいため、ハミング関数を使います。

サンプリング数Nが2のべき乗であることで高速にフーリエ変換できます。 今回はN=1024でのFFTをSTEP(=100)データずつずらしながら実施します。

from pydub import AudioSegment
from scipy import signal
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize

def loadAudio(filePath):
  audio = AudioSegment.from_file(filePath, "m4a")
  print("チャンネル数",audio.channels)
  print("サンプリング周波数",audio.frame_rate,"[Hz]")
  print("再生時間",audio.duration_seconds,"[s]")
  #1chのみの音源を取り出す
  samples = np.array(audio.get_array_of_samples())
  sample = samples[::audio.channels]
  return sample,audio.frame_rate


sound,frameRate = loadAudio("/content/drive/MyDrive/CTL.m4a")

N=1024
step=100

freqs, times, Sx = signal.spectrogram(sound, fs=frameRate, window='hamming',nperseg=N, noverlap=N-step,detrend=False, scaling='spectrum') # スペクトログラム変数


plt.figure()
f,ax = plt.subplots(figsize=(12,8))
im=ax.pcolormesh(times, freqs/1000, 10* np.log10(Sx), cmap='magma',norm=Normalize(vmin=-60, vmax=60))

ax.set_yscale('log')
ax.set_ylim(0.1,24)
ax.set_ylabel('Frequency[kHz]')
ax.set_xlabel('Time[s]')

pp=plt.colorbar(im)
plt.show()
チャンネル数 1
サンプリング周波数 48000 [Hz]
再生時間 4.378729166666667 [s]

f:id:photosynth-inc:20220104033319p:plain
beep音を含む録音データのスペクトラム

全体的に低周波の音が強くなっていますが、beep音の出ている3.0[s]近辺では特定の周波数の音が大きくなっていることが確認できます。

beep音のみのデータにトリミングして再度FFTを行い、上記の特定の周波数について求めていきます

beep音のみの解析

beep音が再生されている時間のみにトリミングしたデータを使います。

音源

Beep音を矩形窓とハミング窓を使って切り取りフーリエ変換します。

from pydub import AudioSegment
from scipy import signal
import numpy as np
import matplotlib.pyplot as plt


def loadAudio(filePath):
  audio = AudioSegment.from_file(filePath, "m4a")
  print("チャンネル数",audio.channels)
  print("サンプリング周波数",audio.frame_rate,"[Hz]")
  print("再生時間",audio.duration_seconds,"[s]")
  #1chのみの音源を取り出す
  samples = np.array(audio.get_array_of_samples())
  sample = samples[::audio.channels]
  return sample,audio.frame_rate

def fft(sample,frameRate):
  # 音声全体を1つの波形としてフーリエ変換
  sp = np.fft.fft(sample)
  f = np.fft.fftfreq(sample.shape[0], 1.0/frameRate)
  # 正の周波数部分だけにする
  f= f[:f.shape[0]//2]
  sp = sp[:sp.shape[0]//2]
  sp[0] = sp[0] / 2
  magnitude=np.abs(sp)
  return f,magnitude


# フーリエ変換する波形の表示

sample,frameRate=loadAudio("/content/drive/MyDrive/beep.m4a")

N=len(sample)
time=[i/frameRate for i in range(N)]
hamming=np.hamming(N) * sample

plt.figure(figsize=(16,16))
plt.subplot(2,1,1)
plt.plot(time,sample,label='Rectangular window')
plt.plot(time,hamming,label='Hamming window')
plt.xlabel('time [s]')
plt.ylabel('amplitude')
plt.legend()

# フーリエ変換とピークとなる周波数の表示
f,magnitude=fft(sample,frameRate)
f_h,magnitude_h=fft(hamming,frameRate)
maxi = signal.argrelmax(magnitude, order=100)
maxi_h = signal.argrelmax(magnitude_h, order=100)

plt.subplot(2,1,2)
plt.plot(f, magnitude)
plt.plot(f_h, magnitude_h)
plt.plot(f[maxi[0]],magnitude[maxi[0]],'ro',label='peak(origin)')
plt.plot(f_h[maxi_h[0]],magnitude_h[maxi_h[0]],'gx',label='peak(hamming)')
plt.yscale("log")
plt.xlabel('frequency [Hz]')
plt.ylabel('magnitude')
plt.legend()
チャンネル数 1
サンプリング周波数 48000 [Hz]
再生時間 0.18695833333333334 [s]

f:id:photosynth-inc:20220104042313p:plain
beep音の周波数解析

極大値かつ前後100点の中で最大値を取るものについて 短形窓での極大値を赤丸、ハミング窓での極大値を緑Xでプロットすると、0Hzから16000[Hz]まではどちらも同じ周波数でピークが出ており、ピークとなる周波数ががほぼ等間隔になりました。 1100[Hz]あたりの音がブザーで鳴らしている周波数、それ以降のピークはブザーの倍音によるものである仮定のもと、ブザーの出力している周波数を推定します。

最小二乗法で周波数を推定する

求めたい周波数aのx倍の倍音の周波数がyとなる関数 y=ax についてaの値を最小二乗法で求めます。 サンプルとして使う点は(x,y)=(0,0)と上記のグラフの赤丸でプロットされた2~13番目のピークの値とします。 (x,y)=(0,0)を使うのは0倍の時に0 [Hz]であることを期待しているためです。

# FFTの極大値から周波数を推定する

y=np.append(0, f[maxi[0][1:14]])
x=np.arange(len(y))
# y=mxとして最小二乗法
A = np.vstack([x, np.zeros(len(x))]).T
m, _ = np.linalg.lstsq(A, y, rcond=None)[0]


plt.plot(x,y,'gx',label='peaks of FFT(Rectangular window)')
plt.plot(x,m*x,label='Approximate line by least squares method')
plt.plot(1,m,'ro',label='Estimated frequency')
plt.xlabel('scale of tone')
plt.ylabel('frequency [Hz]')

plt.legend()

print("最小二乗法で推定した周波数", m,"[Hz]")
最小二乗法で推定した周波数 1187.9528242354183 [Hz]

f:id:photosynth-inc:20220104035206p:plain
周波数の推定

グラフを見ても綺麗に倍音でピークになっていそうです。 推定できた周波数は約1187[Hz]でしたが、Akerunコントローラーが出力しようとしていた音階を推測します。

どの程度正しい周波数なのか

かなりいい加減な解析をしているので、ピッタリ1187[Hz]の音源ではないはずです。 現時点でどの程度信用できる周波数だったのか考えておきます。

ピークとして使った周波数の精度

今回のデータでは8974点のフーリエ変換を行なっていますが、 サンプリング周波数48000[Hz]であるために、\frac{48000i}{8974} (i=0,1,2,...4987)[Hz]の波に変換しています。 今回はピークとして使った周波数も約5.34[Hz]ごとに離散化されているため、本来の再生される周波数とたまたまサンプリングに使われる周波数が合致しなければ本来の周波数との誤差が生まれてしまいます。 申し訳程度に最小二乗法でこの誤差が小さくなることを期待していますが、ピークとして使った周波数にすら信用できる区間が約5.34[Hz]の幅があることを念頭に入れておきましょう。

音階とその周波数

12平均律では $2^{\sqrt{12}}$ 倍の周波数で次の音階が得られるため、 440[Hz] を ラ/A4 としたとき

  • ド♯/C♯6の周波数は約1109[Hz]
  • レ/D6の周波数は約1175[Hz]
  • レ♯/D♯6の周波数は約1245[Hz]

となります。

今回推定したピークとは10Hzほど低いものの、人間の耳ではbeep音はD6の音として聞こえることが想定され、当時slackで即レスをくれた音楽の得意なエンジニアと同じ結論になりました。これらをもとに、デバッグすることができそうですね。

f:id:photosynth-inc:20220109112402p:plain

参考


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

nRF52 DK を使ったノイズ除去の練習

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 24 日目の記事です1

本 Advent Calendar 2度目の登場となる tarotene - Qiita です.先日は「組み込みエンジニアのための徒手空拳のすゝめ」という記事でかなりポエミーな内容をぶっ放したので,今回は地に足のついた内容でお送りします.

akerun.hateblo.jp


チャタリング・ノイズ・ディレイ...

突然ですが,読者の皆さんはボタンのついたデバイスを日常的にお使いかと思います.PC のマウスやキーボード然り,ゲームのコントローラー然り,何らかの ON/OFF 判定を伴う動作にボタンは欠かせないモノです.

こういったボタンはタクトスイッチという電子部品で実装されていることが多いでしょう2:

akizukidenshi.com

タクトスイッチは,触れていない時には基本 OFF(接点開放)の状態で,押し込んだ瞬間だけ ON(接点短絡)と判定されるように作られています.こういった判定方式を特別に A 接点と呼んだりします.逆に,押し込んだ瞬間だけ OFF と判定する方式は B 接点と呼ばれています:

www.fa.omron.co.jp

そして,デジタル電子回路や MCU においてボタンの ON/OFF が常に意図通りのデジタル入力となってくれれば良いのですが,現実にはそうはいきません.というのは,そもそも世の中のデジタル信号はデジタルとして解釈するためのルールが設けられたアナログ信号に過ぎず,開放/短絡の間にはかならず中間の状態が存在するからです3

スイッチの ON/OFF も御多分に洩れず中間状態をとります.よくあるのが,接点そのものの跳ね(bouncing)に伴ってデジタル信号が HIGH/LOW を行ったり来たりするチャタリングと呼ばれる現象です.これは,考えられうる最もシンプルな設計のメカニカルスイッチでは不可避な現象です.こうした現象を回避するのに,例えば水銀リレーなどでは開放(短絡)->短絡(開放)の状態遷移時に接点そのものが張り付いて跳ねないような設計が採用されています:

www.sanyu.co.jp

また,こうした現象を回路レベルで取り除くためにコンデンサと抵抗を加えたり(CR と呼ばれる)やシュミットトリガと呼ばれるヒステリシスを持つ A/D 変換器を用いたりすることもあります:

toshiba.semicon-storage.com

チャタリングを接点レベルや回路レベルで除去できるのだから,当然ソフトウェアレベルでもできるだろうと期待したあなた,その通りです.

実際,世の中の電子機器では回路(というかハードウェア全体)とソフトウェアそれぞれにかかる要件と相談しながらこの手のあるある問題を解決していきます.例えば,回路のレイアウト制約が大きければソフトウェアに一任,ハード・ソフト両方に潤沢なリソースがあって要件も緩ければ両方で対応,逆にソフトウェアのデリバリーが間に合わない(あるいはできない)ことが最初からわかっていればハードウェアだけで済ませる,といった具合です.世の中の電子機器の設計は残念ながらそのほとんどが非公開なので想像の域を出ませんが,民生機器のレベルだとソフトウェアだけでチャタリング除去することが多いと聞きます4

本記事では,実在のマイコンを使ってソフトウェアだけでチャタリング除去を(擬似的に)行う様子をコード例とともに示していきます.

用いたマイコンは nRF52 DK(正式名 PCA10040)という評価ボードです.

www.nordicsemi.com

この評価ボードは元々 nRF52832 と呼ばれる BLE チップを評価するためのものですが,

  • 偶然手元にあった
  • ボタンと LED が 4 つずつ付いている

という理由で今回の実験に採用します.

作る機能は,

  • ボタンを押下(リリース)すると一定の遅延時間の後に LED が点灯(消灯)する

というシンプルなものですが,採用するアルゴリズムが割とこの手のコードにあるあるなので事前に簡単な説明を入れます.

なお,今回は工数の都合で機能的な振る舞いにフォーカスするためにコードの可読性とかメンテナンス性には目を瞑ろうと思います5

ホールド & カウントアップ

チャタリング対策の本質を考えると,最終的にはボタンの ON/OFF 判定における偽陽性偽陰性を減らすという所に行き着くと思います.

ここで言う偽陽性偽陰性とはすなわち,

  • 偽陽性: 押してもいないボタンを押したことにされること
  • 偽陰性: ボタンを押したにも関わらずボタンを押したことにされないこと

です.前者の偽陽性は,そもそものチャタリング対策のニーズと直結しているので今更議論は必要ないでしょう.そして,後者の偽陰性アルゴリズム停止性に関わる大事な問題です:

qiita.com

これらの要件を勘案すると,どんな実装でも下記の 2 つのイベントを考慮することになりそうです:

  • 仮判定: チャタリングでもノイズでも何でもとりあえず入力としてキャッチし,後段の実判定に渡す
  • 実判定: 仮判定のイベントから有限の時間で本当に入力があったかどうかを通知する

一般に,マイコンではハードリアルタイム要求を守らせるような実装が可能で,逆に手を緩めることでソフトリアルタイム要求だけ充足,スループット要求だけ充足,とパフォーマンスを変化させることもできます:

www.fe-siken.com

つまり,仮判定と実判定のそれぞれに対して応答のリアルタイム性を非機能要求として課すことで最終的な実装が決まります6

ここでは,ベアメタル(RTOS を用いない実装)で動く最大限のリアルタイム性を保証するアルゴリズム---ホールド & カウントアップ---をいきなり紹介します.

と進んでいき,最後に完全なコード例(動作確認済)を示します.

main() の実装

まずはモノから:

#include "boards.h"
#include "bsp.h"
#include "nrf_drv_gpiote.h"

#include "nrf_log.h"
#include "nrf_log_ctrl.h"
#include "nrf_log_default_backends.h"

#include "app_timer.h"
#include "nrf_drv_clock.h"

APP_TIMER_DEF(m_repeated_timer_id);     /**< Handler for repeated timer used to blink LED 1. */

int main(void)
{
    NRF_LOG_INIT(NULL);
    NRF_LOG_DEFAULT_BACKENDS_INIT();

    nrf_drv_clock_init();
    nrf_drv_clock_lfclk_request(NULL);

    nrf_drv_gpiote_init();

    nrf_gpio_cfg_output(LED_1);
    nrf_drv_gpiote_out_set(LED_1);

    nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
    button_1_config.pull = NRF_GPIO_PIN_PULLUP;

    nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);

    nrf_drv_gpiote_in_event_enable(BUTTON_1, true);

    app_timer_init();
    app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);

    m_button_1 = nrf_gpio_pin_read(BUTTON_1);
    
    
    // Enter main loop.
    while (true)
    {
        __WFI();
    }
}

まだこれだけではコンパイルが通りませんが,重要な概念がいくつかあります.まず,nRF5 SDK で利用可能なアプリケーションタイマ,そして GPIOTE の 2 つです.

アプリケーションタイマは,起動用のイベントを投げると有限の整数値にセットされたカウンタが一定のペースでデクリメントされ,値が 0 になる(expire する)とユーザ定義のイベントを投げるといった機能を提供します.nRF5 SDK では app_timer_***() といった関数で提供され,#include "app_timer.h" で利用できるようになります.expire 後の挙動としては再度カウンタを元の整数値にセットし直しタイマを起動させっぱなし,そのままタイマを停止させるの 2 択です.今回みたいに周期的にイベントを投げるタイマを利用したかったら前者が妥当ですが,場合によっては後者も使います.

GPIOTE は詳しくは後述しますが,対象の入力ピン(今回だとボタンに直結した GPIO ピン)の状態変化を直接イベントハンドラに繋げる機能です7.これを制御・管理するために一連の HAL ライブラリ関数 nrf_drv_gpiote_***() が提供されています.割り込みベクタを直接消費する機能っぽいので,当然ながら登録可能なピン数は有限で,実際のピン数よりずっと少ないです.なので,たくさんの入出力を一手に担いながらリアルタイム処理を行う製品を作ろうと思ったら全部 GPOITE 任せというのは土台無理で,ここに工夫が求められます.

やろうとしていることは,ざっくり

  • ボタン入力に対応する GPIO ピンを GPIOTE に登録し,常時監視.
    • 実現方法: SDK 側の GPIOTE 用ユーティリティ関数を使用.
  • GPIOTE は状態変化を検知したら周期タイマを起動する.
  • 周期タイマは,カウンタ expire 時に対象の GPIO ピンをチェック.
    • 実現方法: 周期タイマ起動時に GPIO ピンをチェックする関数(タイマイベントハンドラ)を登録.

です.ここで,タイマイベントハンドラには

  • GPIOTE が検知した状態変化がそのまま一定時間に渡って保持されていれば正式に受理
  • それ以外の場合は拒否し,タイマを停止

という仕様を守らせることにします8

これによって前述の仮判定から実判定までがスッと繋がる実装になります.これに加えて

  • 周期タイマを呼ばせる周期(msec 単位)と呼ばせる最大回数

を決めてあげると非機能的な部分も含めて仕様が完成します.

なので, main() 内部の処理としてはざっくり,

  1. クロックドライバの初期化: nrf_drv_clock_init();
  2. クロックインスタンスの生成: nrf_drv_clock_lfclk_request(NULL);
  3. GPIOTE ドライバの初期化: nrf_drv_gpiote_init();
  4. GPIOTE インスタンスの生成: nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);
  5. GPIOTE インスタンスの有効化: nrf_drv_gpiote_in_event_enable(BUTTON_1, true);
  6. タイマの初期化: app_timer_init();
  7. タイマインスタンスの生成: app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);

という順になります.

button_1_toggle_handler および repeated_timer_handler はそれぞれこの後実装するボタンイベントハンドラタイマイベントハンドラへの関数ポインタです.

補足の処理として

  • タイマインスタンスの ID 宣言: APP_TIMER_DEF(m_repeated_timer_id); /**< Handler for repeated timer used to blink LED 1. */
  • 監視対象の GPIOTE ピンのプルアップ指定:
    1. nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
    2. button_1_config.pull = NRF_GPIO_PIN_PULLUP;
  • 実判定用の変数の初期化: m_button_1 = nrf_gpio_pin_read(BUTTON_1);

があるというイメージです.ここで,

  • m_repeated_timer_id: タイマインスタンスの ID
  • APP_TIMER_MODE_REPEATED: タイマがイベントを投げるタイミングを周期的にするための設定子マクロ

で,実際の処理対象である LED の点灯・消灯用ピンの初期化は

  1. nrf_gpio_cfg_output(LED_1);
  2. nrf_drv_gpiote_out_set(LED_1);

で行います.ビルドが通るためにはヘッダファイルのインクルードに加えてコンパイル時に参照される設定ファイルへの変更が必要です.細かい情報についてはフォーラム記事も参考にしてみてください:

devzone.nordicsemi.com

残るはボタンイベントハンドラタイマイベントハンドラの実装です.

ボタンイベントハンドラ button_1_toggle_handler の実装

ただタイマを起動するだけの簡易な実装です:

void button_1_toggle_handler(nrf_drv_gpiote_pin_t pin, nrf_gpiote_polarity_t action)
{
    NRF_LOG_INFO("timer start");
    
    app_timer_start(m_repeated_timer_id, APP_TIMER_TICKS(100), NULL);
}

タイマイベントハンドラ repeated_timer_handler の実装

まず,グローバルスコープでカウンタを宣言・初期化しておきます:

static uint32_t m_button_1;
static uint32_t m_button_1_cnt = 0;

タイマイベントハンドラはカウンタを利用して条件分岐を行います:

void repeated_timer_handler(void * p_context)
{
    // COUNT-UP FINISH
    if (m_button_1_cnt >= BUTTON_1_CNT_MAX)
    {
        NRF_LOG_INFO("count-up finished (%d)", m_button_1_cnt);

        m_button_1 ^= 1;
                nrf_drv_gpiote_out_toggle(LED_1);

        NRF_LOG_INFO("button state changed to %d", m_button_1);

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;

        return;
    }

    // COUNT-UP CONTINUE / ABORT
    if (m_button_1 != nrf_gpio_pin_read(BUTTON_1))
    {
        NRF_LOG_INFO("counting up... (%d)", m_button_1_cnt);

        m_button_1_cnt++;
    } else {
        NRF_LOG_INFO("count-up aborted");

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;
    }
}

以上で処理の大まかな流れが完成します.最終形は下記のようになります:

// unused
// #include <stdbool.h>

#include "boards.h"
#include "bsp.h"
#include "nrf_drv_gpiote.h"

#include "nrf_log.h"
#include "nrf_log_ctrl.h"
#include "nrf_log_default_backends.h"

#include "app_timer.h"
#include "nrf_drv_clock.h"

#define BUTTON_1_CNT_MAX (10)

APP_TIMER_DEF(m_repeated_timer_id);     /**< Handler for repeated timer used to blink LED 1. */

static uint32_t m_button_1;
static uint32_t m_button_1_cnt = 0;

void button_1_toggle_handler(nrf_drv_gpiote_pin_t pin, nrf_gpiote_polarity_t action)
{
    NRF_LOG_INFO("timer start");
    
    app_timer_start(m_repeated_timer_id, APP_TIMER_TICKS(100), NULL);
}

void repeated_timer_handler(void * p_context)
{
    // COUNT-UP FINISH
    if (m_button_1_cnt >= BUTTON_1_CNT_MAX)
    {
        NRF_LOG_INFO("count-up finished (%d)", m_button_1_cnt);

        m_button_1 ^= 1;
                nrf_drv_gpiote_out_toggle(LED_1);

        NRF_LOG_INFO("button state changed to %d", m_button_1);

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;

        return;
    }

    // COUNT-UP CONTINUE / ABORT
    if (m_button_1 != nrf_gpio_pin_read(BUTTON_1))
    {
        NRF_LOG_INFO("counting up... (%d)", m_button_1_cnt);

        m_button_1_cnt++;
    } else {
        NRF_LOG_INFO("count-up aborted");

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;
    }
}

int main(void)
{
    NRF_LOG_INIT(NULL);
    NRF_LOG_DEFAULT_BACKENDS_INIT();

    nrf_drv_clock_init();
    nrf_drv_clock_lfclk_request(NULL);

    nrf_drv_gpiote_init();

    nrf_gpio_cfg_output(LED_1);
    nrf_drv_gpiote_out_set(LED_1);

    nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
    button_1_config.pull = NRF_GPIO_PIN_PULLUP;

    nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);

    nrf_drv_gpiote_in_event_enable(BUTTON_1, true);

    app_timer_init();
    app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);

    m_button_1 = nrf_gpio_pin_read(BUTTON_1);
    
    
    // Enter main loop.
    while (true)
    {
        __WFI();
    }
}

まとめ・発展的なこと

前述のコードをビルドして nRF52 DK にロードし,RTT でログを出しながら 1 つ目のボタンを押したり離したりすることで動きを確認できます.

もっとも,nRF52 DK にはもともとボタンのチャタリングを上手くフィルタする素子が組み込まれているので,nRF52 DK を動かす上ではほぼ不要な機能の実装と言えます.

ただ,複数通りの実装が考えられるチャタリング対策という機能で「要件 XX を満たすコードは最低こう書きましょう」みたいな内容の記事を一度書いてみたかったので,身近なマイコンボードを題材に書かせてもらいました.

ベテランの組み込み屋さんにとっては基本の基みたいな内容でありながら「機能要求から実装へ」という流れで解説を書くというのは難しかったですが,それなりの内容を提供できたのではないかと思います.

コード上の改善については,例えば

  • ボタンもLEDもそれぞれ 4 つとも有効にしてチャタリング対策を保持したまま任意に割り当てたい
  • 割り当て規則を変更したい
  • チャタリング対策の遅延時間を変更したい

みたいな要求に対して最小限のコード変更で対応できる,というのが良い指導原理になると思います.このあたりは読者の演習問題とはせず,どこかで記事にする予定です.


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com


  1. 性懲りも無く投稿予定日を過ぎてからの投稿でごめんなさい… でも書くのが大事だと思うので書きます.

  2. ちなみに「タクト」という言葉からは指揮棒(takt)を連想させますが,タクトスイッチの語源は tactile switch(直訳: 感触のあるスイッチ)です.

  3. スイッチの開放/短絡を直接入力するタイプの I/F では,電子回路の接地(GND)電位に対する相対的な HIGH/LOW を与えるわけではないことに注意しましょう.このタイプの I/F を無電圧接点と呼んだりします.そして,端子のインピーダンスは通常,開放時に無限大(複素インピーダンスなら絶対値が無限大),短絡時にゼロとなります.

  4. PlayStation 4 のコントローラ DualShock 4 のボタン(もとい FW)がどういう実装なのかが気になりちょっとだけ検索したら何らかの方法で FW を dump し公開したような形跡と DMCA による取り下げ記録のあるGithubリポジトリが見つかりました,アーメン…(そりゃそうですよね)

  5. 余裕があれば改良編と称してまた記事にするかも知れません.

  6. ナイーブに考えると仮判定と実判定のそれぞれで要求するリアルタイム性が異なる変なコードもあり得りえますが,実装コストが無駄になることを考えると両者で要求レベルを揃えておくのが普通です.

  7. 名前は nRF 独自ですが似たような機能は他のマイコンにもあると思います.

  8. この実装は,タイマの起動・停止を担う関数がそれぞれ通常のコンテキスト・割り込みコンテキストという異なるコンテキストで呼ばれる非対称な実装で,一度起動したタイマが停止されずに動いたままという状況になっても気が付かない恐れもあります.これはよくある malloc() で確保したメモリ領域のポインタを特定の関数に値渡しした後,関数内部で free() させる実装と似たような話なので気にしないで良いと言えば気にしないで良い… ですが,対称化できるならした方が見通しは良いでしょう.

Webエンジニアに求められるスキル

この記事はCalendar for Akerun | Advent Calendar 2021 - Qiita の25日目の記事です。

開発部VPoEのNonです。

エンジニアの採用が難しい時代になってきましたが、良い組織を作るためには、求めるエンジニアの採用基準を明確にし、ミスマッチのない採用をしていかなければなりません。

ベンチャー企業の多くは、Webエンジニアを採用する際に、フルスタックエンジニアを求めることが多いと思います。

フルスタックエンジニアを採用する際に、どのような観点でスキルを見抜けば良いかお話したいと思います。

フルスタックエンジニアとは?

まず、フルスタックエンジニアという言葉から、皆さんはどんなエンジニアを想像しますか?

「フロントエンド、バックエンド、インフラを一通りできる人」でしょうか?

もしくは、 「 なんでもできる優秀なエンジニア?」でしょうか?

多くの人が、フルスタックエンジニアという言葉を使いながら、あまり具体的な定義をすることなく使っているケースが多いように感じます。

基準が曖昧なままですと表面的なスキルだけ見て、必要なスキルが備わってない人を採用してしまうことになってしまいます。

フルスタックという言葉の意味で考えると

フルスタック = 複数の技術分野についての知識や技能に精通していること」

となります。

ただ、採用側が期待することは、どんな技術を持っているかよりも

「一人でスクラッチでWebシステムを構築できる」

ということの意味合いの方が強いのではないでしょうか?

Webシステム開発に必要な要素

Webシステムを構築するためには、いくつかの工程を経ていく必要があります。

  • 要件定義
  • 設計
  • 実装
  • テスト
  • リリース

また、忘れてはならないのが、リリース後の運用保守です。

システムは、作って終わりではなく、最初にリリースした後は、利用者の要望に応えるために追加開発したり、不具合やインフラの問題で起きる障害対応なども必要となります。

システム開発における一連の要素を絵にすると下記な感でしょうか。

システム開発工程
システム開発工程

これをもっと細かく分類していくと

カテゴリ 項目
開発スタイル ウォータフォール
アジャイル
プロジェクト管理 進捗管理
タスク管理
見積管理
外注管理
予算管理
契約管理
要件定義 業務分析
ユースケース
システム構成図
機能要件
非機能要件
概要設計
設計 設計手法
基本設計
詳細設計
アーキテクチャ設計
DB設計
インフラ設計
運用設計
実装 CRUD
モジュール化
コードレビュー
セキュリティコーディング
非同期処理
ファイルアップロード
インポート・エキスポート
認証・認可
メール送信
キャッシュ
キュー
外部API連携
バージョンアップ
バッチ
テスト テストメソッド
セキュリティ監査
リリース リリース手順
運用 情報セキュリティ管理
バグ管理
トラブルシューティング
障害エスカレーションフロー
インシデント管理
構成管理

技術よりの観点で見ていくと

カテゴリ 項目
言語 フロントエンド
バックエンド
DB
OS Linux
ミドルウェア Webサーバー
DB
NoSQL
Cache
キュー
検索エンジン
メールサーバー
ファイルサーバー
コンテナ
ストレージ
インフラ ネットワーク
SSL
DNS
負荷分散
攻撃対策
障害対策
プロキシ
CI/CD ビルド
CI/CD
イメージ管理
バージョン管理 ソース管理
ブランチモデル
Pull Request
Merge
パフォーマンスチューニング プログラム
クエリ
ミドルウェア
ディスクIO
スケールアップ
スケールアウト
プロビジョニング ansible
Terraform
webpack
npm
サーバー運用 死活監視
リソース監視
プロセス監視
トラフィック監視
ネットワーク監視
ログ監視・ログ管理
パフォーマンス監視
テスト ユニットテスト
E2Eテスト
負荷テスト
セキュリティ
開発環境 Docker
IDE・エディタ
Lint
git
ターミナル

これ以外にももっと要素はあるかもしれませんが、ここに挙げたようなことを面接で確認することで、その人のスキルレベルが見えてくると思います。

全ての分野で高いスキルを持っている人は、少ないと思いますし、人によって、スキルのバラつきも違ってきますので、現在の組織の状態に合わせて、どの分野でどの程度のスキルが必要かチームメンバーと意識合わせをしておくことが大切です。

ビジネスマンとしての仕事力

先に、「一人でスクラッチでWebシステムを構築できる」フルスタックエンジニアを採用することについてお話しましたが、実際、一人でシステム開発をすることはほとんどありません。

企業で開発するシステムは、一人で構築するにはボリュームが多く時間がかかりすぎるため、チームで開発することが求められます。

これまでは、開発に求められるスキルについて見てきましたが、チームで仕事を円滑に進めるための「仕事力」もとても重要です。

主体性、計画力、実行力など、仕事力を生み出すための要素も確認しましょう。

(参考)社会人基礎力とは?その必要性や鍛え方などを解説 www.i-learning.jp

まとめ

  • Webエンジニアを採用する際には、技術要素を細かく分解し、自社に必要なスキルを持っているかを確認すること。
  • 技術だけではなく、チームでワークするための仕事力も備わっているか確認すること。

エンジニア側もこういった観点で見られているということを意識すると、自身のスキルアップも効率的に行えるのではないでしょうか。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

組み込みエンジニアのための徒手空拳のすゝめ

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 23 日目の記事です.

初めまして,2021 年度の tarotene - Qiita です.

今年 1 月より新卒インターン1として FW チームにジョインし,4 月からはそのまま FW チームで製品の開発・運用に携わっております.

FW チームに求められる守備範囲はざっくりと

の開発・運用・その他諸々ですが,実は私はこれらの分野がほぼ全て未経験でした!

実際,Linux は多少のコマンドが打てる程度2で,リアルタイムシステムに至っては概念すら全く知らない状況でした.

にも関わらず,入社から 1 年が経過した現在では製品の運用にがっつり関われるようになった(と自分で自分を評価しています).

もちろん,これはよくある「未経験から最短でITエンジニアへ」みたいな騙し文句のサクセスストーリーではなく,随所に見えない仕掛けを施してきた人間の生き様です.

本記事では,そんな私が元々どういった人間で,組み込みエンジニアとしての業務を軌道に乗せるまでに何をやってきたかを簡単にお話しします.

似たようなキャリアを歩まれたい方の参考になれば幸いです.


エンジニアリングとの接点

プログラミングとの出会い

この手の話でキーワードになるのは何といっても「プログラミング」でしょう.

私は元々,大学の学部・大学院で物理を専攻しており,たまに必要に駆られて数値計算のためにプログラミングを覚えるといった程度でした3

Twitter で希によくいる「高校時代に情報オリンピックで金メダルを取りました」とか「計算機科学の分野で特別栄誉賞をいただきました」みたいなのとは完全に無縁の生活です.

でも,実際に勉強してみると,当時の観察対象とは一見異なる「自然法則」が計算機を支配しているように感じられ,徐々に惹かれていきました.

プログラミングとの中途半端な関係

難しいことに,研究のためにプログラミングをただ使役するだけだと,どうしてもソフトウェアの「設計」とか「運用」を意識する場面がほとんどありません4.あっても,教科書レベルのコーディングガイドラインを守る程度.例えばデザインパターンやテスト技法についての勉強は完全におざなりで,本気で時間を捻出すればできたかも知れないけど,そこまでのモチベーションが持てないというのが実情でした.

そこで,研究も何となく詰んできたことだしチョットくらい気が紛れる遊びに手を出そうと思い,知人から紹介されて某競技プログラミングサービスに手を出しました.この時点で,雀の涙程度のアルゴリズムとデータ構造についての知識が身に付き,わずかながら C++ を触れるようになりました.

面白いのが,数式中心の文化で生きてきたこともあり,計算と言えば「有限個のパラメータ集合を一つ定めると一意的に結果が求まる」ものがほとんどと思っていた自分にとって,「手続き的に何かをやると(やはり一意的に)結果が求まる」ケースの多いプログラミングは控えめに言ってパラダイムシフトでした.でも,よくよく思い返すと,例えばモンテカルロシミュレーションは「確率アルゴリズムによって時系列を所望の確率分布に収束させる」というそれ自体が数学的に保証された手続きなので,数学・計算機科学に共通の側面を 1 nm 程度理解したというのが妥当かも知れません.

プログラミングを超えてエンジニアリングへ

段々と見出しが仰々しくなってきましたね.

プログラミングは飽くまで道具だとする研究中心の生活と同じくらい,ソフトウェアエンジニアとして真っ向からプログラミング(もといエンジニアリング)に取り組むキャリアも魅力的だと思ったのは研究生活も終盤の頃です.

それで,紆余曲折あり Photosynth にジョインする運びとなりました5

この時点で,学生時代には手すら出さなかった新しいことをやろうと決めていました.それは,概ね

  • 学習コスト度外視で組み込みならではのコア技術に習熟し,ゆくゆくは技術選定にも関われるようになる
  • 商用ソフトウェアならではの設計・テスト・運用・... の経験を積む

という 2 点です.

前者の「組み込みならではのコア技術」については挙げるとキリがないのですが,個人的にはプロダクト依存度の非常に高い Bluetooth Low Energy (BLE) や各種リアルタイム OS の仕組みを知るのが優先課題といったところです.

後者の「設計・テスト・運用・...」は広い意味でのソフトウェアエンジニアとして,です.コーディングから離れることで初めて見えてくる開発者特有のニーズとかそういうものを大事にすることで業務の幅も広がると思っています.


エンジニアリングにおける「徒手空拳」

いよいよ,本記事の本題である徒手空拳編(?)にシフトします.

ソフトウェアエンジニアとしての私は

  • 物事を原理原則から理解し,知識を宙に浮いた状態にしない

という態度を非常に大切にしています.この態度によって,

  • ソースコード上の知識片を統合し,本来あったはずの設計情報を看破できる
  • 設計情報をストーリー形式でチームメンバーに共有できる
  • (特に FW 設計で)電子回路・機械の事情を考慮した最適なコードを書ける

といった実利はもちろんですが,いざという場面で徒手空拳で知識を作れるといった強みがあります(タイトル回収).

世の中,知識は良いものも悪いものも指数関数的に増大していく一方です.こうした知識の洪水の中を生き抜き,少しでも信頼性・汎用性・寿命に富む知識を手に入れるには「自ら知識を作る」という態度が肝要だと思います.

知識を作るというのは大変な行為で,苦労して編み出した知識が既出だったというケースは割合多いです.でも,そうやって自らの血肉をベースにして出来上がった知識は一生モノです.また,既存の知識を組み合わせるだけですごいことに気づいてしまうパターンもあります.つまりギャンブルです.

組み込みだとまとまった知識が世の中にない,特に日本語で書かれた文献が少ない,などの理由で調査コストが大きくなりがちです.つまり,知識を作るチャンスが山ほど転がっています.例えば,社内にある組み込み機器をリモートで J-Link デバッグしようと思い,ざっと以下のことを調べた記憶があります:

  • 端末多重接続技術(tmux, screen, byobu, etc...)
  • Raspberry Pi OS に J-Link Commander をインストールする方法
  • 組み込み機器の CPU を停止・リセットする方法

この手の調べ物で

  • なるべくオリジナルの文献にあたる
  • 自ら手を動かして失敗パターンを潰しておく

といったことを心がけておくと,知識の寿命や適用範囲を伸ばすことにつながり,周りのエンジニアもハッピーになれます.

ここにエンジニアリングの本質があるような気がします.実際,やっていることは

  • 得られた事実から,適用範囲と主張の強さのバランスが取れた命題を導く
  • 導かれた命題に対して,事実ベースの検証・改良も継続的に行なっていく

の 2 つに尽きます.今のところ,入社してからの経験値の振り分は大体

上に書いたことの純粋な質的向上 >> 特定の OS や言語,フレームワークに関する技術的スキルの積み上げ

といった感じですが,上手くバランスをとりながら生きていきたいものです.


ブックガイド

最後に,業務知識の集約がてらこの半年間で手を付けてきた名著(?)の数々をご紹介します.

ほぼ積読状態のものもあれば,何度も読み返した本もあります.コメントと共にお楽しみください.

『組み込みソフトウェア開発スタートアップ』

www.amazon.co.jp

ソフトウェアを浅く広く知っている状態で組み込みの世界に飛び込むためのバイブル.ソフト設計はもちろん,製造・テスト治具やそもそものテストの話がカバーされているなど,とにかく話題が豊富.参考文献も付いていて更に進んだ学習を促してくれるのが Good.今すぐポチりましょう.

『MEMS開発&活用スタートアップ』

www.amazon.co.jp

ネタ枠.書泉ブックタワーで何故か『組み込みソフトウェア開発スタートアップ』の隣にあったので一緒に買ってしまいました.半導体バイスをはじめとするナノスケールの世界に興味がある人向けです.C 言語のコードっぽいモノもチラッと載っているのであながち無関係でもないと思います...

『達人プログラマー

www.amazon.co.jp

これは恐ろしい本です.プログラミングパラダイムを片っぱしから紹介してくれる本です.プログラミングパラダイムはどうしても言語で縛られるので,なかなか目線を上げる機会がないなと思ったなら読むと良いでしょう.ソフトウェア設計の本でもあるので,ビジネスロジック分かっているマネージャが技術選定のために読むという使い方もできそうです.

『リーダブルコード』

www.amazon.co.jp

紹介すると徳を積めると思ったので載せました.微妙なプラクティスも少なからずあるみたいですが,

  • 「簡潔なコメントを書こう.」->(でも概念を知らなければいいコメントは書けない,だから様々な概念を知ることから心がけよう)
  • 「コーディングガイドラインに沿う範囲で良い変数名,もとい英単語を選定しよう.」->(でも知らなければできない,だから英語にも慣れよう)

といった具合に,書かれた内容から裏のメッセージを読み解くのが良いと思います.

Bluetooth Low Energy: The Developer's Handbook

www.amazon.co.jp

今読んでいる本です.LaTeX組版されている読み物はやはり落ち着きますね.英文も平易なので読みやすく,それでいて BLE の仕様についてほぼ完全な解説を提供してくれる優れものです.こういうのを血肉にしたいものです.

『組み込みC/C++プログラミング入門』

www.amazon.co.jp

たまにある C 言語でクラスベースのオブジェクト指向を実現する系の指南書です.組み込みだとコード品質とフットプリントの軽さを両立することが求められるので,時にはこういった技術も求められます.手を動かして練習するのに向いています.むしろ言語設計とかやりたい人に最適かも知れません.

『シェル・ワンライナー160本ノック』

www.amazon.co.jp

めちゃくちゃキャッチーなシェル芸の本.組み込みでは Real-time transfer (RTT) という Segger 社のログ出し機能を使うことがあり,サーバー・インフラ屋のログ監視とよく似ています.そういう場面ではシェルコマンドによる労力削減は必須なので練習して覚えたかったらこの本を読みましょう.序章はちょっとしんどいかも.

『組込み開発のための実践的プログラミング』

www.amazon.co.jp

AVR マイコンを使って手を動かしながら組み込みプログラミングを覚える本で,実践志向かつちょっと中級者向きです.手を動かさずに読んだ感想は Very Good.

『ディジタル回路の設計入門』

www.amazon.co.jp

アナログ回路からスタートしてデジタル回路の実装,カウンタやタイマ,クロックなどの各種 IC の実装と進んでいき,最終的に VHDL にもちょろっと触れる計算機屋向けの電子回路の本です.

『これなら通じる技術英語ライティングの基本』

www.amazon.co.jp

コミットメッセージやコード中のコメント等,明確(clear)・正確(correct)・簡潔(consice)な英語を書くことをよく求められます.こうしたケースで,日本人エンジニアが最小限の労力で良い英語を書くための指南書のようなものです6


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com


  1. 当社の製品である Akerun Remote を社内で開発・テストしやすくするツールを Python で書いたりしていました.その時の話はまたいずれします.

  2. 拙いシェルスクリプトくらいは書けるが,勉強不精だったので業務で役に立つログ解析系の TIPS とかは完全に無でした.シェルスクリプトの勉強に関しては後述のブックガイドを参照.

  3. 実際に使っていたのは PythonFortran 90 だけ.C に至っては教科書を手を動かしながら読んだくらいで,成果物すら無です!

  4. 批判が来そうなのであらかじめ言い訳をしておくと,近年では HPC 分野の発展がめざましいので,その運用に合った綺麗なソフトウェアを書こうという動きは広がっている(例えば https://ma.issp.u-tokyo.ac.jp/ に情報がまとまっている).また,最近でなくとも,綺麗なソフトウェアを書ける非計算機科学分野の研究者は昔からたくさん居る.

  5. 選択の軸はシンプルな Web サービスよりも低レイヤ技術を駆使する事業に興味があったという点です.

  6. 余談ですが,学位論文を書く時はこれを使っていました.書かれているガイドラインを守るだけで指導教員の赤入れの量が面白いほど減ったのを今でも覚えています.