0. これは何?
毎年、年末年始の休み期間に新しいことにチャレンジしている。今年はマイコン用のピュアなJavaScript(ECMAScript)開発環境のModdable-SDKを使ってマイコン(ESP32)とサーバ(Linux・Node.js)をWebScocketでつないで見たい。これから、作業とほぼ同時進行で(行き当たりばったりで)この記事を更新して行こうと思う。
この記事のライセンスは「CC BY-NC-SA」です。
=-=-=-=-=-=-=-=
2018.12.30から開始し2019.01.06でひとまず終了です。
今後は図版の誤り修正やプログラムや実行結果の解説の追加とやり残した課題についてゆっくりと更新して行きます。
1. きっかけはRebuild.fm
1.1. JavaScript(ECMAScript)でシームレスにIoTを開発する野望
先年、Raspberry Pi からNode.jsとjohnny-fiveでESP8266とWifi通信させて簡単なIoT実験を行った。このとき、実験システムのプログラムはRaspberry Pi側(サーバ)はNode.jsでJavaScriptを用い、ESP8266側(クライアント)はarduinoのC++を用いた。
ESP8266側でJavaScriptが使えれば2つの言語でプログラムする手間がなくなり便利になるだろうと感じた。また、Web系のフロントエンド・エンジニアがIoTシステムを作るときデバイス側のマイコンがJavaScriptでプログラミングできたら、きっとハードウェア・エンジニアの思いも及ばぬモノを作り上げるのではないかとも思ったりもした。
…と、言うことでマイコンでJavaScript…正式にはECMAScriptが稼働するオープンソースの環境はないか探索を始めた。
JavaScriptのマイコン開発環境は色々ある。micro:bit, ESPruino, Sparcle, Tessel, obniz 等々 しかし特定のマイコンボードに依存せずオープンソースかつフルスペックのECMAScriptが動くものは見つけられなかった。
Node.js + Johnny-five を使えばホスト側(サーバ側)のJavaScriptプログラムだけでマイコンを制御できるが、マイコンが自律稼働しつつホストと連携するようなシステムを構成することは不可能で、マイコン利用のメリットを生かしきることができない。
1.2. ポッドキャスト Rebuild.fm 214回 の”Moddable”の話題
探索をしばし放置していたが、何気に聞いたポッドキャストrebuild.fm 214回 (1:52:12) の @basuke さんの話に釘付けになった。
マイコンで標準JavaScript === ECMAScriptが動く環境があるとのこと。開発元は Moddable Tech. INC. で開発環境”Moddable SDK”はオープンソースで提供されているとか。
さっそく、Moddable Tech. INC.のwebサイトやgithubのmoddableリポジトリを開いて見た。何かすごそうな予感。
ドキュメント「Getting Started」を読みながらModdable SDKをビルドし動かして見ると、予想を遥かに上回る環境でホスト上でマイコンのシュミレータが動く、ソースコード・デバッガがある、ECMAScriptの言語仕様を満足している等々、これを使えば野望を実現できる。
2. インストール・シェル・スクリプトを書いた
Moddable提供元のインストールガイドに沿ってModdable SDKをビルドしたとき、コンパイルエラーが発生したりpythonモジュールの不足でSDKの動作が止まることがあった。その都度ググって対策を講じて不具合なく動くようになった。
これからModdable-SDKを使おうとする人向けに、その時のメモを元にしてエラー無く一気通貫でModdable-SDK環境をセットアップするシェルスクリプトを書いた。
- macOS用 Moddable SDK 簡単インストーラ
- Linux用 Moddable SDK 簡単インストーラ
- PC-Linux用
- arm-Linux(Raspbian)用(ESP-IDF環境構築で苦戦中)
- Windows用は作っていない
3. IoT定番の温度計を作った
SDK付属のexampleを試行しつつ練習してみた。表題をクリックすると詳細ページに移動する。
- ESP32をModdable/ECMAScriptでLちかプログラミング
- M5StackをModdable/ECMAScriptでプログラミング
-
ESP32でModdable/ECMAScriptのREPLを使ってLチカ
サンプルコードmoddable/examples/js/repl/にdigitalモジュールを組み込んだ。 -
Moddable/ECMAScriptで温度ロガー作った
サンプルコードmoddable/examples/pins/analog/を改編し、アナログデータ入力値を温度換算して変数にセーブするようにした。
4. WebSocket接続
温度計ロガーのデータをサーバに投げて、こんなかたちで計測記録をグラフ表示したい。
4.1. 試行システム
4.2. WebSocket接続のテスト
4.2.1. WebSocketプロトコルの勉強
この記事「WebSocketについて調べてみた」を起点にして勉強した。WebSocketプロトコルを実装するモジュールを作る訳ではないので、だいたいの状態遷移と送受するパケット・フレームについて大観した。
4.2.2. Wifiリンク
まず、WifiリンクとIPアドレス他の設定が必須である。Moddable-SDKでESP32にアップロードするパッケージをビルドする際にmcconfig に与える引数でWifi-APのSSIDとパスワードを与える。
$ mcconfig -d -m -p esp32 ssid="WebSocket-TEST" password="12345678"
このことはドキュメントmoddable/documentation/tools/tools.md の mcconfigパート に記載されている。
プログラムからwifiモジュールを呼び出してWifi接続することも可能。
import WiFi from "wifi"
let dummy = new WiFi({ssid: "WebSocket-TEST", password: "12345678"});
4.2.3. IPアドレス
IPアドレスなどはDHCPで取って来る。固定IPアドレスの静的割当の方法は有ると思うがドキュメントを調べた限りでは、その具体的方法を発見できていない。
ESP32の開発ツールESP-IDFにはmDSN機能を提供するライブラリが用意されている。これを使えばホスト名.localでアクセルできるようになりIIPアドレスを知る必要がない。Moddable/ECMAScriptにもmDNSは用意されている。
4.2.4. WebSocketクライアントの新規プロジェクト
なぜか、WebSocketモジュールを使ったクライアントの例がexamplesには無いので自前でWebSocketを利用するプロジェクトを作成することとした。
プロジェクトに最低限必要なのはmain.jsとmanifest.jsonである。manifest.jsonにはmain.jsが利用するモジュール等の組み入れやターゲット・ハードウェア特有の設定情報を記載しておかなければならない。Moddable-SDKはアップロードパッケージをビルドする際に、このjsonファイルを見て必要なモジュールやハードウェアドライバをアップロードパッケージに収容する。manifest.jsonの書式解説(英文)はここにある。
4.2.5. WebSocketリンクのテストコード(Client)
{
"include": [
"$(MODDABLE)/examples/manifest_base.json",
"$(MODDABLE)/examples/manifest_net.json",
],
"modules": {
"*": [
"./main",
"$(MODULES)/data/base64/*",
"$(MODULES)/data/logical/*",
"$(MODULES)/network/websocket/*",
"$(MODULES)/crypt/digest/*",
"$(MODULES)/crypt/digest/kcl/*",
]
},
"preload": [
"websocket",
"base64",
"digest",
"logical"
]
}
次のWebSocketリンク確立までのシーケンスを確かめるコード(main.js)を書く
- Wifiへリンク(mcconfigの引数で設定する)
- サーバに接続
- Websocketハンドシェイク
- Websocketリンク確立
/*
WebSocket test-code for client(Moddable/ECMAScript)
*/
import { Client } from 'websocket'
var host = '172.16.4.1' // Server IP address
var port = 5040 // Server port
let ws = new Client({host, port})
ws.callback = function (message, value) {
trace(`SOCKET MESSAGE ${message} WITH VALUE ${(undefined === value) ? 'undefined' : value} \n`)
switch (message) {
case 1:
trace("main.js: socket connect.\n")
break;
case 2:
trace("main.js: websocket handshake success\n")
break;
case 3:
trace(`main.js: websocket message received: ${value}\n`)
break;
case 4:
trace("main.js: websocket close\n")
break;
}
}
trace(`socket created.\n`)
4.2.6. WebSocketリンクのテストコード(Server)
いろいろ調べて、使い方がシンプルな " ws " を試す。
https://www.npmjs.com/package/ws
var WsServer = require('ws').Server
var webskt = new WsServer({ port: 5040 })
webskt.on('connection', function (ws) {
console.log('Websocket: Connected')
ws.on('message', function (message) {
console.log('Received: ' + message)
webskt.clients.forEach(function (client) {
client.send(message + ' : ' + new Date())
})
})
ws.on('close', function () {
console.log('I lost a client')
})
})
console.log('Websocket server is running')
4.2.7. 実行結果(Clientのログ)
webscketハンドシェイクに成功。 websocketリンクを確立できた。
Wi-Fi connected to "WebSocket-TEST"
IP address 172.16.4.2
main.js (10) # Break: debugger!
socket created.
SOCKET MESSAGE 1 WITH VALUE undefined
main.js: socket connect.
SOCKET MESSAGE 2 WITH VALUE undefined
main.js: websocket handshake success
4.3. WebSocketでJSONフォーマット・データ交換
サーバーはクライアントから送られてきたJSONフォーマットのメッセージを書換えてエコーバックする。
var WsServer = require('ws').Server
// webSocket open
var webskt = new WsServer({ port: 5040 })
// event websocket connete
webskt.on('connection', function (ws) {
console.log('Websocket: Connected')
// event message recieve
ws.on('message', function (value) {
console.log(`Received: ${value}`)
let trxdmsg = JSON.parse(value) // 受信メーセージのJSONをパースしてtrxmsgに格納
if (trxdmsg.message === 'hello') {
trxdmsg.message = 'welcome' // hello だったら welcome に書換え
} else {
trxdmsg.message = 'I am alive.' // そうではないときは I am alive.に書換え
}
trxdmsg.from = 'Server' // from: Serverに書換え
trxdmsg.sequence++ // シーケンス番号を1増やす
webskt.clients.forEach(function (client) {
console.log(' Send: ' + JSON.stringify(trxdmsg))
client.send(JSON.stringify(trxdmsg))
})
})
// event close
ws.on('close', function () {
console.log('I lost a client')
})
})
console.log('Websocket server is running')
クライアントは起動すると
1. Wifi-APに接続(mcconfigの引数で設定する)
2. ブレークポイント1(debugger)で停止する
3. デバッガの操作でブレークポイントから抜けると
2. WebSocketリンクを確立させ
3. メッセージをサーバに送信する
3. サーバからメッセージの着信があるとそれを表示し
4. ブレークポイント2で停止する
5. ブレークポイント2から抜けると
6. メッセージをサーバに送信する。
7. 以下、3〜6を繰り返す。
import { Client } from 'websocket'
var host = '172.16.4.1' // IP address
var port = 5040 // Port number
var nn = 0
var trxdmsg = { from: 'Client', message: 'hello', sequence: nn }
debugger // ブレークポイント1
let ws = new Client({ host, port })
ws.callback = function (message, value) {
switch (message) {
case 1:
trace("Client: socket connect.\n")
break
case 2:
trace("Client: websocket handshake success\n")
trace(" Send: " + JSON.stringify(trxdmsg) + "\n")
ws.write(JSON.stringify(trxdmsg))
break
case 3:
trace(`Received: ${value}\n`)
debugger // ブレークポイント2
trxdmsg = JSON.parse(value)
trxdmsg.from = 'Client'
trxdmsg.message = 'Are you alive?'
trxdmsg.sequence++
trace(' Send: ' + JSON.stringify(trxdmsg) + '\n')
ws.write(JSON.stringify(trxdmsg))
break
case 4:
trace("WebSocket close\n")
break
}
}
trace(`socket created.\n`)
実行結果
狙い通りにクライアント・サーバ間のメッセージ交換ができた。
eps-client.jsのログ
Wi-Fi connected to "WebSocket-TEST"
IP address 172.16.4.2
main.js (12) # Break: debugger!
socket created.
Client: socket connect.
Client: websocket handshake success
Send: {"from":"Client","message":"hello","sequence":0}
Received: {"from":"Server","message":"welcome","sequence":1}
main.js (27) # Break: debugger!
Send: {"from":"Client","message":"Are you alive?","sequence":2}
Received: {"from":"Server","message":"I am alive.","sequence":3}
main.js (27) # Break: debugger!
Send: {"from":"Client","message":"Are you alive?","sequence":4}
Received: {"from":"Server","message":"I am alive.","sequence":5}
main.js (27) # Break: debugger!
node-server.jsのログ
$ node node-server.js
Websocket server is running
Websocket: Connected
Received: {"from":"Client","message":"hello","sequence":0}
Send: {"from":"Server","message":"welcome","sequence":1}
Received: {"from":"Client","message":"Are you alive?","sequence":2}
Send: {"from":"Server","message":"I am alive.","sequence":3}
Received: {"from":"Client","message":"Are you alive?","sequence":4}
Send: {"from":"Server","message":"I am alive.","sequence":5}
ここまでできれば後はサーバ側でexpress使ってESP32から送られてきた温度データを使ったwebアプリを作ったり、twitter-APIを使って温度データを投げるtweet botなどが作れる。
5. 回線断の検出と自動再接続
Wifiなど無線系の接続回線は切れるのが当たり前で、リンクが切れたとき全く人手を介さないで自動再接続する仕組みにしておかないと実用に耐えない。
ここでは、回線断を検知したら再接続ルーチンを自動駆動する仕組みを作る。
5.1. Wifi回線断のリカバー
サーバはいままでのプログラム(node-servet.js)をそのまま使用.
クライアントは、
- Wifi接続しIPアドレスを取得したらWebSocket通信を開始し5秒間隔でメッセージを送信
- Wifiの回線断を検出したら5秒毎に再接続を試みる
- Wifi再接続したら1にもどる
import Timer from 'timer'
import WiFi from 'wifi'
import Net from 'net'
import { Client } from 'websocket'
// Wifi SSID, PassWord
let wifiAp = { ssid: 'WebSocket-TEST', password: '12345678' }
// Server's Socket
let host = '172.16.4.1'
let port = '5040'
// Variables
let recnt = 0 // reconnect counter
let msgcnt = 0 // message counter
// messsage form
let trxdmsg = { from: 'Client', message: 'hello', sequence: msgcnt }
// WebSocket Communication control
function websocketcomm () {
let ws = new Client({ host, port })
ws.callback = function (message, value) {
switch (message) {
case 1:
trace("Client: socket connect.\n")
break
case 2:
trace("Client: websocket handshake success\n")
trace(" Send: " + JSON.stringify(trxdmsg) + "\n")
ws.write(JSON.stringify(trxdmsg))
break
case 3:
trace(`Recieved: ${value}\n`)
Timer.set(id => {
trxdmsg = JSON.parse(value)
trxdmsg.from = 'Client'
trxdmsg.message = 'Are you alive?'
trxdmsg.sequence++
trace(' Send: ' + JSON.stringify(trxdmsg) + '\n')
ws.write(JSON.stringify(trxdmsg))
}, 5000)
break
case 4:
trace("WebSocket close\n")
break
}
}
}
// Wifi connection control
let monitor = new WiFi(wifiAp, msg => {
switch (msg) {
case 'connect':
trace('Connected wifi\n')
break // still waiting for IP address
case 'gotIP':
trace(`Get IP address ${Net.get('IP')}\n`);
websocketcomm()
break // get IP, Do websocket communication
case 'disconnect':
trace('Discconected\n')
Timer.set(id => {
WiFi.connect(wifiAp)
trace('try reconnect:' + (++recnt) + '\n')
}, 5000)
break // detect connection lost, do reconncet
}
})
実行結果
クライアントのログ
No Wi-Fi SSID
Connected wifi
Get IP address 172.16.4.2
Client: socket connect.
Client: websocket handshake success
Send: {"from":"Client","message":"hello","sequence":0}
Received: {"from":"Server","message":"welcome","sequence":1}
Send: {"from":"Client","message":"Are you alive?","sequence":2}
Received: {"from":"Server","message":"I am alive.","sequence":3}
Send: {"from":"Client","message":"Are you alive?","sequence":4}
Received: {"from":"Server","message":"I am alive.","sequence":5}
Send: {"from":"Client","message":"Are you alive?","sequence":6}
Received: {"from":"Server","message":"I am alive.","sequence":7}
Send: {"from":"Client","message":"Are you alive?","sequence":8}
Discconected
try reconnect:1
Discconected
try reconnect:2
Discconected
try reconnect:3
Connected wifi
Get IP address 172.16.4.2
Client: socket connect.
Client: websocket handshake success
Send: {"from":"Client","message":"Are you alive?","sequence":8}
Received: {"from":"Server","message":"I am alive.","sequence":9}
Send: {"from":"Client","message":"Are you alive?","sequence":10}
Received: {"from":"Server","message":"I am alive.","sequence":11}
Send: {"from":"Client","message":"Are you alive?","sequence":12}
Received: {"from":"Server","message":"I am alive.","sequence":13}
サーバのログ
Websocket: Connected
Received: {"from":"Client","message":"hello","sequence":0}
Send: {"from":"Server","message":"welcome","sequence":1}
Received: {"from":"Client","message":"Are you alive?","sequence":2}
Send: {"from":"Server","message":"I am alive.","sequence":3}
Received: {"from":"Client","message":"Are you alive?","sequence":4}
Send: {"from":"Server","message":"I am alive.","sequence":5}
Received: {"from":"Client","message":"Are you alive?","sequence":6}
Send: {"from":"Server","message":"I am alive.","sequence":7}
Received: {"from":"Client","message":"Are you alive?","sequence":8}
Send: {"from":"Server","message":"I am alive.","sequence":9}
Send: {"from":"Server","message":"I am alive.","sequence":9}
I lost a client
Received: {"from":"Client","message":"Are you alive?","sequence":10}
Send: {"from":"Server","message":"I am alive.","sequence":11}
Received: {"from":"Client","message":"Are you alive?","sequence":12}
Send: {"from":"Server","message":"I am alive.","sequence":13}
5.2. WebSocket回線断のリカバー
WifiはリンクしているがSocket/WebSocketの接続が切れた時のリカバー。
ModdableのWebSocketの実装がよくわからないのでトライアンドエラーでテキトーに書いたプログラム
- 無限ループを回しておき、5秒間隔でwebsocketの接続状態を持つ変数wsConModeの内容をswitch (wsConMode)で見る。
2. wsConModeの内容と動作
2. 'reconnect':ソケットをcloseしてコネクト・シーケンスに入る
3. 'connect':コネクト・シーケンスに入る
import Timer from 'timer'
import WiFi from 'wifi'
import Net from 'net'
import { Client } from 'websocket'
// Wifi SSID, PassWord
let wifiAp = { ssid: 'WebSocket-TEST', password: '12345678' }
// Server's Socket
let host = '172.16.4.1'
let port = '5040'
// Variables
var ws // WebSocket descriptor
let wsConMode = '' // WebSocket connection mode
let recnt = 0 // wifi reconnect counter
let msgcnt = 0 // message counter
// messsage form
let trxdmsg = { from: 'Client', message: 'hello', sequence: msgcnt }
// Wifi connection control
let monitor = new WiFi(wifiAp, msg => {
switch (msg) {
case 'connect':
trace('> Connected wifi\n')
break // still waiting for IP address
case 'gotIP':
trace(`> Get IP address ${Net.get('IP')}\n`)
wsConMode = 'connect'
// websocketcomm()
break // get IP, Do websocket communication
case 'disconnect':
trace('> Discconected\n')
Timer.set(id => {
WiFi.connect(wifiAp)
trace('> try reconnect Wifi:' + (++recnt) + '\n')
}, 5000)
break // detect connection lost, do reconncet
}
})
// Infinite loop / WebSocket control {connect, transmit, receive ,reconnect}
Timer.repeat(() => {
switch (wsConMode) {
case 'reconnect':
ws.close() // close old socket
case 'connect':
trace(`> try ${wsConMode} websocket\n`)
wsConMode = 'reconnect'
ws = new Client({ host, port }) // open new socket
ws.callback = function(message, value) {
switch (message) {
case 1:
trace('> socket connected\n')
case 2:
wsConMode = 'connected'
trace('> websocket handshake success connected\n')
trace(' Send: ' + JSON.stringify(trxdmsg) + '\n')
ws.write(JSON.stringify(trxdmsg))
break // WebSocket connect
case 3:
trace(`Recieved: ${value}\n`)
Timer.set(id => {
trxdmsg = JSON.parse(value)
trxdmsg.from = 'Client'
trxdmsg.message = 'Are you alive?'
trxdmsg.sequence++
if (wsConMode === 'connected') {
trace(' Send: ' + JSON.stringify(trxdmsg) + '\n')
ws.write(JSON.stringify(trxdmsg))
}
}, 3000)
break // Message recieve
case 4:
wsConMode = 'connect'
ws.close()
trace('> WebSocket close\n')
throw new Error('WebSocket close')
break // WebSocket close
}
}
break
}
}, 5000)
6. 間欠送信 (今後の課題)
今回、実現したい温度ロガーはクライアントとサーバが常時接続している必要は無いので数分間隔で間欠接続してデータを送れば、その目的を達することができる。次のような動作をさせる。
- サーバは常時待受状態とする
- クライアントはデータ送信時間になったら
3. 送信データをキューに入れる
4. Wifiリンク
リンクしないときはbreak
5. Socket / WebSocket リンク
リンクしないときはbreak
6. キューのデータを送信する
7. サーバとのハンドシェイクで送信データ1個毎に確認を行う。
8. 確認が帰って来ないときはbreak
9. 確認が帰ってきたらキューをポップ
10. キューのデータが無くなるまで1〜3を繰り返す
7. Socket / WebSocket リンクをclose
8. Wifiリンクをclose - 次の送信時間を待つ
【検討】このような場合はクライアント側でタイミングと接続を制御するのではなく、サーバ側がクライアントをポーリングしてデータを吸い上げる方式がスマートではないか?
7. リンク
- ポッドキャスト Rebuld.fm #214/Moddable
-
Moddable Tech. INC.
2. Moddable Blog
3. Youtube Video: Getting Started with the Moddable SDK and the ESP8266
4. Moddable github repository
5. 開発者 Peter Hoddie のMediumポスト
Evolving IoT to put the user in control(原文)(google翻訳) - Moddableの前身プロジェクトKinomaJSのgithub repository
- kosakalabのModdable関連記事
- ESP32をJavaScriptで動かすModdable SDK
- ESP32をJavaScriptでプログラミング
- M5StackをJavaScriptでプログラミング
- Moddable SDK のTips「アップロードスピードの変更」
- Youtube Video: Blink on REPL
- Moddable-SDK easy installer
6. macOS
6. PC-Linux - ESP32+Moddable/ECMAScriptでmDNS
- ほりひろさんの解説記事
7. Moddable SDKを使ってJavaScriptでIoT開発してみた - Qiitaポスト
7. ESP32でJavaScriptが動くModdable SDKさわってみた
8. JavaScript x IoTの大本命「ModdableSDK」をM5Stackで動かしてみた - WebSocket関連
10. Windows 8 と WebSocket プロトコル 前半部分にプロトコルの平易な解説あり。
11. Node.js: socket.io ドキュメント (socket.ioはwebsocketの実装ではない)
12. Node.js: ws ドキュメント (wsがwebsocketの実装)
13. Moddable: WebSocket ドキュメント - JSON関連
15. 【JavaScript入門】JSONの作成とparse( ) / stringify( )の使い方
-- 来歴 --
1019.01.26: 試行錯誤部分の記載を削除
2019.01.10: WebSocketリンク断のリカバーを記載
2019.01.09: 図版差し替え
=-=-=-=-=-=
2019.01.06: 一応、初期の目的を達したので年末年始チャレンジを終了、以後は緩く継続
2019.01.06: Wifi回線断のリカバーを記載
2019.01.05: 関連リンクを整理、来歴整理
2019.01.04: 間欠接続・送信の実験開始-->中断(今後の課題)
2019.01.04: Wifi回線断の検出と自動再接続の実験開始
2019.01.03: JSONフォーマットでデータ交換を記載
2019.01.02: moodable/WebSocketとNode.js/wsでWebSocketリンクを記載
2019.01.01: Node.js/socket.ioはWebSocket実装ではないを記載(削除)
2019.12.30: Qiitaの記事作成開始
2019.12.29: moodable/WebSocketとNode.js/socket.ioの接続実験
2018.12.28: 年末年始チャレンジ開始
=-=-=-=-=-=
2018.12.27: 温度ロガー製作
2018.12.24: ChromOSのLinux.app上にModdable SDKをインストール
2018.12.21: REPL(ターゲット上のインタプリタ)でLチカ実行
2018.12.19: Moddable SDK簡単インストーラgithubで公開
2018.12.13: PC-Linux用Moddable SDK簡単インストーラ制作
2018.12.12: macOS用Moddable SDK簡単インストーラ制作
2018.12.07: Moddable SDKがM5Stackをターゲットにできることを発見
2018.12.05: Blog記事作成公開
2018.12.04: Lチカ実行
2018.12.03: macOS上でModdable SDKをビルド
2018.12.03: 動作検証用ハードウェアModdable Zeroのクローンを製作
2018.11.28: Rebuild.fm #214 でModdableを知る