使用 Go 和樹莓派排查 WiFi 問題

語言: CN / TW / HK

去年夏天,我和妻子變賣了家產,帶著我們的兩隻狗移居了夏威夷。這裡有美麗的陽光、溫暖的沙灘、涼爽的衝浪等你能想到的一切。我們同樣遇到了一些意料之外的事:WiFi 問題。

不過,這不是夏威夷的問題,而是我們租住公寓的問題。我們住在一個單身公寓裡,與房東的公寓僅一牆之隔。我們的租房協議中包含了免費的網路連線!好耶!只不過,它是由房東的公寓裡的 WiFi 提供的,哇哦……

說實話,它的效果還不錯……吧?好吧,我承認它不盡如人意,並且不知道是哪裡的問題。路由器明明就在牆的另一邊,但我們的訊號就是很不穩定,經常會自動斷開連線。在家的時候,我們的 WiFi 路由器的訊號能夠穿過層層牆壁和地板。事實上,它所覆蓋的區域比我們居住的 600 平方英尺(大約 55 平方米)的公寓還要大。

在這種情況下,一個優秀的技術人員會怎麼做呢?既然想知道為什麼,當然是開始排查咯!

幸運的是,我們在搬家之前並沒有變賣掉樹莓派 Zero W。它是如此小巧便攜! 我當然就把它一起帶來了。我有一個機智的想法:通過樹莓派和它內建的 WiFi 介面卡,使用 Go 語言編寫一個小程式來測量並顯示從路由器收到的 WiFi 訊號。我打算先簡單快速地把它實現出來,以後再去考慮優化。真是麻煩!我現在只想知道這個 WiFi 是怎麼回事!

谷歌搜尋了一番後,我發現了一個比較有用的 Go 軟體包 ​ ​mdlayher/wifi​ ​,它專門用於 WiFi 相關操作,聽起來很有希望!

獲取 WiFi 介面的資訊

我的計劃是查詢 WiFi 介面的統計資料並返回訊號強度,所以我需要先找到裝置上的介面。幸運的是,​ ​mdlayher/wifi​ ​​ 包有一個查詢它們的方法,所以我可以建立一個 ​ ​main.go​ ​ 來實現它,具體程式碼如下:

package main

import (
    "fmt"
    "github.com/mdlayher/wifi"
)

func main() {
    c, err := wifi.New()
    defer c.Close()

    if err != nil {
        panic(err)
    }

    interfaces, err := c.Interfaces()

    for _, x := range interfaces {
        fmt.Printf("%+v\n", x)
    }
}

讓我們來看看上面的程式碼都做了什麼吧!首先是匯入依賴包,匯入後,我就可以使用 ​ ​mdlayher/wifi​ ​​ 模組就在 ​ ​main​ ​​ 函式中建立一個新的客戶端(型別為 ​ ​*Client​ ​​)。接下來,只需要呼叫這個新的客戶端(變數名為 ​ ​c​ ​​)的 ​ ​c.Interfaces()​ ​ 方法就可以獲得系統中的介面列表。接著,我就可以遍歷包含介面指標的切片(變長陣列),然後打印出它們的具體資訊。

注意到 ​ ​%+v​ ​​ 中有一個 ​ ​+​ ​​ 了嗎?它意味著程式會詳細輸出 ​ ​*Interface​ ​ 結構體中的屬性名,這將有助於我標識出我看到的東西,而不用去查閱文件。

執行上面的程式碼後,我得到了機器上的 WiFi 介面列表:

&{Index:0 Name: HardwareAddr:5c:5f:67:f3:0a:a7 PHY:0 Device:3 Type:P2P device Frequency:0}
&{Index:3 Name:wlp2s0 HardwareAddr:5c:5f:67:f3:0a:a7 PHY:0 Device:1 Type:station Frequency:2412}

注意,兩行輸出中的 MAC 地址(​ ​HardwareAddr​ ​​)是相同的,這意味著它們是同一個物理硬體。你也可以通過 ​ ​PHY: 0​ ​​ 來確認。查閱 Go 的 ​ ​wifi 模組文件​ ​​,​ ​PHY​ ​ 指的就是介面所屬的物理裝置。

第一個介面沒有名字,型別是 ​ ​TYPE: P2P​ ​​。第二個介面名為 ​ ​wpl2s0​ ​​,型別是 ​ ​TYPE: Station​ ​​。​ ​wifi​ ​​ 模組的文件列出了 ​ ​不同型別的介面​ ​​,以及它們的用途。根據文件,​ ​P2P​ ​​(點對點傳輸) 型別表示“該介面屬於點對點客戶端網路中的一個裝置”。我認為這個介面的用途是 ​ ​WiFi 直連​ ​ ,這是一個允許兩個 WiFi 裝置在沒有中間接入點的情況下直接連線的標準。

​Station​ ​(基站)型別表示“該介面是具有控制接入點controlling access point的客戶端裝置管理的基本服務集basic service set(BSS)的一部分”。這是大眾熟悉的無線裝置標準功能:作為一個客戶端來連線到網路接入點。這是測試 WiFi 質量的重要介面。

利用介面獲取基站資訊

利用該資訊,我可以修改遍歷介面的程式碼來獲取所需資訊:

for _, x := range interfaces {
    if x.Type == wifi.InterfaceTypeStation {
        // c.StationInfo(x) returns a slice of all
        // the staton information about the interface
        info, err := c.StationInfo(x)
        if err != nil {
            fmt.Printf("Station err: %s\n", err)
        }
        for _, x := range info {
            fmt.Printf("%+v\n", x)
        }
    }
}

首先,這段程式檢查了 ​ ​x.Type​ ​​(介面型別)是否為 ​ ​wifi.InterfaceTypeStation​ ​​,它是一個基站介面(也是本練習中唯一涉及到的型別)。不幸的是名字出現了衝突,這個介面“型別”並不是 Golang 中的“型別”。事實上,我在這裡使用了一個叫做 ​ ​interfaceType​ ​ 的 Go 型別來代表介面型別。呼,我花了一分鐘才弄明白!

然後,假設介面的型別正確,我們就可以呼叫 ​ ​c.StationInfo(x)​ ​​ 來檢索基站資訊,​ ​StationInfo()​ ​​ 方法可以獲取到關於這個介面 ​ ​x​ ​ 的資訊。

這將返回一個包含 ​ ​*StationInfo​ ​​ 指標的切片。我不大確定這裡為什麼要用切片,或許是因為介面可能返回多個 ​ ​StationInfo​ ​​?不管怎麼樣,我都可以遍歷這個切片,然後使用之前提到的 ​ ​+%v​ ​​ 技巧格式化打印出 ​ ​StationInfo​ ​ 結構的屬性名和屬性值。

執行上面的程式後,我得到了下面的輸出:

&{HardwareAddr:70:5a:9e:71:2e:d4 Connected:17m10s Inactive:1.579s ReceivedBytes:2458563 TransmittedBytes:1295562 ReceivedPackets:6355 TransmittedPackets:6135 ReceiveBitrate:2000000 TransmitBitrate:43300000 Signal:-79 TransmitRetries:2306 TransmitFailed:4 BeaconLoss:2}

我感興趣的是 ​ ​Signal​ ​​(訊號)部分,可能還有 ​ ​TransmitFailed​ ​​(傳輸失敗)和 ​ ​BeaconLoss​ ​(信標丟失)部分。訊號強度是以 dBm(分貝-毫瓦decibel-milliwatts)為單位來報告的。

簡短科普:如何讀懂 WiFi dBm

根據 ​ ​MetaGeek​ ​ 的說法:

  • -30 最佳,但它既不現實也沒有必要
  • -67 非常好,它適用於需要可靠資料包傳輸的應用,例如流媒體
  • -70 還不錯,它是實現可靠資料包傳輸的底線,適用於電子郵件和網頁瀏覽
  • -80 很差,只是基本連線,資料包傳輸不可靠
  • -90 不可用,接近“背景噪聲noise floor”

注意:dBm 是對數尺度,-60 比 -30 要低 1000 倍。

使它成為一個真的“掃描器”

所以,看著上面輸出顯示的我的訊號:-79。哇哦,感覺不大好呢。不過單看這個結果並沒有太大幫助,它只能提供某個時間點的參考,只對 WiFi 網路介面卡在特定物理空間的某一瞬間有效。一個連續的讀數會更有用,藉助於它,我們觀察到訊號隨著樹莓派的移動而變化。我可以再次修改 ​ ​main​ ​ 函式來實現這一點。

var i *wifi.Interface

for _, x := range interfaces {
    if x.Type == wifi.InterfaceTypeStation {
        // Loop through the interfaces, and assign the station
        // to var x
        // We could hardcode the station by name, or index,
        // or hardwareaddr, but this is more portable, if less efficient
        i = x
        break
    }
}

for {
    // c.StationInfo(x) returns a slice of all
    // the staton information about the interface
    info, err := c.StationInfo(i)
    if err != nil {
        fmt.Printf("Station err: %s\n", err)
    }

    for _, x := range info {
        fmt.Printf("Signal: %d\n", x.Signal)
    }

    time.Sleep(time.Second)
}


首先,我命名了一個 ​ ​wifi.Interface​ ​​ 型別的變數 ​ ​i​ ​。因為它在迴圈的範圍外,所以我可以用它來儲存介面資訊。迴圈內建立的任何變數在該迴圈的範圍外都是不可訪問的。

然後,我可以把這個迴圈一分為二。第一個遍歷了 ​ ​c.Interfaces()​ ​​ 返回的介面切片,如果元素是一個 ​ ​Station​ ​​ 型別,它就將其儲存在先前建立的變數 ​ ​i​ ​ 中,並跳出迴圈。

第二個迴圈是一個死迴圈,它將不斷地執行,直到我按下 ​ ​Ctrl + C​ ​ 來結束程式。和之前一樣,這個迴圈內部獲取介面資訊、檢索基站資訊,並打印出訊號資訊。然後它會休眠一秒鐘,再次執行,反覆列印訊號資訊,直到我退出為止。

執行上面的程式後,我得到了下面的輸出:

[[email protected] wifi-monitor]$ go run main.go
Signal: -81
Signal: -81
Signal: -79
Signal: -81

哇哦,感覺不妙。

繪製公寓訊號分佈圖

不管怎麼說,知道這些資訊總比不知道要好。讓樹莓派連線上顯示器或者電子墨水屏,並接上電源,我就可以讓它在公寓裡移動,並繪製出訊號死角的位置。

劇透一下:由於房東的接入點在隔壁的公寓裡,對我來說最大的死角是以公寓廚房的冰箱為頂點的一個圓錐體形狀區域......這個冰箱與房東的公寓靠著一堵牆!

我想如果用《龍與地下城》裡的黑話來說,它就是一個“沉默之錐Cone of Silence”。或者至少是一個“糟糕的網路連線之錐Cone of Poor Internet”。

總之,這段程式碼可以直接在樹莓派上執行 ​ ​go build -o wifi_scanner​ ​​ 來編譯,得到的二進位制檔案 ​ ​wifi_scanner​ ​ 可以執行在其他同樣的ARM 裝置上。另外,它也可以在常規系統上用正確的 ARM 裝置庫進行編譯。

祝你掃描愉快!希望你的 WiFi 路由器不在你的冰箱後面!你可以在 ​ ​我的 GitHub 儲存庫​ ​ 中找到這個專案所用的程式碼。