2020年9月11日 星期五

使用Python的websockets套件控制樹莓派

之前那一篇利用網頁控制樹莓派(Raspberry Pi)運作
雖然實作出來,點擊後可以讓樹莓派自行運作的程式
不過,實作的交握有點旁門左道
是靠php發射socket給背景程式去運作
怎麼想怎麼怪,雖然讓我硬幹出來了,但總想著要用正規一點的作法
這次在獲得好用的python websocket套件後
正式捲.土.重.來
把之前的程式改寫了一遍
雖然還稱不上完美
但交握確定是繞過了php,而直接python寫出的程式執行websocket交握了

至於會希望使用websocket來取代原本的寫法
最基本原因是這樣會讓javascript的寫法會更簡潔
現在主要瀏覽器的javascript引擎都支援websocket了
只要簡單地寫websocket = new WebSocket("ws://server_adress:port/");
然後靠websocket.send()就可以送出想要的命令
不用寫個ajax落落長才送得出命令
接著靠websocket.onmessage()就可以取得送回來的資料
好寫多了啊
之前是懶得架websocket伺服器
現在有簡單python的套件可用,那當然好好地來用一下啊

使用的套件是這個!
https://websockets.readthedocs.io/
檔案非常小巧,而且非常易學
小巧的原因是
它仰賴的套件只有python本身的asyncio
只是談到asyncio(異步處理套件)講起來又個問題所在,因為我不是很懂
異步處理的概念上我是有了,很簡單
以程式在跑時,除了在cpu內計算,通常還需要等外部IO的處理
例如硬碟/光碟機的讀取或網路通訊對方伺服器的反應
這些事都需要時間,在等待的這些時間可以跑去做其它事,就是異步處理的概念
不過概念有了,實作上還是處於混亂狀態
什麼時候可以加await,怎麼加怎麼跑還不是很清楚
雖然這樣講起來asyncio有點難,但websockets套件使用上沒那麼複雜
因為大概部分的應用都脫離不了官方提供的範例
跟著範例跑過一輪,基本就大概掌握好了

官方範例規劃如下
https://websockets.readthedocs.io/en/stable/intro.html
稍微翻譯大綱說明
首先是基本的echo server與client
再來是加上SSL通訊的設定法
接著是把client換成網頁
然後是比較複雜的同步通訊範例
最後是講述共通樣板(Common Patterns)
像是消費者生產者兩者共用註冊
共通樣板的部分好像比較難懂,沒關係,後面會說明
先從第一個範例開始

第一個範例嘛
其實我只需要server的部分,client是靠網頁
所以就直接講server的基本寫法吧
#導入需要的函式庫
import asyncio
import websockets

#Step1 建立主要的server函數
async def hello(websocket, path):
    #Step2 在使用websocket的接收
    name = await websocket.recv()
    print(f"< {name}")

    greeting = f"Hello {name}!"

    #與傳送功能掛上await
    await websocket.send(greeting)
    print(f"> {greeting}")

#Step3 server的主函數hello,放入websocket伺服中
#      並設定ip與port
start_server = websockets.serve(hello, "localhost", 8765)

#Step4 將websocket服務丟入異步迴圈中啟動
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever() #並設定為永遠啟動
第二範例ssl加入
那要使用ssl的話也很簡單
我不使用pathlib載入檔案
簡化一下流程與列出兩個檔案的建立法
import ssl

#設定sll通訊為TLS Server
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
#載入fullchain檔案
ssl_context.load_verify_locations("/ssl_file_path/fullchain.pem")
#如果是各別兩個檔案,就這樣載入,前面放憑證,後面放key
ssl_context.load_verify_locations("/ssl_file_path/server.crt", "/ssl_file_path/server.key")
#建立websocket伺服時,設定好ssl的部分
start_server = websockets.serve(
    hello, "localhost", 8765, ssl=ssl_context
)

第三、第四個範圍我打算跟後面的共通樣板一起講
簡單地說,第三個範別是所謂的生產者模式(Producer)
async def producer_handler(websocket, path):
    while True:
        message = await producer()
        await websocket.send(message)
一個不斷生產出訊息給客戶端的樣式
這個範例就是不斷地發送時間給瀏覽器顯示
是個很簡單的生產者的模式
async def time(websocket, path):
    while True:
        now = datetime.datetime.utcnow().isoformat() + "Z"
        await websocket.send(now)
        await asyncio.sleep(random.random() * 3)

第四個範例之所以複雜就是夾雜了兩種共通樣板
分別是消費者(Consumer)與註冊(Registration)
消費者很單純就是接收瀏覽器發送後的訊息再處理
async def consumer_handler(websocket, path):
    async for message in websocket:
        await consumer(message)
而註冊則是利用一組set記錄連線進來的資料
然後在離開時刪除其資訊
connected = set()

async def handler(websocket, path):
    # Register.
    connected.add(websocket)
    try:
        # Implement logic here.
        await asyncio.wait([ws.send("Hello!") for ws in connected])
        await asyncio.sleep(10)
    finally:
        # Unregister.
        connected.remove(websocket)
這兩組結合後,就是第四範例
可以得知連線人數的加減法控制器
連上後,不但可以知道目前人數(註冊的功能)
還可以加減目前的數字(生產者的功能)
當然,是全部的連線者都看得到同一組數字
參考這範例可以寫出一個簡單但十足夠用的的聊天室了

如果希望不但可以從伺服器主動接受到訊息(生產者)
還可以發送訊息讓伺服器回應的機制(消費者)
那就是共通樣板的兩者共用(BOTH)
async def handler(websocket, path):
    consumer_task = asyncio.ensure_future(
        consumer_handler(websocket, path))
    producer_task = asyncio.ensure_future(
        producer_handler(websocket, path))
    done, pending = await asyncio.wait(
        [consumer_task, producer_task],
        return_when=asyncio.FIRST_COMPLETED,
    )
    for task in pending:
        task.cancel()
在函數裡創建兩個任務(task),然後混合在一起
再把這個函數交給websockets進行伺服器的建立就完成了
看,這樣很簡單吧

看到這裡眼睛都亮起來了
有這個範例的話,再加第三任務就輕鬆很多了...應該啦
雖然我一開始是這麼想的,不過這關一卡就是卡了兩個禮拜多
因為不管怎麼加入新的任務,都有一定的機率在關掉網頁後就停止不動了
甚至到後來就是網頁一關,第三任務就停了
後來一步一步加了log去追蹤才發現,它在for task in pending那邊被取消了
這真的是很漂亮的寫法,這樣就可以當斷線時,保留資源給其它連線者
只是我需要離線後還能運作的功能,那就只跳脫這裡

有方向後就簡單一點,很明確地要跟start_server,或說跟websocekts.serv裡的handler切開
我這邊創建一個內藏無限迴圈的函數當自己想要外加的服務機制
然後它的實際運作綁在start server的後面
async def mission_handler(mission_list):
    while True:
        #這邊要補上sleep,避免真的卡住無限迴圈
        await asyncio.sleep(1)
        if len(mission_list)>0:
            await do_mission(mission_list)
            
            
start_server = websockets.serve(handler, "localhost", 6789)
asyncio.get_event_loop().run_until_complete(start_server)
#新的handler自己跑run_until_complete
asyncio.get_event_loop().run_until_complete(mision_handler(mission_list))
asyncio.get_event_loop().run_forever()
寫到這邊或許有人會有疑問
mission_handler裡為什麼要補上asyncio的sleep
其實也很簡單,因為假如mission_list是空的(== 0),那就會一直在這裡循環,無法異步跳到其它需要處理的程序
所以當沒事做時,要跳到其它需要的地方,需要加個await asyncio.sleep(1)來處理
那個加個1秒的暫停,先離開這裡是必要的處置
最後就是上網找run_until_complete的用法
把第三個任務加入整個異步的任務中了,就可以用了

那這樣的狀態下控制樹莓派的話
真的就簡單啦
例如要亮燈,就用消費者模式配合裡用RPI.GPIO函式庫去控制IO
也可以用生產者模式持續讀取目前IO狀況回傳給網頁
就算這網頁是離線的(直接跑電腦裡寫的)也行,畢竟實際運作是靠websocket傳輸
而想要像我一樣,有個背景服務在這裡面,可以給予任務後就不予理會的話
那就像最後我的範例一樣再加個新任務進去執行就好
可以用websocket通訊加上異步處理後,這個項目應該就算正式完成
繼續學習其它東西啦

沒有留言: