Web Serial API+ESP32(Arduino)研究 その2 ESP32との疎通

概要

前回は単純なシリアルコンソールを作ってみました。今回はESP32とのかんたんな疎通確認をしてみたいと思います。

作成物

上記においてあります。

見た目は前回からあまり変わっていません。ちょっとボタンが増えています。

基本構造の変更 HTMLとJavaScriptの分離

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Web Serial API Console</title>
    <script type="text/javascript" src="espserial.js"></script>
    <script>
        async function onConnectButtonClick() {
            try {
                const baudRate = Number(document.getElementById('baudRate').value);
                await espConnect(baudRate);

                const { usbProductId, usbVendorId } = espPort.getInfo();
                addSerial("Connect VendorId=0x" + usbVendorId.toString(16) + " ProductId=0x" + usbProductId.toString(16) + "\n");

                try {
                    while (true) {
                        const { value, done } = await espReader.read();
                        if (done) {
                            espReader.releaseLock();
                            break;
                        }
                        const inputValue = new TextDecoder().decode(value);
                        addSerial(inputValue);
                    }
                } catch (error) {
                    addSerial("Error: Read" + error + "\n");
                }
            } catch (error) {
                addSerial("Error: Open" + error + "\n");
            }
        }

        function addSerial(msg) {
            var textarea = document.getElementById('outputArea');
            textarea.value += msg;
            textarea.scrollTop = textarea.scrollHeight;
        }

        async function sendSerial() {
            var text = document.getElementById('sendInput').value;
            document.getElementById('sendInput').value = "";

            const encoder = new TextEncoder();
            const espWriter = espPort.writable.getWriter();
            await espWriter.write(encoder.encode(text + "\n"));
            espWriter.releaseLock();
        }

        function clearSerial() {
            document.getElementById('outputArea').value = "";
        }
    </script>
</head>

<body>
    <h1>Web Serial API Console</h1>
    <input type="button" value="Connect" onclick="onConnectButtonClick();" />
    <input type="button" value="Disonnect" onclick="espDisconnect();addSerial('Disconnect\n');" />
    <select id="baudRate">
        <option value="300">300 bps</option>
        <option value="1200">1200 bps</option>
        <option value="2400">2400 bps</option>
        <option value="4800">4800 bps</option>
        <option value="9600">9600 bps</option>
        <option value="19200">19200 bps</option>
        <option value="38400">38400 bps</option>
        <option value="57600">57600 bps</option>
        <option value="74880">74880 bps</option>
        <option value="115200" selected>115200 bps</option>
        <option value="230400">230400 bps</option>
        <option value="250000">250000 bps</option>
        <option value="500000">500000 bps</option>
        <option value="750000">750000 bps</option>
        <option value="1000000">1000000 bps</option>
        <option value="1500000">1500000 bps</option>
        <option value="2000000">2000000 bps</option>
    </select>
    <br />

    <input type="text" id="sendInput" />
    <input type="button" value="Send" onclick="sendSerial();" />
    <input type="button" value="Reset" onclick="espReset();" />
    <input type="button" value="Download" onclick="espDownload();" />
    <input type="button" value="Sync" onclick="espSync();" />
    <input type="button" value="Read Reg" onclick="espReadRegister(0x60000078);" />
    <br />
    <textarea cols="90" rows="6" id="outputArea" readonly></textarea>
    <br />
    <input type="button" value="Clear" onclick="clearSerial();" />
</body>

</html>

さすがに処理が増えてきましたのでHTMLとJavaScriptは分離しました。上記はHTML部分です。ベタ書きなのであまり解説はいらないと思います。

    <script type="text/javascript" src="espserial.js"></script>

上記でHTMLからJavaScriptを読み込んでいます。

    <script>
        async function onConnectButtonClick() {
            try {
                const baudRate = Number(document.getElementById('baudRate').value);
                await espConnect(baudRate);

                const { usbProductId, usbVendorId } = espPort.getInfo();
                addSerial("Connect VendorId=0x" + usbVendorId.toString(16) + " ProductId=0x" + usbProductId.toString(16) + "\n");

                try {
                    while (true) {
                        const { value, done } = await espReader.read();
                        if (done) {
                            espReader.releaseLock();
                            break;
                        }
                        const inputValue = new TextDecoder().decode(value);
                        addSerial(inputValue);
                    }
                } catch (error) {
                    addSerial("Error: Read" + error + "\n");
                }
            } catch (error) {
                addSerial("Error: Open" + error + "\n");
            }
        }

        function addSerial(msg) {
            var textarea = document.getElementById('outputArea');
            textarea.value += msg;
            textarea.scrollTop = textarea.scrollHeight;
        }

        async function sendSerial() {
            var text = document.getElementById('sendInput').value;
            document.getElementById('sendInput').value = "";

            const encoder = new TextEncoder();
            const espWriter = espPort.writable.getWriter();
            await espWriter.write(encoder.encode(text + "\n"));
            espWriter.releaseLock();
        }

        function clearSerial() {
            document.getElementById('outputArea').value = "";
        }
    </script>

分離したといっても、上記のJavaScriptは残っています。これはHTMLのテキストエリアなどに対する処理になります。分離したのは純粋なWebSerial部分の処理だけで、画面などによって書き換えが必要な部分はHTMLに残しています。本来はこれも他のファイルに分離したほうがきれいになるかもしれません。

                const { usbProductId, usbVendorId } = espPort.getInfo();
                addSerial("Connect VendorId=0x" + usbVendorId.toString(16) + " ProductId=0x" + usbProductId.toString(16) + "\n");

前回との違いはいろいろあるのですが、上記などでVendorIdとProductIdを取得しています。

ReaderとWriterの利用方法を見直し

どうやらgetWriter()でWriterを取得するとロックがかかるらしい。使い終わってからreleaseLock()しないとSerialをクローズできませんでした。他の例でReaderは使いまわしているのにWriterは毎回取得しているのなぜだろうって思ったのですがちょっと扱いに差がありました。

                        const { value, done } = await espReader.read();

Readerの場合には上記のようにawaitで受信するまで待機するような動きで呼び出すことがよくあります。そのため待機中のReaderがある場合にはシリアルを終了できません。この場合にはcancel()を呼び出すことで待機状態が終了し、doneにfalseが戻ってきます。その後にreleaseLock()すると終了できるようになりました。

つまり常に受信しているReaderがいるので、都度開くのではなく開いたReaderを保存しておくことになります。

Writerの場合には基本的には短時間のロックしかしませんので、送信するときにgetWriter()して、使い終わったらすぐにreleaseLock()する動きが良さそうです。

async function espDisconnect() {
    if (espPort) {
        await espReader.cancel();
        await espReader.releaseLock();
        await espPort.close();
        espPort = null;
        espReader = null;
    }
}

今の所、上記のような切断関数でうまく動いていました。

ESP32のリセットについて

最初にリセットの方法を調べてみました。ESP32にはソフトウエア的なリセットとハードウエア的なリセットがあります。ソフトウエアリセットは特定のアドレスにリセットの値を書き込むことでリセットさせます。これはちょっとすぐには実行できないので後ほど試します。

ハードウエアリセットはESP32とパソコンを接続しているUSBシリアルの信号で制御が可能です。こちらはすでに接続してある線で、WebSerialの信号制御なのでかんたんに実行することができます。

ダウンロードモード

async function espDownload() {
    // Reset + Download mode
    await espPort.setSignals({ dataTerminalReady: false, requestToSend: true });
    await new Promise(resolve => setTimeout(resolve, 100));
    await espPort.setSignals({ dataTerminalReady: true, requestToSend: false });
    await new Promise(resolve => setTimeout(resolve, 1000));
}

こちらがesptool.pyを参考にしたハードウエアリセットです。端末側が準備完了であることを知らせるdataTerminalReadyと、送信要求であるrequestToSendの信号レベルを変更しています。

これを実行するとハードウエアリセットがかかり、ダウンロードモートとして起動してきました。ESP32の場合requestToSendにENであるリセットが接続されており、TRUEからFALSEに変更することでリセットがされます。

リセット時にdataTerminalReadyがTRUEの場合にはGPIO0がLOWになり、ダウンロードモードで起動するようでした。

単純リセット

async function espReset() {
    // Reset
    await espPort.setSignals({ dataTerminalReady: false, requestToSend: true });
    await new Promise(resolve => setTimeout(resolve, 100));
    await espPort.setSignals({ dataTerminalReady: false, requestToSend: false });
    await new Promise(resolve => setTimeout(resolve, 1000));
}

その後調べたところ、dataTerminalReadyをfalseのままにすることで単純なハードウエアリセットになりました。esptoolではダウンロードにする場合にのみこの信号線でのリセットを送信しており、単純リセットはソフトウエアリセットを実行しているようでした。

dataTerminalReadyは変更しなくても良さそうに見えますが、ダウンロードモードからの復帰などもあるので、falseに変更しつつ実行しています。

ESP32へのSYNC

リセットは信号線で実施しましたので、ESP32には厳密には通信をしていません。シリアル経由でesptool的な動きをするためにはもう少し独自通信をする必要がありそうです。

esptoolを見たところ、まずはダウンロードモードにリセットしたあとにSYNC用のパケットを送信しているのがわかりました。

async function espSync() {
    let packet = new ArrayBuffer(46);
    let dv = new DataView(packet);

    // Header
    let index = 0;
    dv.setUint8(index, 0xC0);
    index++;
    dv.setUint8(index, 0x00);
    index++;

    // ESP_READ_REG
    dv.setUint8(index, ESP_SYNC);
    index++;

    // Address length
    dv.setUint16(index, 36, true);
    index += 2;

    // Checksum
    dv.setUint32(index, 0x0000, true);
    index += 4;

    // Data
    dv.setUint8(index, 0x07, true);
    index++;
    dv.setUint8(index, 0x07, true);
    index++;
    dv.setUint8(index, 0x12, true);
    index++;
    dv.setUint8(index, 0x20, true);
    index++;

    for (let i = 0; i < 32; i++) {
        dv.setUint8(index, 0x55, true);
        index++;
    }

    // End Header
    dv.setUint8(index, 0xC0);
    index++;

    console.log(packet);
    console.log(dv);

    // Send
    const espWriter = espPort.writable.getWriter();
    await espWriter.write(packet);
    espWriter.releaseLock();

    // Ret
    const { value, done } = await espReader.read();
    console.log(value);
    console.log(done);
}

該当部分のコードです。ArrayBufferで46バイト分のバッファを確保して、DataViewでアクセスしやすくしています。

    // Header
    let index = 0;
    dv.setUint8(index, 0xC0);
    index++;
    dv.setUint8(index, 0x00);
    index++;

こんな感じでindexをずらしつつデータを追加していきます。

    // Address length
    dv.setUint16(index, 36, true);
    index += 2;

こんな感じで16ビットのデータも追加可能です。最後をtrueをすると下位データから保存するリトルエンディアンになります。Adafruitさんの実装はPythonのpack関数をそのままJavaScriptで実装するいう力技を使っていました。なので今回は生JavaScriptっぽいベタ書きにチャレンジしています。

        self.command(self.ESP_SYNC, b'\x07\x07\x12\x20' + 32 * b'\x55',
                     timeout=SYNC_TIMEOUT)

esptoolだと上記のようなパケットを投げつけていました。

    // Data
    dv.setUint8(index, 0x07, true);
    index++;
    dv.setUint8(index, 0x07, true);
    index++;
    dv.setUint8(index, 0x12, true);
    index++;
    dv.setUint8(index, 0x20, true);
    index++;

    for (let i = 0; i < 32; i++) {
        dv.setUint8(index, 0x55, true);
        index++;
    }

JavaScriptだと上記みたいな感じでいいのでしょうか?

    // Send
    const espWriter = espPort.writable.getWriter();
    await espWriter.write(packet);
    espWriter.releaseLock();

上記でシリアルポートにデータを投げつけています。

    // Ret
    const { value, done } = await espReader.read();
    console.log(value);
    console.log(done);

返却は上記のように取得しています。実は別スレッドでも受信待ちをしているのですが、こっち側で受信が可能でした。Adafruitさんの実装では画面側で受信したリストを処理するようになっていましたが、画面側とライブラリ的な実装でごちゃごちゃしているのでいまはこんな感じになっています。

SYNCまでの流れ

  1. ダウンロードモードでリセットする
  2. SYNCパケットを送信
  3. 返事が来なければ2のSYNCパケット送信を繰り返す
  4. 返事が来たら終了

上記のような流れになっていました。ダウンロードモードでリセットしてからちょっと時間が経過しないとSYNCパケットを受信してくれません。そして十分な時間をおいても1回のSYNCパケットでは返事を返してくれません。なので適当に何度か投げて安定して返事が帰ってくるまで待ちます。

この状態までなればESP32との疎通が完了しています。以降他のパケットにも返事をくれるようになりました。ちなみに現状は自動で実行するのではなく、ESP32に接続後にDownloadボタンを押して、再起動したあとにSyncボタンを何度か押していると返事が来るので、その後に呼び出しを押しています。

レジスタ読み出し

    <input type="button" value="Read Reg" onclick="espReadRegister(0x60000078);" />

そんなこんなでレジスタの呼び出しをテストしてみます。0x60000078を呼び出すことでESP8266かESP32シリーズかを判定することができるようです。

async function espReadRegister(reg) {
    let packet = new ArrayBuffer(14);
    let dv = new DataView(packet);

    // Header
    let index = 0;
    dv.setUint8(index, 0xC0);
    index++;
    dv.setUint8(index, 0x00);
    index++;

    // ESP_READ_REG
    dv.setUint8(index, ESP_READ_REG);
    index++;

    // Address length
    dv.setUint16(index, 0x0004, true);
    index += 2;

    // Checksum
    dv.setUint32(index, 0x0000, true);
    index += 4;

    // Address
    dv.setUint32(index, reg, true);
    index += 4;

    // End Header
    dv.setUint8(index, 0xC0);
    index++;

    console.log(packet);
    console.log(dv);

    // Send
    const espWriter = espPort.writable.getWriter();
    await espWriter.write(packet);
    espWriter.releaseLock();

    // 
    const { value, done } = await espReader.read();
    console.log(value);
    console.log(done);
};

はーい、ベタ書きです。

データ備考
C0ヘッダ
00ヘッダ
0AESP_READ_REG
04データ長
00
00チェックサム
00
00
00
78データ
00
00
60
C0END

こんなパケット構造になっています。データは0xC0, 00から開始して、次にパケットの種類が入っています。ESP_READ_REGは0x0Aになります。データ長は種類のよって異なるのですがESP_READ_REGの場合には呼び出しアドレスになるので4になります。チェックサムは0固定で大丈夫みたいです。本当は計算したほうが良さそうな気もしますがesptoolでも0で通信していました。データはアドレスになっており、0x60000078が下位から78, 00, 00, 60と入っていますね。最後にC0でパケットの終わりを宣言します。

データ備考
C0ヘッダ
01ヘッダ
0AESP_READ_REG
04バッファ長
00
00データ
25
12
15
00チェックサム?
00
00
00
C0END

返却されたデータは上記の形式でした。ちょっと項目名が怪しいです。データシートとかで正式名を調べたいんですがDownloadブートの仕様書がないような?

さて、返却されたデータは0x15122500になります。

class ESP32ROM(ESPLoader):
    """Access class for ESP32 ROM bootloader

    """
    CHIP_NAME = "ESP32"
    IMAGE_CHIP_ID = 0
    IS_STUB = False

    DATE_REG_VALUE = 0x15122500
    DATE_REG2_VALUE = None

esptoolを見てみると上記にあるDATE_REG_VALUEと同じ値ですね。なのでこのチップはESP32になります。M5StickCの値なのでESP32で間違いないですね。

まとめ

ちょっと中途半端なところですが、ESP32と疎通ができて内部レジスタの呼び出しまでできることが確認できました。次回はesptoolのコマンドで使いそうなものを実装していきたいと思っています。

現在は事前確認の段階なのでコードの整理は最低限にして、どんどん実験を進めていくことにします。

コメント

  1. そーたメイ より:

    EspToolをjavascript&webSerialにポーティングしたものです(つくるっち用)。
    https://github.com/sohtamei/scratch-vm/blob/develop/src/extensions/scratch3_tukurutch/comlib.js
    のburnESP32
    ファイル選択のhtmlさえ書けばESP32デバイス書き込みできるはず。。

    webSerialのreaderは非常にクセがあり苦労しました。
    ご参考までに。。

    • たなかまさゆき より:

      ありがとうございます!
      Adafruit ESPToolもなんか速度変更ちゃんとやっていなかったりとesptool.pyを結局確認しながら中身を確認しています
      なんとかプロトコルがわかってきたところです
      問題はJavaScript自体あまり触ったことないことです!