ラズパイ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で取得、表示ができるようになりました。
セキュリティの関係で、プログラム上、制約もありますが、用途によっては手軽にクロスプラットフォームのアプリが作成できるのは便利だと思います。