2018年08月08日

Google Home でローカルの MP3 ファイルをプレイリスト再生する方法

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

前回の記事では ほりひろ 様による esp8266-google-home-notifier ライブラリと ESP32 ボード (秋月版)を使って構成した Google Home 用キャストエージェントとその利用例を紹介しました。記事ではあわせて同ライブラリをプライベートにカスタマイズした内容として以下を挙げました

  1. 元の google-home-notifier に存在する ip 関数を追加
  2. 元の google-home-notifier に存在する play 関数 (public)を追加
  3. play 関数による Google Home での MP3 データ再生終了を検知するためのコールバック機構を追加

今回はみっつめの話題について紹介します。

話のきっかけ

Google Home Mini で Spotify など試しているうちに、昔買った音楽 CD をこういう手軽さで聴くことができればと思うようになりました。そのためには楽曲データを MP3 形式等に変換して Google Play Music へアップロードすれば無料で実現できると随所で紹介されています。なかなか魅力的なサービスではありますが、その一方で Google 側の利用規約とのかねあいが気になりました。

手元では日常的に Google の多くの優れたサービスの便宜を享受しており、ひとりのユーザとして同社には敬意と信頼感を持っています。しかし、自分にすべての権利のあるデータであればともかく、自分には所有権しかない楽曲のデータを丸ごとこの規約のもとへ預けることには個人的にためらいがありました。そこで注目したのが google-home-notifier の MP3 データ再生機能です。

google-home-notifier には URL で指定された MP3 データを Google Home へキャストすることのできる play 関数が用意されています。そのため、LAN 上にローカル Web サーバをを立ててそこへ所定の MP3 データを配置し、そのアドレスをこの関数へ渡せば手元の環境からデータを持ち出すことなく Google Home で再生することが可能です。このやり方なら市販の楽曲に対する自分の権利の範囲を踏み越える心配はないでしょう。

ただ、play 関数は一件のデータをワンショットでキャストすることを前提とするもので複数データの連続再生には対応していません。そのため何曲かを順番に流すといったことはできないのが残念です。そこで、この部分に手を加え、所定のプレイリストを参照して複数の MP3 データの順次再生・シャッフル再生ができるようにしてみました。

まずその動作の様子を動画で紹介します。自宅では向かって左側の Raspberry Pi Zero W をローカル Web サーバとして運用しています。このデモでは soundorbis 様によるフリージングル曲を使用させて頂いています。

動画:1分15秒
  

ちなみにここでは以下の IFTTT アプレット (クリックで可読大表示) 経由でローカル Web サーバから再生リスト「demo.txt」内容を読み込んで処理を行っています。
トリガー
アクション

再生リストとその周辺の話題は末尾の「エージェントプログラム」の項に記述しています。
以下、実現に至るまでの経緯を控えます。

対応方法の調査

google-home-notifier の処理内容

まず google-home-notifier の処理を追うことから始めました。以下はそのまとめです。esp8266-google-home-notifier はオリジナルの google-home-notifier が堅実に移植されたライブラリであるためここでは後者をターゲットとしています。

初期設定
  • 呼び出し側から ip() 関数により対象とする Google Home デバイスの IP アドレスと言語が指定されればそれを設定
  • 呼び出し側から device() 関数により対象 Google Home デバイスのデバイス名と言語が指定されればそれを保持

主処理

  • notify() 関数は渡されたテキストを所定の言語で TTS 処理した結果の MP3 ファイルの URL を対象 Google Home デバイスへキャストする
  • play() 関数は指定された MP3 ファイルの URL をを対象 Google Home デバイスへキャストする
  • notify(), play() はいずれも呼び出された時点で対象 Google Home デバイスの IP アドレスが未設定状態であれば前出 device() 関数経由で保持しているデバイス名で mDNS 照会により対象 Google Home デバイスの IP アドレスを得る  =>  GitHub: mdns

キャストの手順

注目すべき点と考え方

ポイントは、上記「キャストの手順」の最後の「load() が終わったら各仮想チャネルを close してコネクションを切断」という部分にあります。該当箇所のソースコードを引用します。

  • google-home-notifier.js (#L88-L96)
                  :
    var onDeviceUp = function(host, url, callback) {
      var client = new Client();
      client.connect(host, function() {
        client.launch(DefaultMediaReceiver, function(err, player) {
    
          var media = {
            contentId: url,
            contentType: 'audio/MP3',
            streamType: 'BUFFERED' // or LIVE
          };
          player.load(media, { autoplay: true }, function(err, status) {
            client.close();
            callback('Device notified');
          });
                  :
    

このように、Google Home 側の DefaultMediaReceiver へ再生対象データの引き渡し(player.load())を終えるとプログラムは直ちにデバイスとの接続を閉じそのまま終了しています。あとは Google Home まかせということになりますが、Google Home はデータ再生中に別のデータのキャストが指示されると現在の再生の終了を待つことなくあっさり新しいデータの再生を開始します。そのため、単に google-home-notifier を繰り返し実行しても、その都度データ再生が中断され別データの再生に移るだけで、プレイリスト再生のように「一件の再生が終了〜次のデータを再生」という動きにはなりません。

一方、手元で上記の player.load() 後も接続を維持した状態で観察したところ、Google Home 側でデータ再生が終了すると前掲の仮想チャネル「urn:x-cast:com.google.cast.media」経由でデバイス側からプログラムへ下記要領のメッセージが送出されることを確認しました。

{"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"IDLE","currentTime":0,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"currentItemId":1,"idleReason":"FINISHED"}],"requestId":0}

このように、接続を維持していればプログラムはデバイス側の再生終了の捕捉が可能であることがわかりました。 この件を含めここまでの一連の事情を踏まえると、google-home-notifier でデータの連続再生を実現するために必要な措置は大きく次のふたつであることがわかります。

  • デバイスへの再生指示を終えたあともデバイスとの接続を維持する
  • デバイスから上記の再生終了メッセージを受け取ったら次のデータの再生を指示する

なお、Google Cast の仕様にはデータの連続再生を行うためのネイティブの機構が存在するのではないかとも思うのですが、手元では今のところその方面の具体的な情報に行き着いておらず、いずれにせよここではあくまでも現行の google-home-notifier の機能を拡張することにしました。

実装まわりの話題

方針

実装にあたっては次の方針を基本としました。

  • 複数 MP3 データの連続再生はあくまでも拡張機能として取り回す。従来の TTS 機能および単一の MP3 再生機能との互換性を保ち両者の処理を衝突させない
  • デバイスからの再生終了メッセージを受け取るためのコネクション維持と監視は他の処理を止めないためにバックグラウンドタスク内で実施する
  • バックグラウンドタスクから再生終了通知を受け取るためにコールバック機構を設置する。ライブラリ側へ追加するのはこの「一件の MP3 データ再生が終了した場合、または中断・スキップされた場合にコールバック関数経由で通知する」処理にとどめ、それ以外の処理はアプリケーション側の要件としてエージェントプログラムへ実装する
  • つまりライブラリ側の改変は必要最小限の内容とする

処理のイメージ

左側のフローは手元での p8266-google-home-notifier とエージェントプログラムによる処理内容を要約したものです。右側はそこに調査から得られた情報を加味して MP3 データのリスト再生機能を追加したイメージで、ざっくり、赤文字の「キャスト処理」「再生監視タスク」の部分をライブラリ側の処理要素と想定しています。なお灰色のブロックは右フローとの共通部分です。 (クリックで可読大表示)

Google Home からのメッセージについて

上記右フローの「再生監視タスク」パート内の条件分岐部分でも触れていますが、データ再生の監視中には Google Home デバイスから仮想チャネル経由でさまざまなメッセージが送られてきます。手元での観察結果から、今回の実装では以下のメッセージを利用しています。

  • (右フロー中の「※1」) データの再生終了は、前掲のとおり当該デバイスからの「urn:x-cast:com.google.cast.media」チャネル経由での以下のメッセージによって判定できる。これを受け取ったらエージェント側は次の曲の再生へ移行する
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"IDLE","currentTime":0,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"currentItemId":1,"idleReason":"FINISHED"}],"requestId":0}
  • (右フロー中の「※2」) デバイスが他のアプリ等とのキャストセッションを開始した場合には「urn:x-cast:com.google.cast.tp.connection」チャネルから以下のメッセージが届く。これを受け取ったらエージェント側は現在の連続再生処理を停止する
    {"type":"CLOSE"}
    • なお、Google Cast 機構においてはデバイス側との接続の維持・確認のために上記チャネル上で相互に PING, PONG メッセージの応酬を継続的に実施することが流儀のひとつ。バックグラウンドタスクでの待機中は 5秒毎に PING を送ることにしたが、タイミングによってはデバイス側から PING が届くこともある。その場合は PONG を送る
      {"type":"PING"}
      {"type":"PONG"}
  • (右フロー中の「※3」) ユーザが「OK, Google。ストップ」「OK, Google。やめて」等の再生停止を求めるフレーズを発した場合は「urn:x-cast:com.google.cast.media」チャネル経由でデバイスから下記要領のメッセージが届く。これを受け取ったらエージェント側は連続再生を停止する
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PAUSED","currentTime":1.880214,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":388729138}
  • (右フロー中の「※4」) ユーザが「OK, Google。スキップ」「OK, Google。次の曲」等のフレーズで次データへのスキップをリクエストした場合は「urn:x-cast:com.google.cast.media」チャネル経由でデバイスから下記要領のメッセージが届く。この状況でのステータスは「"playerState":"PLAYING"」かつ、「"requestId":」の値が 0 以外となる。 これを受け取ったらエージェント側は次のデータの再生へ移行する
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":15.665338,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":388729139}
    なお「"requestId":0」のメッセージがデータの再生開始時点で一度発行されるため上記と混同しないよう注意
    {"type":"MEDIA_STATUS","status":[{"mediaSessionId":1,"playbackRate":1,"playerState":"PLAYING","currentTime":1.384362,"supportedMediaCommands":15,"volume":{"level":1,"muted":false},"activeTrackIds":[],"currentItemId":1,"repeatMode":"REPEAT_OFF"}],"requestId":0}

ソースコード

カスタマイズした esp8266-google-home-notifier

オリジナルの esp8266-google-home-notifier ライブラリのコードへプライベートに手を加えています。変更箇所は「#ifdef TANABE」部分で、fork した GitHub リポジトリへ反映しています。変更内容はこの記事の冒頭、および前回の記事で触れたとおりです。

$ git clone -b private https://github.com/mkttanabe/esp8266-google-home-notifier.git

また、同リポジトリの examples/esp32/SimpleUsage/ 下に play 関数用のサンプルスケッチを加えました。どちらもオリジナルの SimpleUsage.ino のコピーを小さく書き替えたものです。

このふたつの追加サンプルを実行した様子の動画です。
  

エージェントプログラム

Arduino core for the ESP32 環境で作成したエージェントの Adruino スケッチです。前回分と互換性のある機能拡張版です。

  • GoogleHomeNotifierESP32AgentEx - github.com/mkttanabe

    GoogleHomeNotifierESP32Agent.ino の 以下の箇所を環境にあわせて書き替える
    //------- ユーザ定義 ------------------

    // Google Home デバイスの IP アドレス, デバイス名
    // 有効にすれば指定 IP アドレスを直接使用
    // 無効にすれば指定デバイス名で mDNS 照会
    //#define USE_GH_IPADRESS

    IPAddress myGoogleHomeIPAddress(192,168,0,110);
    #define myGoogleHomeDeviceName "room02"

    // WiFi アクセスポイント
    #define ssid "ssid"
    #define password "pass"

    // Beebotte 情報
    #define mqtt_host "mqtt.beebotte.com"
    #define mqtt_port 8883
    #define mqtt_topic "test01/msg"
    #define mqtt_pass "token:" "token_************"

    // MP3 データを配置する Web サーバ
    #define dataServer "192.168.0.127"
    #define dataServerPort 80
    // MP3 データの URL
    #define MP3DataFmt "http://" dataServer "/sound/%s.mp3"
    // MP3 再生リストのパス
    #define MP3ListFmt "/sound/list/%s.txt"
    //-------------------------------------

    上の記述の場合、自分の Beebotte アカウントの Channel "test01", Resource "msg" のメッセージの data キーの値に応じて以下が行われる
    • data 値の先頭文字がアスタリスクであれば音声合成用のテキストとみなして google-home-notifier の TTS 処理にかける
      • 例) {"data":"*こんにちは"} => 「こんにちは」と発話
    • data 値の先頭文字がアスタリスクまたは "@" でなければ MP3 ファイル名とみなし所定の Web サーバの URL に編集して再生
      • 例) {"data":"file01"} => "http://192.168.0.127/sound/file01.mp3" をキャスト
    • data 値の先頭文字が "@" であれば MP3 データ再生リスト名とみなし所定の Web サーバの URL に編集して読み込み、記述されている各 MP3 データエントリを再生
      • 例) {"data":"@demo"} => "http://192.168.0.127/sound/list/demo.txt" を再生リストとして読み込んで処理
        • 下記の指定があればシャッフル再生を行う
          {"data":"@demo,1"} , {"data":"@list01,ランダム"}
        • 上記以外ならリスト上の記述順に再生
      • 再生リストの記述例
        demo.txt
        # 各行の '#' 以降はコメントとして扱われる
        # 空行は無視、有効行先頭末尾のスペース・タブは除去される

        # soundorbis 様によるフリー BGM 作品より
        #
        # 【フリーBGM】リコーダージングル【01〜05】
        # https://www.youtube.com/watch?v=ztm1CSZEpY8
        #
        # 利用規約
        # https://www.soundorbis.net/license
        #

        demo/[1]nc150689
        demo/[2]nc150690
        demo/[3]nc150691
        demo/[4]nc150692
        demo/[5]nc150693

    なお、上の「ユーザ定義」箇所において「#define USE_GH_IPADRESS」が無効な場合は前回版と同様に mDNS 照会によりデバイス名から IP アドレスを取得するが、このやや時間のかかる照会のコストを軽減するために、今回の拡張版においては一旦取得した IP アドレスを ESP32 のフラッシュメモリ領域 (SPIFFS) へ記録し次回はまずそれを参照する処理を加えている。当該アドレスへアクセスできない場合や当該アドレスのデバイスを Google Home と識別できない場合はあらためて照会と記録を行う。詳細は DeviceAddress.cpp を参照のこと

こういった経緯を経て形になったのが冒頭のデモ動画で稼働しているエージェントです。Raspberry Pi Zero W のローカル Web サーバとペアでとても快適に利用しています。


(tanabe)
2018年08月03日

Google Home を拠点間の双方向コミュニケーションに利用する

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

ESP32 版 google-home-notifier のこと

google-home-notifier の ESP8266 移植版「esp8266-google-home-notifier」の作者である ほりひろ 様が、今年(2018年)6月に ESP32 への対応を実施されました。node.js プログラムであるオリジナルの google-home-notifier を実行可能なもっとも小振りなプラットフォームはラズベリーパイですが、さらにコンパクトで消費電力の少ない ESP32 ボードで自由に Google Home へのキャストを実現できることには大きな魅力があります。自宅ではこの素晴らしいライブラリを使って MQTT メッセージをトリガーにキャスティングを行う内容のエージェントを構成し様々な要件で利用させて頂いています。

「声のアバター」によるやりとり?

そんな中で先日ふと、google-home-notifier の「Google Home で任意の音を出せる機能」と、Google Home 側の「音声で所定の処理を実行できる機能」の組み合わせを、離れた拠点間での定型的なコミュニケーションに利用できるのではないか? と思い立ちました。図のようなイメージです。

双方のエージェントがそれぞれ MQTT ブローカー上の別のトピックを Subscribe しておき、必要に応じて相手のトピックへメッセージを Publish することで現地の Goole Home が所定のアナウンスを行います。その内容に対する所定のフレーズでの応答で相手へ向けての処理を発動する形にしておけば、いわば「声のアバター」を通じて両者間のやりとりが成立すると考えました。同じく音声による連絡方法であっても電話とは異なりこのやり方には直接的な拘束感やある種の重さがないため双方ともより手軽により淡々と利用できるでしょう。

試作のシナリオ

いろいろ使途がありそうですが、手元ではまず個人的に現在もっとも身近なテーマである「高齢者世帯の安否確認」を想定したシナリオを形にしてみました。以下の内容です。

A:見守り側
B:高齢者世帯側

  • A => Google Home A:「ねえ Google、安否を確認」
    • Google Home A => A:『はい、これから声をかけてみます』

  • Google Home B => B:『身のまわりは大丈夫ですか?問題がない場合は "ねえ Google、順調です" と話しかけて下さい。もし何かご相談があれば、"ねえ Google、コールして" と話しかけて下さい』

    • パターン 1: B => Google Home B:「ねえ Google、順調です」
      • Google Home B => B:『それはよかったです。さっそく大丈夫と伝えておきますね』
        • Google Home A => A:『先方から "元気です"と音声連絡がありました。メールを確認して下さい』
        • A あてにメールが届く


    • パターン 2: B => Google Home B:「ねえ Google、コールして」
      • Google Home B => B:『はい、すぐに電話をするようにと伝えます』
        • Google Home A => A:『先方から "電話してほしい"と音声連絡がありました。メールを確認して下さい』
        • A あてにメールが届き携帯電話が数回コールされる

相手が高齢者でもあるため Google Home からのアナウンスにはとりわけ「聞き取りやすさ」が求められます。定型の文言なので毎回音声合成を行う必然性もなく、ここでは、google-home-notifier の notify 関数から呼び出される Google 翻訳 TTS を使うのではなく、複数のオンライン TTS サービスを試した中で個人的にもっとも自然に感じられた音声を収録した MP3 データを play 関数で再生することにしました。

動作の様子

後出のプログラムリソース一式を使って二組の「Google Home Mini + エージェント」間でやりとりを行った様子の動画です。

(パターン 1: 「安否を確認」 〜 「順調です」 1分17秒

(パターン 2: 「安否を確認」 〜 「コールして」) 1分16秒

リソース

esp8266-google-home-notifier のカスタマイズ

前述のように手元では作成したエージェントをさまざまな要件で利用しており、この間にオリジナルの esp8266-google-home-notifier ライブラリのコードへ何点かプライベートに手を加えています。変更箇所は「#ifdef TANABE」部分で、fork した GitHub リポジトリへ反映しています。

$ git clone -b private https://github.com/mkttanabe/esp8266-google-home-notifier.git

変更内容

  1. 元の google-home-notifier に存在する ip 関数を追加
    • Google Home デバイスアドレスの mDNS 照会に比較的時間がかかるため IP アドレスの直指定もできると嬉しいため
  2. 元の google-home-notifier に存在する play 関数 (public)を追加
    • 手元では TTS 機能だけでなく MP3 再生機能も必要であるため
  3. play 関数による Google Home での MP3 データ再生終了を検知するためのコールバック機構を追加
    • 元の google-home-notifier にもないが欲しいと思っていた機能。理由は後日あらためて、、

エージェントプログラム

Arduino core for the ESP32 環境で作成したエージェントの Adruino スケッチです。MQTT ブローカーに Beebotte を使用しています。

  • GoogleHomeNotifierESP32Agent - github.com/mkttanabe
    GoogleHomeNotifierESP32Agent.ino の 以下の箇所を環境にあわせて書き替える
    //------- ユーザ定義 ------------------
    // Google Home デバイスの IP アドレス, デバイス名
    // 有効にすれば指定 IP アドレスを直接使用
    // 無効にすれば指定デバイス名で mDNS 照会
    #define USE_GH_IPADRESS

    IPAddress myGoogleHomeIPAddress(192,168,0,121);
    #define myGoogleHomeDeviceName "room01"

    // WiFi アクセスポイント
    #define ssid "ssid"
    #define password "pass"

    // Beebotte 情報
    #define mqtt_host "mqtt.beebotte.com"
    #define mqtt_port 8883
    #define mqtt_topic "test01/msg"
    #define mqtt_pass "token:" "token_************"

    // MP3 データを配置する Web サーバとデータパス
    #define dataServer "192.168.0.126"
    #define dataServerPort 80
    #define MP3DataFmt "http://" dataServer "/sound/%s.mp3"

    //-------------------------------------

    上の記述の場合、自分の Beebotte アカウントの Channel "test01", Resource "msg" のメッセージの data キーの値に応じて以下が行われる.
    • data 値の先頭文字がアスタリスクであれば音声合成用のテキストとみなして google-home-notifier の TTS 処理にかける
      • 例) {"data":"*こんにちは"} => 「こんにちは」と発話
    • data 値の先頭文字がアスタリスクでなければ MP3 ファイル名とみなし所定の Web サーバスペースの URL を編集して再生
      • 例) {"data":"file01"} => "http://192.168.0.126/sound/file01.mp3" をキャスト

用意した IFTTT アプレット

今回の試作では、トリガーに Google Assistant サービス、アクションに WebHooks サービスをアサインしたみっつの IFTTT アプレットを使っています。 ※ いずれもクリックで可読大表示

  • 問い合わせフレーズ「安否を確認」対向のアプレット
    アクションで Beebotte 上の相手側 "test02/msg" トピックへ "AnpiQuery.mp3" のキャスト再生を指示するメッセージを Publish
    トリガー
    アクション
  • 応答フレーズ「順調です」および「コールして」に対向のアプレット
    アクションで次項の Google Apps Script による Web アプリコードを所定のパラメータを添えて実行
    トリガー
    アクション
    トリガー
    アクション

Google Apps Script による Web アプリコード

上のふたつの応答用アプレットから呼び出している GAS コードです。

  • 「順調です」の場合
    アプレットから渡される res パラメータの値は「safe」。 Beebotte 上の相手側 "test01/msg" トピックへ "AnpiSafe.mp3" のキャスト再生を指示するメッセージを Publish して「元気です」と Gmail を送信
  • 「コールして」の場合
    アプレットから渡される res パラメータの値は「doCall」。まず Twillio 経由で相手の携帯電話を数回コールしてから Beebotte 上の相手側 "test01/msg" トピックへ "AnpiDoCall.mp3" のキャスト再生を指示するメッセージを Publish して「電話がほしい」と Gmail を送信


/*
  AnpiResponse
  2018-07
*/
var ADMIN = "***********@gmail.com";

function doGet(e) {
  return  ContentService.createTextOutput("??");
}

function doPost(e) {
  return doIt(e);
}

function doIt(e) {
  var msg, mp3Name;
  var res = e.parameter.res;
  var where = e.parameter.where;

  if (res == "safe") {
    msg = "元気です";
    mp3Name = "AnpiSafe";
  } else if (res == "doCall") {
    msg = "電話がほしい";
    mp3Name = "AnpiDoCall";
    doPhoneCall(); // twilio
  } else {
    return  ContentService.createTextOutput("res not found");
  }

  doPublish(mp3Name); // MQTT

  // gmail
  doSendMail(ADMIN, "Anpi", ADMIN, ADMIN,
           "安否連絡 [" + res + "]", 
           curDate() + " " + curTime() + "\n" +
           where + " より 「" + msg + "」と連絡がありました");

  return  ContentService.createTextOutput("OK");
}

function curDate() {
  var d = new Date();
  return d.getFullYear() + '-' +
    ('00' + (d.getMonth()+1)).slice(-2) + '-' + ('00' + d.getDate()).slice(-2);
}

function curTime() {
  var d = new Date();
  return ('00' + d.getHours()).slice(-2) + ':' +
    ('00' + d.getMinutes()).slice(-2) + ':' +  ('00' + d.getSeconds()).slice(-2);
}

function doPhoneCall() {
  var url = "https://api.twilio.com/2010-04-01/Accounts/****************/Calls";
  var data = "To=%2B************&From=%2B***********0&Url=http://demo.twilio.com/docs/voice.xml&Timeout=10";
  var options = {
    method: "POST",
    headers: {
      "Authorization":"Basic QUMxZjdhYTIzZWM2YTdkNWM*************************",
      "Content-Type":"application/x-www-form-urlencoded"
    },
    payload: data,
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
}

function doPublish(mp3Name) {
  var url = "https://api.beebotte.com/v1/data/publish/test01/msg";
  var data = '{"data":"' + mp3Name + '"}';
  var options = {
    method: "POST",
    headers: {
      "X-Auth-Token":"token_****************",
      "Content-Type":"application/json"
    },
    payload: data,
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
}

function doSendMail(from, fromName, to, cc, subject, body) {
  GmailApp.sendEmail(
    to,
    subject,
    body,
    {
      from: from,
      name: fromName,
      cc: cc
    }
  );
}

現行の Google Home ではサポートされていない「能動的な発話」を擬似的に実現する google-home-notifier を利用しなければできないことであり、こういった使い方も同プログラムの実用的な応用例のひとつではないかと思います。さらに発展させることもできるでしょう。面白い時代になりました。



余談:「うるさいアラーム」機能を DIY した話

手元での google-home-notifier の利用例をもうひとつ紹介します。 Google Home のアラーム機能は手軽で何かと便利なので目覚まし用に使ったりしていました。ところがプリセットのアラーム音がいささか上品すぎるため手元では音量を最大にしても目覚めにつながらないケースが何度かありました。同じ経験をした方はおそらく少なくないのではないかと思います。

ちなみに、Google は 2018-02-01 のアップデートで Google アシスタントのアラーム音を変更可能としたようですが、この機能を利用できるのは現時点では言語設定が英語の場合のみで日本語環境では利用できません。

後日の対応に期待しつつ、前掲のエージェントを利用して耳障りでうるさいアラームを鳴らす機能を自作してみました。

まず、アラーム設定用・解除用の下のふたつの IFTTT アプレットを用意しました。所定のシートの A1 セルを使って音を鳴らす時刻の設定・解除を行う内容です。設定用のアプレットでは Google Assistant トリガーのオプション "Say a phrase with both a number and a text ingredient" での "a number" と "a text" を使って時・分のふたつの音声指示要素を受け入れています。
トリガー
アクション
トリガー
アクション

あわせて当該シートに以下の GAS コードを付与、スクリプト実行のトリガー「時間主導型」の「分タイマー」で「1分ごと」に doNotify() 関数の呼び出しを設定しました。シートに記録された時刻と現在の時刻が一致すると、Beebotte 上の "test01/msg" トピックへ "noise01.mp3" のキャスト再生を指示するメッセージが Publish されます。


// Beebotte へ MQTT メッセージを Publish
function doPublish(mp3Name) {
  var url = "https://api.beebotte.com/v1/data/publish/test01/msg";
  var data = '{"data": "' + mp3Name + '"}';
  var options = {
    method: "POST",
    headers: {
      "X-Auth-Token":"token_*************", 
      "Content-Type":"application/json"
    },
    payload: data,
    muteHttpExceptions: true
  };
  var response = UrlFetchApp.fetch(url, options);
}

function doNotify() {
  // シートをチェック。A1 に時刻が設定されていれば騒音を鳴らす
  var spreadSheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadSheet.getSheets()[0];
  var val = sheet.getRange("A1").getValue();
  Logger.log(val);
  if (val == 0) {
    return;
  }
  var hm = val.split("_");
  Logger.log("set => " + hm[0] + ":" + hm[1]);  
  if (hm[0] >= 24 || hm[1] >= 60){
    //Logger.log("invalid");
    return;
  }
  var dt = new Date();
  var min = dt.getMinutes();
  var h = dt.getHours();
  if (String(h) == hm[0] && String(min) == hm[1]) {
    sheet.getRange("A1").setValue("0");
    doPublish("noise01");
  }
}

動作の様子: 動画 32秒

 

このように単機能でごくシンプルなものですが、手元では結構役に立っています。


(tanabe)
2018年06月29日

最近のPython-dev(2018-06)

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

バックナンバー:

Python 3.7

日本時間の6/28に Python 3.7 がリリースされました。 終盤に駆け込みで2つ大き目の変更が入りました。

  • Unicode 11 対応
  • ASTの変更の revert

Unicode 11 はデータの更新だけなので危険が少ないし、1.5年後の3.8まちにはしたくないということでGoサインが出ました。

AST は、僕が中心で行ったAST段階での定数畳み込みの準備として、 docstring とそうでない文字列の区別をAST段階でつきやすくために変更していました。

その変更によりいくつかのライブラリで問題が報告されていたのですが、AST はもともと後方互換性を保証していないからとライブラリ側での対処がされていました。しかし、RCフェーズに入ってから IPython が他と異なる壊れ方をしていて、今のASTに関するAPIが IPython のニーズを満たしておらず想定外の対処が必要になっていたことから、一旦元に戻してまたAPIから考え直そうということになりました。

もちろんこのASTの変更を元に定数畳み込みが作られていたので、定数畳み込み後に docstring と文字列定数の区別がつかなくならないように Python 側にハックが必要になるのですが、僕が週末作業できないあいだに Serhiy がやってくれました。 https://github.com/python/cpython/pull/7121/files/8ee9227599583c460ff8faf9c1ab1670559a1224#r191080934

Compact GC Header

Python 3.8 に向けた大きめの改善の1つめに、2000年以来変更されてない循環参照GC用のメモリオーバーヘッドの削減に挑戦しています。

ちょうど 6/25 に、低レベルプログラミングが話題になることが多い Turing Complete FM のリスナーが集まる ミートアップイベンント があったので、そこで発表してきました。

基本的にはつぎのような戦略で、GC用ヘッダのメモリ使用量をポインタ3つ分から2つ分に削減しています。

  • 双方向リンクリストの逆方向リンクを、一時的に潰してGC用の参照カウントに使い、必要になる前に戻す。
  • ポインタの下位3bitを他の用途に使う (tagged pointer)。参照カウントに使うときは左に3ビットシフトする。

一旦動くようになったものの、問題がありました。

  • 参照カウントを3ビットシフトしたら、ポインタサイズが4バイトの32bitアーキテクチャでオーバーフローする危険がある。
  • マイナーなアーキテクチャ上のマイナーな malloc 実装が8バイトアラインしなかった場合、ポインタの下位3bitを使うのも危険。

その後、2種類の実装のメンテナンスはなるべく避けたいので、必要な期間が一番短かったビットを逆方向リンクのポインタではなく順方向リンクのポインタの下位ビットに押し込めるという改良を行いました。現在レビュー待ちです。

プルリクエスト

FASTCALL を PyFunction, PyCFunction 以外でも利用できるように

Python 3.6 から段階的に、インタプリタ内部で関数の引数の受け渡し方を変更する FASTCALL 方式が導入されています。

これを利用するために、現在は Python で実装された(バイトコードをインタプリタが実装する)関数を表すクラスと、C言語で実装された関数/メソッドを表すクラスが特別扱いされています。

さて、 Cython はもちろんC/C++言語で関数を実装するのですが、トレースバックなどでソースコードを表示できるようにしたいなど、Python組み込みのC言語で実装されたクラスでは足りない機能を追加するためには独自の型を使う必要があり、そうすると FASTCALL が利用できません。

それを改善するためにいくつかの提案がされていて、最新のものが PEP 580 になります。

提案者の議論の仕方がちょーっと強引すぎて摩擦を感じてはいるのですが、技術的にはまぁ正しい方向だと思うし、ちゃんと動く参照実装も素早く作ってくれているので、個人的には前向きに捉えています。

とはいえ、そもそも FASTCALL ってまだ破壊的変更が入るかもしれない内部専用APIを Cython が先走って採用しているだけで、どう考えても PEP 580 は先走りすぎなんですよね。

まだ 3.8 の開発は始まったばかりなので、 FASTCALL を stable & public 扱いにできないかの議論を始めたりしてフォローに回っています。

PEP 572 (代入演算子)

PEP 572 の議論は大炎上しました。5月にLanguage Summit があってそこで議論のオーバーヒートをどう扱うかなどの話がされたのですが、 LWN.net がその まとめ を作ってくれたところから、またML上での議論が再開し長大なスレッドになっています。。。


@methane

songofacandy at 14:39
この記事のURLComments(0)Python 
2018年06月12日

SwitchBot を ESP32 で遠隔操作してみた

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

SwitchBot と公開 API

物理的なスイッチやボタンを人に代わって操作してくれる BLE デバイス SwitchBot (by Wonderlabs,Inc.が人気です。有線/無線通信や赤外線等によるコントロールに対応していない機器をいわば強引に IoT 方面へ引き込もうという発想が面白いですね。


gigazine.net

技適マークと番号はパッケージに印刷されている

これは同じくクラウドファンディングを経て 2016年に商品化された MicroBot Push (by Naran Inc.: 工事設計認証番号 R208-160099 の後追い製品ではありますが、手元にある両者を比べてみると SwitchBot には後発であることのアドバンテージが随所に活かされているように見受けられます。とりわけ嬉しいのは本体のアームを制御するための API が公開されていることです。

この公約のとおり Wonderlabs,Inc. は Apache License 2.0 のもとに次のリソースを公開しています。

SwitchBot のアーム操作に必要な手順が次のシンプルな内容であることがわかります。

  • 対象とする BLE GATT サービスはユーザ定義の「cba20d00-224d-11e6-9fb8-0002a5d5c51b
  • その配下のキャラクタリスティック「cba20002-224d-11e6-9fb8-0002a5d5c51b」がターゲット
  • 上記キャラクタリスティックへ次の値を書き込むとそれぞれ以下の挙動となる
    • { 0x57, 0x01, 0x00 } : アームを 「倒す+引く」 ("Press")
    • { 0x57, 0x01, 0x01 } : アームを 「倒す」 ("Turn On")
    • { 0x57, 0x01, 0x02 } : アームを 「引く」 ("Turn Off")

以下は以前紹介した手順で採取した GATT 管理下の UUID の一覧です。

onServicesDiscovered: serviceList.size=4

onServicesDiscovered: svc uuid=00001800-0000-1000-8000-00805f9b34fb // Generic Access
onServicesDiscovered: chrlist.size=3
onServicesDiscovered:  chr uuid=00002a00-0000-1000-8000-00805f9b34fb // Device Name
onServicesDiscovered:  desclist.size=0
onServicesDiscovered:  chr uuid=00002a01-0000-1000-8000-00805f9b34fb // Appearance
onServicesDiscovered:  desclist.size=0
onServicesDiscovered:  chr uuid=00002a04-0000-1000-8000-00805f9b34fb // Peripheral Preferred Connection Parameters
onServicesDiscovered:  desclist.size=0

onServicesDiscovered: svc uuid=00001801-0000-1000-8000-00805f9b34fb // Generic Attribute
onServicesDiscovered: chrlist.size=0

onServicesDiscovered: svc uuid=0000fee7-0000-1000-8000-00805f9b34fb // Custom UUID of Tencent Holdings Limited
onServicesDiscovered: chrlist.size=3
onServicesDiscovered:  chr uuid=0000fec8-0000-1000-8000-00805f9b34fb // Custom UUID of Apple, Inc.
onServicesDiscovered:  desclist.size=1
onServicesDiscovered:   desc uuid=00002902-0000-1000-8000-00805f9b34fb // Client Characteristic Configuration Descriptor
onServicesDiscovered:  chr uuid=0000fec7-0000-1000-8000-00805f9b34fb // Custom UUID of Apple, Inc.
onServicesDiscovered:  desclist.size=0
onServicesDiscovered:  chr uuid=0000fec9-0000-1000-8000-00805f9b34fb // Custom UUID of Apple, Inc.
onServicesDiscovered:  desclist.size=0

onServicesDiscovered: svc uuid=cba20d00-224d-11e6-9fb8-0002a5d5c51b // User defined service
onServicesDiscovered: chrlist.size=2
onServicesDiscovered:  chr uuid=cba20003-224d-11e6-9fb8-0002a5d5c51b  // User defined characteristic
onServicesDiscovered:  desclist.size=1
onServicesDiscovered:   desc uuid=00002902-0000-1000-8000-00805f9b34fb // // Client Characteristic Configuration Descriptor
onServicesDiscovered:  chr uuid=cba20002-224d-11e6-9fb8-0002a5d5c51b  // User defined characteristic
onServicesDiscovered:  desclist.size=0

ちなみに、手元の SwithiBot 個体の MAC アドレスは「C0:65:9A:7D:61:E1」でした。

自作の Android アプリで操作してみる

上記の手順にそってまず Android アプリをざっくり書いてみました。以下は動作の様子を収めた動画とソースコードです。

動画:40秒 音量注意

SwitchBot01 - MainActivity.java

/**
 *
 * SwitchBot01
 *
 * SwitchBot を操作する
 *
 * メーカーが公式に公開しているプログラムをベースに作成
 *
 * https://github.com/OpenWonderLabs/python-host/
 *
 */

package jp.klab.SwitchBot01;

import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanRecord;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
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 com.google.android.gms.appindexing.Action;
import java.util.List;
import java.util.UUID;

public class MainActivity extends AppCompatActivity
    implements Runnable, View.OnClickListener, Handler.Callback {

  private static final String TAG = "SB01";
  private static final String TARGET_ADDR = "C0:65:9A:7D:61:E1"; // 手元の SwitchBot

  private static final int SCAN_MODE = ScanSettings.SCAN_MODE_LOW_LATENCY;

  private static final int MSG_DOSCAN = 100;
  private static final int MSG_FOUNDDEVICE = 110;
  private static final int MSG_STOPSCAN = 120;
  private static final int MSG_GATTCONNECT = 200;
  private static final int MSG_GATTCONNECTED = 210;
  private static final int MSG_GATTDISCONNECT = 300;
  private static final int MSG_GATTDISCONNECTED = 310;
  private static final int MSG_GATTGOTSERVICE = 400;
  private static final int MSG_SW_ON = 500;
  private static final int MSG_SW_OFF = 510;
  private static final int MSG_SW_PRESS = 520;
  private static final int MSG_ERROR = 10;
  private static final int REQ_ENABLE_BT = 0;

  private BluetoothAdapter mBtAdapter = null;
  private BluetoothLeScanner mBtScanner = null;
  private BluetoothGatt mBtGatt = null;
  private BluetoothDevice mBtDevice;
  private Handler mHandler;

  private Context mCtx;
  private ProgressDialog mProgressDlg = null;

  private TextView mTvAddr;
  private TextView mTvRssi;
  private Button mButtonDisconn;
  private Button mButtonConn;
  private Button mButtonTurnOn;
  private Button mButtonTurnOff;
  private Button mButtonPress;

  private BluetoothGattCharacteristic mChUser2 = null;
  private BluetoothGattDescriptor mDescUser1 = null;

  // SwitchBot の提供するサービス・キャラクタリスティック群の UUID より
  private UUID mUuidSvcUser1 = UUID.fromString("cba20d00-224d-11e6-9fb8-0002a5d5c51b");
  private UUID mUuidChUser2  = UUID.fromString("cba20002-224d-11e6-9fb8-0002a5d5c51b");

  // SwitchBot の 3 コマンド
  private byte[] mCmdSwPress = new byte[] {(byte)0x57, (byte)0x01, (byte)0x00};
  private byte[] mCmdSwOn    = new byte[] {(byte)0x57, (byte)0x01, (byte)0x01};
  private byte[] mCmdSwOff   = new byte[] {(byte)0x57, (byte)0x01, (byte)0x02};

  private ScanCallback mScanCallback = new bleScanCallback();
  private BluetoothGattCallback mGattCallback = new bleGattCallback();

  // GATT イベントコールバック
  private class bleGattCallback extends BluetoothGattCallback {
    @Override
    public void onDescriptorWrite(BluetoothGatt gatt,
                    BluetoothGattDescriptor desc,
                    int status) { // writeDescriptor() 結果
      super.onDescriptorWrite(gatt, desc, status);
    }
    @Override
    public void onCharacteristicRead(BluetoothGatt gatt,
                     BluetoothGattCharacteristic ch,
                     int status) { // readCharacteristic() 結果
      super.onCharacteristicRead(gatt, ch, status);
      Log.d(TAG, "onCharacteristicRead: sts=" + status);
    }
    @Override
    public void onCharacteristicWrite(BluetoothGatt gatt,
                      BluetoothGattCharacteristic ch,
                      int status) { // writeCharacteristic 結果
      super.onCharacteristicWrite(gatt, ch, status);
      Log.d(TAG, "onCharacteristicWrite: sts=" + status);
    }

    @Override
    public void onConnectionStateChange(BluetoothGatt gatt, int status,
                      int newState) {
      super.onConnectionStateChange(gatt, status, newState);
      if (newState == BluetoothProfile.STATE_CONNECTED) {  // 接続完了
        mHandler.sendEmptyMessage(MSG_GATTCONNECTED);
      } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { // 切断完了
        mHandler.sendEmptyMessage(MSG_GATTDISCONNECTED);
      }
    }

    @Override
    public void onCharacteristicChanged (BluetoothGatt gatt,
                       BluetoothGattCharacteristic ch) {
      Log.d(TAG, "onCharacteristicChanged");
    }

    @Override
    public void onServicesDiscovered(BluetoothGatt gatt, int status) { // GATT サービス一覧取得完了
      super.onServicesDiscovered(gatt, status);

      // SwitchBot のユーザ定義サービス のユーザ定義キャラクタリスティック 2 のオブジェクトを取得
      BluetoothGattService svc = gatt.getService(mUuidSvcUser1);
      mChUser2 = svc.getCharacteristic(mUuidChUser2);

/**  すべての Services - Characteristics - Descriptors をログへ**
 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());
 }
 }
 }
**/
      mHandler.sendEmptyMessage(MSG_GATTGOTSERVICE);
    }
  };

  // SCAN イベントコールバック
  private class bleScanCallback extends ScanCallback {
    @Override
    public void onBatchScanResults(List<ScanResult> results) {
      super.onBatchScanResults(results);
      Log.d(TAG, "onBatchScanResults");
    }
    @Override
    public void onScanResult(int callbackType, ScanResult result) {
      super.onScanResult(callbackType, result);
      int rssi = result.getRssi();
      mBtDevice = result.getDevice();
      ScanRecord rec = result.getScanRecord();
      String addr = mBtDevice.getAddress();
      if (!addr.equals(TARGET_ADDR)) {
        return;
      }
      mTvAddr.setText("ADDRESS\n" + TARGET_ADDR);
      mTvRssi.setText("RSSI\n" + rssi);
      mHandler.sendEmptyMessage(MSG_FOUNDDEVICE);
    }
    @Override
    public void onScanFailed(int errorCode) {
      super.onScanFailed(errorCode);
      Log.e(TAG, "onScanFailed: err=" + errorCode);
    }
  }

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Log.d(TAG, "onCreate");
    mCtx = this;
    setContentView(R.layout.activity_main);
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);
    mTvAddr = (TextView) findViewById(R.id.tvAddr);
    mTvRssi = (TextView) findViewById(R.id.tvRssi);
    mButtonDisconn = (Button)findViewById(R.id.buttonDisconn);
    mButtonConn = (Button)findViewById(R.id.buttonConn);
    mButtonTurnOn = (Button)findViewById(R.id.buttonTurnOn);
    mButtonTurnOff = (Button)findViewById(R.id.buttonTurnOff);
    mButtonPress = (Button)findViewById(R.id.buttonPress);
    mButtonDisconn.setOnClickListener(this);
    mButtonConn.setOnClickListener(this);
    mButtonTurnOn.setOnClickListener(this);
    mButtonTurnOff.setOnClickListener(this);
    mButtonPress.setOnClickListener(this);
    setButtonsVisibility(false);
    setButtonsEnabled(false);
    setTvColor(Color.LTGRAY);

    mHandler = new Handler(this);

    /***** add for Android 6.0 or later ****/
    // https://developer.android.com/training/permissions/requesting.html
    // https://developer.android.com/topic/libraries/support-library/index.html#backward
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) !=
        PackageManager.PERMISSION_GRANTED) {
      ActivityCompat.requestPermissions(this,
          new String[]{Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
    }

    // 端末の Bluetooth アダプタへの参照を取得
    mBtAdapter = ((BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE)).getAdapter();

    if (mBtAdapter == null) {
      // Bluetooth サポートなし
      showDialogMessage(this, "Device does not support Bluetooth.", true); // finish
    } else if (!mBtAdapter.isEnabled()) {
      // Bluetooth 無効状態
      Log.d(TAG, "Bluetooth is not enabled.");
      // 有効化する
      Intent it = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
      startActivityForResult(it, REQ_ENABLE_BT);
    } else {
      mBtScanner = mBtAdapter.getBluetoothLeScanner();
      if (!mBtAdapter.isMultipleAdvertisementSupported()) {
        showDialogMessage(this, "isMultipleAdvertisementSupported NG.", true); // finish
      } else {
        // スキャン開始
        mHandler.sendEmptyMessage(MSG_DOSCAN);
      }
    }
  }

  @Override
  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    Log.d(TAG, "onActivityResult");
    switch (requestCode) {
      case REQ_ENABLE_BT:
        // Bluetooth 有効化 OK
        if (resultCode == Activity.RESULT_OK) {
          Log.d(TAG, "REQ_ENABLE_BT OK");
          // スキャン開始
          mHandler.sendEmptyMessage(MSG_DOSCAN);
        } else {
          Log.d(TAG, "REQ_ENABLE_BT Failed");
          mHandler.sendEmptyMessage(MSG_ERROR); // finish
        }
        break;
    }
  }

  @Override
  public void onStart() {
    super.onStart();
    Log.d(TAG, "onStart");
    Action viewAction = Action.newAction(
        Action.TYPE_VIEW, // TODO: choose an action type.
        "Main Page", // TODO: Define a title for the content shown.
        // TODO: If you have web page content that matches this app activity's content,
        // make sure this auto-generated web page URL is correct.
        // Otherwise, set the URL to null.
        Uri.parse("http://host/path"),
        // TODO: Make sure this auto-generated app deep link URI is correct.
        Uri.parse("android-app://jp.klab.SwitchBot01/http/host/path")
    );
  }

  @Override
  public void onStop() {
    super.onStop();
    Log.d(TAG, "onStop");
  }

  @Override
  public void onDestroy() {
    super.onDestroy();
    Log.d(TAG, "onDestroy");
    // GATT 接続終了
    if (mBtGatt != null) {
      mBtGatt.disconnect();
      mBtGatt.close();
      mBtGatt = null;
    }
    // スキャン停止
    if (mBtScanner != null) {
      mBtScanner.stopScan(mScanCallback);
      mBtScanner = null;
    }
  }

  @Override
  public void onClick(View v) {
    if (v == (View)mButtonConn) {
      mHandler.sendEmptyMessage(MSG_GATTCONNECT);
    } else if (v == (View)mButtonTurnOn) {
      mHandler.sendEmptyMessage(MSG_SW_ON);
    } else if (v == (View)mButtonTurnOff) {
      mHandler.sendEmptyMessage(MSG_SW_OFF);
    } else if (v == (View)mButtonPress) {
      mHandler.sendEmptyMessage(MSG_SW_PRESS);
    } else  if (v == (View)mButtonDisconn) {
      mHandler.sendEmptyMessage(MSG_GATTDISCONNECT);
    }
      return;
  }

  @Override
  public boolean handleMessage(Message msg) {
    switch (msg.what) {
      case MSG_DOSCAN: // スキャン開始
        Log.d(TAG, "msg: MSG_DOSCAN");
        ScanSettings scanSettings = new ScanSettings.Builder().
                setScanMode(SCAN_MODE).build();
        mBtScanner.startScan(null, scanSettings, mScanCallback);
        break;

      case MSG_STOPSCAN: // スキャン停止
        Log.d(TAG, "msg: MSG_STOPSCAN");
        mBtScanner.stopScan(mScanCallback);

        break;

      case MSG_FOUNDDEVICE: // ペリフェラルのアドバタイズパケットを検出
        Log.d(TAG, "msg: MSG_FOUNDDEVICE");
        mHandler.sendEmptyMessage(MSG_STOPSCAN);
        setTvColor(Color.BLACK);
        setButtonsVisibility(true);
        break;

      case MSG_GATTCONNECT: // デバイスへの接続を開始
        Log.d(TAG, "msg: MSG_GATTCONNECT");
        showProgressMessage(getString(R.string.app_name), "デバイスへ接続中・・・");
        mBtGatt = mBtDevice.connectGatt(mCtx, false, mGattCallback);
        mBtGatt.connect();

        break;

      case MSG_GATTCONNECTED: // デバイスへの接続が完了
        Log.d(TAG, "msg: MSG_GATTCONNECTED");
        setTvColor(Color.LTGRAY);
        // デバイスの GATT サービス一覧の取得へ
        mBtGatt.discoverServices();
        break;

      case MSG_GATTGOTSERVICE: // デバイスの GATT サービス一覧取得完了
        Log.d(TAG, "msg: MSG_GATTGOTSERVICE");
        if (mProgressDlg != null) {
          mProgressDlg.cancel();
          mProgressDlg = null;
        }
        setButtonsEnabled(true);
        break;

      case MSG_GATTDISCONNECT: // デバイスとの切断
        Log.d(TAG, "msg: MSG_GATTDISCONNECT");
        mBtGatt.disconnect();
        break;

      case MSG_GATTDISCONNECTED: // デバイスとの切断完了
        Log.d(TAG, "msg: MSG_GATTDISCONNECTED");
          setButtonsEnabled(false);
          if (mBtGatt != null) {
            mBtGatt.close();
            mBtGatt = null;
          }
          showDialogMessage(mCtx, "デバイスとの接続が切断されました", false);
          mHandler.sendEmptyMessage(MSG_DOSCAN);
        break;

      case MSG_SW_ON: // Turn On
        Log.d(TAG, "msg: MSG_SW_ON");
        mChUser2.setValue(mCmdSwOn);
        mBtGatt.writeCharacteristic(mChUser2);
        break;

      case MSG_SW_OFF: // Turn Off
        Log.d(TAG, "msg: MSG_SW_OFF");
        mChUser2.setValue(mCmdSwOff);
        mBtGatt.writeCharacteristic(mChUser2);
        break;

      case MSG_SW_PRESS: // PRESS
        Log.d(TAG, "msg: MSG_SW_PRESS");
        mChUser2.setValue(mCmdSwPress);
        mBtGatt.writeCharacteristic(mChUser2);
        break;

      case MSG_ERROR:
        showDialogMessage(this, "処理を継続できないため終了します", true);
        break;
    }
    return true;
  }

  @Override
  public void run() {
  }

  // ダイアログメッセージ
  private void showDialogMessage(Context ctx, String msg, final boolean bFinish) {
    new AlertDialog.Builder(ctx).setTitle(R.string.app_name)
        .setMessage(msg)
        .setPositiveButton("OK", new DialogInterface.OnClickListener() {
          public void onClick(DialogInterface dialog, int whichButton) {
            if (bFinish) {
              finish();
            }
          }
        }).show();
  }

  // プログレスメッセージ
  private void showProgressMessage(String title, String msg) {
    if (mProgressDlg != null) {
      return;
    }
    mProgressDlg = new ProgressDialog(this);
    mProgressDlg.setTitle(title);
    mProgressDlg.setMessage(msg);
    mProgressDlg.setProgressStyle(ProgressDialog.STYLE_SPINNER);
    mProgressDlg.show();
  }
  private void setButtonsEnabled(boolean isConnected) {
    mButtonConn.setEnabled(!isConnected);
    mButtonTurnOn.setEnabled(isConnected);
    mButtonTurnOff.setEnabled(isConnected);
    mButtonPress.setEnabled(isConnected);
    mButtonDisconn.setEnabled(isConnected);
  }

  private void setButtonsVisibility(boolean visible) {
    int v = (visible)? View.VISIBLE : View.INVISIBLE;
    mButtonConn.setVisibility(v);
    mButtonTurnOn.setVisibility(v);
    mButtonTurnOff.setVisibility(v);
    mButtonPress.setVisibility(v);
    mButtonDisconn.setVisibility(v);
  }

  private void setTvColor(int color) {
    mTvRssi.setTextColor(color);
    mTvAddr.setTextColor(color);
  }
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="jp.klab.SwitchBot01">
  <uses-permission android:name="android.permission.BLUETOOTH"></uses-permission>
  <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"></uses-permission>
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"></uses-permission>
  <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
  <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="portrait"
      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>

ESP32 で操作してみる

Android アプリからの操作を確認したところで、次にマイコン方面からのコントロールを試すことにしました。 手元にある BLE モジュールの中で BLE セントラル機能に対応しているのは ESP32 ボードの ESP-WROOM-32 (秋月版) だけなので選択の余地なくこれを使います。

ESP32 用の BLE セントラルコードは以前 ESP-IDF ベースで何度か作成したことがありますが、生産性は Arduino IDE + Arduino core for the ESP32 環境のほうが高いためできれば後者を利用したいと考えました。

幸い、ESP 界隈で著名な Neil Kolban さんが ESP32 Adruino core 用の高機能な BLE ライブラリ「ESP32 BLE for Arduino」を 2017年9月より公開していることを知り、さっそく examples ディレクトリ下の BLE_client.ino のコードを下敷きにプログラムを実装してみました。 プログラム起動時に BLE ペリフェラルからのアドバタイズパケットをスキャンし、自分の SwitchBot を見つけたら接続を確立して「Press」コマンドを一度発行する簡潔な内容としました。
動作の様子とソースコードです。動画ではボード上のリセットボタンを三度押下しています。

動画:22秒 音量注意

BLE_SwitchBot01_BLE.ino

/**
 * 
 * BLE_SwitchBot01_BLE.ino  (for ESP32)
 * 
 * BLE セントラル. SwitchBot からのアドバタイジングを検出したら
 * 一度だけ同 GATT サーバへ接続〜所定のキャラクタリスティックへ
 * Press コマンドを書き込んでアームを動かす.
 * 
 * Arduino core for ESP32 向けの Neil Kolban 氏による
 * 下記 BLE ライブラリを使用
 * https://github.com/nkolban/ESP32_BLE_Arduino
 *
 */

#include "BLEDevice.h"
#include <HardwareSerial.h>

// 手元の SwitchBot のアドレス
static String addrSwitchBot = "c0:65:9a:7d:61:e1";
// SwitchBot のユーザ定義サービス
static BLEUUID serviceUUID("cba20d00-224d-11e6-9fb8-0002a5d5c51b");
// 上記サービス内の対象キャラクタリスティック
static BLEUUID    charUUID("cba20002-224d-11e6-9fb8-0002a5d5c51b");
// SwitchBot の Press コマンド
static uint8_t cmdPress[3] = {0x57, 0x01, 0x00};

static BLEAddress *pGattServerAddress;
static BLERemoteCharacteristic* pRemoteCharacteristic;
static BLEClient*  pClient = NULL;
static boolean doSendCommand = false;

// BLEDevice::init() でのシリアルポート混乱対策
static HardwareSerial hs2(2);

void dbg(const char *format, ...) {
  char b[512];
  va_list va;
  va_start(va, format);
  vsnprintf(b, sizeof(b), format, va);
  va_end(va);
  hs2.print(b);
}

// アドバタイズ検出時のコールバック
class advdCallback: public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    dbg("BLE device found: ");
    String addr = advertisedDevice.getAddress().toString().c_str();
    dbg("addr=[%s]\r\n", addr.c_str());
    // SwitchBot を発見
    if (addr.equalsIgnoreCase(addrSwitchBot)) {
      dbg("found SwitchBot\r\n");
      advertisedDevice.getScan()->stop();
      pGattServerAddress = new BLEAddress(advertisedDevice.getAddress());
      doSendCommand = true;
    }
  }
};

void setup() {
  hs2.begin(115200, SERIAL_8N1, 16, 17);
  Serial.begin(115200);
  dbg("start");
  // BLE 初期化
  BLEDevice::init("");
  // デバイスからのアドバタイズをスキャン
  BLEScan* pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new advdCallback());
  pBLEScan->setActiveScan(true);
  pBLEScan->start(30); 
}

void loop() {
  if (doSendCommand == true) {
    if (connectAndSendCommand(*pGattServerAddress)) {
    } else {
      dbg("connectAndSendCommand failed");
    }
    doSendCommand = false;
    dbg("done");
  }
  delay(1000);
}

// SwitchBot の GATT サーバへ接続 〜 Press コマンド送信
static bool connectAndSendCommand(BLEAddress pAddress) {
  dbg("start connectAndSendCommand");
  pClient  = BLEDevice::createClient();

  pClient->connect(pAddress);
  dbg("connected\r\n");

  // 対象サービスを得る
  BLERemoteService* pRemoteService = pClient->getService(serviceUUID);
  if (pRemoteService == nullptr) {
    dbg("target service not found\r\n");
    return false;
  }
  dbg("found target service\r\n");

  // 対象キャラクタリスティックを得る
  pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID);
  if (pRemoteCharacteristic == nullptr) {
    dbg("target characteristic not found");
    return false;
  }
  dbg("found target characteristic\r\n");

  // キャラクタリスティックに Press コマンドを書き込む
  pRemoteCharacteristic->writeValue(cmdPress, sizeof(cmdPress), false);

  delay(3000);
  if (pClient) {
    pClient->disconnect();
    pClient = NULL;
  }
  return true;
}

SwitchBot をインターネットごしに操作する

ESP32 といえば・・

上記のように ESP32 で適切に SwitchBot を操作可能であることが確認できました。そこでふと、ここにインターネットアクセスの要素を絡めることを思いつきました。 ESP32 が備えている BLE 機能と WiFi 機能をパラレルに利用できることは以前の試みを通じて確認ずみです。そのため「インターネットごしに所定のメッセージを受信したら SwitchBot を作動させる」ことができるのではないかと考えました。もちろん SwitchBot 公式のインターネットハブ製品である「SwitchBot Hub」のように高機能なものには到底及ばないにしても、ESP32 を使えば最低限の遠隔操作は実現できそうです。

道具立て

インターネット経由での ESP32 へのメッセージングには MQTT を利用することにしました。

CloudMQTT

MQTT ブローカーは最近 Beebotte が流行っているようですが、いくつかのサービスを試し今回はもっとも手に馴染んだ「CloudMQTT」を選びました。無料枠も手元の要件には十分です。

HTTP to MQTT bridge

ノーマルな HTTP リクエストの体裁でメッセージを Publish できれば何かと便利です。Beebotte とは違い現時点で CloudMQTT に用意されている REST API は管理用のもののみですが、petkov さんによる「HTTP to MQTT bridge」の存在とこれを Heroku から利用できることを知りました。ちなみに、Heroku には CloudMQTT 用のアドオンも用意されていますが、ざっと説明を読んだところでは単に CloudMQTT 上のインスタンスを呼び出す内容のようで HTTP to MQTT bridge の利用と比べ際立ったメリットが見えないことと、また、Heroku でアドオンを利用するにはクレジットカード登録が必要である点が若干微妙でもあり今回は利用を見合わせました。

IFTTT

HTTP クライアントから ESP32 へメッセージを送る分には以上の準備で事足ります。一方、SwitchBot のメインのキーワードである「スマートホーム」にはこのところ「スマートスピーカー」がぴたりと寄り添っている感がありますね。なので、お約束どおり(?)手元の Google Home Mini 用に IFTTT アプレットを用意しました。トリガーには Google Assistant サービスを、アクションには Webhooks サービス(旧 Maker チャネル)を割り当て、後者へ上のブリッジへのリクエストを設定します。

コードサイズの超過と対処

引き続き Nick O'Leary さんによる Arduino Client for MQTT ライブラリを利用して CloudMQTT に対向しメッセージの Publish / Subscribe を行うシンプルなスケッチを作成、ESP32 上で問題なく動作することを確認しました。 これと前掲の BLE セントラルプログラムのふたつを軸として結合し細かい処理を書き加えれば完成でしょう。

まずはラフに結合してみました。すると、コンパイルは通ったものの ESP-WROOM-32 のフラッシュメモリへの書き込みでエラーが発生しました。コードサイズの超過です。

最大1310720バイトのフラッシュメモリのうち、スケッチが1568826バイト(119%)を使っています。

最大294912バイトのRAMのうち、グローバル変数が70452バイト(23%)を使っていて、ローカル変数で224460バイト使うことができます。
スケッチが大きすぎます。http://www.arduino.cc/en/Guide/Troubleshooting#size には、小さくするコツが書いてあります。
ボードESP32 Dev Moduleに対するコンパイル時にエラーが発生しました。

あらためて元のふたつのスケッチのコードサイズを確認してみると、内容的にはシンプルな BLE プログラム側が単体で書き込み可能容量の 94%にも及んでいました。ESP32 BLE for Arduino ライブラリのボリュームの大きさが窺えます。

最大1310720バイトのフラッシュメモリのうち、スケッチが1237529バイト(94%)を使っています。

以下を試しました。

  • Arduino 公式のガイダンスとネット上の情報を参考にコーディングレベルでの対処を実施
    • 結果:数10KB程度の削減に留まった
  • ESP32 BLE for Arduino のアーカイブページから、もっともスリムな初版の ESP32_BLE_Arduino-0.1.0.zip(2017-09-10) へのバージョンダウンを試す
    • 結果:すでに現行の Arduino core for the ESP32 とは互換性がなくコンパイルエラーが多発。手元で随所に手を加え最終的にビルド可能となったが当該スケッチのコードサイズは書き込み可能容量対比 115% 程度までにしか収まらず
  • ネット上の情報にそってフラッシュメモリ上の書き込み可能領域を増やしてみる
    • 結果:パーティションテーブルの編集によりコードの書き込みには成功した。だがプログラムは正常に動作せずリブートを繰り返した

現時点では他にこれといった対策も見当たらず Arduino IDE 環境での開発はここで一旦断念しました。

ESP-IDF 環境への移行 〜 完成

こういった経緯を経てネイティブの ESP-IDF 環境へ移行しプログラムを書き直しました。 Espressif はコアライブラリを頻繁に更新しており API の後方互換性が必ずしも維持されないケースのあることが気がかりではありますが、その点は現在もなお活火山状態にある ESP32 向けの開発の宿命と割り切るべきでしょう。

準備

まず ESP-IDF をこの時点での最新版へアップデートしました。

~/esp/esp-idf$ git describe
v3.0-dev-2561-g358c822

MQTT ライブラリは Tuan さんによる「ESP32 MQTT Library」を使用することに。README の記述にそって <ESPIDF>/components/espmqtt へ Git サブモジュールとして一式を追加しました。

処理の流れ

プログラムは以下の内容としました。

  1. WiFi 接続を確立
  2. MQTT セッションの開始
  3. トピック "msg" の メッセージ "1" を受信したら以下の BLE セントラル処理を発動
    • アドバタイズパケットのスキャンを実行
    • 手持ちの SwitchBot からのアドバタイズを検知したらスキャンを停止して接続を確立
    • SwitchBot のサービス一覧の取得を開始
    • サービス cba20d00-224d-11e6-9fb8-0002a5d5c51b を発見したらその配下のハンドルの範囲を記憶
    • サービス一覧の取得が完了したら上記サービス配下のキャラクタリスティック cba20002-224d-11e6-9fb8-0002a5d5c51b のハンドルを取得
    • 上記キャラクタリスティックへ Press コマンド {0x57, 0x01, 0x00} を書き込んで切断
  4. 上記 3. へ

動作の様子

動画:34秒 音量注意 (※ ウェイクワード「ねえ, グーグル」の発声もあります)

ソースコード


(tanabe)
klab_gijutsu2 at 04:44
この記事のURLComments(0)IoT | Bluetooth
2018年04月27日

最近のPython-dev(2018-04)

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

バックナンバー:

Python 3.7 がベータになり、大きな変更はなく安定期に入りました。 その間、Python の言語自体やエコシステムに関して重要な話題が幾つかありました。


pypi.python.org から pypi.org

長年 Python のエコシステムを支えてくれていた PyPI がリニューアルしました。

Python 3 への移行を始めとしてモダン化され、 Markdown で書いた README をレンダリングできるようになるなどの改善も入っています。

IRC から Zulip chat へ

freenode に python-dev という IRC チャンネルがあるのですが、新しい貢献者がコミュニケーションを取るのに今更IRCを使うのはハードルが高いんじゃないか。Slack や Discord みたいなモダンな環境を試してみないかというメールを投げた所、 Zulip を試用することになりました。

今のところは python-dev とその周辺に用途が限定されていますが、結果が好評なら将来的にもう少し広い用途にも開放されるかもしれません。

個人的には、UIやモバイルアプリの完成度は Slack や Discord にかなわないものの、サブチャンネル的な topic という機能が便利で、複数の話題が並列したときに1つの話題に集中でき、なおかつ Slack のスレッドのように議論が見えにくくなることもないという点にメリットを感じています。

RHEL 7.5 が Python 2 を deprecate

RHEL 7.5 がリリースされ、リリースノートで Python 2 が deprecate されました。つぎのメジャーバージョン (RHEL 8?) で Python 2 が削除され Python 3 だけがサポートされるようです。

Ubuntu 18.04 LTS の方は Python 2 を main リポジトリから外せませんでしたが、 20.04 LTS までには外すでしょうから、(サポート終了後のマシンが一部残るものの) Python 2 を使った環境はだいたい 2025 年ごろにリタイアすることになると思われます。

PEP 394 アップデート

PEP 394 -- The "python" Command on Unix-Like Systems

この PEP は、 Python 2 と3 の非互換による苦痛を緩和するためのコマンド名とshebangについてのガイドラインを提供しています。大雑把に言うと次のようになっています。

  • Python 2 が利用可能なら python2 コマンドを、 Python 3 が利用可能なら python3 コマンドを用意する。
  • python コマンドは python2 コマンドと同じ Python を起動するようにする。
  • Python 2 にしか対応しないスクリプトは shebang で python2 を使う。 Python 3 にしか対応しないスクリプトは shebang で python3 を使う。両対応のスクリプトが shebang に python を使う。

これはあくまでもガイドラインであり、 Gentoo や Arch のように python コマンドがデフォルトで Python 3 になっているディストリビューションもあるし、 macOS は python2 コマンドを用意してくれませんが、それでも Debian, Ubuntu, Fedora, Red Hat の開発者がこのガイドラインにより足並みを揃えてくれるだけでも無いよりはマシです。

さて、このガイドラインは時々更新されることになっていて、 Fedora / RHEL が Python 2 を捨てるのに合わせてどうするかが話題になりました。現在の Guido の考えはだいたいこんな感じです。

  • Python 2.7.10 が出ると共に、「2桁バージョン嫌い」を克服したので、Python 3.9 の次は多分 3.10 になる。 "python3" というコマンド名を使い続けるのに問題はない。 Python 4 は GIL の卒業といった大きな変更が入るときになるだろう。
  • (Dropbox では) まだ Python 2 と 3 の両方を使っているので、 "python" が常に Python 2 であって欲しい。
  • "python" コマンドが存在しないのは問題ない。
  • Python 3 で venv を作ったときに python というコマンドをオーバーライドしてしまうのは間違いだった。

Python 2 を捨てるのを機に "python" コマンドを Python 3 にしたかった Fedora / Red Hat のメンテナにとっては水を差された形です。

一方ですでに "python" コマンドが Python 3 になっているディストリや venv は現存するので、強引に "python" コマンドが Python 3 を指すことを禁止することもしません。結論として、今回のアップデートの大きな変更点は次のような変更になります。

  • "python" コマンドは存在しなくて良い (存在する場合は、今まで通り "python2" コマンドと同一であるべき)
  • (この PEP が有効な期間中に) "python" コマンドが "python3" になることを期待させる段落を削除

ということで、「早くどこでも python というコマンドが Python 3 にならないかなー」という希望は捨てて、 "python3" が正式な推奨されるコマンド名なんだということに慣れたほうが良さそうです。

PEP 572 -- Assignment Expressions

Python はずっと代入を「文 (statement)」としてきましたが、新しく := という演算子で代入式を追加しようという提案です。

例えば次のようなコードが書けるようになります。

# 572 があるとき
if m := re.match(pat, s):
    # do something with m

# ないとき
m = re.match(pat, s)
if m:
    # do something with m

メールの量が膨大すぎるのと娘の誕生日があったので完全には議論を追えてないんですが、最初のモチベーションはリスト内包表記で値の再利用をしたいということだったと思います。

# foo() を2回計算してしまっている
ys = [foo(x) for x in xs if foo(x) is not None]

# 一回でやりたい
ys = [z for x in xs if (z := foo(x)) is not None]

内包表記に限った構文拡張や、新たにブロックスコープを追加するなど、いろんなアイデアが出ましたが、シンプルさと便利さのバランスで今の提案に落ち着きました。

とはいえ、 Python はずっと「代入は文」という制約と共にあった言語なので、この提案は制約がもたらす可読性を失わせるものでもあります。

今までは複雑な式を読み飛ばしても代入を見逃す危険は無かったのが、PEP 572 が承認された場合は、自分が代入を見逃していないか疑いながらコードを読む必要が出てきます。

なので「追加されれば便利に使うだろうけれども今の段階では +1 はしない」という慎重派も多いですし、反対の人もいます。私も慎重派の一人で、メリットを質だけじゃなくて量(頻度)でも説明してほしいなと思っています。

参照実装もあるので興味のある人は試してみてください。(でもMLでの議論に参加するときは冷静にお願いします。)


@methane

songofacandy at 12:40
この記事のURLComments(0)Python 
Blog内検索
Archives
このブログについて
DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
最新コメント
Syndicate this site