0

Web+DB Press vol.84でTesselについて書いた

前:Web+DB Press vol.83でHueについて書いた


書いた

今日発売の84号に、JavaScriptが動く、Node.js互換環境が入ってるマイコンボードTesselについて書いた。

Tesselは実行速度(というか拡張ボードと本体間のバスにUARTが使われてるところ)がかなり遅い事を除けば本当に理想的なマイコンボードな感じがする。拡張ボードに色々用意されているセンサー類のドライバもnpmで管理・インストール出来るし、必要があれば自分で機能拡張する事もできる。(例:tesselに日本語を喋らせるnpmライブラリ作った

もちろん既存のnpm資産も併用できるので、今回の記事ではtesselでの開発の簡単な説明のほかにセンサーで明るさを計ってYoやslackに通知するような話を書いた。

調度良いタイミングで最近スイッチサイエンスでも扱われるようになったし、ほんと実行速度がもう少し速ければすごい流行ると思う。

WEB+DB PRESS Vol.84
WEB+DB PRESS Vol.84
posted with amazlet at 14.12.23
藤 吾郎 桑野 章弘 福永 亘 谷井 靖史 野村 晋之介 蛭川 皓平 岡田 友輔 藤本 真樹 伊藤 直也 宮崎 靖彦 佐藤 健太 高橋 俊幸 佐藤 太一 海野 弘成 佐藤 歩 泉水 翔吾 渡邊 恵太 舘野 祐一 中島 聡 橋本 翔 はまちや2 竹原 伊賀敏樹
技術評論社
売り上げランキング: 455

0

tesselに日本語を喋らせるnpmライブラリ作った

そういえばtesselに日本語を喋らせるをnpmにしたのだった
書くの忘れてた

https://www.npmjs.org/package/audio-vs1053b-textspeech


インストール

% npm install audio-vs1053b-textspeech


使う

まずaudioモジュールにスピーカーを接続しておく。

こんな感じでaudioモジュールをtextspeechモジュールに食わせてセットアップし、
var audio = require('audio-vs1053b').use(tessel.port['A']);
var textspeech = require('audio-vs1053b-textspeech').use(audio);

audio.on('ready', function(){
audio.setVolume(20, function(err){ // 音量調整
console.log('audio setup');
// この後に喋ったりさせる
});
});


簡単に喋らせれる。日本語でおk
textspeech.speech('こんにちは'); // japanese
textspeech.speech('hello world', {tl: 'en'}); // english

なお初めてしゃべる言葉はgoogle翻訳にmp3を取りに行くので10秒ぐらいかかる。
2回目以降はオンメモリのキャッシュに入っているので、すぐ発声できる。
でもaudioモジュールの通信がUARTなので、同時に他のセンサ系モジュール使ったりしてるとそっちのUARTもバリバリ通信おこなわれて、途切れ途切れになってちゃんと喋れなくなる。

tesselはaudioとかカメラとか大きなマルチメディアデータやりとりする系のモジュールがウリっぽい所あるのに、それ使ってると他のモジュールが動かなくなるのはなんかつらい。そもそもtesselのCPUであるARMから拡張ボード用に4つUART出してるけど、たぶん全部ソフトウェアUARTだから、ファームウェアの実装を効率化してもどうにかなる気がしない。どうするんだろ。


あとキャッシュのサイズも指定できる
textspeech.setCacheSize(5); // default is 3


前に書いたとおり(おそらく)streamのバグがある状態の上で妥協する感じで実装しているので、そのうち仕様変わるかもしれないので最新情報はnpmjs.orgのほうのドキュメント見てください

0

tesselに日本語を喋らせる

tesselのaudioモジュールを使うとLine出力でスピーカーに接続し、音がだせる。

再生できるのはmp3とwavファイル。


普通のmp3を再生


こんなコードで再生できる。tesselで実行するjsファイルと同じ階層に”cabbage.mp3″をを置いたら、fs.readFileSyncで読めた。実装をちゃんと追ってないから詳細はわからないがファイルシステムがあるらしい。

tessel-study/audio-play-mp3 at master · shokai/tessel-study
var tessel = require('tessel');
var fs = require('fs');
var audio = require('audio-vs1053b').use(tessel.port['A']);

audio.on('ready', function(){
console.log('audio ready');
audio.setVolume(20, function(err){
if(err) return console.error(err);
var data = fs.readFileSync('cabbage.mp3');
setInterval(function(){
audio.play(data, function(err){
if(err) return console.error(err);
console.log('audio done');
});
}, 2000);
});
});


日本語の音声を再生


強引だけどgoogle翻訳のmp3を取得して再生できた。ただし再生まで10秒ぐらいかかる。

tessel-study/audio-google-say at master · shokai/tessel-study
var tessel = require('tessel');
var fs = require('fs');
var stream = require('stream');
var request = require('request');
var wifi = require('wifi-cc3000');
var audio = require('audio-vs1053b').use(tessel.port['A']);

var led_green = tessel.led[0].output(1);
setInterval(function(){
led_green.toggle()
}, 200);

var getAudioStream = function(speech_text){
return request.get({
uri: 'http://translate.google.com/translate_tts',
qs: {
q: speech_text,
tl: 'ja'
},
headers: {
'User-Agent': 'Safari/1.0'
}
});
};

var say = function(speech_text){
console.log('say:'+speech_text);
if(!wifi.isConnected()){
console.error('wifi is not connected');
return;
}
var buf = new Buffer(10240);
var offset = 0;
var ws = stream.Writable({decodeStrings: false});
ws._write = function(chunk, enc, next){
if(chunk.length > buf.length - offset){
return next(new Error('buffer over'));
}
buf.write(chunk, offset, buf.length - offset);
offset += chunk.length;
next();
};
var req = getAudioStream(speech_text);
req.pipe(ws);
req.on('end', function(){
audio.play(buf);
});
};

audio.on('ready', function(){
console.log('audio ready');
audio.setVolume(20, function(err){
if(err) return console.error(err);
audio.emit('ready:volume');
});
});

audio.on('ready:volume', function(){
console.log('audio ready:volume');
if(err) return console.error(err);
setInterval(function(){
say('うどん居酒屋 かずどん');
}, 30*1000);
say('焼肉ざんまい');
});

stream


本当はaudio.createPlayStream()を使いたいんだけど、ビミョーに再生できるのに音声の最後が切れてしまう。音声の長さに関係なく、最後が切れる。
getAudioStream("ざんまい").pipe(audio.createAudioStream());

audio.createPlayStreamの実装も見たけど、どうもそっちじゃなくてstreamの実装の方にバグがある気がする。

0

tesselでYo

tesselは普通にnpmが使えるので、Yoとかも送れる

ただ、multipart/form-dataがpostできないのであまり大きな通信はできないみたいだけど、Yoぐらいなら簡単に送れる。

まあYo送るのが簡単なのはどうでもよくて、プログラムの中にAPI Tokenなどの設定値を書きたくないけど実行時に渡すようなのは(環境変数とか)tesselではどうやるのかな、というのを調べたりした。


MacからYoを送る


久しぶりにYo見てみたら、Yo Developer Dashboardからアカウントを作ったり、APIのTOKENを取得できるようになってた。以前のように待たされることなくすぐ作れる。

% npm install yo-api -save
% export YO_TOKEN=a1b2c3defg45678

Yo = require 'yo-api'

yo = new Yo process.env.YO_TOKEN

yo.yo_link 'SHOKAI', 'http://shokai.org', (err, res, body) ->
console.log body


tesselからYoを送る

tessel-study/yo at master · shokai/tessel-study

前に書いたように起動してすぐwifiを再起動して、on ‘connect’後にネットワークを使う処理をするようにする。

var tessel = require('tessel');
var wifi = require('wifi-cc3000');
var Yo = require('yo-api');

var yo_token = process.argv[2];
var yo = new Yo(yo_token);

var led_green = tessel.led[0].output(1);
setInterval(function(){
if(wifi.isConnected()) led_green.toggle()
}, 500);

// wifiを再起動
wifi.reset();

wifi.on('connect', function(){
console.log('wifi connect');
yo.yo('SHOKAI', function(err, res, body){
console.log(err);
console.log(body.toString());
});
});


tesselに引数を渡す

普通API Tokenとかはコード中に書きたくないので、環境変数とか設定ファイルに書く。

tesselの場合、実行時の引数に渡せる。
コード中でprocess.argv[2]でyo_tokenを取得しているが、これは

% tessel run main.js $YO_TOKEN
% tessel push main.js --args=$YO_TOKEN --logs

こうするとargvに渡せる。

package.jsonのscriptsに書いておくとnpm testやnpm startで書き込めて便利。
{
"name": "yo",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"test": "tessel run main.js $YO_TOKEN",
"start": "tessel push main.js --args=$YO_TOKEN --logs"
},
"author": "",
"license": "ISC",
"dependencies": {
"wifi-cc3000": "0.0.0",
"yo-api": "^1.0.0"
}
}

bundleFiles関数でtessel上でのコードにprocess.argvが設定されて、この後Luaに変換されてボードに書き込まれる。
これがtessel pushからはargvが渡らないバグがあったので修正したプルリクを送ったらmergeされた。

tessel runでは今のバージョンでもargv渡せる。tessel pushの方はそのうちnpmが更新されて使えるようになるまで待つか、俺のgithubからインストールするとかする。


nomnom

tessel cliではnomnomというoption parserが使われている。

複数のargvを渡す場合、–argsオプションを複数書く。

% tessel push app.js --args=foo --args=bar --args=baz

これでprocess.argvは
["tessel", "app.js", "foo", "bar", "baz"]
になる。

0

tesselをカメラ付きwebサーバーにする

Node.jsが動くマイコンボードtesselを買ったのでさっそくカメラモジュールwifitiny-routerというWAFを使ってtessel自体をwebサーバーにし、撮影した画像を配信できるようにしてみた。


ライブカメラというほどの速度は出ないけど、tessel単体で定期的な撮影とhttpでの配信ができた。

マイコンでNode.jsが動くとはいっても、例えばTCPのlistenはWiFiチップと通信しているからチップの状態次第で失敗するし、WiFiの設定をしている間にcameraのreadyイベント取りこぼしたりするから色々とタイミングがシビアでつらい所がある。でもNodeなのでeventemitterがあるからそれほどストレスフルではなくて面白い。


tessel


WiFi

tesselのwifiは802.11b/gの1〜11チャンネルしか使えない。WiFiアクセスポイントがチャンネル12〜14の場合接続できないので注意する。

tesselのwifiは弱いので同じチャンネルが混んでいると接続できない。

Macだと
% /System/Library/PrivateFrameworks/Apple80211.framework/Versions/A/Resources/airport -s
で確認できる。


普通のwebサーバー

まず普通のwebサーバーはこんな感じで書ける。これはnodeのhttpモジュールしか使ってないので、Macでもtesselでも動作する。
tessel-study/main.js at master · shokai/tessel-study
var http = require('http');

http.createServer(function(req, res){
res.writeHead(200, {'Content-Type' : 'text/html'});
res.end('<h1>Hello!!</h1>');
}).listen(80);

console.log('server start at PORT: 80');


普通のwebサーバーの起動

tesselをUSBで接続して、Macにnodeをインストール、ボードのファームウェアアップデート、wifiに接続する
% brew install node
% npm install tessel -g
% tessel update
% tessel wifi -n [WiFi AP NAME] -p [PASSWORD] -s wpa2

そして起動
% tessel run http-server.js

これでtesselが普通のwebサーバーになる。簡単。
tesselのボード上での標準/エラー出力もCLIに表示される。

単体で動作させる

runではなくpushするとプログラムをフラッシュメモリに保存して、Macから離しても単体で動くようになる。
% tessel push http-server.js


単体で動かない

動くと思ったら動かない。socketが開けないエラーが出る

Error: ENOENT: Cannot open another socket.



tesselはLuaでNode.js互換の実行環境を実装してあって、そこに手元で書いたjsファイルとnode_modulesディレクトリ以下を全部Luaに変換して送り込んで実行している。tessel上ではjavascriptではなくLuaが動いている。(なのでevalは使えない)

で、netなどのネットワーク周りのライブラリもTCPをListenする関数とかちゃんと実装してあるんだけど、その実体はtessel上のwifiモジュールのICと通信するという事になっている。

このプログラムが動かない原因は、tesselのMPUが起動してnodeが走っていてもまだwifiモジュールが起きていない(or wifiに接続されていない)から、netまわりの関数が実行できていないということのようだ。


単体で動かせるwifi+httpサーバ+カメラモジュールを実装する

という事で色々とマイコンの気持ちになって実行順を考えて実装したらうまく動くようになった。

tesselはカメラやwifiのモジュールのラッパーがnpmになっているので、インストールしておく。アプリケーションのnode_modules/ディレクトリ以下にインストールしておくと全部まとめてLuaに変換されてtesselに転送される。
% npm i wifi-cc3000 camera-vc0706 tiny-router -save


まず工夫した所・ハマりどころを書いておく。

tiny-routerを使う

expressはtesselで動かせなかった。Errorのスタックトレースを読むあたりの関数とか、色々とtesselのNodeではまだ実装されていない部分があって動かない。
tiny-routerという組み込み用の軽量WAFを使うと軽くて良かった。

wifiを毎回リセットして、接続成功してからhttpサーバー立てる

wifiのAPI見ると、自分でwifiをリセットしたり、wifi.on(‘connect’, callback)とかがある。
毎回プログラムが起動するたびにwifiモジュールをリセットして、on “connect”イベントの後でhttpサーバーを立てるとtessel pushでもtessel runでも動かせる。
既にwifi接続していたらすぐhttpサーバー立てる、接続してなかったらon “connect”で立てる、という方がスマートじゃんと思ったけど、開発中のプログラムが不正終了した場合に(たぶん)wifiモジュールが既にTCPをlistenしているのにMPUはlistenしてないような感じになってて最終的にUSBも認識しなくなったりする事が頻発したので、毎回wifiリセットするようにした。

disconnectとconnectだとwifiのパスワードをプログラム内に書かなければならなくなるので良くない。resetならそのまま再接続してくれる。


カメラの解像度

githubの方のcamera-vc0706のドキュメントを見ると、解像度を下げる方法が書いてある。
camera.setResolution("解像度", callback);
で指定できる。vga, qvga, qqvgaが指定できる。
初期値のvgaのままだとMPUとwifiモジュール間の通信速度がせいぜい数十kbpsしかないはずなので、画像が全然落ちてこなくなる。


cameraとwifiを同時にセットアップしない

var camera = require('camera-vc0706').use(tessel.port['A']);
を実行すると、カメラの初期化が始まって、camera.on(‘ready’, callback)などのイベントが呼ばれるようになる。nodeだと並列に色々やりたいから同時にwifiのリセット〜httpサーバの起動などもやりたくなるけど、1つずつやらないと動かない。

カメラセットアップ→wifi再起動→httpサーバー起動
の順にやる事にした。


末尾ループがわりのsetInterval

httpサーバーも起動せず、何もsetInterval等も動いていない状態だとNodeはふつうにプログラムが末尾で終了する。
終了してしまうので、wifiをresetしてもon “connect”も取れなくなる。
とりあえずsetIntervalで常に基板上のLEDを点滅させておくようにした。動作してるか確認にも使えて便利。


プログラム

色々試行錯誤した結果こうなった。eventemitterのおかげでなんとかなっている感じがする。

tessel-study/camera-server.js at master · shokai/tessel-study

// カメラで撮影してWiFi+HTTPサーバーで配信する
// 15秒間隔で撮影する

var tessel = require('tessel');
var wifi = require('wifi-cc3000');
var router = require('tiny-router');
var camera = require('camera-vc0706').use(tessel.port['A']);
var image = null;

var led_green = tessel.led[0].output(1);
setInterval(function(){
if(wifi.isConnected()) led_green.toggle()
}, 500);

// 解像度設定
camera.on('ready', function() {
console.log('camera ready');
camera.setResolution('qqvga', function(err, res){
if(err) throw 'setting camera resolution failed!';
camera.emit('ready:capture');
});
});

// 撮影準備完了
camera.on('ready:capture', function(){
console.log('camera ready:capture');
camera.startCapture(function(err, res){
if(err) return console.error(err);
image = res;
console.log('capture done');
}, 15000); // 15 sec interval
});

// 定期的に撮影する
camera.startCapture = function(callback, interval){
if(typeof interval !== 'number' || interval < 1){
throw 'interval must be number (msec)';
}
camera.takePicture(function(err, res){
if(typeof callback === 'function') callback(err, res);
setTimeout(function(){
camera.startCapture(callback, interval);
}, interval);
});
};

// カメラの準備ができてからwifiを再起動する
camera.on('ready:capture', function(){
wifi.reset();
});

// wifiが接続できたら、httpサーバーを起動する
wifi.on('connect', function(){
console.log('wifi connect');
var port = (process.env.PORT || 80) - 0;
router.listen(port);
console.log('start HTTP server at PORT: '+port);
});

router.get('/', function(req, res){
console.log(req.method + ': ' + req.url);
res.send('camera-server.js');
});

router.get('/camera.jpg', function(req, res){
console.log(req.method + ': ' + req.url);
if(!image){
res.writeHead(500);
res.end('no capture image');
return;
}
res.sendImage(image);
});