ラズパイPicoとWebSerial による気象センサインターフェース
今回、風向風速計と雨量計をパソコンに接続し、風速が設定値を超えると警報を鳴らすWebアプリを作成しました。
作成の目的は、
1.ラズパイPicoをWebSerialで使った時、何か問題があるか?
2.単一のソースで、Windows、Mac、Linuxのどれでも動作するようにできるか?
などを確認するためです。

接続
今回使用したセンサでは、風向および風速のデータは4-20mAインターフェース、雨量はリードスイッチのオンオフ信号で取得するようになっています。
4-20mAのデータはM5Stackから販売されているI2C用モジュールに接続し、リードスイッチはDIOポートに接続しています。

プログラム
ラズパイPico
ラズパイPicoのプログラムでは、風向風速計と雨量計のデータを装置から取得し、次のテキスト形式でUSBから出力しています。
出力間隔は1秒毎です。
3:15:47 ←電源オン後の経過時間
wind speed = 0.3 ←風速(mm/s)
wind dir = 346.3 ←風向(degree)
rain counter = 0 ←雨量のカウンター(0.5mm/count)
プログラムは次のとおりです。
/*
Arduino IDE 2
*/
#include "time.h"
#include "MODULE_4_20MA.h"
//#define PIN_LED 25
MODULE_4_20MA unit420Ch0;
MODULE_4_20MA unit420Ch1;
int unit420Ch0Addr = 0x55;
int unit420Ch1Addr = 0x55;
// Wind
// 風速計 小松製作所 W633-J4
int windSpeedSpan = 60; // 風速計の値の範囲(20mAの時の風速 m/s)
int windDirSpan = 540; // 風向計の値の範囲 (20mAの時の角度 Degree)
int msecLastWindSpeed;
float windSpeedNow;
float windSpeedTotal; // 風速の1秒毎の積算値
int windSpeedTotalCount; // 積算回数
float windSpeedMax; // 最大風速
float windSpeedAverage; // 平均風速
// Rain
//int portRain = 18;
int portRain = 20;
int counterRain; // 雨量パルスカウンター
int msecLastRain; // パルス受信ミリ秒
float rainPerPulse = 0.5; // 1パルス当たりの雨量mm 小松製作所
float rainPer10min ; // 10分間雨量mm
int rain10minLastCount; // 直前の正10分のカウンター値
int lastMinuteSent = -1;
float tmp = 0.0;
float hum = 0.0;
float pressure = 0.0;
float tmp2 = 0.0;
float height = 122;
void setup() {
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, true);
// Wind speed
bool ok = unit420Ch0.begin(&Wire, unit420Ch0Addr, 8, 9);
if ( !ok ){
// while(1){
Serial.printf("Can't begin 4-20mA Unit on channel 0\r\n");
delay(1000);
// }
}
// Wind direction
ok = unit420Ch1.begin(&Wire1, unit420Ch1Addr, 6, 7);
if ( !ok ){
while(1){
Serial.printf("Can't begin 4-20mA Unit on channel 1\r\n");
delay(1000);
}
}
Serial.printf("Unit 4-20mA initialized \r\n");
//Wire1.setSDA(6);
//Wire1.setSCL(7);
//Wire1.begin();
// ADC
analogReadResolution(12);
// counter
pinMode( portRain, INPUT_PULLUP );
attachInterrupt( portRain, isrRain, FALLING);
}
void loop() {
int nret;
bool ok;
// get time
time_t ltime;
struct tm *timeinfo;
//ltime = time(NULL) + 9 * 3600;
ltime = time(NULL) ;
timeinfo = gmtime(<ime);
int hour = timeinfo->tm_hour;
int minute = timeinfo->tm_min;
int sec = timeinfo->tm_sec;
Serial.printf("%d:%d:%d\r\n", hour, minute, sec);
// 風速の平均
//Serial.printf("wind speed counter = %d\r\n", counterWindSpeed );
float windSpeed = getWindSpeed();
Serial.printf("wind speed = %.1f\r\n", windSpeed );
if ( windSpeed < 0 ){
delay(1000);
}
windSpeedTotal += windSpeed;
windSpeedTotalCount++;
if ( windSpeedMax < windSpeed ) windSpeedMax = windSpeed;
float windDir = getWindDir();
Serial.printf("wind dir = %.1f\r\n",windDir);
// 雨量
Serial.printf("rain counter = %d\r\n",counterRain);
//Serial.printf("rain 10min = %.1f\r\n",rainPer10min);
// 以下5分毎の処理
if ( minute % 5 != 0 || minute == lastMinuteSent ) {
//delay(1000);
return;
}
// get sensor data
// 10min rain count、風速平均値
if ( minute % 10 == 0 ){
rainPer10min = ( counterRain - rain10minLastCount ) * rainPerPulse;
rain10minLastCount = counterRain;
windSpeedAverage = windSpeedTotal / windSpeedTotalCount;
windSpeedTotal = 0;
windSpeedTotalCount = 0;
}
windSpeedMax = 0;
lastMinuteSent = minute;
//delay(1000);
}
float adcRead( int gpioNum )
{
float v = analogRead( gpioNum );
v = v * 3.3 / 4095.0;
return v;
}
// 戻り値=風速(m/s)
// -1: センサ異常
//
// ・0.25秒間隔で1秒間測定し平均を求める
//
float getWindSpeed()
{
float mATotal = 0;
int aveTimes = 4;
int msecDelay = 1000 / aveTimes - 20;
for( int i=0; i < aveTimes; i++ )
{
float mASpeed = unit420Ch0.getCurrentValue(0) / 100.0;
//Serial.printf("mASpeed=%f\r\n",mASpeed );
if ( mASpeed < 3 || mASpeed > 23 ) return -1;
if ( mASpeed < 4.03 ) mASpeed = 4;
mATotal += mASpeed;
delay(msecDelay);
}
float mAAverage = mATotal / aveTimes;
float windSpeedNow = ( mAAverage - 4 ) * windSpeedSpan / 16;
return windSpeedNow;
}
// 戻り値=風向(度)
// -1: センサ異常
//
float getWindDir()
{
float mADir = unit420Ch1.getCurrentValue(0) / 100.0;
if ( mADir < 4 || mADir > 20 ) return -1;
float windDirNow = ( mADir - 4 ) * windDirSpan / 16;
if ( windDirNow > 360 ) windDirNow -= 360;
return windDirNow;
}
// Rain
//
// 小松雨量計
// 0.5mm/pulse
// Max 200mm/h = 0.056mm/s = 1pulse/8.9sec
//
// 雑音を除くため、割り込みの20msec後にLowである事を確認している。
// 正常なパルスの場合、最短50msecはLowになる。
// また、9秒間隔より短いパルスはあり得ないとして無視する。
//
void isrRain()
{
int diff;
unsigned long now = millis();
while(1){
diff = millis() - now;
if ( diff < 0 || diff > 20 ) break;
}
if ( digitalRead( portRain ) == HIGH ) return;
diff = now - msecLastRain;
if ( diff < 9000 ) return;
counterRain++;
msecLastRain = now;
}
ブラウザ
WebSerialに対応しているブラウザとしてはChrome、Edge、Operaがあります。
これらのブラウザでは、ラズパイPicoから送られてきたデータを次のように表示できます。
「接続、設定」タブでは通信の開始、終了及び通信内容の確認、警報を鳴らす風速の設定などを行います。
「表示」タブでは、送られてきたデータの現在値や平均値を表示しています。

このページのHTMLは次のとおりです。
<!DOCTYPE html>
<html>
<head>
<style type="text/css">
.canvas-wrap{
width: 600px;
max-width: 100%;
position: relative;
padding: 0;
box-sizing: content-box;
}
.canvas_warp:before{
content:"";
display: block;
padding-top: 50%;
}
.canvas{
position: absolute;
left:0;
top:0;
border: 0;
max-width:100%;
box-sizing: content-box;
padding: 0;
margin: 0;
}
</style>
<style type="text/css">
/* タブ領域全体 */
#tabcontrol {
margin: 0;
}
/* タブ */
#tabcontrol a {
display: inline-block; /* インラインブロック化 */
border-width: 1px 1px 0px 1px; /* 下以外の枠線を引く */
border-style: solid; /* 枠線の種類:実線 */
border-color: black; /* 枠線の色:黒色 */
border-radius: 0.75em 0.75em 0px 0px; /* 枠線の左上角と右上角だけを丸く */
padding: 0.75em 1em; /* 内側の余白 */
text-decoration: none; /* リンクの下線を消す */
color: black; /* 文字色:黒色 */
background-color: white; /* 背景色:白色 */
font-weight: bold; /* 太字 */
position: relative; /* JavaScriptでz-indexを調整するために必要 */
}
/* タブにマウスポインタが載った際(任意) */
#tabcontrol a:hover {
text-decoration: underline; /* リンクの下線を引く */
}
/* タブの中身 */
#tabbody div {
border: 1px solid black; /* 黒色の実線を1pxの太さで引く */
margin-top: -1px; /* 上側にあるタブと1pxだけ重ねるために「-1px」を指定 */
padding: 1em; /* 内側の余白 */
background-color: white; /* 背景色:白色 */
position: relative; /* z-indexを調整するために必要 */
z-index: 0; /* 重なり順序を「最も背面」にするため */
/*min-height: 5em; 最低の高さが必要なら指定(不要なら省略可) */
min-height: 500px; /* 最低の高さが必要なら指定(不要なら省略可) */
}
/* タブの配色 */
#tabcontrol a:nth-child(1), #tabbody div:nth-child(1) { background-color: #ffffdd; }
#tabcontrol a:nth-child(2), #tabbody div:nth-child(2) { background-color: #ddffdd; }
#tabcontrol a:nth-child(3), #tabbody div:nth-child(3) { background-color: #ffffff; }
</style>
</head>
<body onLoad="init()">
<audio id="audioPlayer">
<source id="audioSource" src="" type="audio/mp3">
お使いのブラウザはaudioタグに対応していません。
</audio>
<p id="tabcontrol">
<a href="#tabpage1">接続、設定</a>
<a href="#tabpage2">表示</a>
<a href="#tabpage3">説明</a>
</p>
<div id="tabbody">
<!-- **************** タブ:接続 *********************-->
<div id="tabpage1">
風速計制御装置のUSBコネクタをパソコンに接続した後、<br>
<button onclick="onConnectButtonClick()">通信開始</button>
を押して下さい。<br>
表示されたシリアルポートの一覧から"PicoArduino(COM..)"を選択し、<br>
"接続”ボタンを押して下さい。<br>
<br>
通信を停止する場合は<br>
<button onclick="onDisconnectButtonClick()">通信停止</button>
を押して下さい。<br>
<br>
通信内容<br>
<textarea id="serialData" cols="60" rows="10"></textarea><br>
<br>
警告を行う風速 平均
<input type="number" id="windAlarmValue" min="0" max="100" step="1" value="5" style="width: 5ch;">
m/s 以上<br>
<input type="checkbox" id="alarmOn" checked>警告音を鳴らす<br>
</div>
<!-- **************** タブ:表示 *********************-->
<div id="tabpage2">
<canvas class="canvas" width="500" height="500" ></canvas>
<canvas class="canvas" width="500" height="500" ></canvas>
</div>
<!-- **************** タブ:使用方法 *********************-->
<div id="tabpage3">
【風速】<br>
瞬間風速:直前3秒間の風速の平均値<br>
最大風速:直前10分間の瞬間風速の最大値<br>
平均風速:直前10分間の瞬間風速の平均値<br>
<br>
【雨量】<br>
10分雨量:直前10分間の雨量<br>
</div>
</div>
<script>
//const canvas = document.getElementById('directionsCanvas');
const canvas = document.getElementsByClassName('canvas');
const frontCanvas = canvas[1];
const backCtx = canvas[0].getContext('2d');
const frontCtx = canvas[1].getContext('2d');
const centerX = canvas[0].width / 2;
const centerY = canvas[0].height / 2;
const radius = 150;
const serialData = document.getElementById('serialData');
const tabMain = document.getElementById('tabpage2');
// 16方位のテキスト
const directions = ["N", "・", "NE", "・", "E", "・", "SE", "・", "S", "・", "SW", "・", "W", "・", "NW", "・"];
const directions2 = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
// Serial
let port;
let reader;
let keepReading = false;
let tabs;
let pages;
let audioPlayer;
let audioSource;
const windAlarmValueSaved = "windAlarmValue";
const windAlarmPath = "windAlarm.wav";
const rainAlarmPath = "rainAlarm.mp3";
let windAlarmValue = 99.0; // m/s
let windAlarmLastMSec = 0;
function initTab()
{
tabs = document.getElementById('tabcontrol').getElementsByTagName('a');
pages = document.getElementById('tabbody').getElementsByTagName('div');
for(let i=0; i<tabs.length; i++) {
tabs[i].onclick = changeTab;
}
tabs[0].onclick();
}
function changeTab() {
let targetid = this.href.substring(this.href.indexOf('#')+1,this.href.length);
currentRoomNum = targetid.substring( targetid.length-1 );
for(let i=0; i<pages.length; i++) {
if( pages[i].id != targetid ) {
pages[i].style.display = "none";
}
else {
pages[i].style.display = "block";
}
}
for(let i=0; i<tabs.length; i++) {
tabs[i].style.zIndex = "0";
}
this.style.zIndex = "10";
return false;
}
function init()
{
initTab();
initAudio();
initAlarm();
}
function initAlarm()
{
let windAlarmTextbox = document.getElementById('windAlarmValue');
windAlarmValue = localStorage.getItem(windAlarmValueSaved) || "5";
windAlarmTextbox.value = windAlarmValue;
windAlarmTextbox.addEventListener("change", windAlarmChanged);
windAlarmTextbox.dispatchEvent(new Event("input"));
}
function windAlarmChanged( event )
{
windAlarmValue = event.target.value;
localStorage.setItem(windAlarmValueSaved, windAlarmValue);
}
// 描画関数
function drawDirections() {
const angle = (Math.PI * 2) / directions.length;
backCtx.font = '24px Arial';
backCtx.fillStyle = 'black';
backCtx.textBaseline = 'middle';
backCtx.textAlign = 'center';
// 円周を描画
backCtx.beginPath();
backCtx.arc(centerX, centerY, radius, 0, Math.PI * 2);
backCtx.strokeStyle = 'lightblue';
backCtx.lineWidth = 40;
backCtx.stroke();
backCtx.closePath();
for (let i = 0; i < directions.length; i++) {
const adjustedAngle = Math.PI / 2 - angle * i; // 90度反時計回りに調整
const x = centerX + Math.cos(adjustedAngle) * (radius + 35); // 円周より内側にテキストを配置
const y = centerY - Math.sin(adjustedAngle) * (radius + 35); // y座標の符号を変更
backCtx.fillText(directions[i], x, y);
}
}
// 風向を表示する
//
// degAngle : 方位角(北が0右回り)
//
function drawWindDirection(degAngle) {
const arrowLength = 30;
const arrowWidth = 20;
angle = Math.PI * degAngle / 180;
adjustedAngle = Math.PI / 2 - angle; // 90度反時計回りに調整
x = centerX + Math.cos(adjustedAngle) * (radius + 15);
y = centerY - Math.sin(adjustedAngle) * (radius + 15);
frontCtx.clearRect(0, 0, frontCanvas.width, frontCanvas.height);
frontCtx.save(); // 現在の変換状態を保存
frontCtx.translate(x, y); // 指定された座標に移動
frontCtx.rotate(Math.PI / 2 + angle); // 指定された角度に回転
// 矢印の描画
frontCtx.beginPath();
frontCtx.moveTo(0, 0);
frontCtx.lineTo(arrowLength, 0);
frontCtx.moveTo(arrowLength, 0);
frontCtx.lineTo(arrowLength - arrowWidth, arrowWidth / 2);
frontCtx.moveTo(arrowLength, 0);
frontCtx.lineTo(arrowLength - arrowWidth, -arrowWidth / 2);
frontCtx.strokeStyle = 'blue'; // 矢印の色
frontCtx.lineWidth = 4;
frontCtx.stroke();
frontCtx.closePath();
frontCtx.restore(); // 保存した変換状態に戻す
}
async function onConnectButtonClick() {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
keepReading = true;
let buff = "";
while (keepReading && port.readable) {
reader = port.readable.getReader();
try {
while (keepReading) {
const { value, done } = await reader.read();
if (done) {
console.log("Canceled\n");
break;
}
const inputValue = new TextDecoder().decode(value);
serialData.value += inputValue;
serialData.scrollTop = serialData.scrollHeight;
console.log("length=" + serialData.value.length + "\n");
let cutLength = 500;
if ( serialData.value.length > cutLength * 2 ){
serialData.value = serialData.value.substring( cutLength );
}
let idx = inputValue.indexOf("\r\n");
if (idx < 0) buff += inputValue;
else {
buff += inputValue.substring(0, idx);
dispLine(buff);
buff = inputValue.substring(idx+2);
}
}
}
catch (error) {
console.log("Error: Read" + error + "\n");
} finally {
reader.releaseLock();
}
}
} catch (error) {
console.log ("Error: Open" + error + "\r\n");
}
}
async function onDisconnectButtonClick() {
try {
keepReading = false;
if (reader) await reader.cancel();
if (port) await port.close();
} catch(error) {
}
}
function initAudio()
{
audioPlayer = document.getElementById('audioPlayer');
audioSource = document.getElementById('audioSource');
}
function playAlarm( filePath )
{
audioSource.src = filePath;
audioPlayer.load();
audioPlayer.play();
}
// 取得した1行分のデータの内容を表示する
//
function dispLine(line)
{
console.log(line+"\r\n");
let colums = line.split("=");
if (colums.length != 2 ) return;
let value = parseFloat(colums[1]);
let idx = line.indexOf("wind speed");
if (idx >= 0) {
console.log("speed="+value);
calcWindMeanMax( value );
checkWindAlarm();
}
idx = line.indexOf("wind dir");
if (idx >= 0) {
console.log("dir="+value);
drawWindDirection(value);
}
idx = line.indexOf("rain counter");
if (idx >= 0) {
console.log("counter="+value);
calcRain(value);
}
backCtx.clearRect( xWindSpeed-100, yWindSpeed-69 , 200, 136 );
backCtx.fillText('瞬間風速 ' + formatNumber( windSpeed, 2, 1 ) + ' m/s', xWindSpeed, yWindSpeed - 51 );
backCtx.fillText('最大風速 ' + formatNumber( windSpeedMax, 2, 1 ) + ' m/s', xWindSpeed, yWindSpeed - 17 );
backCtx.fillText('平均風速 ' + formatNumber( windSpeedMean, 2, 1 ) + ' m/s', xWindSpeed, yWindSpeed + +17 );
backCtx.fillText('10分雨量 ' + formatNumber( rain10min, 2, 1 ) + ' mm', xWindSpeed, yWindSpeed + 51 );
}
function checkWindAlarm()
{
// 色
let backColor = '#ddffdd';
let alarmValue = windSpeedMean;
backCtx.clearRect( 0, 0 , 500, 40 );
if ( alarmValue >= windAlarmValue ) {
backColor = 'lightcoral';
backCtx.fillText('平均風速が設定値('+ formatNumber( parseFloat(windAlarmValue), 2, 1 ) + 'm/s)を超えています', xWindSpeed, 20 );
playWindAlarm();
}
else if ( alarmValue > windAlarmValue * 0.6 ) backColor = 'lemonchiffon';
tabMain.style.backgroundColor = backColor;
}
function playWindAlarm()
{
let alarmOn = document.getElementById('alarmOn').checked;
if ( ! alarmOn ) return;
if ( Date.now() - windAlarmLastMSec > 5 * 60 * 1000 ) {
windAlarmLastMSec = Date.now();
playAlarm( windAlarmPath );
}
}
function formatNumber(num, intLength, decimalLength) {
let parts = num.toFixed(decimalLength).split("."); // 小数点以下を固定
let integerPart = String(parseInt(parts[0], 10)).padStart(intLength, " "); // 先頭の0を削除し、空白埋め
return integerPart + "." + parts[1];
}
let meanPeriodSec = 600; // 10分間平均
let windBuffMax = meanPeriodSec;
let windBuff = new Array( windBuffMax ).fill(0);
let windBuffFilled = false;
let windIndex = 0;
let windBuffTotal = 0;
let windSpeed = 0; // 瞬間風速
let windSpeedMean = 0; // 直前10分平均値
let windSpeedMeanJust = 0; // 毎正10分平均値
let windSpeedMax = 0; // 直前10分最大値
let windSpeedMaxJust = 0; // 毎正10分最大値
let lastMinutes10 = -1;
let windSpeed1 = 0;
let windSpeed2 = 0;
function calcWindMeanMax( value )
{
// 瞬間風速(3秒間の平均風速)
windSpeed = ( windSpeed1 + windSpeed2 + value ) / 3;
windSpeed1 = windSpeed2;
windSpeed2 = value;
//
let prevValue = windBuff[ windIndex ];
windBuff[ windIndex++ ] = windSpeed;
if ( windIndex == windBuffMax ){
windBuffFilled = true;
windIndex = 0;
}
// 平均値
windBuffTotal += windSpeed;
let numData = windIndex;
if ( windBuffFilled ) {
windBuffTotal -= prevValue;
numData = windBuffMax;
}
windSpeedMean = windBuffTotal / numData;
// 最大値
if ( windSpeedMax < windSpeed ) windSpeedMax = windSpeed;
else {
if ( windSpeedMax == prevValue ){
windSpeedMax = 0;
for( let i=0; i < windBuffMax; i++ ){
let speed = windBuff[ i ];
if ( windSpeedMax < speed ) windSpeedMax = speed;
}
}
}
// 毎正10分
let now = new Date();
let minutes = now.getMinutes();
if ( minutes % 10 == 0 && minutes != lastMinutes10 ){
windSpeedMeanJust = windSpeedMean;
windSpeedMaxJust = windSpeedMax;
lastMinutes10 = minutes;
}
}
// 雨量
let rainPeriodSec = 600;
let rainBuffMax = rainPeriodSec;
let rainBuff = new Array( rainBuffMax ).fill(0);
let rainBuffFilled = false;
let rainIndex = 0;
let rain10min = 0;
let rainPerCount = 0.5; // mm/pulse
function calcRain( value )
{
let prevValue = rainBuff[ rainIndex ];
rainBuff[ rainIndex++ ] = value;
if ( rainIndex == rainBuffMax ){
rainBuffFilled = true;
rainIndex = 0;
}
let diffCounter = 0;
if ( rainBuffFilled ) diffCounter = value - prevValue;
else diffCounter = value - rainBuff[0];
rain10min = diffCounter * rainPerCount;
}
// 風向表示
windDir = 45;
drawDirections();
//for ( i=0; i <16; i++ ) drawWindDirection(i*360/16);
drawWindDirection(windDir);
// 風速表示
windSpeedMax = 0;
windSpeedMean = 0;
xWindSpeed = 250;
yWindSpeed = 250;
backCtx.font = '24px Arial';
backCtx.fillStyle = 'black';
backCtx.textBaseline = 'middle';
backCtx.textAlign = 'center';
//backCtx.fillText('最大風速 ' + windSpeedMax + 'm/s', xWindSpeed, yWindSpeed - 24 );
//backCtx.fillText('平均風速 ' + windSpeedMean + 'm/s', xWindSpeed, yWindSpeed + 24 );
</script>
</body>
</html>
結果
WindowsのChromeで1日以上試したところでは、WebSerialでの通信は安定していて、特に問題になる事はありませんでした。
MacとLinux(ubuntu)ではラズパイPicoのUSBのドライバが正常に認識されるか心配でしたが、特に問題なく接続できました。
次の画像は、Windows、Mac、Ubuntuでのシリアルポート選択画面で、いずれも"picoArduino"として表示されています。
この"picoArduino"という名称は変更できる筈ですが、少し調べたところでは分かりませんでした。



WebSerialが使えるようになり、Chromeをインストールすれば、センサのデータを同じHTMLで取得、表示ができるようになりました。
セキュリティの関係で、プログラム上、制約もありますが、用途によっては手軽にクロスプラットフォームのアプリが作成できるのは便利だと思います。