">
FlatIsleロゴ

Flat Isle 日誌

Pico Wで遊ぼう(Webサーバー)

2023-07-13

スマホやPCで表示できるWebブラウザを経由してRaspberry Pi Pico Wを制御する為に、簡易Webサーバーを構築していきます。
ここでは公式ガイドを参考にWi-Fiの有効化およびSocket通信、HTTPヘッダーのやり取りを実装していきます。また上記簡易Webサーバーが非同期処理でCPUを効率良く利用できるように改造します。
なお前回は開発環境としてVisual Studio Codeと拡張機能Pico-W-Goを使用しましたが、動作が不安定な事が多かった為、Pico公式で紹介されているThonnyを使用する事にしました。Thonnyのインストールについては公式サイトをご参照下さい。

簡易Webサーバーの構築

公式ガイドではPico Wに簡易のWebサーバーを作成し、オンボードのLEDと温度センサーをWebブラウザから操作できるようにする手順が説明されています。
今回、上記簡易WebサーバーのプログラムにHTMLファイルの外部化及び、非同期つながりでAjaxによるサーバーとの通信を追加してみます。(もっとも、ポストバックの処理は無くなりますが、代わりのAjaxと言うことで)

Ajax対応HTMLファイルの作成

まず下記の様に公式ガイドのWeb画面をAjax処理を含めたHTMLに変更し、ファイル名を「index.html」としてPicoに保存します。(Thonnyの画面でPico接続中に表示される「Raspberry Pi Pico」側に保存(アップロード)します)

<!DOCTYPE html>
<html lang=ja>
<head>
	<meta charset="utf-8"/>
	<title>PicoAjax</title>
	<script>
	window.addEventListener("load", function(){
		document.getElementById("led_on") .addEventListener("click", function(){CtlLED(1);});
		document.getElementById("led_off").addEventListener("click", function(){CtlLED(0);});
	});
	function CtlLED(ledState){
		let jsonData = {
				"command"   :"toggleLED",
				"led"		:0
			};
		jsonData.led = ledState;
		ajax("ajax", jsonData, cbkCtlLED);
	}
	function cbkCtlLED(data){
		let arrayData = JSON.parse(data);
		if(arrayData.status == true){
			document.getElementById("state"      ).innerText = "LED is "         + arrayData.value.state;
			document.getElementById("temperature").innerText = "Temperature is " + arrayData.value.temperature;
		} else {
			alert(arrayData.value);
		}
	}
	function ajax(addr, jsonData, retFunc){
		let xHR = new XMLHttpRequest();
		xHR.onreadystatechange = function(){
			if(xHR.readyState == 4 && xHR.status == 200){
				retFunc(xHR.responseText);
			}
		}
		xHR.open("POST", addr, true);
		xHR.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
		xHR.send(JSON.stringify(jsonData));
	}
	</script>
</head>
<body>
	<input id="led_on"  type="button" value="Light on" />
	<input id="led_off" type="button" value="Light off" />
	<p id="state"></p>
	<p id="temperature"></p>
</body>
</html>

簡易Webサーバーの作成

PythonのWebサーバーを以下のように変更していきます。
HTMLを外部ファイル化したため読み込み処理を追加します。
デフォルトのWebページは上記で作成したindex.htmlを指定しています。

import micropython
import network
import socket
import json
from utime    import sleep
from picozero import pico_temp_sensor, pico_led
import machine

micropython.alloc_emergency_exception_buf(100)  # for Debug Buffer

ssid        = 'YourSSID1'
ssid2       = 'YourSSID2'
passphrase  = 'yourpassphrase'
port        = 80
defaultHtml = '/index.html'

def setupWLan(ssid, passphrase, ifMode=0, pm=0):
	ifId  = network.STA_IF if ifMode == 0 else network.AP_IF
	aryPm = (network.WLAN.PM_PERFORMANCE, network.WLAN.PM_POWERSAVE, network.WLAN.PM_NONE)
	wlan  = network.WLAN(ifId)
	if ifId == network.STA_IF:
		wlan.config(pm=aryPm[pm])
		wlan.active(True)
		wlan.connect(ssid, passphrase)
		while (wlan.isconnected() == False and ifMode == network.STA_IF):
			print("waiting for connection...")
			sleep(1)
	else:
		wlan.config(ssid=ssid, password=passphrase, pm=aryPm[pm])
		wlan.active(True)
		print(f"SSID:{wlan.config('ssid')}")
	return wlan.ifconfig()[0]

def openSocket(ip, portNo):
	connection = socket.socket()
	connection.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
	connection.bind((ip, portNo))
	connection.listen(1)
	return connection

def http(connection):
	client = connection.accept()[0]
	print("Client connected...")
	# http header read
	aryHeader = client.recv(1024).decode('utf-8').splitlines()
	# Analyze header
	reqFile = aryHeader[0].split()[1]
	reqFile = reqFile if (reqFile != "/") else defaultHtml
	if (aryHeader[0].split()[0] == "POST"):
		print("POST")
		dicData = json.loads(aryHeader[len(aryHeader)-1])
		print(dicData)
		if (dicData['command'] == "toggleLED"):
			if (dicData['led'] == 0):
				pico_led.off()
				ledRet = "OFF"
			else:
				pico_led.on()
				ledRet = "ON"
			resHtml = f'{{"status": 1,"value":{{"state": "{ledRet}", "temperature": "{pico_temp_sensor.temp}"}}}}'
		else:
			resHtml = f'{{"status": 0,"value": "Command error"}}'
		resHeader = 'HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset= UTF-8\r\n'
	else:
		print("GET")
		try:
			with open(reqFile,"rb") as file:
				resHtml = file.read().decode('utf-8')
			resHeader = 'HTTP/1.1 200 OK\r\n'
			if(reqFile.rfind('favicon.ico') >= 0):
				resHeader +=  'Content-Type: image/svg+xml\r\n'
			else:
				resHeader +=  'Content-Type: text/html\r\n'
		except OSError:
			resHtml   = '<html><body>404 Not Found</body></html>'
			resHeader = 'HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n'
			print("404")
	resHeader += f'Content-Length: {len(resHtml)}\r\n'
	resHeader +=  'Server: Raspberry Pi Pico W\r\n'
	resHeader +=  '\r\n'  # Responce header end
	client.send(resHeader + resHtml)
	client.close()
	print("Client disconnected")

# Main
wlanIp  = setupWLan(ssid, passphrase, ifMode=0, pm=2)
print(f"IP:{wlanIp}")
connection = openSocket(wlanIp, port)
try:
	while True:
		http(connection)
except KeyboardInterrupt:
	pass
finally:
	machine.reset()

上記プログラムでは、無限ループを用いて処理をしています。
本方法では他の処理が動作しなくなるため、次項で非同期処理に変更してみます。

非同期 簡易Webサーバーの作成

無限ループの代わりにイベントによる非同期処理に変更します。
データ受信処理をasync/awaitを使って記述し、イベントループのキューに登録します。

import micropython
import uasyncio as asyncio
import network
import socket
import json
from utime    import sleep
from picozero import pico_temp_sensor, pico_led
import machine

micropython.alloc_emergency_exception_buf(100)  # for Debug Buffer

ssid        = 'YourSSID1'
ssid2       = 'YourSSID2'
passphrase  = 'yourpassphrase'
port        = 80
defaultHtml = '/index.html'

def setupWLan(ssid, passphrase, ifMode=0, pm=0):
	ifId  = network.STA_IF if ifMode == 0 else network.AP_IF
	aryPm = (network.WLAN.PM_PERFORMANCE, network.WLAN.PM_POWERSAVE, network.WLAN.PM_NONE)
	wlan  = network.WLAN(ifId)
	if ifId == network.STA_IF:
		wlan.config(pm=aryPm[pm])
		wlan.active(True)
		wlan.connect(ssid, passphrase)
		while (wlan.isconnected() == False and ifMode == network.STA_IF):
			print("waiting for connection...")
			sleep(1)
	else:
		wlan.config(ssid=ssid, password=passphrase, pm=aryPm[pm])
		wlan.active(True)
		print(f"SSID:{wlan.config('ssid')}")
	return wlan.ifconfig()[0]

async def http(reader, writer):
	BLOCK_SIZE = 512
	request = bytearray(BLOCK_SIZE)
	
	print("Client connected...")
	# http header read
	size = await reader.readinto(request)
	aryHeader = request.decode('utf-8').strip('\x00').splitlines()
	# Analyze header
	reqFile = aryHeader[0].split()[1]
	reqFile = reqFile if (reqFile != "/") else defaultHtml
	if (aryHeader[0].split()[0] == "POST"):
		print("POST")
		dicData = json.loads(aryHeader[len(aryHeader)-1])
		print(dicData)
		if (dicData['command'] == "toggleLED"):
			if (dicData['led'] == 0):
				pico_led.off()
				ledRet = "OFF"
			else:
				pico_led.on()
				ledRet = "ON"
			resHtml = f'{{"status": 1,"value":{{"state": "{ledRet}", "temperature": "{pico_temp_sensor.temp}"}}}}'
		else:
			resHtml = f'{{"status": 0,"value": "Command error"}}'
		resHeader = 'HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset= UTF-8\r\n'
	else:
		print("GET")
		try:
			with open(reqFile,"rb") as file:
				resHtml = file.read().decode('utf-8')
			resHeader = 'HTTP/1.1 200 OK\r\n'
			if(reqFile.rfind('favicon.ico') >= 0):
				resHeader +=  'Content-Type: image/svg+xml\r\n'
			else:
				resHeader +=  'Content-Type: text/html\r\n'
		except OSError:
			resHtml   = '<html><body>404 Not Found</body></html>'
			resHeader = 'HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n'
			print("404")
	resHeader += f'Content-Length: {len(resHtml)}\r\n'
	resHeader +=  'Server: Raspberry Pi Pico W\r\n'
	resHeader +=  '\r\n'  # Responce header end
	writer.write(resHeader)
	writer.write(resHtml)
	await writer.drain()
	await writer.wait_closed()
	print("Client disconnected")

# Main
wlanIp  = setupWLan(ssid, passphrase, ifMode=0, pm=2)
print(f"IP:{wlanIp}")
evtLoop = asyncio.new_event_loop()
coro    = asyncio.start_server(http, wlanIp, port)
server  = evtLoop.run_until_complete(coro)
try:
	evtLoop.run_forever()
except KeyboardInterrupt:
	pass
finally:
	server.close()
	evtLoop.run_until_complete(server.wait_closed())
	evtLoop.close()
	machine.reset()

結果

Wi-Fiをクライアントモードで接続し、Webブラウザから動作させました。
上記のプログラムをPico W上で実行し、シェルに表示されたIPアドレスをブラウザのアドレスバーに入力して接続すると、各ボタンをクリックする事によりLEDの状態と温度が更新される事が確認できます。

動作確認の様子

各プログラムの解説

HTMLのAjax処理

既存のデザインを基にボタンのクリックイベント及びAjaxによるデータ更新処理を追加しています。

ボタンクリックイベントの追加

画面のロード時に、各ボタンのクリックイベントを追加します。
Webページの「Light on(Light off)」ボタンがクリックされた時、後述のLEDの制御(CtlLED)関数を呼び出します。

LEDの制御(CtlLED)

Webサーバーに送信するデータを作成し、後述のAjax処理(ajax)関数を呼び出します。
データは一括で送れるように以下の連想配列のキーと値として定義しています。

  • command
    サーバーで処理するコマンド(ここでは「toggleLED」)
  • led
    LEDの状態(LED on:1, off:0)
    関数の引数(ledState)として渡された値を設定

コールバック関数(cbkCtlLED)

後述のAjax処理(ajax)のコールバック関数として作成します。
サーバーに送ったHTTPリクエストが処理された後、返答として受け取ったJSON形式のデータ(引数 dataとして渡される)を配列に読み込みます。
配列の構造を下記の様に定義します。

  • status
    サーバーに送ったコマンドが正常に処理されたか(正常:1, 異常:0)
  • value
    • 上記statusが1の場合は、下記のデータが含まれます
      • state
        LEDの状態(”ON”, “OFF”)
      • temperature
        picoのセンサー温度
    • statusが0の場合は、エラーメッセージが入ります

これらのデータを用いてWebページの内容を更新します。
配列内のstatusがtrue(1)であれば、WebページにLEDの状態(stateの文字列)とセンサーの温度(temperatureの値)を表示します。
それ以外の場合は受け取ったエラーメッセージ(valueの文字列)を表示します。

Ajax処理(ajax)

Ajax処理を簡単に記述できるようにするために関数化しています。
引数は下記の3つです。

  • addr
    Webサーバーに対して要求するファイル名(処理名)を指定
    (今回はpython内部に処理を記述している為、呼び出し元で”ajax”固定としています)
  • jsonData
    Webサーバーに渡すデータ(JSON形式)
    (今回はコマンドとLEDの状態を渡しています)
  • retFunc
    Webサーバー応答時に処理される関数
    (今回は「cbkCtlLED」)

JavaScriptでAjaxを扱うためにXMLHttpRequest(以下XHR)オブジェクトを作成します。
XHRオブジェクト(クライアント)のonreadystatechangeイベント(状態変化)発生時に、readyStateプロパティが4(データ転送完了)かつ、statusプロパティが200(リクエスト成功)であれば、引数で指定されたコールバック関数を呼び出します。この時サーバーから受け取ったテキストデータをコールバック関数に渡します。

open()メソッドに下記3つのパラメータを指定し、HTTPリクエストを初期化します。

  • HTTPリクエストメソッドの種類
    ”POST”
  • リクエスト送信先
    引数addrで指定されたURL
  • 非同期処理
    true

setRequestHeader()メソッドでHTTP リクエストヘッダーを設定します。
サーバーへの送信データをJSON形式にするため、パラメータは下記の2つを指定します。

  • ヘッダー名
    ”Content-Type”

  • “application/json;charset=UTF-8”

send()メソッドでHTTPリクエストをサーバーに送信します。
JSONオブジェクトのstringify()メソッドを使い、引数jsonDataの内容をJSON文字列に変換し、送信します。
サーバーからの応答は前述の通り、onreadystatechangeイベント発生時に取得します。

簡易Webサーバー

メイン処理の流れは次の通りです。
Wi-Fiに接続し、取得したIPアドレスを基にソケットを作成します。このソケットを用いてサーバーとブラウザ間でHTTPリクエストのやり取りをします。
無限ループでWebブラウザからのリクエストを待ち受けて処理します。

変数の宣言

プログラム全体を通して使用する変数をまとめて宣言しておきます。

  • ssid
    Wi-FiのSSID
  • ssid2
    Wi-FiのSSID(後述のWi-Fiのモード切り替えを使用する場合に設定)
  • passphrase
    Wi-Fiのパスフレーズ(ここではssid, ssid2で共通としています)
  • port
    接続を待ち受けるポート番号
  • defaultHtml
    デフォルトのWebページのアドレス

Wi-Fiの有効化(setupWLan)

下記の4つの引数を受け取り、Wi-Fi接続を確立します。

  • ssid
    Wi-FiのSSID
  • passphrase
    Wi-Fiのパスフレーズ(passphaseと書いているサンプル多いですね)
  • ifMode
    0:クライアントモード(既存のWi-Fiに接続)。network.STA_IFを使用
    その他:アクセスポイントモード。network.AP_IFを使用
  • pm
    Wi-Fiの電力管理を設定します
    0:パフォーマンスと電力のバランスを優先
    1:省電力を優先
    2:電力管理を無効化

引数ifMode及びpmより、Wi-Fiの動作モードを決定し、ネットワークインターフェースを作成します。
クライアントモードの場合、電力管理を設定した後、ssid及びpassphraseを用いてネットワークへの接続を試みます。
(無限ループのため、接続できるまで待ち続けます)
アクセスポイントモードの場合、電力管理、ssid、passphraseを設定した後、Wi-Fiを有効にします。

ソケットの作成(openSocket)

下記の2つの引数を受け取り、ソケットを作成します。

  • ip
    サーバーのIPアドレス
  • portNo
    使用するポート番号

関数の戻り値としてソケットオブジェクトを返します。

HTTP受信処理(http)

下記の1つの引数を受け取り、ブラウザからの接続を受け入れます。送られてきたHTTPリクエストを解釈し、コマンドに応じた処理や要求されたファイルを返します。

  • connection
    バインド済みで接続待ち状態のソケットオブジェクト

ソケットのデータ受信時、送られてきたデータ(HTTPリクエスト)から要求アドレスとHTTPリクエストメソッド(”POST”または”GET”)他に分解します。
HTTPリクエストメソッドが”POST”の場合、データの最後に入っているJSON形式(上記CtlLED関数で指定された形式)のデータよりコマンドを実行し、応答データを作成します。
また”GET”の場合、要求アドレス(ファイル名)よりファイルを読み込み、応答データを作成します。
応答データ作成後、クライアントへHTTPヘッダーと共に送り返し、終了します。

非同期 簡易Webサーバー

前述の簡易Webサーバーからの変更点は下記の通りです。なお、Wi-Fiの有効化(setupWLan)には変更はありません。

  • ソケットの作成(openSocket)は非同期処理のstart_server()に変更
  • HTTP受信処理(http)を非同期関数(async/await)に変更(後述)

async型 HTTP受信処理(http)

上記の簡易Webサーバーとは異なり、HTTP受信処理を並列処理(async defで定義)に変更します。
この関数をメイン処理内にあるasyncio.start_server(ソケットサーバー起動処理)のコールバックに指定する事で、非同期処理が行われます。
コールバックとして呼び出される際に2つの引数(reader, writer)が渡されます。readerで受信データを読み取り、writerで送信データを書き出します。