CloudHs Blog


  • 首頁

  • 歸檔

Getting Started with MVVM using RxSwift

發表於 2016-04-06   |  

Preface

前陣子花了一些時間研究了一下 RxSwift 的使用,後來也使用 RxSwift 開發了一個 Video 解碼及顯示的小項目。

然而,因為需要針對公司產品開發一個簡單上手的 Quick Start App,想了想應該可以搬出 RxSwift 來套用,跟之前 Android 版本不同的是,這次的 iOS Quick Start App,我使用了 MVVM 這套設計模式來開發。

何謂 MVVM ?

所謂 MVVM 就是 Model + View + ViewModel 的合稱,是由 MVC 的一種變型,其設計理念就是: View → ViewModel → Model,也就是 View 引用了 ViewModel;ViewModel 引用了 Model。

為了能讓架構儘量單純化,所以 MVVM 架構不允許 View ← ViewModel ← Model 的這種使用方法。說到這邊也許會覺得奇怪,那麼如果 Model / View Model 的資料更改了,又怎麼能夠反應到 View 上呢?

這時候就可以借重這次的主題 RxSwift 來幫忙了。

RxSwift Demo

這次練習 APP 的 View 如下:

  • UITextField:用來輸入目標裝置的 ID。
  • UIButton:按鈕按下後將會記錄 Device ID 的值,並訪問 Web service Restful API。
  • UITableView:當成功新增一筆記錄後,將會自動至 Table 中;如果使用者刪除 Table 的記錄,則會呼叫 Web service Restful API 刪除 Server 上的記錄。

View

View Controller

在 AppDelegate 中必須取得 Remote Notification Token,並把 Token 值放入 Global 的 deviceToken 這個 property 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class AppDelegate: UIResponder, UIApplicationDelegate {

/* Skip dummy codes ... */

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.

let settings: UIUserNotificationSettings = UIUserNotificationSettings(forTypes: [.Alert, .Badge], categories: nil)
application.registerUserNotificationSettings(settings)
application.registerForRemoteNotifications()
application.applicationIconBadgeNumber = 0

return true
}

func application(application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: NSData) {
let characterSet: NSCharacterSet = NSCharacterSet(charactersInString: "<>")
let deviceTokenString: String = (deviceToken.description as NSString)
.stringByTrimmingCharactersInSet(characterSet)
.stringByReplacingOccurrencesOfString(" ", withString: "") as String

print("received device token: \(deviceTokenString)")
Global.sharedInstance.deviceToken = deviceTokenString
}
}

在 Global 這個類別,採用 Singleton 方法實作,其中 deviceToken 這個 property 是個 Observable 物件,所以當它被指定值 (didSet) 時則會被觸發事件 (onNext) 給 Observer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Global {

var deviceToken: String? {
didSet {
if let token = deviceToken {
rx_deviceToken.onNext(token)
}
}
}

var rx_deviceToken = BehaviorSubject<String>(value: "")

class var sharedInstance: Global {
struct Static {
static let instance: Global = Global()
}
return Static.instance
}
}

在 ViewController 中,可以看到在 ViewDidLoad 函式中逐一將 View 中的每個控件和 ViewModel 進行綁定 (Binding),之後如果數據在 ViewModel 中有更動時,透過 RxSwift 便會自動反應在 View 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class ViewController: UIViewController {
/* Skip dummy codes... */

let disposeBag = DisposeBag()
var viewModel: ViewModel!

override func viewDidLoad() {
super.viewDidLoad()

let textFieldValid =
textField.rx_text
.map { $0.characters.count == 20 }
.shareReplay(1)

let deviceTokenValid =
Global.sharedInstance.rx_deviceToken
.map { $0.characters.count == 64 }
.shareReplay(1)

Observable
.combineLatest(textFieldValid, deviceTokenValid) { $0 && $1 }
.shareReplay(1)
.bindTo(bindButton.rx_enabled)
.addDisposableTo(disposeBag)

viewModel = ViewModel(
deviceToken: Global.sharedInstance.rx_deviceToken,
text: textField.rx_text.asObservable(),
buttonTap: bindButton.rx_tap.asObservable(),
tableItemRemoved: tableView.rx_itemDeleted.asObservable())

viewModel.list.asObservable()
.bindTo(tableView.rx_itemsWithCellIdentifier("Cell")) { (_, element, cell) in
cell.textLabel?.text = element
}
.addDisposableTo(disposeBag)
}
}

ViewModel

我們把所有的程式邏輯都寫在 ViewModel 上,可以看到包含 UITextField 的輸入判斷,UIButton 的按鈕處理、Restful API 的使用等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class ViewModel {

private let serverURL = "http://push.iotcplatform.com/tpns"
private let disposeBag = DisposeBag()
let list: Variable<[String]>

init(deviceToken: Observable<String>,
text: Observable<String>,
buttonTap: Observable<Void>,
tableItemRemoved: Observable<NSIndexPath>) {

// load uid from UserDefaults
if let data: [String] = NSUserDefaults.standardUserDefaults().objectForKey("UIDs") as? [String] {
list = Variable<[String]>(data)
} else {
list = Variable<[String]>([])
}

/* Send to server when retrieve device token. */
deviceToken
.filter {
$0.characters.count == 64
}
.flatMapLatest { [unowned self] token in
return self.request(self.serverURL, parameters: ["cmd": "client", "os": "ios", "appid": "com.tutk.cc.samples.tpns.ios", "udid": token, "token": token])
}
.observeOn(MainScheduler.instance)
.subscribeNext {
print($0)
}
.addDisposableTo(disposeBag)

/* Check if the device id exists and add to the list when button tapped. */
buttonTap
.flatMapLatest {
text.take(1)
}
.filter { [unowned self] s -> Bool in
return !self.list.value.contains(s)
}
.subscribeNext { [unowned self] uid in
self.list.value.append(uid)
}
.addDisposableTo(disposeBag)

/* Remove the id from list */
tableItemRemoved
.map { return $0.row }
.subscribeNext { [unowned self] idx in
self.list.value.removeAtIndex(idx)
}
.addDisposableTo(disposeBag)

/* Whenever list modified, encode the ids and send to server. */
list.asObservable()
.flatMap { [unowned self] thiz in
return Observable.combineLatest(deviceToken, self.mapsyncString(thiz)) { ($0, $1) }
}
.flatMapLatest { [unowned self] (token, mapsync) in
return self.request(self.serverURL, parameters: ["cmd": "mapsync", "appid": "com.tutk.cc.samples.tpns.ios", "udid": token, "os": "ios", "map": mapsync.base64String()])
}
.observeOn(MainScheduler.instance)
.subscribeNext {
NSUserDefaults.standardUserDefaults().setObject(self.list.value, forKey: "UIDs")
NSUserDefaults.standardUserDefaults().synchronize()
print($0)
}
.addDisposableTo(disposeBag)
}

func request(url: String, parameters: [String: String]?) -> Observable<String> {
return RxAlamofire.request(.GET, url, parameters: parameters)
.flatMapLatest {
$0
.validate(statusCode: 200 ..< 300)
.rx_string()
}
}

func mapsyncString(list: [String]) -> Observable<String> {
let array: NSMutableArray = []
for uid in list {
array.addObject(NSMutableDictionary(object: uid, forKey: "uid"))
}

let json = JSON(array)

if let s = json.rawString() {
return Observable<String>.just(s)
} else {
return Observable<String>.just("[]")
}
}
}

結論

透過 RxSwift 我們可以很輕易地把數據綁定和程式邏輯分開處理,藉由這個優勢,可以降低程式的耦合程度,大幅提高程式可維護性。

完整程式已經放在 https://github.com/cloudhsiao/ios-tpns-quickstart,有興趣的朋友可以一同研究切磋。

Go internet with usb0 on BeagleBone Black

發表於 2016-02-25   |  

前情提要

之前在北科大使用 BeagleBone Black (BBB) 講課時遇到一個狀況,學校的電腦教室雖說每一台都配發有獨立的 Public IP(真的是很牛的學校,在如今 Public IP 短缺的時代,連電腦教室裡的每台電腦都可以有自己的 Public IP),但這些 IP 仍被學校網管單位限定以 MAC address 所綁定,因此學生們使用的 BBB 無法藉由這些 Public IP 上到 Internet。

後來觀察到電腦教室內的電腦竟然安裝有二張網卡,於是我們想出了一種讓可上 Internet 的網卡開啟網路分享功能讓另一個網卡可以上網,再將 BBB 接至該網卡上完成設定的工作。

但還是需要請系上或學生們自帶一條網路線,著實不便。後來想想既然我們可以使用 USB 線透過 ssh 連線到 BBB 上,那也許 BBB 也可以透過 USB 線上網。

基於上述的想法,我們開始進行一些嘗試…

設定網路分享

成功安裝 BBB 的驅動程式及連接 BBB USB 線之後,電腦上可以成功偵測出一張新的網卡,也因為這張網卡,我們可以透過 ssh 192.168.7.2 的方式遠端連線至 BBB 上。

我們現在需要將原本可以上網的網卡 Local Area Connection,並且設定成讓 BBB 的網卡 Local Area Connection 2 可以透過這張網卡上網。

  • 在 Windows 電腦中,選擇 控制台 >> 網路和網際網路 >> 網路和共用中心 >> 變更介面卡
    變更介面卡
  • 找到原本上網的網路卡 Local Area Connection,並設定成可使用該網卡進行上網。
    開啟網路分享
  • 由於 Windows 會強制將被分享上網的網卡變更為一個特定的固定 IP,所以原本 BBB 所屬的網路卡 Local Area Connection 2 IP 會被修改,為了之後可以連線至 BBB,所以我們將 BBB 所屬網卡再手動改回至 DHCP 取得 IP。
    變更 IP 設定

完成這個步驟後,基本上 Local Area Connection 2 便可以透過 Local Area Connection 上外網了。

設定 Routing Table

接著回到 BBB 上,既然在 Windows 上已經完成網路分享的設定,那麼在 BBB 上應該就可以透過 USB 介面上網才是,現在唯一要做的就是更改 Routing Table,並將 Default Gateway 設定在 usb0 這個介面上。

1
root@beaglebone:~# route add default gw 192.168.7.1

完成之後,我們印出 routing table 再確認一次。

1
2
3
4
5
root@beaglebone:~# route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 192.168.7.1 0.0.0.0 UG 0 0 0 usb0
192.168.7.0 * 255.255.255.0 U 0 0 0 usb0

接下來我們嘗試對外 ping 看看。

1
2
3
4
5
6
root@beaglebone:~# ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_req=1 ttl=64 time=24.247 ms
64 bytes from 8.8.8.8: icmp_req=2 ttl=64 time=25.192 ms
64 bytes from 8.8.8.8: icmp_req=3 ttl=64 time=24.142 ms
64 bytes from 8.8.8.8: icmp_req=4 ttl=64 time=23.232 ms

設定 name server

最後我們仍必須加入 dns server 設定,否則將無法解析 domain name。

1
root@beaglebone:~# echo 'nameserver 8.8.8.8' > /etc/resolv.conf
1
2
3
4
5
6
root@beaglebone:~# ping google.com
PING google.com (173.194.72.101): 56 data bytes
64 bytes from 173.194.72.101: icmp_seq=0 ttl=48 time=27.214 ms
64 bytes from 173.194.72.101: icmp_seq=1 ttl=48 time=26.654 ms
64 bytes from 173.194.72.101: icmp_seq=2 ttl=48 time=27.766 ms
64 bytes from 173.194.72.101: icmp_seq=3 ttl=48 time=26.758 ms

IoT and NAT

發表於 2016-02-21   |  

萬物聯網的困難

我們很習慣地透過網址瀏覽常見的新聞網站,或是從影音平台上觀看最新流行的音樂錄影帶。這中間一連串的過程不外是透過 DNS 伺服器將網址轉換成 IP 地址後再連線至該伺服器。也就是說只要知道一台提供服務的公用伺服器 IP 地址,我們便可以連線至該伺服器取得服務。

隨著網際網路逐漸盛行,全世界連上網路的裝置愈來愈多,伴隨著 IPv4 地址日漸短少,於是在 1990 年代中期,NAT (Network Address Traversal,網路地址轉換)作為解決 IPv4 短缺而保留 IP 地址所產出的解決方案。這種技術是一種在 IP 封包通過路由器或防火牆時重寫來源 IP 地址或目的 IP 地址,普遍用在擁有多台主機,但卻只通過一個公有 IP 地址訪問網路網路的私有網路,該技術大幅降低了對 IPv4 地址的需求,但也產生了另一個問題:NAT 穿透問題。

NAT 穿透問題也就是位於 NAT 內部的私有網路得以透過 NAT 技術訪問外部公有 IP 地址,但外部網路卻無法輕易訪問位於 NAT 內部私有網路的裝置,換句話說,若是手上的任一裝置在 NAT 的另一端時,必須克服 NAT 穿透的問題,方得能連線至該裝置,也就是說要萬物聯網,首先也必須克服 NAT 穿透問題。

NAT 的種類

我們知道 NAT 技術作用在路由器上,用作私、公有地址轉換以及通訊埠(Port)的映射(Mapping),NAT 技術可以分為幾種類型:

  • 完全圓錐型(Full cone NAT)
  • 受限圓錐型(Address-restricted cone NAT)
  • 通訊埠受限圓錐型(Port-restricted cone NAT)
  • 對稱型(Symmetric NAT)

完全圓錐型(Full cone NAT)

一個內部 IP 地址(iAddr:Port1)透過 NAT 映射到外部地址(eAddr:Port2)並綁定,所有發自 iAddr:Port1 的封包都經由 eAddr:Port2 向外發送。任意外部主機都能通過 eAddr:Port2 發送封包至 iAddr:Port1,也就是說 NAT 做單純地做了位址轉換,並未對進出的封包做出限制。

以下圖為例,一個 Client 的 IP 地址 (192.168.0.100: 12345 ),透過 NAT 映射到外部地址(203.66.100.210:23456)並綁定,在 Client 中透過 Port 12345 向外發送的封包都藉由 NAT 的 Port 23456 往外發送。任何外部主機如 Server 2可透過 IP 地址(203.66.100.210:23456)發送封包至 Client(192.168.0.100:12345)。

完全圓錐型 NAT

受限圓錐型(Address-restricted cone NAT)

一個內部 IP 地址(iAddr:Port1)透過 NAT 映射到外部地址(eAddr:Port2)並綁定,所有發自 iAddr:Port1 的封包都經由 eAddr:Port2 向外發送,曾經收到來自 eAddr2:Port2 發送封包的任一主機都能通過 eAddr:Port2 發送封包至 iAddr:Port1。換言之,NAT 會記住目的端的 IP 地址,從其他地址進來的封包會被 NAT 阻擋。

以下圖為例,一個 Client 的 IP 地址(192.168.0.100:12345),透過 NAT 映射到外部地址(203.66.100.210:23456),在Client 中透過 Port 12345 向外發送的封包都藉由 NAT 的 Port 23456 往外發送,曾經收到來自 203.66.100.210:23456 發送封包的主機如 Server 1 都能通過 203.66.100.210:23456 發送封包至 Client(192.168.0.100:12345);由於 203.66.100.210:23456 並沒有發送封包給 Server 2,因此 Server 2 無法透過 203.66.100.210:23456 發送封包給 Client。

受限圓錐型 NAT

通訊埠受限圓錐型(Port-restricted cone NAT)

類似於受限圓錐型 NAT(Address-restricted cone NAT),但多了通訊埠的限制,也就是說 NAT 會記住目的端的 IP 地址及通訊埠,只有該目的端的 IP 地址及通訊埠才能傳送封包給發送端。

以下圖為例,Client 透過 NAT 將封包發送 Server 1 的 Port 10000,在 Port-restricted cone NAT 的環境下,只有 Server 1 的 Port 10000 能夠透過 203.66.100.210:23456 發送封包給 Client(192.168.0.100:12345)。

通訊埠受限圓錐型 NAT

對稱型(Symmetric NAT)

與前三項 NAT 類型皆不同,對稱型 NAT 在同一內部地址與通訊埠到一個特定目的地址與通訊埠,都會在 NAT 映射一個獨特的外部地址及通訊埠,在 NAT 內一個內部地址(IP_ADDR:PORT)如發往不同目的地,都會對應到不同的外部地址(IP_ADDR:PORT)。也就是說,只有曾收到過內部主機封包的外部主機,才能發回封包。

以下圖為例,Client 向 Server 1發送封包,會在 Symmetric NAT 中映射一個地址 203.66.100.210:23456,Server 1 可透過 203.66.100.210:23456 傳送封包至 Client 的內部地址 192.168.0.100:12345。另 Client 向 Server 3 的 Port 34567傳送封包,因為 Symmetric NAT 的特性,會在 Symmetric NAT 中映射一個新的地址 203.66.100.210:24567,Server 3 可利用 Port 34567 透過 203.66.100.210:23456 向 Client 192.168.0.100:13456 發送封包,而 Server 3 無法利用另一個通訊埠(如 Port 39876)向 Client 發送封包。最後,Server 2 因為從未接收到 Client 傳來的封包,因此無法發送封包給Client。

對稱型 NAT

連線至另一端

一個透過 NAT 連線上網的裝置,會因為 NAT 加上的限制,而無法再與另一端位於 NAT 內部的裝置建立連線,於是 NAT 穿透(NAT Traversal)便成為萬物聯網要克服的重要課題之一。發展至今,穿透 NAT 的技術方法主要仍基於幾個概念:

  • 位於 NAT 內部的裝置,向一個位於公有 IP 地址的伺服器發出請求,請其告知裝置本身的公有 IP 地址及通訊埠。
  • 位於 NAT 內部的裝置,向一個位於公有 IP 地址的伺服器發出請求,請其協助與欲連線的對端裝置建立連線,或請協助傳遞封包。

目前基於上述概念已發展出幾種常用的幾種 NAT 穿透方法:

  • UPnP (Universal Plug and Play)
  • STUN (Simple Traversal of UDP Through Network Address Translators)
  • TURN (Traversal Using Relay NAT)
  • ALG (Application Layer Gateway)
  • ICE (Interactive Connectivity Establishment)

參考資料:https://zh.wikipedia.org/wiki/网络地址转换

Learn RxSwift

發表於 2016-02-01   |  

Rx 的世界基本上是由下述二種物件所組成。

  • Observable (Sequence): 發出事件的對象
  • Observer: 收到事件要處理的對象

而這些物件再透過 transform, filter, combine 共同組合強大的功能。

Rx 介紹

Observable

常用到的 Observable 建立:

  • empty()
  • just()
  • from()
  • create()
1
2
3
4
5
let s = Observable<Int>.create { observer in
observer.onNext(3)
return AnonymousDisposable {
}
}

Subject

是 Observable 也是 Observer,常用的有下述:

  • PublishSubject: 訂閱後進行發送
  • ReplaySubject: 訂閱後會補訂閱前發送的事件,有 size 限制
  • BehaviorSubject: 給定一個預設值,訂閱後會補最後一個事件,如果沒有最後的事件則發送預設值

Transformer

轉換,將一個 Sequence 轉成另一個 Sequence

  • map:將傳入的參數做 一次轉換, input 和 output 的資料型別不同
1
2
3
4
5
6
7
Observable.of(1, 2, 3)
.map { int in // 代入 int
"test: \(int)" // 回傳 String
}
.subscribeNext {
print( ($0))
}
  • flatMap:將傳入的參數轉成 Observable 再傳出去,假設有一個二階的資料,先傳入第一階的資料,然後在 flatMap 中把第二階的資料作為 Observable 回傳,如此一來就會把二階的資料 拍平 成一階

Filter

  • distinctUntilChanged: 忽略與上次的觸發事件相同的值,如果這個事件是比較花費資源的計算建議要加入該 filter
1
2
3
4
5
6
7
8
let inputValid = textField.rx_text
.map { s in
s.characters.count > 5
}
.distinctUntilChanged()
.subscribeNext { b in
print(b)// 只有值改變時才會執行這裡
}

Combination

  • combineLatest:同時監看多個 sequence, 當其中一個 sequence 觸發時將這些 sequence 合併送出
1
2
3
4
5
6
let s = Observable<String>.combineLatest(textField1.rx_text, textField2.rx_text) {
s1, s2 in
return "value1: \(s1), value2: \(s2)"
}
.bindTo(label1.rx_text)
.addDisposableTo(disposeBag)

UI interactive

一個 UI control 要綁定一個 observable 時:

1
observable.bindTo(ctrl.rx_xxx)

當 model 資料變更時要觸發 View 更新:

1
observable.onNext(xxx)

UIButton 按下時,讀入 UITextField 內的值進行處理

簡單版:

1
2
3
4
5
6
7
8
9
10
11
12
let allText = Observable.combineLatest(textField1.rx_text, textField2.rx_text) {
text1, text2 in
return("all text:\(text1) + \(text2)")
}
.addDisposableTo(disposeBag)

runButton.rx_tap
.flatMapLatest {
return allText
}
.bindTo(testLabel.rx_text)
.addDisposableTo(disposeBag)

MVVM 版:

ViewModel:

1
2
3
4
5
6
7
8
9
10
11
12
class ViewModel {
var disposeBag = DisposeBag()
init(text: Observable<String>, buttonTaps: Observable<Void>) {
buttonTaps
.flatMapLatest { // 回傳 UITextField 的 rx_text 進行處理
return text.take(1)
}
.subscribeNext { text in
// do something
}
.addDisposeTo(disposeBag)
}

ViewController:

1
2
3
4
5
6
7
8
9
class ViewController {
var textField: UITextField!
var runButton: UIButton!
override func viewDidLoad() {
super.viewDidLoad()

let viewModel = ViewModel(textField.rx_text, runButton.rx_tap)
}
}

一個服務在背景運作,並持續透過一個 callback (closure) 發出命令,可以作一個 Rx 的 Wrapper ,更可以直接綁定在 UI 上

ViewModel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class TestViewModel {
func progress(closure: (String) -> Void) -> Self {
dispatch_async(dispatch_get_global_queue(Int(QOS_CLASS_USER_INTERACTIVE.rawValue), 0)) {

for i in 0..<100 {
closure("the value: \(i)")
sleep(1)
}

}
return self
}
}

extension TestViewModel {
func rx_progress() -> Observable<String> {
return Observable.create { observer in
self.progress() { s in
dispatch_async(dispatch_get_main_queue()) {
observer.onNext(s)
}
}
return AnonymousDisposable {
}
}
}
}

ViewController:

1
2
3
4
5
var test = TestViewModel()
test.rx_progress()
.observeOn(MainScheduler.instance)
.bindTo(label1.rx_text)
.addDisposableTo(disposeBag)

綁定一個文字方塊,為了避免用戶輸入時一直拿到值進行運行,可以透過 debounce(throttle) 來處理

1
2
3
4
5
textField.rx_text
.debounce(0.3, scheduler: MainScheduler.instance) // 延遲 0.3s 後才進行事件處理
.subscribeNext{ s in
print(s)
}.addDisposableTo(disposeBag)

Getting Started with Kalay Starter Kit

發表於 2015-09-04   |  

最近在玩 WRTnode (http://wrtnode.cc),為此也做了幾個小應用程式跟這塊可愛的小板子進行互動,不過之間涉及到網路的聯通動作都還只能在內網下操作,雖說在外網的操作可以藉由像是 MQTT 來達成,不過資料總還是得透過一個中繼伺服器來進行傳輸,總覺得不怎麼可靠呢!

不過在市面上找到一個 P2P 的連線解決方案 Kalay,就讓我們來試試骨子裡賣什麼藥吧!

Requirements

  • WRTnode
  • Android 手機 (也可以用模擬器)
  • 一顆熱誠的心

What will we learn

在本次的主題中,將會建立一個 Android App,用以和 WRTnode 互動,在 App 上按下按鈕,將會開啟或關閉 WRTnode 上的 LED 燈。在這個實作上我們將會分為二個部分進行:

  • WRTnode 撰寫 Restful API,讓 Web Client 可以透過 Restful API 控制板子上的 LED。
  • 撰寫 Android App,並使用 WRTnode 的 Restful API。

Let’s start with WRTnode

在拿到 WRTnode 之後發現這塊板子沒有網路孔,看來只能先透過無線網路方式連到它再進行設定了,所幸 WRTnode 官網上的設定方式 還算親民,從設定對外網路到可以透過 SSH 連線進 WRTnode 約半小時內可完成。

WRTnode 也原生支援 python,因此可以透過 python web framework 完成我們的 API。這邊我用的 python web framework 是 Bottle,Bottle 的安裝方式相對容易,可以直接透過 wget 下載。

1
wget --no-check-certificate https://github.com/bottlepy/bottle/raw/master/bottle.py

在此之前,我們先小試一下 Bottle 的功能,下面是 Bottle 官網上的小程式,我們拿來試一下效果。

1
2
3
4
5
6
7
from bottle import route, run, template

@route('/hello/<name>')
def index(name):
return template('<b>Hello {{name}}</b>!', name=name)

run(host='0.0.0.0', port=8080)

執行上面的 python 程式碼後,WRTnode 將會運行一個 Listen 8080 port 的 web server,這時我們可以透過瀏覽器看一下執行結果。
hello world with Bottle

那麼,要怎麼使用 python 控制 LED 燈呢?下面是個簡單的範例,執行這個程式後,WRTnode 上的 LED 燈將會 blink blink 的閃個不停喔!

1
2
3
4
5
6
7
import os, time

while True:
os.system('echo 0 > /sys/class/leds/wrtnode:blue:indicator/brightness')
time.sleep(1)
os.system('echo 1 > /sys/class/leds/wrtnode:blue:indicator/brightness')
time.sleep(1)

太好了!如果把上面二個程式結合在一起,不就可以透過 web browser 控制 LED 了嗎?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from bottle import route, run
import os

@route('/led/<switch>')
def led_switch(switch):
if switch == "on":
os.system('echo 0 > /sys/class/leds/wrtnode:blue:indicator/brightness')
return "LED ON"
elif switch == "off":
os.system('echo 1 > /sys/class/leds/wrtnode:blue:indicator/brightness')
return "LED OFF"
else:
return "Not support"

run(host='0.0.0.0', port=8080)

執行上面的程式後,便可以透過 web browser 操作 WRTnode 上的 LED 燈了。

  • 開啟 LED 燈:http://%IP%:8080/led/on
  • 關閉 LED 燈:http://%IP%:8080/led/off

讓我先告一段落在 WRTnode 上的實作,接下來我們要撰寫 Android App 控制 WRTnode。

當然,在開發 Android 應用程式之前,要先將開發工具打點好,這邊我選用的是 Android Studio 1.3.2 (http://developer.android.com/sdk),Android Studio 的安裝及設定在此就不介紹了,需要這方面資訊的朋友可以前往 Google 大神詢問。

在安裝好 Android Studio 之後,我們先來建立個應用程式吧,然後我們在 Activity 上加入二個按鈕,分別用作開啟及關閉 LED 燈。

以下是我自己應用程式的 layout。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">


<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="LED On"
android:id="@+id/btnOn" />


<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toRightOf="@id/btnOn"
android:text="LED Off"
android:id="@+id/btnOff"/>


</RelativeLayout>

接下來為了能讓應用程式可以連上網路,因此必須給予 Internet accessible 的 permission。

1
<uses-permission android:name="android.permission.INTERNET" />

在這個應用程式中,我使用了 Volley 來存取 HTTP,所以必須在 Gradle 中加入。

1
compile 'com.mcxiaoke.volley:library:1.0.18'

最後我們在主程式 (MainActivity.java) 中撰寫這二個按鈕的程式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
static final String TAG = "LED_SWITCH";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

Button btnOn = (Button)findViewById(R.id.btnOn);
Button btnOff= (Button)findViewById(R.id.btnOff);

// We use Volley to send http command to the device.
final RequestQueue queue = Volley.newRequestQueue(this);

btnOn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// Send http request to the WRTnode.
StringRequest request = new StringRequest(Request.Method.GET, "http://192.168.2.108:8080/led/on",
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
Log.d(TAG, response);
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
Log.d(TAG, "That didn't work!");
}
}
);

queue.add(request);
}
});

btnOff.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// Send http request to the WRTnode.
StringRequest request = new StringRequest(Request.Method.GET, "http://192.168.2.108:8080/led/off",
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
Log.d(TAG, response);
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
Log.d(TAG, "That didn't work!");
}
}
);

queue.add(request);
}
});
}

好了,在程式順利編譯執行後,按下「On」、「Off」按鈕,可以在 logcat 中看到 WRTnode 的回覆。

Use Kalay SDK

截至目前為止,我們都可以在內網 (LAN) 中存取 WRTnode 的 HTTP 服務,但如果我們要在外網的環境下存取 WRTnode 的話,勢必得設定 Router 進行 port-mapping 或是 port-forwarding 了。所幸我們今天要介紹的 Kalay 提供了另一種簡決方式,透過簡易的 API 使用,便可以 P2P tunneling 的方法存取遠端的裝置。

在 Kalay Starter Kit 中提供了給 WRTnode 使用的 C library,我們可以使用 WRTnode 的交叉編譯器 (Cross compiler) 產出 Kalay P2PTunnel 執行檔 (Daemon) 放在 WRTnode 上執行。

在 WRTnode 的 wiki 官網上可以下載預先編譯好的 tool-chain,我這邊使用 Ubuntu 14.04LTS 下載後並解壓縮。

在佈置好 WRTnode 的 tool-chain 後,我們先以 Kit 中的 Sample code 試著編譯看看。

1
2
3
4
$ export CC=%WRTNODE_TOOL_CHAIN_FOLDER%/bin/mipsel-openwrt-linux-uclibc-gcc
$ export PLATFORM=%KALAY_KIT_FOLDER%/Lib/Linux/Mips_MT7620AOpenwrt_4.8.3
$ cd %KALAY_KIT_FOLDER%/Sample/Linux/Sample_P2PTunnel
$ "$CC" P2PTunnelServer.c -I../../../Lib/ -L"$PLATFORM" -lP2PTunnelAPIs -lRDTAPIs -lIOTCAPIs -lpthread -ldl -o P2PTunnelServer

編譯成功後,我們將 P2PTunnelServer 以及 Kalay SDK 的 Library 檔案丟到 WRTnode 上執行。

1
LD_LIBRARY_PATH=%KALAY_KIT_LIB_FOLDER% ./P2PTunnelServer %UID%

這時已經完成 WRTnode 的整合,接下來進行的是 Android App 上的整合。

從 Kalay Starter Kit 中發現提供給 Android 使用的是 Native Library,在 Android Studio 中使用 Native Library 則必須將這些檔案丟到特定的目錄中。

app/src/main/jniLibs/armeabi/xxx.so

app/src/main/jniLibs/x86/xxx.so

在完成 Library 及 wrapper class 的佈署後,我們在畫面上新增一個文件方塊及按鈕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">


<EditText
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:hint="Please input UID"
android:maxLength="20"
android:inputType="textCapCharacters"
android:id="@+id/edtUID" />


<Button
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_below="@id/edtUID"
android:text="CONNECT"
android:id="@+id/btnConnect"/>

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/btnConnect"
android:text="LED On"
android:id="@+id/btnOn" />


<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toRightOf="@id/btnOn"
android:layout_alignTop="@id/btnOn"
android:text="LED Off"
android:id="@+id/btnOff"/>


</RelativeLayout>

在這個新的應用程式,我們必須先輸入 Kalay UID,然後按下 CONNECT 按鈕,待 App 與 WRTnode 成功後,才得使用 LED ON 及 LED OFF 這二個按鈕進行控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
public class MainActivity extends ActionBarActivity implements P2PTunnelAPIs.IP2PTunnelCallback {

static final String TAG = "LED_SWITCH";
static final String USER = "Tutk.com"; // refer to P2PTunnelServer.c
static final String PWD = "P2P Platform"; // refer to P2PTunnelServer.c

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

final EditText edtUID = (EditText)findViewById(R.id.edtUID);
final Button btnOn = (Button)findViewById(R.id.btnOn);
final Button btnOff= (Button)findViewById(R.id.btnOff);
final Button btnConnect = (Button)findViewById(R.id.btnConnect);

// Disable these LED switch buttons
btnOn.setEnabled(false);
btnOff.setEnabled(false);

// Initiate the P2PTunnelAPI
final P2PTunnelAPIs api = new P2PTunnelAPIs(this);
api.P2PTunnelAgentInitialize(4);

btnConnect.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (edtUID.length() == 20) {

// Disable the connect button
refreshConnectButtonStatus(false);

// Start a new thread to connect to device
new Thread(new Runnable() {
@Override
public void run() {
String UID = edtUID.getText().toString();
int ret = 0;
int[] errFromDevice = new int[1];

ret = api.P2PTunnelAgent_Connect(UID, getAuthData(USER, PWD), getAuthDataLength(), errFromDevice);
if (ret >= 0) {
int idx = api.P2PTunnelAgent_PortMapping(ret, 8080, 8080);
if (idx >= 0) {
Log.d(TAG, "Session established");
// Enable LED buttons
refreshLEDButtonStatus(true);
} else {
Log.d(TAG, "Failed to mapping ports");
// Enable the connection button
refreshConnectButtonStatus(true);
}
} else if (ret == P2PTunnelAPIs.TUNNEL_ER_AUTH_FAILED) {
if (errFromDevice[0] == -888) {
// The error code -888 is from P2PTunnelServer.c
Log.d(TAG, "The auth data is wrong.");
}
// Enable the connection button
refreshConnectButtonStatus(true);
} else {
// Enable the connection button
refreshConnectButtonStatus(true);
}
}
}).start();
}
}
});

// We use Volley to send http command to the device.
final RequestQueue queue = Volley.newRequestQueue(this);

btnOn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// Send http request to the WRTnode.
StringRequest request = new StringRequest(Request.Method.GET, "http://127.0.0.1:8080/led/on",
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
Log.d(TAG, response);
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
Log.d(TAG, "That didn't work!");
}
}
);

queue.add(request);
}
});

btnOff.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// Send http request to the WRTnode.
StringRequest request = new StringRequest(Request.Method.GET, "http://127.0.0.1:8080/led/off",
new Response.Listener<String>() {
@Override
public void onResponse(String response) {
Log.d(TAG, response);
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
Log.d(TAG, "That didn't work!");
}
}
);

queue.add(request);
}
});
}

private int getAuthDataLength() {
return 128;
}

private byte[] getAuthData(String username, String password) {
/* The authdata structure between device and client:
typedef struct st_AuthData
{
char szUsername[64];
char szPassword[64];
} sAuthData;
*/


byte[] result = new byte[128];
byte[] acc = username.getBytes(StandardCharsets.US_ASCII);
byte[] pwd = password.getBytes(StandardCharsets.US_ASCII);

// copy acc and pwd to result
System.arraycopy(acc, 0, result, 0, acc.length);
System.arraycopy(pwd, 0, result, 64, pwd.length);

return result;
}

private void refreshConnectButtonStatus(final boolean status) {
this.runOnUiThread(new Runnable() {
@Override
public void run() {
final Button btnConnect = (Button)findViewById(R.id.btnConnect);
btnConnect.setEnabled(status);
}
});
}

private void refreshLEDButtonStatus(final boolean status) {
this.runOnUiThread(new Runnable() {
@Override
public void run() {
final Button btnOn = (Button)findViewById(R.id.btnOn);
final Button btnOff= (Button)findViewById(R.id.btnOff);
btnOn.setEnabled(status);
btnOff.setEnabled(status);
}
});
}

@Override
public void onTunnelStatusChanged(int nErrCode, int nSID) {
if (nErrCode == P2PTunnelAPIs.TUNNEL_ER_DISCONNECTED) {
refreshConnectButtonStatus(true);
refreshLEDButtonStatus(false);
}
}

@Override
public void onTunnelSessionInfoChanged(sP2PTunnelSessionInfo object) {

}
}

讓我們編譯進執行,然後輸入 WRTnode 的 UID,按下 CONNECT 按鈕,成功建立連線後,就可以操作 LED 燈號了!

App Layout

透過上述的實作,我們明白即便在外網,透過 Kalay 也能夠簡易地連線到遠端的裝置。

上述 Android 程式碼可於 Github 上下載 (https://github.com/cloudhsiao/led_switch)。


本文章以 CC 方式授權。

Play audio with Raspberry Pi

發表於 2015-06-09   |  

最近想在 Raspberry Pi 上播放音樂,於是在網路上搜尋了一下,發現可以透過 Apple AirPlay 技術讓 Raspberry Pi 成為家庭音樂中心,這邊將這幾天的搜尋成果整理了一下。

讓 Raspberry Pi 發出聲音

想讓 Raspberry Pi 發出聲音一般方法有:

  • 透過 HDMI interface。
  • 透過 3.5mm audio jack。
    所以可以先選定想要輸出聲音的硬體界面,我這邊是選擇將耳機插入 3.5mm 的耳機孔。

在 Raspberry Pi 開機之後,我們第一次事先確認 Linux Kernel 是否已載入 Sound Module。

1
lsmod | grep snd_bcm2835

如果沒有載入音效模組的話,便手動載入:

1
sudo modprobe snd_bcm2835

成功載入後,我們接下來進行進一步的設定。
首先設定 Raspberry Pi 聲音輸出的界面:

1
amixer cset numid=3 n

上述 “n” 的值代表:

  • 0: 自動,會自動偵測以 HDMI or 3.5mm audio jack 為輸出界面,但 HDMI 的優先權高於 3.5mm audio jack。
  • 1: 強制以 3.5mm audio jack 輸出。
  • 2: 強制以 HDMI 輸出。

另外 Raspberry Pi 也提供一個程式 alsamixer 讓我們透過 GUI 界面讓我們進行一些進階設定。

在進入 GUI 界面後,我們透過方向鍵 ↑↓ 來調整音量大小,按下 ESC 鍵離開。

alt alsamixer

接下來我們試試看有沒有聲音吧。

1
aplay /usr/share/sounds/alsa/Front_Center.wav

這時你應該可以從耳機上聽到一段人聲。

使用 OMXPlayer 播放

一個簡單播放 Mp3 的方式便是透過 OMXPlayer。

1
2
3
sudo apt-get install omxplayer
wget http://goo.gl/MOXGX3 -O example.mp3 --no-check-certificate
omxplayer example.mp3

OMXPlayer 是很強大的播放程式,除了支援 Raspberry Pi SoC 的硬體視訊加速功能外,也可以直接透過命令列直接播放影片。

以下簡單介紹幾種用法。

播放一個影片檔:

1
omxplayer video.mp4

使用 HDMI 播放影片檔:

1
omxplayer -o hdmi video.mp4

AirPlay with Raspberry Pi

AirPlay 是 Apple 的一種播放技術,可以將 Mac & iDevice 上的 Video or Audio 送到支援 AirPlay 的設備上進行播放,換句話說只要買一個支援 AirPlay 的藍芽喇叭,就可以把 iPhone 上的音樂送到藍芽喇叭上播放;買一個 Apple TV,就可以把 iPad 上的日劇透過 AirPlay 送到家中的大螢幕上觀看,真是太方便的功能啊。

如果不想買貴鬆鬆的藍芽喇叭,那麼發揮 Maker 的浪漫,拿家裡的電腦喇叭透過 Raspberry Pi 來實現 AirPlay 也是可以的喔。

首先更新系統,並下載必要的程式。

1
2
3
sudo apt-get update
sudo apt-get upgrade
sudo apt-get install apt-get install libssl-dev libavahi-client-dev libasound2-dev

shairport

shairport 是一個模擬 AirPort Express 並作為接受 AirPlay 串流音樂的程式,雖然不支援 AirPlay v2 (影像及照片串流),但用作播放音樂使用的話,其實是很不錯的。

shairport 的作者已經聲明不再維護該專案了。

安裝 shairport:

1
2
3
4
5
git clone https://github.com/abrasive/shairport.git
cd shairport
./configure
make
./shairport -a AirPi

現在拿出 iPhone 播放音樂,開啟 AirPlay,並選擇 AirPi,好了,現在有音樂了!
alt AirPlay screenshot 1
alt AirPlay screenshot 2


參考資料:

  1. http://trouch.com/2012/08/03/airpi-airplay-audio-with-raspberry/
  2. https://github.com/abrasive/shairport
  3. http://cagewebdev.com/index.php/raspberry-pi-getting-audio-working/
  4. http://atceiling.blogspot.tw/2014/02/raspberry-pi.html#.VXVXJ2CbS7M
  5. http://www.raspberrypi-spy.co.uk/2013/06/raspberry-pi-command-line-audio/

Streaming video with Raspberry Pi (cont.)

發表於 2015-06-04   |  

上一次我們知道如何使用 raspistill 以及 raspivid 來實現影像串流。現在我要來嘗試如何在 Raspberry Pi 上實現 HLS stream。

在正式開始之前,先來看一下 HLS (HTTP Live Streaming) 吧,HLS 是蘋果公司推出的一款基於 HTTP 協定的影音串流協定,其檔案格式在 Video 端是以 H.264 編碼,在 Audio 方面則是以 MP3, HE-AAC 或 AC-3 格式編碼。也因為如此,所以我們必須把從 Camear 取得的影音資料打包成 HLS 格式,所幸 FFmpeg 已經提供了這部分的功能。

Build Library

FFmpeg 是一個自由軟體,可以執行 Audio, Video 多種格式的錄影、轉檔及串流功能,其中分成幾個組件 (ex. ffmpeg, ffplayer, ffserver),這些組件也會使用像 libavcodec (audio, video codec), libavformat (audio, video converter) 等函式庫,FFmpeg 同時也可以使用外部函式庫來加強對於音視頻編碼的支援廣度。我們待會也會一併把 x264, faac, lame 編入 ffmpeg 中。

Build x264 (H264 encoder)

1
2
3
4
5
git clone git://git.videolan.org/x264
cd x264
./configure --disable-asm --enable-shared
make
make install

Build faac (AAC encoder)

1
2
3
4
5
6
wget http://downloads/sourceforge.net/project/faac/faac-src/faac-1.28/faac-1.28.tar.gz
tar xzvf faac-1.28.tar.gz
cd faac-1.28
./configure
make
make install

Build lame (MP3 encoder)

1
2
3
4
5
6
wget http://downloads.sourceforge.net/project/lame/lame/3.99/lame-3.99.tar.gz
tar xzvf lame-3.99.tar.gz
cd lame-3.99
./configure
make
make install

Build FFmpeg

1
2
3
4
5
git clone git://source.ffmpeg.org/ffmpeg.git
cd ffmpeg
./configure --enable-shared --enable-gpl --prefix=/usr/local --enable-nofree --enablelibmp3lame --enable-libfaac --enable-libx264 --enable-version3 --disable-mmx
make
make install

由於我們用到了 x264,因此在編譯 FFMpeg 時必須選用 GPL license。另外一提,在編譯 FFmpeg 其實是很花時間的工作,讓我們喝杯咖啡休息一下吧…

alt coffee

喝完咖啡,又睡了個午覺後,所有的 Library 終於都已經就位了 (/usr/local),這時候趕緊來試一下 ffmpeg 吧…咦?為什麼會發生錯誤?

1
ffmpeg: error while loading shared libraries: libavdevice.so.56: cannot open shared object file: No such file or directory

原來是 ffmpeg 會用到的函式庫 (ex. libavcodec) 所在目錄不在 library search path 裡,因此才會無法執行,這時候可以透過下述二種方式擇一進行:

Solution 1:

1
2
export LD_LIBRARY_PATH=/usr/local/lib
ffmpeg

Solution 2:

1
LD_LIBRARY_PATH=/usr/local/lib ffmpeg

重新再執行一次,耶!成功了!

1
2
3
4
ffmpeg version N-72416-gc7bd6a5 Copyright (c) 2000-2015 the FFmpeg developers
built with gcc 4.6 (Debian 4.6.3-14+rpi1)
configuration: --enable-shared --enable-gpl --prefix=/usr/local --enable-nonfree --enable-libmp3lame --enable-libfaac --enable-libx264 --enable-version3 --disable-mmx
...

Setting up the streaming server

為了能讓外部連線進來,我們先使用 python 啟用一個 http service。

1
python -m SimpleHTTPServer &

接著我們使用 raspvid 來產生我們需要的 video stream data。

1
2
3
rm -rf live*
mkfifo live.h264
raspivid -hf -vf -w 640 -h 480 -fps 25 -g 25 -b 128000 -t -o - > live.h264

我們使用 mkfifo 透過 named pipe 方式讓 FFmpeg 讀取由 raspivid 輸出的 streaming data。

1
2
3
4
5
6
7
8
9
10
11
12
13
LD_LIBRARY_PATH=/usr/local/lib ffmpeg -y \\
-i live.h264 \\
-c:v copy -b:v 128k \\
-c:a libfaac -b:a 128k \\
-map 0:0 \\
-f segment \\
-segment_time 8 \\
-segment_format mpegts \\
-segment_list "live.m3u8" \\
-segment_list_size 720 \\
-segment_list_flags live \\
-segment_list_type m3u8 \\
"live%08d.ts" < /dev/null

由於我手上的 Raspberry Pi 沒有接麥克風,所以我拿掉了 audio 部分。
好了,現在執行 VLC 打開網址 http://{the_ip}:8000/live.m3u8

alt screenshot

在完成 Video streaming server 端的設定後,我們要使用 Android 上的 VideoView 控件來實現 HLS 影像播放。
目前 Google 正在力推 Android Studio 作為 Android 開發的主要編輯器,我們就下載這套編輯器來使用吧。

截至目前最新版本是 v.1.2.1.1,功能已經相當完整,距 2013 年 Google I/O 剛發表 v0.1 版本時,其整合的功能可說是天壤之別。

alt Android Studio welcome page

在成功安裝設定完成後,我們新建一個專案 “RPiPlayer”。

alt create project step1

選擇 SDK 版本,我個人習慣是選擇 Android 4.1 (Jelly Bean)。

alt create project step2

我們選擇一個空白的 Activity。

alt create project step3

保持預設的 Activity name。

alt create project step4

好了,讓我們寫一些代碼吧。
由於需要賦予 APP 有存取網路的權限,我們打開 AndroidManifest.xml 加入:

1
2
3
...
<uses-permission android:name="android.permission.INTERNET" />
...

然後再開啟 activity_main.xml,修改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context=".MainActivity">


<TextView
android:text="URL"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/url"
android:layout_alignParentTop="true"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true" />


<EditText
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:id="@+id/edtUrl"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/url"
android:layout_toLeftOf="@+id/btnGo" />


<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Go"
android:id="@+id/btnGo"
android:layout_alignParentRight="true" />


<VideoView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/videoView"
android:layout_below="@+id/edtUrl"
android:layout_marginTop="20dip"/>


</RelativeLayout>

完成後,從 Android Studio 上可以看到 APP 的 Layout

alt APP screenshot

最後我們來編輯 Activity 程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected void onCreate(Bundle savedInstanceState) {
...
Button btnGo = (Button) findViewById(R.id.btnGo);
btnGo.setOnClickListener(onBtnGoClicked);
}

private View.OnClickListener onBtnGoClicked = new View.OnClickListener(){

@Override
public void onClick(View v) {
EditText edtUrl = (EditText) findViewById(R.id.edtUrl);
VideoView videoView = (VideoView) findViewById(R.id.videoView);

String url = edtUrl.getText().toString();
videoView.setVideoURI(Uri.parse(url));
videoView.setMediaController(new MediaController(MainActivity.this));
videoView.start();
}
};

然後,開啟模擬器或是接上你的手機來試試,在 APP 上的 EditText 內輸入 HLS 的網址,按下 GO 按鈕。
alt APP screenshot

耶!看到影像了!


經過這個系列的文章,我們學會了如何使用 Raspberry Pi 以及 Raspberry Pi Camera module 來獲取圖片、錄影,以及輸出成 RTSP、HLS 等串流影像。
我們也製作了一個簡單的 Android APP 來觀看 HLS 串流影像。

Streaming video with Raspberry Pi

發表於 2015-06-03   |  

這次的實作是在 Raspberry Pi 上使用 Camera module,並使用 raspivid 以及 ffmpeg 實現 Video streaming server,並在手機上看到 Camera 的影像。

Requirements

  • Raspberry Pi Model B Gen.2
  • Raspberry Pi Camera Module
  • 一顆熱情的心

Equipment setup (hardware)

當取得 Raspberry Pi & Camera 後,我們首先將其組裝起來,在組裝 Camera 之前請先消除身上的靜電,以免損壞了 Camera (畢竟一個也不便宜呢!)。
硬體組裝的步驟大致為:

  1. 找到 Camera 的插槽,這個插槽位於 HDMI 以及音效孔之間,輕輕地向上拉開。
  2. 將 Camera 模組的排線插入插槽中,排線尾端的金屬接頭朝向 HDMI 孔的方向。
  3. 將插栓向下推 (基本上無論怎麼推,排線尾端的金屬接頭都會外露)

在組裝完成後,我們來設定軟體的部分吧。

1
2
3
sudo apt-get update
sudo apt-get dist-upgrade
sudo raspi-config

alt raspi-config
請選擇 Enable Camera 進入下一頁後選擇 Enable,然後重新啟動 Raspiberry Pi。

Raspberry Pi Camera 的使用

既然我們都接好 Camera 了,那為何不先玩一下呢?
Raspberry Pi 很善良的地先提供了二組命令讓我們使用 raspistill 以及 raspivid。

raspistill

首先我們來看 raspistill,在 terminal 中直接鍵入 raspistill 不加任何參加後可以直接得到該命令的 help message,從 help message 的說明可以得知是用來在一段時間內產生一個 still file。說白了,就是讓你可以從 Camera 上拿到一張圖啦!

以下示範取得一張 JPEG file

1
raspistill -o image.jpg

-o 代表輸出的資料要存放的地方,如果給定的是一個檔名的話,則會存成該檔案。
以下取得一張 320 x 240 的 JPEG file

1
raspistill -w 320 -h 240 -o image.jpg

取得一張上下顛倒 (vertical flip) 及影像 (mirror) 的 JPEG file (通常是因為你的 Camera module 會躺在桌上,看到的圖都是顛倒的)

1
raspistill -vf -hf -o image.jpg

在一分鐘內,每隔一秒抓一張圖 (*拿到這些圖後,可以做成縮時錄影)

1
raspistill -t 60*1000 -tl 1*1000 -o image%02d.jpg

對縮時錄影有興趣的話可以參考這篇文章

也可以自己寫一隻小程式,透過 pipe 將 stdin 的 data 讀取進來。

1
raspistill -vf -o - | my_program

剛才提到 -o 為資料輸出的地方,如果要輸出至 stdout 可以寫成 -o -。

接著使用 raspistill 輸出 M-JPEG 吧。

1
2
3
sudo apt-get install cmake libjpeg62-dev
git clone https://github.com/jacksonliam/mjpg-streamer.git
cd mjpg-streamer/mjpg-streamer-experimental/

接下來我們要修改一些 Makefile,請使用任一套你能使用的文字編輯器打開 Makefile,然後找到下面這一行,並把前面的 “#” 移除並存檔。

1
2
3
...
# PLUGINS += input_file.so
...

修改完後,我們進行編譯動作make clean all。

前面做了這麼多,也就表示我們將會使用 mjpg-streamer 這個程式來協助我們完成 MJPEG streamer 的動作,在成功安裝好 mjpg-streamer 之後,可以先試一下效果:

1
LD_LIBRARY_PATH=./ ./mjpg_streamer -i "input_raspicam.so" -o "output_http.so"

現在開啟 VLC 並輸入網址 http://{the_ip}:8080/?action=stream,哇!可以看到影像了。

不過這一節談的是raspistill,當然是無論如何也要牽扯一下啦。

1
2
raspistill -bm -n -vf -hf -w 640 -h 480 -q 50 -o frame.jpg -t 0 -tl 200 &
LD_LIBRARY_PATH=./ ./mjpg_streamer -i "input_file.so -f . -n frame.jpg" -o "output_http.so -w ./www"

在 raspistill 中,我們開啟了 -bm 暴力抓圖模式,以及 -t 0 -tl 200 -o frame.jpg 每隔 0.2 秒刷新 frame.jpg 這張圖。
在 mjpg_streamer 中可以透過 input_file 這個 plug-in 將 frame.jpg 讀進來產生動態圖的效果,我們在 -o 參數中加入了 -w 來指定 http folder。

接著在瀏覽器上輸入 http://%IP%:8080/
alt snapshot

raspivid

相同的,help message 告訴我們這組命令會讓我們從 camera module 上得到一段 h.264 格式的 video。

我們馬上來抓一段視頻。

1
raspivid -o video.h264

透過上述的命令,我們可以得到一段預設為五秒的 h.264 video,最後這段影片可以直接透過 VLC 播放。

一種簡單的作法是透過 scp 把 video.h264 拷貝回自己的電腦上播放 scp pi@IP:/YOUR_FOLDER/video.h264 ./。

讓我們來錄一段 1280x720, bitrate = 512000, fps = 25 長度 30 秒的影片:

1
raspivid -w 1280 -h 720 -fps 25 -b 512000 -t 30000 -o video.h264

也可以透過 VLC 產生 RTSP stream:

1
2
raspivid -t 0 -w 640 -h 480 -fps 10 -o - | cvlc -vvv stream:///dev/stdin \\
--sout '#rtp{sdp=rtsp://:8554/}' :demux=h264

透過 raspivid 取得 video data,並輸出至 stdout,然後使用 cvlc (a VLC console program) 來轉換成 RTSP stream,-vvv 指的是印出 verbose 訊息 (-v or -vv or -vvv),stream: 參數表示其串流影像來源,在此例上是 stdin;--sout 參數後面表示將會把影像串流輸出成 RTSP 格式。
這時候你可以透過手機或電腦上的 RTSP player 來連線至 rtsp://[the ip]:8554/ 來播放囉。

Cloud Hsiao

Cloud Hsiao

8 文章
RSS
© 2016 Cloud Hsiao
由 Hexo 強力驅動
主題 - NexT.Muse