いっぺーちゃんの いろいろやってみよ~

REST to MQTTブリッジを作る(その2)

前回の続きです

アプリケーション本体の説明です。

 

MQTTブローカに関するパラメータです。設定できるパラメータについてはここを参照してください。
ここではlocalhostのデフォルトポート(1883)でanonymous接続可能なMQTTブローカを設定しています。
MQTT通信にはMQTT.js(https://www.npmjs.com/package/mqtt)を使用します。

// ======== MQTT settings =====================================================
var mqtt_opt = {protocol: 'mqtt', host:'localhost'};    // 必要なら port, username, password などを追加
// 本番環境はこちら
// var mqtt_opt = {protocol: 'mqtt', host: '<<アドレス>>', port: <<ポーt番号>>, username: '<<ユーザ名>>', password: '<<パスワード>>'};
・・・
// ==== MQTT ==================================================================
var mqtt = require('mqtt');
var mqtt_client  = mqtt.connect(mqtt_opt);

 

ちょっと順番前後しますが、
MQTTの接続時のイベントハンドラです。特に処理することはありませんが、接続時に接続情報を表示しています。
本来なら、エラー時の処理とかリトライ処理とかいろいろ入れないといけない気がしますが、今回は端折りました

// MQTT 接続時の処理
mqtt_client.on('connect', function (connack) {
  console.log('MQTT client connected to brocker');
  console.log("    host: " + mqtt_opt.host);
  console.log("    port: " + mqtt_opt.port);
});

 

Webサーバのポート番号を指定します。
PaaSの場合、システムで指定されたポート番号を使用する必要があるため、そちらが指定されていたらそちらを優先して使用します。
Herokuの場合、システムで指定されたポート番号は環境変数PORTに設定されています。

// ======== WebServer settings ================================================
var webservicePort = 8080;
・・・
var port = process.env.PORT || webservicePort;

 

Webサーバはnode.jsのWebアプリケーションフレームワークであるexpress(http://expressjs.com/ja/)を使用します。
これを使うとかなりお手軽にWebアプリを記述できます。

// ==== express ===============================================================
var express = require('express');
var app = express();

 

POST メソッドで送られてくるbody部のパーサです。
通常の使用ではapp.use()で、すべてのendpointを共通で処理するパーサとして登録するのですが、今回はイリーガルなJSONデータを処理したいので共通処理は登録しません。

// ==== body parser (POSTを使うときのお約束) ==================================
var bodyParser = require('body-parser');
// 個別にパーサを設定するので、app.useで登録しない
// app.use(bodyParser.urlencoded({extended: false}));
// app.use(bodyParser.json());
// app.use(bodyParser.text({type:"*/*" }));        // すべてのコンテンツタイプでテキストパーサを使う

 

ちょっと順番前後しますが、
Webサーバの開始処理です。これ一行だけ。

// Webサーバのスタート
app.listen(port);
console.log('Server started! listen Port: ' + port);

 

QoSの設定値として送られてきたデータのチェック関数です。
0、1、2のみ設定可能なので、それ以外の値や文字列等はすべて0にします。

// QoS設定値の確認
function checkQoS(targetValue) {
    tmp = parseInt(targetValue);
    if (isNaN(tmp))             return 0;     // 数値でないなら0を返す
    if ((tmp < 0) || (tmp > 2)) return 0;     // 範囲外なら0を返す
    return tmp;
}

 

ブール値(false/true)、数値(0/0以外;数値に変換できる文字列を含む)、文字列("true"以外/"true")をブール値(false/true)に変換する関数です。
最初、Boolean(targetValue)とやってたら文字列"false""0"trueになってしまいハマりました。。。

// 文字列"true"/"false" をbooleanに変換する関数
// Boolean(targetValue)だと文字列"false"や"0"がtrueになってしまうので
function parseBoolean(targetValue) {
    var targetType = typeof targetValue;  // 変数の型
    if      (targetType == 'boolean')                           return targetValue;             // booleanならそのまま返す
    else if (targetType == 'number')                            return Boolean(targetValue);    // 数値ならbooleanに変換して返す
    else if (targetType == 'string' || targetValue instanceof String ) {    // 文字列で
        var tmp = parseInt(targetValue);    // 数値に変換してみる
        if (isNaN(tmp)) {
            // 数値に変換できない
            if (targetValue.toLowerCase().trim() == 'true')     return true;                    // 'true' なら trueを返す
            else                                                return false;                   // それ以外はfalseを返す
        }
        else                                                    return Boolean(tmp);            // 数値をbooleanに変換して返す
    }
    else                                                        return false;                   // それ以外の型はfalseを返す
}

 

トップページの表示処理です。
使い方とテストページへのリンクを表示しています。
ejsを使えばよかった気もしますが、この程度の表示なので、直接sendしてしまいました。

// GET rootの処理
app.get('/', function (req, res) {
  // console.log(req);
  var body = 'usage: <br />';
  var server_addr = req.protocol + '://' + req.headers.host;
  body += server_addr + '/mqtt_get/&lt;topic&gt;/&lt;payload&gt;/&lt;QoS: 0-2&gt;/&lt;retain: true or false&gt;<br />';
  body += server_addr + '/mqtt_query?topoc=&lt;topic&gt;&payload=&lt;payload&gt;qos=&lt;QoS: 0-2&gt;&retain=&lt;retain: true or false&gt;<br />';
  body += server_addr + '/mqtt_post<br />';
  body += '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;';
  body += '{"topic":&lt;topic&gt;,"payload:"&lt;payload&gt;,"qos":&lt;QoS: 0-2&gt;,"retain":&lt;retain: true or false&gt;} <br />';
  body += '<h1><a href="test.html">test</a></h1><br />';
  res.send(body);
});

 

今回は勉強のため、3種類のendpointを用意しました。

まず一つ目。MQTTへ転送するパラメータをGETメソッドのpathとして受け取るendpointです。
/mqtt_get/<<topic>>/<<payload(message)>>/<<qos>>/<<retain>>
として指定します。
例えば、topic="topic1"payload="hello"qos=0retain=false の場合、
/mqtt_get/topic1/hello/0/false
となります。
パラメータはpathとして送られてくるので、URLエンコードされています。
topicとpayloadはURLデコードして使用します。
qosとretainはURLデコードしないといけないような値だと不正値でデフォルト値になるので、ここではデコードしていません。
受け取ったパラメータをレスポンス送信とMQTT publishの共通処理部に渡しています。
この方法はお手軽なのですが、パラメータを省略することができません。
また、topicに階層を指定できません。例えば、階層にtopic1/topicAと指定しようとすると
/mqtt_get/topic1/topicA/hello/0/false
となってしまい、topicAがpayload、helloqos、・・・と認識されてしまいこのendpointにヒットしません。結果、Not Foundエラー(404)が返ります。

// GET mqtt_get endpoint の処理
app.get('/mqtt_get/:topic/:payload/:qos/:retain', function(req, res) {
    // endpointのPATHからパラメータ取り出し
    var mqttTopic   = decodeURI(req.params.topic);
    var mqttPayload = decodeURI(req.params.payload);
    var mqttQos     = req.params.qos;
    var mqttRetain  = req.params.retain;
    var userIP      = req.socket.remoteAddress;

    // console.log(req);    // デバッグ用

    // レスポンス送信 & MQTT publish処理
    responseCommon(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP);
});

テストページではgetボタンをクリックするとこのリクエストが送信されます。

 

二つ目。MQTTへ転送するパラメータをGETメソッドのクエリ文字列(query string)として受け取るendpointです。
/mqtt_query?topic=<<topic>>&payload=<<payload(message)>>&qos=<<qos>>&retain=<<retain>>
として指定します。qosとretainは省略可能です。
例えば、topic="topic1"payload="hello"qos=0retain=false の場合、
/mqtt_query?topic=topic1&payload=hello&qos=0&retain=false
となります。
パラメータはquery stringとして送られてくるので、URLエンコードされています。
topicとpayloadはURLデコードして使用します。
qosとretainはURLデコードしないといけないような値だと不正値でデフォルト値になるので、ここではデコードしていません。
qosとretainは省略されていると、ここではundefinedになっているので、以降の処理でデフォルト値に設定されます。
受け取ったパラメータをレスポンス送信とMQTT publishの共通処理部に渡しています。

// GET mqtt_query endpoint の処理
app.get('/mqtt_query', function(req, res) {
    // queryからパラメータ取り出し
    var mqttTopic   = decodeURI(req.query.topic);
    var mqttPayload = decodeURI(req.query.payload);
    var mqttQos     = req.query.qos;
    var mqttRetain  = req.query.retain;
    var userIP      = req.socket.remoteAddress;

    // console.log(req);    // デバッグ用

    // レスポンス送信 & MQTT publish処理
    responseCommon(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP);
});

テストページではqueryボタンをクリックするとこのリクエストが送信されます。

 

三つ目。MQTTへ転送するパラメータをPOSTメソッドで受け取るendpointです。今回のメインです。
/mqtt_post
でパラメータをメッセージボディで
{ "topic":"<<topic>>", "payload": "<<payload(message)>>", "qos": 0, "retain": <<retain>> }
として指定します。
例えば、topic="topic1"payload="hello"qos=0retain=false の場合、
{ "topic":"topic1", "payload": "hello", "qos": <<qos>>, "retain": false }
となります。
app.postメソッドは第一パラメータのパスに対するPOSTリクエストを受け取ったときに、第二パラメータ以降のコールバック関数を順番に実行していきます。
そこで、第二パラメータにボディパーサを指定し、第三パラメータにユーザ処理を指定しています。
メッセージボディはJSONデータで送られてくるので、ボディパーサには通常JSONパーサ(bodyParser.json())を指定するのですが、それだと改行コードが含まれていたときにエラーになってしまい、今回の目的を達成できません。
そこで今回はテキストパーサ(bodyParser.text())を使用して一旦文字列に変換し、ユーザ処理でJSON文字列をデコードすることにしました。
また、bodyParser.text()はパラメータを省略すると、Content-typeが"text/plain"のリクエストのみ処理することになってしまうので、すべてのContent-typeを処理するように{type:"*/*" }を指定しています。
処理としてはtextとして取り出されたbodyの改行コード(\n)を文字列"\\n"に置換してからJSON.parse()でデコードしてパラメータを取り出しています。
ここはメッセージボディなのでURLエンコードはされていません。
(ちなみに、JSONパーサを使用した場合は、各パラメータはreq.body.パラメータ名に格納されます。)
受け取ったパラメータをレスポンス送信とMQTT publishの共通処理部に渡しています。

// POST mqtt_post endpoint の処理
// note: データに改行が含まれていてもエラーにならないように前処理を入れたいので一旦textで受け取る。
app.post('/mqtt_post', bodyParser.text({type:"*/*" }), function(req, res) {
    // bodyからパラメータ取り出し
/* *************
    // JSONパーサ(bodyParser.json())で受け取っていたらこちら
    var mqttTopic   = req.body.topic;
    var mqttPayload = req.body.payload;
    var mqttQos     = req.body.qos;
    var mqttRetain  = req.body.retain;
************* */
    // TEXTパーサ(bodyParser.text())で受け取っていたらこちら
    var body = req.body;                    // TEXTパーサの時はreq.bodyはString
    // console.log("BEFOR " + body);            // デバッグ用
    body = body.replace(/\n/g, "\\n");      // 改行(\n)を文字列の "\\n"に変更
    // console.log("AFTER " + body);            // デバッグ用
    jsn = JSON.parse(body);                 // JSONパーサ
    var mqttTopic   = jsn.topic;
    var mqttPayload = jsn.payload;
    var mqttQos     = jsn.qos;
    var mqttRetain  = jsn.retain;

    var userIP      = req.socket.remoteAddress;

    // console.log(req);    // デバッグ用

    // レスポンス送信 & MQTT publish処理
    responseCommon(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP);
});

テストページではpostボタンをクリックするとこのリクエストが送信されます。

 

それぞれのendpoint処理からコールされる、レスポンス送信とMQTT publishの共通処理部です。

// レスポンス送信共通処理 & MQTT publish処理
var responseCommon = function(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP) {      

最初にパラメータチェックです。
topicとpayload(message)は文字列でないか、空文字の場合はエラーとしてparam_checkedをfalseにします。
qosとretainは有効な値でなければデフォルト値を設定しています。

    // パラメータチェック
    param_checked = true;
    if (!_.isString(mqttTopic)   || _.isEmpty(mqttTopic))       param_checked = false;      // 必須パラメータ
    if (!_.isString(mqttPayload) || _.isEmpty(mqttPayload))     param_checked = false;      // 必須パラメータ
    mqttQos = checkQoS(mqttQos);                    // 数値化 & 範囲チェック
    mqttRetain = parseBoolean(mqttRetain);          // boolean化

レスポンスは送信後接続が切断されるようにHTTPヘッダに"Connection: close"を設定します。

    // レスポンス送信後、connectionを切断するための設定
    res.set("Connection", "close");

パラメータエラーがなければ(topicとpayloadが正常に設定されていれば)、レスポンスを送信し、
このレスポンスデータ自体には意味はありません。確認用に受け取った値を整形して送り返しています。

    if (param_checked) {
        // パラメータチェックOK
        var dt = moment().tz("Asia/Tokyo");                 // 現在時刻(リクエストパラメータの確認用)
        // レスポンスの作成
        var body = dt.format("YYYY/MM/DD(ddd) HH:mm:ss z\n");
        body += 'received request is ...\n';
        body += '  topic: '   + mqttTopic;
        body += '  payload: ' + mqttPayload;
        body += '  QOS: '     + mqttQos;
        body += '  retain: '  + mqttRetain;
        body += '  from: '    + userIP;
        body += '\n';
        // レスポンスの送信
        res.send(body);
        // console.log(body);      // デバッグ用

MQTTブローカへデータをpublishします。

        // MQTTへpublsh
        mqtt_client.publish(mqttTopic, mqttPayload, mqttQos);

なお、レスポンスに入れている時刻が不要なら、最初の方でモジュールを読み込んでいるvar moment = require('moment-timezone');の部分は不要です。

パラメータエラーがあれば(topicとpayloadが正常に設定されていなければ)、エラーステータス400(Bad request)でレスポンスを送信します。

    } else {
        // パラメータチェックNG
        var body = 'Parameter error';
        res.status(400);        // 400:Bad request
        res.send(body);
        // console.log(body);      // デバッグ用
    }
}

 

 htmlファイルをそのまま見せるファイルを送ディレクトリを指定します。
この設定ではpublicを指定していますが、public/test.htmlファイルはhttp://locahost:8080/public/test.html ではなく、http://locahost:8080/test.html でアクセスします。間違えないよう注意しましょう。

// serve static site
// publicの下にHTMLをそのまま見せるファイルを置く
app.use(express.static('public'));

 

外部のサーバ(Herokuなど)にdeployする方法はググってください。

最後にIFTTTでWebhooksに設定するパラメータはこんな感じです。

URL << http or https >> :// <<サーバアドレス>>/mqtt_post  
Method POST  
Content Type (optional) application/json どれでも同じだけど一応これで
Body (optional) {"topic":"topic1","payload":" {{Text}}"}  

 

 

REST to MQTTブリッジを作る(その1)

前回Twitterの改行を含む本文をIFTTTからbeebotte経由でESP32に送れなかったのが悔しかったので、どうにかして送れるようにしたいと考えました(←かなりの負けず嫌い)

まず、エラーになる原因を考察します。
といっても、ここにそのものズバリの答えがありました。
JSON文字列値に改行を埋め込むには、改行コードを文字列"\n" に置き換えないといけないのです。

 

IFTTTのレシピ内に文字列置換コマンドがあればイッパツで解決しますが、ないので他の手で。。。

Webhooksは送っているデータがJSONデータか否かを認識していないので、ここまでは問題なく動いているはずです。
データを受け取ったbeebotte側がJSONデータの規則に則っていないデータなので、エラーとしてはじいていると思われます。
ということは、JSONデータの規則に則っていない改行を含むJSONデータを受け取れるサーバがあれば良いということになります。

ということで、webhooksの送信先を自分で作ってしまおうと考えました。(←鳴かぬなら、作ってしまえホトトギス)
やはり、外部サーバからアクセスできるサーバである必要がありますが、PaaSを使えば作れそうです。
とはいえ、beebotteをそのまま置き換えるのはかなり重たいので(当然だ)、WebhooksからREST APIでデータを受け取って、既存のMQTTブローカに転送するREST to MQTT ブリッジの構成とします。

データの流れはこんな感じ。

    Twitter  → IFTTT(webhooks) → REST2MQTT(今回作るのはここ) → MQTTブローカ → ESP32

 

私はPaaSに無料で使えるHerokuを使いました。色々制限はありますが、ちょっと勉強のために試すくらいなら問題ないと思います。
Herokuの使い方はググってください(←他力本願)

ESP32とのインタフェースには今まで同様、MQTTを使用することにします。beebotteをそのまま使っても良いですし、test.mosquitto.orgなどの無料で使えるMQTTブローカーを使ったり、PaaSに付属しているMQTTブローカを使うこともできます。
私はHerokuのCloudMQTTというのを使いましたが、これを使うには無料プランでもクレジットカードの登録が必要なようで、ちょっと躊躇してしまいました。

  MQTTまで含めたHerokuの使い方の参考になるサイトはこちら(←ちょっと後ろめたいのでURL貼り付け。でも、やっぱり他力本願)

MQTTでドローンの自動操縦を考える(1) | Webエンジニア × ドローン

今回はHerokuにdeployする前のローカル環境での動作確認だけにしておきます。
(MQTTの設定を変更してdeployすればそのまま使えます)
環境は、virtualbox上のUbuntu 16.04でnode.js、npm、mosquitto(MQTT ブローカ)、gitなどがインストール済みとします。

私はnodebrewを使ってnode.jsのv7.10.1を使っています。
nodebrewを使ったnode.jsのインストールはここらあたりを参考に
NodeBrewインストール編 - Qiita

大体こんな感じ

wget https://raw.github.com/hokaccha/nodebrew/master/nodebrew
perl nodebrew setup
# ~/.bashrc か ~/.profile でPATH設定&再ログイン
# export PATH=$HOME/.nodebrew/current/bin:$PATH

# バイナリ版のインストール
nodebrew install-binary v7.10.1
# 通常使用するバージョンとして設定
nodebrew use v7.10.1

 

mosquittoのインストールはこんな感じで特に難しい設定とかはありません。

apt install mosquitto  mosquitto-clients

 

Herokuの場合、gitがあった方がdeployがやりやすいので必要ならインストールします。

apt-get install git

まず、作業用ディレクトリを作成し、移動します。

mkdir /proj/r2m
cd /proj/r2m

 

本来なら、npm init して、nmp install <モジュール>していくのですが、めんどっちいので
まず、以下内容を 「package.json」 というファイル名で保存します。

{
  "name": "rest2mqtt",
  "version": "0.0.1",
  "description": "REST to MQTT server",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "test": "node app.js"
  },
  "repository": {
    "type": "git",
    "url": "test"
  },
  "engines": {
    "node": "7.10.1",
    "npm": "5.4.2"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "body-parser": "^1.18.1",
    "express": "^4.15.4",
    "lodash": "^4.17.4",
    "moment-timezone": "^0.5.13",
    "mqtt": "^2.13.0"
  }

 

「npm start」と実行すると、"scripts" の下の "start" に設定されたコマンドが実行されます。
Herokuの場合、これが書いてあるとProcfileが要らなくなるらしい。
「npm test」と実行すると、"scripts" の下の "test" に設定されたコマンドが実行されます。
本来、テストスクリプトが動くようにするのですが、ここではstartと同じにしてあります。

"repository" の設定はこの内容は正しくないけど、ワーニングを黙らせるためだけに記述してあります。
"engines"の下は使ってるバージョンです。
"dependencies" の下はnpm install したときに追加されていきます。
また、「npm install」(モジュール名を指定しない) すると、インストールされていないパッケージがインストールされます。
ということで、以下のコマンドでモジュールをインストールします。

npm install

 

gitを使う場合は、以下の内容を 「.gitignore」 というファイル名で保存します。
今回重要なのは「node_modules」「package-lock.json」です。
他にもgitの管理対象外にしたいファイルがあったら追記してください。

# Node build artifacts
node_modules
npm-debug.log
package-lock.json

# Local development
*.env
*.dev
.DS_Store

# Docker
Dockerfile
docker-compose.yml

 

以下がアプリケーション本体です。「app.js」というファイル名で保存してください。

// ======== MQTT settings =====================================================
var mqtt_opt = {protocol: 'mqtt', host:'localhost'};    // 必要なら port, username, password などを追加
// 本番環境はこちら
// var mqtt_opt = {protocol: 'mqtt', host: '<<アドレス>>', port: <<ポーt番号>>, username: '<<ユーザ名>>', password: '<<パスワード>>'};

// ======== WebServer settings ================================================
var webservicePort = 8080;

// ======== utility ===========================================================
var _ = require('lodash');

// ==== moment-timezone =======================================================
var moment = require('moment-timezone');

// ==== express ===============================================================
var express = require('express');
var app = express();
var port = process.env.PORT || webservicePort;

// ==== body parser (POSTを使うときのお約束) ==================================
var bodyParser = require('body-parser');
// 個別にパーサを設定するので、app.useで登録しない
// app.use(bodyParser.urlencoded({extended: false}));
// app.use(bodyParser.json());
// app.use(bodyParser.text({type:"*/*" }));        // すべてのコンテンツタイプでテキストパーサを使う

// ==== MQTT ==================================================================
var mqtt = require('mqtt');
var mqtt_client  = mqtt.connect(mqtt_opt);

// MQTT 接続時の処理
mqtt_client.on('connect', function (connack) {
  console.log('MQTT client connected to brocker');
  console.log("    host: " + mqtt_opt.host);
  console.log("    port: " + mqtt_opt.port);
});

// ==== MISC ==================================================================
// QoS設定値の確認
function checkQoS(targetValue) {
    tmp = parseInt(targetValue);
    if (isNaN(tmp))             return 0;     // 数値でないなら0を返す
    if ((tmp < 0) || (tmp > 2)) return 0;     // 範囲外なら0を返す
    return tmp;
}

// 文字列"true"/"false" をbooleanに変換する関数
// Boolean(targetValue)だと文字列"false"や"0"がtrueになってしまうので
function parseBoolean(targetValue) {
    var targetType = typeof targetValue;  // 変数の型
    if      (targetType == 'boolean')                           return targetValue;             // booleanならそのまま返す
    else if (targetType == 'number')                            return Boolean(targetValue);    // 数値ならbooleanに変換して返す
    else if (targetType == 'string' || targetValue instanceof String ) {    // 文字列で
        var tmp = parseInt(targetValue);    // 数値に変換してみる
        if (isNaN(tmp)) {
            // 数値に変換できない
            if (targetValue.toLowerCase().trim() == 'true')     return true;                    // 'true' なら trueを返す
            else                                                return false;                   // それ以外はfalseを返す
        }
        else                                                    return Boolean(tmp);            // 数値をbooleanに変換して返す
    }
    else                                                        return false;                   // それ以外の型はfalseを返す
}
// ============================================================================

// Webサーバのスタート
app.listen(port);
console.log('Server started! listen Port: ' + port);

// GET rootの処理
app.get('/', function (req, res) {
  // console.log(req);
  var body = 'usage: <br />';
  var server_addr = req.protocol + '://' + req.headers.host;
  body += server_addr + '/mqtt_get/&lt;topic&gt;/&lt;payload&gt;/&lt;QoS: 0-2&gt;/&lt;retain: true or false&gt;<br />';
  body += server_addr + '/mqtt_query?topoc=&lt;topic&gt;&payload=&lt;payload&gt;qos=&lt;QoS: 0-2&gt;&retain=&lt;retain: true or false&gt;<br />';
  body += server_addr + '/mqtt_post<br />';
  body += '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;';
  body += '{"topic":&lt;topic&gt;,"payload:"&lt;payload&gt;,"qos":&lt;QoS: 0-2&gt;,"retain":&lt;retain: true or false&gt;} <br />';
  body += '<h1><a href="test.html">test</a></h1><br />';
  res.send(body);
});

// GET mqtt_get endpoint の処理
app.get('/mqtt_get/:topic/:payload/:qos/:retain', function(req, res) {
    // endpointのPATHからパラメータ取り出し
    var mqttTopic   = decodeURI(req.params.topic);
    var mqttPayload = decodeURI(req.params.payload);
    var mqttQos     = req.params.qos;
    var mqttRetain  = req.params.retain;
    var userIP      = req.socket.remoteAddress;

    // console.log(req);    // デバッグ用

    // レスポンス送信 & MQTT publish処理
    responseCommon(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP);
});


// GET mqtt_query endpoint の処理
app.get('/mqtt_query', function(req, res) {
    // queryからパラメータ取り出し
    var mqttTopic   = decodeURI(req.query.topic);
    var mqttPayload = decodeURI(req.query.payload);
    var mqttQos     = req.query.qos;
    var mqttRetain  = req.query.retain;
    var userIP      = req.socket.remoteAddress;

    // console.log(req);    // デバッグ用

    // レスポンス送信 & MQTT publish処理
    responseCommon(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP);
});


// POST mqtt_post endpoint の処理
// note: データに改行が含まれていてもエラーにならないように前処理を入れたいので一旦textで受け取る。
app.post('/mqtt_post', bodyParser.text({type:"*/*" }), function(req, res) {
    // bodyからパラメータ取り出し
/* *************
    // JSONパーサ(bodyParser.json())で受け取っていたらこちら
    var mqttTopic   = req.body.topic;
    var mqttPayload = req.body.payload;
    var mqttQos     = req.body.qos;
    var mqttRetain  = req.body.retain;
************* */
    // TEXTパーサ(bodyParser.text())で受け取っていたらこちら
    var body = req.body;                    // TEXTパーサの時はreq.bodyはString
    // console.log("BEFOR " + body);            // デバッグ用
    body = body.replace(/\n/g, "\\n");      // 改行(\n)を文字列の "\\n"に変更
    // console.log("AFTER " + body);            // デバッグ用
    jsn = JSON.parse(body);                 // JSONパーサ
    var mqttTopic   = jsn.topic;
    var mqttPayload = jsn.payload;
    var mqttQos     = jsn.qos;
    var mqttRetain  = jsn.retain;

    var userIP      = req.socket.remoteAddress;

    // console.log(req);    // デバッグ用

    // レスポンス送信 & MQTT publish処理
    responseCommon(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP);
})

// レスポンス送信共通処理 & MQTT publish処理
var responseCommon = function(res, mqttTopic, mqttPayload , mqttQos, mqttRetain, userIP) {      
    // パラメータチェック
    param_checked = true;
    if (!_.isString(mqttTopic)   || _.isEmpty(mqttTopic))       param_checked = false;      // 必須パラメータ
    if (!_.isString(mqttPayload) || _.isEmpty(mqttPayload))     param_checked = false;      // 必須パラメータ
    mqttQos = checkQoS(mqttQos);                    // 数値化 & 範囲チェック
    mqttRetain = parseBoolean(mqttRetain);          // boolean化

    // レスポンス送信後、connectionを切断するための設定
    res.set("Connection", "close");

    if (param_checked) {
        // パラメータチェックOK
        var dt = moment().tz("Asia/Tokyo");                 // 現在時刻(リクエストパラメータの確認用)
        // レスポンスの作成
        var body = dt.format("YYYY/MM/DD(ddd) HH:mm:ss z\n");
        body += 'received request is ...\n';
        body += '  topic: '   + mqttTopic;
        body += '  payload: ' + mqttPayload;
        body += '  QOS: '     + mqttQos;
        body += '  retain: '  + mqttRetain;
        body += '  from: '    + userIP;
        body += '\n';
        // レスポンスの送信
        res.send(body);
        // console.log(body);      // デバッグ用
        // MQTTへpublsh
        mqtt_client.publish(mqttTopic, mqttPayload, mqttQos);
    } else {
        // パラメータチェックNG
        var body = 'Parameter error';
        res.status(400);        // 400:Bad request
        res.send(body);
        // console.log(body);      // デバッグ用
    }
}


// serve static site
// publicの下にHTMLをそのまま見せるファイルを置く
app.use(express.static('public'));

 

以下がテストページのHTMLファイルです。「public/test.html」というファイル名で保存してください。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>テスト</title>
  <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/2.2.4/jquery.min.js"></script>
                                <!--プロトコル(http: or https)はつけなければ、このページと同じプロトコルでリクエストが出る  -->
  <script type="text/javascript">
    $(function() {   // Ready function
        $("#req_url").html("");
        $("#req_data").html("");
        $("#result").html("");
        $("#response").html("");

        // POSTボタンクリック時の処理
        $("#post").click( function(){
            var param = getParam();
            var param_str = JSON.stringify(param);    // 文字列化しておかないとクエリ文字列(hoge=xx&fuga=yy)で送られてしまう
            var url = "/mqtt_post";
            $("#req_url").html(url);                  // URLの表示
            $("#req_data").html(param_str);           // データの表示
            // リクエストの送信
            // $.post(url, param_str).done(dispSuccess).fail(dispError).always(dispResponse);
            $.ajax({
                    url: url,
                    type: 'POST',
                    dataType: 'text',
                    contentType: 'application/json',    // postだとこれが指定できないのでajaxで送信
                    data: param_str                     // 送信データは文字列で!
                }).done(dispSuccess).fail(dispError).always(dispResponse);
        });

        // GETボタンクリック時の処理
        $("#get").click( function(){
            var param = getParam();
            var url = "/mqtt_get/" + param.topic +  "/" + param.payload +  "/" + param.qos + "/" + param.retain;
            url = encodeURI(url);                     // エンコードする
            $("#req_url").html(url);                  // URLの表示
            $("#req_data").html("");                  // GETなのでデータはない
            // リクエストの送信
            $.get(url).done(dispSuccess).fail(dispError).always(dispResponse);
        });

        // QUERYボタンクリック時の処理
        $("#query").click( function(){
            var param = getParam();
            var url = "/mqtt_query?topic=" + param.topic + "&payload=" + param.payload + "&qos=" + param.qos + "&retain=" + param.retain;
            url = encodeURI(url);                     // エンコードする
            $("#req_url").html(url);                  // URLの表示
            $("#req_data").html("");                  // GETなのでデータはない
            // リクエストの送信
            $.get(url).done(dispSuccess).fail(dispError).always(dispResponse);
        });
    });   // End of Ready function

    // 成功/失敗表示
    function dispSuccess(data) {
        $("#result").html("success");
    }
    function dispError(data) {
        $("#result").html("error");
    }
    // レスポンス表示関数
    function dispResponse(data) {
        if ($.type(data) === "string") {
            // 成功時はdataに文字列が返ってくる
            txt = data.replace(/\\n/g, "\n");    // 文字列の "\\n" を "\n"(改行)に変更
        }
        else if ($.type(data.responseText) === "string") {
            // 失敗時はdata.responseTextに文字列が返ってくる
            txt = data.responseText.replace(/\\n/g, "\n");    // 文字列の "\\n" を "\n"(改行)に変更
        }
        else {
            // それ以外は現状未サポート
            txt = "Unknown response";
        }
        $("#response").html(txt);
    }
    // パラメータ取得関数
    function getParam() {
        var param = {
            topic:   $("#topic").val().trim(),
            // payload: $("#payload").val().trim(),                       // 通常はこちら
            payload: $("#payload").val().trim().replace(/\\n/g, "\n"),    // テスト用にあえて'\\n'を改行に置き換える
            qos:     Number($("#qos").val()),
            //retain:  Boolean($("#retain").val())  これはNG "false" がTrueになる
            retain:  parseBoolean($("#retain").val())
        };
        return param
    }
    // 文字列"true"/"false" をbooleanに変換する関数
    function parseBoolean(targetValue) {
        targetType = typeof targetValue;  // 変数の型
        if      (targetType == 'boolean')    return targetValue;                // booleanならそのまま返す
        else if (targetType == 'number')     return Boolean(targetValue);       // 数値ならbooleanに変換して返す
        else if (targetType == 'string' || targetValue instanceof String ) {    // 文字列で
            tmp = parseInt(targetValue);
            if (isNaN(tmp)) {
                // 数値に変換できない
                if (targetValue.toLowerCase().trim() == "true")    return true;     // 'true' なら trueを返す
                else                                               return false;    // それ以外はfalseを返す
            }
            else {
                // 数値に変換できる
                return Boolean(tmp);
            }
        }
        else                                 return false;                      // それ以外の型はfalseを返す
    }
  </script>
</head>
<body>
    <h1>TEST</h1>
    <table>
      <tr><td>topic:   </td><td><input type="text" id="topic"   size="100"></td></tr>
      <tr><td>payload: </td><td><input type="text" id="payload" size="100"></td></tr>
      <tr><td>qos:     </td><td><select id="qos">
                                  <option value="0" selected>0</option>
                                  <option value="1"         >1</option>
                                  <option value="2"         >2</option>
                                </select>
      </td></tr>
      <tr><td>retain:  </td><td><select id="retain">
                                  <option value="false" selected>false</option>
                                  <option value="true"          >true</option>
                                </select>
      </td></tr>
    </table>
    <p>
      <button id="post" type="button">post</button>
      <button id="get" type="button">get</button>
      <button id="query" type="button">query</button>
    </p>
    <table>
      <tr><td valign="top">url      </td><td><textarea id="req_url"  cols=120 rows=1  wrap="off" style="overflow:auto;" disabled></textarea></td></tr>
      <tr><td valign="top">data     </td><td><textarea id="req_data" cols=120 rows=1  wrap="off" style="overflow:auto;" disabled></textarea></td></tr>
      <tr><td valign="top">result   </td><td><textarea id="result"   cols=120 rows=1  wrap="off" style="overflow:auto;" disabled></textarea></td></tr>
      <tr><td valign="top">response </td><td><textarea id="response" cols=120 rows=10 wrap="off" style="overflow:auto;" disabled></textarea></td></tr>
    </table>

</body>
</html>

 

 準備が整ったら、以下のコマンドで実行します。

npm start

 

正常に起動されたら以下のように表示され、接続待ちになります。

Server started! listen Port: 8080
MQTT client connected to brocker
    host: localhost
    port: 1883

 

また、MQTTのメッセージが正常に送られたことを確認するために別のターミナルで以下のコマンドを実行します。
これはlocalhostのMQTTブローカに送られたすべてのtopicを購読するものです。-vを指定することでtopic名も表示されます。

mosquitto_sub -v -h localhost -t "#"

 

この状態でブラウザで接続します。

http://localhost:8080

 

「TEST」のリンクをクリックするとテストページに移動します。
topicにメッセージを送信するトピックを、payloadにメッセージ本体を入力し(QoSとretainは省略。何者かはググってください)、
「post」「get」「query」のどれかのボタンをクリックします。
url、data(postのときだけ)、result、responseに結果が表示され(下図参照:正常実行時とエラー時)、mosquitto_subは受け取ったメッセージを表示します。

 

f:id:ippei8jp:20170922153326p:plain

f:id:ippei8jp:20170922153344p:plain

かなり長くなってしまった。。。githubかなにかで公開すれば良かったかな?

 解説は次回、ということで。

 

 

micropython on ESP32 でIFTTT(アクション編)

ジャッキー・チェンがアチョーって、、、またスベった。。。

今回はIFTTTのwebhooksチャネルをアクション(that)で利用し、ESP32側で処理を行うことを試してみます。
ESP32は家庭内LANなどに接続されており、外部から直接アクセスできないので、こちらを参考にESP32とのデータを直接やりとりするのにBeebotteを使用することにします。

データの流れは以下のような感じです。

    IFTTT --REST API--> beebotte --MQTT--> ESP32

まずbeebotteにユーザ登録します。
Beebotteにアクセスし、右上の「Sign up」から必要事項を入力し、「SIGN UP」をクリックすると、登録したe-mailアドレス宛に確認メールが届きます。
このメールに記載されているリンクをクリックすると登録完了です。

 

チャネルとリソースの作成、動作確認はいつものように先人の知恵を拝借。
IFTTTのトリガーおよびアクションをESP8266で実行する - Qiita

beebotteへのアクセス用URLには上記ページにある、
https://api.beebotte.com/v1/data/write/《チャネル》/《リソース》?token=《チャネルトークン》
のほか、
https://api.beebotte.com/v1/data/publish/《チャネル》/《リソース》?token=《チャネルトークン》
も使用できます。

これらの違いは、writeがストレージにデータを記録するのと同時にメッセージ配信を行うのに対し、publishはその場でメッセージを配信するだけだという点です。
その場でメッセージ配信するだけで良いならば、publishで良いでしょう。
ただし、Send on Subscribe(SoS)を使用するにはwriteでなければなりません。

IFTTT側の設定も上記ページを参考にしてください。
注意事項として、以下があります。

  • 送信するデータはダブルクォーテーションで囲む(例:「"送信データ"」)
    • 特に、Add ingredientで項目を選択したときに忘れがちです。
  • 送信するデータに改行が含まれないようにする。含まれているとIFTTTのレシピがエラーになり、データが送信されません。
    • たとえば、トリガをTwitterにした場合、送信データにtweet本文(Text)を指定すると、tweet本文に改行が含まれているとエラーになります。
    • 以下のような回避策が考えられます。
      • 本文に改行を入れないように運用する
      • 本文を送るのを諦め、特定のハッシュタグが含まれていたら固定文字列やなどを送るようにする
        • たとえば、ハッシュタグ#LIGHTが含まれていたらtweet本文を送信、ON/OFFをESP32側で判別する、という方法ではなく、ハッシュタグ#LIGHT_ON が含まれていたらONを送る、#LIGHT_OFFが含まれていたらOFFを送る、といった具合にIFTTTのレシピで分けておく。

beebotteとESP32間の通信はMQTTを使用します。
mqtt通信用モジュールはPypiにumqtt.simpleモジュールがあるので、それを利用します。
あらかじめupip(upipmではない)でumqtt.simpleモジュールをインストールします。

import upip
upip.install("micropython-umqtt.simple")

umqtt.simple についてはこちらを参照してください。
ソースはこちらで。

 

準備ができたら以下のプログラムを実行します。
チャネルトークン、チャネル名、リソース名に使用するデータを設定してください。
IFTTTからトリガがかかるたびにbeebotteにデータが送信され、最終的にcallback_func()が実行されます。

CHANNEL_TOKEN = '取得したチャネルトークン'

CHANNEL_NAME  = 'チャネル名'
RESOURCE_NAME = 'リソース名'

PING_PERIOD   = 120         # pingを打つ間隔(単位:秒)

from umqtt.simple import MQTTClient
import ujson
import utime

MQTT_SERVER = 'mqtt.beebotte.com'                     # サーバ名
MQTT_USER = 'token:' + CHANNEL_TOKEN                  # ユーザ名
MQTT_TOPIC = CHANNEL_NAME + '/' + RESOURCE_NAME       # トピック名

def callback_func(topic, msg):
    print("topic:%s  msg:%s" % (topic, msg))
    json_data= ujson.loads(msg)
    dt = json_data.get("data", "UNKNOWN")
    print("*** " + str(dt) + " ***")

# 初期化
c = MQTTClient("umqtt_client", MQTT_SERVER, user=MQTT_USER, password='', keepalive=PING_PERIOD*2)
# 接続
c.connect()
# コールバックの設定
c.set_callback(callback_func)
# サブスクライブ実行
c.subscribe(MQTT_TOPIC)

prompt = ['-', '\\', '|', '/']
cnt = 0
prevtime = utime.time()        # pingを打つための前回実行時刻を初期化
while  True:
    curtime = utime.time()
    if (curtime - prevtime) > PING_PERIOD :
        # 接続保持のためPING_PERIOD毎にpingを打つ
        c.ping()
        print('ping')
        prevtime = curtime       # 前回時刻の更新
    
    c.check_msg()                 # pingを打つ必要があるので、ノンブロッキング動作
    print(prompt[cnt], end="\r")  # 動作確認用表示
    cnt = (cnt + 1) & 0x03

c.disconnect()                    # 無限ループなのでここには来ない

以下解説です。

 

データを受信したときに実行されるコールバック関数です。
取得したデータのトピックとデータを表示します。
また、データはJSONデータ文字列なので、エンコードし、dataプロパティを取得して表示しています。
実際にはここで変数dtに応じて処理を行います(LEDを点灯/消灯する等)。
データの取得にはdataプロパティが存在しなかった場合に備え、getメソッドを使用しています。

def callback_func(topic, msg):
    print("topic:%s  msg:%s" % (topic, msg))
    json_data= ujson.loads(msg)
    dt = json_data.get("data", "UNKNOWN")
    print("*** " + str(dt) + " ***")

 

MQTTモジュールの初期化です。
ユーザ名には'token:'+チャネルトークン、パスワードには空文字列を指定します。
keepalive時間はPING_PERIOD(pingを打つ間隔)の2倍の値を設定しています。
MQTTブローカはkeepaliveに設定した時間の1.5倍の間接続を維持しますので、実際にはPING_PERIODの3倍の時間 接続が維持されます。
keepaliveを設定しない(or 0を設定)と、接続が切断されたことが通知されません(本来は切断されないのですが、beebotteでは5分くらいで接続断になってしまうようです。もしかしたらうちのネットワーク環境のせいかもしれませんが)。そこで、keepaliveを設定することで接続断時に例外がraiseされるようにしています。
(説明がわかりにくいですが、keepaliveの設定の有無の違いはwhileループ内のc.ping()を削除して10分ぐらい放置したあと、データを送信してみると違いが分かると思います)

また、この設定ではSSLを有効にしていません。SSLを有効にするにはパラメータに「ssl=True」を追加してください。
ただし、linux版micropythonはSSLのノンブロッキング動作に対応していないので、SSLを有効にするとエラー終了します。

c = MQTTClient("umqtt_client", MQTT_SERVER, user=MQTT_USER, password='', keepalive=PING_PERIOD*2)

 

接続~コールバックの設定~サブスクライブの実行の処理です。
トピック名は チャネル名/リソース名 で指定しますが、リソース名にワイルドカード(#)を指定することができるようです。
つまり、トピック名に チャネル名/# と指定すれば、チャネル内のすべてのトピックのデータが取得できるということです(使い道があるかはわかりませんが)。
トピック名に'#'と指定すると、すべてのチャネルのすべてのリソースのデータが取得できそうな気がしますが、beebotteではエラーになるようです。

c.connect()
c.set_callback(callback_func)
c.subscribe(MQTT_TOPIC)

 

pingを打つための時刻を取得するために現在の時刻を取得しておきます。
相対時間が必要なだけなので、時刻合わせを行う必要はありません。

prevtime = utime.time()        # pingを打つための前回実行時刻を初期化

 

データ取得のために無限ループにします。
接続断時やエラー発生時は例外がraiseされるので、try~except~ で囲んでおくのが良いかもしれません。

while  True:

 

keepaliveを設定してあるので、何もしないと接続が切断されてしまいます。それを防ぐために一定時間ごとにpingを打ちます。
他の処理の処理時間によってはPING_PERIODよりも長くなることがありますが、keepaliveがPING_PERIOD*2と余裕を持って設定されているので、大丈夫でしょう(たぶん、おそらく、もしかしたら。。。)

    curtime = utime.time()
    if (curtime - prevtime) > PING_PERIOD :
        # 接続保持のためPING_PERIOD毎にpingを打つ
        c.ping()
        print('ping')
        prevtime = curtime       # 前回時刻の更新

 

定期的に受信処理を呼び出します。
受信データがあったら、コールバック関数が実行されます。ここにデータが返ってくるわけではありません。

    c.check_msg()                 # pingを打つ必要があるので、ノンブロッキング動作

 

メインループで処理が必要な場合はここで処理します。

    print(prompt[cnt], end="\r")  # 動作確認用表示
    cnt = (cnt + 1) & 0x03

 

 

 

micropython on ESP32 でpush通知

TwitterやLINEで通知を試しましたが、twitterやLINEのアプリをインストールしたりアカウントを作成する手間も省きたいというモノグサ魂がムクムクと。。。
モノグサのためなら苦労も厭わないという、本末転倒な感じですが(笑)。
また、通知の痕跡が残らないようにしたいという希望も。「なお、このメッセージは自動的に消滅する」というアレみたいな感じですね。
どうせなら、PCだけでなくスマホにも、と欲求は尽きません。
で、いろいろと調べてみたところ、Web Pusht通知を使うとそんな感じの機能が実現できそうです。

ただし、すべての環境で動作するわけではなさそうです。
試した時点ではWindows10のfirefoxChromeでは動作しましたが、Edgeでは動作しませんでした。その他のブラウザは持ってないので試してません。
MacOSは持ってないのでわかりません(貧乏なので・・・)。
スマホAndroidのみ試しました。
iOSではPush7アプリのインストールが必要なようです。詳細はググってください。

push通知をローカルな環境だけで実現できそうにないので、push通知サービスのPush7を使うことにします。

freeプランだと5000送信/月ですが、趣味でちょっと試すくらいなら十分でしょう。
注意しないといけないのが、制限がリクエスト数ではなく、送信数だというところです。購読者が10人いると、1リクエストあたり10送信行われることになります。
例えば、10分に1回づつ通知して(そんなに通知することはないと思いますが)、1か月で6×24×30=4320回だと思うと大間違いです。さらに購読者数をかけなければいけません。
また、自分で購読要求を制御できない(そもそも他人に勝手に登録して利用してもらうサービスなので)ので、秘密の情報など流さないように注意しましょう。

 

まずはWebプッシュ通知サービス「Push7」の導入方法①アカウント登録編にしたがってアカウント登録~アプリケーション登録を行ってください。

 

 アプリケーションの登録でのいっぺーちゃんなりの補足です。

  • アプリケーション名(↑のヘルプページではサイト名になっている)を入力します
    • 自分の好きな名前で良い
  • URLを入力します。
    • 購読ページの戻るボタンのリンク先です
    • とりあえず自分のブログのURLとか、やほーのトップとか書いておく
  • アイコンが必要なら画像をアップロードします
  • カテゴリを選択します
    • 「娯楽・レジャー・趣味」かな。
  • アプリケーションURLを入力します
    • アプリケーション名と同じにしておくと分かりやすいかな?
    • 既に使用されているとダメ。

 

 ダッシュボードでアプリケーションを開くと、「初期導入」の説明がありますが、今回はESP32からAPIで通知要求を行うので、無視して構いません。
また、APIの実行に必要な「App Number」と「API Key」は左側のメニューの「API」をクリックすると確認できます。

 

 次は通知を受け取る側の設定(購読の設定)です。
何もしないとpush通知を受け取れません。モノグサでもこれだけは我慢してやってください。

  • ブラウザ(スマホでも可)でアプリケーションURLに指定したURLにアクセス
  • 「~~に通知の送信を許可して受信しますか?」と聞かれるので、「通知を許可する」をクリック
  • 再度アプリケーションURLにアクセスし、「購読解除」をクリックすると購読をやめられます。

 

次にテストしてみましょう。

  • Push7のダッシュボードに戻ります。
  • 左側のメニューの一般通知(送信の下にあります)で「新規プッシュ通知」をクリック
  • タイトル、URL、内容を入力し、必要なら「クリックされるまで表示」を選択し、送信をクリック
    • タイトルと内容が通知ウィンドウに表示される文字列です。
    • URLは通知ウィンドウをクリックしたときにジャンプするURLです。
    • すべて入力しないとエラーになりますので、ダミーでも入れてください。
  • 送信をクリック
    • → 通知が表示されます。

 

「クリックされるまで表示」を選択しても、PCのブラウザ(Firefox/chromeで確認)では20秒程度で表示が消えてしまいます。
これはブラウザが表示を消しているようです。
逆にスマホだと、「クリックされるまで表示」を選択しなくても通知領域にずっと表示されているようです。

firefoxの場合、通知をクリックするまで表示させるには、以下の設定を行う必要があります(55.0.3 64bitで確認)。
ただし、どのような副作用が起こるか確認していませんので、設定は自己責任でお願いします。

  • about:config で設定エディタを開く
  • 「dom.webnotifications.requireinteraction.enabled」を「true」に設定

chromeでは対応する設定は見当たりませんでした。

 

なお、PCで通知を受け取るにはブラウザ(複数インストールされている場合は、購読のページで購読を許可したブラウザ)を起動しておく必要があります。どこか特定のページを開いておかなければならない、というようなことはありません。

 

 いよいよESP32から通知を行います。

 

 以下のプログラムをtiny_push7.pyという名前で保存し、いつものようにupipmでインストールしてください。

import usocket as socket
import ussl as ssl

class tiny_push7 :
    def __init__(self, app_no, api_key, icon, url, debug=False) :
        # パラメータチェック
        if type(app_no) is not str:
            raise ValueError("app_no must be string")
        if type(api_key) is not str:
            raise ValueError("api_key must be string")
        if type(icon) is not str:
            raise ValueError("icon must be string")
        if type(url) is not str:
            raise ValueError("url must be string")
        
        self.app_no     = app_no
        self.api_key    = api_key
        self.icon       = icon
        self.url        = url
        self.__DEBUG__  = debug

    def __debug_print(self, str) :
        if self.__DEBUG__ :
            print(str)
    
    def __makeRequestMessage(self, host, title, body, disappear) :
        # disappear の設定を文字列化
        disappear_str = "true" if disappear else "false"
        
        # make request
        request = 'POST /api/v1/' + self.app_no + '/send HTTP/1.1\n'
        # bodyがbytesなので、requestもbytesに
        request = request.encode('utf-8')

        # make message body
        # disappear_instantlyは効かないけどとりあえず設定しておく
        body =     '{"title":"'   + title        + '",' \
                 +   '"body":"'    + body         + '",' \
                 +   '"icon":"'    + self.icon    + '",' \
                 +   '"url":"'     + self.url     + '",' \
                 +   '"disappear_instantly":'+ disappear_str +','      \
                 +   '"apikey":"'  + self.api_key + '"}'

        # bodyは RFC3986 エンコードではなく、バイナリエンコード
        body = body.encode('utf-8')
        
        # make message header
        header  = 'Host: ' + host['host'] + '\n'                    \
                + 'User-Agent: micropython push7 agent v0.1\n'      \
                + 'Content-Type:application/json\n'                   \
                + 'Connection: close\n'                             \
                + 'Accept: */*\n'                                   \
                + 'Content-Length: ' + str(len(body)) + '\n'
        # bodyがbytesなので、headerもbytesに
        header = header.encode('utf-8')

        # request message
        ret = request + header + b'\n' + body + b'\n'
        return(ret)
    
    def __sendmessage(self, host, msg) :
        sock = socket.socket()
        addr = socket.getaddrinfo(host['host'], host['port'])[0][-1]
        
        # connect socket
        sock.connect(addr)
        try :
            # SSL wrap
            ssl_sock = ssl.wrap_socket(sock)
            
            # send data
            ssl_sock.write(msg)
            
            # 受信データの最初の1行
            rcv_line = ssl_sock.readline()
            protover, status, rcv_msg = rcv_line.split(None, 2)
            status = status.decode('utf-8')
            rcv_msg = rcv_msg.decode('utf-8')
            self.__debug_print('%s  %s  %s' % (protover, status, rcv_msg))
            # それ以外のレスポンスヘッダを読む
            while True:
                rcv_line = ssl_sock.readline()
                self.__debug_print(rcv_line)
                if not rcv_line:
                    # なんらかの異常なレスポンス(ヘッダが終わる前にデータがなくなった)
                    ssl_sock.close()
                    raise ValueError("Unexpected EOF in HTTP headers")
                if rcv_line == b'\r\n':
                    # 空行でヘッダ終了
                    break
        except Exception as e:
            # エラーが発生したらクローズして上位へ例外通知
            ssl_sock.close()
            raise e
        # メッセージ本体を受信(とりあえず読み捨て)
        rcv_line = b''
        while True :
            try :
                l = ssl_sock.readline()
            except Exception as e:              # エラーが発生したらクローズして上位へ例外通知
                ssl_sock.close()
                raise e
           
            if not l:                           # データがない → 終了
                break
            
            # 読み込んだデータをためる
            rcv_line += l
        
        self.__debug_print("@@@@" + str(rcv_line))
        self.__debug_print("close!!")
        ssl_sock.close()
        # 受信終了後にエラー判定
        if not ((status == "200") or (status == "201")) :
            # status 200,201 以外はエラー
            raise ValueError(status +' '+rcv_msg)
    
    # ######## notify API ################################
    def push(self, title, body, disappear=True) : 
        host = { 'host': 'api.push7.jp', 'port': 443}
        
        reqMessage = self.__makeRequestMessage(host, title, body, disappear)
        self.__debug_print('%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%')
        self.__debug_print(reqMessage)
        self.__debug_print('%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%')
        
        self.__sendmessage(host, reqMessage)

以下解説

 

送信データを作成している部分です。
API仕様にしたがって送信データを作成しています。
TwitterやLINEのときは、body部分はRFC3986でエンコードしていましたが、Push7ではRFC3986ではなく、バイナリデータそのもので送信します。
よって、メッセージヘッダの Content-Length にはバイナリエンコードした後のデータ長を設定します。
(RFC3986でエンコードしたデータはASCII文字のみなので、Stringのままデータ長を取得しても大丈夫でした。ここでハマりました。。。)

    def __makeRequestMessage(self, host, title, body, disappear) :
        ・・・・

なお、API仕様についてはPush7 APIの利用 - Push7 Docsを参照してください

 

データを送信する部分ですが、ほとんどこれまでの使いまわしです。
レスポンスのエラー判定方法と例外をraiseする部分をちょっと変更してあります。

    def __sendmessage(self, host, msg) :
        ・・・・

 

API関数です。
パラメータtitleにタイトル、bodyに本文を設定します。
disappearにTrueを設定する(or設定を省略する)とダッシュボードから送信した時の「クリックされるまで表示」を選択していないのと同等です。
disappearにFalseを設定するとダッシュボードから送信した時の「クリックされるまで表示」を選択したのと同等です。

    def push(self, title, body, disappear=True) : 
        ・・・・

 

以下が通知プログラムです。

app_no  = '<<ダッシュボードで取得したApp Number>>'
api_key = '<<ダッシュボードで取得したAPI Key>>'
icon    = '<<通知に表示するアイコンのURL>>'                  # httpsでなければならない。512文字以下
url     = '<<クリックしたときにジャンプするページのURL>>'    # 512文字以下

from tiny_push7 import tiny_push7

# 初期化
tp = tiny_push7(app_no, api_key, icon, url, debug=True)

title = "緊急指令"
body = "このメッセージは自動的に消滅する"
tp.push(title, body, True)
または
tp.push(title, body, False)         # クリックするまで表示される

 

iconに設定するURLは通知に表示するアイコンのURLですが、HTTPS接続のサイトにある必要があります。
もし用意できない場合は、Push7のアプリケーションで設定したアイコンを使うと良いでしょう。

  • アプリケーションで設定したアイコンのURLは以下にブラウザでアクセスすると調べられます。

urlには通知ウィンドウをクリックしたときにジャンプするページのURLを指定します。
実在しないページでも大丈夫なようですが、URLの形になっていないとエラーになります。
(ちょっと試した感じでは、 "" ⇒ NG  "http://hoge" ⇒ NG   "http://hoge.jp" ⇒ OK)
念のため「やほー」とか「ごーごる」のURLを入れておくのが良いと思います。
ここはHTTPS接続のサイトである必要はありません。
urlはpush API側で設定するようにしてもいいかなとも思いましたが、とりあえずコンストラクタで指定するようにしました。

あとは説明するまでもありませんね。

 

 

 

 

 

 

 

micropython on ESP32 でIFTTT(トリガ編)

micropythonでTwitterやLINE notifyにデータを送信するプログラムを作ってみましたが、IFTTTを使えば通知先を気にせず同じプログラムで送信できることに今更気が付きました。
ということで、IFTTTにデータを送信するプログラムを作ってみました。

まず、IFTTTにアクセスするモジュールをインストールします。
以下のプログラムをtiny_ifttt.pyという名前で保存し、いつものようにupipmでインストールしてください。

import usocket as socket
import ussl as ssl

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# ---- percent encoding
def RFC3986_encode(s):
    ret = ''
    bts = s.encode('utf-8')
    for c in bts :
        if c in range(0x30, 0x39 + 1) or \
           c in range(0x41, 0x5a + 1) or \
           c in range(0x61, 0x7a + 1) or \
           c in (0x2d, 0x2e, 0x5f, 0x7e):
            ret += chr(c)
        else :
            ret += '%%%02X' % (c)
    return ret

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
class tiny_ifttt :
    def __init__(self, my_key, debug=False) :
        # パラメータチェック
        if type(my_key) is not str:
            raise ValueError("key must be string")
        
        self.my_key      = my_key
        self.__DEBUG__   = debug

    def __debug_print(self, str) :
        if self.__DEBUG__ :
            print(str)
    
    def __makeRequestMessage(self, host, event, value1, value2, value3) :
        # make request
        request = 'POST /trigger/' + event + '/with/key/' + self.my_key
        request += '?'
        if not value1 is None :
            request += 'value1=' + RFC3986_encode(value1) + '&'
        if not value2 is None :
            request += 'value2=' + RFC3986_encode(value2) + '&'
        if not value3 is None :
            request += 'value3=' + RFC3986_encode(value3) + '&'
        # 最後の「?」または「&」を削除して「 HTTP/1.1」をつける 
        request = request[0:-1] + ' HTTP/1.1\n'
        
        # make message header
        header  = 'Host: ' + host['host'] + '\n'                    \
                + 'User-Agent: micropython ifttt agent v0.1\n'      \
                + 'Connection: close\n'                             \
                + 'Accept: */*\n'

        # request message
        ret = request + header + '\n'
        return(ret)
    
    def __sendmessage(self, host, msg) :
        sock = socket.socket()
        addr = socket.getaddrinfo(host['host'], host['port'])[0][-1]
        
        # connect socket
        sock.connect(addr)
        try :
            # SSL wrap
            ssl_sock = ssl.wrap_socket(sock)
            
            # send data
            ssl_sock.write(msg)
            
            # 受信データの最初の1行
            rcv_line = ssl_sock.readline()
            protover, status, msg = rcv_line.split(None, 2)
            # self.__debug_print('%s::::%s::::%s' % (protover, status, msg))
            # status 200以外はエラー
            if status != b"200":
                ssl_sock.close()
                raise ValueError(status)
            # それ以外のレスポンスヘッダを読む
            while True:
                rcv_line = ssl_sock.readline()
                # self.__debug_print(rcv_line)
                if not rcv_line:
                    # なんらかの異常なレスポンス(ヘッダが終わる前にデータがなくなった)
                    ssl_sock.close()
                    raise ValueError("Unexpected EOF in HTTP headers")
                if rcv_line == b'\r\n':
                    # 空行でヘッダ終了
                    break
        except Exception as e:
            # エラーが発生したらクローズして上位へ例外通知
            ssl_sock.close()
            raise e
        # メッセージ本体を受信(とりあえず読み捨て)
        rcv_line = b''
        while True :
            try :
                l = ssl_sock.readline()
            except Exception as e:              # エラーが発生したらクローズして上位へ例外通知
                ssl_sock.close()
                raise e
           
            if not l:                           # データがない → 終了
                break
            
            # 読み込んだデータをためる
            rcv_line += l
        
        self.__debug_print("@@@@" + str(rcv_line))
        self.__debug_print("close!!")
        ssl_sock.close()
    
    # ######## notify API ################################
    def trigger(self, event, value1=None, value2=None, value3=None) : 
        host = { 'host': 'maker.ifttt.com', 'port': 443}
        
        reqMessage = self.__makeRequestMessage(host, event, value1, value2, value3)
        self.__debug_print('%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%')
        self.__debug_print(reqMessage)
        self.__debug_print('%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%')
        
        self.__sendmessage(host, reqMessage)

プログラム自体はLINEのときとそんなに変わりません。
大きく違うのは送信するデータの作成部分(__makeRequestMessage メソッド)だけです。

次に、IFTTTのレシピを作成します。
ESP32からのデータを受け取るにはwebhooksというチャネルを使用します。これはREST APIでデータを受け取るチャネルのようです。
旧称は MakerChannel のようで、ネット上にはこの名前で検索すると結構情報が出てきたりします。
トリガ(this)、アクション(that)それぞれに対応していますが、ここではトリガとして使用する方を試してみます。
具体的な設定手順は、例によって先人の知恵を拝借します。
Nefry BTとIFTTTでスイッチを押したらLINEを送る仕組みを作ってみよう | dotstudio
「最後にFinishを押し、IFTTTのレシピの作成は完了です。」までの部分です。

 

準備ができたら以下のプログラムを実行します。

my_keyにはアカウントごとに割り当てられるキーを設定します。
上記ページではsettingをクリックしてほにゃららと書いてありますが、settingの左のDocumentationをクリックすると一番上にデカデカと表示されます。
eventにはレシピに設定したイベント名を設定します。上記ページでは「Nefry」となっているものです。
triggerのパラメータ、value1, value2, value3 は必要なものだけ記述してください。不要ならすべて省略してもかまいません。(eventは必須)

my_key = '取得したキー'
event  = '作成したイベント'

from tiny_ifttt import tiny_ifttt

# 初期化
ti = tiny_ifttt(my_key, debug=True)
ti.trigger(event, value1='へろー', value2='えぶり', value3='わん')

成功すれば、コンソールに

@@@@b"Congratulations! You've fired the <<イベント名>> event"
close!!

と表示され、LINEに以下のようなメッセージが届きます。

[IFTTT] Value 1: へろー
Value 2: えぶり
Value 3: わん

届かない場合、IFTTTのトップページから上のほうにあるメニューの一番右の「Activity」で「Applet ran」という表示が出ているか確認してください。
(失敗している場合は「Applet failed」と表示されます)
まだ表示されていない場合は、メニューの右から2番目の「My Applets」で作成したレシピを選択、下の方にある「Check now」をクリックしてみてください。
(webhooksをトリガとするレシピは大体数秒で実行されるようですが、まれに数分以上かかることがあります)

 

LINEに届いた日本語文字列が%数字%数字~となっていた場合は、シリアルコンソールやファイルの文字コードUTF-8になっているか確認してください。
また、シリアルコンソールからプログラムを入力する場合は、ペーストモードでコピペしてください。通常モードだと日本語部分が消えます。

my_key の設定が間違っていた場合、例外が発生し、

ValueError: b'401'

と表示されます。

 

eventが間違っていた場合は、プログラムは正常に終了しますが、IFTTT側では何も起こりません。
(居ない人が呼ばれたんだから、誰も返事しないのは当然)

これで、ESP32側のプログラムは一つでLINEやTwitterFacebook、e-mailなど色々な通知先を選択(またはすべてに同時配信)することができます。

また、アクションにDropboxやGoogleDriveを使用すればセンサの測定データを定期的に保存するといった使用方法もできるでしょう。

 

micropython on ESP32 で名前解決

別に子供の名前を決めてくれるわけではない。。。(スベった)

インターネットアクセスでマシン名からIPアドレスを取得するアレです。

公開されているサーバであれば、DNSで解決できるのですが、家の中のようなローカルな環境のマシンも名前でアクセスできたらいいですよね。
家でDNSサーバを動かす!というわけにもいかないし、、と思っていたら、
mDNS(Multicast Domain Name Service)とかLLMNR(Link-Local Multicast Name Resolution)というのがあると知りました。

茶番をスキップ

で、micropython上で使えないかなぁ、と色々調べてみました。
どうやら、mDNSはマルチキャスト通信でデータのやり取りを行うらしい(名前にMulticastって書いてあるやん)。
問い合わせをマルチキャストで投げたら、応答が問い合わせ元にユニキャストで返ってくるのかと思っていたんですが、応答もマルチキャストで返ってくるようです。
つまり、mDNSを使うにはマルチキャストアドレスでの待ち受けができないとダメということです。
micropythonのソースを眺めてみたけど、マルチキャストには対応していないようなので、あきらめました。
LLMNRの方はあんまり調べてないけど、似たようなもんだと思います。
どうしてもESP32でmDNS使いたい人は、ESP32のドライバのサンプル
esp-idf/examples/protocols/mdns/
があるので、そっちを参照してください。
まぁ、別タスクでこれを動かしておいて、そっちに問い合わせるという使い方も考えられますが。

ちゃんちゃん、
と終わっては話にならないので、さらに考えました。

mDNSの勉強のためにWiresharkでパケットキャプチャしていたのですが、mDNSとLLMNRのパケットの近くにいつも出ているNBNSというパケット、
中身を見てみるとどうやらこれも名前解決のためのパケットのようです。
で、ググりまくってみたところ、NetBT(NetBIOS over TCP/IP)のName Service らしい。
これだと、問い合わせはブロードキャストで送信、応答はユニキャストで返ってくるので、なんとかなりそう。

NetBTにはあまり良い印象はないのですが(情報も少ないし)、Windowsマシンでは必ず動いていますし、ubuntuでもsamba(windowsマシンへ共有ディレクトリを見せるためのプログラム)が入っていれば動いているので家庭内での環境としては悪くないと思います。
(nmbd というデーモンがこの機能を実現しています)

確かめていませんが、同様にRaspberryPiでもsambaをインストールすれば使えそうな気がします。

さらにググりまくって、仕様をみつけました。

その他、参考になりそうなサイト。

基礎から学ぶWindowsネットワーク:第18回 NetBIOS over TCP/IPプロトコル(その1) (3/3) - @IT

NetBIOSネームサービスでネットワーク内の端末をリアルタイムに列挙する:CodeZine(コードジン)

 

よし、なんとかなりそうだ。

カタカタ・・・カッターン。・・・カタカタ・・・カッターン。←プログラムを入力している音

が、、、、まさかの事実発覚!
ESP32版 micropythonはブロードキャストをサポートしていない!!
ここまで来て。。。。
しかーし、以下の修正でブロードキャスト対応できることを発見。
(正確には設定用のシンボルが定義されていなかっただけ)

diff --git a/esp32/modsocket.c b/esp32/modsocket.c
index fad42e9..6337938 100644
--- a/esp32/modsocket.c
+++ b/esp32/modsocket.c
@@ -559,6 +559,7 @@ STATIC const mp_map_elem_t mp_module_socket_globals_table[] = {
     { MP_OBJ_NEW_QSTR(MP_QSTR_IPPROTO_UDP), MP_OBJ_NEW_SMALL_INT(IPPROTO_UDP) },
     { MP_OBJ_NEW_QSTR(MP_QSTR_SOL_SOCKET), MP_OBJ_NEW_SMALL_INT(SOL_SOCKET) },
     { MP_OBJ_NEW_QSTR(MP_QSTR_SO_REUSEADDR), MP_OBJ_NEW_SMALL_INT(SO_REUSEADDR) },
+    { MP_OBJ_NEW_QSTR(MP_QSTR_SO_BROADCAST), MP_OBJ_NEW_SMALL_INT(SO_BROADCAST) },
 };
 
 STATIC MP_DEFINE_CONST_DICT(mp_module_socket_globals, mp_module_socket_globals_table);

変更しなくても、SO_BROADCASTの代わりに0x0020と数値で指定すればOKな気もする。。。。

で、ソースはこちら。
nbns.pyという名前で保存し、いつものようにupipmでインストールしてください

try:
    import usocket as socket        # for micropython
except:
    import socket                   # for python

try:
    import utime as time            # for micropython
except:
    import time                     # for python

try:
    import ustruct as struct        # for micropython
except:
    import struct                   # for python

# デバッグ用バイナリデータのダンプ
def dumpData(data) :
    # print(data)
    for i in range(len(data)) :
        print("%02x" % (data[i]), end=' ')
        if (i % 0x10) == 0x0f :
            print("")
    print("")


# NBNS
# http://www.ietf.org/rfc/rfc1002.txt
# http://www.atmarkit.co.jp/ait/articles/0405/20/news085_3.html
# NBNSのコンピュータ名のエンコード
# http://codezine.jp/article/detail/192


# 文字列のニブルエンコードを行う
# name   : string
# return : bytes
def NibbleEncode(name) :
    if len(name) > 15 :                 # 文字列は15文字以下でなければならない
        raise ValueError("name too long")

    # 大文字に変換
    name = name.upper()
    
    # 15文字に足りない分はスペースで埋める
                 # 123456789012345
    name = name + "               "     # 後ろにスペースをくっつけて
    name = name[0:15]                   # 15文字にする
    # print('"%s"'% (name))
    
    name_byte = name.encode('ascii')         # バイト列に変換
    name_byte += b'\0'                       # 後ろにidとして00をつける
    
    name_nibble=""              # 文字列初期化
    for i in range(len(name_byte)) :
        name_nibble += chr((name_byte[i] >> 4  ) + 0x41)    # ord('A') = 0x41 
        name_nibble += chr((name_byte[i] & 0x0f) + 0x41)
    # print('"%s"' % (name_nibble))
    Q_name = b'\x20'                            # size
    Q_name += name_nibble.encode('ascii')       # ニブルエンコードしたものをバイト列に変換
    Q_name += b'\x00'                           # terminate
    return Q_name


# ニブルエンコードされたバイト列のデコードを行う
# name   : bytes
# return : string, int
def NibbleDecode(name) :
    if not len(name) == 34 :                            # サイズは34バイトでなければならない(1+32+1)
        raise ValueError("name length is wrong")
    if not (name[0] == 0x20 and name[-1] == 0x00) :     # 先頭は0x20、末尾は0x00でなければならない
        raise ValueError("data is wrong")

    # 名前本体部分を取り出し
    name_byte = name[1:-1]
    
    # 0~15文字目をデコード
    name_decpded = ""
    for i in range(15) :                        # 15 = int(len(name_byte) / 2) - 1
        data_u = name_byte[i * 2    ] - 0x41    # ord('A') = 0x41 
        data_l = name_byte[i * 2 + 1] - 0x41
        name_decpded += chr((data_u << 4) + data_l)
    
    # 16文字目は識別子
    data_u = name_byte[15 * 2    ] - 0x41
    data_l = name_byte[15 * 2 + 1] - 0x41
    id = (data_u << 4) + data_l
    
    # print('"%s" <%02x>' % (name_decpded, id))
    # デコードした名前の右側の空白を削除して返す
    return name_decpded.rstrip(), id

# IPアドレスを32bit数値化(Cのinet_addrと同等の処理)
def inet_addr(addr) :
    if not addr.count('.') == 3 :
        # '.' が3つでないとIPアドレスではない
        raise ValueError("IP address format error")
    tmp = addr.split('.')       # '.' を区切り文字として分割
    n0 = int(tmp[0])            # それぞれを数値化
    n1 = int(tmp[1])
    n2 = int(tmp[2])
    n3 = int(tmp[3])
    if n0 > 255 or n1 > 255 or n2 > 255 or n3 > 255 :
        # 255より大きい値はIPアドレスでない
        raise ValueError("IP address format error")
    return (n0 << 24) | (n1 << 16) | (n2 << 8) | n3 

# 問い合わせパケットの作成
def makePacket(transaction_id, name) :
    OP_flag = 0x0110      # flag           15: R   14-11: OPCODE   10-4: NMflag 3-0: RCODE
        # 15    : R        0:request
        # 14-11 : OPCODE   0000  query
        # 10-4  : NMflag   0010001 
        #                  ||||  +--- B broadcast
        #                  |||+------ RA
        #                  ||+------- RD
        #                  |+-------- TC
        #                  +--------- AA
        # 3-0   : RCODE    0000
    QD_count = 1        # questions
    AN_count = 0        # answers
    NS_count = 0        # authorities
    AR_count = 0        # additionals
    Q_name = NibbleEncode(name)         # ニブルエンコードしてバイト列に変換
    Q_type = 0x0020     # 0x0020:  NB
    Q_class = 0x0001    # 0x0001:  Internet class

    # 質問エントリ
    q_entry  = Q_name
    q_entry += struct.pack("!HH", Q_type, Q_class)

    pack = struct.pack("!HHHHHH", transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count)
    pack += q_entry
    # dumpData(pack)
    return pack



# 応答パケットの解析
def analysisPacket(pack) :
    # 応答パケットの先頭部分の解析
    (transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count) = struct.unpack_from("!HHHHHH", pack, 0)
    # print("id=%04x  flag=%04x   QD=%04x   AN=%04x   NS=%04x   AR=%04x" % (transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count))
    if not (QD_count == 0 and AN_count == 1 and NS_count == 0 and AR_count == 0) :
        # 応答レコード数1だけ対応
        raise ValueError("response value not expected")
    
    # 本来はIDのチェックを行うべきだが、ローカルポートなので、他のデータは入ってこないハズなので省略
    
    res_record = pack[12:]        # 応答レコード部分を取り出し
    # dumpData(res_record)
    RR_name = res_record[0:34]          # 名前部分を取り出して
    name, id = NibbleDecode(RR_name)    # デコード
    # print('"%s" <%02x>' % (name, id))

    (RR_type, RR_class, RR_TTL, RD_length) = struct.unpack_from("!HHLH", res_record, 34)
    # print("RR_type=%04x  RR_class=%04x  RR_TTL=%08x  RD_length=%04x" % (RR_type, RR_class, RR_TTL, RD_length))
    # RD_lengthにIPアドレス部分の応答データサイズが入っている
    tmp = res_record[34+10:]
    ipaddr_strs = []
    for i in range(RD_length // 6) :    # IPアドレス1個あたりのデータは6Byte(Flag2ByteとIPアドレス4Byte)
        # ipaddr = tmp[i*6+2:i*6+6]
        # ipaddr_strs.append(socket.inet_ntoa(ipaddr))
        ipaddr_str = '%d.%d.%d.%d' % (tmp[i*6+2], tmp[i*6+3], tmp[i*6+4], tmp[i*6+5])
        ipaddr_strs.append(ipaddr_str)
    
    return ipaddr_strs

def get_addr(name, address, mask, timeout=10) :
    # 乱数の代わりに時刻を使う
    id = int(time.time()) & 0xffff
    
    # 問い合わせパケットの作成
    pack = makePacket(id, name)
    try :
        # micropythonはこれでaddrを取得しないとエラーになる
        addr = socket.getaddrinfo(address, 137)[0][-1]
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        s.sendto(pack, addr)
        try :
            s.settimeout(timeout)
        except :
            # linux版 micropythonはsettimeoutできない
            print("can not set socket timeout")
        repl, addr = s.recvfrom(512)
        # print("addr:" + str(addr))
        # dumpData(repl)
        s.close()
    except Exception as e:
        s.close()
        # 失敗したらNoneを返す
        print(e)
        return None
    
    # 受信データの解析
    ipaddrs = analysisPacket(repl)
    # WindowsだとNICが複数あると複数のデータが返ってくる
    # for i in range(len(ipaddrs)) :
    #     print("%d %s" % (i, ipaddrs[i]))
    
    # サブネットマスクをintに変換した値
    mask_addr = inet_addr(mask)
    # サブネットアドレスをintに変換した値
    subnet_addr = inet_addr(address) & mask_addr;
    # print("%08x   %08x" %(subnet_addr, mask_addr))
    
    # サブネットアドレスに一致するアドレスを検索する
    for i in range(len(ipaddrs)) :
        tmp = inet_addr(ipaddrs[i]) & mask_addr
        if tmp == subnet_addr :
            # 見つかった
            return ipaddrs[i]
    
    # 見つからなかったらNoneを返す
    return None

 

で、使い方はこんな感じ。
addressにブロードキャストアドレス、maskにサブネットマスクを指定し
nameにマシン名(コンピュータ名)を指定してnbns.get_addr()を呼び出すと
対象マシンのIPアドレスの「文字列」が返ります。
nameで指定した名前のマシンが見つからない場合は、Noneが返ります。
マシン名は大文字/小文字の区別は行いません。これはNetBTの仕様で、すべて大文字として取り扱われます。
マシン名に全角文字は指定できません。これはNetBTでは全角文字をShiftJISで管理していますがmicropythonではShiftJISを扱えないためです。

import nbns

address = "192.168.78.255"
mask    = "255.255.255.0"
name = "myPC"
print("==== %s ====" % name)
ipaddr = nbns.get_addr(name, address, mask)
print("ipaddr[%s] = %s" % (name, ipaddr))

 

結果はこんな感じで表示されます。

==== myPC ====
ipaddr[myPC] = 192.168.78.66

 

マシンが見つからなかったときなどのタイムアウト時間はパラメータtmoutで指定(単位:秒)できます。
デフォルトは10秒です。
以下の例ではタイムアウトまでの時間を3秒にしています。

ipaddr = nbns.get_addr(name, address, mask, timeout=3)

戻り値は文字列なので、得られたIPアドレスは socket.getaddrinfo()にそのまま渡すことができます。

このプログラムはLinux版micropythonやpython3(3.5.2で確認)でも動作します。
しかし、Linux版micropythonではsocketのタイムアウト時間を指定できません。よって、応答がなければ永遠に待ち続けてしまいますので注意してください。

 

以下解説。

 

ニブルエンコード処理部分です。
文字列を入力すると、ニブル変換したバイト列を返します。

def NibbleEncode(name) :
    ・・・・

ニブルデコード処理部分です。
ニブル変換されたバイト列を入力すると、名前の文字列と識別子を返します。

def NibbleDecode(name) :
    ・・・・

 

ニブル変換については以下のページの真ん中あたりに説明があります。

NetBIOSネームサービスでネットワーク内の端末をリアルタイムに列挙する:CodeZine(コードジン)

 

IPアドレス("xx.xx.xx.xx")の数値部分を接続して32bit整数値を生成します。
C言語のinet_addrと同等の処理です。
サブネットアドレスの比較を行いやすいようにIPアドレスのstring型をint型に変換したいときに使用します。

def inet_addr(addr) :
    ・・・・

 

問い合わせパケットの生成処理です。
名前部分以外固定値なので、b'~~'の固定値で書いても良いのですが、
分かりやすさを重視して各パラメータを設定したあと、struct.pack()でパケットを組み立てています。

def makePacket(transaction_id, name) :
    ・・・・

 

応答パケットの解析処理です。

def analysisPacket(pack) :
    ・・・・

応答パケットの解析処理はちょっと処理が多いので、さらに詳しく。
簡単にするため、応答レコード数=1、その他レコード数=0 のパケットのみ対応してしています。

    (transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count) = struct.unpack_from("!HHHHHH", pack, 0)
    if not (QD_count == 0 and AN_count == 1 and NS_count == 0 and AR_count == 0) :
        # 応答レコード数1だけ対応
        raise ValueError("response value not expected")

 

名前部分を取り出してデコードしていますが、使ってないのでコメントアウトしてしまっても良いでしょう。

    RR_name = res_record[0:34]          # 名前部分を取り出して
    name, id = NibbleDecode(RR_name)    # デコード

 

名前以降の固定長部分を取り出しています。
以降で参照しているのはRD_lengthだけです。

    (RR_type, RR_class, RR_TTL, RD_length) = struct.unpack_from("!HHLH", res_record, 34)

 

RD_length 以降にフラグ(2Byte)とIPアドレス(4Byte)が入っているのですが、このデータが1つとは限りません。
複数のNICが接続されている場合、NICの数だけデータが入っています(Windowsの場合)。
したがって、(RD_length // 6) でデータ組数を求めて、すべてのIPアドレスをリストに格納しています。
ここではフラグは使用しないので、フラグ部分(tmp[i*6+0]、tmp[i*6+1])は読み込んでいません。
Pythonだと、socket.inet_ntoa()でスマートに変換できるのですが、microputhonにはないので、泥臭い手法で行っています。

    tmp = res_record[34+10:]
    ipaddr_strs = []
    for i in range(RD_length // 6) :    # IPアドレス1個あたりのデータは6Byte(Flag2ByteとIPアドレス4Byte)
        # ipaddr = tmp[i*6+2:i*6+6]
        # ipaddr_strs.append(socket.inet_ntoa(ipaddr))
        ipaddr_str = '%d.%d.%d.%d' % (tmp[i*6+2], tmp[i*6+3], tmp[i*6+4], tmp[i*6+5])
        ipaddr_strs.append(ipaddr_str)

取得したIPアドレスのリストを返します。

    return ipaddr_strs

応答パケットの解析処理はここまで。

 

このモジュールのAPIです。
タイムアウトのデフォルト値はここで10に設定しています。

def get_addr(name, address, mask, timeout=10) :
    ・・・・

APIも中身を詳しく見ていきましょう。
idは問い合わせパケットを識別するためのユニークな値が入っていれば良いので、時刻データで代用しています。
ただ、このモジュールでは複数の問い合わせパケットを同時に送受信することはないので、固定値でも良いかもしれません。

    id = int(time.time()) & 0xffff

 

問い合わせパケットを作成します。

    pack = makePacket(id, name)

 

UDPソケットを生成してブロードキャストに設定し、データを送信しています。

        addr = socket.getaddrinfo(address, 137)[0][-1]
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        s.sendto(pack, addr)

 

応答がなかったときのために、受信タイムアウトを設定しています。
linux版micropythonではsocket.settimeout()は用意されていないので例外が発生します。
例外が発生したときはタイムアウトを設定できない旨表示して処理を続けます。

        try :
            s.settimeout(timeout)
        except :
            print("can not set socket timeout")

 

応答を受信します。
replに受信データが、addrに送信元アドレスが入ります。

        repl, addr = s.recvfrom(512)

実質、ここのaddrが取得したいIPアドレスだという気もしますが。。。

 

送受信処理の例外処理部です。
実質、受信タイムアウト処理です。ソケットをcloseしてNoneを返しています。

    except Exception as e:
        s.close()
        print(e)
        return None

 

受信パケットを解析してIPアドレスのリストを取得します。

    ipaddrs = analysisPacket(repl)

 

サブネットマスクとサブネットアドレスをint型に変換します。
mask:"255.255.255.0" (0xff.0xff.0xff.0x0) → 0xffffff00
addr:"192.168.78.255" (0xc0.0xa8.0x4e.0xff) → 0xc0a84eff
subnet_addr : 0xc0a84eff & 0xffffff00 → 0xc0a84e00

    mask_addr = inet_addr(mask)
    subnet_addr = inet_addr(address) & mask_addr;

 

取得したIPアドレスのリストからサブネットアドレスに一致するIPアドレスを検索します。
処理としては、IPアドレスサブネットマスクでマスクし、サブネットアドレスと一致するものを探します。
見つかったらそのIPアドレスを返します。
もし、複数のサブネットアドレスに一致するIPアドレスがあっても、最初に出てきたものが有効になります。

    for i in range(len(ipaddrs)) :
        tmp = inet_addr(ipaddrs[i]) & mask_addr
        if tmp == subnet_addr :
            return ipaddrs[i]

 

見つからなかったらNoneを返します(ここには来ないハズ)。

    return None

 

micropython on ESP32 でDAC

今更だけど備忘録として。

ESP32でアナログ電圧を出力するのにDAC(Digital-Analog Converter)を使用できます。
DAC出力端子として、Pin25、Pin26を指定できます。
それ以外の端子を指定すると例外ValueErrorがraise されます。
初期化は以下のように実行します。
初期化直後の出力レベルは0Vです。

import machine

pin25 = machine.Pin(25,machine.Pin.OUT)
dac0 = machine.DAC(pin25)

 

DACの出力レベルを変更するには以下のように実行します。
値は0~255が指定可能で、指定値/256* VDD が出力されます(ま、精度はアレなので、大体ね)。
範囲外の値を指定すると例外ValueErrorがraise されます。

dac0.write(100)

 

設定端子を確認するには以下のように実行します。
現在の出力レベルは取得できないようです。

print(dac0)
    ==> DAC(Pin(25))