Go で Intel-HEX フォーマットを扱うパッケージ

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

どうも、 daikw - Qiita です。ソフトウェアエンジニアをしています。

今年は Go 言語を扱う機会が少しあったので、前回の記事では Go 言語の BLE 関連パッケージも調べました が、今日は Intel HEX フォーマットのデータを扱うパッケージを調べてみます。

結論

  • 公開されているパッケージの中では marcinbor85/gohex が最も良い。
  • unixdj/ihex も悪くはない。
  • 単純な実装なので再発明しても良い。

Intel HEX フォーマット

そもそも Intel HEX とはなんでしょうか? 最近話題の ChatGPT くんに泣きついてみましょう。

ふんふん、非常にわかりやすい。エンジニアに聞くよりわかりやすいぞ。プログラムをプログラムするのか、なるほどなるほど。

で、それはどうやって Go 言語で扱うのかな?

うんうん、ファイルの入出力のやり方から教えてくれるのは丁寧だ。僕らはもう廃業かな。

うーん、「データの解析」のところが単純すぎる実装だなぁ。Go のデータ型としてどう扱ったらいいかな?

そっかー、レコードタイプも扱いたいんだけど、どうかな ...?

おおっ、なんかいい感じだ!ありがたい。それじゃ最後に、アドレスもうまく扱えたりしないかな ...?

助けて wiki えもん

まだしばらくは廃業しなくて済みそうでしたので、こういう時は普通に調べましょう。 Intel HEX - Wikipedia より、

Intel HEX はバイナリ情報を ASCII テキスト形式で記載したファイル形式である。マイクロコントローラや EPROM などのプログラム可能なデバイスのプログラム書き込みのために広く用いられている。

典型的な利用用途としてはコンパイラアセンブラがプログラムの C 言語やアセンブリ言語などのソースコード機械語に変換し、HEX ファイルとして出力する。

HEX ファイルは ROM にマシン語のコードを「焼く」ために書き込み機によって読み込まれたり、対象のシステムで読み込んだり実行したりするために転送されたりする。

ああ〜、 ChatGPT くんの言う「プログラムをプログラムする」は、マシン語のコードを(プログラムを)焼く(プログラムする)ことかもしれないですね。

実際に J-Link や Jeff Probe などを利用してマイコンに焼く ときに、この形式のファイルを使っています。

各行は複数の 2 進数値をエンコードする 16 進数の文字を含む。2 進数の値は行の位置や形式、長さによってデータ、メモリアドレスなどに相当する。各行はレコードと呼ばれる。

レコード(テキストの行)は左から順に並んだ 6 つのフィールド(部分)を有する

データの構造は以下のようになり、6 種類のレコードタイプを扱う必要はありますが、比較的シンプルな実装のパーサで扱えそうですね。探索も実装も楽そうに見えます。

wikiえもんより

パッケージの選定

探索

さて、一通り理解が進んだところで、先週 BLE 通信パッケージを探索した時と同様に pkg.go.dev で探してみましょう。

関連キーワードは BLE ほどたくさん思いつかないので、 intel hex で検索(intel hex - Search Results - pkg.go.dev)して、インターフェースの異なるパッケージが 6 つ 見つかりました。

なお unixdj/ihexcat は選定から除きます。

静的比較

パッケージ比較をロジカルにやってみましょう。ざっくり利用する基準を考えてみると、

  • ihex ファイルのアドレスを指定して扱う機能があること
  • 読み書きの機能があること
  • テストコードがあること
  • 他のパッケージから利用されていること

といったところでしょうか。

では pkg.go.dev から取得できる情報と、ドキュメント・ソースコードをさっと見た時に得られる情報をまとめてみます。

package published license imported_by testcode
zellyn/go6502/asm/ihex Sep 3, 2018 GPL-3.0 0 あり
tejainece/ihex Jul 12, 2016 BSD-3-Clause 0 あり
marcinbor85/gohex Mar 8, 2021 MIT 20 あり
edmccard/ihex Apr 25, 2015 MIT 1 あり
unixdj/ihex Jan 18, 2022 ISC 1 なし
littlehawk93/ihex Jan 24, 2020 MIT 0 なし

その他のメモも書き下します *1

  • zellyn/go6502/asm/ihex
    • MOS 6502 という CPU を扱うパッケージの一部
    • Writer はあるが Reader がない
  • tejainece/ihex
    • アドレス指定で取り出せない
  • marcinbor85/gohex
    • テストコードが最もちゃんと書いてある
    • 利用例がある
  • edmccard/ihex
    • 1 レコードずつ読む・メタ情報を読むには良さそう
    • アドレス指定できない
  • unixdj/ihex
    • ドキュメントが最も丁寧
    • テスト・利用例がなく扱いにくい
  • littlehawk93/ihex
    • coming soon など書いてある割にメンテされた様子がなく、ややスメリー
    • 使えそう感は出ている

以上のことから、基準に合致するのは marcinbor85/gohex のほぼ一択ですね。ギリギリ良さそうな unixdj/ihex も、ついでに参考実装を書いてみましょう。

動的比較: 参考実装

以下の要件で実装してみます。

  • アドレス指定でファイルに書き込むこと
  • ファイルを読み込んで処理すること
  • 共通の以下の実装を持つこと
func cat(filepath string) error {
    data, err := os.ReadFile(filepath)
    if err != nil {
        return err
    }

    fmt.Println("----------------------------------------------------")
    os.Stdout.Write(data)
    fmt.Println("----------------------------------------------------")

    return nil
}

func main() {
    filepath := "output.hex"
    if err := dump(filepath); err != nil {
        panic(err)
    }
    if err := read(filepath); err != nil {
        panic(err)
    }

    cat(filepath)
}

marcinbor85/gohex

こちらは参考実装がレポジトリにもあるので、さっと書けそうです。

https://go.dev/play/p/NMgScee-1ZX

func dump(filepath string) error {
    file, err := os.Create(filepath)
    if err != nil {
        return err
    }
    defer file.Close()

    mem := gohex.NewMemory()
    mem.SetStartAddress(0x80008000)
    mem.AddBinary(0x10008000, []byte{0x01, 0x02, 0x03, 0x04})
    mem.AddBinary(0x20000000, make([]byte, 256))
    mem.AddBinary(0x30008000, []byte{0x04, 0x03, 0x02, 0x01})

    mem.DumpIntelHex(file, 16)

    return nil
}

func read(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        return err
    }

    mem := gohex.NewMemory()
    if err := mem.ParseIntelHex(file); err != nil {
        return err
    }
    for i, segment := range mem.GetDataSegments() {
        fmt.Printf("%v: {Address: 0x%00000000x, Data: %v}\n", i, segment.Address, segment.Data)
    }
    bytes := mem.ToBinary(0xFFF0, 128, 0x00)
    fmt.Printf("b: %v\n", bytes)

    return nil
}

割とわかりやすいんじゃないでしょうか。

0: {Address: 0x10008000, Data: [1 2 3 4]}
1: {Address: 0x20000000, Data: [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]}
2: {Address: 0x30008000, Data: [4 3 2 1]}
b: [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
----------------------------------------------------
:0400000580008000F7
:020000041000EA
:048000000102030472
:020000042000DA
:1000000000000000000000000000000000000000F0
:1000100000000000000000000000000000000000E0
:1000200000000000000000000000000000000000D0
:1000300000000000000000000000000000000000C0
:1000400000000000000000000000000000000000B0
:1000500000000000000000000000000000000000A0
:100060000000000000000000000000000000000090
:100070000000000000000000000000000000000080
:100080000000000000000000000000000000000070
:100090000000000000000000000000000000000060
:1000A0000000000000000000000000000000000050
:1000B0000000000000000000000000000000000040
:1000C0000000000000000000000000000000000030
:1000D0000000000000000000000000000000000020
:1000E0000000000000000000000000000000000010
:1000F0000000000000000000000000000000000000
:020000043000CA
:048000000403020172
:00000001FF
----------------------------------------------------

Program exited.

出力も予想通りになりました。

unixdj/ihex

こちらは参考実装がないので少し苦労しました。

選定から除いた unixdj/ihexcat で 利用されていた (https://github.com/unixdj/ihexcat/blob/v1.0.1/main.go)ので、これで雰囲気を掴みつつ書いてみます。

https://go.dev/play/p/qLSFWVED-hm

func dump(filepath string) error {
    file, err := os.Create(filepath)
    if err != nil {
        return err
    }
    defer file.Close()

    ix := ihex.IHex{
        Format: ihex.Format32Bit,
    }

    ix.Chunks = []ihex.Chunk{
        ihex.Chunk{
            Addr: 0x10008000,
            Data: []byte{0x01, 0x02, 0x03, 0x04},
        },
        ihex.Chunk{
            Addr: 0x20000000,
            Data: make([]byte, 256),
        },
        ihex.Chunk{
            Addr: 0x30008000,
            Data: []byte{0x04, 0x03, 0x02, 0x01},
        },
    }

    if err := ix.WriteTo(file); err != nil {
        log.Fatal(err)
    }

    return nil
}

func read(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        return err
    }

    var ix ihex.IHex
    ix.ReadFrom(file)

    for i, chunk := range ix.Chunks {
        fmt.Printf("%v: {Address: 0x%00000000x, Data: %v}\n", i, chunk.Addr, chunk.Data)
    }

    return nil
}

Chunk という表現に違和感がなければ、割とわかりやすいように見えますね。

0: {Address: 0x10008000, Data: [1 2 3 4]}
1: {Address: 0x20000000, Data: [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]}
2: {Address: 0x30008000, Data: [4 3 2 1]}
----------------------------------------------------
:020000041000EA
:048000000102030472
:020000042000DA
:1000000000000000000000000000000000000000F0
:1000100000000000000000000000000000000000E0
:1000200000000000000000000000000000000000D0
:1000300000000000000000000000000000000000C0
:1000400000000000000000000000000000000000B0
:1000500000000000000000000000000000000000A0
:100060000000000000000000000000000000000090
:100070000000000000000000000000000000000080
:100080000000000000000000000000000000000070
:100090000000000000000000000000000000000060
:1000A0000000000000000000000000000000000050
:1000B0000000000000000000000000000000000040
:1000C0000000000000000000000000000000000030
:1000D0000000000000000000000000000000000020
:1000E0000000000000000000000000000000000010
:1000F0000000000000000000000000000000000000
:020000043000CA
:048000000403020172
:00000001FF
----------------------------------------------------

Program exited.

出力は予想通りで、 marcinbor85/gohex と同じになりました。

その他: J-Link で他のフォーマットを扱う

https://wiki.segger.com/J-Link_Commander#LoadFile より、 J-Link では以下の形式のファイルが扱えます。

*.mot
*.srec
*.s37
*.s19
*.s
*.hex
*.bin

mot, srec, s37, s19, s はいずれも Motorola S-record というフォーマットで、 Intel HEX と同じような仕組みを持っています。 先頭が S で始まる点が違いますが、アドレス・データ・チェックサムを扱える点は同じです。

参考記事


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

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

キャンプでハック - スマート?ホットサンドメーカー

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

3日目の記事も書きましたので、こちらもどうぞ。

akerun.hateblo.jp

WebエンジニアのBunです。

数年前からキャンプ🏕にハマっています。多い時は週一くらいでキャンプに行ってます。 最初はソロキャンですが、最近はよくグループで行きます。一人で静かに過ごすのも良いですが、みんなでワイワイするグループキャンプも楽しいです(夜は静かにしていますw)。

キャンプと言えば定番?のBBQですが、朝のホットサンドも格別お美味しいです。食パンにハームやチーズ、半熟目玉焼き...好きなものを色々挟んで、ホットサンドメーカーに乗せて、両面を数分ほど焼けば、美味しいサンドの出来上がりです。

しかし、こんな時もありますね。ホットサンドメーカーをコンロに置いたまま喋っているうちにこうなってしまいます。

何度も焼き加減をチェックするのも面倒なので、タイマー機能が付いているホットサンドメーカーならもっと手軽くいい感じに焼けるんじゃないかなと思って色々調べてみましたが、キャンプで使えるものは見つかりませんでした。残念。。。

なかったら作ります。ホットサンドメーカーではなく、後付けタイマーを。

アイディア

必須機能

  • タイマーの設定、変更が可能
  • 設定された時間になったら、alerm(音)での通知が可能
  • ホットサンドメーカーをひっくり返した後、自動的にタイマー再設定が可能(これがないと、百均のタイマーを付けるだけで良いので)
  • 後付けが可能(これは大事)
  • IoTっぽく、WiFi/BLEでインターネット、あるいはスマホに繋げて、いろんなホットサンドメニューの焼き時間の設定が可能

オプション機能

  • LCDパネルを付けて情報表示が可能
  • WiFi経由で自動的にクラウドからホットサンドメニューの取得が可能
  • 数百℃まで計測できる温度センサーを付けて、焼き時間調整が可能

材料準備

色々部品が必要だったので、作りながら数回分けてAmazonでポチりました。ブラックフライデーセールに間に合っていないのがちょっと残念です。

  • マイコン:ESP32
  • 各種センサー:6軸(加速度、ジャイロ)センサー、3軸磁気センサー
  • 電子ブザー:電圧スピーカー(パッシブブザー)
  • 各色発光ダイオード(LED)
  • スイッチ:タクトスイッチ
  • ボタン電池ホルダ:CR2032用
  • ブレッドボード
  • ジャンプワイヤ
  • ユニバーサル基板
  • はんだごてセット
  • その他諸々

試作

マイコンのLチカの確認から各種センサー、ブザー、タイマーの確認が取れたので、簡単な回路図を作ってブレッドボードに各種部品を組み込みます。

回路図

各パーツ毎に確認しながら問題なく組み立てたので、回路図までは作りませんでした。後日、作ってみたいと思います。

ESP32

ESP32はESP-32S NodeMCUを購入しました。DEVKIT V1のピン配置になっています。今回は下記のピンを使いました。

#define BEEP_PIN    13          // ブザーBEEP音のピン
#define BEEP_CHANEL 0      // BEEPのチャネル番号

#define LED_START   25       // 開始/終了LED用ピン

#define LED_BIT4    26          // タイマー表示用LED(4bit目)
#define LED_BIT3    27          // タイマー表示用LED(3bit目)
#define LED_BIT2    14          // タイマー表示用LED(2bit目)
#define LED_BIT1    12          // タイマー表示用LED(1bit目)

#define SW_START    33      // 開始/終了スイッチ
#define SW_TIMER    32       // タイマー設定スイッチ

6軸(加速度、ジャイロ)センサー、あるいは3軸磁気センサーはI2Cのピン22(SCL)とピン21(SDL)を使います。

各種センサー:6軸(加速度、ジャイロ)センサー、3軸磁気センサー

最初は6軸の加速とジャイロセンサーか、あるいは3軸磁気センサーでホットサンドメーカーのひっくり返しを判定しようと思いましたが、計算方法がやや複雑でしたので、シンプルに加速度のZ軸で判定するようにしました。

6軸(加速度、ジャイロ)センサーはMPU-6050を使いました。

void setup() {
  // SDA(GPIO21) / SCL(GPIO22)
  Wire.begin();         //I2C通信開始
  // センサ開始動作
  Wire.beginTransmission(0x68); //アドレス0x68指定でMPU-6050を選択、送信処理開始
  Wire.write(0x6B);             //MPU6050_PWR_MGMT_1レジスタのアドレス指定
  Wire.write(0x08);             //8を書き込むことでセンサ動作開始(Temp: disable)
  Wire.endTransmission();
}

Z軸加速度を取得し、0.9g前後でUP、-0.8gでDOWNとし、UP->DOWN、あるいはDOWN->UPになった時、ひっくり返しと判定しました。

void loop() {
  Wire.beginTransmission(0x68);     //アドレス0x68指定でMPU-6050を選択、送信処理開始
  // Wire.write(0x43);                 //GYRO_XOUT_Hレジスタのアドレス指定
  Wire.write(0x3B);                 //ACCEL_XOUT_Hレジスタのアドレス指定
  Wire.endTransmission(false);      //false設定で接続維持
  Wire.requestFrom(0x68, 6, 1);     //MPU-6050に対して6byte分データ(accel)を要求、I2Cバス開放

  int16_t ax, ay, az;

  //シフト演算と論理和で16bitのデータを変数に格納
  //ax~gzまで、16bit(2byte) × 7 = 14byte
  ax = Wire.read() << 8 | Wire.read();    //x軸の加速度の読み取り 16bit
  ay = Wire.read() << 8 | Wire.read();    //y軸の加速度の読み取り 16bit
  az = Wire.read() << 8 | Wire.read();    //z軸の加速度の読み取り 16bit

  // 連続3回分の軸加速度を取得し、その平均値でUP/DOWNを判定
  少し長いので、割愛
}

タイマー

シンプルに開始スイッチを押したら、タイマーを開始し、1分間隔のタイマーCallbackでTimeをカウントダウンします。 設定Timeが0になったら、ブザーを鳴らします。

void IRAM_ATTR onTimer() {
  if (timeCounter > 0) {
    timeCounter--;
  }
  // Timeを3bitを表すLEDで表現
  setTimeLED();  
}

void startTimer() {
  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, TRUN_OVER_TIME_UNIT * 1000000, true);
  timerAlarmEnable(timer);
  Serial.println("Timer started.");
}

ブザー(BEEP音)

設定Timeが0になったら、BEEP音を鳴らします。

void makeBeep() {
  ledcSetup(BEEP_CHANEL, 12000, 8);
  ledcAttachPin(BEEP_PIN, BEEP_CHANEL);

  ledcWriteTone(BEEP_CHANEL, 820); 
  delay(50);
  ledcWriteTone(BEEP_CHANEL, 0);    // 消音
  delay(50);
  ledcWriteTone(BEEP_CHANEL, 820); 
  delay(50);
  ledcWriteTone(BEEP_CHANEL, 0);
}

スイッチとLED

タクトスイッチ二つを用意し、Timeの設定とタイマー開始終了に使います。

ピンの数の制限もあって、とりあえず3つのピンにLEDを繋げて、二進数で最大7分(111b)の時間を表示します。4桁7セグメントLEDでも良かったなと後から気付きました。。。

void init() {
  // ピン情報設定
  pinMode(SW_START, INPUT_PULLUP);  // ボタンスイッチのピン番号と、入力(INPUT)を指定
  pinMode(SW_TIMER, INPUT_PULLUP);  // ボタンスイッチのピン番号と、入力(INPUT)を指定

  pinMode(LED_START, OUTPUT);
  pinMode(LED_BIT1, OUTPUT);
  pinMode(LED_BIT2, OUTPUT);
  pinMode(LED_BIT3, OUTPUT);

  digitalWrite(LED_START, LED_OFF);
  digitalWrite(LED_BIT1, LED_OFF);
  digitalWrite(LED_BIT2, LED_OFF);
  digitalWrite(LED_BIT3, LED_OFF);

  // 3bitのLEDのON/OFF設定
  setTimeLED();
}

組み立て完成

こうなりました。

Time設定スイッチで0~7分の時間を設定します。

タイマー開始スイッチでタイマーを開始し、設定された時間になりましたらブザーのBEEP音が鳴ります。 同時に加速度センサーが動作し、Z軸の加速度でひっくり返しを判定します。ひっくり返しになったら、BEEP音が止まって再度タイマーが開始します。これでホットサンドの両面が時間通りに焼けます。

ただ、このままだとホットサンドメーカーに後付けができないので、ユニバーサル基板にはんだ付けをします。小さい基板にそこそこの部品を配置しないといけないし、また久しぶりのはんだ付けなので、配線を考えたら想定以上の時間が掛かってしまいました。

特に不要な電源ケーブルを減らすために、ボタン電池を使えないか結構苦労しました。CR2032x2を多少無理やりに電池ケースに詰め込んで何度かギリギリ基板に付けることができました。

もう少し上手く配置できそうですが、まだ初心者なので、一旦下記で良さそうです。USB給電でもボタン電池給電でもなんと一発で想定通りの動作になりました🎉

ちょうどワールドカップ2022が始まっている時期に作り始めたので、ワールドカップを見ながらよなよな作っていました。今夜はグループステージのポルトガル終戦。そして、明日は会社サークルでキャンプに行くので、そこでユーザーテストもしたく、今夜は最後の仕上げと後付け後のテストをします。

後付けと言えば弊社のスマートロックAkerun(サラッと宣伝w)。

明日までもう時間がないので、家の中を色々探したところ、ちょうど息子のポケモンカードが使えそうなので、数枚を両面テープでくっつけて基板を固定しました。そして、ホットサンドメーカーのハンドルに両面テープでくっつけます。

いざホットサンドを作って最終チェックです。弱火で両面それぞれ3分設定し焼きます。

あれ、ちょっとタイマーがおかしい。1分経っても2分経ってもTimeを表示するLEDが変わりません。さらに時間が経って、少し焦げた匂いがします。焦げてしまいました。。。

PCに繋げてデバッグしてみると、タイマーの単位が間違ってしまいました。ちゃんと動作することを確認し、再度コンロに載せました。片面の焼き時間になったらブザーが鳴り、ひっくり返しでブザーが止まり、タイマーが再開します。もう一面の焼き時間のブザーが鳴ったら出来上がりです。

これはまあまあ上手く出来ていますね!明日のキャンプ直前にホットサンド2個も食べちゃいました。。。

ユーザーテスト

山梨県の富士西湖近くのキャンプ場にやってきました。12月に入っているのにあんまり寒さを感じない良い天気でした。

翌朝、ホットサンドを作ってもらいました。ホットサンドの中身も火加減も変わったので、時間は2~4分の間で適当に設定しました。悪くない出来上がりです。焼き時間の忘れやスマホを出してタイマーを設定する手間は無くなりました。

ただ、両面テープで適当にくっ付けたので、ゆるゆるになってしまい、今後改善の課題になります。

また、「片面は3分、もう一面は2分とかできないの?」の要望も出てました。なるほど、パンに乗せる物によって焼き時間は異なる可能性がありますね。この発想はありませんでした。

※ちなみに、ボタン電池で上手く動作しなかったので、急遽モバイルバッテリからUSB給電にしたのがちょっと残念でした。

今後改善

あんまり余裕がなくて、大分手抜きで作ってしまいましたが、せっかくなので、来年こそキャンプで活用できるように改善していこうかなと思います。

  • ちゃんとした後付けに改善する。百均で探せば色々あるはず。3Dプリンターも使ってみる。
  • IoTっぽくWiFiで直接クラウドに繋げるか、BLEでスマホに繋げる。ホットサンドメニューによって最適な焼き時間を簡単に設定できるようにする。
  • タイマー情報がもっと分かりやすく見えるようにLCDパネルを付ける。SPIピンがまだ残っているので、行けるはず。
  • 温度センサーを付けて火加減によるタイマー設定をもっと簡単にする。
  • ボタン電池で安定して給電できるようにする。

などなど色々発想が膨らんできます。

まとめ

数年ぶりにマイコンを触って、はんだ付けもして、やっぱり電子工作は楽しいですね。ただ、色々忘れてしまい、マイコンや回路についてもっと勉強が必要だなと実感しました。

これからもキャンプを楽しみながら改善していきたいと思います。来年のAdvent Calendarでは改良版を見せられたらなと思います。


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

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

社内での輪読会のすゝめ

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

このイベントに参加して 3 年目となりました AkiAbe - Qiita です。
今年は FW 開発領域のマネージャーとしての活動をしていました。

弊社 FW グループでの取り組みの一部として、輪読会をやっています。
どういう目的でこの勉強会をしているかをご紹介します。

きっかけ

発端は、 daikw - Qiita -san からの提案でした。
前の職場でもこのような取り組みをしていたし、個人的にはやりたいなと思っていたところにナイスなタイミングでの提案で、めちゃくちゃうれしかったです。
(そしてお隣の web グループでも独自にこのような活動をしています🙂)

2021 年末くらいから始めて、大体半年で 1 冊を読み切るような感じで運用しています。

今まで対象にした書籍

現時点で対象は 2 書籍で以下をお題にしました。 どちらも良書ですね。

書籍 1.「リーダブルコード」
基礎って感じですが、めちゃくちゃいい本ですよね。
(これ、私の息子は「音楽の本」と思っていますw)

www.oreilly.co.jp

書籍 2.「セキュア・バイ・デザイン 安全なソフトウェア設計」
ドメイン駆動設計 (DDD) のメリットがよく理解できる本だと思います。
出てきている例もわかりやすくて、まじでこの内容書けるのすごいと思いました。

book.mynavi.jp

やってみて感じたこと

輪読会してみて良いなと感じたのはこの辺りです。

  • 読んでいてわからなかったこと、腑に落ちなかったことを話し合える
  • 話し合うことで、「自分たちのシステムであれば、、」にあたはめて考えるきっかけを得ることができる
  • チームメンバーが同じ知識をえられる
  • その前提で業務コミュニケーションができるようになり、業務の質が向上する

デメリットは特に感じていません。 強いて言えば、自分が発表の当番のときに、発表資料をつくる時間を捻出するのが大変なくらいだと思いますw

活動を始めるにあたり

こういった活動の初期に、マネージャーがやるは以下の 2 点だけで良いかと思います。 あとはメンバーの自主性に委ねましょう。

1. 開催ペースの設定

私はあまり頑張りすぎないことを第一におき、 2 週間で 1 章ずつすすめるスローペースでの提案としました。
日々の開発に影響しない感じでゆるく継続したいなという思いからです。
ペース設定だけはチームと開発案件の事情を考慮して マネージャーが関わるのが良いかと思います。

2. オーナー設定

1 冊やることにオーナーとなる人を設定します。
オーナーにしてもらうことはこの 3 点。

  • ガイドラインを作成する
  • みんなに周知する
  • そのルールで輪読会を運用 (準備の促し、当日の会のファシリテート) する

その背景、目的としては

  • 各自の自主性の向上
  • 業務以外でも「何かのプロジェクトをリードしてやりきる」経験
  • ファシリテート能力の向上

があります。

特にファシリテートというのはある程度、経験が必要な分野だと思います。
書籍とかで良いファシリテートとは?みたいなのを読んで理解していても
参加者によって雰囲気も違いますよね。会議は生き物だと思っています

会議を進行するに当たって、このあたりは重要なスキルで経験が割と必要なところかなと感じます。

  • どうやって人に話題を振って進めればいいか
  • 議論がずれていったときにどう修正すればいいか
  • 白熱しているときにどう打ち切ればいいか
  • 逆に何も意見がないときにどう繋げて、次に進めればいいか

こういうチームで閉じた会議だと何度も失敗してやり直せるので、とても良い経験の場となっていると感じています。

実際の活動

こんな感じでオーナーにスケジューリングして、ルールを策定してもらい、各自担当ごとに記事を作成し共有していく形で運営しています。
ちゃんとガイドライン用意していて、どういうことに気をつけるべきかキチンと書いて伝えていて素晴らしい!

私が担当の記事の冒頭部分。

こんな形で記事を蓄えなが、共有して議論をしてという形でやっています。
大体 1 回 1 時間くらいの時間をみていて、前半が発表者からの共有。後半でみんなで議論をするくらいの配分でやっています。

感想的なのは、前述の [やってみて感じたこと] に記載してあるので割愛。

まとめ

輪読会をやることで以下の効果を感じています。

  • オーナーになった人のファシリテート能力が上がる
  • メンバー各自のの知識が増える、整理される
  • メンバー間で知識のベースが揃う
  • チームのコミュニケーションの質が上がる
  • 合宿のようなノリで会話ができるようになることで心理的障壁が低くなる
  • さまざまな場面で声がかけやすい状態がうまれる
  • そのほかの日常の会議運用もやりやすくなる

ぜひ、みなさんも取り入れてみてください。 ソフトウェア開発はチームで開発するので、チームがよくなりそうなことはどんどんやっていきたいですね!


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

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

便利な解錠方法 - 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