はじめに
冬休みの自由工作として、Raspberry Pi Pico Wで物理的な工作をしようかと思い立ちました。
しかしRasPi Pico単体では柔軟な設定などができないため、PCやスマホとの連携を考えたくなります。
RasPi Pico WではPC等との接続方法として、USB等での有線接続のほかにWifiとBluetoothでの無線接続に対応しています。今回はBluetoothを選択してみます1。
またPCやスマホでの実装としては、マルチプラットフォームかつGUI付きのものを実装するために、ブラウザ上で動くWebアプリの実装を選択します。
ブラウザからデバイスのBluetoothを触る手段として、Web Bluetooth APIという規格が制定されているので、これを利用します。
RasPi側
環境構築
RasPi Picoを触ったことがある方はもうお馴染みかと思いますが、RasPi PicoにMicroPythonを焼いておきます。
- こちらからMicroPythonのRasPi Pico向けポートをダウンロードします2
- RasPi Picoを
BOOTSEL
ボタンを押しながらPCにUSB接続し、マウントされるRPI-RP2
というドライブに上記ファイルをコピーします - ドライブとの接続が自動的に切断されるのを確認します
また、PC側の開発環境も構築しておきます。
- VSCodeに MicroPico という拡張機能をインストールます
- プロジェクトディレクトリとして適当なディレクトリを用意します
- VSCodeでそのディレクトリを開き、左のエクスプローラバーからそのディレクトリを右クリックして "Setup MicroPico Project" を選択します
実行
下記のPythonコードを適当な名前で保存し、VSCode下部のステータスバーの "Run" から実行します。
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というAndroidアプリがnRF41822のUART機能をサポートしており、デバッグにちょうど良かったためです。
Webアプリ側
次にWebアプリ側ですが、読者の皆さんのお好きなフレームワークをお好きなようにセットアップしてください3。
筆者は React + Next.js + MUI というありがちな構成をとりました。
また、プロジェクトに@types/web-bluetooth
を追加しておいてください。
次に以下のコードを適当な場所に追加し、下記の使用法を参考に使用してください。
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);
}
}
使用法
- 上記クラスにデバイス名(今回は
bt-test
)と適当なコールバック関数を渡してインスタンス化します -
connect
を呼んでRasPi Picoと接続します。この際、ユーザのアクションに起因したイベント内から呼ばなければいけないことに注意してください - 適当な文字列を引数にして
send
を呼びます
動作確認
上記クラスを使用した適当な実装を用意します。筆者は下記のような、非常に雑なコードを使用しました。
'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の使い道の拡張にでも参考にしていただければと思います。