2017年6月17日 星期六

好書 : 了解人工智慧的第一本書

這本是我從市圖鄉下的分館借回來的書, 譯自日本人工智慧專家松尾豐的科普著作 "人工知能は人間を超えるか: ディープラーニングの先にあるもの" (經濟新潮社出版). AI 領域深奧難懂, 令人感到高不可攀, 但作者卻能把它寫得如平易近人的散文實在不容易, 甚至讓我覺得寫得太淺白了, 好像 AI 不過是如此而已. 對初學者來說, 此書真的如中文書名, 是 AI 入門的第一本書.

Source : 城邦

我在網路上找到一篇此書的讀後感與摘要 "品書隨記", 他提到三采出的 "大演算" 比這本早一周出版似乎時序上佔了優勢, 我覺得一本好書不會因為晚一周面世就會賣不好, 其實 "大演算" 我買了幾個月了, 斷斷續續只讀完一章, 坦白說內容比較深, 而且敘述繁雜不是很好讀. 而松尾豐這本卻是外行人都能輕鬆看下去的書.

以下是我的閱讀心得與札記.

人工智慧 Artificial Intelligence 一詞誕生於 1956 年夏天的達特茅斯會議, 這是由 John McCarthy 發起在其所任教的新罕布夏州達特茅斯學院所召開的一個學術會議. 當時成員中的四人後來都獲得了有電腦界諾貝爾獎之稱的圖靈獎, 其中 Herbert Simon 還以組織內決策程序理論獲得 1978 年諾貝爾經濟學獎. 這四人可說是達特茅斯傳奇四人組.

 John McCarthy 1927-2011 1971 年圖靈獎
 Marvin L. Minsky 1927-2016 1969 年圖靈獎
 Allen Newell 1927-1992 1975 年圖靈獎
 Herbert A. Simon 1916-2001 1975 年圖靈獎, 1978 年諾貝爾經濟學獎

人工智慧在達特茅斯會議後逐漸發展, 但過去的 60 年間 AI 卻經歷了三次熱潮, 以及遭遇經費困難後隨之而來的寒冬. 這三次熱潮的焦點與所立下的里程碑如下 :

 第一次 AI 熱潮 (60 年代) 以電腦推論 (思考過程符號化) 與探索解決特定問題 (如迷宮問題)
 第二次 AI 熱潮 (80 年代) 將知識輸入電腦進行決策判斷例如專家系統 (知識表達)
 第三次 AI 熱潮 (千禧年前後) 利用大數據, 類神經網路與機器學習演算法自動挖掘知識的特徵

參考 :

# https://zh.wikipedia.org/wiki/人工智能史

人工智慧應用於電腦棋賽從 50 年代就開始了, 最早是從棋局較簡單的西洋棋開始, 逐漸發展到挑戰變化複雜的圍棋. IBM 電腦西洋棋深藍在 1997 年的世紀對奕中首次擊敗了棋王卡斯帕羅夫, 使得人工智慧再次贏得舉世注目. 而接下來的 10 年由於大數據與深度學習演算法的發展, 連難度更高的圍棋也被 Google 的 AlphaGo 攻下來, 連續三年陸續擊敗人類圍棋頂尖好手 :

 1997 IBM 超級電腦深藍擊敗西洋棋世界冠軍俄羅斯籍的卡斯帕羅夫
 2011 IBM 人工智慧系統華生在危險邊緣節目中擊敗人類對手
 2015 Google 人工智慧系統 AlphaGo 以 3:0 成績完勝歐洲圍棋冠軍華裔法籍棋士樊麾 (二段)
 2016 Google 人工智慧系統 AlphaGo 以 4:1 成績擊敗圍棋世界冠軍南韓棋士李世乭 (九段)
 2017 Google 人工智慧系統 AlphaGo 以 3:0 成績完勝圍棋世界冠軍南韓棋士柯潔 (九段)

值得注意的是, 電腦棋類的突破都有台灣人的貢獻, 深藍源自於台大電機系畢業的許峰雄於卡內基美隆大學攻讀博士學位時的研究, 許氏後來加入 IBM 成為深藍的主要設計者. 而 2014 年開始發展的 AlphaGo 在黃士傑 (交大資工系 & 台師大資工博士) 加入後戰力增強, 連續過關斬將一夕成名.

電腦棋賽軟體能夠戰勝人類得力於兩大祕訣, 一是利用機器學習找出更好的特徵量 (Feature, 即資訊中要關注的相關性); 二是以蒙地卡羅搜尋樹演算法作為決策評分機制. 透過大數據分析可以在巨量的棋譜中找出人類尚未發現的相關性 (特徵量), 可以有效提升下一步棋的精確度. 而蒙地卡羅法則是放棄以權重來評估盤勢的傳統做法, 改採如暴力破解法那樣近乎隨機出棋的方式來評估最後的勝率以決定下一步的走法.

AlphaGo 的成功關鍵是採用了深度學習演算法. 深度學習讓電腦不需人為介入, 可以根據資料自行找出特徵量, 故又稱為特徵表達學習 (Feature representation learning), 是一種揉合非監督式與有監督式機器學習的演算法. 此演算法是在 2006 年由加拿大多倫多大學的 Geoffrey Hinton 所提出, 屬於一種多層神經網路. 他開發的演算法使用自動編碼器做為資訊壓縮器, 利用無監督式學習所得資料提升手監督式學習之精確度, 有效地提高了機器學習的效率 (但在圖像辨識中通常不使用自動編碼器, 而是使用捲積神經網路). 2012 年在全球視覺辨識大賽中以顯著差距擊敗全世界名校與知名研究機構的多倫多大學 SuperVision 團隊就是採用了 Hinton 主導開發的深度學習演算法.

簡言之, 深度學習只是從資料中找出特徵量與概念, 再運用這些去找出更多東西出來而已. 但是特徵量必須具有強固性 (Robustness), 而要提升強固性可以在輸入資訊中加入雜訊, 或者隨機丟失隱藏層中的部分神經元.

雖然深度學習的出現給人工智慧打了一劑強心針, 但實際上電腦到這階段充其量還只是個在統計學支配下的高效能符號操作機器而已, 它沒有概念, 因此無法將符號與概念結合起來. AlphaGo 雖然打敗了人類頂尖圍棋手, 但它其實還是不懂圍棋是啥東西. 它只知道要怎麼做才能在這場遊戲中提高勝率而已. 所以, 其實目前的人工智慧離奇異點還遠得很哩, 史迪夫霍金等人的憂慮現在可說是杞人憂天.

對於人工智慧的潛力, 有所謂弱人工智慧 (Weak AI) 與強人工智慧 (Strong AI), 持弱人工智慧看法的人認為電腦只需要以特定智慧以知性的方式解決人類的問題即可, AI 不需要有 "心". 本書作者比較傾向後者, 我個人也是持如此看法, AI 終有一天會發展出人工意識, AI 必將兼具知性與感性, 電腦將能像人類一樣思考, 人腦所有的心智活動, 例如思考, 認知, 記憶, 與情感等都能在電腦上實現, 智慧機器人將擁有自我, 能認識周遭環境並採取行動.

數學家 Roger Penrose 在其著作 "The Emperor's New Mind : Concerning Computer; Minds, and the Law of Physics" 中說人腦的微細管子存在量子現象, 且與意識相連結. 這很有意思, 因為馮紐曼也認為, 意識是大腦內部量子行為的結果 (量子力學的數學基礎). Roger Penrose 認為意識是腦細胞微管中的量子現象, 人死後這些量子訊息會離開肉體回到宇宙, 就是所謂的靈魂. 不過佛教惟識學認為意識只是此生的現象, 與眼耳鼻舌身一樣死後即滅, 不滅的是第八阿賴耶識. 參考 :

# 人的靈魂是量子現象,離開肉體後將重回宇宙?

量子現象或許就是物質與精神之間的繫結, 我們所確知並感受到的世界是意識干涉導致波函數塌縮後的狀態, 否則就是不確定的量子疊加態, 這就是薛丁格之貓所要表達的思維. 佛教說的萬法唯心造跟量子現象竟然是如此不謀而合. 參考 :

# 量子意識?現代科學與佛法的匯合處

另外, 我找到的下面這篇文章介紹了幾本很棒的 AI 書籍, 我對馬文明斯基寫的 "The Emotion Machine (情感機器)" 非常感興趣, 我覺得是指引意識引擎明路的一本好書, 明斯基是 AI 領域第一位獲得圖靈獎 (1969) 的人工智慧先驅, 可惜已在 2016 年逝世了.

# 有關人工智慧的那些書,看這一篇就夠了
# 周末薦書|《情感機器》

2017年6月16日 星期五

樹莓派 Pi Zero W 的功率消耗

自從買了兩塊 Pi Zero W 後, 一直想要比較手上幾款樹莓派板子的功耗, 但功率計壞掉了沒辦法做. 今天看到下面這網站有完整的比較, 我就不用再忙了 :

# How much power does Pi Zero W use?


由圖可知 A+ 最優, 略勝 Pi Zero, 因為 A+ 沒有內建 WiFi, 功耗當然較少. 這些板子只有 A 與 Pi 2 我沒買, 原先蠻喜歡 A+ 的, 但 Pi Zero 出來後就被取代了, Pi Zero W 更優, 空載時僅耗 0.6W, 全速運作也低於 1.25W. 雖然 Pi 3 效能較高, 但體積重量都大, 我覺得還是 Pi Zero W 最適合做飛控電腦.

2017年6月15日 星期四

MicroPython on ESP8266 (十一) : urllib.urequest 模組測試

做完 socket 模組測試後, 我在 Oreilly 出版的 "精通 Python" 這本書的第九章看到 Python 內建模組 urllib 的介紹, 可以使用 urllib.request 類別來處理 HTTP 請求, 這比用 socket 要簡單, 因為不需要去操作 socket 與處理 HTTP 協定格式.

原來 Python 3 整合原本 Python 2 零散的用戶端與伺服器模組, 在標準函式庫中提供 http 與 urllib 模組. 其中 http 模組負責用戶端與伺服端的 HTTP 協定操作 (包括 cookie 管理); 而 urllib 模組負責請求 (urllib.request) 與回應 (urllib.response) 之處理, 以及 URL 的拆解.

可惜 MicroPython 沒有實作 http 模組, 但有實作 urllib 模組處理請求的部分, 不過模組名稱是 urllib.ureqest 而非 urllib.request (不會 fall-back).

>>> import urllib
>>> import http
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: no module named 'http'

雖然 urllib.ureqest 實作的功能不多, 但也足夠使用了, 相關文件可參考 CPython 的 urllib.request 模組說明 :

# https://docs.python.org/3.5/library/urllib.request.html#module-urllib.request
# HOWTO Fetch Internet Resources Using The urllib Package

本系列測試紀錄前十篇參考 :

# 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 紅外線移動偵測
# MicroPython on ESP8266 (十) : socket 模組測試

MicroPython 文件參考 :

# MicroPython tutorial for ESP8266  (官方教學)
# http://docs.micropython.org/en/latest/micropython-esp8266.pdf
# http://docs.micropython.org/en/latest/pyboard/library/usocket.html#class-socket
# http://docs.micropython.org/en/v1.8.7/esp8266/library/usocket.html#module-usocket
# https://gist.github.com/xyb/9a4c8d7fba92e6e3761a (驅動程式)
使用 urllib.urequest 模組要先匯入 :

import urllib.urequest

或者取個別名以減少輸入時間 :

import urllib.urequest as u

這樣就可以呼叫模組的方法 urlopen() 來開啟遠端 URL 資源, 其 API 如下 :

urllib.urequest.urlopen(url[, data][, timeout])

其中可有可無的第二參數 data 是要傳送給伺服器的資料字串, 如果有傳送 data 的話就用 POST 方法提出請求; 否則就是用 GET 方法. urlopen() 方法會傳回一個 socket 物件, 這樣就可以用 socket() 物件的 read() 方法來讀取 HTTP 回應內容, 例如讀取 MicroPython 測試網頁 :

>>> import urllib.urequest as u
>>> s=u.urlopen('http://micropython.org/ks/test.html')  #以 GET 方法提出請求
>>> type(s)
<class 'socket'>      #傳回值是 socket 物件
>>> s
<socket state=3 timeout=-1 incoming=3fffa058 off=0>
>>> s.status      #沒有實作 HTTP 回應狀態碼
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'socket' object has no attribute 'status'
>>> s.getheader('Content-Type')    #沒有實作 getheader()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'socket' object has no attribute 'getheader'
>>> s.getheaders()      #沒有實作 getheaders()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'socket' object has no attribute 'getheaders'
>>> r=s.read()    #讀取 TCP 串流緩衝區 (傳回 bytes 型態)
>>> s
<socket state=3 timeout=-1 incoming=0 off=0>      #讀取過後 incoming 歸零
>>> type(r)
<class 'bytes'>            #傳回 bytes 型態
>>> r          #二進位 (byte) 資料
b'<!DOCTYPE html>\n<html lang="en">\n    <head>\n        <title>Test</title>\n    </head>\n    <body>\n        <h1>Test</h1>\n        It\'s working if you can read this!\n    </body>\n</html>\n'
>>> r=s.read()      #讀過之後緩衝區已清空, 再次 read() 無內容
>>> r
b''

上面 socket.read() 傳回的 bytes 串流可以用 decode('utf-8') 方法轉成 utf-8 編碼的字串, 然後再用字串的 split() 方法以 '\n' 為界拆分字串為串列型態, 這樣就可以用 for 迴圈印出 HTML 格式了 :

>>> import urllib.urequest as u
>>> s=u.urlopen('http://micropython.org/ks/test.html')
>>> r=s.read()
>>> r.decode("utf-8")
'<!DOCTYPE html>\n<html lang="en">\n    <head>\n        <title>Test</title>\n    </head>\n    <body>\n        <h1>Test</h1>\n        It\'s working if you can read this!\n    </body>\n</html>\n'
>>> html=r.decode('utf-8').split('\n')
>>> 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>', '']
>>> for line in html:
...     print(line)
...
...
...
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Test</title>
    </head>
    <body>
        <h1>Test</h1>
        It's working if you can read this!
    </body>
</html>

>>>

以上是 urllib.urequest 模組處理 GET 請求的方法, 不過由於 MicroPython 測試網頁只是單純的靜態網頁, 無法測試帶參數的 GET 請求. 測試含有參數的 GET 可以利用 httpbin.org 的主機所提供的服務, 其 GET 測試網址為 :

# http://httpbin.org/get

此網頁會回應客戶端所傳送的參數 (args) 與請求標頭資訊, 如果在瀏覽器輸入如下網址傳遞 a=1 與 b=2 參數, 伺服器回應如下  :

# http://httpbin.org/get?a=1&b=2

{
  "args": { 
    "a": "1", 
    "b": "2"
  }, 
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 
    "Accept-Encoding": "gzip, deflate, sdch", 
    "Accept-Language": "zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4", 
    "Cache-Control": "max-stale=0", 
    "Connection": "close", 
    "Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1", 
    "Host": "httpbin.org", 
    "Upgrade-Insecure-Requests": "1", 
    "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 OPR/45.0.2552.888", 
    "X-Bluecoat-Via": "e850b88f17ae9572"
  }, 
  "origin": "210.88.213.252", 
  "url": "http://httpbin.org/get?a=1&b=2"
}

而在 MicroPython 輸入如下指令 :

import urllib.urequest as u
url='http://httpbin.org/get?a=1&b=2'
s=u.urlopen(url)
r=s.read()
lines=r.decode('utf-8').split('\n')
for line in lines:
    print(line)

結果如下 :

MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>> import urllib.urequest as u
>>> url='http://httpbin.org/get?a=1&b=2'
>>> s=u.urlopen(url)
>>> r=s.read()
>>> r
b'{\n  "args": {\n    "a": "1", \n    "b": "2"\n  }, \n  "headers": {\n    "Connection": "close", \n    "Host": "httpbin.org"\n  }, \n  "origin": "223.138.76.144", \n  "url": "http://httpbin.org/get?a=1&b=2"\n}\n'
>>> lines=r.decode('utf-8').split('\n')    #格式化
>>> for line in lines:
...     print(line)
...
...
...
{
  "args": {
    "a": "1",
    "b": "2"
  },
  "headers": {
    "Connection": "close",
    "Host": "httpbin.org"
  },
  "origin": "223.138.76.144",
  "url": "http://httpbin.org/get?a=1&b=2"
}

>>>

可見伺服器確實有收到所傳遞之參數.

測試 POST 請求可以用 httpbin.org 主機的 POST 測試網址 :

# http://httpbin.org/post

在 MicroPython 輸入下列指令, 當 urlopen() 含有第二參數時, 將會以 POST 方法提出請求. 注意, 這裡傳遞的參數須以 dict 形式的字串呈現, 不可直接用 dict 物件, 否則會產生 "TypeError: object with buffer protocol required" 的錯誤訊息.

import urllib.urequest as u
url='http://httpbin.org/post'
data="{'a':'1','b':'2'}"
s=u.urlopen(url,data)
r=s.read()
lines=r.decode('utf-8').split('\n')
for line in lines:
    print(line)

結果如下 :

MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>> import urllib.urequest as u
>>> url='http://httpbin.org/post'
>>> data="{'a':'1','b':'2'}"             #必須是字串
>>> s=u.urlopen(url,data)
>>> r=s.read()
>>> lines=r.decode('utf-8').split('\n')
>>> for line in lines:
...     print(line)
...
...
...
{
  "args": {},
  "data": "{'a':'1','b':'2'}",
  "files": {},
  "form": {},
  "headers": {
    "Connection": "close",
    "Content-Length": "17",
    "Host": "httpbin.org"
  },
  "json": null,
  "origin": "223.198.79.144",
  "url": "http://httpbin.org/post"
}

可見所傳遞的參數是放在 data 屬性中, 而記錄 GET 參數的 args 為空.

參考 :

# Henry's HTTP Post Dumping Server
# http://posttestserver.com/post.php
# Testing multipart/form-data uploads with Post Test Server
# MICROPYTHON ON THE ESP8266: KICKING THE TIRES (socket 部分)

2017年6月14日 星期三

Arduino 中斷時執行下一個副程式問題

網友在下面這篇文章中留言問了一個問題 :

# Arduino 按鈕開關測試 (二) : 硬體中斷法 (Interrupt)

"若好幾支副程式A程式B程式C程式D程式依序被呼叫,然後B程式值行的時間太久所以我想中斷後直接看到C程式"

簡言之就是希望中斷發生後跳到下一個副程式. 因為篇幅有點長, 所以我把回覆內容記在這裡. 已經好久沒寫 Arduino 程式, 有點生疏了. 還好勤於寫測試筆記有案可稽, 要恢復記憶比較容易.

硬體接線很簡單, 我在 D2 接上一個按鈕, 啟用內部上拉電阻讓平時為 HIGH, 而按下按鈕時接地為 LOW 以觸發中斷.

在下列模擬程式中我定義了 a(), b(), c(), d() 四個副程式, 裡面又呼叫一個 doSomething() 副程式來模擬耗時的工作, 這裡使用 delay(1) 跑 10000 次迴圈, 故正常無中斷情況下 10 秒才會跑完. 注意, 這裡不用 delay(1000) 跑 10 圈, 或直用 delay(10000) 的原因是, delay() 被中斷後跑去執行中斷常式再回來時, 似乎不是跳到 delay() 下一個指令, 而是會繼續把 delay() 沒跑完的休息時間跑完, 這樣會造成中斷後還是得等 delay() 休息結束才會去執行下一個副程式.

在 loop() 中依序呼叫此四個副程式, 當中斷發生時主控權會轉移到 int0() 執行, 印出 "Interrupted ..." 同時把預設為 false 的 breakloop 旗標設為 true 以便回到被中斷的 doSomething() 時可以跳出迴圈, 執行下一個副程式 :

const byte intPin=2; //interrupt pin D2
int debounceDelay=200; //debounce delay (ms)
void int0();
void a();
void b();
void c();
void d();
void doSomething();
boolean debounced();
boolean breakloop=false;    #跳出迴圈旗標

void setup() {
  Serial.begin(9600);
  pinMode(intPin, INPUT_PULLUP); //enable pull-up resistor of input pin
  attachInterrupt(0, int0, LOW); //enable int0 (D2)
  }

void loop() {
  a();
  b();
  c();
  d();
  }

void a() {
  Serial.println("running a ...");
  doSomething();
  }

void b() {
  Serial.println("running b ...");
  doSomething();
  }

void c() {
  Serial.println("running c ...");
  doSomething();
  }

void d() {
  Serial.println("running d ...");
  doSomething();
  }  

void doSomething() {
    for (int i=0; i<10000; i++) {
      if (breakloop) {       #若被中斷服務程式 int0() 設定為 true
        breakloop=false;   #重置旗標
        break;                    #跳出迴圈                
        }
      delay(1);
      }
  }

void int0() { //interrupt handler
  if (debounced()) {
    Serial.println("Interrupted ... ");
    breakloop=true;
    }
  }

boolean debounced() { //check if debounced
  static unsigned long lastMillis=0; //record last millis
  unsigned long currentMillis=millis(); //get current elapsed time
  if ((currentMillis-lastMillis) > debounceDelay) {
    lastMillis=currentMillis; //update lastMillis with currentMillis
    return true; //debounced
    }
  else {return false;} //not debounced
  }

監控視窗輸出結果如下 :

running a ...
Interrupted ...
running b ...
Interrupted ...
running c ...
running d ...
Interrupted ...
running a ...
Interrupted ...
running b ...
running c ...
running d ...
running a ...
running b ...
Interrupted ...
running c ...
Interrupted ...
running d ...

每按一下按鈕就會馬上跳到下一個副程式執行.

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 紅外線移動偵測

MicroPython 文件參考 :

# MicroPython tutorial for ESP8266  (官方教學)
# http://docs.micropython.org/en/latest/micropython-esp8266.pdf
# http://docs.micropython.org/en/latest/pyboard/library/usocket.html#class-socket
# http://docs.micropython.org/en/v1.8.7/esp8266/library/usocket.html#module-usocket
# https://gist.github.com/xyb/9a4c8d7fba92e6e3761a (驅動程式)
簡言之只要三個指令就可以讓 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)

2017年6月13日 星期二

向 tsai_pl 購買 D1 Mini 等零組件一批

因為要補充 AMS1117 以及測試 D1 mini 與飛控模組的關係, 今天向露天賣家 tsai_pl 購買下列零組件一批 :

商品名稱/商品編號 數量/
商品總價
紅外線避障尋跡模組 智能車 黑白線識別 距離可調 Arduino  (21713687252150) 2
$30
L9110S 馬達 電機 驅動 模組 板 兩路 避障車 自走車  (21713686788243) 2
$30
TB6612FNG電機驅動模組 小體積高性能超L298N 避障 自走車  (21634415523556) 2
$90
KCD1-101 大電流 船型開關 翹板開關 電源開關 10A 125V 紅色2腳 21*15mm  (21711545810421) 5
$20
GY-652 HMC5983 + BMP180 四軸飛控 感測器 電子指南針 大氣壓強 模組  (21713687032044) 1
$150
MOS FET 場效應 電晶體 驅動 模組 IRF520 Arduino  (21719199508349) 4
$60
ESP8266 ESP-01  (21723446432468) 1
$60
PCB板 實驗板 2*8cm 1.6mm 雙面噴錫纖維板 電路板 洞洞板 綠色  (21720335748800) 5
$30
ADXL345 數字三軸重力加速度傾斜度模塊  (21713636060735) 1
$40
高精度 紅外線 避障 尋跡 模組 智能車 黑白線識別 距離可調  (21713661131428) 2
$30
MPU 6050 模組 三軸加速度陀螺儀 6DOF模組 (21651864933143) 1
$50
ESP8266 ESP 12S 送轉接板 Arduino  (21628094187705) 2
$150
BMP280 3.3V 高精度 大氣壓 高度計 感測器模組  (21713687070406) 2
$100
D1 mini 迷你 NodeMcu Lua WIFI 物聯網 開發板 基於ESP8266  (21651863971346) 1
$115
TTP223 觸摸按鍵模組 自鎖 點動 電容式開關 單路改造  (21702082259187) 3
$18
IIC I2C 電平轉換模組 5v - 3.3v 雙向  (21723468792089) 4
$60
PCB板 實驗板 3*7cm 1.6mm 雙面噴錫纖維板 電路板 洞洞板 綠 紅 藍 三色  (21711526971798) 5
規格:綠 $35
USB 轉 ESP8266 WIFI 模組 轉接板  (21723446408247) 1
$50
AMS1117 3.3V 電源降壓模組 3.3V 穩壓 Arduino (21711503557838) 1
$6
新版 Wemos NodeMCU V2 腳位 尺寸 相容 開發板 ESP8266 / 12E Lua 物聯網 340G  (21722410210945) 1
$110
4路電平轉換模組 雙向互轉 3.3V 轉 5V IIC I2C UART SPI  (21723468774540) 4
$60
AMS1117-3.3V 電源IC 降壓IC 線性穩壓LDO SOT-223  (21713661277867) 11
$11
1A鋰電池專用充電板 充電模塊 TP4056鋰電池充電器 高精度 移動電源 Micro USB  (21704180396900) 2
$16


7-11取貨付款 $60
含運總價 $1381

下列物品因缺貨下次再買 :

# 無線模塊轉接板 5V 轉 3.3V 配套 NRF24L01 無線模塊用 智能小車機器人必備 $20
# 電壓檢測模組 Voltage Sensor 電壓感測器 Arduino $5
# HY-SRF05 五針 超音波模組 超聲波測距模組 超聲波感測器 $20
# ESP-WROOM-32 ESP32 模組 + 轉接板(WIFI&藍牙二合一)ESP8266 Arduino $150
# 1路 繼電器 模組 5V 低電平 arduino 適用 $15
# RCWL-0516 微波雷達感應開關模組 人體感應模組 智能感應探測器 $24

參考 :

# D1 mini 迷你 NodeMcu Lua WIFI 物聯網 開發板 基於ESP8266 $119
# 【傑森創工】WeMos D1 mini WiFi 開發板 ESP8266 Arduino 相容 開發NodeMCU $165
# micropython-on-wemos-d1-mini.readthedocs.io/en/latest/setup.html