WebブラウザからRP2040をファームウェア更新モードにする方法【Web Serial API】
自作キーボードやマイコンボードなどの、RP2040 (Raspberry Pi Pico) を搭載したデバイスを開発していると、ファームウェアの更新作業が頻繁に発生します。
通常、RP2040を書き込みモード(ブートローダー/マスストレージモード)にするには、 BOOTSELボタンを押しながらUSBケーブルを接続 する必要があります。しかし、デバイスをケースに組み込んでしまうとボタンにアクセスしづらかったり、いちいちケーブルを抜き差しするのが面倒だったりしませんか?
Arduino IDEを使っている場合は、書き込みボタンを押すだけで自動的にリセットがかかりますが、これを 自作のWebツール(Web Serial API) から実現する方法を紹介します。
特に、Web Serial API特有の「ポートが閉じきらないエラー」の回避策についても詳しく解説します。
※ブラウザはChromeでテストしています。
仕組み:1200bps Touch (Magic Baud Rate)
Raspberry Pi Pico Arduino core (arduino-pico) には、 「USB CDCシリアルポートを1200bpsでオープンし、その後クローズすると、ブートローダーモードにリセットされる」 という機能が組み込まれています。
これはArduino界隈では "1200bps Touch" と呼ばれる一般的な手法で、Arduino Leonardo等の時代から使われています。(一部では "Magic Baud Rate" と呼ばれることもあります)
⚠️ 注意
この機能はRP2040のハードウェア機能ではなく、 Raspberry Pi Pico Arduino core が提供する機能 です。
そのため、このCoreを使ってビルドされたファームウェアが 既に書き込まれている状態 でのみ動作します。
購入直後の真っ更な状態や、この機能を持たないファームウェアが書き込まれている場合は、従来どおり物理的なBOOTSELボタンを使って最初の書き込みを行う必要があります。
Webブラウザから以下のシーケンスを実行すれば、物理ボタンに触れずにファームウェア更新モードへ移行できます。
- 現在の接続を切断する。
- 同じポートを 1200bps で開く。
- DTR信号を ON にし、その後 OFF にする。
- ポートを閉じる。
これだけで、デバイスは再起動し、PC上に RPI-RP2 というドライブとしてマウントされます。あとは新しい .uf2 ファイルをドラッグ&ドロップするだけです。

実装コード
Web Serial APIを使用した、最小限の構成で動作するサンプルコードです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RP2040 Firmware Updater</title>
</head>
<body>
<h1>RP2040 Firmware Updater</h1>
<p>
<button id="btnConnect">接続</button>
<button id="btnFirmwareUpdate" disabled>ファームウェアアップデート</button>
</p>
<script src="app.js"></script>
</body>
</html>
let port;
let reader;
let writer;
let readableStreamClosed;
let writableStreamClosed;
const elBtnConnect = document.getElementById('btnConnect');
const elBtnFirmwareUpdate = document.getElementById('btnFirmwareUpdate');
// 接続処理
elBtnConnect.addEventListener('click', async () => {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
// Streamのセットアップ
const textDecoder = new TextDecoderStream();
readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
reader = textDecoder.readable.getReader();
const textEncoder = new TextEncoderStream();
writableStreamClosed = textEncoder.readable.pipeTo(port.writable);
writer = textEncoder.writable.getWriter();
elBtnConnect.disabled = true;
elBtnFirmwareUpdate.disabled = false;
alert('接続しました');
} catch (e) {
alert('接続エラー: ' + e.message);
}
});
// ファームウェアアップデート処理
elBtnFirmwareUpdate.addEventListener('click', async () => {
if (!port) return;
if (!confirm('ファームウェアアップデートモードに移行しますか?\n接続が切断されます。')) return;
try {
// 1. 現在のポートを保持しておく
const targetPort = port;
// 2. 現在の接続を完全に切断する(後述の重要ポイント)
await disconnect();
// ポート解放待ち(OS側の処理待ち)
await new Promise(r => setTimeout(r, 500));
// 3. 1200bpsでオープン (1200bps Touch)
console.log('ブートローダ起動シーケンス: 1200bps Open');
// タイミングによっては "The port is already open" になることがあるためリトライ処理を入れると親切
for (let i = 0; i < 3; i++) {
try {
await targetPort.open({ baudRate: 1200 });
break;
} catch (e) {
if (i === 2) throw e;
console.log(`Open retry ${i+1}...`);
await new Promise(r => setTimeout(r, 500));
}
}
// 4. DTR信号の操作 (DTRをON→OFFすることでリセットトリガーとなる)
await targetPort.setSignals({ dataTerminalReady: true });
await new Promise(r => setTimeout(r, 200));
await targetPort.setSignals({ dataTerminalReady: false });
// 5. クローズしてリセット発動
await targetPort.close();
alert('デバイスをリセットしました。RPI-RP2ドライブに.uf2ファイルをコピーしてください。');
elBtnConnect.disabled = false;
elBtnFirmwareUpdate.disabled = true;
} catch (e) {
console.error(e);
alert('エラーが発生しました: ' + e.message);
}
});
// 切断処理
async function disconnect() {
try {
if (reader) {
await reader.cancel();
await readableStreamClosed.catch(() => {});
reader = null;
}
if (writer) {
await writer.close();
await writableStreamClosed;
writer = null;
}
if (port) {
await port.close();
port = null;
}
} catch (e) {
console.error('Disconnect error:', e);
}
}
ハマりポイント:Failed to execute 'open' on 'SerialPort'
この機能を実装する際、最も躓きやすいのが以下のエラーです。
Failed to execute 'open' on 'SerialPort': The port is already open.

「disconnect() で閉じたはずなのに、直後の open({ baudRate: 1200 }) で『まだ開いている』と怒られる」という現象です。しかも、1回目は失敗して、2回目は成功するなど挙動が不安定になりがちです。
原因:Streamのロックが解除されていない
Web Serial APIでは、ポートの読み書きに ReadableStream と WritableStream を使用します。これらを pipeTo で TextDecoder などに繋いでいる場合、単に reader.cancel() や writer.close() を呼ぶだけでは、 パイプライン全体の終了処理が完了する前に次の行へ進んでしまいます 。
その結果、port.close() を呼んでも内部的にまだリソースが掴まれたままになり、次の open が失敗します。
解決策:Promiseを待つ
pipeTo メソッドが返す Promise を保持しておき、切断時にその Promise が解決される(=ストリームが完全に閉じる)のを await で待つ必要があります。
修正前の切断処理(不安定):
async function disconnect() {
if (reader) {
await reader.cancel(); // これだけでは不十分
reader.releaseLock();
}
if (writer) {
await writer.close(); // これだけでは不十分
writer.releaseLock();
}
await port.close(); // ストリームが残っているとここでエラーになるか、不完全に閉じる
}
修正後の切断処理:
let readableStreamClosed; // pipeToの戻り値を保持する変数
let writableStreamClosed; // pipeToの戻り値を保持する変数
async function connect() {
// ... (ポート選択処理) ...
await port.open({ baudRate: 115200 });
const textDecoder = new TextDecoderStream();
const textEncoder = new TextEncoderStream();
// pipeTo の Promise をグローバル変数などに保存しておく
readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
writableStreamClosed = textEncoder.readable.pipeTo(port.writable);
reader = textDecoder.readable.getReader();
writer = textEncoder.writable.getWriter();
// ...
}
async function disconnect() {
try {
if (reader) {
await reader.cancel();
// パイプラインが閉じきるのを待つ
await readableStreamClosed.catch(() => {}); // エラーは無視
reader = null;
}
if (writer) {
await writer.close();
// パイプラインが閉じきるのを待つ
await writableStreamClosed;
writer = null;
}
if (port) {
await port.close();
port = null;
}
} catch (e) {
console.error('Disconnect error:', e);
}
}
このように、readableStreamClosed と writableStreamClosed を await することで、ポートが安全に閉じられる状態になるまで待機できます。これにより、直後に 1200bps で開き直してもエラーが出なくなります。
まとめ
- RP2040 は 1200bps に変更して DTR信号を ON にし、その後 OFF にするとブートローダーモードに入る。
- Web Serial API でこれを実装すれば、ブラウザからファームウェア更新準備ができる。
- 「The port is already open」エラーを防ぐには、 Stream の pipeTo Promise を await して確実に閉じる ことが重要。
Discussion