node_canvasを使って温湿度計を作る

node_canvasというnpmモジュールがありまして、HTML5のCanvasに似せて作ったそうです。これを使ってみたいと思い、ネタを探しておりました。

node-canvas
　https://github.com/Automattic/node-canvas

一方で、ちょうど使っていないガジェットがありました。

USB LED文字盤：J842-3 LED 4MOJI
　https://www.pc-koubou.jp/products/detail.php?product_id=568334

（今はもう売っていませんが。。。）
これは、11×44個のLEDが文字盤のようになっていて、USBに接続すると仮想COMポートとして見え、ドットマトリクス液晶のようにドット絵を描くことができます。

温湿度のセンサーはというと、すでに以下で稼働中です。定期的にMQTTでPublishしています。

　[Xiaomi Mijia 温湿度計 をIoTデバイスとして使う](https://qiita.com/poruruba/items/d16b58dfb45e513111d6)

これら組み合わせると、温湿度計ができあがります。

① Raspberryに USB LED文字盤をつなげ、MQTTをSubscribeしておきます。
② 定期的に、Xiaomi Mijia温湿度計のアドバタイジングデータをスキャンし、温湿度をPublishします。
③ Pulishされた温湿度を受信すると、LED文字盤に温度と湿度を表示します。

②では、以下のトピックで温湿度データがPublishされてきます。

> トピック名：awsiot/mijia
> メッセージ内容：
> {
>   　productId: 426,
>   　counter: 125,
>   　mac: 'XXXXXXXXXXXX',
>   　tmp: 20.2,
>   　hum: 41.4,
>   　createdat: 1546343788634,
>   　createdatstr: '2019-1-1 20:56:28'
> }

# npmセットアップする

利用するnpmモジュールは以下の通りです。

* canvas
* dotenv
* mqtt
* serialport

canvasのnpmモジュールをインストールする前に、いくつかのセットアップが必要です。
私の環境では、以下が不足していましたので、事前にインストールしておきました。

```
apt-get install libpixman-1-dev
apt-get install libcairo2-dev
apt-get install libpango1.0-dev
apt-get install libjpeg-dev
```

serialportについて補足すると、USB LED文字盤は、USB接続すると仮想COMポートとして見えます。Prolificのチップを使っているようで、特に追加のドライバのインストールは不要でした。

# フォントをセットアップする

node_canvasでは、文字表示にTTFフォントを使うことができます。
今回のUSB LED文字盤の表示領域は44×11ドットと小さいため、普通のフォントを使うと文字が欠けてしまいます。そこで、10px程度を想定したフォントを使いました。
以下のフォントを使わせていただきました。（ありがとうございました）

PixelMplus（ピクセル・エムプラス） ‥ 8bitビットマップふうフリーフォント
　http://itouhiro.hatenablog.com/entry/20130602/font

これをダウンロードしておきます。

# 実装

まずフォルダを用意し、npmモジュールをインストールします。

```
mkdir mqtt_disp
cd mqtt_disp
npm init -y
npm install --save canvas
npm install --save dotenv
npm install --save mqtt
npm install --save serialport
```

フォントとして、10pxをベースに作られた「PixelMplus10-Regular.ttf」をカレントフォルダにコピーしておきます。

まずは、USB LED文字盤を制御するためのクラスライブラリを作りました。

```:minidisp.js
'use strict';

var SerialPort = require('serialport');

class MiniDisplay{
    constructor(port, num = 1, width = 44){
        this.MINIDISP_EFFECT_LEFT = 0x00;
        this.MINIDISP_EFFECT_RIGHT = 0x01;
        this.MINIDISP_EFFECT_UP = 0x02;
        this.MINIDISP_EFFECT_DOWN = 0x03;
        this.MINIDISP_EFFECT_FIX = 0x04;
        this.MINIDISP_EFFECT_ANIME = 0x05;
        this.MINIDISP_EFFECT_SNOW = 0x06;
        this.MINIDISP_EFFECT_MIDDLE = 0x07;
        this.MINIDISP_BLINK = 0x08;
        this.MINIDISP_MARQUEE = 0x80;

        this.num = num;
        this.width = width;
        this.height = 11;
        this.unit = Math.ceil(this.width / 8);
        this.ctrls = [];
        this.images = [];
        for( var i = 0 ; i < this.num ; i++ ){
            this.ctrls.push(this.MINIDISP_EFFECT_FIX);
            var tmp = new Buffer(this.unit * this.height);
            tmp.fill(0);
            this.images.push(tmp);
        }

        this.null_buffer = new Buffer(this.unit);
        this.cmd_header = new Buffer(6 + 8 + 1 + 4 * 8);
        const hello = new Buffer('Hello');
        hello.copy(this.cmd_header);
        this.sp = new SerialPort(port, {
            baudRate: 4800
        });
    }

    set_speed(no, speed){
        if( no >= this.num || no < 0 )
            return;
        this.ctrls[no] = ((speed & 0x07) << 4) | (this.ctrls[no] & 0x8f);
    }

    set_effect(no, effect){
        if( no >= this.num || no < 0 )
            return;
        this.ctrls[no] = (effect & 0x8f) | (this.ctrls[no] & 0x70 );
    }

    put_pixel(no, x, y, val){
        if( no >= this.num || no < 0 )
            return;
        if (x >= this.width || x < 0 || y >= this.height || y < 0)
    		return;

        if (val)
            this.images[no][this.unit * y + Math.floor(x / 8)] |= (0x01 << (7 - (x % 8)));
        else
            this.images[no][this.unit * y + Math.floor(x / 8)] &= ~(0x01 << (7 - (x % 8)));
    }

    get_pixel(no, x, y){
        if( no >= this.num || no < 0 )
            return -1;
        if (x >= this.width || x < 0 || y >= this.height || y < 0)
            return -1;
        
        var tmp = this.images[no][this.unit * y + Math.floor(x / 8)];
        return ((tmp >> (7 - (x % 8)) & 0x01 ) & 0x01 ) == 0x00 ? 0 : 1;
    }

    clear_image(no){
        if( no >= this.num || no < 0 )
            return -1;
        this.images[no].fill(0);
    }

    async minidisp_display(){
        console.log('minidisp_display called');

        var i;
        for (i = 0; i < this.num; i++)
            this.cmd_header[6 + i] = this.ctrls[i];
        for (; i < 8; i++)
            this.cmd_header[6 + i] = 0x00;
        this.cmd_header[6 + 8] = 0x00;
    
        var offset = 0;
        for (i = 0; i < this.num; i++){
            this.cmd_header[6 + 8 + 1 + 4 * i] = 0x08;
            this.cmd_header[6 + 8 + 1 + 4 * i + 1] = offset;
            this.cmd_header[6 + 8 + 1 + 4 * i + 2] = 0x00;
            this.cmd_header[6 + 8 + 1 + 4 * i + 3] = Math.ceil(this.width / 8);
            offset += this.cmd_header[6 + 8 + 1 + 4 * i + 3];
        }
        for (; i < 8; i++){
            this.cmd_header[6 + 8 + 1 + 4 * i] = 0x08;
            this.cmd_header[6 + 8 + 1 + 4 * i + 1] = offset;
            this.cmd_header[6 + 8 + 1 + 4 * i + 2] = 0x00;
            this.cmd_header[6 + 8 + 1 + 4 * i + 3] = 0x00;
        }
    
        await sp_write(this.sp, this.cmd_header, 0, this.cmd_header.length);

        await wait_async(200);
    
        var j;
        for (i = 0; i < 11; i++){
            for (j = 0; j < this.num; j++){
                await sp_write(this.sp, this.images[j], this.unit * i, this.unit);
            }
        }

        for (j = 0; j < this.num; j++){
            await sp_write(this.sp, this.null_buffer, 0, this.unit );
        }
    }
};

function wait_async(timeout){
	return new Promise((resolve, reject) =>{
		setTimeout(resolve, timeout);
	});
}

function sp_write(sp, buffer, offset, length){
    var tmp = buffer.slice(offset, offset + length );
    return new Promise((resolve, reject) =>{
        sp.write(tmp, 'binary', (error) =>{
            if( error )
                return reject(error);
            resolve(length);
        });
    })
}

module.exports = MiniDisplay;
```

コンストラクタは、2つの引数を受け取ります。
　port：USBデバイス名です。
　num：画面数です。2以上の場合には、2つの画面が交互に文字盤に表示されます。
　width：画面の横の長さです。44以上の場合には、画面が長すぎて1度に文字盤に表示できないので、左右に文字を流すことができます。

そして、それを使ってMQTTをSubscribeします。

```:index.js
'use strict';

require('dotenv').config();

var mqtt = require('mqtt');
var Canvas = require('canvas');
const MiniDisplay = require('./minidisp');

const PORT_NAME = process.env.PORT_NAME || 【USB LED文字盤のUSBデバイス名】;
const MQTT_HOST = process.env.MQTT_HOST || 【MQTTサーバのホスト名】;
const MQTT_TOPIC = process.env.MQTT_TOPIC || 【Subscribeするトピック名】;
const FONT_FNAME = process.env.FONT_FNAME || 【TTFフォントファイル名】;

var disp = new MiniDisplay(PORT_NAME, 2);
disp.set_speed(0, 0);
disp.set_speed(1, 0);
var canvas = Canvas.createCanvas(disp.width, disp.height * 2);
var ctx    = canvas.getContext('2d');
ctx.antialias = 'none';
ctx.textBaseline = 'top';

Canvas.registerFont(FONT_FNAME, { family: 'MyFont' });
//ctx.font = '16px Sans-serif';
ctx.font = '10px "MyFont"';

var called = false;

console.log('MQTT_HOST=' + MQTT_HOST);
console.log('MQTT_TOPIC=' + MQTT_TOPIC);
var client = mqtt.connect(MQTT_HOST);

client.subscribe(MQTT_TOPIC, (err, granted) => {
    if( err ){
        console.log(err);
        return;
    }
    console.log('subscriber.subscribed.');
});

client.on('message', async (topic, message) =>{
    if( called )
        return;
    called = true;

    console.log('subscriber.on.message', 'topic:', topic);

    var payload = JSON.parse(message.toString());
    console.log(payload);

    if( !payload.tmp || !payload.hum ){
        console.log('date not notified');
        called = false;
        return;
    }

    ctx.fillText("温度 " + payload.tmp, 0, 0);
    ctx.fillText("湿度 " + payload.hum, 0, disp.height);

    var imageData1 = ctx.getImageData(0, 0, disp.width, disp.height);
    var imageData2 = ctx.getImageData(0, disp.height, disp.width, disp.height);

    for (var y = 0; y < disp.height; y++ ) {
        for (var x = 0; x < disp.width; x++) {
            var val1 = to_mono(imageData1.data[(x + y * disp.width) * 4], imageData1.data[(x + y * disp.width) * 4 + 1], imageData1.data[(x + y * disp.width) * 4 + 2], imageData1.data[(x + y * disp.width) * 4 + 3]);
            disp.put_pixel(0, x, y, val1);
            var val2 = to_mono(imageData2.data[(x + y * disp.width) * 4], imageData2.data[(x + y * disp.width) * 4 + 1], imageData2.data[(x + y * disp.width) * 4 + 2], imageData2.data[(x + y * disp.width) * 4 + 3]);
            disp.put_pixel(1, x, y, val2);
        }
    }
    
    await disp.minidisp_display();
    console.log('displayed');

    ctx.clearRect(0, 0, disp.width, disp.height * 2);
    disp.clear_image(0);
    disp.clear_image(1);

    called = false;
});

function to_mono(r, g, b, a){
    var grey = Math.round( r * 0.299 + g * 0.587 + b * 0.114 );
    if( a > 128 || grey > 128)
        return 1;
    else
        return 0;
}
```

以下を環境に合わせて変更する必要があります。

【USB LED文字盤のUSBデバイス名】：おそらく「/dev/tty/ttyUSB0」かと思います。
【MQTTサーバのホスト名】 ：Mosquittoサーバを立ち上げたホスト名です。
【Subscribeするトピック名】：以前の投稿通りであれば、「awsiot/mijia」です。
【TTFフォントファイル名】：「PixelMplus10-Regular.ttf」とします。

補足すると、以下の通り進みます。

* CANVASをインスタンス化します。
* MQTT受信すると、CANVASに温湿度の文字列を描画します。
* CANVASからイメージデータを生成します。
* イメージデータをモノクロにしてUSB LED文字盤に出力します。

# 実行

Raspberry Piに、USB LED文字盤を接続します。
dmesgを実行すると、検出し、ttyUSB0がアタッチされていることがわかります。

```
[127270.880222] Indeed it is in host mode hprt0 = 00021501
[127271.090035] usb 1-1: new full-speed USB device number 16 using dwc_otg
[127271.090299] Indeed it is in host mode hprt0 = 00021501
[127271.331248] usb 1-1: New USB device found, idVendor=067b, idProduct=2303
[127271.331267] usb 1-1: New USB device strings: Mfr=1, Product=2, SerialNumber=0
[127271.331276] usb 1-1: Product: USB-Serial Controller
[127271.331283] usb 1-1: Manufacturer: Prolific Technology Inc.
[127271.350945] pl2303 1-1:1.0: pl2303 converter detected
[127271.353270] usb 1-1: pl2303 converter now attached to ttyUSB0
```

Raspberry Piで、以下の通り、実行します。

> node index.js

以降、起動し続け、MQTTでSubscribe状態となり、Publish受信するごとに、USB LED文字盤の表示を切り替えるはずです。

別のホストから、Xiaomi Mijiaを検出し、温湿度をMQTT Publishしてみてください。温度と湿度が交互に表示されれば成功です。

以上です。