ブラウザとRaspberry Pi PicoをBluetoothで接続する

# はじめに
冬休みの自由工作として、Raspberry Pi Pico Wで物理的な工作をしようかと思い立ちました。
しかしRasPi Pico単体では柔軟な設定などができないため、PCやスマホとの連携を考えたくなります。

RasPi Pico WではPC等との接続方法として、USB等での有線接続のほかにWifiとBluetoothでの無線接続に対応しています。今回はBluetoothを選択してみます[^bluetooth]。

[^bluetooth]: よりチャレンジングで面白そうなので

またPCやスマホでの実装としては、マルチプラットフォームかつGUI付きのものを実装するために、ブラウザ上で動くWebアプリの実装を選択します。

ブラウザからデバイスのBluetoothを触る手段として、[Web Bluetooth API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API)という規格が制定されているので、これを利用します。

# RasPi側

## 環境構築

RasPi Picoを触ったことがある方はもうお馴染みかと思いますが、RasPi PicoにMicroPythonを焼いておきます。

1. [こちら](https://micropython.org/download/RPI_PICO_W/)からMicroPythonのRasPi Pico向けポートをダウンロードします[^rpipico]
2. RasPi Picoを`BOOTSEL` ボタンを押しながらPCにUSB接続し、マウントされる `RPI-RP2` というドライブに上記ファイルをコピーします
3. ドライブとの接続が自動的に切断されるのを確認します

また、PC側の開発環境も構築しておきます。

1. VSCodeに [MicroPico](https://marketplace.visualstudio.com/items?itemName=paulober.pico-w-go) という拡張機能をインストールます
1. プロジェクトディレクトリとして適当なディレクトリを用意します
1. VSCodeでそのディレクトリを開き、左のエクスプローラバーからそのディレクトリを右クリックして "Setup MicroPico Project" を選択します

## 実行
下記のPythonコードを適当な名前で保存し、VSCode下部のステータスバーの "Run" から実行します。

[^rpipico]: 筆者が使用したファイルは `RPI_PICO_W-20241129-v1.24.1.uf2` です

```python
import asyncio
import aioble
import bluetooth

# mimicking nRF41822 UART
SERVICE_UUID = bluetooth.UUID('6E400001-B5A3-F393-E0A9-E50E24DCCA9E')

service = aioble.Service(SERVICE_UUID)
send_ch = aioble.Characteristic(
    service = service,
    uuid = bluetooth.UUID('6E400003-B5A3-F393-E0A9-E50E24DCCA9E'),
    read = True,
    notify = True,
)
recv_ch = aioble.Characteristic(
    service=service,
    uuid=bluetooth.UUID('6E400002-B5A3-F393-E0A9-E50E24DCCA9E'),
    write=True,
    write_no_response=True,
    capture=True
)

aioble.register_services(service)

async def advertise_task():
    while True:
        async with await aioble.advertise(
            interval_us=500_000,
            name="bt-test",
            services=[SERVICE_UUID]
        ) as connection: # type: ignore
            print("connected: ", connection.device)
            await connection.disconnected(timeout_ms=None)

async def receive_task():
    while True:
        await recv_ch.written() # type: ignore
        data = recv_ch.read()
        print(f"received: {data}")
        send_ch.write(b'you said: ' + data, True)

async def main():
    tasks = [
        asyncio.create_task(receive_task()),
        asyncio.create_task(advertise_task())
    ]
    await asyncio.gather(*tasks)

asyncio.run(main())
```

今回実装するBluetooth BLEのGATTの詳細についての解説は他のサイトを見ていただくこととしますが、大雑把に言うと、一つのデバイスが一つ以上のサービス（≒機能）を提供し、各サービスがそれぞれ複数のキャラクタリスティック（≒読み書きできる値）から構成されるような形になります。

使用するサービスやキャラクタリスティックは何でもよいのですが、今回はNordic社のnRF41822のUART機能を模倣します。
これは[Serial Bluetooth Terminal](https://play.google.com/store/apps/details?id=de.kai_morich.serial_bluetooth_terminal&hl=ja&pli=1)というAndroidアプリがnRF41822のUART機能をサポートしており、デバッグにちょうど良かったためです。

# Webアプリ側

次にWebアプリ側ですが、読者の皆さんのお好きなフレームワークをお好きなようにセットアップしてください[^ts]。
筆者は React + Next.js + MUI というありがちな構成をとりました。

[^ts]:この辺りは読者の皆さんの方が詳しいでしょうし。いちおうTypeScript縛りという条件はありますが、さすがに今日日JS直書きじゃないと嫌という方は珍しいかと思います

また、プロジェクトに[@types/web-bluetooth
](https://www.npmjs.com/package/@types/web-bluetooth)を追加しておいてください。

次に以下のコードを適当な場所に追加し、下記の使用法を参考に使用してください。

```typescript
class SimpleBluetooth {
  private textEncoder = new TextEncoder();
  private textDecoder = new TextDecoder('utf-8');
  private recvCharacteristic: BluetoothRemoteGATTCharacteristic | undefined;
  private sendCharacteristic: BluetoothRemoteGATTCharacteristic | undefined;
  
  public async connect(params: {
    deviceName: string,
    onReceived: (value: string) => void
  }) {
    const td = this.textDecoder;
    function onReceivedWrapper(this: BluetoothRemoteGATTCharacteristic, ev: Event): void {
      const v = this.value;
      if (v === undefined) {
        return;
      }
      const str = td.decode(v);
      params.onReceived(str);
    }

    const device = await navigator.bluetooth.requestDevice({
      filters: [{
        name: params.deviceName
      }],
      optionalServices: ['6e400001-b5a3-f393-e0a9-e50e24dcca9e']
    })

    const server = await device.gatt?.connect();
    if (server === undefined) throw new Error("GATT server not provided");
    
    const service = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');

    this.sendCharacteristic = await service.getCharacteristic('6e400002-b5a3-f393-e0a9-e50e24dcca9e');
    this.recvCharacteristic = await service.getCharacteristic('6e400003-b5a3-f393-e0a9-e50e24dcca9e');

    this.recvCharacteristic.addEventListener('characteristicvaluechanged', onReceivedWrapper);

    await this.recvCharacteristic.startNotifications().catch(e => {
      this.recvCharacteristic!!.removeEventListener('characteristicvaluechanged', onReceivedWrapper);
      throw e;
    });

  }

  public async send(str: string) {
    if (this.sendCharacteristic === undefined) {
      throw new Error("Bluetooth device not connected");
    }
    const payload = this.textEncoder.encode(str);
    return this.sendCharacteristic.writeValueWithoutResponse(payload);
  } 
}
```

## 使用法

1. 上記クラスにデバイス名（今回は`bt-test`）と適当なコールバック関数を渡してインスタンス化します
2.  `connect` を呼んでRasPi Picoと接続します。この際、ユーザのアクションに起因したイベント内から呼ばなければいけないことに注意してください
3. 適当な文字列を引数にして `send` を呼びます

# 動作確認

上記クラスを使用した適当な実装を用意します。筆者は下記のような、非常に雑なコードを使用しました。

```tsx
'use client'
import { useState } from "react";
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { Container, IconButton, Stack, TextField, Grid2 as Grid } from "@mui/material";
import { Bluetooth, Send } from '@mui/icons-material';

class SimpleBluetooth {
  /*
    :
    :
  */
}

export default function Home() {
  const [output, setOutput] = useState<string>("");
  const [command, setCommand] = useState<string>("");
  const [bt] = useState<SimpleBluetooth>(new SimpleBluetooth());

  const startConnection = async () => {
    bt.connect({
      deviceName: 'bt-test',
      onReceived: (str: string) => setOutput(str)
    });
  }

  const theme = createTheme({
    colorSchemes: {
      light: true,
      dark: true,
    }
  });

  return (
    <ThemeProvider theme={theme}>
      <Container maxWidth="sm" component="main">
        <Stack sx={{ marginTop: '10px' }} spacing={2}>
          <TextField label="Output" multiline rows={10} value={output}/>
          <Grid container spacing={0}>
            <Grid size={1}>
              <IconButton onClick={startConnection} size="large">
                <Bluetooth fontSize="inherit"/>
              </IconButton>
            </Grid>
            <Grid size={10}>
              <TextField onChange={ e => setCommand(e.target.value) } fullWidth></TextField>
            </Grid>
            <Grid size={1}>
              <IconButton onClick={ _ =>  bt.send(command)} size="large">
                <Send fontSize="inherit"/>
              </IconButton>
            </Grid>
          </Grid>
        </Stack>
      </Container>
    </ThemeProvider>
  );
}
```

BluetoothアイコンのボタンをクリックしてRasPi Picoに接続した後、その隣のテキストボックスに適当な文字列をセットして送信すると、上のテキストエリアに "you said: (送信した文字列)"と出てくることが確認できるかと思います。

ただし、Web Bluetooth API自体があまり成熟しておらず、サポートしているブラウザがChromeやChromium、Edge等に限られること、localhost内かHTTPS接続下でなければ動作しないことにご注意ください。

# 最後に

今回はごく基本的なエコーバックのみを実装しましたが、これを拡張し、JSONで通信するなどすれば、ブラウザとRasPiの間で自由に通信できるようになるかと思います。
RasPiの使い道の拡張にでも参考にしていただければと思います。