2017年6月14日 星期三

MicroPython on ESP8266 (十) : socket 模組測試

完成 PIR 移動偵測測試後, 本來打算繼續做 ThingSpeak 物聯網連線測試, 但是我發現這部分資料較少, 而且牽涉到 Socket 與 TCP/UDP 等較低階的網路功能, 所以決定先做 Socket 的測試, 同時趁這機會整理一下 Python 的網路存取筆記.

Python 提供低階與高階兩種網路存取 API. 在低階部分這些 API 透過底層作業系統的支援基本的 Socket 存取以實作連接式 (Connection-oriented, TCP) 與非連接式 (Connectionless) 的客戶端 (Client) 與伺服端 (Server) 應用; 而在高階部分, 也提供了特定應用層如 HTTP, FTP 等通訊協定的存取.

Socket (網路插槽) 是兩個主機透過 IP 網路連線時在雙向通訊通道兩端的端點, 它是主機應用層與傳輸層之間的介面, 兩個不同的程序透過 IP (網路位址) 與 Port (通訊埠) 組成的網路位址來找到通訊的對象 (程序), 這是因為一個主機上有多個程序共用一個 IP, 必須加上 Port 才能區別 不同的程序 (0~1024 為系統保留埠號, 1025~65535 可自由使用). 事實上 Port 是用來讓作業系統比對收到的封包是要分派給哪一個程序的 Socket.

不同的通道型態 (channel type) 需建立不同的 Socket, 例如常用的 TCP scoket 是用在連接式的連線通道; 而 UDP Socket 則用在非連接式通道. 連接式協定需要確認程序, 例如 TCP 即透過三向交握才能建立一條連線通道, 而 UDP 則不需要, 因此 TCP 較費時. 透過 Socket 可以讓網路的存取就跟檔案 I/O 一樣, 基本上就是讀 (接收) 與寫 (傳送) 而已, 參考 :

# Bear實驗室:網路通訊之什麼是Socket?

一般的 Socket 只能讀取傳輸層 (例如 TCP/UDP) 以上的資訊, 但有一種用在 sniffer 程式設計的 Raw Socket 則可以讀取包含傳輸層以下的封包資訊, 例如 TCP/UDP 層, IP 層, 甚至鏈路層的資訊, , 參考 :

# Beej's Guide to Network Programming (很棒的 UNIX Networking 教學)

對於 Server-Client 架構來說, Socket 可以分成 Server socket 與 Client Socket, 作為伺服器的主機在建立 Server socket 後必須監聽 (listen) 特定網路位址 (IP+Port) 以等候客戶端連線; 而作為客戶端的主機在建立 Client socket 後則須連線 (connect) 遠端主機.

Python 提供了 socket 模組來支援低階網路應用程式存取 BSD Socket 介面, 其 API 參考 :

https://docs.python.org/3/library/socket.html#
python socket编程详细介绍

而在 MicroPython 則是使用 usocket 模組, 使用 Socket 功能前須先匯入此模組 :

import usocket

當然基於移植性考慮還是可以匯入 socket 模組, 如同其他 u 開頭的改寫模組, MicroPython 找不到 socket 模組時會自動匯入 usocket 模組, 此機制稱為 Fall-back. 此外, 為了效率與一致性, MicroPython 直接在 usocket 物件實作了與檔案處理類似的串流介面 (stream interface), 因此不需要使用 makefile() 將 socket 物件轉換成類似檔案的物件, 不過基於可移植性, 在 MicroPython 中仍然可以使用 makefile().

以下我以燒錄 1.9 版韌體的 ESP-01 模組 (1M) 按圖索驥測試 Socket 模組功能, 參考 :

# 5. Network - TCP sockets
class socket
usocket – socket module
http://docs.micropython.org/en/latest/micropython-esp8266.pdf (2.1.4 usocket module)

在測試 Socket 之前, 首先必須先將 ESP8266 模組透過 WiFi 連線 Internet, 參考本系列之前測試紀錄中的 WiFi 連線篇 :

MicroPython on ESP8266 (二) : 數值型別測試
MicroPython on ESP8266 (三) : 序列型別測試
MicroPython on ESP8266 (四) : 字典與集合型別測試
MicroPython on ESP8266 (五) : WiFi 連線與 WebREPL 測試
MicroPython on ESP8266 (六) : 檔案系統測試
MicroPython on ESP8266 (七) : 時間日期測試
MicroPython on ESP8266 (八) : GPIO 測試
MicroPython on ESP8266 (九) : PIR 紅外線移動偵測

簡言之只要三個指令就可以讓 ESP8266 模組透過附近的 WiFi 基地台連上 Internet :

import network
sta=network.WLAN(network.STA_IF)
sta.connect('H30-L02-webbot', '1234567890')
sta.ifconfig()

最後一個指令 ifconfig() 只是用來確認是否連線成功取得 IP, 實際測試如下 :

MicroPython v1.9-8-gfcaadf92 on 2017-05-26; ESP module with ESP8266
Type "help()" for more information.
>>> import network
>>> sta=network.WLAN(network.STA_IF)
>>> sta.connect('H30-L02-webbot', '1234567890')
>>> sta.ifconfig()
('192.168.43.72', '255.255.255.0', '192.168.43.1', '192.168.43.1')
>>> import socket

參考 :

# 4.1. Configuration of the WiFi

可見已獲得 DHCP 指派 IP 為 192.168.43.72 了. 連線 Internet 之後即可匯入 socket 模組並呼叫其建構式 socket() 來建立 socket 物件, 此建構式可傳入三個可有可無的參數如下 :

socket.socket([family[, type[, proto]]])

其中 family 是指位址家族 (address family), 可用 socket.AF_INET (預設), 或 socket.AF_INET6. type 是 socket 類型, 可用 socket.SOCK_STREAM (TCP, 預設) 或 socket.SOCK_DGRAM (UDP). 而 proto 為協定編號, 可用 socket.IPPROTO_UDP 或 socket.IPPROTO_TCP, 通常設為 0.

參考 :

# Socket address format(s)

因此下面的指令會在本機建立一個 TCP socket :

import socket
s=socket.socket()     #預設建立 TCP socket

測試結果如下 :

>>> import socket
>>> s=socket.socket()
>>> s
<socket state=0 timeout=-1 incoming=0 off=0>

但是第三參數傳入 socket.IPPROTO_TCP 卻報錯 (無 socket.IPPROTO_TCP 屬性) :

>>> s=socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute 'IPPROTO_TCP'

照下面這個文件, 第三參數通常為 0 (預設), 測試是 OK 的 :

https://docs.python.org/2/library/socket.html#socket.socket

"The protocol number is usually zero and may be omitted in that case."

>>> s=socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
>>> s
<socket state=0 timeout=-1 incoming=0 off=0>

如果是作為 Client, 則建立 socket 物件 (Client socket) 後, 就可以呼叫 connect(address) 方法與遠端主機的 socket 連線, 此方法必須傳入遠端 Socket 位址, 即 IP 與對方 Port 組成之 tuple, 且必須使用 IP 位址, 不能用網域名稱 (Domain Name). 但要如何得知遠端網域名稱的 IP 呢? 這就要用到 socket 物件的 getaddrinfo() 方法了, 此方法會傳回一個五個元素的 tuple 組成的串列, 參考 :

http://docs.micropython.org/en/latest/pyboard/library/usocket.html#usocket.socket.getaddrinfo
https://docs.python.org/3/library/socket.html#socket.getaddrinfo

例如查詢 MicroPython 首頁 micropython.org 的網址資訊, 會得到 IP 位址為 176.58.119.26 :

MicroPython v1.9-8-gfcaadf92 on 2017-05-26; ESP module with ESP8266
Type "help()" for more information.
>>> import socket
>>> addr_info=socket.getaddrinfo('micropython.org',80)
>>> addr_info
[(2, 1, 0, '', ('176.58.119.26', 80))]

利用串列索引就可以取出 IP+Port 組成的 Socket 位址了 :

>>> addr=addr_info[0][-1]
>>> addr
('176.58.119.26', 80)

建立起 socket 物件後, 應用層與傳輸層 (TCP/UDP) 的通道就建立起來, 這樣便可以用 socket 物件的送收方法 send() 與 recv() 與伺服端進行應用層的交易了.

一. Telnet 協定 : 

下面測試 1 程式取自 "5. Network - TCP sockets" 教學文件中的 Telnet 連線範例, Telnet 是 TCP/IP 的一種應用層協定, 使用系統保留埠 Port 23, 主要用於本地主機透過帳號密碼登錄遠端主機以便遙控遠端主機之用. 此例中的遠端主機 towel.blinkenlights.nl 會向與其連線的用戶端主機傳送字元動畫.

測試 1 : Telnet 協定測試 (字元動畫)  

import socket
import network
sta=network.WLAN(network.STA_IF)
sta.connect('H30-L02-webbot', '1234567890')     #這行要自己改
s=socket.socket()
s.connect(socket.getaddrinfo("towel.blinkenlights.nl", 23)[0][-1])
while True:
    data=s.recv(500)
    print(str(data,'utf8'),end='')

此程式以 Telnet 與遠端主機連線後, 在無窮迴圈內用 socket 的 recv() 方法讀取遠端主機傳來的 byte 串流, 用 str() 以 unicode 編碼轉成文字後印出來, 結果是如下的動畫 :


recv() 方法會從 Socket 接收 byte 串流資料, 須傳入一個必要參數 bufsize (緩衝區大小), 用來指定一次所能接收的資料 byte 數; 而 flag 為, 預設是 0 :

socket.recv(bufsize)

bufsize 最好是 2 的冪次整數, 例如 512, 1024, 或 4096, 參考 :

http://docs.micropython.org/en/latest/pyboard/library/usocket.html#usocket.socket.recv

在 CPython 此 recv() 方法還有一個可有可無的參數 flag (預設是 0), 參考 :

https://docs.python.org/3/library/socket.html#socket.socket.recv

但在 MicroPython 並未實作, 傳 0 進去會出現錯誤 :

>>> s=socket.socket()
>>> s.connect(socket.getaddrinfo("towel.blinkenlights.nl", 23)[0][-1])
>>> data=s.recv(500,0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: function takes 2 positional arguments but 3 were given

若不用無窮迴圈, 而是手動讀取 Socket 接收緩衝區, 可以看到每次收到的串流 :

>>> data=s.recv(500)
>>> print(data)
b'\x1b[H\x1b[J'
>>> data=s.recv(500)
>>> print(data)
b'\x1b[H\r\n\r\n\r\n\r\n\r\n\r\n                                                                         \r\n      Original Work   : Simon Jansen ( http://www.asciimation.co.nz/ )   \r\n      Telnetification : Sten Spans ( http://blinkenlights.nl/ )          \r\n      Terminal Tricks : Mike Edwards (pf-asciimation@mirkwood.net)       \r\n                                                                         \r\n      The hard work was done by Simon and Mike,                          \r\n      I just placed it online in a '

二. HTTP 協定 : 

HTTP 是 WWW 所使用的應用層協定, 它跟 Telnet 一樣也是底層採用 TCP 協定, 預設使用系統保留埠 80, 也可以改為任何自訂埠, 例如 8080 埠. WWW 是英國科學家 Tim Berners-Lee 為了協助 CERN (歐洲粒子物理實驗室) 與物理學者社群交換研究資料於 1989 年所提出的計畫, 整個 WWW 的運作是基於 Tim Berners-Lee 的三個核心設計 : HTTP 協定, HTML 語言, 以及 URL 資源定位方式. Tim Berners-Lee 於 2016 年獲得有電腦諾貝爾獎之稱的圖靈獎.

HTTP 是一種交易導向 (Transaction-based) 的 Client-Server 互動架構, 一次 TCP 連線代表一個交易, 主要用在瀏覽器 (Client) 向 WWW 網頁伺服器進行資源存取的操作上, 例如瀏覽網頁或資料庫讀寫等. 但瀏覽器只是 WWW 的一個使用者代理 (User Agent) 而已, 任何實作 HTTP 的應用程式都是 User Agent.

HTTP 以 Client-Server 模式運作, 請求 (Request) 訊息由客戶端發出, 而伺服端則在收到後進行處理並發出回應 (Response) 訊息. 在 HTTP 1.1 版以前, 所使用的 TCP 連線為非持續性連線 (Non-persistent), 一次只能傳送一個資源, 若一個網頁含有 2 個圖檔, 則連同網頁本身必須先後建立 3 個 TCP 連線才能取得全部資源. HTTP 1.1 版則支援持續性連線, 即可以在一次 TCP 連線中回應多個資源, 傳輸效率較高.

HTTP 是一種無狀態的協定, 當伺服器對一個請求做出回應後, 伺服器不會儲存這個交易的狀態或訊息 (無記憶性), 亦即每一個 Request之間都是彼此獨立, 即使客戶端馬上發出同樣的 Request, 伺服端不會記得與前面的 Request 相同, 還是重新回應一次. 有狀態 (Stateful) 的應用層協定例如 FTP.

HTTP 協定是文本的 (Text-based), 由 ASCII 純文字構成, 其訊息結構主要分成三部分 :
  1. 請求或狀態行 : 在 Request 訊息為請求行, 在 Response 訊息則為狀態行
  2. 標頭 : 由通用標頭, 請求或回應標頭, 以及實體標頭 (有實體的話) 組成
  3. 實體內容 : 應用層訊息例如 HTML

注意, 標頭與實體內容之間必須有一個空行, 且此空行就只是單純的 \r\n (即跳行字元 ) 而已, 不能含有空格. 參考 : 

# 工具篇 - HTTP协议报文结构及示例03
HTTP报文结构图解
https://www.slideshare.net/waqas1234/dictributed-application-by-waqas-presentation

HTTP 定義了客戶端向伺服端提出請求的 8 種方法 (Method), 也就是要求伺服器操作資源的 8 種動作, 一個 HTTP 伺服器至少要實作 GET 與 HEAD 這兩種方法 (注意, 方法的名稱一定要大寫), 不一定要實作全部 :

 方法 說明
 GET 在網址上傳送訊息來擷取伺服器上的資料
 POST 在 HTTP 內容中傳送訊息來擷取伺服器上的資料
 HEAD 與 GET 相同但伺服器只回應標頭
 OPTIONS 查詢伺服器可用的通訊選項
 PUT 上傳資料給伺服器以取代原來的資料
 DELETE 刪除伺服器上客戶端指定之資料 (可能被伺服器拒絕)
 PATCH 上傳與伺服器原始資料不同之處進行更新
 TRACE 要求伺服器回傳所收到之訊息並記錄所經過之 Proxy
 CONNECT  要求 Proxy 伺服器建立連線轉送 HTTP 訊息

這 8 種方法中最常用的是 GET 與 POST 這兩種, 都是用來向伺服器提出資源要求 (Request), 其主要差別在於請求參數的傳遞方式, GET 是把要傳送給伺服器的資訊組成 key=value 字串 (例如 k1=v1&k2=v2) 放在請求行中資源 URL 後面傳遞 (以 ? 號隔開), 因為 URL 長度限制為 255 個字元 (1K Bytes), 因此傳送的資訊大小有限. POST 則是將要傳遞給伺服器的資訊放在 HTTP 訊息的 Entity body (實體本文) 中, 最大可傳送 2M Bytes 的數據, 參考 :

淺談 HTTP Method:表單中的 GET 與 POST 有什麼差別
GET 與 POST 的區別與優缺點
# HTTP 方法:GET 对比 POST
# Http GET、POST Method
# HTTP - Requests

HTTP 訊息結構的第一行為請求或狀態行, 如果是 Request 訊息, 此行即為請求行; 若為 Response 訊息則為狀態行. 每欄用一個空格 (SP) 隔開, 以歸位 (CR) 與換列 (LF) 字元結束 :

此處請求行中的 Method 就是 GET/POST 等 HTTP 方法, SP 是空格 (Space), URL 是資源的網址 (事實上這是資源在主機根目錄下的 path), 而 Version 為 HTTP 版本, 目前為 "HTTP/1.1", 最後的 CRLF 為跳行字元, 例如下列的請求行向伺服器發出 GET 請求, 所要求的資源網址為靜態網頁 /myweb/test.htm :

GET  /myweb/test.htm  HTTP/1.1

如果是動態網頁, 則可以在網址後面以 ? 號附掛參數, 每一個參數以 key=value 方式傳送, 多個參數則用 & 串接, 例如 :

GET  /myweb/test.php?k1=v1&k2=v2&k3=v3  HTTP/1.1

而伺服器的 Response 訊息第一行為狀態行, 第一欄位 Version 為 HTTP 版本 "HTTP/1.1", 第二欄位為狀態碼, 表示伺服器對該請求之處理狀態, 一般正常是 200, 代表該請求已被成功執行, 常見的不正常狀態是 404, 表示所要求的資源不存在, 例如網址不正確或網頁不存在. 如果請求沒有成功執行 (即不是 200 OK), 則伺服器可能在第三欄位 Reason 註明原因.

可能的狀態代碼如下表 :

 代碼 狀態 說明
 100 Continue 通知客戶端繼續傳送 HTTP Request 訊息的本文 (Body)
 101 Switching protocol 同意用戶端要求, 更換為標頭中指定之協定
 200 OK Request 執行成功
 201 Created 建立了一個新的網路資源
 202 Accepted Request 已被接受但尚未執行完畢
 204 No content Request 執行完畢, 但無回傳訊息
 301 Multiple choices 因所要求之網路資源指定到多個地點而無法決定
 302 Moved permanently 所要求之網路資源已被永久移除無法取得
 304 Moved temporariy 所要求之網路資源暫時移除無法取得
 400 Bad request Request 語法錯誤無法辨識
 401 Unauthorized Request 未獲授權
 403 Forbidden 拒絕提供服務
 404 Not found 找不到所要求之資源
 405 Method not allowed 要求之方法不被允許
 406 Not acceptable Request 的格式不被接受
 500 Internal server error 伺服器內部有問題
 501 Not implemented 所要求之方法未實作
 503 Service unavailable 伺服器無法提供服務

HTTP 的三類標頭中, 通用標頭不管是請求或回應訊息都可以用; 請求標頭只有請求訊息才會用; 回應標頭只用在回應訊息中; 而實體標頭則只在訊息含有實體本文 (Entity body) 時才會用到. 標頭的定義參考 :

# HTTP頭欄位列表 (完整)
# HTTP - Header Fields
# HTTP 標頭參照
# HTTP協議頭部與Keep-Alive模式詳解

以 MicroPython 的測試網頁為例, 它是一個單純的 HTML 靜態網頁, 使用瀏覽器連線此網頁, 瀏覽器會以 GET 方法提出請求, 伺服器會回應簡單的文字頁面 :

http://micropython.org/ks/test.html

其網頁原始碼如下 :

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Test</title>
    </head>
    <body>
        <h1>Test</h1>
        It's working if you can read this!
    </body>
</html>

這個網頁內容會被放在回應訊息的實體本文中傳回給客戶端 (瀏覽器). 在 FireFox 中按 F12, 切到 "網路" 點選 test.htm, 在右方 "檔頭" 頁籤按 "原始檔頭" 即可顯示標頭訊息, 切到 "回應" 標籤可看到放在實體內容中的回應網頁如下 :



可見作為 Client 的瀏覽器所送出的標頭除了通用標頭 Connection 外, 其餘都是請求標頭, 例如 Host, User-Agent (瀏覽器類型), Accept (可接受資源), 以及 Accept-Language (可接受語言) 等等. 而回應訊息中, 含有 Connection, Date, Transfer-Encoding 三個通用標題; 與 Etag, Very, Server 這三個回應標頭, 以及 Content-Type, Content-Encoding, Last-Modified 這三個實體標題 (因為回應訊息中會攜帶回應網頁內容於實體本文中).

不過在 MicroPython 中要擷取此網頁不需要傳送這麼多標頭, 只要傳送請求標頭 Host 即可. 在 GET 訊息中最重要的是請求行以及請求標頭中的 Host 標頭, 請求行是用來告訴伺服器所要求的資源位置 (URL) 在哪裡 ; 而請求標頭的 Host 標頭則是所連線的遠端伺服器之 Socket 位址 (IP, Port). 因此在 MicroPython 中只要用 send() 方法向伺服端傳送下面兩行 GET 請求即可 :

GET  /ks/test.html  HTTP/1.1
Host: micropython.org

寫成 HTTP 請求訊息字串如下 (注意結尾是兩個跳行) :

msg='GET /ks/test.html HTTP/1.0\r\nHost: micropython.org\r\n\r\n'

與上面 Telnet 協定不同的是, WWW 資源的 URL (網頁位址) 是階層式結構, 例如 MicroPython 測試網頁的位址 "http://micropython.org/ks/test.html" 是在主機域名 micropython.org 的 ks 目錄下的 test.htm 檔案, 因此在連線遠端網頁伺服器前, 須利用如 split() 函數將主機位址 (host) 與網頁路徑 (path) 從完整的 URL 中擷取出來, 參考 :


函數 split() 的 API 如下 :

split([sep[, maxsplit]])

它會以第一參數為界拆分字串 (預設是空格), 並將拆出來的子字串以串列傳回, 例如 : 

>>> url='http://micropython.org/ks/test.html'
>>> url.split('/')
['http:', '', 'micropython.org', 'ks', 'test.html']

可見主機位址 (Host) 在索引 2, 而網頁路徑 ks/test.html 卻被分解了. 不過在 HTTP 協定中我們要的是網頁完整的路徑, 這可以利用 split() 的第二參數 maxsplit 來達成, 以上例來說只要拆分 3 次就可以了, 例如 : 

>>> url.split('/', 3)
['http:', '', 'micropython.org', 'ks/test.html']

官網教學文件的範例是以多重指派來取得所需的域名 host 與資源路徑 path :

_, _, host, path=url.split('/', 3)    #拆解 url 字串, 取出索引 2 的 host 與索引 3 的 path
addr=socket.getaddrinfo(host, 80)[0][-1]    #傳回域名 (host,port) 的位址資訊 (IP, Port)
s=socket.socket()   #建立 socket 物件
s.connect(addr)      #連線遠端伺服器的 socket

TCP 連線成功後便可以用 send() 方法傳送 HTTP 訊息向伺服器請求資源, 即路徑 ks/test.html 所指的網頁, 其 API 如下 :

socket.send(bytes)

參考 :

http://docs.micropython.org/en/latest/pyboard/library/usocket.html#usocket.socket.send

這裡傳入參數是 bytes 類型的二進位資料,  因 TCP 網路串流傳送的是 Byte stream 二進位資料 (TCP 是傳輸層, 只負責在兩台連線主機間搬移 byte, 這些資料的意義 TCP 不懂, 必須應用層如 HTTP 才懂), 因此必須先將 ASCII 編碼的 HTTP 請求訊息字串轉成 byte 型態資料才能傳送. Python 有內建函數 bytes() 與 bytearray() 可以處理二進位資料, bytes() 的傳回值是類似元組的 bytes 類型資料 (不可變), 而 bytearray() 的傳回值則是類似串列的 bytearray 類型資料, 跟串列或元組一樣是透過索引存取. bytes() 與 bytearray() 之 API 如下 :

bytearray([source[, encoding[, errors]]])
bytes([source[, encoding[, errors]]])

參考 :

https://docs.python.org/3/library/stdtypes.html#bytearray

注意, 如果參數 Source 是字串, 一定要指定編碼參數 Encoding, 例如 'utf-8'.

例如 :

>>> blist=[1,2,3,255]
>>> the_bytes=bytes(blist)
>>> the_bytes
b'\x01\x02\x03\xff'
>>> the_bytes[0]
1
>>> the_bytes[1]
2
>>> the_bytes[2]
3
>>> the_bytes[3]
255
>>> the_bytes[2:3]
b'\x03'
>>> the_bytes[1:3]
b'\x02\x03'
>>> the_bytes[4]              #索引超過範圍
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: bytes index out of range
>>> the_bytes[3]=127      # bytes 類型為不可變
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment
>>> the_bytes=bytes(range(0,256))      #將全部 ASCII 碼轉成 Byte
>>> the_bytes
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff'

而 bytearray() 的傳回值是可變的, 例如 :

>>> the_byte_array=bytearray(blist)
>>> the_byte_array
bytearray(b'\x01\x02\x03\xff')
>>> the_byte_array[0]
1
>>> the_byte_array[3]
255
>>> the_byte_array[3]=127
>>> the_byte_array
bytearray(b'\x01\x02\x03\x7f')
>>> the_byte_array[3]
127

傳送 HTTP 請求訊息之後, 就可以用 socket 物件的 recv() 方法來接收伺服端的回應訊息, 通常用無窮迴圈來讀取接收緩衝器, 直到緩衝器無資料可讀為止. recv() 的 API 如下 :

socket.recv(bufsize)

此處傳入參數為緩衝器一次能接收的最大 byte 數, 傳回值為一個 byte 的二進位資料, 需用 str() 函數轉成 ASCII 編碼的字串.

http://docs.micropython.org/en/latest/pyboard/library/usocket.html#usocket.socket.recv

下列測試 2 是把 ESP8266 當作一個客戶端, 透過所建立的 Client socket 傳送 HTTP 請求訊息去下載 MicroPython 官網 (Server socket) 的測試網頁 :

http://micropython.org/ks/test.html

測試 2 : HTTP 協定測試 (下載網頁)

#main.py
import network
import socket
def connect_wifi(ssid, pwd):
    wlan=network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print('connecting to network ...')
        wlan.connect(ssid, pwd)
        while not wlan.isconnected():
            pass
    print('Connected:', wlan.ifconfig())

def get_ip():
  return network.WLAN(network.STA_IF).ifconfig()[0]

def http_get(url):
    _, _, host, path=url.split('/', 3)
    addr=socket.getaddrinfo(host, 80)[0][-1]
    s=socket.socket()
    s.connect(addr)
    s.send(bytes('GET /%s HTTP/1.0\r\nHost: %s\r\n\r\n' % (path, host), 'utf8'))
    while True:
        data=s.recv(100)
        if data:
            print(str(data, 'utf8'), end='')
        else:
            break
    s.close()

將上面的程式存成 main.py, 用 ampy 上傳 ESP8266 模組後按 Ctrl-D 重開機, 先呼叫 connect_wifi() 連線 WiFi 基地台, 再呼叫 http_get() 函數, 傳入測試網頁網址 "http://micropython.org/ks/test.html" 即可收到伺服器回應之網頁內容了 :
 
MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>>
PYB: soft reboot
#8 ets_task(40100164, 3, 3fff829c, 4)
MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>> connect_wifi('EDIMAX-tony','1234567890')
Connected: ('192.168.2.112', '255.255.255.0', '192.168.2.1', '168.95.1.1')
>>> get_ip()
'192.168.2.112'          
>>> http_get('http://micropython.org/ks/test.html')
HTTP/1.1 200 OK
Server: nginx/1.8.1
Date: Mon, 12 Jun 2017 23:18:35 GMT
Content-Type: text/html
Content-Length: 180
Last-Modified: Tue, 03 Dec 2013 00:16:26 GMT
Connection: close
Vary: Accept-Encoding
ETag: "529d22da-b4"
Accept-Ranges: bytes

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Test</title>
    </head>
    <body>
        <h1>Test</h1>
        It's working if you can read this!
    </body>
</html>
>>>

可見伺服器回應的網頁放在標頭後面, 中間隔了一個空行. 由回應標頭 Server 可知伺服器為 Ngix, 而 Content-Length 則表示內容有 180 bytes.

可是當我測試 Google 首頁 'http://www.google.com.tw' 時發現測試 2 程式處理 url 有瑕疵, 其中的 split() 方法拆分 '/' 第三次時會出錯, 因為此 url 只能拆兩次 :

>>> url="http://www.google.com.tw"
>>> _, _, host, path=url.split('/', 3)     # 這一行會出錯
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: need more than 3 values to unpack

解決辦法是全拆, path 的部分 (索引 3 之後) 再重新用 '/' 聚合 :

url=url.split('/')
host=url[2]
path='/'.join(url[3:])

這樣就可以順利處理 path 的問題了. 上面測試 2 程式修改如下 :

#main.py
import network
import socket
def connect_wifi(ssid, pwd):
    wlan=network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print('connecting to network ...')
        wlan.connect(ssid, pwd)
        while not wlan.isconnected():
            pass
    print('Connected:', wlan.ifconfig())

def get_ip():
  return network.WLAN(network.STA_IF).ifconfig()[0]

def http_get(url):
    url=url.split('/')
    host=url[2]
    path='/'.join(url[3:])
    addr=socket.getaddrinfo(host, 80)[0][-1]
    s=socket.socket()
    s.connect(addr)
    s.send(bytes('GET /%s HTTP/1.0\r\nHost: %s\r\n\r\n' % (path, host), 'utf8'))
    while True:
        data=s.recv(100)
        if data:
            print(str(data, 'utf8'), end='')
        else:
            break
    s.close()

藍色為修改的部分, 這樣下面這個函數呼叫就不會出錯了 (但回應內容很長喔) :

>>>htt_get('http://www.google.com.tw')

如果只是要取得伺服器回應的標頭, 可以向伺服器提出 HEAD 請求, 其 HTTP 訊息格式與 GET 一樣, 只要把 GET 改成 HEAD 即可 :

HEAD / HTTP/1.1

伺服器只回應標頭, 而不會傳回資源內容. 我在 main.py 裡面複製 http_get() 為 http_head(), 將其中 GET 改成 HEAD 如下 :

#main.py
import network
import socket
def connect_wifi(ssid, pwd):
    wlan=network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print('connecting to network ...')
        wlan.connect(ssid, pwd)
        while not wlan.isconnected():
            pass
    print('Connected:', wlan.ifconfig())

def get_ip():
  return network.WLAN(network.STA_IF).ifconfig()[0]

def http_get(url):
    url=url.split('/')
    host=url[2]
    path='/'.join(url[3:])
    addr=socket.getaddrinfo(host, 80)[0][-1]
    s=socket.socket()
    s.connect(addr)
    s.send(bytes('GET /%s HTTP/1.0\r\nHost: %s\r\n\r\n' % (path, host), 'utf8'))
    while True:
        data=s.recv(100)
        if data:
            print(str(data, 'utf8'), end='')
        else:
            break
    s.close()

def http_head(url):
    url=url.split('/')
    host=url[2]
    path='/'.join(url[3:])
    addr=socket.getaddrinfo(host, 80)[0][-1]
    s=socket.socket()
    s.connect(addr)
    s.send(bytes('HEAD /%s HTTP/1.0\r\nHost: %s\r\n\r\n' % (path, host), 'utf8'))
    while True:
        data=s.recv(100)
        if data:
            print(str(data, 'utf8'), end='')
        else:
            break
    s.close()

用 ampy 上傳 ESP8266 後按 Ctrl-D 軟開機, 呼叫 http_head() 查詢 MicroPython 測試網頁與 Google 首頁標頭 :

>>>
PYB: soft reboot
#7 ets_task(40100164, 3, 3fff829c, 4)
MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>>
>>> http_head('http://micropython.org/ks/test.html')
HTTP/1.1 200 OK
Server: nginx/1.8.1
Date: Thu, 15 Jun 2017 01:32:08 GMT
Content-Type: text/html
Content-Length: 180
Last-Modified: Tue, 03 Dec 2013 00:16:26 GMT
Connection: close
Vary: Accept-Encoding
ETag: "529d22da-b4"
Accept-Ranges: bytes

>>> http_head('http://www.google.com.tw')
HTTP/1.0 200 OK
Date: Thu, 15 Jun 2017 01:33:03 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=Big5
P3P: CP="This is not a P3P policy! See https://www.google.com/support/accounts/answer/151657?hl=en for more info."
Server: gws
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Set-Cookie: NID=105=RWTxicR6Kgq4IYlTYBvoLoirOP_rLjL4y_ka8k4GHHQ-3l2C0uymV1hIMtxC5d2_DJaqIghQqa4DIoGITMXvgeAxu_bKkxoBgWJUNTiVYg6ah2-f2noikme3aAe-AYr7; expires=Fri, 15-Dec-2017 01:33:03 GMT; path=/; domain=.google.com.tw; HttpOnly
Accept-Ranges: none
Vary: Accept-Encoding

>>>

接下來測試 POST 請求的傳送方法, 參考 :

# Python socket client Post parameters

def http_post(url, parameters):
    _, _, host, path=url.split('/', 3)
    addr=socket.getaddrinfo(host, 80)[0][-1]
    s=socket.socket()
    s.connect(addr)
    request='POST /%s HTTP/1.0\r\n' % path
    headers='Host: %s\r\n' % host
    headers += 'Content-Length: %s\r\n' % str(len(parameters))
    headers += 'Content-Type: application/x-www-form-urlencoded\r\n\r\n'
    s.send(bytes(request + headers + parameters + '\r\n', 'utf8'))
    while True:
        data=s.recv(256)
        if data:
            print(str(data, 'utf8'), end='')
        else:
            break
    s.close()


http_post('http://httpbin.org/post','a=1&b=2')

測試結果如下 :

PYB: soft reboot
#7 ets_task(40100164, 3, 3fff829c, 4)
MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>> http_post('http://httpbin.org/post','a=1&b=2')
HTTP/1.1 200 OK
Connection: close
Server: meinheld/0.6.1
Date: Thu, 15 Jun 2017 23:23:14 GMT
Content-Type: application/json
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
X-Powered-By: Flask
X-Processed-Time: 0.000968217849731
Content-Length: 341
Via: 1.1 vegur

{
  "args": {},
  "data": "",
  "files": {},
  "form": {
    "a": "1",
    "b": "2"
  },
  "headers": {
    "Connection": "close",
    "Content-Length": "7",
    "Content-Type": "application/x-www-form-urlencoded",
    "Host": "httpbin.org"
  },
  "json": null,
  "origin": "223.138.253.129",
  "url": "http://httpbin.org/post"
}

在上面的範例中, POST 訊息中包含了三個請求標頭 : Host, Content-Type, 以及 Content-Length, 其中 Host 在 Internet 存取中是一定要的標頭, 否則 Proxy 會不知道要將此要求丟給誰, 參考 :

Python socket client Post parameters

"A client MUST include a Host header field in all HTTP/1.1 request messages . If the requested URI does not include an Internet host name for the service being requested, then the Host header field MUST be given with an empty value. An HTTP/1.1 proxy MUST ensure that any request message it forwards does contain an appropriate Host header field that identifies the service being requested by the proxy. All Internet-based HTTP/1.1 servers MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message which lacks a Host header field."

參考 :

urllib / urequests for Micropython
micropython/micropython-lib
NTP update micropython time
Get time from NTP Server
micropython-lib/smtplib
micropython sending mail
# HTTP協議頭部與Keep-Alive模式詳解
# Beej's Guide to Network Programming 正體中文版 (推薦)
# usocket UDP and sockaddr questions
python socket编程详细介绍
m@rcus 學習筆記
# HTTP - Responses
# Learn Python in 24 Hours - Sunny Chanday
# Python3.pdf
# Core Python Programming 2nd Edition 2006 (TCP/UDP)

沒有留言 :