IoT

2017年08月31日

micro:bit と ESP32 でインターネットボタンを作る

はてなブックマークに登録

今月(2017年8月)5日に国内で正式販売の始まった BBC micro:bit を楽しんでいます。遅ればせながら、世界中の子どもと大人に人気のこの素敵なコンピュータのファンになりました。


http://www.bbc.com/
http://www.kitronik.co.uk/

私が micro:bit に関心を持ったのは最近扱っていた話題にちょうどぴったりのデバイスだったことがきっかけでした。今回はその内容と手元での試作を順をおって紹介します。

BLE ボタンデバイスをインターネットボタンとして使うこと

Amazon Dash Button の成功がひとつの契機にもなり この数年の間にインターネットボタンの利用と普及が進みました。手軽さといえば何かとスマホが引き合いに出されることの多い状況あってより単純な操作でより簡単に所定の処理を呼び出せることが専用の機器であることの最大の強みでしょう。

ただその一方で、機器の性質上ボタンデバイスの多くが電池駆動式であることが今後の用途・要件の拡大に伴いより悩ましい要素となる可能性も考えられます。限られた貴重な電力を使って自力でインターネット上の所定のサービスを叩きに行くのは必ずしも効率の良いやり方でありませんから、この点への対処が今後の課題となるかもしれません。

初代の Amazon Dash Button に高容量ではあるものの傍目にもコストバランスの危なげな米エナジャイザー社製の高価なリチウム一次電池が内蔵されていたことは象徴的です

一案として、省電性に優れた BLE ボタンデバイスと仲介役のエージェントデバイスを組み合わせてインターネットボタンを構成する形を想定しました。図にしてみます。

このように考え方はとてもシンプルです。ボタンデバイスは電池駆動、据え置きのエージェントデバイスは AC 電源からの給電を想定しています。 ボタン側は BLE 通信で必要最小限の通知をエージェントへ送り、エージェント側はそれをトリガーに所定のインターネットサービスを呼び出します。インターネットボタンは通常屋内空間での利用が前提ですから、BLE と WiFi の通信距離の違いに留意して機器の設置・運用を行えばこの構成には相応の合理性があります。また、小さなボタンデバイスひとつに複雑な処理を入れ込むことに比べ、機器の組み合わせによって必要な機能を実現するやり方には柔軟性が見込まれるでしょう。

通知の方法と道具立て

通知方法について

通常、BLE ペリフェラルからセントラルへの通知はデバイス間の接続確立後に所定の手続きによりセットアップを行うことで実施可能となります。この設定は両者の接続が維持されている間のみ有効です。

もちろんそれは BLE の仕様上のまっとうな規約なのですが、前掲の案への適用にはあまり気が進まずにいました。理由は以下の三点です。

  1. 「接続の維持」が必須であることは BLE の省電性を最大限に活かそうとする主旨と必ずしも折り合わない
  2. エージェントへの通知は必要最小限の合図のみでよいにもかかわらず仕掛けが豪華すぎる
  3. 技術情報や SDK の公開されていないボタンデバイスを利用したい場合にはグレーな解析が必要

代わりに デバイスからのアドバタイジングパケットを合図として利用することを考えました。この方法ならよりコストが低くデバイス間の接続に拘束されることもありません。また、アドバタイズの内容は文字通りオープンなので一般的なツールで普通に読めるという間口の広さも利点です。
注意の必要な点としては、連続して発生するアドバタイジングパケットにエージェント側が過剰に反応しないようにすることと、当該デバイスを本件専用として扱うべきであることが挙げられるでしょう。

エージェントについて

前掲の案のポイントは BLE 通信とインターネット通信の両方をカバーするエージェントを用意する点にあります。BLE つきの遊んでいるスマホを利用する手もありますが、「据え置き」「常時稼働」を前提とする IoT 装置として利用するにはいささかオーバースペックの感があり、また、視点は異なるもののスマホ系とボタンの連携に関しては「Flic」や「Pochiru」のような先例もあるためもうひとつ食指が動きません。

そこで ESP32 モジュールを使うことにしました。ESP-WROOM-32 は今回の要件を単体ですべて満たしているためまさに適役で価格面での魅力もあります。

ボタンについて

過去にこのブログでピックアップしたもの以外にも手元には複数の BLE ボタンデバイスがあります。それぞれに個性があり並べて使ってみるとなかなか面白いのですが、上述のようにアドバタイジングパケットを利用することを想定すると次のような機能・機構がほしいと考えました。

  1. 給電中にアドバタイズの開始・停止を任意に制御できること
  2. 複数のボタンが装備されていること
    (「ひとつのデバイスでひとつの処理」ではちょっと寂しい。アドバタイズを合図とする以上、ボタンデバイスが本来の通知機能においてダブルプッシュや長押しといったボタンアクションの区別に対応していてもそれを利用することができないという事情もある)
  3. 上記 2. に関連して、アドバタイズデータの内容を変更可能であること

残念ながらこれらの要件を満たすものは手元にありませんでしたが、プロトタイピングであれこれ試したところではどのボタンもそれなりに使えるのでまあこれはこれで・・とも思っていました。そこでたまたま目にしたのが micro:bit 国内販売のニュースです。

それまでこの製品のことは名前しか知らずにいました。あらためて写真をみてふたつのタクトスイッチが目にとまり、さらに記事中の「技適」の文字が気になって情報を調べたところ実は上のみっつの要件をすべて満たしていることがわかったため迷わずこれを利用することにしました。
ちなみにスイッチサイエンスさんが去年 12月にリリースした chibi:bit のこともこの時に知りました。ショップにはよくお世話になっているのですが、一見あまり関係のなさそうなところにしっかり良いものがあったりするものですね。

micro:bit 用に追加購入したパーツ

micro:bit ボードをボタンデバイスとして扱うために欠かせないふたつのパーツを調達しました。 いずれも英 Kitronik 社の製品で、ボタン電池から給電するためのオプションボード「MI:power(直販価格 £4.16)と、同ボードを装着した状態の全体をすっぽり覆うことのできる「MI:pro Protective Case(直販価格 £4.10)です。写真はケースに収めた状態の外観です。


前面
背面
また、今回は使っていませんが、拡張コネクタ用の「Edge Connector Breakout Board(直販価格 £4.15)もあわせて入手しました。

余談ながら Kitronik 社は直販を行ってはいるものの日本への送料が最安でも £30.95 と高いため、送料の安いショップを探して英国内の Pimoroni にたどり着き、そこで「MI:power」と「Edge Connector Breakout Board」を購入しました。送料は £5.50。8月7日に発注し 1週間で到着しました。なお、このふたつの製品はスイッチサイエンスさんのサイトにも掲載されており 8月21日に同社から「ご注文いただけるようになりました」メールが届きました。

どうしても欲しかった「MI:pro Protective Case」は Pimoroni にも見当たらず、しつこい検索を経て結局その時点で送料を含め最も安く販売していた ebay 出品企業から買いました。それでも総額が日本国内での micro:bit 本体価格よりも高額ではありましたが、ちょうど 2週間後の 8月25日に届き嬉々として使っています。

スイッチサイエンスさん、ぜひこの「MI:pro Protective Case」も扱って下さい!Pimoroni さんも今のところ扱っていないようですが、もし入荷されたら少なくとも私は確実に買います!(^^;

実装

micro:bit 側

micro:bit 側のプログラムは Microsoft MakeCode で手早く作成しました。以下の内容としています。

  • 電源が入ると最上段中央の LED を 2秒間隔で点滅開始 (給電状態の確認用)
  • 左側のボタン A が押されるとボタン右の LED を点灯し Eddystone-URL パケットにダミー URL "http://A" をのせて 4秒間アドバタイズする
  • 右側のボタン B が押されるとボタン左の LED を点灯し Eddystone-URL パケットにダミー URL "http://B" をのせて 4秒間アドバタイズする

アドバタイズに Eddystone-URL パケットを使用しているのは MakeCode 上の「Bluetooth」の項に他の選択肢が見当たらなかったためです。これと言って弊害も想定されず、また、どのような形式であれデータの内容で A, B ボタンを区別できれば事足りるのでこれはこれでよしとしました。図はエディタ画面のスクリーンショットです。すでにこういう時代なのですね。 (クリックで可読大表示)

ESP-WROOM-32 側

ESP32 モジュール側のプログラムは ESP-IDF 環境で作成しました。

処理の概要

  1. 所定の WiFi アクセスポイントへ接続
  2. 所定の URL へ HTTPS アクセスを行うためのタスクを生成して待機
    〜 下記 3. での要求発生時にリクエスト送出とレスポンス受信を行い終われば再び待機状態へ
  3. 永続ループで BLE アドバタイジングパケットのスキャンを開始
    〜 対象とする MAC アドレスのデバイスからの対象とする符丁を含む Eddystone-URL パケットを検知すると上記 2. のタスクへ所定の URL へのリクエストを要求

ソースコード

ここでは一台の micro:bit のみを対象としていますが、ソースコード中の Entries テーブルへ適宜エントリを追加することで複数の micro:bit を対象とすることが可能です。

A, B ボタンの押下に呼応するアクションのサンプルとして Gmail 発信, 携帯電話のコールのふたつの処理を紐付けています。これらはいずれも Microsoft Flow 上に作成したフローの URL への GET 要求によって実現しています。ふたつのフローのスクリーンショットを以下に示します。 (クリックで可読大表示)

Gmail 発信

電話機をコール(Twilio API を利用)

動作の様子

動画:1分18秒 (途中で電話着信音が鳴ります。音量に注意して下さい)


(tanabe)
klab_gijutsu2 at 09:06|この記事のURLComments(2)
2017年06月23日

音を利用する 5 〜mbed マイコンでの応用:実装編〜 (終)

はてなブックマークに登録

この記事の内容

前回の記事では mbed LPC1768 を使って音声信号の入出力と分析を試みました。今回はその内容をもとに実用性の見込めるものを作ってみます。

※記事では音の分析に以下のアプリケーションを利用しています

1. 音をトリガーに通知を行うしくみを作る

まず、所定の音を鳴らすとそれに反応して所定の連絡先へ通知を行うしくみを想定しました。装置が屋内で常時集音と分析を行い、所定の周波数の連続性のある音声信号を検知するとインターネット経由で所定の対象へ通知を送るイメージです。次のように考えました。

  • 音は電磁波に比べより小さなエネルギーで発生させることができるため何らかの処理を指示するためのトリガーとしての有用性が見込まれる
  • 音は人間の官能に直結しており扱いが比較的容易であるため他の選択肢が閉ざされた状況において意思を伝えるための最後の手段となり得る
  • マイコンは常時稼働を前提とする情報処理装置の構成要素として好適でありこうした使い方は「IoT」そのものでもある

使途はいろいろ考えられそうですが、たとえば日常的に危険の潜在する高齢者世帯や独居世帯において非常時に SOS を発信するための用心棒としても有用でしょう。

どのような音を使うか

通知のトリガーとする音には所定の周波数と振幅の維持が求められます。もっとも簡単にそのような音を出せる身近な道具として「笛」に目を向け 5 種類の製品を試してみました。それぞれに特長がありこれらの笛製品の音をすべてトリガーとして利用したいと考えました。

A&F 社製 サイレントホイッスル 犬笛

  • A&F エイアンドエフ サイレントホイッスル - www.aandfstore.com
    人に聞こえる可聴領域の低音と犬にしか聞こえない超音波領域(16000〜22000Hz以上)の 高音を出すことができる軽量なアルミ合金から削り出して作った、たった7gの犬笛です
  • A&F エイアンドエフ サイレント ホイッスル - www.yodobashi.com
  • 長所
    • 日常的な環境音とはほとんど競合しない高周波の音を簡単に出力できる
    • 振幅の大きな音を「静か」に鳴らすことが可能
    • 少ない息で大きく長く音を出せる
    • 今回の中ではもっとも遠くまで音が伝わる
  • 備考
    • 価格が高め(実売¥2,000前後)
    • 犬笛は音が人間に聞こえにくい性質上一般的な防災・避難用途には適さない ※この製品自体は可聴音域へ切り替えが可能
サンプル(強い息:音量注意)
ピークは 10000Hz 付近
周波数分布

コクヨ社製 防災用救助笛

  • 防災用救助笛ツインウェーブ - 救助・避難用品 - www.kokuyo-st.co.jp
    ツインウェーブは人の耳に最も良く聞こえる約3000Hz〜5000Hzの周波数の中で、2種類の波長を出すため、より広範囲な人への注意喚起が可能となります。 小さな息でも大きな音を出すことができます。
  • コクヨ 防災用救助笛 防災の達人 ツインウェーブ オレンジ DRK-WS1YR - www.amazon.co.jp
  • 特別対談 山村武彦氏×鈴木松美氏(提供:コクヨ S&T 株式会社) - www.webside.jp
    山村氏 この「ツインウェーブ」ですが、一見、笛とはわかりませんね。・・・
    いつでも、どこでも、誰でもが持ち歩けるというのは、非常に大きなポイントだと思います。・・・
    鈴木氏 ひとつの音だけではなく、2種類、3種類の音が出せれば、より助けを求めやすいのではないかと思います。 ツインウェーブは、3200ヘルツと4800ヘルツの音を同時に出します。・・・
    鈴木氏 ツインウェーブは、通常で700〜800メートル先までは音が届きますから、エレベータで閉じ込められた場合なども有効です。 さらに、軽い息で大きな音を鳴らせるので、お年寄りにも最適です。力がなくても同じ周波数が出せるように、ずいぶん苦労しました。息のエネルギーをほとんど音に変換して、非常に効率がいいんです。 山村氏 この笛の効果は、これからの防災対策に欠かせないように思います。高齢者でも、 寝たきりの人でも、笛さえ吹ければ自らの居場所を知らせることができます。
  • 長所
    • 少ない息でより大きな音を出せる
    • プラスティック製かつ蓋つきで小さく携帯性に優れる
    • コストパフォーマンスが高い(実売¥400前後)
  • 備考
    • 手元での確認では製品名の由来でもある「2種類の波長」はあまりはっきりと観察できなかった。吹き方の影響?
サンプル(強い息:音量注意)
ピークは 3200Hz 付近
周波数分布

モルテン社製 電子ホイッスル

サンプル(音量注意)
ピークは 3800Hz 付近
周波数分布

レモン社製 緊急用呼子笛

  • 緊急用呼子笛 - lemon.gr.jp
  • 緊急用 呼子笛 885800 - www.amazon.co.jp
    携帯に便利なペンダントタイプの緊急用呼子笛です。 身元確認のためのIDカードが付いています。
  • 長所
    • 少ない息で大きく長く音を出せる
    • スリムで軽く携帯しやすい
    • コストパフォーマンスがきわめて高い(実売¥100〜200程度)
  • 備考
    • 「アルミ製」だが剛性に若干の不安あり
サンプル(強い息:音量注意)
ピークは 4300Hz 付近
周波数分布

一般的なホイッスル(メーカー不明)

  • 長所
    • 百均店で買えるなど入手性がよく紛失してもダメージが少ない
    • スタンダードであるため一見して笛と認識でき誰でも簡単に扱える
  • 備考
    • 音量の確保と持続に多くの息が必要
サンプル(強い息:音量注意)
ピークは 2300Hz 付近
周波数分布

どのように通知を行うか

緊急連絡用途を想定すると通知は複数の方法を併用することが好ましいと考えました。今回は以下のみっつの方法を想定しサービスの選定を行いました。

電話機への通知

電話はもっとも手早い通知手段でしょう。電話連絡を手近に扱えるサービスとして米国発の「Twilio」を知りこれを利用することにしました。所定の相手に対し所定の音声メッセージを流すことも簡単に実現できますが、ここでは電話機をコールするだけで受け手は何ごとかの発生を察知できるためコストのかかる音声通話機能を利用する必要はないかもしれません。

Twilio

  • Twilio - APIs for Text Messaging, VoIP & Voice in the Cloud - www.twilio.com
  • Twilio for KDDI Web Communications | クラウド電話API - twilio.kddi-web.com
    Twilio は開発者向けのプラットフォームサービス、たった数行のコードを書くだけでネットと電話・SMS をつなぎます。ネット上の仕組みを電話から操作したり、ネット上の情報を電話や SMS に通知することができるようになります。
  • API リファレンス - 全Twilio APIおよびSDKの詳細リファレンス - jp.twilio.com
    Twilio の REST API を使って、ユーザー アカウント、電話番号、通話、 テキスト メッセージ、録音のメタデータにクエリを送信することができます。 発信通話を開始したりテキスト メッセージを送信したりすることができます。
           :
  • 料金 - twilio.kddi-web.com
    電話番号料 1ヶ月あたり
    050 番号 100 円(税別) 108 円(税込)
           :
    発信料 1 分あたり
           :
    携帯電話宛(070 / 080 / 090 番号) 15 円(税別) 16.2 円(税込)
           :

サインアップするとトライアルアカウントとして試用ポイントが提供され(額は非公開)、同ポイントが尽きるとフルアカウントへのアップグレードとクレジットカード番号の登録を促されるしくみです。このポイントで 050 番号を一件取得しました。プログラムから簡単に電話連絡を扱えるのは便利なのでトライアル終了後も利用を継続したいと考えています。ちなみにコールだけなら上記の「発信料」は発生しません。

メッセージプッシュによる通知

スマホ・デスクトップ(ブラウザ)へのプッシュ通知も行います。メジャーな Pushover や Pushbullet はそれぞれ IFTTT に専用チャネルを持っていることもあり便利に扱えます。Pushover は専用アドレスへのメール送信によって通知処理をキックすることもできます。

メールでの通知

手元で使い慣れた SendGrid を利用してメールでの通知を行います。

試作 1

以下の要領で試作を行いました。

  • 使用したソフトウェア資産
  • インターネット接続の利用に伴い装置に以下のアダプタを追加
  • 処理の流れ
    1. サンプリングレート 33330Hz, FFT サイズ 1024 で集音と周波数分析をループ実行する
    2. FFT 処理において次の帯域のピーク周波数が検知されれば 1. を中断し 3. へ移行する
      ただしノイズによる影響を抑えるため所定の回数以上の連続検知を条件とする
      • 9800Hz〜16000Hz(犬笛系)
      • 2000Hz〜4500Hz(ホイッスル全般)
    3. 前回の通知から所定の時間が経過していれば 4. へ。
      経過していなければ 1. へ(通知の頻発を回避)
    4. Google ドライブ上の所定のエントリに対する GET リクエストを試行。
      レスポンスコードが 200 なら 5. へ
      それ以外なら 1. へ(通知の無効化をリモートから可能とするための措置)
      • 所定のアカウントの Google ドライブ上のエントリへの第三者からのアクセスにおいては、当該エントリの共有がオンの状態なら 200 が返され、共有オフの状態なら 302 が返される。この仕様を低コストで取り回せる簡易フラグとして利用
    5. Twilio へ手持ちの電話のコールをリクエスト
      • 今回はコールのみを目的とし短時間でタイムアウトさせる
    6. SendGrid へ手持ち Gmail アドレスへのメール通知をリクエスト
      • Gmail のフィルタ設定により当該メールは Pushover 通知キック用のアドレスへ転送
    7. ふたたび 1. へ

動作の様子

動画:2分31秒  ※音量注意

ソースコード

Sonic06_FFT3

  • main.cpp
    
    // 所定の周波数信号を所定の回数以上連続検知すると通知を行う
    
    #include "mbed.h"
    #include "rtos.h"
    #include "EthernetInterface.h"
    #include "HTTPClient.h"
    #include "FastAnalogIn.h"
    
    #define DEVELOP
    #ifdef DEVELOP
    #define dbg(...) printf(__VA_ARGS__)
    #else
    #define dbg(...)
    #endif
    
    //#define USE_DHCP
    #if !defined(USE_DHCP)
    #define IP      "192.168.0.113"
    #define GATEWAY "192.168.0.1"
    #define MASK    "255.255.255.0"
    #endif
    
    #define SAMPLE_RATE 33330
    // n >= 2, n = power of 2
    #define FFTSIZE 1024
    // length of ip >= 2+sqrt(n/2)
    const int IP_LEN = 4 + sqrt((double)(FFTSIZE/2));
    // 当該サンプリングレートでのサンプリング間隔
    const float SEC_PER_SAMPLEPOINT = 1.0 / SAMPLE_RATE;
    
    // 通知の最短間隔秒
    #define INTERVAL_NOTIFICATION 180
    
    // 犬笛系周波数帯
    #define FREQ_LOW1   9800
    #define FREQ_HIGH1 16000
    
    // ホイッスル系周波数帯
    #define FREQ_LOW2  2000
    #define FREQ_HIGH2 4500
    
    // 対象周波数連続検知回数閾値
    #define MAXHITCOUNT 20
    
    // for Twilio
    #define TWILIO_SID  "AC1f7aa23e****************"
    #define TWILIO_AUTH "ef3638ca7*****************"
    #define URL_POSTDATA_TWILIO \
      "https://api.twilio.com/2010-04-01/Accounts/" TWILIO_SID "/Calls"
    #define REQUEST_HEADER_FOR_TWILIO \
      "Content-Type: "       "application/x-www-form-urlencoded"   "\r\n"
    #define POSTDATA_TWILIO \
      "To=%2B8190********" "&" \
      "From=%2B8150********" "&"  \
      "Url=http://demo.twilio.com/docs/voice.xml"  "&" \
      "Timeout=5"
    
    // for SendGrid
    #define SENDGRID_APIKEY "SG.UNFbQr_DQRmu8J********"
    #define REQUEST_HEADER_FOR_SENDGRID \
      "Authorization: Bearer " SENDGRID_APIKEY "\r\n" \
      "Content-Type: application/x-www-form-urlencoded\r\n"
    #define URL_POSTDATA_SENDGRID "https://api.sendgrid.com/api/mail.send.json"
    #define MAILTO    "********@gmail.com"
    #define MAILFROM  "mkttanabe2012@gmail.com"
    #define MAILSUBJECT "** ALERT [%s] **"
    #define MAILBODY  "%dHz"
    
    // Google ドライブ上のフラグエントリ URL
    #define URL_FLAGFILE \
      "https://docs.google.com/spreadsheets/d/1FR_RaR_AsyPAsIYU****************"
    
    Ticker tick;
    double *recBufp = NULL;
    int countTick = 0;
    FastAnalogIn mic(p20);
    AnalogOut speaker(p18);
    DigitalOut led(LED4);
    
    osThreadId mainThreadID;
    EthernetInterface eth;
    HTTPClient http;
    
    void rdft(int n, int isgn, double *a, int *ip, double *w);
    
    void setDummyTime() {
      struct tm t;
      t.tm_sec = t.tm_min = t.tm_hour = t.tm_mday = t.tm_mon = 1;
      t.tm_year = 100;
      time_t seconds = mktime(&t);
      set_time(seconds);
    }
    
    int initNetwork() {
      int sts;
      led = 1;
      dbg("start eth.init\r\n" );
    #ifdef USE_DHCP
      sts = eth.init();
    #else
      sts = eth.init(IP, MASK, GATEWAY);
    #endif
      if (sts != 0) {
        dbg("ech.init error!\r\n" );
        return -1;
      }
      dbg("start eth.connect\r\n" );
      sts = eth.connect();
      if (sts != 0) {
        dbg("eth.connect error!\r\n" );
        return -2;
      }
      dbg("my IP Address = %s\r\n", eth.getIPAddress());
      led = 0;
      return 0;
    }
    
    // Googleドライブ上の所定のフラグエントリをチェック
    int checkFlagFile() {
      char buf[8];
      for (int i = 0; i < 6; i++) {
        int sts = http.get(URL_FLAGFILE, buf, sizeof(buf),
              HTTP_CLIENT_DEFAULT_TIMEOUT);
        if (sts == HTTP_OK) {
          int rc = http.getHTTPResponseCode();
          // 共有オンなら 200, オフなら 302
          dbg("Flag is %s\r\n", 
            (rc == 200) ? "enabled" :
            (rc == 302) ? "disabled" : "?????");
          return rc;
        }
        dbg("sts=%d retrying..\r\n", sts);
        wait(5);
      }
      return -1;
    }
    
    int httpsPost(const char *targetUrl,
          const char *postData,
          const char *optHeader,
          const char *authId,
          const char *authPass) {
      int sts;
      char buf[64];
      HTTPText dataToPost((char*)postData);
      HTTPText resData(buf, sizeof(buf));
      http.setHeader(optHeader);
    
      for (int i = 0; i < 3; i++) {
        dbg("request to [%s]..\r\n", targetUrl);
        http.basicAuth(authId, authPass);
        sts = http.post(targetUrl, dataToPost, &resData, HTTP_CLIENT_DEFAULT_TIMEOUT);
        if (sts == HTTP_OK) {
          dbg("received HTTPS response OK\r\n");
          dbg("head of data [%s]\r\n", buf);
          dbg("done\r\n\r\n");
          break;
        } else {
          dbg("HTTPS request error=%d, status=%d\r\n", sts, http.getHTTPResponseCode());
          dbg("head of data [%s]\r\n", buf);
          dbg("sts=%d retrying..\r\n", sts);
          wait(5);
        }
      }
      return sts;
    }
    
    int doFFT(double *FFTBuffer) {
      int ip[IP_LEN];
      double *w = (double*)malloc(sizeof(double) * (FFTSIZE / 2));
      ip[0] = 0;
      rdft(FFTSIZE, 1, FFTBuffer, ip, w);
    
      double maxAmp = 0;
      int index = 0;
      int endIdx = FFTSIZE/2;
      for (int i = 0; i < endIdx ; i++) {
        double a = FFTBuffer[i*2]; // 実部
        double b = FFTBuffer[i*2 + 1]; // 虚部
        // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
        double r = sqrt(a*a + b*b);
        if (r > maxAmp) {
          maxAmp = r;
          index = i;
        }
      }
      float freq = index * SAMPLE_RATE / FFTSIZE;
      free(w);
      return (int)freq;
    }
    
    // サンプリング
    void flip() {
      // AnalogIn.read() の値範囲は 0〜1.0 につき中心値を 0 に
      recBufp[countTick++] = mic.read() - 0.5;
      if (countTick == FFTSIZE) {
        tick.detach();
        countTick = 0;
        osSignalSet(mainThreadID, 1); // 集音完了通知
      }
    }
    
    // 通知
    int checkAndNotify(int freq, time_t *lastNotificationTime) {
      int pastSeconds = time(NULL) - *lastNotificationTime;
      // 前回の通知から所定の時間が経過の場合のみ次処理へ
      if (pastSeconds > INTERVAL_NOTIFICATION) {
        // 通信処理のために 集音・FFT用バッファを一旦開放
        free(recBufp);
        recBufp = NULL;
        // Google ドライブ上の所定のフラグエントリをチェック
        // 共有がオンなら 200 が返り オフなら が返る
        dbg("checking flagFile..\r\n");
        int rc = checkFlagFile();
        dbg("rc=%d\r\n", rc);
        if (rc == 200) {
          for (int i = 0; i < 8; i++) {
            led = !led;
            wait_ms(200);
          }
          // Twilio で携帯電話をコール
          httpsPost(URL_POSTDATA_TWILIO, POSTDATA_TWILIO,
            REQUEST_HEADER_FOR_TWILIO, TWILIO_SID, TWILIO_AUTH);
    
          // SendGrid でメール送信
          char b[256];
          snprintf(b, sizeof(b), "from=" MAILFROM
              "&to=" MAILTO "&subject=" MAILSUBJECT
              "&text=" MAILBODY, eth.getIPAddress(), freq);
          dbg("mes=[%s]\r\n", b);
          
          httpsPost(URL_POSTDATA_SENDGRID, b,
            REQUEST_HEADER_FOR_SENDGRID, NULL, NULL);
    
          // 今回通知時刻を保持
          *lastNotificationTime = time(NULL);
        }
        recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
        return 1;
      }
      // 前回の発呼から所定の時間が経過していない
      dbg("pastSeconds=%d..\r\n", pastSeconds);   
      return 0;
    }
    
    int main()
    {
      mainThreadID = osThreadGetId();
      setDummyTime();
      initNetwork();
      // 集音用兼FFT用バッファ
      recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
      time_t lastNotificationTime = 0;
      dbg("sampleRate=%dHz interval=%fsec\r\n", SAMPLE_RATE, SEC_PER_SAMPLEPOINT);
      
      tick.attach(&flip, SEC_PER_SAMPLEPOINT);
      // 対象周波数信号検出数
      int hitCount = 0;
    
      while (1) {
        // サンプリング完了まで待機
        osSignalWait(1, osWaitForever);
        // ピーク周波数を得る
        int freq = doFFT(recBufp);
        if (freq > FREQ_LOW1 && freq <= FREQ_HIGH1 ||
          freq > FREQ_LOW2 && freq <= FREQ_HIGH2) {
          dbg("detected! %dHz\r\n", freq);
          led = 1;
          // 所定回数以上連続検知の場合のみ通知処理へ
          if (hitCount++ > MAXHITCOUNT) {
            hitCount = 0;
            checkAndNotify(freq, &lastNotificationTime);
          }
        } else {
          if (hitCount > 0) {
            dbg("ignored..\r\n");
          }
          hitCount = 0;
          led = 0;
        }
        tick.attach(&flip, SEC_PER_SAMPLEPOINT);
      }
      if (recBufp) {
        free(recBufp);
      }
      return 0;
    }
    
    

試作 1 の問題点について

試作 1 では集音した信号の中でもっとも振幅の大きい周波数(ピーク周波数)を評価しており、また、対象範囲の周波数が FFT 処理において所定の回数以上 "連続して" 検知されることを判定条件としています。そのため周辺音の大きな環境では対象音を取りこぼす可能性があり、装置・音源間の距離が判定に影響を及ぼす度合いも少なくありません。この点の改善が必要と考えました。

下の動画には静かな状況と騒がしい状況での装置の反応を確認した様子を収めています。このように周囲の音が大きいと負けてしまいます。

動画:41秒  ※音量注意!

(似た話:Amazon Echo が反応しないので大声で怒鳴る人:Youtube より)

試作 2

以下の内容でプログラムの修正を行いました。

  1. ピーク周波数ベースの判定を変更
    • ピーク周波数の参照に代え信号に含まれる対象範囲の周波数成分を能動的にチェックする。これにより周辺音による干渉を抑えられる
  2. 検知の考え方
    • 上記のチェックにおいて所定のレベル以上の振幅値を伴うものの発見をもって検知とみなす。これにより対象音が微弱でもピックアップが可能となる
  3. 連続性の判定方法
    • 音の連続性の判定は「対象周波数が所定の回数以上続けてピーク検出されること」を条件としていたが、これを「前回の検出から所定時間内の再検出が所定の回数以上繰り返されること」に変更する。これにより対象音が途切れがちでも切り捨ての可能性を抑えられる
  4. 対象周波数範囲の見直し
    • 従来は 2000Hz〜4500Hz(ホイッスル全般)および 9800Hz〜16000Hz(犬笛系)のふたつのレンジを対象としていたが、前者は 基音として日常生活に登場する機会が少なくないため混乱につながるケースも想定される。そこで「上音」の存在に注目した
      • 倍音 - wikipedia
        上音 音を正弦波に分解したときに、最も低い周波数である基音以外の成分を 上音(じょうおん、overtone)という。この上音には倍音でない音も含まれる。 倍音は、基音の(2以上の)整数倍の周波数の上音であると言い換えることができる。
      • どのような音を使うか」項に掲載の各音源の周波数分布(スペクトル)図を参照すると倍音を含む上音が高周波領域まで豊かに鳴っている様子が見てとれる。抜粋を以下に示す。※図の縦軸最大値は 15kHz
      • 上記 1, 2 の措置により振幅の小さな成分であっても細かく検知可能となる背景もあり、実験的に 10000Hz 以上の音を対象とする改訂を加えた。サンプリングレート 33330Hz で集音しているため理論上 16600Hz 付近まで拾うことが可能  →サンプリング定理
        使用しているマイクモジュールのカバーする音域は公称「100Hz〜15kHz」 →データシート
      • むろん周囲の生活音等が 10000Hz 以上の上音を発生させることもあるが、意図的なものでない限り上記 3 の連続性チェックをすり抜ける可能性は実用上低いと考えられる

動作の様子

動画:2分3秒  ※音量注意!

ソースコード

(利用するプッシュ通知サービスを Pushover から Pushbullet に変更しています)

Sonic06_FFT3

  • main.cpp
    
    // 所定の周波数信号を所定の回数以上連続検知すると通知を行う
    // 改訂版:連続性のある 10kHz 以上の周波数成分をターゲットとする
    
    #include "mbed.h"
    #include "rtos.h"
    #include "EthernetInterface.h"
    #include "HTTPClient.h"
    #include "FastAnalogIn.h"
    
    #define DEVELOP
    #ifdef DEVELOP
    #define dbg(...) printf(__VA_ARGS__)
    #else
    #define dbg(...)
    #endif
    
    //#define USE_DHCP
    #if !defined(USE_DHCP)
    #define IP      "192.168.0.113"
    #define GATEWAY "192.168.0.1"
    #define MASK    "255.255.255.0"
    #endif
    
    #define SAMPLE_RATE 33330
    // n >= 2, n = power of 2
    #define FFTSIZE 1024
    // length of ip >= 2+sqrt(n/2)
    const int IP_LEN = 4 + sqrt((double)(FFTSIZE/2));
    // 当該サンプリングレートでのサンプリング間隔
    const float SEC_PER_SAMPLEPOINT = 1.0 / SAMPLE_RATE;
    
    // 通知の最短間隔秒
    #define INTERVAL_NOTIFICATION 180
    
    #define MAXHITCOUNT 30
    short indexOf10000Hz;
    
    // for Twilio
    #define TWILIO_SID  "AC1f7aa23e****************""
    #define TWILIO_AUTH "ef3638ca7****************""
    #define URL_POSTDATA_TWILIO \
        "https://api.twilio.com/2010-04-01/Accounts/" TWILIO_SID "/Calls"
    #define REQUEST_HEADER_FOR_TWILIO \
      "Content-Type: "       "application/x-www-form-urlencoded"   "\r\n"
    #define POSTDATA_TWILIO \
      "To=%2B8190********"" "&" \
      "From=%2B8150********"" "&"  \
      "Url=http://demo.twilio.com/docs/voice.xml"  "&" \
      "Timeout=5"
    
    // for SendGrid
    #define SENDGRID_APIKEY "SG.UNFbQr_DQRmu8J********"
    #define REQUEST_HEADER_FOR_SENDGRID \
      "Authorization: Bearer " SENDGRID_APIKEY "\r\n" \
      "Content-Type: application/x-www-form-urlencoded\r\n"
    #define URL_POSTDATA_SENDGRID "https://api.sendgrid.com/api/mail.send.json"
    #define MAILTO    "********@gmail.com"
    #define MAILFROM  "mkttanabe2012@gmail.com"
    #define MAILSUBJECT "** ALERT [%s] **"
    #define MAILBODY  "%dHz"
    
    // for IFTTT
    #define IFTTT_KEY "dSIwDguVEYfZ1SgiISL"********@"
    #define IFTTT_EVENT_NAME "ALERT"
    #define REQUEST_HEADER_FOR_IFTTT \
      "Content-Type: application/json\r\n"
    #define URL_POSTDATA_IFTTT  "https://maker.ifttt.com/trigger/" \
          IFTTT_EVENT_NAME "/with/key/" IFTTT_KEY
    
    // Google ドライブ上のフラグエントリ URL
    #define URL_FLAGFILE \
      "https://docs.google.com/spreadsheets/d/1FR_RaR_AsyPAsIYU****************"
    
    Ticker tick;
    double *recBufp = NULL;
    int countTick = 0;
    FastAnalogIn mic(p20);
    AnalogOut speaker(p18);
    DigitalOut led(LED4);
    
    osThreadId mainThreadID;
    EthernetInterface eth;
    HTTPClient http;
    
    void rdft(int n, int isgn, double *a, int *ip, double *w);
    
    void setDummyTime() {
      struct tm t;
      t.tm_sec = t.tm_min = t.tm_hour = t.tm_mday = t.tm_mon = 1;
      t.tm_year = 100;
      time_t seconds = mktime(&t);
      set_time(seconds);
    }
    
    int initNetwork() {
      int sts;
      led = 1;
      dbg("start eth.init\r\n" );
    #ifdef USE_DHCP
      sts = eth.init();
    #else
      sts = eth.init(IP, MASK, GATEWAY);
    #endif
      if (sts != 0) {
        dbg("ech.init error!\r\n" );
        return -1;
      }
      dbg("start eth.connect\r\n" );
      sts = eth.connect();
      if (sts != 0) {
        dbg("eth.connect error!\r\n" );
        return -2;
      }
      dbg("my IP Address = %s\r\n", eth.getIPAddress());
      led = 0;
      return 0;
    }
    
    // Googleドライブ上の所定のフラグエントリをチェック
    int checkFlagFile() {
      char buf[8];
      for (int i = 0; i < 6; i++) {
        int sts = http.get(URL_FLAGFILE, buf, sizeof(buf),
              HTTP_CLIENT_DEFAULT_TIMEOUT);
        if (sts == HTTP_OK) {
          int rc = http.getHTTPResponseCode();
          // 共有オンなら 200, オフなら 302
          dbg("Flag is %s\r\n", 
            (rc == 200) ? "enabled" :
            (rc == 302) ? "disabled" : "?????");
          return rc;
        }
        dbg("sts=%d retrying..\r\n", sts);
        wait(5);
      }
      return -1;
    }
    
    int httpsPost(const char *targetUrl,
          const char *postData,
          const char *optHeader,
          const char *authId,
          const char *authPass) {
      int sts;
      char buf[64];
      HTTPText dataToPost((char*)postData);
      HTTPText resData(buf, sizeof(buf));
      http.setHeader(optHeader);
      
      for (int i = 0; i < 3; i++) {
        //dbg("postData=[%s]\r\n", postData);
        dbg("request to [%s]..\r\n", targetUrl);
        http.basicAuth(authId, authPass);
        sts = http.post(targetUrl, dataToPost, &resData, HTTP_CLIENT_DEFAULT_TIMEOUT);
        if (sts == HTTP_OK) {
          dbg("received HTTPS response OK\r\n");
          dbg("head of data [%s]\r\n", buf);
          dbg("done\r\n\r\n");
          break;
        } else {
          dbg("HTTPS request error=%d, status=%d\r\n", sts, http.getHTTPResponseCode());
          dbg("head of data [%s]\r\n", buf);
          dbg("sts=%d retrying..\r\n", sts);
          wait(5);
        }
      }
      return sts;
    }
    
    int doFFT(double *FFTBuffer) {
      int ip[IP_LEN];
      double *w = (double*)malloc(sizeof(double) * (FFTSIZE / 2));
      ip[0] = 0;
      rdft(FFTSIZE, 1, FFTBuffer, ip, w);
      bool found = false;
      float freq;
      int endIdx = FFTSIZE/2;
      for (int i = endIdx - 1; i >= indexOf10000Hz; i--) {
        freq = i * SAMPLE_RATE / FFTSIZE;
        double a = FFTBuffer[i*2];
        double b = FFTBuffer[i*2 + 1];
        double mag = sqrt(a*a + b*b);
        if (mag > 0.2) {
          dbg("freq=%f mag=%f\r\n", freq, mag);
          found = true;
          break;
        }
      }
      free(w);
      return (found) ? (int)freq : 0;
    }
    
    // サンプリング
    void flip() {
      // AnalogIn.read() の値範囲は 0〜1.0 につき中心値を 0 に
      recBufp[countTick++] = mic.read() - 0.5;
      if (countTick == FFTSIZE) {
        tick.detach();
        countTick = 0;
        osSignalSet(mainThreadID, 1); // 集音完了通知
      }
    }
    
    // 通知
    int checkAndNotify(int freq, time_t *lastNotificationTime) {
      int pastSeconds = time(NULL) - *lastNotificationTime;
      // 前回の通知から所定の時間が経過の場合のみ次処理へ
      if (pastSeconds > INTERVAL_NOTIFICATION) {
        // 通信処理のために 集音・FFT用バッファを一旦開放
        free(recBufp);
        recBufp = NULL;
        // Google ドライブ上の所定のフラグエントリをチェック
        // 共有がオンなら 200 が返り オフなら が返る
        dbg("checking flagFile..\r\n");
        int rc = checkFlagFile();
        dbg("rc=%d\r\n", rc);
        if (rc == 200) {
          for (int i = 0; i < 8; i++) {
            led = !led;
            wait_ms(200);
          }
          // Twilio で携帯電話をコール
          httpsPost(URL_POSTDATA_TWILIO, POSTDATA_TWILIO,
            REQUEST_HEADER_FOR_TWILIO, TWILIO_SID, TWILIO_AUTH);
    
          // SendGrid でメール送信
          char b[256];
          snprintf(b, sizeof(b), "from=" MAILFROM
              "&to=" MAILTO "&subject=" MAILSUBJECT
              "&text=" MAILBODY, eth.getIPAddress(), freq);
          dbg("mes=[%s]\r\n", b);
          httpsPost(URL_POSTDATA_SENDGRID, b,
            REQUEST_HEADER_FOR_SENDGRID, NULL, NULL);
    
          // IFTTT 経由で Pushbullet 通知をキック
          snprintf(b, sizeof(b),
            "{\"value1\":\"ALERT [%s]\", \"value2\":\"%dHz\", \"value3\":\"\"}\r\n",
            eth.getIPAddress(), freq);
          httpsPost(URL_POSTDATA_IFTTT, b,
            REQUEST_HEADER_FOR_IFTTT, NULL, NULL);
    
          // 今回通知時刻を保持
          *lastNotificationTime = time(NULL);
        }
        recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
        return 1;
      }
      // 前回の発呼から所定の時間が経過していない
      dbg("pastSeconds=%d..\r\n", pastSeconds);   
      return 0;
    }
    
    int main()
    {
       dbg("**** TARGET FREQUENCY MODE ****\r\n");
       int endIdx = FFTSIZE/2 - 1;
       for (int i = 0; i <= endIdx; i++) {
        float freq0 = i * SAMPLE_RATE / FFTSIZE;
        // 10kHz 以上の高周波音をターゲットとする
        if (freq0 >= 10000) {
          indexOf10000Hz = i;
          float freq1 = endIdx * SAMPLE_RATE / FFTSIZE;
          dbg("idx0=%d freq=%f\r\nidx1=%d freq=%f\r\n",
              i, freq0, endIdx, freq1);
          break;
        }
      }
      mainThreadID = osThreadGetId();
      setDummyTime();
      initNetwork();
      // 集音用兼FFT用バッファ
      recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
      time_t lastNotificationTime = 0;
      
      dbg("sampleRate=%dHz interval=%fsec\r\n", SAMPLE_RATE, SEC_PER_SAMPLEPOINT);
      
      tick.attach(&flip, SEC_PER_SAMPLEPOINT);
      // 対象周波数信号検出数
      int hitCount = 0;
      time_t prevHitTime = 0;
      while (1) {
        osSignalWait(1, osWaitForever);
        // 監視対象とする周波数領域の成分が所定レベル以上の振幅ならその周波数、
        // そうでなければ 0 が返る
        int freq = doFFT(recBufp);
        if (freq != 0) {
          // 前回検出から1秒以内の再検出ならカウントアップ
          if (time(NULL) - prevHitTime <= 1) {
            // 所定の回数以上検知の場合は通知処理へ
            if (++hitCount >= MAXHITCOUNT) {
              dbg("checkAndNotify\r\n");
              checkAndNotify(freq, &lastNotificationTime);
              hitCount = 0;   
            }
          } else {
            hitCount = 1;
            dbg("restart\r\n");
          }
          prevHitTime = time(NULL);
        }
        tick.attach(&flip, SEC_PER_SAMPLEPOINT);
      }
      if (recBufp) {
        free(recBufp);
      }
      return 0;
    }
    
    

2. 着信音で遠隔操作を行うしくみを作る

自宅の固定電話機の着信音をトリガーに処理を行うしくみを考えました。次のイメージです。

  • 手持ちの携帯電話など所定の電話番号からのコールに対する固有の着信音を自宅固定電話へ設定しておく
  • 電話機の側に設置した装置は当該着信音によるコールを待ち続ける
  • 装置は所定回数のコールを連続して検知すると所定の機器への通電をオン/オフする
使途として防犯目的での屋内照明の遠隔操作などが考えられるでしょう。

過去の試みとの比較

手元で過去に何度か同様の遠隔操作を試みたことがあります。いずれもインターネット上のサービスを利用して装置へ通知を行い、それをトリガーに処理を行うものです。

これらはいずれもおおむね期待通りに動きましたが、一方で次のようなジレンマもありました。

  • 外部サービス側処理の遅延発生や一時停止の可能性を回避することはできない:問題 大
  • 遠隔操作にはインターネット接続環境が必須:問題 中
  • クライアントアプリ(Webブラウザを含む)の操作に手間がかかる:問題 小

それに対して、装置への通知に「音」を利用することには次のメリットが挙げられます。

  • 災害時等の特殊な状況以外での発着信上のトラブルの可能性はほぼ度外視できる
  • 発着信にはネット接続環境も特別なアプリケーションも不要
  • 発信電話番号にこだわらなければ携帯電話機を携行する必要もない

こういった特長を活かしうまく使えば有用なツールとなりそうです。

準備

現在自宅では固定電話機としてユニデン社製の「UCT-002」を使っています。

トリガーとする着信音として、この UCT-002 の「パターン2」を選びました。次のような音です。

  • 音の様子
  • 波形の概観(Audacity による)
  • 周波数分布
特徴をまとめてみます。

  • 1秒間発振 〜 2秒間無音 を繰り返す
  • 複数の周波数の音を小刻みに発振
  • mbed 上で前出のプログラムを使ってピーク周波数を確認すると以下の成分が検出された
    • 1204Hz
    • 1009Hz
    • 976Hz
    • 813Hz
    • 781Hz

試作

以上の内容をもとに次の要領で試作を行いました。

  • この電話機は装置への密接が可能であるためここでは常にピーク周波数に注目する
  • 上に挙げた 5種類の周波数成分を監視対象とする
  • 「対象周波数を所定回数以上連続検知すること」と「その後に 2秒程度の非検知が続くこと」をもって一回のコールと判断する
  • コールを 3回連続して認識した後にもう一度対象周波数を検知するとアクションを実行する
  • アクションの内容は AC電源機器への通電。前に作ったリレーつき電源ケーブルを mbed LPC1768 の p10 に接続し Hi / Low で機器への通電有無を切り替える
  • アクション発生時にはメールで通知する

動作の様子

動画:1分40秒  ※音量注意!

この動画では携帯電話から自宅固定電話をコールして照明の ON/OFF を行っています。

動画:1分24秒  ※音量注意!

PC 上で対象着信音をサンプリング〜改変したものを数種類鳴らし、装置がそれらには応じない様子を収めています。

ソースコード

Sonic09_FFT4

  • main.cpp
    
    // 所定パターンの電話着信音に反応してアクションを起こす
    // ユニデン社製 UCT-002 の着信音「パターン2」への対応版
    // 1秒発振〜2秒無音 の繰り返し
    
    #include "mbed.h"
    #include "rtos.h"
    #include "EthernetInterface.h"
    #include "HTTPClient.h"
    
    #define DEVELOP
    #ifdef DEVELOP
    #define dbg(...) printf(__VA_ARGS__)
    #else
    #define dbg(...)
    #endif
    
    //#define USE_DHCP
    #if !defined(USE_DHCP)
    #define IP      "192.168.0.113"
    #define GATEWAY "192.168.0.1"
    #define MASK    "255.255.255.0"
    #endif
    
    #define SAMPLE_RATE 33330
    // n >= 2, n = power of 2
    #define FFTSIZE 1024
    // length of ip >= 2+sqrt(n/2)
    const int IP_LEN = 4 + sqrt((double)(FFTSIZE/2));
    
    // 発振音に含まれる周波数成分(監視対象)
    int tgtF[] = {1204, 1009, 976, 813, 781};
    #define isTargetFreq(F) \
      (F == tgtF[0] || F == tgtF[1] || F == tgtF[2] || F == tgtF[3] || F == tgtF[4])
    
    // for SendGrid
    #define SENDGRID_APIKEY "SG.UNFbQr_DQRmu8J********"
    #define REQUEST_HEADER_FOR_SENDGRID \
      "Authorization: Bearer " SENDGRID_APIKEY "\r\n" \
      "Content-Type: application/x-www-form-urlencoded\r\n"
    #define URL_POSTDATA_SENDGRID "https://api.sendgrid.com/api/mail.send.json"
    #define MAILTO    "********@gmail.com"
    #define MAILFROM  "mkttanabe2012@gmail.com"
    #define MAILSUBJECT "** Power %s **"
    #define MAILBODY  "callCount:%d"
    
    Ticker tick;
    double *recBufp = NULL;
    int countTick;
    Timer timer;
    bool recDone;
    AnalogIn mic(p20);
    DigitalOut led(LED4);
    DigitalOut relay(p10); // リレーつき電源ケーブルを接続
    
    EthernetInterface eth;
    HTTPClient http;
    osThreadId mainThreadID;
    
    // 当該サンプリングレートでのサンプリング間隔
    const float SEC_PER_SAMPLEPOINT = 1.0 / SAMPLE_RATE;
    // 音声信号は実数データにつき実離散フーリエ変換(Real DFT)関数を使う
    void rdft(int n, int isgn, double *a, int *ip, double *w);
    
    void setDummyTime() {
      struct tm t;
      t.tm_sec = t.tm_min = t.tm_hour = t.tm_mday = t.tm_mon = 1;
      t.tm_year = 100;
      time_t seconds = mktime(&t);
      set_time(seconds);
    }
    
    int initNetwork() {
      int sts;
      led = 1;
      dbg("start eth.init\r\n" );
    #ifdef USE_DHCP
      sts = eth.init();
    #else
      sts = eth.init(IP, MASK, GATEWAY);
    #endif
      if (sts != 0) {
        dbg("ech.init error!\r\n" );
        return -1;
      }
      dbg("start eth.connect\r\n" );
      sts = eth.connect();
      if (sts != 0) {
        dbg("eth.connect error!\r\n" );
        return -2;
      }
      dbg("my IP Address = %s\r\n", eth.getIPAddress());
      led = 0;
      return 0;
    }
    
    int httpsPost(const char *targetUrl,
          const char *postData,
          const char *optHeader,
          const char *authId,
          const char *authPass) {
      int sts;
      char buf[64];
      HTTPText dataToPost((char*)postData);
      HTTPText resData(buf, sizeof(buf));
      http.setHeader(optHeader);
      
      for (int i = 0; i < 3; i++) {
        //dbg("postData=[%s]\r\n", postData);
        dbg("request to [%s]..\r\n", targetUrl);
        http.basicAuth(authId, authPass);
        sts = http.post(targetUrl, dataToPost, &resData, HTTP_CLIENT_DEFAULT_TIMEOUT);
        if (sts == HTTP_OK) {
          dbg("received HTTPS response OK\r\n");
          dbg("head of data [%s]\r\n", buf);
          dbg("done\r\n\r\n");
          break;
        } else {
          dbg("HTTPS request error=%d, status=%d\r\n", sts, http.getHTTPResponseCode());
          dbg("head of data [%s]\r\n", buf);
          dbg("sts=%d retrying..\r\n", sts);
          wait(5);
        }
      }
      return sts;
    }
    
    int doFFT(double *FFTBuffer) {
      int ip[IP_LEN];
      double *w = (double*)malloc(sizeof(double) * (FFTSIZE/2));
      ip[0] = 0;
      rdft(FFTSIZE, 1, FFTBuffer, ip, w);
    
      double maxAmp = 0;
      int index;
      for (int i = 0; i < FFTSIZE/2; i++) {
        double a = FFTBuffer[i*2]; // 実部
        double b = FFTBuffer[i*2 + 1]; // 虚部
        double r = sqrt(a*a + b*b);
        if (r > maxAmp) {
          maxAmp = r;
          index = i;
        }
      }
      double freq = index * SAMPLE_RATE / FFTSIZE;
      if (freq > 0 && maxAmp > 1.0) {
        //dbg("freq=%d mag=%f\r\n", (int)freq, maxAmp);
      }
      free(w);
      return (int)freq;
    }
    
    // サンプリング
    void flip() {
      // AnalogIn.read() の値範囲は 0〜1.0 につき中心値を 0 に
      recBufp[countTick++] = mic.read() - 0.5;
      if (countTick == FFTSIZE) {
      tick.detach();
      osSignalSet(mainThreadID, 1); // 集音完了通知
      }
    }
    
    // メール送信
    void sendMail(int callCount) {
      char b[128];
      snprintf(b, sizeof(b), "from=" MAILFROM
            "&to=" MAILTO "&subject=" MAILSUBJECT
            "&text=" MAILBODY, (relay == 1) ? "ON" : "OFF", callCount+1);
      httpsPost(URL_POSTDATA_SENDGRID, b,
            REQUEST_HEADER_FOR_SENDGRID, NULL, NULL);
    }
    
    int main() {
      time_t lastDetectedTime = 0, lastActionTime = 0;
      time_t lastNonDetectedTime = 0;
      int blankPeriod = 0, ringCount = 0, callCount = 0;
    
      relay = 0;
      setDummyTime();
      mainThreadID = osThreadGetId();
      initNetwork();
      recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
      dbg("start\r\n");
    
      while (1) {
        countTick = 0;
        tick.attach(&flip, SEC_PER_SAMPLEPOINT);
        osSignalWait(1, osWaitForever);
        if (time(NULL) - lastDetectedTime >= 4) {
          // 4秒以上対象音を非検知ならコールカウンタをリセット
          callCount = 0;
        }
        int freq = doFFT(recBufp);
        if (isTargetFreq(freq)) {
          // 前回のアクションから所定の時間が経過していなければ受け流す
          if (lastActionTime > 0) {
            int w = time(NULL) - lastActionTime;
            if (w < 30) {
              dbg("pending.. %dsec\r\n", w);
              continue;
            }
          }
          ringCount++;
          if (callCount >= 3) {
            // アクション実行
            relay = !relay;
            dbg("Power %s\r\n", (relay == 1) ? "On" : "Off");
            free(recBufp);
            sendMail(callCount);
            recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
            callCount = ringCount = 0;
            lastActionTime = time(NULL);
          }
          lastDetectedTime = time(NULL);
        } else { // 非対象音
          blankPeriod += (time(NULL) - lastNonDetectedTime);
          if (blankPeriod > 30) {
            blankPeriod = 0;
          }
          lastNonDetectedTime = time(NULL);
          if (ringCount > 7) {
            if (callCount == 0 ||
              blankPeriod >= 2 && blankPeriod < 4) {
              callCount++;
              dbg("call count=%d\r\n", callCount);
              blankPeriod = 0;
            }
          }
          ringCount = 0;
        }
      }
      if (recBufp) {
        free(recBufp);
      }
      return 0;
    }
    
    

おわりに

音を題材に手元で行ってきた実験と試作の内容を 5 回に分けて紹介しました。音を媒体とする手法は旧くて新しい奥の深い世界です。新たな製品やサービス・アプリケーションを構築する際の道具立てのひとつとして、あるいは、他の方法がすべて利用できない場合の伏兵としても有用でしょう。とても興味深い経験でした。


(tanabe)
klab_gijutsu2 at 11:54|この記事のURLComments(0)TrackBack(0)
2017年06月20日

音を利用する 4 〜mbed マイコンでの応用:準備編〜

はてなブックマークに登録

この記事の内容

IoT 方面での応用を想定しマイコン系でも音に関する実験と試作を行いました。今回はその準備編です。ここではプラットフォームとして mbed LPC1768 ボードを使用しています。基本的な考え方は環境に依存しないため要素の多くは他の製品向けに読み替えることも可能でしょう。

準備 1. 部品を用意する

音声データの入出力用に以下の部品を用意しました。

マイクモジュールを mbed LPC1768 の p20(AnalogIn)に、オーディオアンプを p18(AnalogOut)に接続しました。これを実験に使います。

準備 2. mbed マイコンでの音声入出力処理を覚える

何はともあれ音声データの入出力方法からです。リファレンスを確認します。

  • AnalogIn - アナログ入力 - developer.mbed.org
    val = name.read();
    valには0(0V)〜1.0(3.3V)の数値(float)が代入されます。

    val = name.read_u16();
    valには0(0V)〜65535(0xffff, 3.3V)の数値が代入されます。(上位12ビットが有効)
    • AN10974 LPC176x/175x 12-bit ADC design guidelines(PDF) - www.nxp.com
      Page 3 of 19 より
      The LPC175x/6x family is based on the ARM Cortex M3 core, and includes a 12-bit Analog-to-Digital (ADC) module with input multiplexing among eight pins, conversion rates up to 200 kHz, and multiple result registers.
  • AnalogOut - アナログ出力 - developer.mbed.org
    name = 0.5;
    0(0V)〜1(3.3V)の数値(float)で出力を指定します。

    name.write_u16 = 1000;
    0(0V)〜65535(0xffff, 3.3V)の数値(unsigned short)で出力を指定します。(上位10ビットが有効)

試作 1

まず、ごく素朴な録音・再生プログラムを書いてみました。次の内容です。

  • サンプリングレートは 4000Hz
  • 入力信号を 4000Hz でサンプリングするための時間間隔 w を計算
    w = 1sec / 4000Hz = 0.00025sec = 0.25msec = 250μsec
  • アナログ入力から w 間隔で読み取った値をサンプリング用バッファの終端まで順次格納する
  • サンプリング用バッファに格納した値を w 間隔で順次アナログ出力へ書き込む 〜無限ループ

動作の様子

動画:20秒  ※音量注意

ソースコード

sonic01_mic

  • main.cpp
    
    #include "mbed.h"
    
    #define SAMPLE_RATE 4000
    #define BUFSIZE 7680
    
    AnalogIn mic(p20);
    AnalogOut speaker(p18);
    DigitalOut led(LED4);
    float recBuf[BUFSIZE];
    
    int main() {
      // 当該サンプリングレートでのサンプリング間隔
      float w = 1.0 / SAMPLE_RATE;
    
      // 録音開始までに LED を 3回点滅させる
      led = 1;
      for (int i = 0; i < 6; i++) {
        wait(0.5);
        led = !led;
      }
      // 録音
      for (int i = 0; i < BUFSIZE; i++) {
        recBuf[i] = mic.read();
        wait(w);
      }
      led = 0;
    
      // 再生
      while (1) {
        for (int i = 0; i < BUFSIZE; i++) {
          speaker = recBuf[i] ;
          wait(w);
        }
      }
      return 0;
    }
    
    

試作 1 の問題点について

このようにとても短いコードで簡単に音声データの入出力を実現できました。しかし、録音と再生を試していると微妙な違和感がありました。録音した音と再生される音の高さが違うような気がしたのです。このことを楽器を使って確認してみました。

再生音の高さがおかしい?

動画:21秒  ※音量注意

この動画では楽器の音を録音し再生時に合わせ弾きなどを行っています。なお、前掲の安価なスピーカーではやはり音質が貧弱なので、この動画以降ではアンプ内蔵のスピーカー(PHILIPS 社製 BT50)を AnalogOut ポートにつないで再生に使用しています。

問題の原因と対処方法

このおかしな現象の原因は試作 1 のコードにおいてサンプリング間隔を制御している wait() 関数にあります。mbed プログラミングにおいてきわめてカジュアルに使用されるこの関数のリファレンスをあらためて確認したところ「When you call wait your board's CPU will be busy in a loop waiting for the required time to pass」との記述があり、とりあえず以下の内容で精度の確認を行いました。

  • 次のみっつのパターンについて、インターバル 10μ秒から 250μ秒までの 1μ秒刻みのループ内でそれぞれ 1000回 wait() を呼び出し所要時間と理論時間の差違を計測する
    • wait() のみ
    • wait() + AnalogIn.read()
    • wait() + AnalogOut.write()
インターバル 250μ秒での結果は次の通りです。
  • wait() のみ
    • wait=250us 所要時間:250666us 理論時間:250000us 誤差:666us (0.266400%)
  • wait() + AnalogIn.read()
    • wait=250us 所要時間:271662us 理論時間:250000us 誤差:21662us (8.664801%)
  • wait() + AnalogOut.write()
    • wait=250us 所要時間:252363us 理論時間:250000us 誤差:2363us (0.945200%)

このように wait() + AnalogIn.read() で大幅な遅延が発生しています。一方、wait() + AnalogOut.write() では相対的に遅延が小さいため試作 1 のプログラムは結果として「低いレートでサンプリング」したデータを「高いレートで再生」する所作となっていました。上の検証でのインターバル 10〜250μ秒を横軸、所要時間の誤差の割合を縦軸にプロットしたグラフを以下に示します。

(クリックで可読大表示)

サンプリング間隔のコントロールに mbed 標準のタイマ割込み機構である Ticker を使って同じ検証を行いました。 インターバル 250μ秒での結果は次の通りです。

  • Analog read / write なし
    • ticker=250us 所要時間:249770us 理論時間:250000us 誤差:230us (0.092000%)
  • AnalogIn.read() 呼び出しつき
    • ticker=250us 所要時間:249791us 理論時間:250000us 誤差:209us (0.083600%)
  • AnalogOut.write() 呼び出しつき
    • ticker=250us 所要時間:249772us 理論時間:250000us 誤差:228us (0.091200%)

(クリックで可読大表示)

このように結果は wait() 使用時よりも圧倒的に良好です。その一方で、AnalogIn.read() 併用時のインターバル 25μ秒付近の「壁」の存在が目に止まります。手元の検証ではこの結果には明確な再現性がみられました。25μ秒以下の割込みを必要としないサンプリングレートを利用すれば大きな支障はなさそうですが、広く用いられるレート 44100Hz の場合には 1sec / 44100Hz ≒ 23μsec とサンプリング間隔が 25μ秒を割り込むことが微妙に残念でもあります。

一連の調査の過程で Erik Olieman 氏による FastAnalogIn ライブラリの存在を知りました。

所定のアナログ入力ポートに対する mbed 標準の AnalogIn.read() が、呼び出しの都度、当該ポートに対応する ADC チャネル経由で AD 変換を行う仕様(そのため遅い)であるのに対し、FastAnalogIn は ADC の BURST mode を利用して変換をバックグラウンドで最大 200kHzで繰り返し実行し read() が呼び出されると「最後にサンプリングした値」を直ちに返す内容で実装されています。 (※ 前掲の ADC ドキュメント中の "conversion rates up to 200 kHz" のくだりは BURST mode 下のこの所作を指すものと考えられる)

  • FastAnalogIn - a mercurial repository | mbed - developer.mbed.org
    When you read an AnalogIn object it will enable the corresponding ADC channel, depending on the implementation do either one or multiple measurements for more accuracy, and return that value to your program. This way the ADC is only active when it is required, and it is fairly straightforward. However the downside is, is that an ADC is relatively slow.
          :
    This library uses the 'burst' feature of the microcontroller. This allows the ADC on the background to perform the AD conversions without requiring intervention from the microcontroller's core. Also there are no interrupts used, so also your time-sensitive code is not affected.
          :
    • FastAnalogIn Class Reference - developer.mbed.org
      AnalogIn does a single conversion when you read a value (actually several conversions and it takes the median of that). This library runns the ADC conversion automatically in the background. When read is called, it immediatly returns the last sampled value.
            :
  • ADC_LPC1768 - John Kneen: Microcontrollers - 6. Selecting and triggering the analogue to digital conversion. - sites.google.com
    Bit 16 : BURST

    1 The AD converter does repeated conversions at up to 200 kHz, scanning (if necessary) through the pins selected by bits set to ones in the SEL field.
          :

計測用コードの中の AnalogIn をこの FastAnalogIn に変更して同様の計測を行ったところ 25μ秒以下のインターバルでの成績が劇的に改善されました。前掲のふたつのグラフの縦軸最大値が「100%」であったのに対し、下のグラフでは「1.00%」であることに要注目です。

(クリックで可読大表示)

試作 2

上の「対処方法」を反映した形で録音・再生プログラムを書き直しました。検証上の便宜を兼ねて次の内容としています。

  • 録音・再生に要素数 14000 の unsigned short 型配列を使用する
  • サンプリングレート 8000Hz, 16000Hz, 22000Hz, 30720Hz, 44100Hz の順に録音と再生を行う
  • レートが高くなるほど録音可能時間は短くなるが、再生はいずれも 8秒間程度繰り返し行う

動作の様子

動画:1分28秒  ※音量注意

この動画では各サンプリングレートにおいてキーボードの「ファ」の音(F5:698.456Hz)を録音し、その再生音を以前作成したピーク周波数表示プログラムと楽器用のチューナー(KORG 社製 AW-2G)で確認しています。

ソースコード

sonic07_mic3

  • main.cpp
    // サンプリングと再生
    // レート 8000,16000,22000,30720,44100Hz の順にサンプリングを行い
    // それぞれ 8秒程度ループ再生する
    // FastAnalogIn ライブラリを使用
    
    #include "mbed.h"
    #include "FastAnalogIn.h"
    
    #define DEVELOP
    #ifdef DEVELOP
    #define dbg(...) printf(__VA_ARGS__)
    #else
    #define dbg(...)
    #endif
    
    #define BUFSIZE 14000
    
    Ticker tick;
    Timer timer;
    int countTick,interval_us, sampleTime;
    bool recDone;
    unsigned short *recBufp = NULL;
    AnalogOut speaker(p18);
    FastAnalogIn mic(p20);
    DigitalOut led(LED4);
    
    // サンプリング
    void flipRec() {
      recBufp[countTick++] = mic.read_u16() & 0xFFFC;
      if (countTick == BUFSIZE) {
        tick.detach();
        recDone = true;
      }
    }
    
    // 再生
    void flipPlay() {
      speaker.write_u16(recBufp[countTick++]);
      if (countTick == BUFSIZE) {
        countTick = 0;
      }
    }
    
    void recAndPlay(int sampleRate) {
      // 当該サンプリングレートでのサンプリング間隔 usec
      interval_us = 1.0 / sampleRate * 1000000;
      // 当該サンプリングレートでのサンプリング時間 usec
      sampleTime = BUFSIZE * interval_us / sizeof(short);
      dbg("rate=%5dHz irq=%dus time=%dms\r\n",
            sampleRate, interval_us, sampleTime/1000);
      
      // サンプリング開始までに LED を 3回点滅させる
      led = 1;
      for (int i = 0; i < 6; i++) {
        wait(0.4);
        led =! led;
      }
      // サンプリング
      countTick = 0;
      recDone = false;
      tick.attach_us(&flipRec, interval_us);
      while (!recDone) {
        wait(0.2);
      }
      led = 0;
      wait(1);
      // 8秒程度ループ再生
      countTick = 0;
      tick.attach_us(&flipPlay, interval_us);
      wait(8);
      tick.detach();
    }
    
    int main() {
      recBufp = (unsigned short*)malloc(BUFSIZE);
      recAndPlay(8000);
      recAndPlay(16000);
      recAndPlay(22000);
      recAndPlay(30720);
      recAndPlay(44100);
      dbg("done\r\n");
      if (recBufp) {
        free(recBufp);
      }
      return 0;
    }
    
    
    

準備 3. 任意の周波数の音波を生成・出力する

先般のAndroid 環境での試みと同様に手元の mbed マイコンでサイン波信号を生成・出力してみます。Android 版での到達点を踏襲できるのであまり手はかかりませんでした。

試作

以下の要領でプログラムを作成しました。

  • 400Hz〜14000Hz の範囲の 10種類の周波数のサイン波信号を順次生成する
  • サンプリングレートは 33330Hz(サンプリング間隔 30μ秒)とし、バッファ上に各周波数 100ミリ秒分の信号データを構築してそれぞれ 4秒間程度 AnalogOut ポートへ出力する
  • unsigned short 型配列を引数にとる AnalogOut.write_u16() の引数範囲は 0〜65535 であるため信号生成時に直流成分として 65535 / 2 を加える

動作の様子

動画:1分15秒  ※音量注意

この動画では二台のタブレット上のふたつのプログラムを使って信号の確認を行っています。

最初に 400Hz サイン波信号の収録された Youtube 動画を使って確認を行い、その後 mbed マイコンから 10種類の周波数のサイン波信号を順番に 4秒ずつ鳴らしています。

ソースコード

sonic08_sine2

  • main.cpp
    
    // サイン波音声信号を生成・出力する
    
    #include "mbed.h"
    
    #define DEVELOP
    #ifdef DEVELOP
    #define dbg(...) printf(__VA_ARGS__)
    #else
    #define dbg(...)
    #endif
    
    #define SAMPLE_RATE 33330
    #define PI 3.141592653589793238462
    #define MAG 3000
    
    Ticker tick;
    int count = 0;
    const int BUFSIZE = SAMPLE_RATE / 10; // バッファサイズ 100ミリ秒分
    unsigned short waveBuf[BUFSIZE];
    // 当該サンプリングレートでのサンプリング間隔 秒
    const float SEC_PER_SAMPLEPOINT = 1.0 / SAMPLE_RATE;
    const float a0 = 65535 / 2;
    
    AnalogOut speaker(p18);
    
    // 出力
    void flip() {
       speaker.write_u16(waveBuf[count++]);
       (count >= BUFSIZE) ? count = 0 : 1;
    }
    
    void outputSineWave(int freq) {
      for (int i = 0; i < BUFSIZE; i++) {
        float currentSec = i * SEC_PER_SAMPLEPOINT;
        double val = a0 + MAG * sin(2.0*PI * freq * currentSec);
        waveBuf[i] = (unsigned short)val;
      }
      count = 0;
      tick.attach(&flip, SEC_PER_SAMPLEPOINT;
      wait(4); // 4秒程度出力
      tick.detach();  
    }
    
    int main() {
      dbg("interval=%fs\r\n", SEC_PER_SAMPLEPOINT);
      outputSineWave(400);
      outputSineWave(800);
      outputSineWave(1200);
      outputSineWave(1600);
      outputSineWave(2000);
      outputSineWave(4000);
      outputSineWave(8000);
      outputSineWave(10000);
      outputSineWave(12000);
      outputSineWave(14000);
      return 0;
    }
    
    

準備 4. 集音したデータの周波数分析を行う

準備編の最後に入力信号の分析を試みます。

大浦版 FFT について

ここでは高性能かつコンパクトな FFT ライブラリとして広く利用(NASA の例)されている大浦拓哉様(京都大学数理解析研究所 助教)によるライセンスフリーの汎用 FFT パッケージ(通称 大浦版 FFT)を使ってみることにしました。先般 Android 環境下で利用した JTransforms はこのパッケージを母体とするソフトウェアです。

  • 大浦 拓哉 (Ooura, Takuya) - 京都大学数理解析研究所 - www.kurims.kyoto-u.ac.jp
    • 汎用 FFT (高速 フーリエ/コサイン/サイン 変換) パッケージ
      一次元 DFT / DCT / DST

      詳細
      一次元,2の整数乗個のデータの離散 Fourier 変換, コサイン変換,サイン変換等を行います.このパッケージには C と Fortran の FFT コードが含まれます.
            :

      内容
      fft4g.c : FFT パッケージ in C - 高速版 (基数 4, 2)
            :

      ファイルの違い
      簡易版は作業用配列を一切使いません.高速版は三角関数表などの作業用配列を使います.高速版は簡易版に比べて高速かつ高精度.高速版の仕様はすべて同じ.

      パッケージ内のルーチン
      cdft() : 複素離散 Fourier 変換 rdft() : 実離散 Fourier 変換
            :

      ライセンス
      Copyright Takuya OOURA, 1996-2001

      このソースコードはフリーソフトです.商用目的を含め,このコードの使用, コピー,修正及び配布は自由に行って結構です.ただし,このコードの修正を 行った場合は,そのことを明記してください.
    • 音声信号は実数データにつき実離散フーリエ変換関数 rdft() を使用する。使い方はシンプル。FFT サイズを 2のべき乗の値とすることに注意。
      -------- Real DFT / Inverse of Real DFT --------

       [usage]
        <case1>
         ip[0] = 0; // first time only
         rdft(n, 1, a, ip, w);
        <case2>
         ip[0] = 0; // first time only
         rdft(n, -1, a, ip, w);

       [parameters]
        n     :data length (int)
            n >= 2, n = power of 2

        a[0...n-1]  :input/output data (double *)
            <case1>
             output data
              a[2*k] = R[k], 0<=k<n/2
              a[2*k+1] = I[k], 0<k<n/2
              a[1] = R[n/2]
            <case2>
             input data
              a[2*j] = R[j], 0<=j<n/2
              a[2*j+1] = I[j], 0<j<n/2
              a[1] = R[n/2]

        ip[0...*]   :work area for bit reversal (int *)
            length of ip >= 2+sqrt(n/2)
            strictly, 
            length of ip >= 
             2+(1<<(int)(log(n/2+0.5)/log(2))/2).
            ip[0],ip[1] are pointers of the cos/sin table.

        w[0...n/2-1]   :cos/sin table (double *)
            w[],ip[] are initialized if ip[0] == 0.

       [remark]
        Inverse of 
         rdft(n, 1, a, ip, w);
        is 
         rdft(n, -1, a, ip, w);
         for (j = 0; j <= n - 1; j++) {
          a[j] *= 2.0 / n;
         }

試作

レート 33330Hz でサンプリングした音声信号を mbed マイコン上の FFT 処理にかけてピーク周波数を求めコンソールへ連続出力する内容で試作を行いました。手元の確認では mbed LPC1768 のメモリ容量上 FFT サイズは 210 = 1024 が限界でありその影響らしきものが若干窺えるものの十分に実用水準にあると考えられます。

動作の様子

動画:1分33秒  ※音量注意

この動画では二台のタブレット上のふたつのプログラムを使ってテストを行っています。

右のタブレットから順次所定の周波数のサイン波信号を出力し、それを mbed マイコンと左側のタブレットでそれぞれ FFT 処理にかけ両者の結果を観察しています。

ソースコード

sonic05_FFT2

  • main.cpp
    
    // 大浦版 FFT ライブラリで入力音声信号のピーク周波数を得る
    
    #include "mbed.h"
    
    #define DEVELOP
    #ifdef DEVELOP
    #define dbg(...) printf(__VA_ARGS__)
    #else
    #define dbg(...)
    #endif
    
    #define PI = 3.141592653589793238462;
    #define SAMPLE_RATE 33330
    // n >= 2, n = power of 2
    #define FFTSIZE 1024
    // length of ip >= 2+sqrt(n/2)
    const int IP_LEN = 4 + sqrt((double)(FFTSIZE/2));
    
    Ticker tick;
    double *recBufp = NULL;
    int countTick;
    Timer timer;
    bool recDone;
    AnalogIn mic(p20);
    DigitalOut led(LED4);
    
    // 当該サンプリングレートでのサンプリング間隔 秒
    const float SEC_PER_SAMPLEPOINT = 1.0 / SAMPLE_RATE;
    
    void rdft(int n, int isgn, double *a, int *ip, double *w);
    
    int doFFT(double *FFTBuffer) {
      int ip[IP_LEN];
      double *w = (double*)malloc(sizeof(double) * (FFTSIZE/2));
      ip[0] = 0;
      rdft(FFTSIZE, 1, FFTBuffer, ip, w);
    
      double maxAmp = 0;
      int index;
      for (int i = 0; i < FFTSIZE/2; i++) {
        double a = FFTBuffer[i*2]; // 実部
        double b = FFTBuffer[i*2 + 1]; // 虚部
        // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
        double r = sqrt(a*a + b*b);
        if (r > maxAmp) {
          maxAmp = r;
          index = i;
        }
      }
      double freq = index * SAMPLE_RATE / FFTSIZE;
      if (freq > 0 && maxAmp > 1.0) {
        dbg("freq=%d mag=%f\r\n", (int)freq, maxAmp);
      }
      free(w);
      return (int)freq;
    }
    
    // サンプリング
    void flip() {
      // AnalogIn.read() の値範囲は 0〜1.0 につき中心値を 0 に
      recBufp[countTick++] = mic.read() - 0.5;
      if (countTick == FFTSIZE) {
        tick.detach();
        recDone = true;
      }
    }
    
    int main() {
      recBufp = (double*)malloc(sizeof(double) * FFTSIZE);
      while (1) {
        countTick = 0;
        recDone = false;
        tick.attach(&flip, SEC_PER_SAMPLEPOINT);
        while (!recDone) {
          wait_ms(100);
        }
        doFFT(recBufp);
      }
      if (recBufp) {
        free(recBufp);
      }
      return 0;
    }
    
    

(tanabe)
klab_gijutsu2 at 17:21|この記事のURLComments(0)TrackBack(0)
2017年06月15日

音を利用する 3 〜Android デバイスでの音波通信:実装編〜

はてなブックマークに登録

この記事の内容

前回の記事では Android デバイス間で音波通信を行うための準備として次のみっつの要素に目を向けました。

今回はこれらを素材として音声信号でビットデータを送受信してみます。四段階に分け順次機能を加えながら試作を行います。

1. ビットデータを音声信号で送受信する 1

想定した内容

まず最初の試作に際し次の内容を想定しました。

  • 送信側は送信専用、受信側は受信専用とする
  • 送信側は任意の文字列を所定の規則に添ってサイン波信号に変換しオーディオ出力する
  • 受信側は集音中に所定の規則に添った音声信号を検知するとそこから文字列情報を復元し画面に表示する
  • 開発上の便宜のために可聴音を使用する

以下についてはとりあえず後まわしです。

  • 伝送効率
  • 誤りへの対策

周波数変調(FSK)について

所定のデータを音声信号に変換するために周波数変調方式を利用することにしました。次の考え方です。

  • ふたつの周波数 A, B に 0 と 1 を割り当てる
  • 元データのビット並びを上記 A, B に置き換えそれらを所定の時間間隔で切り替える

  • FSK - 「通信用語の基礎知識」より
    正弦波に対してディジタル信号で変調を行なういわゆるディジタル変調の方式の一つで、ディジタル値を正弦波の周波数に対応させて伝送する方式のこと。具体的にはディジタル値の1/0に対応させて2つの周波数を決め、入力されるディジタル信号に応じてそれぞれの周波数の正弦波を交互に送出することで実現させる。

試作

処理のイメージ

ざっくり以下の要領で実装を試みました。

  • 周波数変調においては 8000Hz を 0, 10000Hz を 1 として扱い 1 ビットあたりの発振持続時間を 100ミリ秒とする (よって伝送速度は 10bps)

  • 信号出力に際しては単一バッファ上に終始の完結したデータを構築するのではなく生成済みの 8000Hz, 10000Hz 信号を再生バッファに順次直接書き込む
  • 今回は参照していないが末尾に終端の符丁として 12000Hz の信号を 1秒間付与する
  • 左:送信側 右:受信側

動作の様子

動画: 1分30秒  ※音量注意!

ソースコード

送信側: sonic05

  • MainActivity.java
    
    /**
     *
     * sonic05
     *
     * 文字列をサイン波信号に置き換えて出力する
     * Lo -> 8000Hz, Hi -> 10000Hz
     * 対向の受信プログラムは sonic06
     *
     */
    
    package jp.klab.sonic05;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.EditText;
    import android.widget.ToggleButton;
    
    import java.io.UnsupportedEncodingException;
    import java.util.Arrays;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int AMP = 4000;
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_100MSEC = SAMPLE_RATE/10;
    
      private static final int FREQ_LOW  = 8000;
      private static final int FREQ_HI  = 10000;
      private static final int FREQ_END = 12000;
    
      private static final int MSG_PLAY_START   = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private static final byte [] BITS = new byte [] {
          (byte)0x80, (byte)0x40, (byte)0x20, (byte)0x10,
          (byte)0x08, (byte)0x04, (byte)0x02, (byte)0x01};
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButton01;
      private EditText mEditText01;
      private short mPlayBuf1[];
      private short mPlayBuf2[];
      private short mPlayBuf3[];
      private String mText;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < ELMS_1SEC; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mButton01 = (ToggleButton)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mEditText01 = (EditText)findViewById(R.id.editText01);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mPlayBuf1 = new short[ELMS_1SEC];
        mPlayBuf2 = new short[ELMS_1SEC];
        mPlayBuf3 = new short[ELMS_1SEC];
        createSineWave(mPlayBuf1, FREQ_LOW, AMP, true); // Low
        createSineWave(mPlayBuf2, FREQ_HI,  AMP, true); // Hi
        createSineWave(mPlayBuf3, FREQ_END, AMP, true); // END
    
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButton01.isChecked()) {
          mText = mEditText01.getText().toString();
          if (mText.length() > 0) {
            new Thread(this).start();
          } else {
            mButton01.setChecked(false);
          }
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        byte[] strByte = null;
        try {
          strByte = mText.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        valueToWave((byte) 0x20); // ダミー
        mAudioTrack.write(mPlayBuf3, 0, ELMS_1SEC); // 終端
    
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(byte val) {
        for (int i = 0; i < BITS.length; i++) {
          // ビットごとに Hi, Low を出力
          mAudioTrack.write(((val & BITS[i]) != 0) ? mPlayBuf2 : mPlayBuf1, 0, ELMS_100MSEC);
        }
      }
    }
    
    

受信側: sonic06

  • MainActivity.java
    
    /**
     *
     * sonic06
     *
     * 端末のマイクから集音した信号をバイトデータに変換する
     * Lo -> 8000Hz, Hi -> 10000Hz
     * FFT 処理に JTransforms ライブラリを利用
     * 対向の送信プログラムは sonic05
     *
     */
    
    package jp.klab.sonic06;
    
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int FREQ_LOW =  8000;
      private static final int FREQ_HI  = 10000;
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_DATA_RECV  = 130;
    
      private static final byte [] BITS = new byte [] {
          (byte)0x80, (byte)0x40, (byte)0x20, (byte)0x10,
          (byte)0x08, (byte)0x04, (byte)0x02, (byte)0x01};
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf =  new short[SAMPLE_RATE/10]; // 100msec
        mFFTSize = mBufferSizeInShort;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView02) {
          // 表示データをクリア
          mTextView02.setText("");
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        int bitCount = 0;
        byte val = 0;
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声データ読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            dataCount = bitCount = 0;
            continue;
          }
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < mTestBuf.length) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = mTestBuf.length - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= mTestBuf.length) {
            byte ret = doFFT(mTestBuf);
            //Log.d(TAG, "ret=" + ret);
            if (ret == -1) { // FREQ_LOW, FREQ_HI 以外の周波数の場合
              dataCount = bitCount = 0;
              continue;
            } else {
              // バイトデータを順次構成
              if (bitCount == 0) {
                val = 0;
              }
              val |= (ret == 1) ? BITS[bitCount] : 0;
              if (bitCount < 7) {
                bitCount++;
              } else { // 1バイト分完了
                bitCount = 0;
                Message msg = new Message();
                msg.what = MSG_DATA_RECV;
                msg.arg1 = (int) val;
                mHandler.sendMessage(msg);
              }
            }
            dataCount = 0;
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 録音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private byte doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        return (byte)((freq == FREQ_LOW) ? 0 : (freq == FREQ_HI) ? 1 : -1);
      }
    }
    
    

周波数切り替え箇所の波形を見る

信号中の Lo(8000Hz)から Hi(10000Hz)、Hi から Lo への切り替え部分の波形の確認を行いました。

Lo, Hi ともに 10 で割り切れる周波数なので、先頭から 100ミリ秒後の位置での運動円上の Y 座標は 1秒後の位置と等しく 0 であり、そこに同じく Y=0 始まりの別の周期のサイン波を接合しても位相の不連続は発生しないはずです。 (ただし周期の違いが継ぎ目前後のカーブの形状の違いとして表れるため波形として完璧ではないでしょう)

以下は送信側アプリからの信号出力を PC に接続したマイクから取り込んだ Audacity のスクリーンショットです。

Lo -> Hi 部分のズーム

後続の Hi -> Lo 部分を同様にズーム

2. ビットデータを音声信号で送受信する 2

上の最初の試作ではまず音によるデータの送受信自体が可能であることを確認しましたが、動作が不安定で何より通信の遅さが目につきます。この点について考えてみます。

伝送効率改善の考え方

効率を改善するために伝達する情報の内容に手を加えることにしました。1バイト分のキャラクタデータをビットごとに 0,1 を表わすふたつの周波数の波の羅列で表現するのではなく、256種類の周波数を用いて直接 0x00 〜 0xFF を表現する形にすれば理論上伝送効率が 8倍向上するはずです。これを具体化してみることにします。

試作

処理のイメージ

以下の要領で実装を行いました。

  • 送信側はバイト値 0x00 〜 0xFF を 400Hz 〜 5500Hz の 20Hz 刻みの 256 パターンの周波数から成る音声信号に変調する
  • 受信側は「(検知したピーク周波数 - 400)/ 20」の平易な計算で元のバイト値へ復号する
  • アプリの UI は最初の試作と同じ

動作の様子

動画: 1分  ※音量注意!

ソースコード

送信側: sonic07     GitHub

  • MainActivity.java
    /**
     * sonic07
     *
     * 文字列をサイン波信号に置き換えて出力する
     * 伝送効率改善版
     * 対向の受信プログラムは sonic08
     *
     */
    
    package jp.klab.sonic07;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.EditText;
    import android.widget.ToggleButton;
    
    import java.io.UnsupportedEncodingException;
    import java.util.Arrays;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int AMP = 4000;
      private static final int FREQ_BASE = 400;
      private static final int FREQ_STEP = 20;
      private static final int FREQ_KEY = 300;
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_100MSEC = SAMPLE_RATE/10;
      private static final int ELMS_MAX = 256;
    
      private static final int MSG_PLAY_START   = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButton01;
      private EditText mEditText01;
      private short mPlayBuf[] = new short[SAMPLE_RATE];
      private short mSignals[][] = new short[ELMS_MAX][SAMPLE_RATE/10];
      private String mText;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < buf.length; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mButton01 = (ToggleButton)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mEditText01 = (EditText)findViewById(R.id.editText01);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        // 先頭・終端の目印用信号データ
        createSineWave(mPlayBuf, FREQ_KEY, AMP, true);
    
        // 256種類の信号データを生成
        for (int i = 0; i < ELMS_MAX; i++) {
          createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP*i), AMP, true);
        }
    
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButton01.isChecked()) {
          mText = mEditText01.getText().toString();
          if (mText.length() > 0) {
            new Thread(this).start();
          } else {
            mButton01.setChecked(false);
          }
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        byte[] strByte = null;
        try {
          strByte = mText.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        mAudioTrack.write(mPlayBuf, 0, ELMS_1SEC); // 開始
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        mAudioTrack.write(mPlayBuf, 0, ELMS_1SEC); // 終端
    
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(byte val) {
        mAudioTrack.write(mSignals[val], 0, ELMS_100MSEC);
      }
    }
    
    
    

受信側: sonic08     GitHub

  • MainActivity.java
    
    /**
     * sonic08
     *
     * 端末のマイクから集音した信号をバイトデータに変換する
     * 伝送効率改善版
     * FFT 処理に JTransforms ライブラリを利用
     * 対向の送信プログラムは sonic07
     *
     */
    
    package jp.klab.sonic08;
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int FREQ_BASE = 400;
      private static final int FREQ_STEP = 20;
      private static final int FREQ_MAX = 400 + 255 * 20;
      private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_DATA_RECV  = 120;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 集音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf =  new short[UNITSIZE];
        mFFTSize = UNITSIZE;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView02) {
          // 表示データをクリア
          mTextView02.setText("");
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声データ読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            dataCount = 0;
            continue;
          }
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < mTestBuf.length) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = mTestBuf.length - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= mTestBuf.length) {
            // 100ms 分溜まったら FFT にかける
            int freq = doFFT(mTestBuf);
            // 待ってた範囲の周波数かチェック
            if (freq >= FREQ_BASE && freq <= FREQ_MAX) {
              int val = (int) ((freq - FREQ_BASE) / FREQ_STEP);
              if (val >= 0 && val <= 255) {
                Message msg = new Message();
                msg.what = MSG_DATA_RECV;
                msg.arg1 = val;
                mHandler.sendMessage(msg);
              } else {
                freq = -1;
              }
            } else {
              freq = -1;
            }
            dataCount = 0;
            if (freq == -1) {
              continue;
            }
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 集音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        //Log.d(TAG, "peak=" + freq + "Hz");
        return freq;
      }
    }
    
    

3. ビットデータを音声信号で送受信する 3

伝送効率の改善に続き、次の要素に目を向けてみます。

  • 誤り発生への対処
  • 超音波の利用

誤り発生への対処について

他の通信方法と同様に、音波による通信においても送受信の際に何らかの理由でデータに誤りが発生する可能性があります。この問題への対処について考えてみます。

まず誤りを検出可能であること、さらに、自動的にデータを訂正することができれば理想的でしょう。しかしながら、誤り訂正を実現するためには何らかの形でデータの冗長化が必須となり必然的に伝送効率とのトレードオフとなります。冗長化されたより大きなデータを授受することで自動訂正実現の可能性向上を選択すべきか?あるいは、誤り有無判定のみを可能とするスリムなデータを短時間で授受することを旨とするか?

たとえば、プライベートな環境での音波通信の応用を想定すると、エラー発生に気づいた利用者が自発的に送受信を再試行するのはごく普通の自然な流れです。この場面では訂正機能よりもすみやかに通信が行われることのほうが求められるでしょう。また、パブリックな空間でのビーコン系サービスにおいては送り手が同一の情報を所定の期間何度も繰り返し発信する(それもまた単方向通信であることに起因)ことが一般的なので受け手には情報取得に成功するまで何度もチャンスが提供されます。

このように考えると、ここでは誤り訂正のための機構は現在の話題に不可欠の要素ではなく、むしろ誤りの検出までが重要であることが見えてきます。

超音波の利用について

ここまでの試作では開発上の便宜から可聴音域の信号で通信を行ってきました。

音を利用する 1」冒頭の「はじめに」の項でも触れたように、音声信号を利用する際の音域の選定は環境や使途とのかねあいによって単に適材適所で判断されるべきはずのものですが、そのこととはあまり関係なく洋の東西を問わず「超音波」「Ultrasonic」は人気がありますね。そろそろこのあたりで従来の可聴音に加え超音波も扱ってみることにします。

試作

処理のイメージ

以下の要領で実装を行いました。

可聴音モードと超音波モード

「超音波モード」を追加。

  • 送信側は所定のバイト値 0x00 〜 0xFF を以下の周波数域で構成される音声信号に変調する
    (前項のふたつめの試作では 20Hz刻みとしたが 10Hz刻みでも性能上の支障が見られないため帯域幅を節約)
    • 可聴音モード:500Hz 〜 3050Hz(10Hz 刻み)
    • 超音波モード:14000Hz 〜 16550Hz(10Hz 刻み)

先端符丁と終端符丁

発信する音声信号の先頭と終端に目印として上図の範囲に含まれない所定の周波数の信号を 1秒間付与する。

  • 先端符丁:各モードで使用する音域の先頭周波数 − 80Hz(=420Hz,13920Hz)
  • 終端符丁:各モードで使用する音域の先頭周波数 − 100Hz(=400Hz, 13900Hz)

誤り検出

次の内容で誤り検出機構を追加。

  • 送信側は先端符丁とデータ本体の間にデータの 32ビット CRC 計算結果(java.util.zip.CRC32 使用)を 4 * 100ミリ秒間発信する
  • 受信側は先端符丁を検知すると上記 CRC 値を取得・復号して保持。終端符丁検出時に復号ずみデータ本体の CRC を計算し両者を比較することで誤りの可能性の有無を判定する
    • データ本体の受け渡しに失敗したケースのみならず CRC 値の受け渡しに失敗のケースもありえるが、現時点では CRC 値情報授受の冗長化は考えず一律に「エラー」とみなす

UI

  • 左:送信側 右:受信側
    両者とも「Ultrasonic」スイッチで超音波モードへ移行

動作の様子

動画: 1分19秒  ※音量注意!

ソースコード

送信側: sonic09     GitHub

  • MainActivity.java
    
    /**
     * sonic09
     *
     * 文字列をサイン波信号に置き換えて出力する
     * 32ビットCRC付与, 超音波モード追加
     * 対向の受信プログラムは sonic10
     *
     */
    
    package jp.klab.sonic09;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.CompoundButton;
    import android.widget.EditText;
    import android.widget.Switch;
    import android.widget.ToggleButton;
    
    import java.io.UnsupportedEncodingException;
    import java.util.Arrays;
    import java.util.zip.CRC32;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener,
        Switch.OnCheckedChangeListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int FREQ_BASE_LOW = 500;
      private static final int FREQ_BASE_HIGH = 14000;
      private static final int AMP_SMALL = 5000;
      private static final int AMP_LARGE = 28000;
    
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_UNITSEC = SAMPLE_RATE/10;
      private static final int ELMS_MAX = 256;
    
      private static final int MSG_PLAY_START   = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private int FREQ_STEP = 10;
      private int AMP;
      private int FREQ_BASE;
      private int FREQ_OUT;
      private int FREQ_IN;
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButton01;
      private EditText mEditText01;
      private Switch mSwitch01;
    
      private short mSigIn[] = new short[SAMPLE_RATE];
      private short mSigOut[] = new short[SAMPLE_RATE];
      private short mSignals[][] = new short[ELMS_MAX][ELMS_UNITSEC];
      private String mText;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < buf.length; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        mButton01 = (ToggleButton)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mEditText01 = (EditText)findViewById(R.id.editText01);
        mSwitch01 = (Switch)findViewById(R.id.switch01);
        mSwitch01.setOnCheckedChangeListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
        setParams(false);
    
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButton01.isChecked()) {
          mText = mEditText01.getText().toString();
          if (mText.length() > 0) {
            new Thread(this).start();
          } else {
            mButton01.setChecked(false);
          }
        }
      }
      @Override
      public void onCheckedChanged(CompoundButton b, boolean isChecked) {
        setParams(isChecked);
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        byte[] strByte = null;
        try {
          strByte = mText.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        mAudioTrack.write(mSigIn, 0, ELMS_1SEC); // 先端符丁
        // データ本体の CRC32 を計算して発信
        CRC32 crc = new CRC32();
        crc.reset();
        crc.update(strByte, 0, strByte.length);
        long crcVal = crc.getValue();
        //Log.d(TAG, "crc=" + Long.toHexString(crcVal));
        byte crcData[] = new byte[4];
        for (int i = 0; i < 4; i++) {
          crcData[i] = (byte)(crcVal >> (24-i*8));
          valueToWave((short) (crcData[i] & 0xFF));
        }
        // データ本体
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        mAudioTrack.write(mSigOut, 0, ELMS_1SEC); // 終端符丁
    
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(short val) {
        //Log.d(TAG, "val=" + val);
        mAudioTrack.write(mSignals[val], 0, ELMS_UNITSEC);
      }
    
      private void setParams(boolean useUltrasonic) {
        AMP = (useUltrasonic) ? AMP_LARGE : AMP_SMALL;
        FREQ_BASE = (useUltrasonic) ? FREQ_BASE_HIGH : FREQ_BASE_LOW;
        FREQ_OUT = FREQ_BASE - 100;
        FREQ_IN = FREQ_OUT + 20;
        // 先端・終端符丁の信号データを生成
        createSineWave(mSigIn, FREQ_IN, AMP, true);
        createSineWave(mSigOut, FREQ_OUT, AMP, true);
        // 256種類の信号データを生成
        for (int i = 0; i < ELMS_MAX; i++) {
          createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP * i), AMP, true);
        }
    
      }
    }
    
    

受信側: sonic10     GitHub

  • MainActivity.java
    
    /**
     * sonic10
     *
     * 端末のマイクから集音した信号をバイトデータに変換する
     * CRC32による誤り検出, 超音波モードへ対応
     * FFT 処理に JTransforms ライブラリを利用
     * 対向の送信プログラムは sonic09
     *
     */
    
    package jp.klab.sonic10;
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.graphics.Color;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.CompoundButton;
    import android.widget.Switch;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    import java.util.ArrayList;
    import java.util.zip.CRC32;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener,
          Switch.OnCheckedChangeListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int FREQ_BASE_LOW = 500;
      private static final int FREQ_BASE_HIGH = 14000;
      private static final int FREQ_STEP = 10;
      private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_DATA_RECV  = 120;
      private static final int MSG_RECV_OK    = 200;
      private static final int MSG_RECV_NG    = 210;
    
      private int FREQ_BASE = FREQ_BASE_LOW;
      private int FREQ_OUT = FREQ_BASE - 100;
      private int FREQ_IN = FREQ_OUT + 20;
      private int FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
      private TextView mTextView03;
      private Switch mSwitch01;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
      private byte mValueCount = -1;
      private long mCrc32Val = 0;
      private String mRecvStr;
      private ArrayList<Byte> mDataArrayList = new ArrayList<Byte>();
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
        mTextView03 = (TextView)findViewById(R.id.textView03);
        mTextView03.setTextColor(Color.RED);
        mSwitch01 = (Switch)findViewById(R.id.switch01);
        mSwitch01.setOnCheckedChangeListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 集音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf =  new short[UNITSIZE];
        mFFTSize = UNITSIZE;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView02) {
          // 表示データをクリア
          mTextView02.setText("");
          mTextView03.setText("");
        }
        return;
      }
      @Override
      public void onCheckedChanged(CompoundButton b, boolean isChecked) {
        if (b == (CompoundButton)mSwitch01) {
          FREQ_BASE = (isChecked) ? FREQ_BASE_HIGH : FREQ_BASE_LOW;
          FREQ_OUT = FREQ_BASE - 100;
          FREQ_IN = FREQ_OUT + 20;
          FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            mSwitch01.setEnabled(false);
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            mSwitch01.setEnabled(true);
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            mTextView03.setText("");
            break;
          case MSG_RECV_OK:
            mTextView03.setText("OK!");
            break;
          case MSG_RECV_NG:
            mTextView03.setText("NG!");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声データ読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            dataCount = 0;
            continue;
          }
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < UNITSIZE) {//mTestBuf.length) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = UNITSIZE - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= UNITSIZE) {//mTestBuf.length) {
            // 100ms 分溜まったら FFT にかける
            int freq = doFFT(mTestBuf);
            if (mValueCount < 0) {
              // データ終了
              if (freq == FREQ_OUT && mCrc32Val != 0) {
                byte check [] = new byte[mDataArrayList.size()];
                for (int i = 0; i < check.length; i++) {
                  check[i] = mDataArrayList.get(i);
                }
                CRC32 crc = new CRC32();
                crc.reset();
                crc.update(check, 0, check.length);
                long crcVal = crc.getValue();
                Log.d(TAG, "crc check=" +  Long.toHexString(crcVal));
                if (crcVal == mCrc32Val) {
                  mHandler.sendEmptyMessage(MSG_RECV_OK);
                } else {
                  mHandler.sendEmptyMessage(MSG_RECV_NG);
                }
                mCrc32Val = 0;
              }
            }
            // 待ってた範囲の周波数かチェック
            if (freq >= FREQ_BASE && freq <= FREQ_MAX) {
              int val = (int) ((freq - FREQ_BASE) / FREQ_STEP);
              if (val >= 0 && val <= 255) {
                if (mValueCount > 4) {
                  mDataArrayList.add((byte)val);
                  Message msg = new Message();
                  msg.what = MSG_DATA_RECV;
                  msg.arg1 = val;
                  mHandler.sendMessage(msg);
                }
              } else {
                freq = -1;
              }
            } else {
              freq = -1;
            }
            dataCount = 0;
            if (freq == -1) {
              continue;
            }
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 集音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        byte val = (byte)((freq-FREQ_BASE)/FREQ_STEP);
    
        if (freq == FREQ_IN) { // 先端符丁
          mValueCount = 0;
          mCrc32Val = 0;
          if (!mDataArrayList.isEmpty()) {
            mDataArrayList.clear();
          }
          return freq;
        } else if (freq == FREQ_OUT) { // 終端符丁
          mValueCount = -1;
          return freq;
        }
    
        // 先端符丁直後の 4バイトは32ビットCRC
        if (mValueCount >= 0 &&  mValueCount < 4) {
          mCrc32Val |=  (val & 0xFF);
          if (mValueCount != 3) {
            mCrc32Val <<= 8;
          } else {
            Log.d(TAG, "mCrc32Val=" + Long.toHexString(mCrc32Val));
          }
        }
        mValueCount++;
        return freq;
      }
    }
    
    

4. ビットデータを音声信号で送受信する 4

ここまでの試作では単方向の通信のみを扱ってきました。これは以下の理由によるものです。

  1. 集音・発音の両機能を混在させると自機の発した信号音による混乱が起こり得る
  2. 伝送が基本的に低速であるため機器間で相互に通信を行うなら双方の拘束時間が長くなることになり実用性を想定しにくい
  3. 双方向通信に伴いプロトコルが煩雑化することよりも単方向通信であることを活かせる用途を選ぶべき

これらはおそらく考え方として間違っていないように思いますが、一方で、二台の機器が相互に音で情報をやりとりする光景を想像すると理由のない好奇心をくすぐられます。そこで、実験の一環として最後にデバイス間の双方向通信を試すことにしました。

双方向通信を考える

双方向通信の方法として以下の内容を想定しました。

試作

処理のイメージ

以下の要領で実装を行いました。

「しりとり」を題材とする

  • 機器間で自律的にある程度意味のある応酬を行わせるために英単語のしりとりを題材とした
    ability -> yellow -> warn -> national .... の要領
  • 機器間の応酬は自動化し最初の単語を発信するために初回のみ UI を操作する形とした

集音・発音の切り替え

  • 集音状態での待機中に先端符丁の信号音を検知したらデータ到着とみなし後続処理を開始。その後終端符丁を検知したら所定の時間間隔をおいて発信を行う。発信が完了したら集音状態での待機に戻る

再送要求

  • 受信・復号したデータの内容に誤りを検出した場合、「?」文字を発信することで相手に再送を要求する

UI

  • タイトル部分をタップすると最初の単語を発信。あとはエンドレス

動作の様子

動画: 1分29秒  ※音量注意!

ソースコード

sonic11     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic11
     *
     * サイン波音声信号による半二重式での双方向通信のしくみの試作
     * 二台の機器間で「しりとり」を行う
     * FFT 処理に JTransforms ライブラリを利用
     *
     */
    
    package jp.klab.sonic11;
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.graphics.Color;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.CompoundButton;
    import android.widget.Switch;
    import android.widget.TextView;
    
    import java.io.UnsupportedEncodingException;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.zip.CRC32;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener,
          Switch.OnCheckedChangeListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final short THRESHOLD_SILENCE = 0x00ff;
      private static final int THRESHOLD_COUNT_SILENCE = 25; // 無音区間判定用
      private static final int FREQ_BASE_LOW = 500;
      private static final int FREQ_BASE_HIGH = 14000;
      private static final int AMP_SMALL = 28000;
      private static final int AMP_LARGE = 28000;
    
      private static final int ELMS_1SEC = SAMPLE_RATE;
      private static final int ELMS_UNITSEC = SAMPLE_RATE/10;
      private static final int ELMS_MAX = 256;
      private static final int FREQ_STEP = 10;
      private static final int UNITSIZE = SAMPLE_RATE/10; // 100msec分
    
      private static final int MSG_RECORD_START  = 100;
      private static final int MSG_RECORD_END    = 110;
      private static final int MSG_DATA_RECV     = 130;
      private static final int MSG_DATA_SEND_START = 140;
      private static final int MSG_RECV_OK     = 200;
      private static final int MSG_RECV_NG     = 210;
    
      private int AMP;
      private int FREQ_BASE;
      private int FREQ_OUT;
      private int FREQ_IN;
      private int FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
      private AudioTrack mAudioTrack = null;
    
      private Button mButton01;
      private TextView mTextView01;
      private TextView mTextView02;
      private TextView mTextView03;
      private TextView mTextView04;
      private Switch mSwitch01;
    
      private short mSigIn[] = new short[SAMPLE_RATE];
      private short mSigOut[] = new short[SAMPLE_RATE];
      private short mSignals[][] = new short[ELMS_MAX][SAMPLE_RATE/10];
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private short mTestBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
      private byte mValueCount = -1;
      private long mCrc32Val = 0;
      private ArrayList<Byte> mDataArrayList = new ArrayList<Byte>();
      private int mLastFreq;
      private String mRecvWord = "";
      private String mSendWord = "";
      private boolean mRecvOK;
    
      // サイン波データを生成
      private void createSineWave(short[] buf, int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(buf, (short) 0);
        }
        for (int i = 0; i < buf.length; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          buf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView01 = (TextView)findViewById(R.id.textView01);
        mTextView01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
        mTextView02.setOnClickListener(this);
        mTextView03 = (TextView)findViewById(R.id.textView03);
        mTextView03.setTextColor(Color.RED);
        mTextView04 = (TextView)findViewById(R.id.textView04);
        mSwitch01 = (Switch)findViewById(R.id.switch01);
        mSwitch01.setOnCheckedChangeListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 集音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mTestBuf = new short[UNITSIZE];
        mFFTSize = UNITSIZE;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
    
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
            SAMPLE_RATE,
            AudioFormat.CHANNEL_OUT_MONO,
            AudioFormat.ENCODING_PCM_16BIT,
            bufferSizeInBytes,
            AudioTrack.MODE_STREAM);
    
        setParams(false);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        } else if (v == (View)mTextView01) {
          // しりとりの開始・再開
          // すでに集音中なら自分で自分の音を拾わないように
          // スレッドを一旦終了
          boolean running = mInRecording;
          if (mInRecording) {
            mStop = true;
            try {
              while (mInRecording) {
                Thread.sleep(100);
              }
            } catch (InterruptedException e) {
            }
          }
          // ランダムに選んだ英単語を音で発信
          mSendWord = words.getWord();
          doSendWord(mSendWord);
          if (running) {
            // 集音スレッド再稼働
            mInRecording = true;
            mStop = false;
            new Thread(this).start();
          }
        } else if (v == (View)mTextView02) {
          mTextView02.setText("");
          mTextView03.setText("");
        }
        return;
      }
    
      @Override
      public void onCheckedChanged(CompoundButton b, boolean isChecked) {
        if (b == (CompoundButton)mSwitch01) {
          setParams(isChecked);
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            mSwitch01.setEnabled(false);
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            mSwitch01.setEnabled(true);
            break;
          case MSG_DATA_RECV:
            //Log.d(TAG, "MSG_DATA_RECV");
            if (mRecvWord.length() <= 0) {
              mTextView02.setText("RECV: ");
            }
            byte[] ch = new byte[] {(byte)msg.arg1};
            try {
              // 受信データを表示
              String s = new String(ch, "UTF-8");
              mRecvWord += s;
              s = mTextView02.getText() + s;
              mTextView02.setText(s);
            } catch (UnsupportedEncodingException e) {
            }
            mTextView03.setText("");
            break;
          case MSG_DATA_SEND_START:
            mTextView04.setText("SEND: " + mSendWord);
            break;
          case MSG_RECV_OK:
            mTextView03.setText("OK!");
            break;
          case MSG_RECV_NG:
            mTextView03.setText("NG!");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        int dataCount = 0;
        boolean bSilence = false;
        long countSilence = 0;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          // 音声信号読み込み
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_SILENCE) {
              bSilence = false;
              break;
            }
          }
          if (bSilence) { // 静寂
            dataCount = 0;
            countSilence++;
          } else {
            countSilence = 0;
          }
          // 単語を受信ずみかつ一定期間以上静寂が続いたら送信にまわる
          if (countSilence > THRESHOLD_COUNT_SILENCE &&
              mLastFreq == FREQ_OUT && mRecvWord.length() > 0) {
            if (mRecvOK) { // 直前の受信・復号結果が正常だった
              if (!mRecvWord.equals("?")) {
                // 受信した単語のしりとり語を取得
                mSendWord = words.getWord(mRecvWord);
              }
            } else {
              // 再送要求
              mSendWord = "?";
            }
            Log.d(TAG, "mSendWord=" + mSendWord);
            // 単語を発信
            doSendWord(mSendWord);
            continue;
          }
    
          int copyLength = 0;
          // データを mTestBuf へ順次アペンド
          if (dataCount < UNITSIZE) {
            // mTestBuf の残領域に応じてコピーするサイズを決定
            int remain = UNITSIZE - dataCount;
            if (remain > mBufferSizeInShort) {
              copyLength = mBufferSizeInShort;
            } else {
              copyLength = remain;
            }
            System.arraycopy(mRecordBuf, 0, mTestBuf, dataCount, copyLength);
            dataCount += copyLength;
          }
          if (dataCount >= UNITSIZE) {
            // 100ms 分溜まったら FFT にかける
            int freq = doFFT(mTestBuf);
            mLastFreq = freq;
            if (mValueCount < 0) {
              // データ終了
              if (freq == FREQ_OUT && mCrc32Val != 0) {
                byte check [] = new byte[mDataArrayList.size()];
                for (int i = 0; i < check.length; i++) {
                  check[i] = mDataArrayList.get(i);
                }
                //Log.d(TAG, "mRecvWord=" + mRecvWord);
                CRC32 crc = new CRC32();
                crc.reset();
                crc.update(check, 0, check.length);
                long crcVal = crc.getValue();
                //Log.d(TAG, "crc check=" +  Long.toHexString(crcVal));
                mRecvOK = false;
                if (crcVal == mCrc32Val) {
                  mRecvOK = true;
                  mHandler.sendEmptyMessage(MSG_RECV_OK);
                } else {
                  mHandler.sendEmptyMessage(MSG_RECV_NG);
                }
                mCrc32Val = 0;
              }
            }
            // 待ってた範囲の周波数かチェック
            if (freq >= FREQ_BASE && freq <= FREQ_MAX) {
              int val = (int) ((freq - FREQ_BASE) / FREQ_STEP);
              if (val >= 0 && val <= 255) {
                if (mValueCount > 4) {
                  mDataArrayList.add((byte)val);
                  Message msg = new Message();
                  msg.what = MSG_DATA_RECV;
                  msg.arg1 = val;
                  mHandler.sendMessage(msg);
                }
              } else {
                freq = -1;
              }
            } else {
              freq = -1;
            }
            dataCount = 0;
            if (freq == -1) {
              continue;
            }
            // mRecordBuf の途中までを mTestBuf へコピーして FFT した場合は
            // mRecordBuf の残データを mTestBuf 先頭へコピーした上で継続
            if (copyLength < mBufferSizeInShort) {
              int startPos = copyLength;
              copyLength = mBufferSizeInShort - copyLength;
              System.arraycopy(mRecordBuf, startPos, mTestBuf, 0, copyLength);
              dataCount += copyLength;
            }
          }
        }
        // 集音終了
        mAudioRecord.stop();
        mInRecording = false;
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列からピーク周波数成分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // ピーク周波数を求める
        int freq = index * SAMPLE_RATE / mFFTSize;
        byte val = (byte)((freq-FREQ_BASE)/FREQ_STEP);
    
        if (freq == FREQ_IN) { // 先端符丁
          mValueCount = 0;
          mCrc32Val = 0;
          if (!mDataArrayList.isEmpty()) {
            mDataArrayList.clear();
          }
          mRecvWord = "";
          return freq;
        } else if (freq == FREQ_OUT) { // 終端符丁
          mValueCount = -1;
          return freq;
        }
    
        // 先端符丁直後の 4バイトは32ビットCRC
        if (mValueCount >= 0 &&  mValueCount < 4) {
          mCrc32Val |=  (val & 0xFF);
          if (mValueCount != 3) {
            mCrc32Val <<= 8;
          } else {
            Log.d(TAG, "mCrc32Val=" + Long.toHexString(mCrc32Val));
          }
        }
        mValueCount++;
        return freq;
      }
    
      // 指定されたバイト値を音声信号に置き換えて再生する
      private void valueToWave(short val) {
        //Log.d(TAG, "val=" + val);
        mAudioTrack.write(mSignals[val], 0, ELMS_UNITSEC);
      }
    
      private void doSendWord(String str) {
        mSendWord = str;
        mHandler.sendEmptyMessage(MSG_DATA_SEND_START);
        Log.d(TAG, "mSendWord=" + mSendWord);
        byte[] strByte = null;
        try {
          strByte = mSendWord.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
        }
        mAudioTrack.play();
        mAudioTrack.write(mSigIn, 0, ELMS_1SEC); // 先端符丁
        // データ本体の CRC32 を計算して発信
        CRC32 crc = new CRC32();
        crc.reset();
        crc.update(strByte, 0, strByte.length);
        long crcVal = crc.getValue();
        //Log.d(TAG, "crc=" + Long.toHexString(crcVal));
        byte crcData[] = new byte[4];
        for (int i = 0; i < 4; i++) {
          crcData[i] = (byte)(crcVal >> (24-i*8));
          valueToWave((short) (crcData[i] & 0xFF));
        }
        // データ本体
        for (int i = 0; i < strByte.length; i++) {
          valueToWave(strByte[i]);
        }
        mAudioTrack.write(mSigOut, 0, ELMS_1SEC); // 終端符丁
        mAudioTrack.stop();
        mAudioTrack.flush();
        mLastFreq = -1;
        mRecvWord = "";
      }
    
      private void setParams(boolean useUltrasonic) {
        AMP = (useUltrasonic) ? AMP_LARGE : AMP_SMALL;
        FREQ_BASE = (useUltrasonic) ? FREQ_BASE_HIGH : FREQ_BASE_LOW;
        FREQ_OUT = FREQ_BASE - 100;
        FREQ_IN = FREQ_OUT + 20;
        // 先端・終端符丁の信号データを生成
        createSineWave(mSigIn, FREQ_IN, AMP, true);
        createSineWave(mSigOut, FREQ_OUT, AMP, true);
        // 256種類の信号データを生成
        for (int i = 0; i < ELMS_MAX; i++) {
          createSineWave(mSignals[i], (short) (FREQ_BASE + FREQ_STEP * i), AMP, true);
        }
        FREQ_MAX = FREQ_BASE + FREQ_STEP * 255;
      }
    }
    
    

(tanabe)
klab_gijutsu2 at 09:00|この記事のURLComments(0)TrackBack(0)
2017年06月02日

音を利用する 2 〜Android デバイスでの音波通信:準備編〜

はてなブックマークに登録

この記事の内容

前回の記事では音を媒体とする機器間の情報通信が近年ふたたび注目されていることに触れ複数の具体的な事例を挙げました。近距離通信手段としての音波の利用には技術的好奇心をそそられますが、それをどのように形にすればよいのかは案外ピンと来ません。そこで、Android デバイスを題材に手元で行った音波通信の実験と試作の内容をこれから何回かに分けて紹介します。今回は準備編として実装に必要な要素の整理を行います。

準備 1. Andorid での音声入出力処理を覚える

プラットフォームにより開発環境や API に違いはあっても音を扱う処理の本質は変わりません。手元でターゲットとして Android を選んだのは以下の理由によるものです。

  • メジャーである
  • サウンド I/O を標準で利用できる(マイコン系と比べての利点)
  • ポータブルであるため応用が利きやすく IoT 方面との連携性も良い(PC と比べての利点)
  • 開発環境の制約ごとが相対的に少ない(他のスマホ系と比べての利点)

まず、今回使用した音声入出力 API の情報をリファレンスより引用します。特に難しい要素もなく分かりやすい内容でした。

入力:AudioRecord クラス

  • AudioRecord
    The AudioRecord class manages the audio resources for Java applications to record audio from the audio input hardware of the platform.
          :
    Upon creation, an AudioRecord object initializes its associated audio buffer that it will fill with the new audio data.
          :
    Data should be read from the audio hardware in chunks of sizes inferior to the total recording buffer size.
    • AudioRecord (constructor)
      AudioRecord (int audioSource,
            int sampleRateInHz,
            int channelConfig,
            int audioFormat,
            int bufferSizeInBytes)
            :
      Parameters
            :
      bufferSizeInBytes
      int: the total size (in bytes) of the buffer where audio data is written to during the recording. New audio data can be read from this buffer in smaller chunks than this size. See getMinBufferSize(int, int, int) to determine the minimum required buffer size for the successful creation of an AudioRecord instance. Using values smaller than getMinBufferSize() will result in an initialization failure.
    • getMinBufferSize
      int getMinBufferSize (
            int sampleRateInHz,
            int channelConfig,
            int audioFormat)

      Returns the minimum buffer size required for the successful creation of an AudioRecord object, in byte units.
            :
    • startRecording
      void startRecording ()

      Starts recording from the AudioRecord instance.
    • read
      int read (short[] audioData,
            int offsetInShorts,
            int sizeInShorts)

      Reads audio data from the audio hardware for recording into a short array. The format specified in the AudioRecord constructor should be ENCODING_PCM_16BIT to correspond to the data in the array.
            :
    • getRecordingState
      int getRecordingState ()

      Returns the recording state of the AudioRecord instance.

      See also:
            RECORDSTATE_STOPPED
            RECORDSTATE_RECORDING
    • stop
      void stop ()

      Stops recording.

出力:AudioTrack クラス

  • AudioTrack
    The AudioTrack class manages and plays a single audio resource for Java applications. It allows streaming of PCM audio buffers to the audio sink for playback.
          :
    An AudioTrack instance can operate under two modes: static or streaming. In Streaming mode, the application writes a continuous stream of data to the AudioTrack, using one of the write() methods.
          :
    The static mode should be chosen when dealing with short sounds that fit in memory and that need to be played with the smallest latency possible.
          :
    • AudioTrack (constructor)
      AudioTrack (
            int streamType,
            int sampleRateInHz,
            int channelConfig,
            int audioFormat,
            int bufferSizeInBytes,
            int mode)
            :
      Parameters
            :
      int: the total size (in bytes) of the internal buffer where audio data is read from for playback. This should be a nonzero multiple of the frame size in bytes.
            :
      If the track's creation mode is MODE_STREAM, this should be the desired buffer size for the AudioTrack to satisfy the application's latency requirements. If bufferSizeInBytes is less than the minimum buffer size for the output sink, it is increased to the minimum buffer size.
            :
      See getMinBufferSize(int, int, int) to determine the estimated minimum buffer size for an AudioTrack instance in streaming mode.
    • play
      void play ()

      Starts playing an AudioTrack.

      If track's creation mode is MODE_STATIC, you must have called one of the write methods(中略)prior to play().

      If the mode is MODE_STREAM, you can optionally prime the data path prior to calling play(), by writing up to bufferSizeInBytes (from constructor). If you don't call write() first, or if you call write() but with an insufficient amount of data, then the track will be in underrun state at play(). In this case, playback will not actually start playing until the data path is filled to a device-specific minimum level.
            :
    • write
      int write (
            short[] audioData,
            int offsetInShorts,
            int sizeInShorts)

      Writes the audio data to the audio sink for playback (streaming mode), or copies audio data for later playback (static buffer mode). The format specified in the AudioTrack constructor should be ENCODING_PCM_16BIT to correspond to the data in the array.
            :
    • getPlayState
      int getPlayState ()

      Returns the playback state of the AudioTrack instance.

      See also:
            PLAYSTATE_STOPPED
            PLAYSTATE_PAUSED
            PLAYSTATE_PLAYING
    • pause
      void pause ()

      Pauses the playback of the audio data. Data that has not been played back will not be discarded. Subsequent calls to play() will play this data back. See flush() to discard this data.
            :
    • stop
      void stop ()

      Stops playing the audio data.
            :
    • flush
      void flush ()

      Flushes the audio data currently queued for playback. Any data that has been written but not yet presented will be discarded. No-op if not stopped or paused, or if the track's creation mode is not MODE_STREAM.
            :

試作

Android 環境での音声入出力の基本的な作法を覚えるために最初にごくシンプルなアプリを書いてみました。 入力側と出力側は多くの場合機能的に分けられますが、ここではその両方の要素を次の内容で単一のプログラムに組み入れています。

  • UI 上のボタンを押下するとマイクロフォンからの録音を開始する
  • 再度ボタンを押下すると録音を終了しその内容を再生する
  • 音声データはメモリ上で扱いファイルは使用しない

処理のイメージ

   

動作の様子

動画:33秒  ※音量注意!

ソースコード

sonic01     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic01
     *
     * 端末のマイクから音声を録音しスピーカーで再生する
     * Android 標準の AudioRecord, AudioTrack を使用
     *
     * サンプリング周波数 44.1kHz
     * 量子化ビット数 16
     * モノラル
     *
     */
    
    package jp.klab.sonic01;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.ProgressBar;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final int BLOCK_NUMBER = 300;
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_PLAY_END   = 130;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
      private AudioTrack mAudioTrack = null;
    
      private Button mButton01;
      private ProgressBar mProgressBar;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mPlayBuf[];
      private short mRecordBuf[];
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mProgressBar = (ProgressBar)findViewById(R.id.progressBar);
        mProgressBar.setMax(BLOCK_NUMBER);
        mProgressBar.setProgress(0);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
        // 再生用バッファ
        mPlayBuf = new short[mBufferSizeInShort * BLOCK_NUMBER];
    
        // 録音用
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setEnabled(false);
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButton01.setEnabled(true);
            mButton01.setText("START");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        int count = 0;
        while (mInRecording && !mStop) {
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          // 再生用バッファはリングバッファとして扱う
          if (count * mBufferSizeInShort >= mPlayBuf.length) {
            count = 0;
            mProgressBar.setProgress(0);
          }
          // 再生用バッファへ集音したデータをアペンド
          System.arraycopy(mRecordBuf, 0, mPlayBuf, count * mBufferSizeInShort, mBufferSizeInShort);
          mProgressBar.setProgress(++count);
        }
        // 集音終了
        mAudioRecord.stop();
        mProgressBar.setProgress(0);
        mHandler.sendEmptyMessage(MSG_RECORD_END);
        if (mStop) {
          return;
        }
        // 再生
        mAudioTrack.setPlaybackRate(SAMPLE_RATE);
        mAudioTrack.play();
        mAudioTrack.write(mPlayBuf, 0, count * mBufferSizeInShort);
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    }
    
    
  • AndroidManifest.xml
    
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="jp.klab.sonic01">
      <uses-permission android:name="android.permission.RECORD_AUDIO"/>
      <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="orientation|screenSize"
          android:screenOrientation="unspecified"
          android:theme="@style/AppTheme.NoActionBar">
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
        <!-- ATTENTION: This was auto-generated to add Google Play services to your project for
           App Indexing.  See https://g.co/AppIndexing/AndroidStudio for more information. -->
        <meta-data
          android:name="com.google.android.gms.version"
          android:value="@integer/google_play_services_version" />
      </application>
    </manifest>
    
    

準備 2. 任意の周波数の音波を生成・出力する

音による情報伝達(言語音を除く)においては、伝えたい内容の意味を構成する要素として「音の高さ(周波数)」と「音の大きさ(振幅)」が挙げられます。これらを所定の規則に基づいて組み合わせることで送り手と受け手の間でのやりとりが可能となるでしょう。送り手側にはそこで任意の周波数・振幅の音を作る処理が必要となります。これがふたつめの課題です。

サイン波について

音の波形には種類がありますが、ここでは基本波形のひとつであるサイン波を使うことにしました。サイン波をプログラムで生成するためにその性質・特徴と関連する情報を整理してみます。

  • 正弦波 - wikipedia より
    正弦波(せいげんは、sine wave、sinusoidal wave)は、正弦関数として観測可能な周期的変化を示す波動のことである。その波形は正弦曲線(せいげんきょくせん、sine curve)もしくはシヌソイド (Sinusoid) と呼ばれ、数学、信号処理、電気工学およびその他の分野において重要な働きをする。       :
  • 正弦波交流って、実は円運動なんです - manapo.com 様のサイトより
    ある点を中心に回転する運動は「円運動」と言われ、 物理の世界ではよく出てくる言葉でもある。この円運動の軌跡を回転角度を横軸にしてグラフにすると、このように正弦波の形になる。
    角度が90°のところで山になり、180°のところで出発点と同じになり、270°のところで谷になり、360°のところで元に戻る。

    図では回転角度ではなく、少しでも理解しやすいように時間としてある。言い換えると、円運動で1回転するのに必要な時間を周期と呼ぶのである。そして、1秒間の回転数が即ち周波数である。
  •  https://www.youtube.com/embed/ij0K2tUVEGs - surikenclub 様による Youtube 動画
  • 第5章 フーリエ級数 - Masasi.Sanae 様のサイトより
    ・1秒間に描かれる波の回数を「周波数」といい,単位はヘルツ(Hz)を用います。
    ・1秒間に進む角度のことを「角速度」といいω(オメガ)で表します。
    ・横軸からの波の振れ具合を「振幅」といいます。

    sin波,cos波の1つの波が現れる角度(周期といいます)は360°ですから

        角速度=周波数×360°

    が成り立ちます。次の例では周波数が3,角速度1080°,振幅1のsin波を表しています。
    周波数k,振幅aのsin波はy=a sin kωtで表されます。(ω=360°)

    y=sin 1080°t=sin 3ωt

  • ラジアン(弧度法) - wikipedia より
    ラジアン(radian, 単位記号: [rad])は、国際単位系 (SI) における角度(平面角)の単位である。円周上でその円の半径と同じ長さのを切り取る2本の半径が成す角の値と定義される。

    1ラジアンは度数法では (180/π)° で、およそ 57.29578° に相当する。180°は弧度法においては π rad であり、360° は 2π rad である。
          :
    数学で三角関数を扱う時は、角度にラジアンを用いるのが普通である。その理由は、それが「自然」だからであり、多くの公式が簡潔に書けるようになる。

    (※半径 r の円の円周は 2 π r であるため 円(=360°)を弧度法で表すと「2 π r ÷ r」。よって 360° = 2π ラジアン)

以上のことから、振幅が A、角速度 ω のサイン波信号のある時点 t 秒における値 y は次の式によって得られます。

    y = A * sin ( ω * t )

ここで、角速度 = 周波数( f Hz )× 360° であり 360° = 2π ラジアン であるため角速度は「周波数( f Hz ) × 2π」と表せるためこの式は次のようにも書けます。

    y = A * sin ( 2π * f * t )

サンプリングレートについて

所定の周波数の信号を出力するためには相応のサンプリング(標本化)レートが必要となります。

オーディオ CD の規格で採用されているサンプリングレート 44,100Hz であれば理論上 22,000Hz 付近までの再現が可能であるため実用上の不足はないでしょう。また、十分なダイナミックレンジを確保するために振幅情報の量子化にはこれもオーディオ CD と同様に 16ビットを用いることとします。(ちなみに DVD は 24ビットだそうです)

計算してみると、「サンプリングレート 44,100Hz・量子化ビット数 16・モノラル」で 1秒間の信号を構成するには、44,100点 * 2バイト * 1チャネル = 88,200バイトのメモリ空間が必要ということになります。

関連情報

試作

以上の情報をもとにプログラムを試作しました。200Hz 〜 14,000Hz の範囲の 10種類の周波数のサイン波信号を生成・出力するものです。ここでは要素数 44,100 の short 型配列に 1秒分のサイン波データを格納しループ再生を行っています。

動作の様子

動画:2分13秒  ※音量注意!


分析に使用しているアプリ: AudioUtil Spectrum Analyzer - play.google.com

ソースコード

sonic03     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic03
     *
     * サイン波信号を生成して鳴らす
     *
     * サンプリング周波数 44.1kHz
     * 量子化ビット数 16
     * モノラル
     *
     */
    
    package jp.klab.sonic03;
    
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.AudioManager;
    import android.media.AudioTrack;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.ToggleButton;
    
    import java.util.Arrays;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final float SEC_PER_SAMPLEPOINT = 1.0f / SAMPLE_RATE;
      private static final int AMP = 4000;
    
      private static final int [] FREQS =
          new int[] {200, 400, 800, 1600, 2000, 4000, 8000, 10000, 12000, 14000};
      private static final int DO = 262 * 2;
      private static final int RE = 294 * 2;
      private static final int MI = 330 * 2;
      private static final int FA = 349 * 2;
      private static final int SO = 392 * 2;
      private static final int RA = 440 * 2;
      private static final int SI = 494 * 2;
    
      private static final int MSG_PLAY_START = 120;
      private static final int MSG_PLAY_END   = 130;
    
      private Handler mHandler;
      private AudioTrack mAudioTrack = null;
      private ToggleButton mButtons[];
      private int mIdxButtonPushed = -1;
      private int mBufferSizeInShort;
      private short mPlayBuf[];
    
      // 1秒分のサイン波データを生成
      private void createSineWave(int freq, int amplitude, boolean doClear) {
        if (doClear) {
          Arrays.fill(mPlayBuf, (short) 0);
        }
        for (int i = 0; i < SAMPLE_RATE; i++) {
          float currentSec = i * SEC_PER_SAMPLEPOINT; // 現在位置の経過秒数
          // y(t) = A * sin(2π * f * t)
          double val = amplitude * Math.sin(2.0 * Math.PI * freq * currentSec);
          mPlayBuf[i] += (short)val;
        }
      }
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButtons = new ToggleButton[11];
        (mButtons[0] = (ToggleButton)findViewById(R.id.button01)).setOnClickListener(this);
        (mButtons[1] = (ToggleButton)findViewById(R.id.button02)).setOnClickListener(this);
        (mButtons[2] = (ToggleButton)findViewById(R.id.button03)).setOnClickListener(this);
        (mButtons[3] = (ToggleButton)findViewById(R.id.button04)).setOnClickListener(this);
        (mButtons[4] = (ToggleButton)findViewById(R.id.button05)).setOnClickListener(this);
        (mButtons[5] = (ToggleButton)findViewById(R.id.button06)).setOnClickListener(this);
        (mButtons[6] = (ToggleButton)findViewById(R.id.button07)).setOnClickListener(this);
        (mButtons[7] = (ToggleButton)findViewById(R.id.button08)).setOnClickListener(this);
        (mButtons[8] = (ToggleButton)findViewById(R.id.button09)).setOnClickListener(this);
        (mButtons[9] = (ToggleButton)findViewById(R.id.button10)).setOnClickListener(this);
        (mButtons[10] = (ToggleButton)findViewById(R.id.button11)).setOnClickListener(this);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
        // 再生用バッファ
        mPlayBuf = new short[SAMPLE_RATE]; // 1秒分のバッファを確保
    
        // 再生用
        mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_OUT_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes,
                        AudioTrack.MODE_STREAM);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioTrack != null) {
          if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioTrack");
            mAudioTrack.stop();
            mAudioTrack.flush();
          }
          mAudioTrack = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        for (int i = 0; i < mButtons.length; i++) {
          if (mButtons[i] == (ToggleButton)v) {
            mIdxButtonPushed = i;
            break;
          }
        }
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        if (mButtons[mIdxButtonPushed].isChecked()) {
          new Thread(this).start();
        }
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_PLAY_START:
            Log.d(TAG, "MSG_PLAY_START");
            break;
          case MSG_PLAY_END:
            Log.d(TAG, "MSG_PLAY_END");
            mButtons[mIdxButtonPushed].setChecked(false);
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        mHandler.sendEmptyMessage(MSG_PLAY_START);
        if (mAudioTrack.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) {
          mAudioTrack.stop();
          mAudioTrack.flush();
        }
        mAudioTrack.play();
        if (mIdxButtonPushed != 10) {
          createSineWave(FREQS[mIdxButtonPushed], AMP, true);
          for (int i = 0; i < 5; i++) { // 5秒程度鳴らす
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE);
          }
        } else { // SONG
          for (int i = 0; i < 2; i++) {
            mAudioTrack.play();
            createSineWave(DO, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(FA, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(DO, AMP, true);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE);
    
            // 和音
            createSineWave(DO, AMP, true);
            createSineWave(MI, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            createSineWave(FA, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            createSineWave(SO, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(FA, AMP, true);
            createSineWave(RA, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(MI, AMP, true);
            createSineWave(SO, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(RE, AMP, true);
            createSineWave(FA, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE / 2);
            createSineWave(DO, AMP, true);
            createSineWave(MI, AMP, false);
            mAudioTrack.write(mPlayBuf, 0, SAMPLE_RATE);
          }
        }
        mAudioTrack.stop();
        mAudioTrack.flush();
        mHandler.sendEmptyMessage(MSG_PLAY_END);
      }
    }
    
    
  • AndroidManifest.xml
    
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="jp.klab.sonic03">
      <uses-permission android:name="android.permission.RECORD_AUDIO"/>
      <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="orientation|screenSize"
          android:screenOrientation="unspecified"
          android:theme="@style/AppTheme.NoActionBar">
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
        <!-- ATTENTION: This was auto-generated to add Google Play services to your project for
           App Indexing.  See https://g.co/AppIndexing/AndroidStudio for more information. -->
        <meta-data
          android:name="com.google.android.gms.version"
          android:value="@integer/google_play_services_version" />
      </application>
    </manifest>
    
    

スプレッドシートを使っての検証

前述のように上のコードの createSineWave 関数は要素数 44,100 の short 型配列に 44,100Hz・16ビット・モノラル 1秒分のサイン波データを生成しています。

この配列の先頭 441 要素分、つまり 1/100 秒分のデータとそれをグラフ化したものを以下に示します。それぞれ期待通りの波形が生成されていることが見てとれます。

  • 周波数 400Hz を指定した場合:400Hz/100 につき 4 周期分の出力
    element,value
    0,0
    1,227
    2,454
    3,680
    4,903
    5,1124
    6,1341
    7,1553
    8,1761
    9,1962
    10,2158
    11,2346
    12,2527
    13,2699
    14,2863
    15,3017
    16,3162
    17,3296
    18,3420
    19,3533
    20,3634
    21,3723
    22,3800
    23,3865
    24,3917
    25,3957
    26,3984
    27,3997
    28,3998
    29,3986
    30,3961
    31,3923
    32,3872
    33,3809
    34,3733
    35,3646
    36,3546
    37,3435
    38,3312
    39,3179
    40,3036
    41,2883
    42,2720
    43,2549
    44,2369
    45,2182
    46,1987
    47,1786
    48,1579
    49,1368
    50,1151
    51,931
    52,708
    53,483
    54,256
    55,28
    56,-199
    57,-426
    58,-652
    59,-876
    60,-1097
    61,-1314
    62,-1527
    63,-1735
    64,-1937
    65,-2134
    66,-2323
    67,-2505
    68,-2678
    69,-2843
    70,-2999
    71,-3145
    72,-3280
    73,-3405
    74,-3519
    75,-3622
    76,-3712
    77,-3791
    78,-3858
    79,-3911
    80,-3953
    81,-3981
    82,-3996
    83,-3999
    84,-3988
    85,-3965
    86,-3928
    87,-3879
    88,-3818
    89,-3743
    90,-3657
    91,-3559
    92,-3449
    93,-3328
    94,-3197
    95,-3055
    96,-2902
    97,-2741
    98,-2571
    99,-2392
    100,-2205
    101,-2012
    102,-1812
    103,-1606
    104,-1394
    105,-1179
    106,-959
    107,-736
    108,-511
    109,-284
    110,-56
    111,170
    112,398
    113,624
    114,848
    115,1069
    116,1287
    117,1501
    118,1709
    119,1913
    120,2110
    121,2300
    122,2482
    123,2657
    124,2823
    125,2980
    126,3127
    127,3264
    128,3390
    129,3506
    130,3610
    131,3702
    132,3782
    133,3850
    134,3905
    135,3948
    136,3978
    137,3995
    138,3999
    139,3990
    140,3968
    141,3934
    142,3886
    143,3826
    144,3753
    145,3669
    146,3572
    147,3464
    148,3344
    149,3214
    150,3073
    151,2922
    152,2762
    153,2592
    154,2415
    155,2229
    156,2036
    157,1837
    158,1632
    159,1421
    160,1206
    161,987
    162,764
    163,539
    164,313
    165,85
    166,-142
    167,-369
    168,-596
    169,-820
    170,-1042
    171,-1260
    172,-1474
    173,-1684
    174,-1887
    175,-2085
    176,-2276
    177,-2460
    178,-2636
    179,-2803
    180,-2961
    181,-3109
    182,-3247
    183,-3375
    184,-3492
    185,-3597
    186,-3691
    187,-3773
    188,-3842
    189,-3899
    190,-3944
    191,-3975
    192,-3994
    193,-3999
    194,-3992
    195,-3972
    196,-3939
    197,-3893
    198,-3834
    199,-3763
    200,-3680
    201,-3585
    202,-3478
    203,-3360
    204,-3231
    205,-3091
    206,-2941
    207,-2782
    208,-2614
    209,-2437
    210,-2253
    211,-2061
    212,-1862
    213,-1658
    214,-1448
    215,-1233
    216,-1014
    217,-792
    218,-567
    219,-341
    220,-113
    221,113
    222,341
    223,567
    224,792
    225,1014
    226,1233
    227,1448
    228,1658
    229,1862
    230,2061
    231,2253
    232,2437
    233,2614
    234,2782
    235,2941
    236,3091
    237,3231
    238,3360
    239,3478
    240,3585
    241,3680
    242,3763
    243,3834
    244,3893
    245,3939
    246,3972
    247,3992
    248,3999
    249,3994
    250,3975
    251,3944
    252,3899
    253,3842
    254,3773
    255,3691
    256,3597
    257,3492
    258,3375
    259,3247
    260,3109
    261,2961
    262,2803
    263,2636
    264,2460
    265,2276
    266,2085
    267,1887
    268,1684
    269,1474
    270,1260
    271,1042
    272,820
    273,596
    274,369
    275,142
    276,-85
    277,-313
    278,-539
    279,-764
    280,-987
    281,-1206
    282,-1421
    283,-1632
    284,-1837
    285,-2036
    286,-2229
    287,-2415
    288,-2592
    289,-2762
    290,-2922
    291,-3073
    292,-3214
    293,-3344
    294,-3464
    295,-3572
    296,-3669
    297,-3753
    298,-3826
    299,-3886
    300,-3934
    301,-3968
    302,-3990
    303,-3999
    304,-3995
    305,-3978
    306,-3948
    307,-3905
    308,-3850
    309,-3782
    310,-3702
    311,-3610
    312,-3506
    313,-3390
    314,-3264
    315,-3127
    316,-2980
    317,-2823
    318,-2657
    319,-2482
    320,-2300
    321,-2110
    322,-1913
    323,-1709
    324,-1501
    325,-1287
    326,-1069
    327,-848
    328,-624
    329,-398
    330,-170
    331,56
    332,284
    333,511
    334,736
    335,959
    336,1179
    337,1394
    338,1606
    339,1812
    340,2012
    341,2205
    342,2392
    343,2571
    344,2741
    345,2902
    346,3055
    347,3197
    348,3328
    349,3449
    350,3559
    351,3657
    352,3743
    353,3818
    354,3879
    355,3928
    356,3965
    357,3988
    358,3999
    359,3996
    360,3981
    361,3953
    362,3911
    363,3858
    364,3791
    365,3712
    366,3622
    367,3519
    368,3405
    369,3280
    370,3145
    371,2999
    372,2843
    373,2678
    374,2505
    375,2323
    376,2134
    377,1937
    378,1735
    379,1527
    380,1314
    381,1097
    382,876
    383,652
    384,426
    385,199
    386,-28
    387,-256
    388,-483
    389,-708
    390,-931
    391,-1151
    392,-1368
    393,-1579
    394,-1786
    395,-1987
    396,-2182
    397,-2369
    398,-2549
    399,-2720
    400,-2883
    401,-3036
    402,-3179
    403,-3312
    404,-3435
    405,-3546
    406,-3646
    407,-3733
    408,-3809
    409,-3872
    410,-3923
    411,-3961
    412,-3986
    413,-3998
    414,-3997
    415,-3984
    416,-3957
    417,-3917
    418,-3865
    419,-3800
    420,-3723
    421,-3634
    422,-3533
    423,-3420
    424,-3296
    425,-3162
    426,-3017
    427,-2863
    428,-2699
    429,-2527
    430,-2346
    431,-2158
    432,-1962
    433,-1761
    434,-1553
    435,-1341
    436,-1124
    437,-903
    438,-680
    439,-454
    440,-227
    
  • 周波数 1,600Hz を指定した場合:1,600Hz/100 につき 16 周期分の出力
    element,value
    0,0
    1,903
    2,1761
    3,2527
    4,3162
    5,3634
    6,3917
    7,3998
    8,3872
    9,3546
    10,3036
    11,2369
    12,1579
    13,708
    14,-199
    15,-1097
    16,-1937
    17,-2678
    18,-3280
    19,-3712
    20,-3953
    21,-3988
    22,-3818
    23,-3449
    24,-2902
    25,-2205
    26,-1394
    27,-511
    28,398
    29,1287
    30,2110
    31,2823
    32,3390
    33,3782
    34,3978
    35,3968
    36,3753
    37,3344
    38,2762
    39,2036
    40,1206
    41,313
    42,-596
    43,-1474
    44,-2276
    45,-2961
    46,-3492
    47,-3842
    48,-3994
    49,-3939
    50,-3680
    51,-3231
    52,-2614
    53,-1862
    54,-1014
    55,-113
    56,792
    57,1658
    58,2437
    59,3091
    60,3585
    61,3893
    62,3999
    63,3899
    64,3597
    65,3109
    66,2460
    67,1684
    68,820
    69,-85
    70,-987
    71,-1837
    72,-2592
    73,-3214
    74,-3669
    75,-3934
    76,-3995
    77,-3850
    78,-3506
    79,-2980
    80,-2300
    81,-1501
    82,-624
    83,284
    84,1179
    85,2012
    86,2741
    87,3328
    88,3743
    89,3965
    90,3981
    91,3791
    92,3405
    93,2843
    94,2134
    95,1314
    96,426
    97,-483
    98,-1368
    99,-2182
    100,-2883
    101,-3435
    102,-3809
    103,-3986
    104,-3957
    105,-3723
    106,-3296
    107,-2699
    108,-1962
    109,-1124
    110,-227
    111,680
    112,1553
    113,2346
    114,3017
    115,3533
    116,3865
    117,3997
    118,3923
    119,3646
    120,3179
    121,2549
    122,1786
    123,931
    124,28
    125,-876
    126,-1735
    127,-2505
    128,-3145
    129,-3622
    130,-3911
    131,-3999
    132,-3879
    133,-3559
    134,-3055
    135,-2392
    136,-1606
    137,-736
    138,170
    139,1069
    140,1913
    141,2657
    142,3264
    143,3702
    144,3948
    145,3990
    146,3826
    147,3464
    148,2922
    149,2229
    150,1421
    151,539
    152,-369
    153,-1260
    154,-2085
    155,-2803
    156,-3375
    157,-3773
    158,-3975
    159,-3972
    160,-3763
    161,-3360
    162,-2782
    163,-2061
    164,-1233
    165,-341
    166,567
    167,1448
    168,2253
    169,2941
    170,3478
    171,3834
    172,3992
    173,3944
    174,3691
    175,3247
    176,2636
    177,1887
    178,1042
    179,142
    180,-764
    181,-1632
    182,-2415
    183,-3073
    184,-3572
    185,-3886
    186,-3999
    187,-3905
    188,-3610
    189,-3127
    190,-2482
    191,-1709
    192,-848
    193,56
    194,959
    195,1812
    196,2571
    197,3197
    198,3657
    199,3928
    200,3996
    201,3858
    202,3519
    203,2999
    204,2323
    205,1527
    206,652
    207,-256
    208,-1151
    209,-1987
    210,-2720
    211,-3312
    212,-3733
    213,-3961
    214,-3984
    215,-3800
    216,-3420
    217,-2863
    218,-2158
    219,-1341
    220,-454
    221,454
    222,1341
    223,2158
    224,2863
    225,3420
    226,3800
    227,3984
    228,3961
    229,3733
    230,3312
    231,2720
    232,1987
    234,256
    235,-652
    236,-1527
    237,-2323
    238,-2999
    239,-3519
    240,-3858
    241,-3996
    242,-3928
    243,-3657
    244,-3197
    245,-2571
    246,-1812
    247,-959
    248,-56
    249,848
    250,1709
    251,2482
    252,3127
    253,3610
    254,3905
    255,3999
    256,3886
    257,3572
    258,3073
    259,2415
    260,1632
    261,764
    262,-142
    263,-1042
    264,-1887
    265,-2636
    266,-3247
    267,-3691
    268,-3944
    269,-3992
    270,-3834
    271,-3478
    272,-2941
    273,-2253
    274,-1448
    275,-567
    276,341
    277,1233
    278,2061
    279,2782
    280,3360
    281,3763
    282,3972
    283,3975
    284,3773
    285,3375
    286,2803
    287,2085
    288,1260
    289,369
    290,-539
    291,-1421
    292,-2229
    293,-2922
    294,-3464
    295,-3826
    296,-3990
    297,-3948
    298,-3702
    299,-3264
    300,-2657
    302,-1069
    303,-170
    304,736
    305,1606
    306,2392
    307,3055
    308,3559
    309,3879
    310,3999
    311,3911
    312,3622
    313,3145
    314,2505
    315,1735
    316,876
    317,-28
    318,-931
    319,-1786
    320,-2549
    321,-3179
    322,-3646
    323,-3923
    324,-3997
    325,-3865
    326,-3533
    327,-3017
    328,-2346
    329,-1553
    330,-680
    331,227
    332,1124
    333,1962
    334,2699
    335,3296
    336,3723
    337,3957
    338,3986
    339,3809
    340,3435
    341,2883
    342,2182
    343,1368
    344,483
    345,-426
    346,-1314
    347,-2134
    348,-2843
    349,-3405
    350,-3791
    351,-3981
    352,-3965
    353,-3743
    354,-3328
    355,-2741
    356,-2012
    357,-1179
    358,-284
    359,624
    360,1501
    361,2300
    362,2980
    363,3506
    364,3850
    365,3995
    366,3934
    367,3669
    368,3214
    369,2592
    370,1837
    371,987
    372,85
    373,-820
    374,-1684
    375,-2460
    376,-3109
    377,-3597
    378,-3899
    379,-3999
    380,-3893
    381,-3585
    382,-3091
    383,-2437
    384,-1658
    385,-792
    386,113
    387,1014
    388,1862
    389,2614
    390,3231
    391,3680
    392,3939
    393,3994
    394,3842
    395,3492
    396,2961
    397,2276
    398,1474
    399,596
    400,-313
    401,-1206
    402,-2036
    403,-2762
    404,-3344
    405,-3753
    406,-3968
    407,-3978
    408,-3782
    409,-3390
    410,-2823
    411,-2110
    412,-1287
    413,-398
    414,511
    415,1394
    416,2205
    417,2902
    418,3449
    419,3818
    420,3988
    421,3953
    422,3712
    423,3280
    424,2678
    425,1937
    426,1097
    427,199
    428,-708
    429,-1579
    430,-2369
    431,-3036
    432,-3546
    433,-3872
    434,-3998
    435,-3917
    436,-3634
    437,-3162
    438,-2527
    439,-1761
    440,-903
    

準備 3. 集音したデータの周波数分析を行う

FFT(Fast Fourier Transform 高速フーリエ変換)について

無響室のように人為的な特殊環境を除き世界は雑多な音で満ちています。人間の脳は自身が無意識のうちに複数の音の中から必要な音を選り分けており(カクテルパーティー効果)、静かに感じられる室内であってもレコーダーでの録音内容を再生すると意外なノイズに気づくといった経験は一般的なものですね。そのため、受け手側の装置が特定の音を検知するためには必要なものだけを明示的にピックアップする必要があります。これがみっつめの課題です。広く知られるフーリエ変換を音声信号に適用すれば音の塊を周波数成分ごとに分析できます。

フーリエ変換とは

  • フーリエ変換の本質 - apphokuson 様のサイトより

    全ての信号は、上図のように波の足しあわせで表現することが出来ます。

    具体的には、周波数が1の波1と周波数が2の波2と周波数が3の波3と・・・周波数が nの波nを足し合わせることで、あらゆる信号を表現することが出来るのです。
          :
    周波数を横軸に、振幅の大きさ(パワー値)を縦軸にとってグラフを書きなおしてみます。
          :
    この結果は、左図の信号に周波数1の波が大きさ4で含まれていること、周波数2の 波が大きさ0.5で含まれていること、周波数3の波が・・・ということを意味しています。
          :
    フーリエ変換を行えば、「信号に含まれる周波数の成分比」を得ることが出来ます。
          :

    (※引用に際し表記に一部手を加えさせて頂きました tanabe)

    • 統計的音声認識の基本原理 - 篠崎隆宏様のサイトより
      音声信号は,一次元の時系列データです.横軸を時間,縦軸を音の大きさとしてグラフを作成すると,以下のような図が得られます.

高速フーリエ変換とは

  • 高速フーリエ変換 - wikipedia より
    高速フーリエ変換(こうそくフーリエへんかん、英: Fast Fourier Transform、FFT) とは、離散フーリエ変換 (Discrete Fourier Transform、DFT) を計算機上で高速に 計算するアルゴリズム。FFTの逆変換をIFFT (Inverse FFT) と呼ぶ。
    • 離散フーリエ変換入門 - EnergyChord 様のサイトより
      コンピュータで行われるフーリエ変換は基本的に離散フーリエ変換である.なぜなら,電子計算機は連続量をそのまま扱うことができないため,コンピュータが処理するデータは必ず離散化される必要があるからである.つまり,コンピュータによるデータ処理が当たり前となった現代技術においては,フーリエ変換と言えば離散フーリエ変換を指すと言っても過言ではない.
      • 離散化とは - IT用語辞典バイナリより
        離散化とは、ある連続した情報を、非連続の値に分割することである。
        連続した値を持った情報を解析することは非常に困難であるが、離散化を行い非連続な数値に置き換えることで、近似的な計算結果を比較的容易に算出することが可能となる。
    • フーリエ変換と離散フーリエ変換 - 岩本真裕子様のサイトより
            :
      フーリエ変換では、波y(t)を構成する三角関数とその周波数・振幅・初期位相などを知ることができるため、波の特徴を明確に捉えることができる。

      離散フーリエ変換

      これまでは、連続関数に対するフーリエ変換およびフーリエ積分(逆フーリエ変換)について考えてきた。実際、音声や音楽などは、連続的な波として出力される。しかし、コンピュータは離散値でしか扱えないため、Analog-Didital (AD) 変換器を使って、一定間隔でサンプリングし、コンピュータが直接扱える離散的な波に変換される。
            :


      連続的な波と離散的な波の関係

      フーリエ変換は、連続的な波を三角関数を重ね合わせることで表そうとするものであった。例えば、下図のように、8個の周期関数を重ね合わせたものが実際の音であったとする。


      次に、AD変換によって、1秒間に4個のサンプリング数(4等分)で連続的な波が離散化されたとする(黒丸)。よく見てみると、cos 3t の離散の4点は cos t の離散の4点と全く同じである。実は sin 3t と sin t についても同様である。
      つまり、離散化された波においては、高周波数の波は低周波数の波に吸収されてしまう。今の場合は、重ね合わせられた複雑な波が、k=0,1,2 の三角関数のみで表せるということになってしまう。
      しかし、容易に予想できるように、サンプリング数を 増やすと、この問題は解消する。
            :
      離散フーリエ変換を用いた音などの解析や加工には、サンプリングは非常に重要な要素であり、扱う波についてある程度特徴や性質を知っている必要がある。例えば、人間が聞くことができる音の周波数は、20 Hzから20 kHzと言われている(一般的には15 kHzくらいまで)ので、人間が聞くことができるすべての周波数をカバーしようとすると、サンプリング数は1秒間あたり、20000(Hz)×2=40000 回必要となる。
            :

FFT ライブラリ「JTransforms」

Android には標準の Visualizer クラスに FFT 用の API getFft() が用意されています。ただし、あくまでも「The Visualizer class enables application to retrieve part of the currently playing audio for visualization purpose.」であり、「Frequency data: 8-bit magnitude FFT by using the getFft(byte[]) method」と機能的にも控えめな内容となっています。 Android で使える FFT ライブラリには複数の選択肢がありますが、Piotr Wendykier 氏による JTransforms が高性能かつメジャーであることを知りこれを利用することにしました。

  • JTransforms - Piotr Wendykier - sites.google.com
    JTransforms is the first, open source, multithreaded FFT library written in pure Java. Currently, four types of transforms are available: Discrete Fourier Transform (DFT), Discrete Cosine Transform (DCT), Discrete Sine Transform (DST) and Discrete Hartley Transform (DHT). The code is derived from General Purpose FFT Package written by Takuya Ooura and from Java FFTPack written by Baoshe Zhang.

    (Google 訳)
    JTransformsは、純粋なJavaで書かれた最初のオープンソースのマルチスレッド FFTライブラリです。現在、離散フーリエ変換(DFT)、離散コサイン変換(DCT)、 離散サイン変換(DST)および離散ハートレー変換(DHT)の4種類の変換が利用可能 である。このコードは、Takuya Ooura が書いた汎用FFTパッケージと、Baoshe Zhangが 書いたJava FFTPackから得られたものです。
          :
    License
    JTransforms is distributed under the terms of the BSD-2-Clause license.
  • wendykierp/JTransforms - GitHub
    • Documentation | Overview (JTransforms 3.1 API) - wendykierp.github.io
      JTransforms 3.1 API
      Packages
      Package                Description
      org.jtransforms.dct    Discrete Cosine Transforms.
      org.jtransforms.dht    Discrete Hartley Transforms.
      org.jtransforms.dst    Discrete Sine Transforms.
      org.jtransforms.fft    Discrete Fourier Transforms.
      org.jtransforms.utils  Utility classes.
      • Class DoubleFFT_1D
        org.jtransforms.fft
        Class DoubleFFT_1D

        Computes 1D Discrete Fourier Transform (DFT) of complex and real,double precision data. The size of the data can be an arbitrary number.

        (Google 訳)
        複素数と実数の倍精度データの一次元離散フーリエ変換(DFT)を計算します。データのサイズは任意の数にすることができます。
前掲のように音声信号は一次元データなので使うのはこの DoubleFFT_1D クラスでよさそうですが Javadoc では利用方法がさっぱりわからず使用例をネットで探しました。
  • JTransforms FFT in Android from PCM data - stackoverflow.com
      System.arraycopy(applyWindow(sampleData), 0, a, 0, sampleData.length);
      fft.realForward(a);

      /* find the peak magnitude and it's index */
      double maxMag = Double.NEGATIVE_INFINITY;
      int maxInd = -1;

      for(int i = 0; i < a.length / 2; ++i) {
        double re  = a[2*i];
        double im  = a[2*i+1];
        double mag = Math.sqrt(re * re + im * im);

        if(mag > maxMag) {
          maxMag = mag;
          maxInd = i;
        }
      }
  • How to get frequency from fft result? - stackoverflow.com
    The complex data is interleaved, with real components at even indices and imaginary components at odd indices, i.e. the real components are at index 2*i, the imaginary components are at index 2*i+1.

    To get the magnitude of the spectrum at index i, you want:

    re = fft[2*i];
    im = fft[2*i+1];
    magnitude[i] = sqrt(re*re+im*im);

    Then you can plot magnitude[i] for i = 0 to N / 2 to get the power spectrum. Depending on the nature of your audio input you should see one or more peaks in the spectrum.

    To get the approximate frequency of any given peak you can convert the index of the peak as follows:

    freq = i * Fs / N;

    where:

    freq = frequency in Hz
    i = index of peak
    Fs = sample rate (e.g. 44100 Hz or whatever you are using)
    N = size of FFT (e.g. 1024 in your case)

なるほど、使い方は難しくないようです。整理してみます。

  • 音声情報(=実数のみで構成される)を FFT にかける場合は信号データの配列を引数として realForward メソッドを呼び出す
  • 実行結果は配列に複素数で上書きされ偶数インデックスには実部、ひとつ後の奇数インデックスには虚部、というペアが続く
  • 各周波数成分の振幅値は当該複素数の絶対値re2+im2 で得られる
    • 複素平面 - KIT数学ナビゲーション様のサイトより
      xy 平面において, x 軸に実数, y 軸に虚数を対応させて,複素数を表したものを複素平面という.または,複素数平面,ガウス平面ともいう.

      複素数 z=a+ⅈb を複素平面上に表したものが,右の図である.

      複素数z の絶対値の定義:

      | z |=| a+b |= a 2 + b 2 =r

      すなわち,複素平面上の原点Oから z までの距離 r となる.
  • 周波数は「配列要素のインデックス * サンプリングレート / 信号データの要素数(=「FFT サイズ」)」の式で得られる
  • つまり、もっとも振幅の大きい周波数(ピーク周波数)を得るには最大振幅値を持つ配列要素のインデックスを上の式に突っ込めばよい

試作

上記の JTransforms ライブラリを利用して集音データ中のピーク周波数を画面に表示するアプリを作成。期待どおりの結果が得られました。

動作の様子

動画:1分7秒  ※音量注意!

ソースコード

sonic04     GitHub

  • MainActivity.java
    
    /**
     *
     * sonic04
     *
     * 端末のマイクから音声を受信しピーク周波数を表示する
     * FFT 処理に JTransforms ライブラリを利用
     *
     * サンプリング周波数 44.1kHz
     * 量子化ビット数 16
     * モノラル
     *
     */
    
    package jp.klab.sonic04;
    
    
    import org.jtransforms.fft.DoubleFFT_1D;
    import android.media.AudioFormat;
    import android.media.AudioRecord;
    import android.media.MediaRecorder;
    import android.os.Bundle;
    import android.os.Handler;
    import android.os.Message;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.util.Log;
    import android.view.View;
    import android.widget.Button;
    import android.widget.TextView;
    
    public class MainActivity extends AppCompatActivity
        implements Runnable, View.OnClickListener, Handler.Callback {
      private static final String TAG = "SNC";
    
      private static final int SAMPLE_RATE = 44100;
      private static final short THRESHOLD_AMP = 0x00ff;
    
      private static final int MSG_RECORD_START = 100;
      private static final int MSG_RECORD_END   = 110;
      private static final int MSG_FREQ_PEAK  = 120;
      private static final int MSG_SILENCE    = 130;
    
      private Handler mHandler;
      private AudioRecord mAudioRecord = null;
    
      private Button mButton01;
      private TextView mTextView02;
    
      private boolean mInRecording = false;
      private boolean mStop = false;
      private int mBufferSizeInShort;
    
      private short mRecordBuf[];
      private DoubleFFT_1D mFFT;
      private double mFFTBuffer[];
      private int mFFTSize;
    
      @Override
      protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Log.d(TAG, "onCreate");
        mHandler = new Handler(this);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    
        mButton01 = (Button)findViewById(R.id.button01);
        mButton01.setOnClickListener(this);
        mTextView02 = (TextView)findViewById(R.id.textView02);
    
        int bufferSizeInBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT);
    
        mBufferSizeInShort = bufferSizeInBytes / 2;
        // 録音用バッファ
        mRecordBuf = new short[mBufferSizeInShort];
    
        // FFT 処理用
        mFFTSize = mBufferSizeInShort;
        mFFT = new DoubleFFT_1D(mFFTSize);
        mFFTBuffer = new double[mFFTSize];
    
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
                        SAMPLE_RATE,
                        AudioFormat.CHANNEL_IN_MONO,
                        AudioFormat.ENCODING_PCM_16BIT,
                        bufferSizeInBytes);
      }
    
      @Override
      public void onStop() {
        super.onStop();
        Log.d(TAG, "onStop");
      }
    
      @Override
      public void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy");
        mStop = true;
        try {
          Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        if (mAudioRecord != null) {
          if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_STOPPED) {
            Log.d(TAG, "cleanup mAudioRecord");
            mAudioRecord.stop();
          }
          mAudioRecord = null;
        }
      }
    
      @Override
      public void onClick(View v) {
        if (v == (View)mButton01) {
          // 集音開始 or 終了
          if (!mInRecording) {
            mInRecording = true;
            new Thread(this).start();
          } else {
            mInRecording = false;
          }
        }
        return;
      }
    
      @Override
      public boolean handleMessage(Message msg) {
        switch (msg.what) {
          case MSG_RECORD_START:
            Log.d(TAG, "MSG_RECORD_START");
            mButton01.setText("STOP");
            break;
          case MSG_RECORD_END:
            Log.d(TAG, "MSG_RECORD_END");
            mButton01.setText("START");
            break;
          case MSG_FREQ_PEAK:
            mTextView02.setText(Integer.toString(msg.arg1) + " Hz");
            break;
          case MSG_SILENCE:
            mTextView02.setText("");
            break;
        }
        return true;
      }
    
      @Override
      public void run() {
        boolean bSilence = false;
        mHandler.sendEmptyMessage(MSG_RECORD_START);
        // 集音開始
        mAudioRecord.startRecording();
        while (mInRecording && !mStop) {
          mAudioRecord.read(mRecordBuf, 0, mBufferSizeInShort);
          bSilence = true;
          for (int i = 0; i < mBufferSizeInShort; i++) {
            short s = mRecordBuf[i];
            if (s > THRESHOLD_AMP) {
              bSilence = false;
            }
          }
          if (bSilence) { // 静寂
            mHandler.sendEmptyMessage(MSG_SILENCE);
            continue;
          }
          int freq = doFFT(mRecordBuf);
          Message msg = new Message();
          msg.what = MSG_FREQ_PEAK;
          msg.arg1 = freq;
          mHandler.sendMessage(msg);
        }
        // 集音終了
        mAudioRecord.stop();
        mHandler.sendEmptyMessage(MSG_RECORD_END);
      }
    
      private int doFFT(short[] data) {
        for (int i = 0; i < mFFTSize; i++) {
          mFFTBuffer[i] = (double)data[i];
        }
        // FFT 実行
        mFFT.realForward(mFFTBuffer);
    
        // 処理結果の複素数配列から各周波数成分の振幅値を求めピーク分の要素番号を得る
        double maxAmp = 0;
        int index = 0;
        for (int i = 0; i < mFFTSize/2; i++) {
          double a = mFFTBuffer[i*2]; // 実部
          double b = mFFTBuffer[i*2 + 1]; // 虚部
          // a+ib の絶対値 √ a^2 + b^2 = r が振幅値
          double r = Math.sqrt(a*a + b*b);
          if (r > maxAmp) {
            maxAmp = r;
            index = i;
          }
        }
        // 要素番号・サンプリングレート・FFT サイズからピーク周波数を求める
        return index * SAMPLE_RATE / mFFTSize;
      }
    }
    
    
  • AndroidManifest.xml
    
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="jp.klab.sonic04">
      <uses-permission android:name="android.permission.RECORD_AUDIO"/>
      <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
          android:name=".MainActivity"
          android:label="@string/app_name"
          android:configChanges="orientation|screenSize"
          android:screenOrientation="unspecified"
          android:theme="@style/AppTheme.NoActionBar">
          <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
        </activity>
        <!-- ATTENTION: This was auto-generated to add Google Play services to your project for
           App Indexing.  See https://g.co/AppIndexing/AndroidStudio for more information. -->
        <meta-data
          android:name="com.google.android.gms.version"
          android:value="@integer/google_play_services_version" />
      </application>
    </manifest>
    
    

次回の予定

以上の内容により Android デバイスで「任意の周波数・振幅の音を出すこと」と「拾った音に含まれる周波数を検知すること」ができるようになりました。音を使ってデバイス間で通信を行うための素材がこれで揃ったことになります。次回はここまでの到達点を応用してビットデータの送受信を試みます。


(tanabe)
klab_gijutsu2 at 07:21|この記事のURLComments(0)TrackBack(0)
2017年05月18日

音を利用する 1

はてなブックマークに登録

はじめに

技術・インフラの進化とともに情報通信の効率が加速を続ける状況にあって、「音」を利用した往年の通信手法が近年あらためて注目されています。関連する話題をいくつか以下にピックアップしてみます。これらはいずれも最新の技術や文化に密着した要件での応用という点で共通しており、懐古的なモチーフとはまったく無縁であることが興味深く感じられます。

(※各引用文中の青字表示は筆者によるものです)

  • お買い物体験をあたらしく
    株式会社スポットライト様サイトより)
    スポットライトの技術

    (1) 位置検出技術
    独自の超音波技術により、Andorid/iOS双方に対応した、正確でセキュアな来店検知が実現できます

  • 『SSC』Smart Sonic Communication 超音波通信のご案内
    ドリームニュース様サイトより: 2012-06-20)
    SINGAPORE SDI PTE LTD(本社:シンガポール)は、超音波を利用した機器間通信技術として『SSC(Smart Sonic Communication)』を開発いたしました。 iPhone、Androidなどのスマートフォンはもとより、マイクとスピーカーを備えている機器であれば特別なハードウェアを追加することなく、ソフトウェアの導入のみで通信が可能です。

  • 店舗にチェックインすればポイントが貯まる「楽天チェック」はO2Oの本命となるか
    TechCrunch Japan 様サイトより: 2014-04-02)
    超音波を利用した来店検知ポイントアプリスマポ」を提供するスポットライト。 2013年10月に楽天が買収して完全子会社となった同社が、4月2日より楽天と連携した来店ポイントアプリ「楽天チェック」の提供を開始した。

  • 楽天チェック - 来店でポイントが貯まる!お得な無料アプリ!
    (Google Play ストア上の株式会社スポットライト様によるアプリケーション説明より: 当記事時点での更新日付表示は 2017-04-23)
    1.楽天チェックでポイントが貯まるお店を探す
    2.好きなお店や気になったお店に行く
    3.お店に着いたらアプリに記載された指定のエリアでボタンをタップするだけ!

  • LINEの「友だち追加」で超音波が使用可能に!ネタかと思ったらほんとに出来た!
    「デジさる」様サイトより: 2015-09-06)
    LINEを起動して左下の「その他」から「友だち追加」をタップします。
    「QRコード」をタップします。
    するとQRコードリーダーが起動します。友だち追加をする一方はこの状態で待機します。
    もう一方は、「自分のQRコードを表示」をタップしてQRコードを画面に出します。
    すると音量がこの時だけ大きくなります。人間の耳には聞こえませんがこの時既に超音波が出ているようです。

  • Radon - Share using Ultrasound
    (Google Play ストア上の Nam Nghiem 様によるアプリケーション説明より: 当記事時点での更新日付表示は 2016-03-29)
    NO NFC? NO PROBLEM.
    Radon uses sensors you already have, and everyone else has including Wi-Fi, Bluetooth and Ultrasound. It does everything automatically, so all you have to do is tap share.

  • 新しい Amazon Dash Button にマイクが残されている理由
    (当ブログの記事より: 2016-12-22)
    旧 Button が iOS 端末でのセットアップ時に音声信号を利用するためにマイクロフォンを内蔵していたことは理解できます。では、なぜ BLE 通信を利用する新 Button にもマイクが残されているのでしょう?
    使わない部品であれば製造コスト削減のためにも撤去するほうが合理的なはずです。まさか Amazon が何か良からぬことを企んでいるのでしょうか?

      ・ Amazon Dashからマイクが見つかる、これ盗聴器だろ - jisaka.blog.jp

  • 新ThinkPadではエラービープ音をAndroidアプリに聞かせるとエラー内容の特定が可能
    PC Watch 様サイトより: 2017-02-09)
    2017年モデルの新機能の1つとして、エラービープ音の進化が挙げられる。 これまでのPCのエラービープ音と言えば、長い音と短い音を組み合わせたモールス信号のようなもので、ユーザーはその音を聞いて、マニュアルと照らし合わせてエラーを特定するといった煩雑な作業が必要だった。

    一方、2017年モデルでは、個別の音階を持ったエラー音でエラーコードを発信し、スマートフォン対応の「Lenovo PC Diagnostics」(リンク先は Android版)というアプリに聞かせると、エラーコードとともに、機種名、エラーの原因、タイムスタンプ、そしてモデル名やシリアルナンバーに至るまでを特定できるようになった。これによって管理の利便性を大幅に 向上させた。

事例では人間の可聴域を離れた超音波を利用するケースがしばしば見受けられます。実際、「超音波通信」は人気のあるキーワードですね。もっとも、かつて電話回線ごしの音響カプラや旧世代のファクシミリでの応酬に可聴音が用いられてきたように、使用する音域を決定づけるのは利用環境等の条件の組み合わせにすぎませんから、技術者にとって一番の関心事はあくまでも「音を媒体としてどのように情報をやりとりするか」という本質的な話題でしょう。

電磁波に比べ音波には伝送上の距離や効率により多くの制約があります。その一方で、法規制の影響がほぼ皆無であることに加え、機器に必要な I/F は音声入出力(役割によっては片方でよい)のみであり自由度が高いという大きな特長があります。このような事情に関心を持ち、近距離通信手段の選択肢のひとつとして「音」を利用するノウハウに触れておきたいと考えました。

そんなわけでここしばらく手元では Android デバイスと mbed マイコンをプラットフォームとしてそれぞれの環境で音(超音波を含む)による情報通信の実験を行ってきました。実践的な情報が比較的乏しい状況も相まって興味深い経験でしたが、全体のボリュームがやや大きいこともあり今後中身を整理しながら数回に分け順を追ってこのブログに記事を書きたいと考えています。

今回は予告をかねて試作の一部を紹介します。以下の二本の動画をご覧下さい。

  • 例1:音による通信で二台の端末に「しりとり」をさせる   

  • 例2:電話の着信音をトリガーにマイコンで所定の処理を実行する   

    次回は Android 環境での取り組みと実装まわりの話題を掲載する予定です。

    雑記:最初の最初に試したこと 〜 所定の音に反応する機器

    2004年ごろ、東京駅八重洲地下街にまだ存在していたアイディアグッズの店「王様のアイディア」で「リモコン発見器」というネーミングの商品を購入した。付属の笛を吹くと小さな本体が音と光で反応するというもの。

    何度か使ってずっと忘れていたが今回ふと思い出しネットで探したところ現在も販売されていることを知った。音に関する予備知識がほとんどない状態なりに一連の取り組みの皮切りとしてこのデバイスを使って観察を試みた。

    • リモコン発見器
      旭電機化成株式会社様サイトより)
      リモコンに装着し笛又は口笛を吹けばピッピッと音と光でリモコンがお知らせします。
      特徴
      よく使うリモコンに、付属テープで発信機をセット。
      笛又は、口笛を吹けばピッピッと音と光でリモコンがお知らせします。
    • 音と光で行方不明のリモコンをお知らせ! スマイルキッズ リモコン発見器 ARS-11 - www.amazon.co.jp 当記事時点の販売価格は¥378
    • 説明書
      「本製品は、約1600〜2000ヘルツの音に反応するよう作られていますので、笛以外の音でも周波数が合えば反応します」
    • 中身
      写真左:下の丸い圧電素子がスピーカーとマイクを兼ねておりメーカーへ尋ねたところ「特定の音域のみへの反応はフィルタ処理ではなく圧電素子の周波数特性によるもの」とのこと。
    • 動作の様子
      付属の笛・音楽キーボード・口笛を使い、反応する周波数帯を確認しながらこのデバイスを操作した様子。
      動画:1分26秒  ※38秒あたりから耳に障る音が鳴るので音量に注意
      使用アプリ: AudioUtil Spectrum Analyzer - play.google.com
        
    • それぞれの音の特徴
      以下は動画撮影後にあらためて採取した上記アプリのスクリーンショット。環境音も含まれるがもっとも振幅の大きい周波数成分が近いため三者の波形の周期は大体似ている。
      1. 製品付属の笛
        波形
        ピーク周波数は 2.0kHz 付近
      2. 音楽キーボード(CASIO 製 SA-5: 音色 "HARMONICA")
        波形
        ピーク周波数は 1.9kHz 付近
      3. 口笛
        波形
        ピーク周波数は 1.6kHz 付近

    (tanabe)
  • klab_gijutsu2 at 07:46|この記事のURLComments(0)TrackBack(0)
    2017年01月20日

    さよなら Parse

    はてなブックマークに登録

    はじめに

    世界中の利用者の指先と視線を凝固させた突然の発表からまもなく 1年、Parse.com の全サービスが いよいよ 2017年1月28日(土)に終了します。拡張性が高く高機能でありながら使い勝手の良い優れたサービスだったので終息が惜しまれます。

          https://parse.com/

    手元では 2年ほど前に IoT の実験として試作した以下のしくみ「Anpi」で Parse.com を採り入れました。

    • mbed と Parse で作る高齢者世帯安否確認システム - 当ブログ
      一般に BaaS の主な目的はアプリケーションの対向サーバ側機能を代替・補完することにあり、サーバの管理運用やサーバ側コード開発に踏み込むコストを抑制しアプリ本体の開発に注力可能となることが利用者にとってのメリットですが、Parse にはサーバ上でユーザコードを実行することのできる「Cloud Code」というしくみが用意されています。(中略)今回作った装置は Parse サーバ上に設置した自作コードをそのまま呼び出して必要な処理を行っています。このように目的に応じて柔軟に利用できることが Parse の魅力のひとつと言ってよいでしょう。
    この「Anpi」はシンプルながら実用性が高く現在もプライベートで大いに役立っています。そのためすでに Parse.com からの乗り換えを完了していますが、移行の過程で複数のプラットフォームを対象として再実装を横断的に試してみました。今後必要となった際にスムースに想起できる道具立ての選択肢は多いほうが好ましく、今回のように具体的な要件があればその実装を通じて未体験のサービスの特徴や個性を見定めやすいと考えたためです。この記事はそういった試みの記録で、日本国内ではまだ知名度の低いものにも触れています。興味のある方はご覧下さい。

    ※文中の記述はいずれも 2016年11月から12月の時点の状況に基づくものであり現在の事情とは異なる可能性があります。あらかじめご了承下さい。

    手元の要件と移行先選定基準

    「Anpi」において Parse.com サイドで行っていた処理は以下の内容です。

    • 屋内に設置ずみの装置が人感センサ反応時に最短 30 分間隔で送信してくる情報をデータストアへ保存
    • 定刻に直近 60 レコード分をレポートとして所定のアドレスへメール送信
    • 装置につないだボタンが長押しされたら所定のアドレスへメールで通知(緊急メール)
    • メール配信には Parse.com が 公式 API で連携している Mailgun サービスを利用

    要件に特に煩雑なものはなくこれらを吸収可能なサービスはいくつも存在するでしょう。その上で、今回は次の三点を移行先選定の基準とすることにしました。

    • 運用に手がかからないこと
    • 柔軟性があること
    • 費用がかからないこと
    絵に描いたようなユーザエゴではありますが、当時 Parse.com を選んだ理由はこれらのすべてを満たしていたためでもあります。予備調査を経て次のサービス・プラットフォームを検討の対象としました。

    1. IFTTT + Google Drive + Google Apps Script
    2. Kii Cloud
    3. back4app(+ AWS Lambda)
    4. Backand

    なお、要件のうち「緊急メール」の発信については以前「今、ワンボタンの IoT デバイスが面白い」でも利用した SendGrid サービスの API を装置側のコードから直接叩くことにしました。メールの内容は決め打ちなので各サービスに仲介させるよりもそのほうが合理的と考えたためです。

    以下、上のよっつを順番にピックアップしてみます。

    移行先候補 1: IFTTT + Google Drive + Google Apps Script

    もっともシンプルに要件を満たす道具立てとしてまず IFTTT と Google サービスの組み合わせを考えました。IFTTT 経由で Google Drive 上の Spreadsheet をデータストアとして利用し Google Apps Script でサーバサイドの処理を実行する内容です。

    仮移行を通じての * 個人的な * 印象

    GOOD !

    • シンプルかつ柔軟
    • Google, IFTTT ともに今後課金の発生する可能性がきわめて低い
    • Google, IFTTT ともに今後サービスが終息する可能性がきわめて低い
    • コードを含めすべてをブラウザ上で操作できるため運用上の自由度が高い
    ! GOOD ?

    • 今回の要件には十分だが規模の大きいデータを扱うには不向き
    • あくまでも SaaS とハブサービスの組み合わせであるため当然ながらプッシュ通知など一般的な IoT プラットフォームの提供する機能は代替できない

    移行先候補 2: Kii Cloud

    Kii CloudKii 株式会社様の提供する日本発の BaaS です。

    仮移行を通じての * 個人的な * 印象

    GOOD !

    • 多機能かつ無料枠が広い
    • Parse.com と同様に BaaS でありながらサーバ機能拡張が可能
    • サーバのリージョンを自由に選択可能であり特に中国リージョンの存在は大きい
    • キーバリュー形式のデータは使用容量にカウントされないため他のサービスとの併用にも好適か
    • アクセス制御機能が充実
    • 日本語のリソースが充実している

    ! GOOD ?

    • ドキュメントの情報量は豊かだが通読性がもうひとつ?リンクの張り方にも改善の余地がありそうな印象(このあたりが改善されればより利用しやすくなるかも)
    • 開発者ポータルは UI・機能ともに今後の進化が期待される
    • 開発用のコマンドラインツールが Node.js ベースなのは利点と欠点が半々くらい? バッチジョブのスケジュール変更といった操作はブラウザ上でできると嬉しい・・

    移行先候補 3: back4app(+ AWS Lambda)

    back4appBACK4APP SERVICOS DIGITAIS LTDA(本社 米カリフォルニア)の提供する BaaS です。

    仮移行を通じての * 個人的な * 印象

    GOOD !

    • 無料枠が広い
    • 最初から Parse.com の代替用として起ち上げられた純度の高い Parse Alternative であるため Parse.com との親和性が高く移行が相対的に容易
    • UI, ドキュメントがわかりやすい

    ! GOOD ?

    • 記事中のジョブ設定の問題など現時点では荒削りな側面も見られる
    • Parse.com を継承するサービスとしては良好だが、逆にそのことが独立した BaaS としての新鮮味や個性の発露を削いでいる印象も?

    移行先候補 4: Backand

    BackandModuBiz Ltd(本社イスラエル Tel Aviv)の提供する BaaS です。

    仮移行を通じての * 個人的な * 印象

    GOOD !

    • 多機能かつ拡張性に優れている上に非常に使いやすい
    • テスト・デバッグ機能も充実しており UI もわかり易い
    • ファイルホスティングまわりの操作以外はすべてブラウザ上で完結できるため機動性が高い
    • ユーザビリティは今回の中で一番。あとは可用性とスケーラビリティ次第か

    ! GOOD ?

    • 無料枠の狭さが残念。また、Prototype Plan $0 -> Hobby Plan $19/月 -> Work Plan $49/月 ... といった大きめの料金差に比して待遇差が小さめ
    • 運営側の内部的な指標である「Cache Memory」「Compute Units」のように利用者が主体的にコントロールすることの難しい指標がプランの基準に含まれているため利用目処を立てにくい(むしろ、利用するならはじめから UNLIMITED な料金プランを選ぶべき)
    • 日本のみならず国外でもまだあまりメジャーではなく情報がとても少ない。優れたサービスであるにもかかわらず世間への浸透圧が低い一因は上の料金体系にもあるのではないか?

    おわりに

    手元要件の Parse.com からの移行にあたり以上よっつの環境を試してみました。最終的にこの中のひとつを「Anpi」の乗り換え先に決定して現在に至ります。当初はどれを選んだのかを書くつもりでしたが無粋にも思いやめておくことにしました。いずれも質の高いサービスでそれぞれに十分なメリットがあります。

    ほんの10年ほど前には影もなかったものが日進月歩で進化していく状況にリアルタイムで向き合っていると10年後の世界への想像が膨らみます。この時代の傍らを去っていく Parse.com をユーザのひとりとして敬意と感謝の念をもって見送りたいと思います。


    (tanabe)
    klab_gijutsu2 at 14:26|この記事のURLComments(0)TrackBack(0)
    2016年12月22日

    新しい Amazon Dash Button に「マイク」が残されている理由

    はてなブックマークに登録

    昨年以下の記事でピックアップした「Amazon Dash Button」が 2016年12月5日に日本国内でもリリースされ話題を集めています。

    Amazon.co.jp での販売開始からほどなく Button の多くが品切れとなり 12月22日現在も入荷待ちの状態が続いています。 このデバイスを実質無料で配布する巨大多国籍企業 Amazon のパワーは凄まじいですね。

    新旧 Dash Button の違い

    現在提供されている Dash Button は 2015年に米国でデビューしたオリジナルとは異なるものです。上のブログ記事でも触れた Matthew Petroff 氏のサイトには新旧 Button それぞれについて詳細かつ網羅的な興味ぶかい記事が掲載されています。

    The Amazon Dash Button it an Internet connected button that allows ordering a single product from Amazon.
               :
    Others have already posted about disassembling it, so I’ll focus mostly on the electronics, since the aforementioned blog posts are missing high-resolution images of thecircuit board and don’t quite get some details correct.
    Amazon updated the Dash Button’s hardware to revision two earlier this year, so I decided it was time for a new teardown (here’s last year’s teardown). The new product number is JK29LP; the old product number is JK76PL. While the form factor and case remained much the same, the internals changed substantially. The major highlights are a switch from Broadcom to Atmel chips, a switch from an Energizer lithium battery to a Duracell alkaline battery, and the addition of Bluetooth Low Energy.
               :

    Overall, the new Dash Button appears to be a revision designed to reduce production cost, centered around a reduction in energy usage, which allows for use of a considerably cheaper, alkaline battery.
    同氏の記事を参考にしながらまず新旧 Button の主な違いを整理してみます。
    (※余談ながら、同じ記事に注目した数日前の GIGAZINE さんの記事に先ほど気がつきましたが、特にぶつかるものでもないため草稿の内容のまま書くことにします)

    電源がリチウム一次乾電池からアルカリ乾電池に

    旧 Dash Button には単四型リチウム一次乾電池が内蔵されていました。

    http://dsas.blog.klab.org/archives/52233150.html

    ちなみに Amazon Dash Button は 米エナジャイザー社Ultimate Lithium 乾電池 単4形 (二次電池ではない)1本を内蔵している。アルカリ電池に比べ「最大 9倍長持ち」を惹句とする 現時点でおそらく最強の乾電池。

    データシートによると Ultimate Lithium AAA は「Max Discharge: 1.5 Amps Continuous, 2.0Amps Pulse (2 sec on / 8 sec off)」と高容量

    現在の Dash Button ではこの高価な電池に代えて米デュラセル社の単四型アルカリ乾電池が使用されています。
    旧:Energizer Ultimate Lithium AAA Battery
    新:Duracell Ultra AAA Alkaline Battery
    https://mpetroff.net/
    (2016-12-14 時点での両者の小売価格の例) 電池交換は新 Button においても不可であるため上記の変更が使用寿命に及ぼす影響が気になりますが、Petroff 氏による新旧両 Button の消費電流実測結果(下グラフ:目盛幅の違いに注意)によればスリープ状態では新 Button が 2.0μA以下、旧 Button が 2.3μA以下と前者のほうが良好であり、また、ボタン押下後のアクティブ状態においては新 Button のほうがおおむね 10%前後レベルが高いものの処理を完了し再度スリープするまでの所要時間は旧 Button の半分以下と、電源仕様の変更に伴い電力消費を抑制する作りに変更されている様子が窺えます。
    https://mpetroff.net/

    マイクロコントローラ / Wi-Fi チップの変更, BLE チップ追加, Flash メモリ容量増加

    新 Button では、以前の ST マイクロエレクトロニクス社製マイクロコントローラ STM32F205 が Atmel 社(2016年4月より Microchip Technology 傘下)製 ATSAMG55J19A-MU に、Broadcom 社製の Wi-Fi モジュール BCM943362WCD4 WICED が Atmel 社製 ATWINC1500B に変更されています。一方で 新 Button の Flash メモリは倍容量の 32Mビットに増強されており、また、旧 Button には存在しなかった BLE チップがセットアップ用に追加されています。これら一連の構成要素の変更は性能強化とコストダウンの両立を目的とした判断の結果と考えられます。


    U5) マイクロコントローラ:ST STM32F205
    U9) Wi-Fi モジュール:
    Broadcom BCM943362WCD4 WICED
    U6) フラッシュメモリ: Micron M25P16 (16Mbit)
    マイクあり


    U1) マイクロコントローラ:Atmel ATSAMG55J19A-MU
    U19) Wi-Fi チップ:Atmel ATWINC1500B
    U22) BLE チップ:Cypress CYBL10563-68FNXI
    U15) フラッシュメモリ: Micron N25Q032 (32Mbit)
    マイクあり
    https://mpetroff.net/

    セットアップ方法の変更

    新旧 Button はセットアップの方法が異なります。Android または iOS 端末と公式ショッピングアプリを利用する点は共通ですが、新版が同アプリと Button との応酬に BLE 通信を使う内容であるのに対し、旧版では Android 環境では Wi-Fi 通信、iOS 環境では超音波通信を利用する仕様でした。ちなみに、旧版では Fire Phone もセットアップに利用することが可能でした。

    旧 Dash Button のセットアップ

    web.archive.org に旧 Button のセットアップ手順説明ページのキャッシュが残っています。 この内容から、ボタン長押しにより移行するセットアップモードにおいて旧 Button がダミー Wi-Fi アクセスポイント 兼 音声情報のリスナーとして振る舞っていたことがわかります。

    ※下の図は上記キャッシュの iOS 端末向けの説明箇所からの抜粋
    ※動画は旧版のセットアップの様子(Youtube 2015ー08-06 投稿, iPhone)3分20秒あたりから


    http://www.amazon.com/
    https://youtu.be/NSrdo5oNzsI?t=203
    Android 端末を使ったセットアップに Dash Button のダミー Wi-Fi アクセスポイントへの一時的な切り替えが利用されている一方で iOS 端末でのセットアップに音声信号が採用されたのは JailBreak しない限り接続先の Wi-Fi アクセスポイントをプログラムから変更不可であることに起因するもののようです。

    新 Dash Button のセットアップ

    BLE チップが搭載された新 Button ではセットアップ時の公式アプリとの応酬に BLE 通信が利用されています。

    この変更によって Android / iOS プラットフォームでの手順が統一されシンプルでスマートになった反面、旧版では特に言及されていなかった対象 OS バージョンが「iOS 8.3 or higher, Android 4.1 or higher」に限定される形となりました。また、BLE 非対応の Fire Phone(2015年下期販売終了)も手順説明から消えています。

    現在の Dash Button セットアップ手順説明ページ

    画面遷移

    1
    2
    3
    4
    5
    6
    7
    8
    9

    新 Dash Button にマイクが残されている理由

    旧 Button が iOS 端末でのセットアップ時に音声信号を利用するためにマイクロフォンを内蔵していたことは理解できます。では、なぜ BLE 通信を利用する新 Button にもマイクが残されているのでしょう?使わない部品であれば製造コスト削減のためにも撤去するほうが合理的なはずです。まさか Amazon が何か良からぬことを企んでいるのでしょうか?

    前掲の手順説明ページを読んでいるうちにふと以下の記述が気になりました。

    Note: Some Dash Buttons and phones do not support Bluetooth connections. If your phone does not connect to your button, select Skip Bluetooth Setup. Then, follow the instructions in the app. Your phone then uses other connection options.
    注: スマートフォンとDash Buttonが接続されない場合は、 Bluetoothをスキップを選択します。次に、アプリの画面に 表示される手順に従います。

    公式アプリが Button と BLE 接続を確立できない場合はどのような所作となるのでしょう?興味を感じ試してみることにしました。

    BLE 接続不可の状況を再現するもっとも簡単な方法は「Button からの BLE アドバタイジングを発生させない」ことです。つまり、アプリに求められたタイミングでわざと Button のセットアップモードを起動せずそのまま放置しておけばよいでしょう。

    結論として、アプリは BLE 接続をしばらく試行した後に自動的に旧 Button でのセットアップシーケンスへ移行することがわかりました。 BLE 接続を諦めると、Android 版アプリは Button のダミー AP へ Wi-Fi 接続の切り替えを試み、iOS 版アプリは端末のスピーカーへ Button を接近させることを利用者へ促します。そのタイミングで新 Button をセットアップモードで起動すると旧スタイルでのセットアップ処理が滞りなく行われます。

    つまり、新 Button のセットアップモードは旧 Button でのそれと同一の I/F を備えています。この実装は、BLE の利便性を取り入れつつも間口の広い旧版での機構を温存することによりセットアップ段階でのトラブルを可能な限り吸収することに加え、旧 Button - 新アプリ間の互換性をシンプルに保つことを目的とするものと考えられます。

    Android, iOS 端末それぞれでこの操作を行った様子の動画を以下に示します。

    • 新 Button を旧スタイルでセットアップ - Android 版(2分9秒 環境音あり)
    • 新 Button を旧スタイルでセットアップ - iOS 版(1分33秒 環境音あり)
    なお、アプリは「端末の Bluetooth 機能が OFF の場合」に以下のメッセージを表示します。ここで Android 版では「拒否」、iOS 版では「Bluetooth をスキップ」を選択することで同様に旧スタイルのセットアップシーケンスへ移行することを確認しました。
    Andorid 版
    iOS 版

    図のように iOS 版に関しては本項の冒頭に引用した英語版の説明記事とほぼ整合しますが、日本語版記事は翻訳が十分ではなく(意図的なもの?)、また、Android ユーザが記事中のメッセージを目にする機会はありません。

    米国内にのみ存在する旧 Button は電池寿命で徐々に消えていく過程にあります。また、BLE 非対応の端末も次第に世間から姿を消していくことでしょう。上に掲げた現 Dash Button の二段構えのセットアップ I/F はあるいは過渡的なものかもしれません。


    (tanabe)
    klab_gijutsu2 at 14:41|この記事のURLComments(0)TrackBack(0)
    2016年08月30日

    既成の BLE デバイスを自作プログラムから利用する試み

    はてなブックマークに登録

    前回の記事:
    • BLE デバイス間の通信内容をパケットレベルで読み解いてみる
      題材には低価格で出回っているありふれた Anti-Lost 系 BLE デバイス A を選びました。この小さなデバイスには LED・ブザーと押しボタンスイッチが 実装されており、対向スマホアプリとの間で双方向のやりとりが可能なつくりになっています。


      デバイス A の UI
    前回は BLE パケットスニファを使って既成の BLE デバイス「A」と対向アプリ間の通信内容を記録しその精査を試みました。そこで得られた情報をもとに、今回は実験の一環としてデバイス A と連携する Android アプリの試作を行います。パケットログから窺える BLE セントラル側の所作を自作プログラムで再現することは論理的に可能でしょう。

    デバイス A とアプリの連携に必要な処理

    前回の記事から、デバイス A とアプリの連携に関する要素をピックアップしてみます。

    • アプリからデバイス A の LED・ブザーをコントロール

      • 「Scene 6: アプリからデバイス A の LED・ブザーを操作」
        Frame 616

        対向アプリ上の所定のボタンを押下すると、Frame 345, Frame 238 の示す、 "「Immediate Alert」サービス配下の「Alert Level」キャラクタリスティック" の Value Handle 0x0025 に値「2(High Alert)」が書き込まれる

        デバイス A の LED・ブザーはファームウェアレベルでこの Alert Level キャラクタリスティックに紐づけられている模様。High Alert 値が書き込まれるとそれに反応して短時間 LED とブザーが ON になる

      ー> デバイス A の提供する Immediate Alert サービス配下の Alert Level キャラクタリスティックにアプリから値 2 を書き込めばデバイス A の LED・ブザーが ON になり、値 0 を書き込めば OFF になる

    • デバイス A のボタン押下をアプリへ通知

      • 「Scene 5: デバイス A ボタン押下時のアプリへの通知を設定」
        Frame 491

        Frame 453、Frame 439、Frame 453 の示す通り、ハンドル 0x0036 は、 "ユーザ定義サービス 1(UUID = 0xFFE0)配下のユーザ定義キャラクタリスティック(UUID = 0xFFE1)配下の Client Characteristic Configuration Descriptor (CCCD)" である

        当該ユーザ定義キャラクタリスティック(UUID = 0xFFE1)のプロパティには Notify が設定されており、クライアントである対向アプリから CCCD 0x0036 に Notification bit (0x0001) を書き込んでおくことで、このキャラクタリスティックの値が更新された時に Value Handle である 0x0035 経由でアプリ側へ通知(Notification)が行われるようになる

      • 「Scene 7: デバイス A のボタンを押すとアプリへ通知」
        Frame 711

        デバイス A の物理ボタンはファームウェアレベルで Frame 439 の示す "ユーザ定義サービス 1(UUID = 0xFFE0)配下のユーザ定義キャラクタリスティック(UUID = 0xFFE1)" に紐づけられている模様。このボタンを押すと当該キャラクタリスティックの値がデバイス内部で更新され Frame 491 での仕込みに基づきアプリ側へ通知が行われる

      ー> 初期処理として、デバイス A の提供するユーザ定義サービス 1(UUID = 0xFFE0)配下のユーザ定義キャラクタリスティック(UUID = 0xFFE1)の持つ Client Characteristic Configuration Descriptor (詳細:1, 2) にアプリから値 0x0001を書き込んでおく。それ以降にデバイス A のボタンが押下されると当該キャラクタリスティック経由でデバイス A からアプリへ通知が行われる
    処理そのものには特に難しそうな要素もなく、アプリからデバイス A への接続後に所定のキャラクタリスティック・デスクリプタの操作を適切に行うことがポイントとなりそうです。

    サービス・キャラクタリスティック・デスクリプタの UUID について

    BLE ネイティブの世界では所定のキャラクタリスティックやデスクリプタの I/O には各エントリに紐づけられたハンドルが使用されますが、抽象化された Android API での処理対象はオブジェクトです。所定のキャラクタリスティックやデスクリプタのオブジェクトの取得にはそれぞれの UUID が必要であるため、プログラムの記述に際しては、パケットログ上に記録された所定のハンドルがどの UUID のエントリのものであるかを正確に把握する必要があります。
    また、サービス - キャラクタリスティック - デスクリプタは階層関係にあるため、所定のエントリのオブジェクトを取得する手続きは最上位にあるサービスのオブジェクトが常に起点となります。

    以下に、パケットログから所定のサービス以下の各エントリの UUID を見つける方法と、各 UUID からそれぞれのエントリのオブジェクトを取得する Android コードの例を示します。

    パケットログから所定のサービス以下の各エントリの UUID を拾う

    • 前回記事中のパケットログ Frame 229 では 0x0001 - 0xffff のハンドル空間を対象に Read By Group Type Request で GATT Primary Service Declaration を照会、そのレスポンスが Frame 231 です

             (クリックで大きく表示)

      ここではレスポンスに含まれる 3件のレコードのうち 2件めに注目してみます

      Opcode: Read By Group Type Response (0x11)
               :
      
      Attribute Data, Handle: 0x000c, Group End Handle: 0x000f
        Handle: 0x000c
        Group End Handle: 0x000f
        Value: 0118
               :
      
      • Primary Service Declaration の照会に対する Read By Group Type Response の「Value」には当該サービスの UUID が格納される
      • この例では 16ビット UUID = 0x1801 であり、これは、BLE 既定の Generic Attribute サービスを示す
      • 当該サービスはハンドルグループ 0x000c - 0x000f を占有する
    • Frame 287 では上記のハンドルグループ 0x000c - 0x000f を対象に Read By Type Request で GATT Characteristic Declaration を照会、そのレスポンスが Frame 289 です

             (クリックで大きく表示)

      レスポンスに注目します

      Opcode: Read By Type Response (0x09)
               :
      
      Attribute Data, Handle: 0x000d
        Handle: 0x000d
        Value: 200e00052a
      
      • Characteristic Declaration の照会に対する Read By Type Response の「Value」には当該キャラクタリスティックのプロパティ・Value Handle・UUID が格納される(詳細:1a, 2a, 1b

        (※表は BLUETOOTH SPECIFICATION Version 4.2 [Vol 3, Part G] page 532 より)
      • ここでは「value: 200e00052a」につき、プロパティ = 0x20 (Indicate), Characteristic Value Handle = 0x000e, 16ビット UUID = 0x2A52
      • 16ビット UUID = 0x2A52 は、BLE 既定の Service Changed キャラクタリスティックを示す
    • Frame 302 では残りのハンドル 0x000f についての情報を GATT へ Find Information Request で照会、そのレスポンスが Frame 305 です

             (クリックで大きく表示)

      レスポンスに注目します

      Opcode: Find Information Response (0x05)
      UUID Format: 16-bit UUIDs (0x01)
      Handle: 0x000f
      UUID: Client Characteristic Configuration (0x2902)
      
      • ハンドル 0x000f は Client Characteristic Configuration Descriptor (CCCD) であり、この CCCD は直前の Indicate プロパティを持つ Service Changed キャラクタリスティックに属する
        (※CCCD の 16ビット UUID は 0x2902 固定

    以上のことから、デバイス A 上の Generic Attribute サービスの構成は以下の内容であることがわかります。

    Generic Attribute サービス(UUID = 0x1801)
      |  Handle Group = 0x000c - 0x000f
     |
      +-- Service Changed キャラクタリスティック(UUID = 0x2A52)
            |   Handle = 0x000d, Value Handle = 0x000e
            |
            +-- Client Characteristic Configuration Descriptor (UUID = 0x2902)
                  Handle = 0x000f
    

    所定の UUID のエントリのオブジェクトを取得する

    ここまでに登場した UUID はすべて 16ビット値でしたが、16 ビット UUID は Bluetooth 用にアサインされている本来の 128ビット UUID の固定部分(BASE_UUID)を省略した表現です。

    • BLUETOOTH SPECIFICATION Version 4.2 [Vol 3, Part B] page 227
      2.5 SEARCHING FOR SERVICES
             :
      2.5.1 UUID
             :
      To reduce the burden of storing and transferring 128-bit UUID values, 
      a range of UUID values has been pre-allocated for assignment to 
      often-used, registered purposes. The first UUID in this pre-allocated
      range is known as the Bluetooth Base UUID and has the value 
      00000000-0000-1000-8000-00805F9B34FB,
             :
      
    つまり、Generic Attribute サービスの 16ビット UUID「0x1801」は、128ビット UUID「00001801-0000-1000-8000-00805F9B34FB」です。

    Android API を用いて前出の Generic Attribute サービスと Service Changed キャラクタリスティック、およびその配下の Client Characteristic Configuration Descriptor のオブジェクトを取得するコードのイメージを示します。

    private BluetoothGatt mBtGatt;
    
    private BluetoothGattCharacteristic mChServiceChanged;
    private BluetoothGattDescriptor mCCCD;
    
    // Generic Attribute サービス の UUID
    private UUID mUuidSvcGenericAttribute = UUID.fromString("00001801-0000-1000-8000-00805f9b34fb");
    // Service Changed キャラクタリスティックの UUID 
    private UUID mUuidChServiceChanged    = UUID.fromString("00002a52-0000-1000-8000-00805f9b34fb");
    // Client Characteristic Configuration Descriptor の UUID (固定値)
    private UUID mUuidCCCD                = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
                     :
    
    private BluetoothGattCallback mGattCallback = new bleGattCallback();
                     :
    
    // デバイス A へ接続後、GATT の提供する各サービス以下の一覧を取得
    mBtGatt.discoverServices();(mCtx, false, mGattCallback);
                     :
    
    // GATT イベントハンドラ
    private class bleGattCallback extends BluetoothGattCallback {
      @Override
      // GATT サービス一覧取得完了
      public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        super.onServicesDiscovered(gatt, status);
    
        // デバイス A の Generic Attribute サービスの
        // Service Changed キャラクタリスティックオブジェクトを取得
        BluetoothGattService svc = gatt.getService(mUuidSvcGenericAttribute);
        mChServiceChanged = svc.getCharacteristic(mUuidChServiceChanged);
    
        // Service Changed キャラクタリスティックの
        // Client Characteristic Configulation Descriptor を取得
        mCCCD = mChServiceChanged.getDescriptor(mUuidCCCD);
                     :
      }
    

    ちなみに、アプリ開発の初期に BluetoothGattCallback の onServicesDiscovered() に次の要領のコードを挿入して 各 GATT サービス配下の全エントリの UUID を階層的に出力し保存しておくと何かと便利です。

    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
      super.onServicesDiscovered(gatt, status);
    
      List<BluetoothGattService> serviceList = gatt.getServices();
    
      Log.d(TAG, "onServicesDiscovered: serviceList.size=" + serviceList.size());
    
      for (BluetoothGattService s : serviceList) {
        Log.d(TAG, "onServicesDiscovered: svc uuid=" + s.getUuid().toString());
        List<BluetoothGattCharacteristic> chlist = s.getCharacteristics();
        Log.d(TAG, "onServicesDiscovered: chrlist.size=" + chlist.size());
    
        for (BluetoothGattCharacteristic c : chlist) {
          UUID uuid = c.getUuid();
          Log.d(TAG, "onServicesDiscovered:  chr uuid=" + uuid.toString());
          List<BluetoothGattDescriptor> dlist = c.getDescriptors();
    
          Log.d(TAG, "onServicesDiscovered:  desclist.size=" + dlist.size());
          for (BluetoothGattDescriptor d : dlist) {
            Log.d(TAG, "onServicesDiscovered:   desc uuid=" + d.getUuid());
          }
        }
      }
    

    デバイス A への接続後に上のコードを実行した際のログです。(※見やすさのためにサービスごとに改行を挿入)

    当然ながら、この内容は前回採取したパケットログ内の各エントリの情報と符合しています。

    作成したアプリ

    以上の内容にもとづいてアプリを作成しました。ごくシンプルなものですが期待通りに動いています。

    ソースコード一式

    動画:動作の様子

    作成した Android アプリとデバイス A の連携の様子を収めた動画です。デバイス A は UI 部分のみを露出しています。
    (34秒 アラーム音あり 音量注意)
        

    メモ:実装手順など

    private BluetoothAdapter mBtAdapter;
    private BluetoothLeScanner mBtScanner;
    private BluetoothDevice mBtDevice;
    private BluetoothGatt mBtGatt;
    

    1. BluetoothAdapter 〜 BluetoothLeScanner を取得

    • BluetoothAdapter - developer.android.com
      Represents the local device Bluetooth adapter. The BluetoothAdapter lets you
      perform fundamental Bluetooth tasks, such as initiate device discovery, query
      a list of bonded (paired) devices, instantiate a BluetoothDevice using a known
      MAC address, and create a BluetoothServerSocket to listen for connection
      requests from other devices, and start a scan for Bluetooth LE devices.
                        :
      
      static BluetoothAdapter	getDefaultAdapter()
      
      Get a handle to the default local Bluetooth adapter. 
      
    • BluetoothAdapter - getBluetoothLeScanner - developer.android.com
      BluetoothLeScanner getBluetoothLeScanner ()
      
      Returns a BluetoothLeScanner object for Bluetooth LE scan operations. 
      
      • BluetoothLeScanner - developer.android.com
        BluetoothLeScanner
        
        This class provides methods to perform scan related operations for Bluetooth
        LE devices. An application can scan for a particular type of Bluetooth LE
        devices using ScanFilter. It can also request different types of callbacks
        for delivering the result. 
        
    mBtAdapter = BluetoothAdapter.getDefaultAdapter();
    mBtScanner = mBtAdapter.getBluetoothLeScanner();
    

    2. アドバタイジングパケットのスキャン 〜 対象とする BluetoothDevice を取得

    private ScanCallback mScanCallback = new bleScanCallback();
                     :
    
    mBtScanner.startScan(mScanCallback);
                     :
    
    private class bleScanCallback extends ScanCallback {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            super.onScanResult(callbackType, result);
            if (.......) {
              mBtDevice = result.getDevice();
            }
        }
        @Override
        public void onScanFailed(int errorCode) {
            super.onScanFailed(errorCode);
            Log.e(TAG, "onScanFailed: err=" + errorCode);
        }
    }
    

    3. デバイスへの接続

    • BluetoothDevice - connectGatt - developer.android.com
      BluetoothGatt connectGatt (Context context, 
                      boolean autoConnect, 
                      BluetoothGattCallback callback)
      
      Connect to GATT Server hosted by this device. Caller acts as GATT client. 
      The callback is used to deliver results to Caller, such as connection status
      as well as any further GATT client operations. The method returns a
      BluetoothGatt instance. You can use BluetoothGatt to conduct GATT client
      operations.
                        :
      callback 	BluetoothGattCallback: GATT callback handler that will
      receive asynchronous callbacks.
                        :
      
    • BluetoothGattCallback - developer.android.com
      •  onConnectionStateChange - developer.android.com
        void onConnectionStateChange (BluetoothGatt gatt, 
                        int status, 
                        int newState)
        
        Callback indicating when GATT client has connected/disconnected to/from
        a remote GATT server.
                          :
        newState 	int: Returns the new connection state. Can be one of
        STATE_DISCONNECTED or STATE_CONNECTED
                          :
        
    private BluetoothGattCallback mGattCallback = new bleGattCallback();
                     :
    
    mBtGatt = mBtDevice.connectGatt(mCtx, false, mGattCallback);
                     :
    
    private class bleGattCallback extends BluetoothGattCallback {
      @Override
      public void onConnectionStateChange(BluetoothGatt gatt, int status,
                                          int newState) {
        super.onConnectionStateChange(gatt, status, newState);
        if (newState == BluetoothProfile.STATE_CONNECTED) {
          // 接続確立 - デバイスの GATT サービス一覧の取得へ
        } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
          // 切断完了の処理
        }
      }
                     :
    
    

    4. GATT サービス - キャラクタリスティック - デスクリプタを探索 〜 必要なオブジェクトを取得

    • BluetoothGatt - discoverServices - developer.android.com
      boolean discoverServices ()
      
      Discovers services offered by a remote device as well as their
      characteristics and descriptors.
      
      This is an asynchronous operation. Once service discovery is completed, 
      the onServicesDiscovered(BluetoothGatt, int) callback is triggered. 
      If the discovery was successful, the remote services can be retrieved
      using the getServices() function. 
                        :
      
    • BluetoothGattCallback - onServicesDiscovered - developer.android.com
      void onServicesDiscovered (BluetoothGatt gatt, 
                      int status)
      
      Callback invoked when the list of remote services, characteristics
      and descriptors for the remote device have been updated, ie new services
      have been discovered.
                        :
      
    • BluetoothGatt - getService - developer.android.com
      getService
      
      BluetoothGattService getService (UUID uuid)
      
      Returns a BluetoothGattService, if the requested UUID is supported by
      the remote device.
      
      This function requires that service discovery has been completed for
      the given device.
      
      If multiple instances of the same service (as identified by UUID) exist,
      the first instance of the service is returned.
      
      Requires BLUETOOTH permission.
      
      Parameters
      uuid 	UUID: UUID of the requested service
      Returns
      BluetoothGattService 	BluetoothGattService if supported, or null if the
                              requested service is not offered by the remote device. 
      
      • BluetoothGattService - getCharacteristic - developer.android.com
        getCharacteristic
        
        BluetoothGattCharacteristic getCharacteristic (UUID uuid)
        
        Returns a characteristic with a given UUID out of the list of
        characteristics offered by this service.
        
        This is a convenience function to allow access to a given characteristic
        without enumerating over the list returned by getCharacteristics()
        manually.
        
        If a remote service offers multiple characteristics with the same UUID,
        the first instance of a characteristic with the given UUID is returned.
        
        Parameters
        uuid 	UUID
        Returns
        BluetoothGattCharacteristic  GATT characteristic object or null if no
                                     characteristic with the given UUID was found. 
        
        
        • BluetoothGattCharacteristic - getDescriptor - developer.android.com
          getDescriptor
          
          BluetoothGattDescriptor getDescriptor (UUID uuid)
          
          Returns a descriptor with a given UUID out of the list of descriptors
          for this characteristic.
          
          Parameters
          uuid  UUID
          Returns
          BluetoothGattDescriptor  GATT descriptor object or null if no
                                   descriptor with the given UUID was found. 
          
    private BluetoothGattCharacteristic mChAlertLevel = null;
    private BluetoothGattCharacteristic mChUser1 = null;
    private BluetoothGattDescriptor mDescUser1 = null;
    
    // デバイス A の提供するサービス・キャラクタリスティック群の UUID より
    private UUID mUuidSvcImAlert   = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb");
    private UUID mUuidChAlertLevel = UUID.fromString("00002a06-0000-1000-8000-00805f9b34fb");
    private UUID mUuidSvcUser1     = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb");
    private UUID mUuidChUser1      = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb");
    // UUID for Client Characteristic Configuration Descriptor
    // - BLUETOOTH SPECIFICATION Version 4.2 [Vol 3, Part G] page 537
    private UUID mUuidCCCD         = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
                     :
    
    private BluetoothGattCallback mGattCallback = new bleGattCallback();
                     :
    
    mBtGatt.discoverServices();(mCtx, false, mGattCallback);
                     :
    
    private class bleGattCallback extends BluetoothGattCallback {
      @Override
      // GATT サービス一覧取得完了
      public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        super.onServicesDiscovered(gatt, status);
    
        // デバイス A の Immediate Alert サービスの
        // Alert Level キャラクタリスティックオブジェクトを取得
        BluetoothGattService svc = gatt.getService(mUuidSvcImAlert);
        mChAlertLevel = svc.getCharacteristic(mUuidChAlertLevel);
    
        // デバイス A のユーザ定義サービス 1 の ユーザ定義キャラクタリスティックの
        // Client ​Characteristic Configulation Descriptor を取得
        svc = gatt.getService(mUuidSvcUser1);
        mChUser1 = svc.getCharacteristic(mUuidChUser1);
        mDescUser1 = mChUser1.getDescriptor(mUuidCCCD);
                     :
      }
    

    5. 所定のキャラクタリスティックからの通知を有効に

    // デバイス A への Alert 指示用
    private byte[] mCmdAlertOff = new byte[] {(byte)0x00}; // OFF (No Alert)
    private byte[] mCmdAlertOn  = new byte[] {(byte)0x02}; // ON (High Alert)
                     :
    
      // デバイス A のユーザ定義サービス 1 の ユーザ定義キャラクタリスティックの
      // Client ​Characteristic Configulation Descriptor を取得
      svc = gatt.getService(mUuidSvcUser1);
      mChUser1 = svc.getCharacteristic(mUuidChUser1);
      mDescUser1 = mChUser1.getDescriptor(mUuidCCCD);
    
      // 同キャラクタリスティックの値変更時の通知を有功にして
      // 同 CCCD へ ENABLE_NOTIFICATION_VALUE を書き込んで通知へ待機
      mBtGatt.setCharacteristicNotification(mChUser1, true);
      byte[] val = new byte[] {(byte)0x01, (byte)0x00};
      mDescUser1.setValue(val);
      mBtGatt.writeDescriptor(mDescUser1);
                     :
    
    private class bleGattCallback extends BluetoothGattCallback {
      @Override
      public void onCharacteristicChanged (BluetoothGatt gatt,
                                           BluetoothGattCharacteristic ch) {
        Log.d(TAG, "onCharacteristicChanged");
        // デバイス A のユーザ定義キャラクタリスティック 1 からの通知を受信
        if (ch == mChUser1) {
            Toast.makeText(mCtx, "* P U S H E D *", Toast.LENGTH_SHORT).show();
        }
      }
      @Override
      public void onDescriptorWrite (BluetoothGatt gatt,
                                    BluetoothGattDescriptor desc,
                                    int status) { // writeDescriptor() 結果
        super.onDescriptorWrite(gatt, desc, status);
        Log.d(TAG, "onDescriptorWrite: sts=" + status);
        if (desc == mDescUser1) {
          // デバイス A のユーザ定義サービス 1 の ユーザ定義キャラクタリスティックの
          // Client ​Characteristic Configulation Descriptor への書き込みが完了
        }
      }
                     :
    

    6. 所定のキャラクタリスティックへの書き込み

    // デバイス A への Alert 指示用
    private byte[] mCmdAlertOff = new byte[] {(byte)0x00}; // OFF (No Alert)
    private byte[] mCmdAlertOn  = new byte[] {(byte)0x02}; // ON (High Alert)
                     :
    
      mChAlertLevel.setValue(mCmdAlertOn);
      mBtGatt.writeCharacteristic(mChAlertLevel);
                     :
    
    private class bleGattCallback extends BluetoothGattCallback {
      @Override
      public void onCharacteristicWrite(BluetoothGatt gatt,
                                        BluetoothGattCharacteristic ch,
                                        int status) { // writeCharacteristic 結果
        super.onCharacteristicWrite(gatt, ch, status);
        if (ch == mChAlertLevel) { 
          Log.d(TAG, "mChAlertLevel: onCharacteristicWrite: sts=" + status);
        }
      }
                     :
    

    (tanabe)
    klab_gijutsu2 at 14:28|この記事のURLComments(0)TrackBack(0)
    2016年08月24日

    BLE デバイス間の通信内容をパケットレベルで読み解いてみる

    はてなブックマークに登録

    前回の記事:
    パケットスニファを使って実際に BLE 機器間の応酬を追ってみました。備忘をかねて以下に情報を控えます。題材には低価格で出回っているありふれた Anti-Lost 系 BLE デバイス A を選びました。この小さなデバイスには LED・ブザーと押しボタンスイッチが実装されており、対向スマホアプリとの間で双方向のやりとりが可能なつくりになっています。


    デバイス A の UI

    操作内容

    デバイス A と Android 端末を使って以下の操作を行いスニファで BLE パケットのログを採取しました。

    1. デバイス A の電源を入れる
    2. Android 端末上の対向アプリからデバイスを探索しデバイス A との接続を確立
    3. アプリからデバイス A の LED+ブザーを ON にする
    4. デバイス A のボタンを押しアプリへ通知を送る
    5. アプリ上でデバイス A との接続を切断

    応酬の全体像

    スニファの Wireshark ログより

    上の操作時に収集したパケットログの全体図です。   (クリックで大きく表示)

        

    要約

    パケットログの内容の要約を以下に示します。   (クリックで大きく表示)

    1. デバイス A の電源を入れる
      • デバイス A がアドバタイジングを開始 (ADV_IND)
    2. スマホ上の対向アプリからデバイスを探索しデバイス A との接続を確立
      • デバイスのスキャン (SCAN_REQ / SCAN_RSP) を経て接続要求 (CONNECT_REQ) 〜接続が確立するとデバイス A はアドバタイジングを停止
      • 機能情報を交換 (LL_FEATURE_REQ / LL_FEATURE_RSP)
      • デバイス A の提供する GATT プライマリサービス一覧を取得
      • 各サービス配下のエントリ(Included Service, キャラクタリスティック)一覧を取得
      • 各キャラクタリスティック配下のデスクリプタを取得
      • デバイス A のボタンが押された時に通知されるよう所定の Client Characteristic Configuration descriptor へアプリから Notification Bit 0x0001 を書き込む
    3. アプリからデバイス A の LED+ブザーを ON にする
      • アプリ上の所定のボタンが押されたタイミングでアプリはデバイス A の Immediate Alert サービス配下の Alert Level キャラクタリスティックへ値 2 (High Alert)を書き込む。これにより一定時間 A の LED とブザーが ON になる
    4. デバイス A のボタンを押しアプリへ通知を送る
      • デバイス A のボタンが押されたタイミングでデバイス A は自機のユーザ定義サービス 1 (UUID=0xFFE0) 配下のユーザ定義キャラクタリスティック (UUID=0xFFE1) へ値 1 を書き込む。当該キャラクタリスティック配下の Client Characteristic Configuration descriptor にはアプリ側から予め Notification Bit がセットされているためアプリへ通知される
    5. アプリ上でデバイス A との接続を切断
      • アプリからデバイス A へ LL_TERMINATE_IND を送る

    応酬の詳細

    全体の流れを見渡したところで次に個々のパケットデータを読み進めていきます。

    • 以下の記事には Wireshark のスクリーンショットをログとして添えています  (それぞれクリックで大きく表示)
    • ログには BLE パケットデータ本体に加え nRF Sniffer が解析の便宜のためにログへ出力する「Nordic BLE sniffer meta」ヘッダが含まれています
      • nRF Sniffer User Guide v1.1 (PDF) - www.nordicsemi.com Page 8 より
        4   Using Wireshark
        
        All BLE packets detected by the Sniffer are passed to Wireshark and are
        wrapped in a header which contains useful meta-information not present
        in the BLE packet itself. Wireshark dissects the packets and separates
        the actual packet from the meta-information.
                     :
        
    • 記事には下記資料からの抜粋へのリンクを参照用に適宜挿入しています
    • 随所に「https://developer.bluetooth.org/gatt/」下のサービス・キャラクタリスティックの記事へのリンクを張っていますが、2016年7月頃までは参照可能だった各記事が現在はリンク切れになっており(再構成中?)、さらに現時点では代替ページが見当たらないためリンク先はやむなく http://web.archive.org/ 上のキャッシュとしています。ただし web.archive.org 上にも保存履歴のない記事については一階層上のページへのリンクを張っています
      例:「Link Loss」サービスの単独ページのキャッシュは web.archive.org にも見当たらないため、上位のサービス一覧ページのキャッシュをターゲットに

    局面

    Scene 1: デバイス A のアドバタイジングと SCAN_REQ / SCAN_RSP

    Frame 124 - 126 はデバイス A 発のアドバタイジングパケット

    • 全 40 チャネルのうちアドバタイジングパケット用の 37, 38, 39 の 3 チャネルが順次使用されていることが見てとれる
    • 「0x8e89bed6」の Access Address はアドバタイジングチャネルパケットで使用される固定値
    • デバイス A のアドバタイジングのタイプは Connectable Undirected(詳細: 1, 2, 3, 4) であることを示す一般的な「ADV_IND」である
    • 「Advertising Data: 020106020a000702031802180418」に含まれる不完全 16ビットサービスクラス UUID は次の内容
      • 16 bit uuid: 0x1803 --> 既定の「Link Loss」サービス
      • 16 bit uuid: 0x1802 --> 既定の「Immediate Alert」サービス
      • 16 bit uuid: 0x1804 --> 既定の「Tx Power」サービス
    Frame 124

        

    Frame 125

        

    Frame 126

        

    Frame 127

    接続に際し対向アプリがデバイス A に対して SCAN_REQ を発行

        

    Frame 128

    デバイス A が対向アプリからの SCAN_REQ に対し SCAN_RSP を返信。ここでは ScanRspData としてデバイスのローカルネームが渡されている

        

    Scene 2: 接続の確立と情報交換

    Frame 217

    対向アプリがデバイス A に対して CONNECT_REQ(詳細:1a, 2a, 1b, 2b)を発行して接続を試みる

    • 「Connection Request」中の「Connection Access Address」には、接続確立後のデータ通信において Access Address として双方が一意に使用する任意のアドレスが含まれる。ここでは「0xa6a188c8」
    • 「Connection Request」中の「Channel map」には接続確立後のデータ通信で使用するチャネル番号のリストが提示される。ここでは 0 - 36 の全データチャネルが指定されている
    • その他のパラメータについては上のリンクの資料を参照のこと
    • CONNECT_REQ に対する直接のレスポンスは発生しない。接続が確立するとデータ通信アイドル時にスレーブ - マスタ間で空パケット(Empty PDU)の応酬が始まるためこれが接続成否の判定に用いられる

        

    Frame 218, 224 は接続確立後の最初の応酬。接続が確立すると対向アプリはマスタ、デバイス A はスレーブの位置づけとなる。ここでは リンク層制御 PDU の LL_FEATURE_REQ および LL_FEATURE_RSP によりマスタ - スレーブ間で Feature Exchange を行っている。ここでは双方とも「Supported feature: LE Encryption (0)」のみを提示(ただしここでは以降の通信において暗号化は行われていない)
    Frame 218

        

    Frame 224

        

    Frame 222 ではアプリ側が「Device Name」を要求し Frame 226 でデバイス A 側がそれに応えている。
    「Device Name」は、既定の Generic Access(GAP)サービス配下の既定のキャラクタリスティック(UUID = 0x2A00)であり、GATT サーバは必ず GAP サービスを含んでいる(詳細:1, 2
    Frame 222

      Opcode: Read By Type Request (0x08)
      Starting Handle: 0x0001
      Ending Handle: 0xffff
      UUID: Device Name (0x2a00)

        

    Frame 226

      Opcode: Read By Type Response (0x09)
      Length: 6
      Attribute Data, Handle: 0x0003
          Handle: 0x0003
          Value: ********

        

    (GAP サービスおよび Device Name キャラクタリスティックは後続の Frame 231, Frame 266 であらためて表に現れる)

    Scene 3: デバイス A の GATT サービス群の取得

    (Scene 3, Scene 4 共通の基礎知識)
    BLUETOOTH SPECIFICATION Version 4.2(PDF) [Vol 3, Part G] より
        2.6 GATT PROFILE HIERARCHY
           2.6.1 Overview, 2.6.2 Service, 2.6.3 Included Services, 2.6.4 Characteristic
      3 SERVICE INTEROPERABILITY REQUIREMENTS
         3.1 SERVICE DEFINITION, 3.2 INCLUDE DEFINITION, 3.3 CHARACTERISTIC DEFINITION
           3.3.1 Characteristic Declaration
             3.3.1.1 Characteristic Properties, 3.3.1.2 Characteristic Value Attribute Handle, 3.3.1.3 Characteristic UUID
           3.3.2 Characteristic Value Declaration

    初期処理として、マスタはスレーブの提供するサービス一覧を取得する (詳細:1, 2, 3

    • アプリ側が、デバイス A の GATT サーバの提供する公開サービス(プライマリサービス)の問合せを開始。 以降、GATT サーバ上の所定のサービスへアクセスするためのハンドルのアドレスと当該サービスの種類を識別する UUID を順次取得する
    • ハンドルのアドレス空間は 0x0000 - 0xFFFF であり GATT サーバ側の応答に応じて照会範囲を絞っていく


        (図は 「BLUETOOTH SPECIFICATION Version 4.2」 より引用)

    Frame 229

    まずハンドル 0x0001 - 0xffff 全範囲についてプライマリサービスを照会

        Opcode: Read By Group Type Request (0x10)
        Starting Handle: 0x0001
        Ending Handle: 0xffff
        UUID: GATT Primary Service Declaration (0x2800)

        

    Frame 231

    以下のみっつのサービスの情報が得られた

    • ハンドルグループ 0x0001 - 0x000b:既定の「Generic Access」サービス(UUID = 0x1800)が使用
    • ハンドルグループ 0x000c - 0x000f:既定の「Generic Attribute」サービス(UUID = 0x1801)が使用
    • ハンドルグループ 0x0010 - 0x0022:既定の「Device Information」サービス(UUID = 0x180A)が使用

        Opcode: Read By Group Type Response (0x11)
        Length: 6
        Attribute Data, Handle: 0x0001, Group End Handle: 0x000b
            Handle: 0x0001
            Group End Handle: 0x000b
            Value: 0018
        Attribute Data, Handle: 0x000c, Group End Handle: 0x000f
            Handle: 0x000c
            Group End Handle: 0x000f
            Value: 0118
        Attribute Data, Handle: 0x0010, Group End Handle: 0x0022
            Handle: 0x0010
            Group End Handle: 0x0022
            Value: 0a18

        

    Frame 235

    続けてハンドル 0x0023 - 0xffff 範囲のプライマリサービスを照会

        

    Frame 238

    以下のみっつのサービスの情報が得られた

    • ハンドルグループ 0x0023 - 0x0025:既定の「Immediate Alert」サービス(UUID = 0x1802)が使用
    • ハンドルグループ 0x0026 - 0x002a:既定の「Tx Power」サービス(UUID = 0x1804)が使用
    • ハンドルグループ 0x002b - 0x002d:既定の「Link Loss」サービス(UUID = 0x1803)が使用

        

    Frame 242

    続けてハンドル 0x002e - 0xffff 範囲のプライマリサービスを照会

        

    Frame 247

    以下のみっつのサービスの情報が得られた

    • ハンドルグループ 0x002e - 0x0032:既定の「Battery Service」サービス(UUID = 0x180F)が使用
    • ハンドルグループ 0x0033 - 0x0037:ユーザ定義のサービス 1(UUID = 0xFFE0)が使用
    • ハンドルグループ 0x0038 - 0x003a:ユーザ定義のサービス 2(UUID = 0xFFF0)が使用

        

    Frame 250

    続けてハンドル 0x003b - 0xffff 範囲のプライマリサービスを照会

        

    Frame 253

    「Attribute Not Found (0x0a)」のエラーが返る。これ以上プライマリサービスが存在しないことを意味する。サービスの照会はここまで

        Opcode: Error Response (0x01)
        Request Opcode in Error: Read By Group Type Request (0x10)
        Handle in Error: 0x003b
        Error Code: Attribute Not Found (0x0a)

        

    Scene 4: デバイス A 各サービス配下のキャラクタリスティック - デスクリプタの取得

    (Scene 3, Scene 4 共通の基礎知識)
    BLUETOOTH SPECIFICATION Version 4.2(PDF) [Vol 3, Part G] より
        2.6 GATT PROFILE HIERARCHY
           2.6.1 Overview, 2.6.2 Service, 2.6.3 Included Services, 2.6.4 Characteristic
      3 SERVICE INTEROPERABILITY REQUIREMENTS
         3.1 SERVICE DEFINITION, 3.2 INCLUDE DEFINITION, 3.3 CHARACTERISTIC DEFINITION
           3.3.1 Characteristic Declaration
             3.3.1.1 Characteristic Properties, 3.3.1.2 Characteristic Value Attribute Handle, 3.3.1.3 Characteristic UUID
           3.3.2 Characteristic Value Declaration

    次に、収集ずみの各プライマリサービスのハンドルグループごとに以下を行う (詳細:1, 2, 3, 4, 5, 6

    • 所定のサービスに含まれる Included Service の照会


        (図は 「BLUETOOTH SPECIFICATION Version 4.2」 より引用)

    • 所定のサービスに含まれるキャラクタリスティックの照会


        (図は 「BLUETOOTH SPECIFICATION Version 4.2」 より引用)

      • 所定のキャラクタリスティックに含まれるデスクリプタの照会


          (図は 「BLUETOOTH SPECIFICATION Version 4.2」 より引用)

    Frame 255

    前掲の Frame 231 の示すハンドルグループ 0x0001 - 0x000b の「Generic Access」サービス内の Included Service を照会

        Opcode: Read By Type Request (0x08)
        Starting Handle: 0x0001
        Ending Handle: 0x000b
        UUID: GATT Include Declaration (0x2802)

        

    Frame 259

    当該サービス内に Included Service は存在しない(注:図のログではスニファがパケットデータを取りこぼしている)

        

    Frame 262

    ハンドルグループ 0x0001 - 0x000b の「Generic Access」サービス内のキャラクタリスティックを照会

        Opcode: Read By Type Request (0x08)
        Starting Handle: 0x0001
        Ending Handle: 0x000b
        UUID: GATT Characteristic Declaration (0x2803)

        

    Frame 266

    以下のみっつのキャラクタリスティックの情報が得られた

    (※キャラクタリスティック の Value フィールドの構成は前掲の「BLUETOOTH SPECIFICATION Version 4.2] - [Vol 3, Part G] p.532 「3.3.1 Characteristic Declaration」 に、プロパティ値の意味は同じく p.533 「3.3.1.1 Characteristic Properties」 に説明あり)

    • ハンドル:0x0002
      「value: 080300002a」より、プロパティ = 0x08 (Write), Characteristic Value Handle = 0x0003
      UUID = 0x2A00 = 既定の「Device Name
    • ハンドル:0x0004
      「value: 020500012a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0005
      UUID = 0x2A01 = 既定の「Appearance
    • ハンドル:0x0006
      「value: 020700042a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0007
      UUID = 0x2A04 = 既定の「Peripheral Preferred Connection Parameters

        Opcode: Read By Type Response (0x09)
        Length: 7
        Attribute Data, Handle: 0x0002
          Handle: 0x0002
          Value: 080300002a
        Attribute Data, Handle: 0x0004
          Handle: 0x0004
          Value: 020500012a
        Attribute Data, Handle: 0x0006
          Handle: 0x0006
          Value: 020700042a

        

    Frame 270

    続けてハンドル 0x0007 - 0x000b 範囲のキャラクタリスティックを照会

        

    Frame 275

    以下のふたつのキャラクタリスティックの情報が得られた

    • ハンドル:0x0008
      「value: 020900022a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0009
      UUID = 0x2A02 = 既定の「Peripheral Privacy Flag
    • ハンドル:0x000a
      「value: 020b00032a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x000b
      UUID = 0x2A03 = 既定の「Reconnection Address

        

    Frame 279

    前掲の Frame 231 の示すハンドルグループ 0x000c - 0x000f の「Generic Attribute」サービス内の Included Service を照会

        

    Frame 284

    当該サービス内に Included Service は存在しない

        

    Frame 287

    ハンドルグループ 0x000c - 0x000f の「Generic Attribute」サービス内のキャラクタリスティックを照会

        

    Frame 289

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x000d
      「value: 200e00052a」より、プロパティ = 0x20 (Indicate), Characteristic Value Handle = 0x000e
      UUID = 0x2A52 = 既定の「Service Changed

        

    Frame 293

    続けてハンドル 0x000e - 0x000f 範囲のキャラクタリスティックを照会

        

    Frame 298

    当該サービス内にはこれ以上キャラクタリスティックは存在しない

        

    Frame 302

    Frame 289 の示す通り 0x0000e は Service Changed キャラクタリスティックの Value Handle であり、残る0x000f に関する情報を GATT に問い合わせてみる (詳細:1, 2

        Opcode: Find Information Request (0x04)
        Starting Handle: 0x000f
        Ending Handle: 0x000f

        

    Frame 305

    0x000f は Service Changed キャラクタリスティックの Client Characteristic Configuration Descriptor (UUID = 0x2902) (詳細:1, 2) である旨の情報が得られた

        Opcode: Find Information Response (0x05)
        UUID Format: 16-bit UUIDs (0x01)
        Handle: 0x000f
        UUID: Client Characteristic Configuration (0x2902)

        

    Frame 308

    前掲の Frame 231 の示すハンドルグループ 0x0010 - 0x0022 の「Device Information」サービス内の Included Service を照会

        

    Frame 312

    当該サービス内に Included Service は存在しない

        

    Frame 315

    ハンドルグループ 0x0010 - 0x0022 の「Device Information」サービス内のキャラクタリスティックを照会

        

    Frame 319

    以下のみっつのキャラクタリスティックの情報が得られた

    • ハンドル:0x00011
      「value: 021200292a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0012
      UUID = 0x2A29 = 既定の「Manufacturer Name String
    • ハンドル:0x00013
      「value: 021400242a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0014
      UUID = 0x2A24 = 既定の「Model Number String
    • ハンドル:0x00015
      「value: 021600252a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0014
      UUID = 0x2A25 = 既定の「Serial Number String

        

    Frame 323

    続けてハンドル 0x0016 - 0x0022 範囲のキャラクタリスティックを照会

        

    Frame 326

    以下のみっつのキャラクタリスティックの情報が得られた

    • ハンドル:0x00017
      「value: 021800262a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0018
      UUID = 0x2A26 = 既定の「Firmware Revision String
    • ハンドル:0x00019
      「value: 021a00272a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x001a
      UUID = 0x2A27 = 既定の「Hardware Revision String
    • ハンドル:0x0001b
      「value: 021c00282a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x001c
      UUID = 0x2A28 = 既定の「Software Revision String

        

    Frame 330

    続けてハンドル 0x001c - 0x0022 範囲のキャラクタリスティックを照会

        

    Frame 333

    以下のみっつのキャラクタリスティックの情報が得られた

    • ハンドル:0x0001d
      「value: 021e00232a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x001e
      UUID = 0x2A23 = 既定の「System ID
    • ハンドル:0x0001f
      「value: 0220002a2a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0020
      UUID = 0x2A2A = 既定の「IEEE 11073-20601 Regulatory Certification Data List
    • ハンドル:0x00021
      「value: 022200502a」より、プロパティ = 0x02 (Read), Characteristic Value Handle = 0x0022
      UUID = 0x2A50 = 既定の「PnP ID

        

    Frame 337

    前掲の Frame 238 の示すハンドルグループ 0x0023 - 0x0025 の「Immediate Alert」サービス内の Included Service を照会
    (注:このリクエストに対する正しいレスポンスは「Attribute Not Found (0x0a)」だが、スニファ取りこぼしのためログが欠落している)

        

    Frame 341

    ハンドルグループ 0x0023 - 0x0025 の「Immediate Alert」サービス内のキャラクタリスティックを照会

        

    Frame 345

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x0024
      「value: 042500062a」より、
      プロパティ = 0x04 (Write without Response),
      Characteristic Value Handle = 0x0025
      UUID = 0x2A06 = 既定の「Alert Level

        

    Frame 349

    前掲の Frame 238 の示すハンドルグループ 0x0026 - 0x002a の「Tx Power」サービス内の Included Service を照会
    (注:このリクエストに対する正しいレスポンスは「Attribute Not Found (0x0a)」だが、スニファ取りこぼしのためログが欠落している)

        

    Frame 355

    ハンドルグループ 0x0026 - 0x002a の「Tx Power」サービス内のキャラクタリスティックを照会

        

    Frame 359

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x0027
      「value: 122800072a」より、プロパティ = 0x12 (Read | Notify), Characteristic Value Handle = 0x0028
      UUID = 0x2A07 = 既定の「Tx Power Level

        

    Frame 362

    続けてハンドル 0x0028 - 0x002a 範囲のキャラクタリスティックを照会

        

    Frame 366

    当該サービス内にはこれ以上キャラクタリスティックは存在しない

        

    Frame 369

    Frame 359 の示す通り 0x00028 は Tx Power Level キャラクタリスティックの Value Handle であり後続の 0x0029 - 0x002a に関する情報を GATT に問い合わせてみる (詳細:1, 2

        

    Frame 373

    0x0029 は Tx Power Level キャラクタリスティックの Client Characteristic Configuration Descriptor (UUID = 0x2902) (詳細:1, 2) である旨の情報が得られた

        

    Frame 376

    残る0x002a に関する情報を GATT に問い合わせてみる (詳細:1, 2

        

    Frame 380

    0x002a は Tx Power Level キャラクタリスティックの Characteristic Presentation Format Descriptor (UUID = 0x2904) (詳細:1, 2, 3, 4) である旨の情報が得られた

        Opcode: Find Information Response (0x05)
        UUID Format: 16-bit UUIDs (0x01)
        Handle: 0x002a
        UUID: Characteristic Presentation Format (0x2904)

        

    Frame 384

    前掲の Frame 238 の示すハンドルグループ 0x002b - 0x002d の「Link Loss」サービス内の Included Service を照会
    (注:このリクエストに対する正しいレスポンスは「Attribute Not Found (0x0a)」だが、スニファ取りこぼしのためログが欠落している)

        

    Frame 390

    ハンドルグループ 0x002b - 0x002d の「Link Loss」サービス内のキャラクタリスティックを照会

        

    Frame 392

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x002c
      「value: 0a2d00062a」より、プロパティ = 0x0a (Read | Write), Characteristic Value Handle = 0x002d
      UUID = 0x2A06 = 既定の「Alert Level

        

    Frame 395

    前掲の Frame 247 の示すハンドルグループ 0x002e - 0x0032 の「Battery Service」サービス内の Included Service を照会

        

    Frame 397

    当該サービス内に Included Service は存在しない

        

    Frame 401

    ハンドルグループ 0x002e - 0x0032 の「Battery Service」サービス内のキャラクタリスティックを照会

        

    Frame 404

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x002f
      「value: 123000192a」より、プロパティ = 0x12 (Read | Notify), Characteristic Value Handle = 0x0030
      UUID = 0x2A19 = 既定の「Battery Level

        

    Frame 407

    続けてハンドル 0x0030 - 0x0032 範囲のキャラクタリスティックを照会

        

    Frame 411

    当該サービス内にこれ以上キャラクタリスティックは存在しない

        

    Frame 415

    Frame 404 の示す通り 0x00030 は Battery Level キャラクタリスティックの Value Handle であり後続の 0x0031 - 0x0032 に関する情報を GATT に問い合わせてみる (詳細:1, 2

        

    Frame 420

    0x0031 は Battery Level キャラクタリスティックの Client Characteristic Configuration Descriptor (UUID = 0x2902) (詳細:1, 2) である旨の情報が得られた

        

    Frame 423

    残る0x0032 に関する情報を GATT に問い合わせてみる (詳細:1, 2

        

    Frame 427

    0x0032 は Battery Level キャラクタリスティックの Characteristic Presentation Format Descriptor (UUID = 0x2904) (詳細:1, 2, 3, 4) である旨の情報が得られた

        

    Frame 431

    前掲の Frame 247 の示すハンドルグループ 0x0033 - 0x0037 のユーザ定義サービス 1(UUID = 0xFFE0)内の Included Service を照会

        

    Frame 434

    当該サービス内に Included Service は存在しない

        

    Frame 437

    ハンドルグループ 0x0033 - 0x0037 のユーザ定義サービス 1 内のキャラクタリスティックを照会

        

    Frame 439

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x0034
      「value: 103500e1ff」より、
      プロパティ = 0x10 (Notify),
      Characteristic Value Handle = 0x0035
      UUID = 0xFFE1 : ユーザ定義のキャラクタリスティック

        

    Frame 442

    続けてハンドル 0x0035 - 0x0037 範囲のキャラクタリスティックを照会

        

    Frame 446

    当該サービス内にこれ以上キャラクタリスティックは存在しない

        

    Frame 450

    Frame 439 の示す通り 0x00035 はユーザ定義キャラクタリスティック(UUID = 0xFFE1)の Value Handle であり後続の 0x0036 - 0x0037 に関する情報を GATT に問い合わせてみる (詳細:1, 2

        

    Frame 453

    0x0036 はユーザ定義キャラクタリスティック(UUID = 0xFFE1)の Client Characteristic Configuration Descriptor (UUID = 0x2902) (詳細:1, 2) である旨の情報が得られた

        

    Frame 456

    残る0x0037 に関する情報を GATT に問い合わせてみる (詳細:1, 2

        

    Frame 458

    0x0037 はユーザ定義キャラクタリスティック(UUID = 0xFFE1)の Characteristic User Description Descriptor (UUID = 0x2901)である旨の情報が得られた

        

    Frame 462

    前掲の Frame 247 の示すハンドルグループ 0x0038- 0x003a のユーザ定義サービス 2(UUID = 0xFFF0)内の Included Service を照会

        

    Frame 465

    当該サービス内に Included Service は存在しない

        

    Frame 468

    ハンドルグループ 0x0038 - 0x003a のユーザ定義サービス 2 内のキャラクタリスティックを照会

        

    Frame 470

    以下のキャラクタリスティックの情報が得られた

    • ハンドル:0x0039
      「value: 043a00f1ff」より、プロパティ = 0x04 (Write without Response), Characteristic Value Handle = 0x003a
      UUID = 0xFFF1 : ユーザ定義のキャラクタリスティック

        

    Scene 5: デバイス A ボタン押下時のアプリへの通知を設定

    Frame 491

    Frame 453Frame 439Frame 453 の示す通り、ハンドル 0x0036 は、 "ユーザ定義サービス 1(UUID = 0xFFE0)配下のユーザ定義キャラクタリスティック(UUID = 0xFFE1)配下の Client Characteristic Configuration Descriptor (CCCD)" である

    当該ユーザ定義キャラクタリスティック(UUID = 0xFFE1)のプロパティには Notify が設定されており、クライアントである対向アプリから CCCD 0x0036 に Notification bit (0x0001) を書き込んでおくことで、このキャラクタリスティックの値が更新された時に Value Handle である 0x0035 経由でアプリ側へ通知(Notification)が行われるようになる
    (詳細: 1a, 2a, 1b, 2b

        Opcode: Write Request (0x12)
        Handle: 0x0026
        Value: 0100

        

    Frame 495

        Opcode: Write Response (0x13)

        

    Scene 6: アプリからデバイス A の LED・ブザーを操作

    Frame 616

    対向アプリ上の所定のボタンを押下すると、Frame 345, Frame 238 の示す、 "「Immediate Alert」サービス配下の「Alert Level」キャラクタリスティック" の Value Handle 0x0025 に値「2(High Alert)」が書き込まれる

    デバイス A の LED・ブザーはファームウェアレベルでこの Alert Level キャラクタリスティックに紐づけられている模様。High Alert 値が書き込まれるとそれに反応して短時間 LED とブザーが ON になる

    なお、Frame 345 の示すように Alert Level キャラクタリスティックのプロパティには「Write without Response」(0x04) が設定されているため、アプリから値を書き込んでもデバイス A からのレスポンスは発生しない

        Opcode: Write Command (0x52)
        Handle: 0x0025
        Value: 02

        

    Scene 7: デバイス A のボタンを押すとアプリへ通知

    Frame 711

    デバイス A の物理ボタンはファームウェアレベルで Frame 439 の示す "ユーザ定義サービス 1(UUID = 0xFFE0)配下のユーザ定義キャラクタリスティック(UUID = 0xFFE1)" に紐づけられている模様。このボタンを押すと当該キャラクタリスティックの値がデバイス内部で更新され Frame 491 での仕込みに基づきアプリ側へ通知が行われる

        Opcode: Handle Value Notification (0x1b)
        Handle: 0x0035
        Value: 01

        

    Scene 8: アプリ側からデバイス A との接続を切断

    対向アプリ上の所定のボタンを押下するとリンク層制御 PDU の LL_TERMINATE_IND (0x02) がデバイス A 側に送出され両者間の接続が終了する(詳細:1, 2

    Frame 779

        


    (tanabe)
    klab_gijutsu2 at 19:02|この記事のURLComments(0)TrackBack(0)
    2016年08月18日

    技適マークつき BLE パケットスニファを入手する

    はてなブックマークに登録

    Bluetooth Low Energy (BLE) の勉強のために BLE パケットを覗いてみたいと思いました。BLE の通信プロトコルは複雑ですが、パケットの内容を適宜精査すれば座学的な情報の向こう側にある実像を捉えることが可能となるでしょう。

    国内では次のような BLE プロトコルアナライザが販売されています。もっともこういった数百万円オーダーの専用機にはなかなか手を出せません。

    もっと手軽な方法として、BLE チップ・モジュールベンダの提供するパケットスニファを利用する選択があります。代表的な製品をピックアップしてみます。 2016年8月時点では日本国内で正規に流通している BLE パケットスニファ製品は見当たりません。もちろん国外から調達することは可能ですが、電波法に基づく技術基準適合(技適)証明とのかねあいが気になるところです。

    パケットスニファと技適

    たとえば、前掲の Nordic Semiconductor 社製「nRF51 Dongle」は技適証明を受けていないためディストリビュータが次のように注意を促しています。

    • nRF51 USB dongle for emulator,firmware - jp.rs-online.com
      警告
      
      本開発キットは技術基準適合証明を受けておりません。本製品のご使用に際しては、
      電波法遵守のため、以下のいずれかの措置を取っていただく必要がありますので
      ご注意ください。
      
       - 電波法施行規則第 6 条第 1 項第 1 号に基づく平成 18 年 3 月 28 日総務省告示
         第 173 号で定められた電波暗室等の試験設備内で使用する。
       - 実験局の免許を取得したのち使用する。
       - 技術基準適合証明を取得したのち使用する。
      
    ただ、電波法は受信のみを目的とするものを規制対象外としています。
    • 電波法 (最終改正:平成二七年五月二二日法律第二六号) - law.e-gov.go.jp
         第一章 総則
                :
      第二条 この法律及びこの法律に基づく命令の規定の解釈に関しては、次の定義に
             従うものとする。 
                :
        五  「無線局」とは、無線設備及び無線設備の操作を行う者の総体をいう。
             但し、受信のみを目的とするものを含まない。 
                :
      
    機能の性質上、パケットスニファの通信上の役割は受信に特化しています。ではこのデバイスを規制対象外と判断し安心して国内で使うことは適切でしょうか? 実はさらに考慮すべき話題があります。

    上の記事のように、nRF51 Dongle は mbed 対応のプログラマブルな無線通信デバイスです。つまり、この「無線設備」は元来「受信のみを目的とするもの」ではなくむしろ「操作を行う者」によってプログラムを書き換え可能であることを特長のひとつに掲げている製品です。上に挙げたスニファデバイスはいずれも同様の側面を持っています。

    以下の例のように、技適マークつきの製品においてさえ「ファームウェアの書き換え」との整合性を一意に判断できない事情を考え合わせると、技適マークなしのこれらのスニファ製品の日本国内での使用の是非はやはり微妙かもしれません。

    • ESP-WROOM-02のファームウェアを書き換えた場合、技適はどうなるのか - スイッチサイエンス
      ユーザーによるファームウェアの書き換えが、ESP-WROOM-02の工事設計認証を
      無効にする可能性について、メーカーのEspressif Systemsに確認をしました。
      同社は登録認証機関に確認した上で、Arduino core for ESP8266 WiFi chip
      または同社製SDKを使っている限りにおいては、認証には影響を与えないという
      回答を下さいました。他の開発環境など、ファームウェアを書き換える部分に
      よっては、認証に影響を及ぼし得るとのことですので、ご注意ください。
      
    • モノワイヤレス製品情報 - MONO-WIRELESS.COM - mono-wireless.com
      電波法規(技適)について
               :
      更にファームウエアを書き換えると認証の範囲を外れてしまう無線モジュール
      も存在しています。弊社製品は全て技適認証に適合した無線モジュールですので
      コンプライアンスに背くことなく安心してご使用していただけます。併せて
      「電波法についての考慮事項」を参照ください。
      
    • DD-WRT - wikipedia
      電波法による規制
      
      日本においては無線機器に対してメーカー側が想定していない非公式ファーム
      ウェアへの書き換えを行った時点で技適マークが無効となり、無線LANを利用する
      場合は電波法に違反する[要出典]。
               :
      

    Adafruit 社製「Bluefruit LE Sniffer」

    そんなわけで前掲のスニファの導入は一旦保留していましたが、情報を探しているうちにふとある製品のスペックに目がとまりました。

    MDBT40 は、Nordic Semiconductor 社製のメジャーな nRF51822 チップを搭載した 中国 Raytac 社の BLE モジュールです。前に BLE まわりの製品調査を行っていた折にこの MDBT40 が日本の技適証明を取得ずみであることを知り名前が印象に残っていました。

    MDBT40 を載せた製品は下記の例のように国内で流通しています。

    • Adafruit Feather 32u4 Bluefruit LE - スイッチサイエンス
      BLE(Bluetooth Low Energy)機能付きの小型Arduino互換ボードです。
      Adafruit Bluefruit LE Microの後継品です。
                      :
      Arduino Leonardoなどに搭載されているATmega 32u4を搭載。
      BLEモジュールであるMDBT40は、総務省の工事設計認証(いわゆる技適)を
      取得済みなので、日本国内で使用することができます。
                      :
      
    Adafruit 社製のこの「Bluefruit LE Sniffer」の製品写真をよく見ると、技適マークつきの MDBT40 が載っていることを確認できます。 (クリックで大きく表示)

    このスニファであれば技適関連のジレンマなしに利用できそうです。本体価格は直販で $29.95。以下、Adafruit 社公式サイトより。

    Bluefruit LE Sniffer のポイントをざっくりまとめてみます。

    さっそく Adafruit 社サイトでオーダーしました。 配送方法には最も安価な United States Postal Service の「First-Class Package International Service™ incl. $1.60 insurance : $16.40」を指定、計 $46.35 を PayPal で決済し一週間ほどで到着しました。

    発送連絡後に USPS サイトで配送状況を確認すると経由地に「JAMAICA」の表記あり。さては Japan -> Jamaica のミスか? と疑いましたがこれはカリブ海域にある国の名前ではなく JFK 空港そばの「JAMAICA, NY 11430」でした。物流の要衝のようですが恥ずかしながらニューヨークにジャマイカという地名があることを初めて知りました。Adafruit さん疑ってごめんなさい(^^;

    使ってみる

    Windows PC で Bluefruit LE Sniffer を使用する最短の手順を示します。詳細は nRF Sniffer アーカイブ内の「nRF Sniffer User Guide」に記載されています。

    1. FTDI 社の仮想 COM ポートドライバを未導入ならインストール
    2. 最新の nRF Sniffer の zip アーカイブをダウンロードし適当なフォルダ A へ展開
    3. Wireshark(v1.10.1 以降)を未導入ならインストール
    4. Bluefruit LE Sniffer を USB ポートへ接続し Sniffing の対象とするデバイスを近接させておく
      図は「nRF Sniffer User Guide v1.2」より
    5. フォルダ A 直下の「ble-sniffer_win_<version>_Sniffer.exe」を実行
    6. コンソールが開きアドバタイジング中の BLE デバイスが一覧表示される
    7. カーソルキー+ENTER または「#」番号で対象とするデバイスを選択
    8. デバイスを選択した状態で「w」キーを押下すると Wireshark が起動。あとはデバイス側で必要な操作を行えばよい

    付記:デュアルユース品と輸出規制

    ご存知の方も多いと思いますが、MouserRSDigi-key といった一般のディストリビュータから所定の電子部品を購入しようとすると、その製品の内容と在庫元・出荷元の国や地域によっては軍事転用の可能な「デュアルユース品目」として輸出規制に引っかかり、所定の書類一式の提出を求められ審査のために数週間程度待たされる場合があります。

    今回、某ディストリビュータへ所定の商品が輸出規制の対象が否かを事前に判別する方法の有無を尋ねたところ「注文を受け実際に輸出手続きを開始しなければわからない」との回答でした。 輸出規制に関する注意書きの有無はまちまちですが、以下に一例を引用します。

    • nRF51-Dongle Nordic Semiconductor | Mouser - www.mouser.jp
      This product may require additional documentation to export
      from the United States.
      この製品をアメリカから配送するには、追加の書類が必要になる
      ことがあります。
      
    一方、直販も行っている Adafruit は自社製品の輸出管理を主体的に実施している旨を公式サイト上で宣言しています。こういう場合には直販を利用するほうが面倒が少ないようです。


    (tanabe)
    klab_gijutsu2 at 11:35|この記事のURLComments(4)TrackBack(0)
    2016年08月03日

    Tomoru と Pochiru と Linking

    はてなブックマークに登録

    Tomoru

    Tomoru は、昨年(2015年)11月より国内のクラウドファンディングサービス Makuake で支援募集の行われた Bluetooth Low Energy(BLE)デバイスです。「Project Linking」の立ち上げとともに当時話題になったためご存知の方も多いことでしょう。2016年8月現在は製造元である株式会社 Braveridge公式ストアAmazon から入手することができます。以前このブログで「blink(1)」という製品をピックアップしたことがありますが、個人的に「光りもの」は好物で、この Tomoru も支援募集への応募によって届いた個体を持っています。

    Tomoru

    www.products.braveridge.com

    現時点では対応アプリが全体的にやや大人しめで Linking 自体も本格的な普及はこれからのようですが、手元では BLE の勉強がてらにこの可愛いデバイスを楽しんでいます。

    Pochiru

    Braveridge 社はその後も活発に Linking 対応製品をリリースしています。最近目にとまった「Pochiru」というデバイスを買ってみました。Pochiru には LED に加えネーミングどおり押しボタンが実装されています。そのため通信先の機器との双方向のやりとりが可能です。

    Pochiru

    www.products.braveridge.com
    ボタンものの BLE デバイスはいろいろ販売されていますが、リモートシャッターやメディアプレイヤーの操作といった所定の目的に閉じたものが多く、廉価でありながら自作のアプリケーションと汎用的に連携可能であることが Pochiru の魅力です。この価格でこのサイズの BLE ボタンデバイスを自作することの難度を考えるとコストパフォーマンスの高い製品と言えるでしょう。

    プログラムを書く

    Tomoru や Pochiru のような Linking 対応デバイス用のアプリは Project Linking が無償で公開している SDK を利用して作成します。BLE まわりのハンドリングはすべて Linking 本体が仲介するため、抽象化された Linking API を呼び出すだけでデバイスとの連携が可能です。

    Project Linking の開発者向けページの中ほどの「API guide」項の利用規約を確認の上 SDK をダウンロードし、「API 仕様書」の「ポーティング」ページの説明に添って開発環境をセットアップします。後は「API 仕様書」と SDK アーカイブに含まれる「LinkingIFDemo」のコードを読み合わせれば要領を理解しやすいでしょう。

    手元では Android 版 SDK を利用しています。上記のサンプルを元に作成した簡単なアプリのコードを以下に示します。アプリの「LED」ボタンを押すと連携ずみデバイスの LED が点灯し、連携ずみデバイス側のボタン押下等のイベントによってアプリが通知を受けるとその旨を画面に表示する内容です。

    試作:Linking01

    MainActivity.java
    /**
     *
     * Linking01
     *
     * - 連携ずみ Linking デバイスの LED を点灯させる
     * - Linking デバイスからの通知を受け取る
     *
     */
    
    package jp.klab.Linking01;
    
    import android.content.Context;
    import android.content.SharedPreferences;
    import android.media.AudioManager;
    import android.media.ToneGenerator;
    import android.os.Bundle;
    import android.support.v7.app.AppCompatActivity;
    import android.support.v7.widget.Toolbar;
    import android.view.View;
    import android.widget.Button;
    import android.widget.Toast;
    
    import com.nttdocomo.android.sdaiflib.Define;
    import com.nttdocomo.android.sdaiflib.NotifyNotification;
    import com.nttdocomo.android.sdaiflib.SendOther;
    
    public class MainActivity extends AppCompatActivity
            implements View.OnClickListener {
    
        private Context mCtx;
        private Button mButtonLED;
    
        private static final byte LINKING_IF_PATTERN_ID = 0x20; //LEDパターンの設定項目ID(固定値)
        private static final byte LINKING_IF_COLOR_ID = 0x30;   //LED色の設定項目ID(固定値)
        private static final byte COLOR_ID_RED = 0x01;  // 点灯色
        private static final byte BLINK_PATTERN = 0x22; // 点灯パターン
    
        private NotifyNotification mNotifyNotification;
        private MyNotificationInterface mMyNotificationInterface;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            mCtx = this;
            setContentView(R.layout.activity_main);
            Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
            setSupportActionBar(toolbar);
            mButtonLED = (Button)findViewById(R.id.buttonLED);
            mButtonLED.setOnClickListener(this);
            // Linking デバイスからの通知受信用
            mMyNotificationInterface = new MyNotificationInterface();
            mNotifyNotification = new NotifyNotification(this, mMyNotificationInterface);
        }
    
        @Override
        public void onClick(View v) {
            // LED ボタン押下で連携ずみデバイスあてに LED 点灯指示を送る
            if (v == (View)mButtonLED) {
                SendOther sendOther = new SendOther(this);
                sendOther.setIllumination(
                        new byte[] {
                                LINKING_IF_PATTERN_ID,
                                BLINK_PATTERN,
                                LINKING_IF_COLOR_ID,
                                COLOR_ID_RED
                        });
                sendOther.send();
            }
        }
    
        @Override
        protected void onDestroy() {
            super.onDestroy();
            mNotifyNotification.release();
        }
    
        private class MyNotificationInterface implements NotifyNotification.NotificationInterface {
            @Override
            public void onNotify() { // 通知を受信した
                // Linking デバイスからの通知内容は SharedPreferences に記録される
                SharedPreferences preference =
                        getSharedPreferences(Define.NotificationInfo, Context.MODE_PRIVATE);
                int DEVICE_ID = preference.getInt("DEVICE_ID", -1);
                int DEVICE_BUTTON_ID = preference.getInt("DEVICE_BUTTON_ID", -1);
                // Toast 表示
                Toast.makeText(mCtx, "onNotify: DEVICE_ID=" + DEVICE_ID +
                        " DEVICE_BUTTON_ID=" + DEVICE_BUTTON_ID, Toast.LENGTH_SHORT).show();
                // 音も鳴らす
                ToneGenerator toneGenerator
                               = new ToneGenerator(AudioManager.STREAM_SYSTEM, ToneGenerator.MAX_VOLUME);
                toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALL_SIGNAL_ISDN_PING_RING);
            }
        }
    }
    

    試作の動作の様子

    (動画:36秒 音なし)

    Linking でのデバイス・アプリの設定手順

    Linking 対応デバイスと Linking アプリを連携させるためには事前に設定が必要です。手順は以下の要領です。

    1. 端末に「Linking」をインストールする(未インストールの場合のみ)

               Linking(Android 版)- play.google.com

    2. 連携させるアプリ(ここでは自作の「Linking01」)のインストールを行い Linking を実行する
        
    3. 「デバイスの検索」を行い連携対象のデバイスを Linking に接続する(当該デバイスを未登録の場合のみ)
              
           
    4. デバイスの登録・接続が完了したら「デバイス詳細画面」へ移動し当該デバイスと連携させるアプリケーション(複数可。ここでは「Linking01」)を設定する
              
    5. Linking を抜け上の手順で設定したアプリを起動してデバイスとの連携を確認する
           

    備考: デバイスの Linking への再接続について

    手元の Android 5.1 端末の環境では以下の現象が見られます。Linking のバージョンは 2016年8月現在最新の「03.10.00000」です。

    • Linking に接続ずみのデバイスを Linking 上で「切断」すると再接続できなくなる
                 
    手元ではこの状況に以下の手順で対処しています。
    • Android の Bluetooth 設定において当該デバイスとのペアを解除してから Linking 上で再接続を行う
                 
           
    関連記事
    (tanabe)
    klab_gijutsu2 at 19:54|この記事のURLComments(2)TrackBack(0)
    2016年03月11日

    「入院者と外部とのコミュニケーション」への IoT の応用

    はてなブックマークに登録

    フェイススケールのこと

    先日身内が入院した折にこういう顔の絵の入ったクリアファイルを病棟で見かけました。

    フェイススケール(英: Face scale)とは、現在」の痛みを「にっこり笑った顔」から普通の顔、
    そして「しかめっ面」そして「泣き顔」までの様々な段階の顔を用意して、神経痛などの痛みを
    訴えている患者にどのぐらい痛むのかを示してもらうことで、その痛み度を客観的に知るために、
    ペインクリニック・麻酔科などで用いられる用具・用紙のことである。
    
    看護師さんがベッドに横になったままのご高齢者にこれを見せながら大きな明るい声で尋ねている様子が印象に残りました。たった一枚の印刷物ですが、自由に話したり体を動かしたりすることのむずかしい状態の入院者とのやりとりには特に有用なツールであることが想像されます。

    入院者と外部とのコミュニケーションへの IoT の応用

    携帯端末での電子メール利用の普及により入院者と外部との連絡はメール以前の時代に比べ格段に便利になりました。病室内での通話は無理でもメールの送受信は多くの場合認められており、おそらくは施設側が伝言を取り次ぐ頻度も昔よりもずっと少なくなったことでしょう。

    その一方で、症状や状況によっては小さなキーを操作することが困難であったり、あるいは携帯端末やメールとの接点を想像しにくい雰囲気の入院者も少なくないことを連日の病棟内の光景であらためて実感しました。残念なことに、現実にはそういう人たちにこそ関係者との日常的なつながりがより必要なケースが多いようにも感じられます。たとえ最小限の内容であっても当事者間で手軽にコミュニケーションをとることができれば双方の不安が確実に軽くなることを考えあわせると、社会の高齢化が進行する状況にあってこの話題は潜在的な必要性の高いテーマのひとつと言えるのではないでしょうか。

    試みに、フェイススケールをヒントに病室内での使用を想定してごく簡単な操作で離れた相手とメッセージを交わすしくみを形にしてみました。身近な場面での実用性のある IoT のアイディアとして紹介します。

    試作装置の外観と概要

    下の写真の装置のいずれかのボタンを押すと相手側の装置の同じ絵の下の緑の LED が点滅し、併せてその絵柄を添えたメールが所定のアドレスへ届きます。
    親しい間柄の人や自分自身が病室のベッドで苦しんだ経験のある方にはわかるのではないかと思いますが、いま入院者に「ボタンを押すだけの余力」と「絵柄を選択する意識」があるということを情報として伝えられることには、離れて案ずる側にとっても入院者自身にとってもそれ自体に大きな意味があります。また、一方通行ではなく相手からの反応が手元に届くことは双方の精神的な支えとなるでしょう。写真のとおり、互いに言葉や意味ではなく気持ちや意識を伝えることに主眼を置いてそれぞれのボタンの絵柄はあえて解釈の余地の広いものを選んでいます。

    ※クリックで大きく表示
     

    装置を病室に設置したイメージ

    メールには押したボタンの画像がインラインで表示されリンクのクリックで返信用のページが開く

    • インターネット接続環境を備えた入院施設が少ないことを考慮し装置は現在もっとも低コストでモバイル通信のハンドリングが可能な Raspberry Pi + 3G モデムの構成で MVNO SIM を使用しておりコンセントひとつで稼働可能
    • 自分が押したボタンは赤 LED の点灯、相手の押したボタンは緑の LED の緩やかな点滅で示される
    • 装置間のメッセージングには Milkcocoa 、メール送信には SendGrid を利用
    • 赤ボタンの押下で各 LED を消灯、5秒以上の長押しで装置をシャットダウン
    • 右端の LED は装置の稼働を示すパイロットランプ

    動作の様子(動画:2分20秒 音声なし)

    このデモ動画は入院者側が実装置を使用し相手側がインターネット接続環境のもとで Web 版の仮想装置を使用することを想定した内容です。もちろん二台の実装置間での応酬も可能です。

    画像について

    今回の試作では画像がコミュニケーションの仲介者として大きな役割を担っており、これらは病室という気持ちの沈みがちな空間に小さな癒しを灯すシンボルでもあります。一連の画像は Beeboo.SLI(ビブースライ)様が運用される画像素材サイト 「イラスト無料素材 イラストボックス」I<アイ>さん が掲載された作品を加工して使用させて頂いています。I<アイ>さんの素敵なイラストと素晴らしいサイトに謹んで御礼申し上げます。

    装置づくり

    メインの要素

    これらに説明は要らないでしょう。いずれも「持っていると便利」な道具です。
    Raspberry Pi 2 Model B

    アマゾンで 5,000円前後
    3G USB モデム: L-05A

    2015年10月に「イオシス」で 1,180円で購入(中古)
    MVNO SIM:0 SIM
    デジモノステーション 2016年02月号付属版)

    手元の SORACOM Air とどちらを使うか迷ったが今回は 0 SIM を使用

    筐体の材料と加工

    今回の装置では操作用のボタンの選定が大きなポイントでした。小さすぎず大きすぎないものを探し以下の製品を選びました。

    パネル取付用押しボタンスイッチ(緑色)です。過酷な使用に耐えられるヘビーデューティー
    仕様です。プッシュ機構部(樹脂製)とマイクロスイッチで構成されています。
    

    このボタンは取りつけ時の占有直径がおよそ 34mm で、これを 5mm 程度の間隔で 7つ設置するには最低 28cm ほどの長さが必要です。100円ショップをいくつか見てまわり筐体として写真のパスタケースを選びました(長さ:30.6cm 幅:8.5cm 高さ:7.6cm)。 蓋の方が少し広めの台形で、固い底のほうを上にすれば安定感があります。

    このケースの底にボタンを取りつけるには厚さ 3mm のポリプロピレン板に 30mm 程度のまるい穴が 7つ必要です。今の自分の工作力ではこのように硬く厚みのある素材に綺麗なまるい穴をあけることが難しく、練習用の予備であれこれやってみましたがなかなかうまくいきません。店先でたまたま野菜調理用の金属製の抜き型が目にとまりました。下の写真の大2 + 小2 セットの「小」の持ち手側の外径が 3cm に近く、試しにこれを熱してケースに押し当てるとちょうどよいサイズの綺麗な穴が得られました。ケースの両端には 1cm のマージンを設け、シャットダウンボタン用の一番右の穴は隣りと 1cm 離してあとは 5mm 間隔で穴をあけました。

    野菜の抜き型 (金属製)

    穴をあけ終えたケース

    パーツの取りつけ

    実際にボタンを取りつけてみると全体の印象は当初のイメージどおりでした。ただ、ボタン 7個、LED 13個分の計 40本の配線を内部の残りのスペースの四方へめぐらせるには電子工作用の一般的なワイヤでは固すぎて取り回しに融通が利かず、柔らかい導線を加工して筐体内の隙間へ這わせることにしました。スペース節約のため GND への繋ぎ込みにはメスのピンソケットのオス側をスズメッキ線で連結したものを使いました。

    ざっくり結線。ワイヤが固い

    導線にLEDと抵抗とハンダづけして末端をオス・メス化

    裁断した色画用紙を加工

    ケースへ一式を収納して動作確認

    ソフトウェア要素

    作成したプログラム類一式を以下の場所で公開しています。

    https://github.com/mkttanabe/iot_hospital

    使用しているソフトウェア資産とサービス

    土台の Raspberry Pi 2 Model B には標準ディストリビューションの Raspbian をインストールずみで今回は以下のソフトウェア資産を使用しています。

    また、ふたつの優れたサービスを利用しています。
    • Milkcocoa 装置間のメッセージング用
    • SendGrid 実装置からのメール送信用

    メインプログラム

    実装置用のプログラム本体は Node.js スクリプトとして記述しています。

    /home/pi/node/iot_hospital.js

    Web 版仮想装置のページは以下の URL から参照できます。

    http://dsas.blog.klab.org/data/iot_hospital/index.html?ID02&ID01

    注:Web 版の動作には Milkcocoa アカウントを取得し 22行めに正しい APP ID を指定する必要があります

    モデムまわり

    L-05A + 0 SIM の組み合わせでのインターネットへの接続には wvdial ユーティリティを使用しています。/etc/wvdial.conf は以下の内容。"sudo wvdial" の実行で接続を開始します。

    /etc/wvdial.conf

    [Dialer Defaults]
    init1 = ATZ
    init2 = AT+CGDCONT=1,"IP","so-net.jp"
    Password = nuro
    Username = nuro
    Dial Attempts = 3
    Stupid Mode = 1
    Modem Type = Analog Modem
    Dial Command = ATD
    Stupid Mode = yes
    Baud = 115200
    New PPPD = yes
    Modem = /dev/ttyACM0
    ISDN = 0
    Phone = *99***1#
    Carrier Check = no
    

    ※L-05A はゼロインストール機能を内蔵しており出荷時の設定により USB 接続時にはデフォルトで CD-ROM デバイスとして認識されます。eject コマンドで毎回これを解除するのが煩わしいため手元ではデフォルトで USB モデムとして認識されるように設定を変更しています。

    モデムモードに設定した L-05A は lsusb コマンドで以下の要領で表示されます。

    Bus 001 Device 004: ID 1004:6124 LG Electronics, Inc. 
    

    スタートアップまわり

    システム起動時に今回の一式を自動実行するために /etc/rc.local に iot_hospital.sh の呼び出しを記述しています。 iot_hospital.sh は L-05A が装着されていれば wvdial でインターネット接続を試行し、接続に成功すれば前掲の iot_hospital.js を実行します。

    /etc/rc.local

    #!/bin/sh -e
    #
    # rc.local
    #
    # This script is executed at the end of each multiuser runlevel.
    # Make sure that the script will "exit 0" on success or any other
    # value on error.
    #
    # In order to enable or disable this script just change the execution
    # bits.
    #
    # By default this script does nothing.
    
    # Print the IP address
    _IP=$(hostname -I) || true
    if [ "$_IP" ]; then
      printf "My IP address is %s\n" "$_IP"
    fi
    
    # run app
    sudo /bin/bash /home/pi/node/iot_hospital.sh &
    
    exit 0
    

    /home/pi/node/iot_hospital.sh


    (tanabe)
    klab_gijutsu2 at 07:01|この記事のURLComments(2)TrackBack(0)
    2016年01月01日

    ネットワーク対応の光通知デバイスを自作してみる

    はてなブックマークに登録

    「blink(1)」のこと

    コードネームのような製品名ですが、「blink(1)」は KickStarter 発のカスタマイズ可能な USB デバイスです。接続先の機器やネットワークから所定の情報を受けとると LED 光の色や明滅により人間への通知を行うもので、2012年に製品化され 2013年には IFTTT との連携機能を強化した blink(1) mk2 がリリースされています。

    文字の情報は人間が意識的にそこに向き合わなければ伝わらず、音の情報は状況により場の環境を乱す可能性があります。一方、「光」による情報はそれが適度な明るさであれば他の何かを妨げることなく人間の視界へ届き自然に認識され得ます。そのことを応用した例は他にも見られますが、blink(1) もまた光の特性を活かした機器のひとつです。

    www.kickstarter.com gigazine.net gigazine.net
    ※詳細なレポートが GIGAZINE 様の以下の一連の記事に掲載されています。 この blink(1)、なかなか楽しそうなので使ってみたいと思いました。ただ、日本国内には今のところ代理店がなく国外のマーケットから購入することになりますが、これが * 微妙に高価 * なのですね。 そこで、一連の機能の中でもっとも興味のあるネットワーク連携部分を自作してみることにしました。どこかで何かがあったら知らせるというのはまさに IoT 向きのテーマであり、また、普段は主にバイプレイヤーである LED が一躍主役となるちょっと面白いケースでもあります。装置は「Wi-Fi 機能つきの小さなマイコン」としてこのところ何かと重宝している ESP8266 モジュールをメインに構成すれば手近な素材のみでも USB ポートへ直挿し可能なサイズに収めることが可能でしょう。装置へのメッセージング用のソリューションには今回 PubNub を選びました。

    PubNub のこと / ESP8266 との関係

    PubNub は Pub/Sub モデルのリアルタイムメッセージング機能を主軸とする BaaS です。 2010年のローンチ以来スマホや Web アプリ向けのプッシュ通知サービス等を展開、近年は IoT 方面にも注力しており対応言語・環境の広い有用なプラットフォームのひとつです。サポート対象は現在も拡大中、ちなみに無料枠もなかなかの太っ腹です。

    また、GET リクエストベースの REST API セットが用意されており(HTTPS, HTTP)、これを利用すれば他のシステムやデバイスからも PubNub の所定の Channel へ柔軟にメッセージを送ることが可能です。 このように便宜の大きい PubNub ですが、この記事を書いている 2015年12月時点では実は肝心の ESP8266 がまだサポートされていません。でも幸い非公式のクライアントライブラリが公開されていることを知りました。本家の GitHub リポジトリにも fork されていることから非公式ながらも公認の位置づけのようです。 上の記事のとおりこの非公式ライブラリは任意のタイミングでの双方向通信には対応していない(subscribe() 呼び出し後の受信完了前には publish() 不可)ものの、今回想定している装置はもっぱら受信側であるため特に支障はなさそうです。作者の Kurt Clothier さん自身が「can be adjusted for a particular need」と書いているようにライブラリコードは用途に応じ適宜手を加えて利用すれば良いでしょう。
    なお、このライブラリは Espressif 社による純正の ESP8266 SDK 向けに記述されています。より扱いやすい Arduino IDE for ESP8266 環境でも利用できるように併せて手を加えることにしました。

    試作の動作の様子 (動画:1分11秒 環境音・操作音あり)

    下の動画は、作成した装置へ PubNub の Web コンソールからメッセージを送り動作を確認している様子です。各メッセージはそれぞれ一行の REST API 記述に置き換えることができます。     

    手元での現在の使途

    手元ではこの試作を下記の要領で使用中。「光による通知」が想像以上に便利で実用的であることを実感しています。スマートさや表現性は blink(1) に及ばないものの必要になれば好きなようにいじれることが手作りの利点ですね。

    • 黄点灯:明日の予報が雨の場合
      (IFTTT レシピを利用)

           クリックで可読大表示
      ありがちなテーマだが実際に使ってみると手放せなくなる。好天続きで何となく油断している折に不意に点灯を目にしたら直ちに予報をチェック。

    • 青点滅:高齢者世帯の安否確認装置に反応があった場合
      Parse.com サーバサイドの Cloud Code を利用)

           クリックで可読大表示
      人を検知すると最短 30分間隔で Parse.com へレポートを送出する自作装置を実家で稼働中。対向するサーバ側コードに試作への通知処理を追加。光れば安心、メールによる通知よりも即時性が高く煩雑感が小さい。

    • 赤点滅:特定のユーザからのツイートがあった場合
      (IFTTT レシピを利用)

            クリックで可読大表示

      件数は少ないがいつも非常に有用な情報を提供してくれる某氏のツイートを見落とさないために。

    • 緑点滅:所定のブログが更新された場合
      (IFTTT レシピを利用)

            クリックで可読大表示

      とりあえず昨年末 「KLab Advent Calendar 2015」のチェック用に使ってみたらとても快適。

    • 管理用 HTML フォーム

            クリックで可読大表示

      PC・スマホの Web ブラウザから装置へメッセージを手早く簡単に送るために用意。 [ソース]

    装置について

    外観と構成

        

           クリックで可読大表示

    材料

    メモ

    • blink(1) は PC など対応機器の機能を利用するが今回の装置はネットワーク専用であり通信機能を内蔵しているため USB AC アダプタでも使える
    • USB 端子は規格上両端の2本が 5V (500mA) OUT と GND
          
    • フルカラー LED ではなく赤・緑・青・黄の 4つの単色 LED を使用。LED 用の抵抗は現物の発色をみながら「赤 75Ω 緑 75Ω 青 470Ω 黄 390Ω」に
    • LED の表示は各色「点灯・点滅」の二種類で合計 8パターン。これは手元での実利用を想定し妥当と判断した数
      (複数 LED の点滅時は明滅のタイミングが重ならないようプログラム側で管理)
    • 輪ゴムは USB コネクタ外抜け防止用
    • エアキャップはタッパ蓋の下に中敷き。LED 光拡散、外観柔和化、USB コネクタとタクトスイッチの圧迫用
    • タッパ蓋の上からタクトスイッチを押すと装置がリセットされる。電源接続時と LED 消灯用に使う

    プログラムについて

    ESP8266 用 PubNub ライブラリ

    前述のように現時点では PubNub は ESP8266 を正式にサポートしておらず Espressif 社純正 SDK 向けの非公式ライブラリが公開されているのみです。これに以下の要領で手を加えて使用しています。

    • Arduino IDE で利用できるように
    • publish() の完了通知用にコールバックを登録可能に
    • HTTPS 対応

    以下にソースコードを掲載します。ライブラリ「pubnub-esp8266」はファイル一式をローカルの Arduino ライブラリフォルダ下の pubnub-esp8266/ 下へコピーして使います。

    (キーワード "DSAS" 部分がオリジナルからの変更箇所)

    pubnub.h - GitHub

    pubnub.cpp - GitHub

    Arduino スケッチ

    アプリ側のコードです。Arduino の SimpleTimer ライブラリを併用しています。

    LEDMessage.ino - GitHub

    作るのは簡単、アイディア次第でとても便利に使えます。


    (tanabe)
    klab_gijutsu2 at 00:41|この記事のURLComments(0)TrackBack(0)
    2015年11月06日

    今、ワンボタンの IoT デバイスが面白い

    はてなブックマークに登録

    米アマゾンが 2015年3月31日に発表した「Amazon Dash Button」はボタンを押すだけで所定の日用品のオーダーを可能とする小さなデバイスです。電池交換は不可。アイテムごとに専用のデバイスが用意されています。

    Dash Buttonはただの通過点、アマゾンが真に狙うは家電すべてとの連携ショッピング:ギズモード・ジャパン
    当初 Dash Button の利用は招待制でデバイスは無料で配布されていました。その後$4.99 での販売に変更され、2015年9月に米アマゾンは Dash Button ビジネスの拡大方針を決定しています。好調のようです。

    なお、今のところこのサービスの日本での開始はスケジュールされておらず、米アマゾンから Dash Button 単体を購入することもできません。

    2016-12-22 追記:
    Amazon.co.jp は 2016年12月5日に日本国内で Dash Button サービスを開始しました。これに伴い関連する以下の記事を当ブログに追加しています。

    ワンボタンデバイスのメリットと応用例

    Amazon Dash Button は事業形態としても面白そうですがこの記事ではデバイス本体に注目します。Dash Button の実体はマイコンと無線 LAN モジュールを主要素とする乾電池駆動の IoT デバイスです。

           
    http://mpetroff.net/

    Dash Button は独立した装置なので必要な折にスマートフォンアプリ等よりも素早く使用することが可能。ボタンひとつの単機能であることが手軽さと即応性をさらに高めています。 この迷いようのないユーザインターフェイスは、その素朴さとは裏腹に、関係者が消費者への効果的なアプローチを模索する過程において綿密に戦略を練った結果と想像されます。一見「誰でも思いつきそうなこと」はそれが本当に実現されればしばしば斬新です。手軽さといえばスマホばかりに目が向けられる現状においてあえて専用ハードの形態を選んだ発想には学ぶべき要素があるように思います。

    このデバイスのアイディアはビジネス用途以外の IoT においても広く活用することが可能でしょう。「物理ボタンの押下」という手間がかからず誰にでもできる簡単な操作をトリガーにどのような処理を行うかはアイディア次第であり、その便宜と魅力に気づいた向きがこの安価な Dash Button を本来の目的以外のことに利用し始めています。以下にいくつか例を挙げます。

    • How I Hacked Amazon’s $5 WiFi Button to track Baby Data (2015ー08ー10) - medium.com
      おそらくこれが最初の Dash Button 転用例。Edward Benson 氏は自宅で乳児の排泄日時を手早く記録・管理する手段を探す中で、夜中にスマホを操作する煩わしさもなくボタン押下のみで完結する Dash Button の長所に着目した。
      "using your smart phone at night disrupts sleep. I want a simple button I can stick to the wall and push to record"

      Amazon へのオーダーを行う上で本来必要な初期設定を意図的に保留。デバイス本体をハックするのではなく、ボタン押下〜デバイス起床時に LAN 上に流れる ARP パケットに注目し、当該デバイスの MAC アドレスを検出すればそれをトリガーに PC で処理を実行する手法によって要件を実現した。


      https://medium.com/
      「The Internet of Things is already here.」という結語が印象に残る。デバイスのコストを事実上度外視して Button を供給しているアマゾンにとってはあまり歓迎できない試みと思われるが、本体を改変しているわけではなく意味的には単に「未登録」の状態に過ぎない。この手の転用が拡がれば何らかの対抗措置を敷かれる可能性が皆無ではないものの、現時点ではこうした行為に対し直接クレームをつけることは難しいだろう。

    • Amazon Dash — It’s Dinner Time! (2015ー08ー27) - theappslab.com
      Raymond Xie 氏は Dash Button のコストパフォーマンスの高さに大喜び。稼働中に熱を持つ PiTFT をコードを抜かず簡単に ON/OFF する目的にさっそく利用していたところ奥方からもっと実用的なことはできないの?と聞かれ、ヘッドホン装着率の高い二階の子供たちを夕食時にいちいち呼びに行く代わりに Dash Button で子供部屋の フィリップス ヒュー を点滅させることを思いつく。Button は台所の奥方の手に。

    • PizzaDash (2015ー09ー12) - github.com/bhberson
      前出の Benson 氏の記事に触発された、と Brody Berson 氏は Dash Button をドミノピザの宅配オーダーに利用(無論アマゾンとは無関係)。ボタン押下をトリガーに Raspberry Pi が ドミノピザ公式 API を叩いて注文成立。
      2015-11-25 追記:
      英国ドミノピザが公式にワンボタンのオーダーサービスを開始する旨の情報あり (2015-11-23) "PizzaDash" からの影響の有無は不明だが、dominos 社はこれまでに独自の様々なオンラインオーダーシステムを確立しており IT 活用にきわめて積極的

    • Amazon dash button automation silliness. (2015ー09ー26) - www.youtube.com
      Michael Donnelly 氏による Youtube への投稿動画。玄関の照明を点灯/消灯したり、ガレージの車の内部が一定の温度になったらクラクションを鳴らしたり。

    • Dash Hacking: Bare-Metal STM32 Programming - learn.adafruit.com
      Adafruit 社TONY DICOLA 氏による正面からの Dash Button ハックの試み。ファームウェアを書き換え本体の LED を自作のコードで制御。"this guide didn't touch on the Dash's WiFi module yet" とのこと。

    また、米アマゾンウェブサービス(AWS)は 2015年10月の AWS IoT ベータ公開にあわせ、Amazon Dash Button と同一のハードを使った AWS IoT Button (beta)を発表しました。10月6〜9日にラスベガスで開催された「AWS re:Invent 2015」の会場では参加者へ AWS IoT 体験用に配布されています。ボタンを押すと AWS IoT サービスへ通知され利用者の記述した処理を実行できるしくみです。
            

    さらに、クラウドファンディング発の次のようなオリジナルのボタン製品も登場しています。またこれからも新しいものが出てくることでしょう。

    このように「ボタンひとつ」の IoT デバイスの有用性がいま注目されています。

    工作と実験

    こういった事情を背景にワンボタンデバイスを自作する向きもあり例 1例 2、試みに手元でも手近な材料を使って作ってみることにしました。装置が形になればそこにどのような処理を絡めるかは自由ですが、まずもっとも身近な題材のひとつとして自分あてのメール送信機能を組み込みました。

    • 今回の「ワンボタンメーラ」は試作上の題材ではありますが、実用を想定すると以前手がけた高齢者世帯安否確認のしくみと併せ、遠隔世帯からの手間いらずの ping 的定刻連絡やナースコール的な使い方ができそうです

    試作の動作の様子 (動画:30秒)

        

    装置について

    試作した装置の材料と構成は以下の通りです。Arduino IDE for ESP8266 プロジェクトのリソースを使用し ESP8266 Wi-Fi モジュールをスタンドアロンのマイコンとして制御しています。
    (価格は 2015年10月時点)

    • ESP8266 モジュール CDP-ESP8266 - Cerevo ¥842(税別)
    • バッテリー RW-111(電話機用:3.6V 800mAh) - ロワジャパン ¥780(税込) amazon
      ※説明欄の「単三電池三本の形状」の記述は「単四電池三本」が正しい
    • ブレッドボード EIC-15010 - スイッチサイエンス ¥216(税込)
    • ジャンプワイヤ・抵抗・タクトスイッチ・赤 LED
    • タッパ・ペーパータオル

         

         

    メモ

    • タクトスイッチはペーパータオルをクッションにタッパ蓋のたわみを利用して操作。チープながらこの中敷きは装置を保護し外観を和らげ LED のシェードの役割も
    • ロワのバッテリーは手元の固定電話機 (ユニデン社製 UCT-002 用にストックしていたもの。定格もサイズもぴったりだが充電には電話機への装着が必要。充電器の有無を同社へ問い合わせるとやはり「本体充電のみ」とのこと
    • バッテリー消費抑制のため ESP8266 モジュールは平時はディープスリープ状態で待機させる。ボタン押下でリセットをかけると起床して必要な処理を行いふたたびスリープへ
      • ディープスリープ中の ESP8266 の使用電流は公称 10μA、手元の実測で 11μA
      • ESP8266EX の Operating Voltage は 3.0〜3.6V、Operating Current(Average value) は公称 80mA(下図)、今回の実験でボタン押下〜再スリープまでの実測では LED 点灯分を含めおよそ 60mA。処理完了までの所要時間は 10秒前後

                    ESP8266EX Datasheet Version 4.4 (2015-08-01) Page 8
                        (クリックで可読大表示)

      • バッテリー駆動可能期間を大まかに試算してみる。
        条件として、使用者が 1日に 2回、起床時と就寝時にボタンを押すものとする。この場合、前掲の値を当てはめると 1日のうち 2 × 10秒間が 80mA、残りの 86380秒間が 11μA(0.011mA)であり、平均をとるとおよそ 0.03mA。 自然放電特性を加味せず www.digikey.jp の 電池寿命カリキュレータで単純計算すると今回のロワの 800mAh のバッテリーの場合は次の結果となる
        800mAh / 0.03mA * 0.7 ≒ 18666.67 時間 ≒ 777.78 日 ≒ 25.1 月 ≒ 2.1 年

      • ちなみに Amazon Dash Button は 米エナジャイザー社Ultimate Lithium 乾電池 単4形(二次電池ではない)1本を内蔵している。アルカリ電池に比べ「最大 9倍長持ち」を惹句とする現時点でおそらく最強の乾電池。下記記事の Button 分解写真にこの電池の様子も写っている。電極がタブ付けされており電池交換は想定されていない模様
        Amazon Dash Button Teardown - mpetroff.net
        AWS IoT Buttonを分解してみた - Developers.IO
        ※プラス極側の黒地に白文字の「12-2034」「12-2035」等の表記は未使用時の品質保持期限を示す。20年!

        http://mpetroff.net/
      • データシートによると エナジャイザー Ultimate Lithium AAA は "Max Discharge: 1.5 Amps Continuous, 2.0Amps Pulse (2 sec on / 8 sec off)" と高容量であり、Dash Button のように間欠的に電力を使用する機器においては後者のメリットを最大限に享受できるものと想像される
      • 上の mpetroff.net の記事に次の記述がある。内部で 3.3V に昇圧しているとのこと
        "The Dash Button operates at 3.3 V boosted from the battery’s nominal 1.5 V, drawing 200–300 mA from the battery when on and 2.3 μA when in sleep."
        現時点では Dash Button の battery life に関する公式の記述は見当たらず
      • エナジャイザーの Ultimate Lithium 乾電池は性能相応に高価い。バッテリーだけをみても Dash Button の$4.99という価格設定への興味を誘われる
        Energizer L92BP-Energizer Ultimate Lithium AAA Battery (4-Pack) - www.amazon.com $7.99
        エナジャイザー リチウム乾電池単4形 4本パック - www.amazon.co.jp ¥1250
    • ブレッドボードを使わなければ配線部分をもっと小さくできるが全体のサイズは実験用としては手頃か

    プログラムについて

    メールの送信には「SendGrid」を利用しています。その方面ではメジャーなサービスであり、国内に正規代理店株式会社 構造計画研究所があり日本版の公式サイトも運営されています。濫用防止のためサインアップの際には使用目的等の詳細説明が求められアカウント発行可否は人間によって審査されます。

    フリープランで 400通/日の送信が可能であり手元での利用にはまず十分そうです。ドキュメント・API も充実しています。 今回は単純明快でくみしやすい初版のほうの Web API を利用することにしました。 ところで、多くのクラウドサービスがそうであるように SendGrid Web API の利用にも HTTPS が必須です。 Arduino IDE for ESP8266 プロジェクトは 2015-08-31 より TLS 1.0/1.1 への対応を始めており利用者の一人として大いに期待しているのですが、この記事を書いている 2015年11月初旬の時点ではなお活発に開発が進行中で、stable になるまでにはまだ多少時間がかかりそうです。 そうした事情もあり、先だって「ESP8266 モジュールの AT コマンドに SSL クライアント機能を追加する」の試みで Espressif 社によるサンプルプログラムをもとに構成した HTTPS クライアントコードに手を加え、当面 Arduino IDE でライブラリとして使うことにしました。もちろんメールサービス以外の用途にも利用できるでしょう。
    ※手元の Arduino IDE for ESP8266 のバージョンは現時点で stable な
     2015-07-23 リリースの「1.6.5-947-g39819f0」なのですが、この版に
     同梱されている Espressif ライブラリを使ってスケッチをビルドすると
     SendGrid の API サーバ である https://api.sendgrid.com/ への接続
     要求時に異常終了の発生を確認しました。同ライブラリ群の配置された
     packages/esp8266/hardware/esp8266/1.6.5-947-g39819f0/tools/sdk/lib/
     配下の *.a を現時点で最新の esp_iot_sdk_v1.4.0_15_09_18 に
     含まれる一式に差し替えたところ問題の解消が見られました。
    

    以下にソースコードを掲載します。ライブラリ「EspHttpsClient」はファイル一式をローカルのライブラリフォルダ下の EspHttpsClient/ 下へコピーして使います。

    ライブラリ:EspHttpClient

    EspHttpClient.h - GitHub

    EspHttpClient.cpp - GitHub

    スケッチ:OneButtonMailer

    OneButtonMailer.h - GitHub

    OneButtonMailer.ino

    また、多少遅延が発生する可能性はあるものの、専用のサービスの代わりに手近に IFTTTMaker Channel を利用する選択肢もあるでしょう。目的がメール送信であれば下図の例の要領でレシピを作成しておきます。 (クリックで可読大表示)

         

    このレシピを呼び出すスケッチです。

    OneButtonMailer_IFTTT.h - GitHub

    OneButtonMailer_IFTTT.ino

    付録

    Espressif 社のドキュメントより ESP8266 のスリープモードに関する記述箇所を以下に抜粋します。
    (クリックで可読大表示)

    ESP8266EX Datasheet Version 4.4 (2015-08-01) 」より

       

    ESP8266 SDK API Guide Version 1.4 (2015-09-18)」中の system_deep_sleep() および system_deep_sleep_set_option() のリファレンス

       


    (tanabe)
    klab_gijutsu2 at 14:46|この記事のURLComments(0)
    2015年10月08日

    ESP8266 モジュール + Blynk でさらりと Wake On Wan

    はてなブックマークに登録

    Android / iOS 端末から所定の IoT デバイスの遠隔制御や監視を可能とする Kickstarter 発の Blynk が人気を集めています。2015年10月現在 Blynk はまだ開発の途上にあり、今の時点では Backer(出資者)でなくとも実装ずみのすべての機能を無償で利用することができます(※)。シンプルで取りまわしのしやすいこのサービスを使い、ちょっとした実用性のある道具として遠隔の PC をスマホで Wake On Wan するものを作ってみました。なかなか便利なので一式を公開します。

    • (※)公式サイトの「FAQ」より
      + I backed Blynk on Kickstarter. Where are my widgets and
        why the app is free?
      
        - App is free becasuse otherwise you would have to pay to
          download it. This is how AppStore and Google Play works.
        - Current Blynk release has a limited amount of widgets.
          We decided to make them free for everyone until we
          implement store. After that,every widget will be paid.
          However every backer will get them for free (according
          to their pledge).
      
    Blynk は ESP8266 Wi-Fi モジュールに対応しており、以下の記事では技適マークつきの「ESP-WROOM-02」を使用しています。このシリーズのモジュールは自立したマイコンとして動作可能であるため IoT デバイスの主要素としてもっとも手軽な選択肢のひとつと言えるでしょう。

    準備

    まず Blynk の利用を構成するみっつの要素を整理しておきます。

    • Blynk アプリ
      Google Play / AppStore で公開中の専用アプリを使ってユーザが構成した制御用のアプリ
    • IoT デバイス
      専用の Blynk ライブラリを使ってユーザが作成したプログラムを導入ずみの制御対象の装置
    • Blynk サーバ
      Blynk アプリと IoT デバイスの連係を仲介するクラウドサービス(※オープンソースであり自前での構築も可能)

    Android / iOS 端末にはそれぞれの公式ストア上で配布されている専用のアプリ「Blynk」をインストールします。利用者はこのアプリを使って独自の Blynk アプリを構成することができます。
    Blynk アプリに対向する IoT デバイスのためのプログラム開発には Arduino IDE を使用します。あらかじめ IDE に ESP8266 用ライブラリモジュールを導入しておき、そこへ Blynk の提供するライブラリ群をインストールします。
    Blynk の公式ドキュメントには Blynk を利用する上で必要となるこういった情報・手順がわかりやすく記述されています。また、ネットを検索すれば多くの有用な記事を参照することができます。

    試作の題材について

    手元のニーズに基き、Blynk を使った遠隔制御の身近な応用例として次の内容を想定しました。IoT デバイスは対象の PC と同じ LAN 環境へ設置します。

    1. 所定の PC を Wake On Wan する
    2. 所定の PC へ ping を打つ
    3. 所定の AC 機器への給電を ON / OFF する
    4. 制御対象の IoT デバイスをリセットする

    外出中に自宅の PC を利用したくなる場合があります。PC をずっと起ち上げたままにしておくのは好ましくないので、これまでは遊んでいる Android 端末を自宅で待機させておき GCM 経由で所定の PC を Wake On Lan するという方法をとっていました。それはそれで問題なく機能しているのですが、必要な手順を踏むのがだんだん面倒になってきたことに加え、その場で手早く PC の起床状況を確認できないことに不便を感じていました。確認方法は所定の IP アドレスに対する ping テストで十分でしょう。

    上記 3.は拡張用です。手元では留守の夜の防犯用に自宅の部屋の電灯を任意のタイミングで点灯/消灯するために使っています。これまでは上の PC に接続した USB 赤外線リモコンで操作を行っていましたが、それも面倒なので別の手段がほしいと思っていました。

    実装方法について

    Wake On Lan マジックパケットの送出と所定の PC への ping 打ちは Blynk の「Virtual Pin」機能を使って IoT デバイス側と連携すれば簡単に実現できると考えました。Blynk アプリ上に配置したボタンの設定において「OUTPUT」に物理ピンではなく適当な仮想ピンの番号を割り当てて、受け側の IoT デバイス側のコードに "BLYNK_WRITE(仮想ピン番号)" ハンドラを定義してそこに必要な処理を記述すればよいはずです。

    • Send data from app to hardware - Blynk 公式ドキュメント - blynkkk.github.io
      Send data from app to hardware
      
      You can send any data from Widgets in the app to your
      hardware.
      
      All Controller Widgets can send data to Virtual Pins on
      your hardware. 
      For instance, code below shows how to get values from
      the Button Widget in the App
      
      BLYNK_WRITE(V1) //Button Widget is writing to pin V1
      {
        int pinData = param.asInt(); 
      }
      
      When you press Button, Blynk App sends 1 On second
      click - it sends 0
      
      This is how Button Widget is set up:
    • Wake On Lan マジックパケットの仕様に関する記事
    • Espressif 社公式フォーラムの以下の記事の末尾に ESP8266 で ping を実行するサンプルコードあり
    ping 結果をアプリへ通知するには、アプリ側に設置したデータ INPUT 用のウィジェットに所定の仮想ピン番号を設定しておき、その番号に対し IoT デバイス側のコードで "Blynk.virtualWrite(仮想ピン番号, データ)" API を使ってデータを送ってやればよさそうです。

    • Virtual Pins - Blynk 公式ドキュメント - blynkkk.github.io
      Virtual Pins
      
      Virtual Pins are designed to send any data from your
      microcontroller to the Blynk App and back. Think about
      Virtual Pins as channels for sending any data. Make sure
      you differentiate Virtual Pins from physical pins on your
      hardware. Virtual Pins have no physical representation.
      
      Virtual Pins can be used to interface with libraries
      (Servo, LCD and others) and implement custom functionality. 
      The device may send data to the Widget to the Virtual Pin
      like this:
      
      Blynk.virtualWrite(pin, "abc");
      Blynk.virtualWrite(pin, 123);
      Blynk.virtualWrite(pin, 12.34);
      
    AC 機器への給電の ON / OFF の制御には ESP8266 モジュールからの 3.3V 出力信号でリレースイッチを操作すればよいでしょう。入力側と出力側の絶縁性の高いソリッドステートリレー(SSR) は動作時にカチリと音のする有接点リレーに比べ値段が高めですが秋月電子さんが電子工作向きの手頃なキットを販売しています。

    ※余談ながら、個人的にはこのように高電圧を扱うものは本当はあまり自作したくないです。その方面に素養がなくても部品を揃えれば作るのは簡単ですが、「手作りの楽しさ」などよりも安全性がもっとも重要ですから、手頃な価格で堅牢な完成品を入手できるならそれを使いたいというのが正直なところです。残念ながら今のところそういう商品は見当たらないようですが(需要はあると思うのですが・・)、上のキットを使ってリレーつき電源ケーブルを作る方法が下記の書籍にわかりやすく記載されています。

    動作の様子

    ひと通り形になったものが動作している様子を以下の動画に収めています。(1分56秒 環境音あり)

    内容:

    • IoT デバイス稼働中は死活確認用の LED が 2秒間隔で点滅、そのタイミングでアプリの TICK フィールド(デバイス稼働秒数)が更新される
    • アプリの BOOT PC ボタン押下で IoT デバイスが所定の PC を Wake On Lan する
    • アプリの PING ボタン押下で IoT デバイスが所定の PC へ ping を投げアプリの OK / NG フィールドへ結果をカウント
    • アプリの AC DEVICE ボタン押下で IoT デバイスへ接続した AC 機器への給電を ON / OFF
    • アプリの RESTART ボタンを長押し(3秒以上)してリリースすると IoT デバイスは再始動する

    リソース一式

    今回の Blynk アプリおよび IoT デバイスを構成する要素は以下のとおりです。

    ※各画像はクリックで実寸表示

    Blynk アプリ側

    Android / iPhone での当該 Blynk アプリの画面表示

    プロジェクトと各ウィジェットの設定

    IoT デバイス側

    装置全体の様子
    ※ESP-WROOM-02 モジュールは Cerevo 社製のブレイクアウト基板「CDP-ESP8266」とともに使用
    ※稼働状況確認用に IO4 に赤色 LED を、AC 機器制御用に IO12 にリレーつきケーブルを装着しているが不要ならどちらも省略可

    リレーつき電源ケーブル

    ソースコード
    SimpleTimer ライブラリのインストールが必要です)

    ESP8266_WakeOnWan.ino - github.com/mkttanabe

    留意すべき事項など

    Blynk は便利で有用ですが現在進行形で開発中であるため利用時に若干注意しなければならない点もあります。今回手元で気づいた話題をメモしておきます。

    Blynk ライブラリ旧バージョンでの不具合について(重要)

    2015-08-06 にリリースされた v0.3.0 までのBlynk ライブラリには ESP8266 モジュールのハンドリングに大きな不具合があります。同モジュール − Blynk サーバ間の持続接続に不意に切断が発生した場合に無限ループに陥りデバイスをリセットしない限り再接続不能となる問題です。この不具合は 2015-09-24 にリリースされた v0.3.1 では修正されており、切断が発生した場合に接続を自動復旧する機構が正常に機能するようになっています。この修正により連続稼働が安定するようになりました。Blynk で ESP8266 を使う場合は必ずこの v0.3.1 以降のライブラリを使うべきです。公式フォーラム上の以下の記事に関連する応酬があります。

    Android - iOS 端末間の Blynk アプリの互換性について

    Blynk は Android と iOS に対応していますが、両方のプラットフォームで等しく動作する Blynk アプリを構成するためには以下の点への注意が必要です。

    • 2015年10月8日時点の Android 版 Blynk の最新バージョンは "1.0RC7 fix"、iOS 版 Blynk の最新バージョンは "1.3" です。バージョン番号は iOS 版のほうが上ですが、Android 版で利用可能なウィジェットうち iOS 版ではまだ実装されていないものがいくつかあります。下図左は Android 版 "1.0RC7 fix、右は iOS 版 "1.3" でのウィジェット一覧です。

      iOS 版未実装のウィジェットを Android 版で配置して保存したアプリを iOS 版で開くとその部分が空白となります。それだけではなく、そのアプリを iOS 版で保存すると当該ウィジェットは失われます。要注意です。


    • Android 版では LED ウィジェットが正常に機能しません
    以上の事情により、現時点では、両プラットフォーム間での互換性の確保が必要なアプリにおいては iOS 版で利用できるウィジェットのみを使用し、また、LED ウィジェットは使わないようにする必要があります。今後の改善が期待されます。

    最後に

    Prepare to Blynk! - www.kickstarter.com に掲載されているイラスト

    がんばれ Blynk!


    (tanabe)
    klab_gijutsu2 at 05:49|この記事のURLComments(0)TrackBack(0)
    2015年08月27日

    ESP8266 モジュールの AT コマンドに SSL クライアント機能を追加する

    はてなブックマークに登録

    中国 Espressif Systems 社製 ESP8266EX 搭載の「ESP-WROOM-02」は興味ぶかいモジュールです。 今年(2015年)上半期に技適を取得し人気を集めているこの製品は安価であるにも関わらず自立したマイコンとして扱える点が素晴らしく、また、出荷時のファームウェアに組み込まれている AT コマンドセットを利用すればホスト側に負荷をかけず簡単に丸ごと通信処理を代行してくれる手軽さにも好感が持てます。

    ただ、この AT コマンドセットで HTTPS リクエストを処理する方法が見当たらないことを不思議に思っていました。あらためてドキュメントを確認するとどうやら AT コマンドで SSL 通信を扱うことはできないようです。プログラマブルであることがこの製品の魅力のひとつであるとは言え 出荷時の状態で SSL を利用できないのはそのこと自体が残念に思われます。AT コマンドのインターフェイスそのものは何かと便利なのでこれを拡張する形で SSL への対応を試みました。

    最終的にはサーバ証明書の検証も含め期待通りに動作しましたが、その過程で意外な曲折もあり、手元で経験したことを備忘をかねてほぼ順番どおりに記録しておくことにします。興味のある方は追ってみて下さい。

    AT コマンド拡張について

    メーカーは次のページで ESP8266 用の SDK を公開しています。

    2015-08-21 時点の最新版は以下の通りです。

    SDK アーカイブを覗いてみると examples/at/ ディレクトリ下に AT コマンドセットを拡張するためのサンプルコードが収録されており「AT+CIUPDATE」コマンドは他のコマンド群とは別にここで定義されていることが見てとれます。

    examples/at/user/user_main.c

    コードの末尾付近の at_custom_cmd[] テーブルには「AT+CIUPDATE」に加え「AT+TEST」という拡張コマンドが記述されています。拡張コマンドの定義の方法は次の記事に説明があります。

    In the example of \esp_iot_sdk\examples\at\user\user_main.c, 
    ways are delivered on how to implement a self-defined AT Command,
     AT+TEST”.
    The structure, at_funcationType, is used to define four types of
    a command, e.g., “AT+TEST”. 
    
    "at_testCmd" is a testing command and it’s formatted as AT+TEST=?.
    In the example of AT, the registered callback is “at_testCmdTest”; 
    the testing demand could be designed as the value range of the
    return parameter. If registered as NULL, there will be no testing
    command.
    
    "at_queryCmd" is a query command and it’s formatted as AT+TEST?.
    In the example of AT, the registered callback is “at_queryCmdTest” ; 
    the query command could be designed as returning the current value. 
    If registered as NULL, there will be no query command.
    
    "at_setupCmd" is a setup command and it’s formatted as
    AT+TEST=parameter1,parameter2,........ In the example of AT, 
    the registered callback is "at_setupCmdTest"; 
    the setup command could be designed as the value of the parameter; 
    if registered as NULL, there will be no setup command.
    
    "at_exeCmd" is an execution command and it’s formatted as AT+TEST. 
    In the example of AT, the registered callback is “at_exeCmdTest”; 
    if registered as NULL, there will be no execution command.
    
    一連の書式は他のコマンドでも見かけるものです。一読したところでは既存のコマンドセットにオリジナルの AT コマンドを追加することはあまり難しくないようです。

    SDK に同梱のビルドずみファームを書き込んでみる

    SDK の bin/ ディレクトリにはメーカー側がビルドしたファームウェアバイナリ群が格納されています。
    ESP-WROOM-02 出荷時の標準ファーム(AT コマンドセット)は以下のバイナリから構成されます。

    • bin/boot_v1.2.bin ---- ブートローダ
    • bin/at/user1.1024.new.2.bin ---- AT コマンドプログラム本体
    • bin/blank.bin ---- 領域初期化用 中身は 0xFF * 4096バイト
    • bin/esp_init_data_default.bin --- "Stores default RF parameter values"

    自作のファームをビルドして書き込む前に、まずこれらを練習台として内蔵フラッシュメモリに書き込んでみることにしました。 正しく書き込めるようになっておけばいざと言うときの切り戻しも楽でしょう。

    「bin/at/readme.txt」冒頭に次の記述があります。

    download:
    boot_v1.2+.bin      	0x00000
    user1.1024.new.2.bin  	0x01000
    blank.bin           	0x7e000 & 0xfe000
    

    ※「user1.1024.new.2.bin」の「1024」は 1024KB(8Mbit)容量のフラッシュメモリ使用を想定してビルドされたバイナリであることを意味する。ESP-WROOM-02 は 32Mbit のフラッシュメモリを内蔵しているが、以下の記事においてはこのバイナリの仕様に準じ 8Mbit 空間範囲の使用を前提とする。

    ※「esp_init_data_default.bin」には RF(Radio Frequency)パラメータのデフォルト値が保持されている旨の説明が SDK の「ESP8266 IOT SDK User Manual」に記載されている。次の記事にも説明あり。

    ファームウェアの書き込みは以下の「Flash Download Tool」を使い USB - シリアル変換器経由で行います。ツールは Windows 版と Python 版が用意されています。 書き込みはモジュールの IO0 ピンを Lo にセットして「UART Download Mode」へ移行し次の要領で。
    (図は Windows 版)
      ※1024KB フラッシュ使用時のメモリマップ
      
           - SDK の「Espressif IOT SDK User Manual」より
      
      Note 
      
      • System param (system parameter area) is the last 16KB offlash. 
      
      • User param is the user parameter area used by Espressif demo code
        ( IOT_Demo or AT ). If users develop their own application, 
        user data can be saved in any available flash area. 
      
      • User Data area ( green area in pictures below ) means the flash
        area that may be available, if program area doesn’t reach the
        maximum size, remaining area can be used to save user data. 
      
    動作確認
    AATTEE00
    
    
    OK
    AT+GMR
    AT version:0.40.0.0(Aug  8 2015 14:45:58)
    SDK version:1.3.0
    compile time:Aug  8 2015 17:19:38
    OK
    AT+CWMODE=1
    
    OK
    AT+CWLAP
    +CWLAP:(1,"GPMAP",-93,"10:6f:3f:XX:XX:XX",1,-47)
    +CWLAP:(0,"000D0BF6C164_G",-91,"00:16:01:XX:XX:XX",5,-32)
    +CWLAP:(3,"hogehogeSSID",-72,"00:22:cf:XX:XX:XX",10,-91)
    +CWLAP:(2,"STWEBKAIGI",-88,"4c:e6:76:XX:XX:XX",10,-27)
    +CWLAP:(4,"elecom2g-DE8564",-90,"00:90:fe:XX:XX:XX",10,-26)
    +CWLAP:(3,"e-timer-DE8564",-90,"02:90:fe:XX:XX:XX",10,-26)
    +CWLAP:(3,"auhome_acyCYh",-92,"8c:4c:dc:XX:XX:XX",1,-31)
    
    OK
    

    開発環境の導入

    ESP8266 モジュールのファームウェア開発には現在 Arduino 向けの IDE・言語を用いることも可能ですが SDK に含まれるコードはいずれもネイティブの開発環境を前提としているため今回はそれを使います。

    メーカーは必要な一式をインストールずみの開発環境(Linux)の VirtualBox 仮想マシンイメージを配布しており、導入手順・方法は以下の記事で説明されています。

    ポイントなど
    • SDK の一式を配置した PC 上のディレクトリを "share" の名前で VirtualBox 仮想マシンの共有フォルダとして設定しておく
    • 仮想マシンのデスクトップ上の "LXTerminal" を起動するとアカウント esp8266 でターミナルが開く
    • アカウント esp8266 ホームディレクトリの ./mount.sh を実行すると PC 上の共有フォルダが ~/Share にマウントされる
    • アカウント esp8266 のパスワードは "espressif"

    ※仮想マシンのタイムゾーンは CST 北京時間に設定されているため変更が必要です

    ファームウェアをビルドしてみる

    SDK の examples/ 下には複数のサンプルプロジェクトが配置されており、利用するプロジェクトのファイル群をそこから app/ 下へ丸ごとコピーして使います。今回は前述のとおり examples/at/* を使用します。

    仮想マシン側で app/ へ移動し gen_misc.sh スクリプトを実行するとバイナリがビルドされます。 以下のビルド開始時のオプション指定はメーカー側ビルドでの内容に準じています。

    $ pwd
    /home/esp8266/Share/esp_iot_sdk_v1.3.0_15_08_08_build/esp_iot_sdk_v1.3.0/app
    
    $ make clean
    make -C user clean;
    make[1]: Entering directory `/mnt/Share/esp_iot_sdk_v1.3.0_15_08_08_build/esp_iot_sdk_v1.3.0/app/user'
    rm -f -r .output/eagle/debug
    make[1]: Leaving directory `/mnt/Share/esp_iot_sdk_v1.3.0_15_08_08_build/esp_iot_sdk_v1.3.0/app/user'
    rm -f -r .output/eagle/debug
    
    $./gen_misc.sh
    gen_misc.sh version 20150511
    
    Please follow below steps(1-5) to generate specific bin(s):
    STEP 1: choose boot version(0=boot_v1.1, 1=boot_v1.2+, 2=none)
    enter(0/1/2, default 2):
    1
    boot mode: new
    
    STEP 2: choose bin generate(0=eagle.flash.bin+eagle.irom0text.bin, 1=user1.bin, 2=user2.bin)
    enter (0/1/2, default 0):
    1
    generate bin: user1.bin
    
    STEP 3: choose spi speed(0=20MHz, 1=26.7MHz, 2=40MHz, 3=80MHz)
    enter (0/1/2/3, default 2):
    
    spi speed: 40 MHz
    
    STEP 4: choose spi mode(0=QIO, 1=QOUT, 2=DIO, 3=DOUT)
    enter (0/1/2/3, default 0):
    
    spi mode: QIO
    
    STEP 5: choose spi size and map
        0= 512KB( 256KB+ 256KB)
        2=1024KB( 512KB+ 512KB)
        3=2048KB( 512KB+ 512KB)
        4=4096KB( 512KB+ 512KB)
        5=2048KB(1024KB+1024KB)
        6=4096KB(1024KB+1024KB)
    enter (0/2/3/4/5/6, default 0):
    2
    spi size: 1024KB
    spi ota map:  512KB + 512KB
    
    
    start...
    
    make[1]: Entering directory `/mnt/Share/esp_iot_sdk_v1.3.0_15_08_08_build/esp_iot_sdk_v1.3.0/app/user'
    DEPEND: xtensa-lx106-elf-gcc -M -Os -g -Wpointer-arith -Wundef -Werror -Wl,-EL -fno-inline-functions
    -nostdlib -mlongcalls -mtext-section-literals -ffunction-sections -fdata-
    
    (中略)
    
    !!!
    Support boot_v1.2 and +
    Generate user1.1024.new.2.bin successully in folder bin/upgrade.
    boot.bin------------>0x00000
    user1.1024.new.2.bin--->0x01000
    !!!
    

    以上の手順で bin/upgrade/ 下に生成される user1.1024.new.2.bin を前出の bin/at/user1.1024.new.2.bin の代わりにフラッシュメモリの 0x01000 番地を起点に書き込めば完了です。

    なお、boot*.bin, blank.bin はユーザによるビルドの対象ではありません。これらは SDK に含まれているものをそのまま使います。

    HTTPS クライアント処理はどのように書けばいいの?

    SDK にはドキュメントとして「ESP8266 SSL User Manual」が同梱されています。しかし、現時点では ESP8266 モジュールでの SSL 利用に関する情報は少ないのが実状です。

    ちなみに、IBM 所属の Neil Kolban 氏が 2015年8月1日に公開した「Kolban's book on the ESP8266」は技術情報が詳細に記述された貴重な資料ですが、同著においても SSL 処理については API の紹介のみに留まっています。

    各国の利用者も手探りの途中といった印象で次のような応酬が随所で見受けられます。


    By helpme -  Tue Jun 30, 2015 5:14 am
    
    Can the ESP8266 support HTTPS websites? Does the Espressif SDK
    support SSL function?
    
    By Angelo Santagata -  Tue Jun 30, 2015 4:51 pm
    
    Alas I no, but I happened to see this ESP8266 SSL Example on their website!
    http://bbs.espressif.com/viewtopic.php?f=21&t=389
    
    hopefully someone can port it to the SDK
    
    というわけで、メーカー側は下記ページに HTTPS クライアント処理のサンプルコードを掲載しています。

    Here is a demo code of ESP8266 as TCP SSL client. 
    It is based on ESP8266 SDK without OS.
    
    It just like ESP8266 to be normal TCP client except
    change espconn_xxx to be espconn_secure_xxx
    
    If your SSL packet is larger than 2048 bytes, please
    try to call espconn_secure_set_size
    to enlarge SSL buffer size (max:8192 )
    

    このコードを下敷きに AT コマンドを拡張すればよいと考えました。

    AT コマンド拡張では SSL 処理が正常動作しない?? (SDK v1.2.0)

    さっそく自作の +GETTEST コマンドを書いてみました。
    ちなみにこの実験の時点で使用していた SDK は 2015年8月初旬当時の最新版だった v1.2.0 です。

    試作した +GETTEST コマンドは次の内容です。

    AT+GETTEST="hostname" で当該ホストへ "GET /" リクエストを送る
    
    AT+GETTEST  --- 引数がなければ www.example.com:80 へ HTTP リクエスト
    
    AT+GETTEST="www.example.com",0 --- 2nd arg が 0 なら
                                       80番ポートへ HTTP リクエスト
    AT+GETTEST="www.example.com",1 --- 2nd arg が 1 なら
                                       443番ポートへ HTTPS リクエスト
    
    AT+GETTEST="www.example.com",1,0 --- 3rd arg が 0 ならサーバ証明書検証なしで
                                         443番ポートへ HTTPS リクエスト
    AT+GETTEST="www.example.com",1,1 --- 3rd arg が 1 ならサーバ証明書検証ありで
                                         443番ポートへ HTTPS リクエスト
    
    ソースコードを以下に示します。
    (注:これらはその後の調査結果も反映したものです。最初の実験の段階ではずっとラフな内容でした) ところがこの AT+GETTEST コマンドは期待通りには動作しませんでした。HTTP リクエストは正しく処理されるものの HTTPS リクエストではエラーが発生するのです。
    AATTEE00
    
    
    OK
    AT+GMR
    AT version:0.30.0.0(Jul  3 2015 19:35:49)
    SDK version:1.2.0
    compile time:Aug 11 2015 14:09:10
    OK
    
    
    AT+GETTEST="www.example.com",1
    mode : sta(18:fe:34:a4:0f:a0) + softAP(1a:fe:34:a4:0f:a0)
    add if0
    11
    f 0, 221
    scandone
    add 0
    aid 4
    
    connected with hogehogeSSID, channel 10
    WIFI CONNECTED
    dhcp client start...
    WIFI GOT IP
    ip:10.10.0.19,mask:255.255.255.0,gw:10.10.0.1
    got ip !!!
    start connect to server
    client handshake start. 
    client handshake failed
    reconnect callback, error code -28 !!! 
    
    エラーコード -28 は SDK の include/espconn.h に定義されています
    /* Definitions for error constants. */
    
    #define ESPCONN_OK          0    /* No error, everything OK. */
    #define ESPCONN_MEM        -1    /* Out of memory error.     */
    #define ESPCONN_TIMEOUT    -3    /* Timeout.                 */
    #define ESPCONN_RTE        -4    /* Routing problem.         */
    #define ESPCONN_INPROGRESS  -5    /* Operation in progress    */
    
    #define ESPCONN_ABRT       -8    /* Connection aborted.      */
    #define ESPCONN_RST        -9    /* Connection reset.        */
    #define ESPCONN_CLSD       -10   /* Connection closed.       */
    #define ESPCONN_CONN       -11   /* Not connected.           */
    
    #define ESPCONN_ARG        -12   /* Illegal argument.        */
    #define ESPCONN_ISCONN     -15   /* Already connected.       */
    
    #define ESPCONN_HANDSHAKE  -28   /* ssl handshake failed	 */
    #define ESPCONN_SSL_INVALID_DATA  -61   /* ssl application invalid	 */
    
    ハンドシェイクに失敗しているようですがこれでは何もわかりません。さて?

    SDK v1.2.0 での AT コマンド拡張内で SSL が機能しない理由

    不審に思い情報を探したところ同じ問題に遭遇したユーザとスタッフの応酬に気づきました。

    Re: SSL connection via AT commands
    
    Postby doughboy ≫ Tue Apr 21, 2015 11:59 pm
    
    I am writing AT command to support SSL, but it seems none of
    the espconn_secure_* api works. 
    At least when I connect to real ssl servers using real
    cerfificates. (and yes, I have set buffer size to 8196 and
    use the patched ssl library) esp module will
    reset (I think due to wdt timeout). I already reported this
    via email.
    
    Re: SSL connection via AT commands
    
    Postby Espressif_Faye ≫ Mon Apr 27, 2015 10:22 am
    
    Hi, doughboy,
    
    We debugged on your problem, it's the RAM limitation cause that.
    
    AT commands has only 17KBytes available heap now,
    SSL need 12KBytes free heap size, 
    if you espconn_secure_set_size to set 5KBytes,it is not enough..
    
    So SSL can not be used in AT commands because RAM is limited. 
    
    Sorry for the inconvenience.
    

    AT コマンドプログラム実行時には残ヒープが 17KB 程度だが、SSL 処理には 12KB のヒープが必要で、そこに espconn_secure_set_size() で数 KB の SSL バッファ確保を指定すると完全に枯渇してしまう。そのため、AT コマンドで SSL を扱うことはできない、という話です。

    ホストへの接続要求の前に system_print_meminfo() および system_get_free_heap_size() を呼び出してみると次の結果でした。

    
    data  : 0x3ffe8000 ~ 0x3ffe89d8, len: 2520
    rodata: 0x3ffe89e0 ~ 0x3ffeabd4, len: 8692
    bss   : 0x3ffeabd8 ~ 0x3fff5010, len: 42040
    heap  : 0x3fff5010 ~ 0x3fffc000, len: 28656
    
    system_get_free_heap_size=16592 
    

    なるほど。なお、AT コマンドセットは SDK においてはライブラリ形式で供給されており、自作コマンドを追加することはできても既存のものを変更することはできません。

    さて?

    冒頭に書いたように、ESP-WROOM-02 を通信モジュールとして扱う状況においてはシリアル経由でのコマンド渡しで簡単に通信処理を利用できることには便宜があります。

    しかし、そこで SSL を利用できないことは大きなマイナスであり実用を想定すると使途が限定されることになるでしょう。そのため既存の AT コマンドセットに代わるインターフェイス込みでの SSL 処理の実装が必要であり、その方法はいろいろ考えられるもののどうも遠まわりな印象があります。

    それはそれとして、一方では ESP-WROOM-02 は自立したマイコンとしての側面も持ち合わせているため、要件によっては単体で処理をまかなうことが可能であるわけで、とどのつまり製品出荷時の AT コマンドセットは「おまけ」程度のものと割り切るべきなのかも?

    そんなことを考えながら、SSL 処理そのものが正常に動作することを確認するために前掲のプログラムを AT コマンドセットと切り離し単体で起動する形に変更して動作を試してみました。
    ホストへの接続要求前のヒープ状況は以下の通りで、HTTPS リクエストは問題なく処理されました。やはりメモリ不足の問題のようです。

    
    data  : 0x3ffe8000 ~ 0x3ffe8560, len: 1376
    rodata: 0x3ffe8560 ~ 0x3ffe9764, len: 4612
    bss   : 0x3ffe9768 ~ 0x3fff2b30, len: 37832
    heap  : 0x3fff2b30 ~ 0x3fffc000, len: 38096
    
    system_get_free_heap_size=34856
    

    サーバ証明書の検証について

    立ち止まっていても仕方がないので、いずれにしても必要となる SSL サーバ証明書の検証に手をつけてみることにしました。

    SDK に同梱の「ESP8266 SSL User Manual」には HTTPS クライアント処理においてサーバ証明書を検証する方法が次のように記述されています。

    (クリックで実寸表示)

    上記ドキュメントからの抜粋

    CA verify function default to be disabled, user can enable it
    by espconn_secure_ca_enable.
    
    3.1. Generate CA Certificate
    
    (1) Put script “make_cert.py”and CA certificate into
        the same folder. 
    (2) Run script “make_cert.py” to generate esp_ca_cert.bin
        which contains all CA certificates
        (2 CA certificates at most) in the same folder. 
        Download address of esp_ca_cert.bin depends on
        espconn_secure_ca_enable.
    
    3.2. CA Verify
    
    STEP 1: ESP8266 connects to server, read esp_ca_cert.bin
            from flash, get the corresponding SSL 
            ctx. Only 2 CA certificates is allowed at most.
    STEP 2: ESP8266 starts TLS handshake, get certificate from
            SSL server, check with the CA in step 1:
            • if CA check fail, connection break;
            • if succeed, CA verify pass. 
    
    4.2. espconn_secure_ca_enable
    
    Function: 
      Enable SSL CA (certificate authenticate) function 
    
    Note:
      • CA function is disabled by default 
      • If user want to call this API, please call it before 
                     espconn_secure_accept
                     (ESP8266 as TCP SSL server) or 
                     espconn_secure_connect
                     (ESP8266 as TCP SSL client)
    Prototype: 
      bool espconn_secure_ca_enable (uint8 level, uint16 flash_sector) 
    
    Parameter: 
      uint8 level : set configuration forESP8266
                    SSL server/client:
                     0x01 SSL client;
                     0x02 SSL server;
                     0x03 both SSL client and SSL server 
    
      uint16 flash_sector : flash sector in which CA
      (esp_ca_cert.bin) is downloaded. For example, 
      flash_sector is 0x3B, then esp_ca_cert.bin need to
      download into flash 0x3B000
    
    Return:
      true : succeed
      false  : fail 
    

    「make_cert.py」は SDK には含まれません。下記ページにダウンロードリンクが掲載されています。

    以下の手順で「https://www.example.com/」のサーバ証明書の検証を試みました。

    • DigiCert のルート証明書をブラウザから X509 形式(DER エンコード)でエクスポートし拡張子を .cer とする
    • *.cer を置いたディレクトリで上記の make_cert.py を実行し「esp_ca_cert.bin」を生成する
    • espconn_secure_connect() の前に下記行を記述しuser1.bin をフラッシュメモリサイズ 1024KB の指定でビルド
      espconn_secure_ca_enable(0x01, 0x65)); // 0x01 = client
    • 他の *.bin ファイルと一緒に、esp_ca_cert.bin をフラッシュメモリの 0x65000 番地(User Data area)を起点に書き込む
      ※ビルドした user1.bin のサイズは 300KB ほどであり余裕をみて 400KB として
      ブート領域の 4KB と合算すると 404KB につき 404 * 1024 = 413696 = 0x65000
      - 下のメモリマップ(再掲)を参照のこと
      ※1024KB フラッシュ使用時のメモリマップ
      
           - SDK の「Espressif IOT SDK User Manual」より
      
      Note 
      
      • System param (system parameter area) is the last 16KB offlash. 
      
      • User param is the user parameter area used by Espressif demo code
        ( IOT_Demo or AT ). If users develop their own application, 
        user data can be saved in any available flash area. 
      
      • User Data area ( green area in pictures below ) means the flash
        area that may be available, if program area doesn’t reach the
        maximum size, remaining area can be used to save user data. 
      
    • 期待通りにサーバ証明書の検証が行われ www.example.com への HTTPS リクエストは正常に処理され、ルート CA が DigiCert ではないサイトへの HTTPS リクエストにおいてはハンドシェイクの時点でエラーとなることを確認

    解決:SDK v1.3.0 なら AT コマンド拡張内で SSL 処理が動く!

    そうこうしている内に、2015-08-08 に公開された esp_iot_sdk_v1.3.0_15_08_08 のリリースノートの次の記述が目に飛び込んできました。

    Optimization:
    
    1.Memory optimization to save 12KBytes.
                :
    

    前述のとおり、SDK v1.2.0 を使ってビルドした AT コマンド拡張版で SSL が機能しなかったのはメモリ不足が原因だったため、v1.3.0 で 12KB 余裕が出来たのであればあるいは動作可能ではないかと考えました。 実際に v1.3.0 でビルドした user1.bin で AT+GETTEST コマンドを試したところ HTTPS リクエストが正常に処理されることを確認、ホストへの接続要求前のヒープ状況は以下の通りでした。まずはめでたし、です。

    
    data  : 0x3ffe8000 ~ 0x3ffe83ac, len: 940
    rodata: 0x3ffe83b0 ~ 0x3ffe9d40, len: 6544
    bss   : 0x3ffe9d40 ~ 0x3fff18b8, len: 31608
    heap  : 0x3fff18b8 ~ 0x3fffc000, len: 42824
    
    system_get_free_heap_size=31376
    

    動作の様子 (動画:2分19秒)

    今回試作した GETTEST コマンドは所定のホストに対し "GET /" を行うのみの内容ですが、以上のように大枠での動作を確認できたため今後は任意の URI の指定や POST メソッドへの対応など実用上必要な肉付けを手元で行うことになるでしょう。楽しみです。

    上の動画で使用している現時点のファーム一式のうち user1.bin 以外のバイナリを以下のアーカイブに収めています。 自分の環境の AP の SSID とパスワードをソースに記述して user1.bin をビルドし、図の要領でこれらのバイナリ一式を ESP-WROOM-02 のフラッシュメモリに書き込めば AT+GETTEST コマンドが動作するはずです。興味のある方は自己責任でお試し下さい。


    (tanabe)

    klab_gijutsu2 at 18:00|この記事のURLComments(0)TrackBack(0)
    2015年04月21日

    「TWE-LITE」ファームウェアプログラミングの試み

    はてなブックマークに登録

    2017-01-13 追記:
    TWE-LITE(旧表記「TWE-Lite」)のメーカーが変わり同社からのリクエストに基づいて関連箇所の改訂を行いました。結果として、2015年4月に公開した元の記事に2017年1月現在の新しい情報やキーワードが部分的に反映された内容となっています。念のため要所に注釈を加えており混乱につながる要素は特に含まれていないと判断していますが、記事参照の際にはご注意下さい。

    モノワイヤレス株式会社様の ZigBee 無線マイコンモジュール「TWE-LITE DIP」はコストパフォーマンスが高く人気のある製品です。手軽にノード間の無線通信を実現できるため IoT を構成する要素としても所定の圏内に閉じた機器間ネットワークのための道具立てとしても有用でしょう。
    標準のものに加え複数の典型的な用途に即したファームウェア群とそのソースコードが開発環境とともに無償で公開されていることも興味深く、製品はホスト PC から専用の USB アダプタ (TWE-LITE R) または市販の USB シリアル変換モジュール/ケーブル経由で自由にファームを書き換えられるようにデザインされています。

    このように単なる通信モジュールではなくプログラマブルなマイコンとしての側面を持ち合わせながら価格が低廉であることが TWE-LITE DIP の大きな魅力です。また、「超簡単!」の惹句の通り出荷時の標準ファームでは扱いやすさが特に重視されており、そういった間口の広さと奥の深さがこの製品の特長と言ってよいでしょう。

    試みのきっかけ

    TWE-LITE ユーザはメーカーの提示するソフトウェア使用許諾契約書に基づいて TWE-NET SDK を利用することができます。また、規約の認める範囲で公式ファームのソースコードを改変したりその内容を公開することも可能です。

    ネットを参照すると現時点では実際に公式ファームに手を加えて TWE-LITE DIP を使っている利用者は製品の人気に比べまだあまり多くないように見受けられます(注:2015年04月時点の記述)。その背景には、まず公式のファームウェア群が充実しているため改変の不要なケースの多いことが想像されますが、それに加えて、精密に記述されている各ファームのボリュームのあるソースコードはその内容を適切に理解しなければ手を触れにくいという事情もあるように思われます。メーカーが公開しているこれらのファームは基本的に実用を目的とするものであって教育用ではありませんから、利用する側はコードの世界をじっくり楽しみながらノウハウを覚えるスタンスに立つことが好ましいでしょう。

    ただ、ここしばらく TWE-LITE に触れた印象では、そこに「ちょっとした足がかり」があれば公式ファームのコードをより見渡しやすくなり、そのことがこの優れた製品を手元で活用する機会を拡げることにもつながるように感じました。思い浮かべたのは "Hello, world!" のようにもっとも単純で簡潔な内容からはじまり徐々に処理が肉付けされていくイメージの小さなサンプルファームコード群です。でも残念ながら今のところそういうものは見当たりません(注:2015年04月時点の記述)。そこで、実験と勉強をかねて現時点での自分の到達点なりにそういうコードを書いてみることにしました。一連の過程での疑問点・不明点の解決には言うまでもなく公式ファームのソースと上記の SDK マニュアルが非常に参考になりました。この試みは今後も継続するかもしれませんが、まずはここまでのソースコードと動作の様子を公開します。興味のある方は覗いてみて下さい。

    ※本記事に掲載のソースコードには公式ファームウェアのソースからの
     引用が含まれます。取り扱いに際しては著作権表記を確認の上、前掲の
     「モノワイヤレスソフトウェア使用許諾契約書」の内容を遵守して下さい。
      なお、本記事での各ソースコードの掲載ならびにメーカー公式サイト上の
      個々のページへの直接のリンク・公式文書からの情報の抜粋については
      いずれも事前にモノワイヤレス様より承諾を得ています。
    
    ※本記事の本文および本記事に掲載のソースコードには誤りが含まれて
     いる可能性があります。そのことがいかなる損害に繋がったとしても
     筆者および KLab は一切の責任を負いません。あらかじめご了承下さい。
    

    技術情報について

    TWE-LITE 用プログラミングに必要な情報は多岐に渡り、メーカー公式サイトの情報前掲の TWE-NET SDK マニュアル、また、NXP 社製 JN5164 用のペリフェラル (TWE-LITE の I/O ポートまわり) API のマニュアル等が主な情報源となります。後続のソースコードを参照する上で最小限必要なもっとも基本的な資料三点を以下に抜粋します。
    ※図はクリックで大きく表示されます

    TWE-LITE DIP のピン配置   (メーカー公式サイトの「超簡単!TWE標準アプリ」ページより)

    TWE-LITE ファームウェアコードの動作フロー   (前掲の SDK マニュアルより)

    Test01: LED を点滅させる

    まずはマイコン・電子工作の世界での「Hello, world!」にあたる LED の点滅、いわゆる「L チカ」を行います。ネットワーク通信は行いません。

    動作の様子

    装置の写真と構成図(クリックで大きく表示)

    ソースコード  GitHub

    ユーザ定義のイベントハンドラ内で 1秒周期の E_EVENT_TICK_SECOND 通知に呼応し出力ポートの Lo, Hi をトグルする

    Test02: スイッチ操作に LED の状態を連動させる

    上の Test01 は全自動かつエンドレスですが、今度は人間の操作に反応させてみます。タクトスイッチの ON/OFF に LED の点灯/消灯 が連動します。ネットワーク通信は行いません。

    動作の様子

    装置の写真と構成図(クリックで大きく表示)

    ソースコード  GitHub

    既定のイベントハンドラ cbToCoNet_vMain() 内でタクトスイッチの状態に応じて LED の状態を変化させる

    Test03: シリアル経由でデバッグメッセージを出力

    プログラミングにはデバッグのための手段が不可欠です。Test02 のコードに、シリアル接続経由で PC 上のターミナルアプリへトレース文を出力する処理を加えてみます。PC との接続には専用の USB アダプタ 「TWE-LITE R」を使用しています。 (市販の USB シリアル変換モジュール/ケーブルも利用できます ネットワーク通信は行いません。

    動作の様子

    装置の写真と構成図(クリックで大きく表示)

    ソースコード  GitHub

    起動時に UART とデバッグ出力用の初期化を行い vfPrintf() 関数を使ってシリアルへトレース文を出力

    Test04: スイッチ押下でメッセージを送信 〜 受信側は LED で反応

    無線送受信を行います。Test02 の装置と同じものをもうひとつ用意します。タクトスイッチが押下されるとメッセージをブロードキャストし、受信した側は LED を一定時間点灯させます。

    動作の様子

    装置の写真と構成図(クリックで大きく表示)

    ソースコード  GitHub

    スイッチ押下時に ToCoNet_bMacTxReq() によりブロードキャストを実行。既定のイベントハンドラ cbToCoNet_vTxEvent() および cbToCoNet_vRxEvent() 内で送受信通知への対応を行う

    Test05: 電力消費を抑制した送信専用コードと装置

    上記 Test04 での送信処理を独立させ、消費電力を抑えることを目的にメーカー公式の「無線タグアプリ(App_Tag)」(注:2015年04月時点での名称は「Samp_Monitor」)押しボタン・磁気スイッチ対応機能における子機処理(EndDevice_Input)での以下の要所を取り入れた内容です。

    • TWE-LITE のモード設定ビット 1 (M1) が GND に接続されていれば(すなわち M1 が Lo であれば)、デジタル入力 1 (DI1) の立ち上がり (Lo -> Hi) を送信のトリガーとする
      (典型的には平時が導通状態の磁気リードスイッチが磁界から離れ切断された状況)
      • 立ち上がりトリガーの場合 節電のため DI1 の内部プルアップを無効化する。そのためこの場合は外部プルアップ抵抗を設置する。(公式サイト上の記事を参考に 1MΩ 抵抗を使用)
    • M1 が GND に接続されていなければ DI1 の立ち下がり (Hi -> Lo) をトリガーとする
      (典型的には平時が非導通状態のタクトスイッチが押下された状況)

    • 送信を終えたらすみやかに Sleep 状態へ移行し DI1 の状態が変化すると Sleep から復帰する

    受信側は Test04 のものをそのまま使います。以下の動画・写真では送信側の装置は磁気リードスイッチを使っており立ち上がり検出を行っています。 ちなみにこの装置の待機(Sleep)状態の消費電流を測ったところ 3μA(0.003mA)でした。一般に CR2032 の放電容量はおよそ 225mAh であることから、下記のサイトを利用して単純計算すると待機継続可能期間は 225mAh / 0.003mA * 0.7 = 52500時間 = 2187.5日 ≒ 5.99年となります。実際の電池寿命は送信頻度によって大きく変わるでしょう。

    電池寿命カリキュレータ - www.digikey.jp

    動作の様子

    装置の写真と構成図(クリックで大きく表示)

    ソースコード  GitHub

    電源投入直後やリセット後はまずそのまま Sleep 状態へ移行。DI1 の状態が変化すると起床して送信を行いふたたび Sleep へ。DI1 の立ち上がり/立ち下がりのどちらで送信を行うかは M1 の Lo / Hi 状態で決定する

    付録:「無線タグアプリ」カスタマイズの記録

    2015年4月、手元の実験的要件への対応のために前掲の公式アプリ「無線タグアプリ(App_Tag)」(当時の名称は「Samp_Monitor」)の親機(Parent/)および子機(EndDevice_Input/)コードのカスタマイズを試みました。以下はその記録です。

    変更を加えたソース・ヘッダ

    ※ベースのバージョンは 2015年4月20日当時の最新版「Samp_Monitor v1.4.1 β」です(2017年1月現在は既に公開終了)
    ※変更箇所は識別子「MODIFIED_BY_KLAB」で区別しています

    変更内容

    EndDevice_Input:

    • センサモードが PKT_ID_BUTTON(押しボタン・磁気スイッチ) の場合、一度の送信完了でただちにスリープ状態へ移行せず所定の回数繰り返し送信を行ってから移行する (diff)

    Parent:

    • 電子ブザーの接続を想定し DO4 の使用を追加。センサモードが PKT_ID_BUTTON の子機からパケットを受信した場合、DO1 の LED トグルに合わせ DO4 の状態もトグルする
    • PKT_ID_BUTTON の子機からの受信発生後は親機がリセットされるまで DO1 の LED を点滅させる(受信有無を事後に目で確認するための便宜) (diff)

    以下の動画・写真では子機側の装置は磁気リードスイッチを使用し立ち上がり検出を行っています。したがってこの子機のパラメータ設定において「m:センサ種別の設定」には 0xFE、「p:センサ固有パラメータの設定」には 1 を指定しています。

    動作の様子

    装置の写真と構成図 上:親機 下:子機 (クリックで大きく表示)

    (備考)

    2017年1月13日時点での最新版 SDK 「2014/8月号」に収録されている旧 Samp_Monitor v1.3.2 の子機側ソースコードには本来の意図とは異なるものと考えられるロジックが含まれています。

    1. 子機のパラメータ設定内容をセーブ領域から読み出すタイミング
      (/TWESDK/Wks_ToCoNet/Samp_Monitor/EndDevice_Input/Source/EndDevice_Input.c)
      ※ 旧 Samp_Monitor v1.4.1β で改修されました
    2. 子機のセンサモードが PKT_ID_BUTTON (0xFE) の場合の固有パラメータ 立ち上がり (1) / 立ち下がり (0) 指定に対する処理
      (/TWESDK/Wks_ToCoNet/Samp_Monitor/EndDevice_Input/Source/ProcessEv_Button.c)
      ※ 旧 Samp_Monitor v1.3.3 で改修されました

    そのため、今の時点で「無線タグアプリ」を使う場合は単体で配布されている版を利用するほうが良いでしょう。


    (tanabe)
    klab_gijutsu2 at 16:37|この記事のURLComments(14)
    2015年02月17日

    mbed と Parse で作る高齢者世帯安否確認システム

    はてなブックマークに登録

    2017-01-30 追記:
    2017年1月末の Parse.com サービス終了に伴い以下の記事を追加しました。他サービスへの移行に関する情報等を記述しています。

    さよなら Parse - 当ブログ

    個人的に、高齢者世帯の安否を日常的に静かに確認できる仕組みがほしいと思っていました。要件として想定していたのは次の二点。現地にはインターネットへの接続環境が整っています。

    • 現地での被監視感や拘束感が希薄であること
    • 情報を自分でハンドリングできること
    実現方法を考えると案外悩ましく、よくあるリモートカメラの類は手軽な反面 日常を生々しく監視するようでNG、自作の選択肢はあれど PC をフロントエンドとすることには何かと牛刀感があり気が進まず、また、各社様による優れたサービスの数々にも関心はあるものの恒常的に費用がかかることは当然としても自分が個人的に望む柔軟性・拡張性に適合する仕組みをなかなか見出すことができず導入には至らずにいました。
    以前から頭の中でときどきそういうループを巡らせていたのですが、先日ふとマイコンとクラウドサービスを組み合わせて利用することを思いつきました。折からの「モノのインターネット(Internet of Things : IoT)」の潮流にも合致するためその視点からも面白そうです。

    マイコン方面には今までほとんど馴染みがなかったのですが、情報収集を経て選択した mbed マイコンボードを利用することでフロントエンドの装置本体もそれを動かすためのプログラムも手早く作ることができました。バックエンドにはメジャーな BaaS のひとつである Parse を利用しサーバ側処理もシンプルに仕上げています。必要な費用は装置材料分のおよそ 1万円程度(2015年2月現在)のみでクラウドサービスは手元では無料枠内でゆったりと使えており、今後もいろいろ手を加えていくつもりです。なかなか便利なものになったので現時点での内容と構成を紹介します。離れて暮らす家族を気に懸けておられる方のご参考となれば幸いです。

    全体像を図に示します。

    動作の様子

    装置の動きとクラウドサービスとの連携の様子を以下の動画に収めています。

    動画1:装置が人間を検知すると Parse サーバへデータを送出

    動画の内容
    (無音 1分15秒)

    1. 装置へ給電 〜 初期化が完了するまで全 LED が点滅
    2. 赤外線センサが人を検知し赤 LED が点灯 〜 Parse サーバへ検知情報を送出
    3. PC 上のブラウザでは Parse アプリ「Anpi」の「Detected」 クラス(テーブル)のデータを表示中
    4. 表示を更新して新しいデータが追加されていることを確認
    5. Gmail で前日受信したレポートメール(人を検知した日付時刻と気温情報の直近の一覧)を表示してみる
    6. 最後にふたたび装置の赤外線センサに手をかざして反応を試してみる

    動画2:装置の「緊急ボタン」が長押しされるとその場でメール通知

    動画の内容
    環境音+ブザー音あり 30秒)

    1. 緊急ボタンを押すと装置の LED が反応 〜 短時間の押下はそのまま受け流される
    2. 2秒程度長押しすると操作者にブザーで長押しの受け入れを伝え Parse サーバへ緊急ボタン押下を通知
    3. ボタン押下がメールで通知される

    mbed について

    ARM 社 によって開発された mbed は ARM マイコン搭載のコンピュータボードと専用の開発環境によって構成され「高速プロトタイピングツール」のキャッチフレーズの通りシンプルな手順で手早く扱うことができるように設計されています。現在さまざまな mbed ボードが販売されていますが、今回選んだのはもっとも代表的な「mbed NXP LPC1768」です。Cortex-M3 プロセッサを載せたこの単三電池二本分ほどの大きさのボードには多様な入出力ポートに加えイーサネット用コントローラまでもが実装されています。

    初めて mbed に触れる場合は mbed.org へのユーザ登録が必要です。製品に同梱の USB ケーブルで mbed LPC1768 を PC へ接続するとボード上のストレージがマウントされます。ストレージ上の「MBED.HTM」をブラウザへロードするとユーザ登録画面へ遷移するのでそこへ必要な情報を投入します。登録が完了すると mbed のオンラインコンパイラをブラウザから利用できるようになります。C, C++ でコードを記述し「Compile」ボタンを押すとサーバ側でビルドが行われ、そこで生成された実行形式をダウンロード 〜 mbed の USB ストレージへコピーしてボードのリセットボタンを押下するとプログラムが実行されます。そこから先は、コードを編集したり既存のライブラリをインポートしたり mbed ボードを装着した回路に手を加えたり、、といった手順の繰り返しです。作法どおり最初はまずあれこれサンプルコードをビルドして動かしたりそれに手を加えて試したりということから始めました。なお、mbed ボードのコントローラは USB 仮想シリアルポート経由でホスト PC とのシリアル通信をサポートしており PC 側でターミナルアプリを使えばデバッグにも便利です。

    mbed の世界ではコミュニティが発展しており様々な資産が共有されています。わかり易く有用な記事・文書も数多く存在するのでとても参考になります。

    装置について

    製作した装置の材料と組み立て方、制御用プログラムについて説明します。

    材料

    (※価格はいずれも2015年1月時点の税込額)

    • マイコンボード mbed NXP LPC1768 ¥5,800 秋月電子通商
    • 焦電型赤外線センサーモジュール (SB612A) ¥600 秋月電子通商
    • LAN コネクタ (mbed用イーサネット接続キット) ¥514 スイッチサイエンス
    • IC 温度センサ (LM61BIZ) ¥200 (4個入) 秋月電子通商
    • 電子ブザー (PKB24SPCH3601) ¥150 秋月電子通商  
      ※不要なら省略可
    • ブレッドボード (EIC-801) ¥257 スイッチサイエンス
    • ジャンプワイヤ (固いオス〜オス)  ¥257 (70本入) スイッチサイエンス
    • ジャンプワイヤ (柔らかいオス〜メス) ¥691 (50本入) スイッチサイエンス
    • 赤色 LED ¥350 (100個入) 秋月電子通商  
      ※不要ならカーボン抵抗とともに省略可
    • 75 Ω カーボン抵抗 ¥100 (100本入) 秋月電子通商  
      ※不要なら LED とともに省略可
    • 押しボタン・ピンコネクタ・導線 近所のホームセンターで 5〜600 円くらいで購入 
      ※不要なら省略可
    • 適当なケース 廃物利用
    ※上記参考価格計 ¥9,519
    ※LAN ケーブルと DC 5V USB-Aタイプ の AC アダプタは手持ちのものを利用

    組み立て

    LAN コネクタである「mbed用イーサネット接続キット」の組み立てのみハンダづけが必要ですが、その他の配線・パーツの取り回しはすべてブレッドボード上で行うため材料が揃っていれば装置の組み立ては簡単です。写真のようにブレッドボード上の配線には固いジャンプワイヤ、センサやブザーの接続にはオス−メスの柔らかいジャンプワイヤを使いました。実体配線図をあわせて掲載します。(クリックで拡大)

    むき出しのままでは扱いにくいので前に買った PC 用パーツの外箱を再利用して簡単なケースを用意しました。素材が薄いプラスチックと紙なので加工しやすく耐久性もそれなりにありそうです。

    焦電型赤外線センサモジュールは白いレンズの部分を露出しないと機能しないためサイズに合わせてケースに切り込みを入れました。測定誤差軽減のために IC 温度センサも外に引き出しています。外観を不透明にしているのは設置先での存在感を抑えるためで、LED は動作確認用と割り切りトラブルが起こった場合のみ点滅状態を見ることにしています。

    プログラム

    上記の装置構成にあわせて作成した mbed LPC1768 用プログラムを以下の場所で公開しています。

    Parse サーバ上の対向処理との連携のために main.cpp の冒頭に当該 Parse アプリケーションの ID と REST API キーを記述する形にしています。公開したソースでは伏字にしていますが、後述の手順で当該 Parse アプリのコピーを作成しその ID とキーをここに記述してリビルドすれば実際に連携を試すことができます。Parse 上の所定の ID とキーは Parse へのログイン後、[Dashboard] - [(アプリ名)] - [Settings] - [Keys] から確認できます。

    装置には識別用に任意の名前をつけることができます。名前は mbed ボードの USB ストレージ上の "id.txt" ファイルに記述します。この名前は装置が人を検知した際に Parse サーバへ送出する情報のひとつとして扱われます。

    装置が人を検知するたびに Parse サーバへデータを送るのは不経済なので、データ送出の最短間隔を以下の定義で指定しています。
    #define HTTPS_REQUEST_INTERVAL 1800 // 30分

    クラウドサービスについて

    装置の対向処理用に用意した Parse 用のサーバコードと、そのコードからメール送信のために呼び出している Mailgun サービスについて説明します。

    Mailgun の準備

    Mailgun は多くのプログラミング言語に対応したクラウドメールサービスです。REST API セットに加え Python, Ruby, Java, C#, PHP 用のライブラリが公開されており、自作のコードで簡単にメール送信をハンドリングできる便利なサービスなので今回の話題とは無関係にアカウントを作って試してみるのも良いでしょう。サインアップ時にクレジットカード情報の入力は不要です。

    http://www.mailgun.com/

    Mailgun にアカウントを作成すると一件の「Sandbox Domain」が割り当てられます。この Mailgun Subdomain はテスト用とされておりメール送信は 300件/日までという制約がありますが、自前のドメインがあればそれを登録することでこの制約はなくなります。なお、無料枠はこの記事の時点で 10,000件/月までとなっています。[詳細]
    ちなみに手元では今のところ一日 300件もメールを送信できればまず十分なのでひとまず Sandbox Domain をそのまま使っています。

    Parse からの Mailgun API 呼び出しにおいては Mailgun アカウントに紐付けられた「API Key」を使用します。Parse は Mailgun と公式に連携しているためごくシンプルなコード記述で Mailgun API を呼び出すことができます。

    Parse の準備

    Parse は Facebook 傘下の Parse.com が開発・運営する現在もっともメジャーな BaaS(Backend as a Service) のひとつです。ユーザ管理・データストア・スマホへのプッシュ通知・アクセス解析・ホスティング・SNS 連携・位置情報連携など多くの機能から構成されており対象プラットフォーム・言語も広範です。無料枠も広く 30リクエスト/秒まで、プッシュ通知 100万件/月までは無料、また、アクセス解析機能も自由に利用できます。[詳細]

    https://parse.com/

    一般に BaaS の主な目的はアプリケーションの対向サーバ側機能を代替・補完することにあり、サーバの管理運用やサーバ側コード開発に踏み込むコストを抑制しアプリ本体の開発に注力可能となることが利用者にとってのメリットですが、Parse にはサーバ上でユーザコードを実行することのできる「Cloud Code」というしくみが用意されています。これを利用することで標準の機能のみではカバーできない固有の要件に対応できる余地が大きくなり、また、フロントエンドのアプリケーションを一切伴わず所定のサーバコードのみを Parse サーバ上で実行するといった使い方も可能となります。今回作った装置は Parse サーバ上に設置した自作コードをそのまま呼び出して必要な処理を行っています。このように目的に応じて柔軟に利用できることが Parse の魅力のひとつと言ってよいでしょう。

    Parse サーバ用に作成したコードを https://github.com/mkttanabe/Anpi で公開しています。このコードを自分の Parse アカウント環境で使用する手順を以下に記述します。

    1. 「Anpi」アプリと「Detected」クラスの作成

    Parse へログイン後、[Dashboard] - [Create a new App] をクリックして「Anpi」という名前のアプリを作成し同アプリの [Core] へ移動

    [Dashboard] - [Anpi] - [Core] - [Data] の [+ Add Class] をクリックして Custom タイプの「Detected」クラスを作成
    ※ここでいう「クラス」とは Parse 用語であり、「テーブル」の概念とほぼ同じものです

    [+Col] ボタンをクリックして Detected クラスに次の各カラムを追加。カラム並びは任意に調整可

    • 「posted」Date 型
    • 「deviceId」String 型
    • 「deviceAddress」String 型
    • 「temperature」Number 型

    装置から受信した人検知情報はこの Detected クラスのデータとして保存されます。

    2. Parse コマンドラインツールの導入と Cloud Code のセットアップ

    Parse 用の開発を行う PC には専用のコマンドラインツールの設置が必要です。公式ドキュメント「Cloud Codeとは」の冒頭の説明にそって開発環境へツールをインストールします。インストールが終わったら任意の開発用フォルダへ移動し「parse new」コマンドで「Anpi」アプリ用 Cloud Code の雛形を生成します。

    $ parse new Anpi
    Email: your@mail.com
    Password:
    1:Anpi
    Select an App: 1
    

    github.com/mkttanabe/Anpi 下の Parse/Anpi/cloud/main.js の内容を開発用フォルダ下の Anpi/cloud/main.js へ上書きし、冒頭の下記の箇所を自分の Mailgun ドメインと API Key、送信先とするメールアドレスで書き換えます。

    var MAILGUN_DOMAIN = 'sandboxXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.mailgun.org';
    var MAILGUN_KEY    = 'key-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
    var MAILGUN_TO     = 'Makoto Tanabe <XXXXXXXXXXXXX@XXXXX.XXXX>';
    var MAILGUN_FROM   = 'Safety Alert <postmaster@' + MAILGUN_DOMAIN + '>';
    

    開発用フォルダ下の Anpi/ へ移動しそこから「parse deploy」コマンドを実行すると配下の各ファイルが Parse サーバへ転送されます。

    $ cd Anpi
    $ parse deploy
    Uploading source files
    Finished uploading files
    New release is named v1
    

    Parse サーバへ転送したコードの動作は 公式ドキュメント「Cloud Codeとは] - [はじめに] - [シンプルな関数] に記述された方法で確認できます。

    3. ジョブの登録

    上の手順で deploy した main.js には Detected クラス上の直近 30件のデータをメールでレポートするためのジョブ用関数「report」が含まれています。 ジョブの登録・編集・削除は [Dashboard] - [Anpi] - [Core] - [Jobs] から行います。図の UI から任意の内容で設定可能です。

    ※無料枠の Parse アカウントでは複数のジョブを同時に実行することはできません

    以上の手順・設定で、今回作成した mbed ベースの装置本体と各クラウドサービスが連携して稼動します。アイディア次第で応用も可能でしょう。こういう仕組みを低コストで自作できる時代になりました。


    (tanabe)
    klab_gijutsu2 at 07:54|この記事のURLComments(0)TrackBack(0)
    Blog内検索
    Archives
    このブログについて
    DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
    このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
    最新コメント