上一篇,純粹玩 ESP8266,寫入了 init.lua 能收發 UDP。這次拿 BBB 開刀,用 BBB host 一個 web server ,用於與用戶交互,數據來自 ESP8266 的 UDP 交互結果。本來,ESP8266 能直接用 TCP,但我希望廣播 UDP 來做自動發現,那服務端和設...
上一篇,純粹玩 ESP8266,寫入了 init.lua 能收發 UDP。這次拿 BBB 開刀,用 BBB host 一個 web server ,用於與用戶交互,數據來自 ESP8266 的 UDP 交互結果。本來,ESP8266 能直接用 TCP,但我希望廣播 UDP 來做自動發現,那服務端和設備端統一全部用 UDP 交互吧,服務端再通過 HTTP 與客戶端交互。
以下過程,與 Linux 上面搭 web 沒有區別。我選擇用 node.js,沒有什麼特殊原因,只是因為它本來就跟著 BBB debian distro 一起裝好了的。為求快捷,也搭著 Express 一起用。我要用最高速度完成這個東西來,試試而已,Node + Express 很快能搞定。
安裝
首先,BBB 上面要有 node,確認一下在不在:
node –v
當然在。然後當前看看埠
netstate –tlpn
這裡看到,80,是 systemd 用掉,就是 bone101 那一頁介紹頁面,3000 也是,Cloud9 IDE 的。兩者都可以關掉,關掉對應的服務即可(bonescript.socket 和 bonescript.service)。8080 埠,是 apache2 。那我用 4001 吧。也是沒有原因的。好,繼續。
在某某文件夾裡面創建一個子文件夾 /root/lasapp,然後 npm init,按需輸入一些參數,它會幫我生成 package 檔,然後 npm install express –-save,其後等它安裝就好了。具體方法請參看這裡:http://www.expressjs.com.cn/starter/installing.html
熱身, Hello World 一下,app.js:
var express = require('express');
var app = express();
app.get('/', function (req, res) {
res.send('Hello World!');
});
var server = app.listen(4001, function () {
var host = server.address().address;
var port = server.address().port;
});
然後 node app.js。用電腦打開瀏覽器輸入對應地址 http://192.168.7.2:4001/ 就會看到 Hello World,十行代碼不到,夠快了吧。
我準備做的,整個過程,是由一個網頁上的點擊,觸發伺服器發送 UDP 廣播,然後接上一篇的 ESP8266 UDP 接收。然後ESP8266,或者多個不同的 ESP8266,響應後把它們回傳報上來的身份標識,服務負責處理回傳保存到資料庫,頁面定時刷新從資料庫取值。一個人項目,藍圖在心中。簡單寫一下的話,是分開前端,後臺兩個,後臺分開靜態頁、UDP、和 web service 三個部分。隨便從哪裡開始,那就從前端那裡,頁面吧。
前端頁面
IDE 我用 webstorm,在 windows 寫好 cp 過去 BBB 上,隨便拿個 Bootstrap 模板改就是:
模板來自一個什麼二十分鐘搭好 bootstrap 的博文,其實不需要二十分鐘,Copy & Paste 然後改文字而已。任何模板都能做,甚至是直接手敲 HTML 也不會有問題。關鍵是中間的部分,將會用 JS 從 web service 獲得 JSON (設備列表)把它填上。中間還有一個綠色按鈕“重新搜索設備”,要有 web service 響應處理設備搜索(就是 UDP 廣播)。
頁面樣式大概弄好了就拷過去 BBB 先。把檔案打包成 lasapp.tar (在 windows 用 7z),然後 pscp (putty 自帶的遠程 copy、cp 工具)去 BBB 上。
pscp lasapp.tar [email protected]:/root/lasapp.tar
然後到 BBB 上 在 lasapp文件夾內,創建 public 文件夾:
mkdir public
在 public 文件夾內解壓:
tar –xf ~/lasapp.tar
最後修改剛纔hello world 那個例子的 app.js 加入靜態文件把 public 文件夾放出來,和根目錄 GET 時候傳送 index.html:
var express = require('express');
var app = express();
app.use(express.static('public')); // 配置靜態文件路徑
app.get('/', function (req, res) {
res.sendFile('index.html'); // 之前是 send(‘Hello World!’)
});
var server = app.listen(4001, function () {
var host = '192.168.7.2';
var port = server.address().port;
});
然後運行測試一下,沒問題就下一步,web service。
後臺服務
BBB 空間有限,UDP、網頁伺服器、Web Service 三者都能在 node 實現的話,那就不裝其他,就用 node 。快速做一遍三個分別是怎樣在 node 實現。
測試 Web Service 與發出 UDP
寫個 api.js 先,創建一個 api 文件夾然後在裡面 vim api.js :
exports.udpService = function(port,bc_addr){
var dgram = require('dgram');
var port = port;
var bc_addr = bc_addr;
var queryTxt = '{"cmd":"0"}';
var queryMsg = new Buffer(queryTxt);
var client = dgram.createSocket('udp4');
client.bind(port, function(){
this.setBroadcast(true);
this.setMulticastTTL(128);
});
return {
query : function(req,res){
client.send(queryMsg,0,queryMsg.length,port,bc_addr);
res.sendStatus(200);
}
};
};
node 可以發 datagram(UDP),API 請參看這裡:
https://nodejs.org/docs/latest-v0.10.x/api/dgram.html
代碼 api.js 的 query 方法是接受到請求時候,對廣播地址(bc_addr)的特定埠(port)以 UDP 包方式發出一個字元 {“cmd”:”0”}。註意 setBroadcast 和 setMulticastTTL 兩個方法都必須在 bind 綁定完成後才能操作,所以我放了它在 callback 內。
完成需要告訴客戶端,搞定了沒問題,STATUS 200 OK。
關於廣播
IPv4 中,掩碼 subnet mask,是指定子網的方式。一個 192.168.7.0 作為 network prefix 指定了掩碼 255.255.255.252,等於 2^8 – 252 = 4個地址,這四個 192.168.7.0 至 192.168.7.3 之中,第一個 192.168.7.0是 network prefix,最後一個 192.168.7.3 是 broadcast address 廣播地址,只有餘下的 192.168.7.1 和 192.168.7.2 兩個地址可以用作 host 主機。博文中 BBB 插著 USB 不插網線預設就是這個網段,BBB 用 USB 共用網路時本身 IP 用 192.168.7.2,電腦這時候應該設置為 192.168.7.1,因為不改 BBB 地址網段的情況下,你別無選擇,餘下只有一個主機地址可用。用這個子網,要發廣播,這子網的廣播地址是 192.168.7.3 了。而 255.255.255.255 就是公網以外,全物理網段廣播,不區分割開了多少個子網。
Multicast TLL 這 Multicast 這個字是來自 IPv6,IPv6地址分三類,Unicast、Anycast、Multicast。Unicast 是給單獨一個主機接收,Anycast 是給最近的一個主機接收,Multicast 是給網段所有主機接收,Multicast 意義上就是 IPv4 的 Broadcast。TTL 全寫是 Time To Live,意義是封包的存活時間,實際上實現的時候,它是每到達一個節點就會減一,直到 0 時候它就會不再被傳送。所以它並不是一個實際時間值(多少毫秒等等)。直接插 USB 連然後對一個只有兩個主機地址的網段廣播而且設置 TTL 128 其實是沒有任何意義,這裡面沒有 128 個節點。看不慣就把上面代碼那句刪掉吧。能設置的範圍是 1-255,預設值是 OS 指定,我沒有查看 BBB 的 Debian 預設值是多少,據說是 1。
有興趣研究可以參考:
https://en.wikipedia.org/wiki/IP_address
https://en.wikipedia.org/wiki/Subnetwork
http://tools.ietf.org/html/rfc4291#section-2
書的話只需要一本,TCP/IP Illustrated Vol 1 The Protocols,Richard Stevens,ISBN: 9780321336316
要調用它,就需要在 express 那邊開介面。/query 接到 GET 請求就調用這個 api.js 裡面的 query 方法。現在修改 app.js:
var express = require('express');
var app = express();
var api = require('./api/api'); // 引用才能使用 api.js
var svc = new api.udpService(4000,'192.168.7.3'); // 利用 api 創建 udpService 實例
app.use(express.static('public'));
app.get('/',function(req,res){
res.sendFile('index.html');
});
app.get('/query', svc.query); // 調用 query 方法
var server = app.listen(4001, function(){
var host = server.address().address;
var port = server.address().port;
console.log('app listening at http://%s:%s',host,port);
});
下一步,修改 index.html 把圖中綠色按鈕的點擊,用 ajax 請求發到 /query,就完成了。簡單點比如就 <a …… onclick="$.ajax({url:'/query'})"> … 。
最後一步,BBB 插上電源和網線,廣播地址改為正確值。web 請求就是這樣和 UDP 廣播連在一起(/query 的 GET 請求收到後,觸發 udpService 的 query 方法)。效果 ok 就來真的了。
整體後臺代碼
由於空間所限,數據量小,併發少,資料庫用 Sqlite 我夠了,喜歡其他的請自行修改。首先安裝一下 Sqlite3,去到之前建的 lasapp 目錄,然後:
npm install sqlite3 –-save
新版的 express 已經沒有了內置 body parser,要自己裝再自己加入中間件,這樣安裝:
npm install body-parser
然後可以寫代碼了,看看我的最終版代碼:
/lasapp/app.js
var express = require('express');
var app = express();
var api = require('./api/api');
var bp = require('body-parser');
var svc = new api.udpService(4000,'255.255.255.255');
app.use(express.static('public'));
app.use(bp.json());
app.get('/',function(req,res){
res.sendFile('index.html');
});
app.get('/query', svc.query);
app.get('/devices/getAll',api.deviceService().getAll);
app.put('/devices', api.deviceService().save);
var server = app.listen(4001, function(){
var host = server.address().address;
var port = server.address().port;
console.log('app listening at http://%s:%s',host,port);
});
與之前代碼區別有幾個地方:
- 它引用了 body-parser 並且在 app.use 啟用了 json 中間件,目的是對 body 解析 JSON https://www.npmjs.com/package/body-parser
- udp 廣播地址用了 255.255.255.255 全物理網段廣播
- 多了兩個介面
- get /devices/getAll
- put /devices
- 兩個介面對應調用了 deviceService 裡面的兩個方法
看看 api.js 裡面是怎樣的:
/lasapp/api/api.js
exports.dbHelper = function(){
var sqlite = require('sqlite3').verbose();
var db = new sqlite.Database('lasdb.db');
db.serialize(function(){
db.run("CREATE TABLE if not exists devices(guid TEXT, dType TEXT,displayName TEXT)");
});
return {
saveOrUpdate: function(device,callback){
db.get("SELECT guid FROM devices WHERE guid=?",device.guid,function(err,row){
if(err===null && row === undefined) {
db.run("INSERT INTO devices VALUES (?,?,?)",device.guid,device.dType,device.displayName);
} else if (err===null) {
db.run("UPDATE devices SET displayName=? WHERE guid=?",device.displayName,device.guid);
} else {
console.log(err);
}
});
var getType={};
if(callback && getType.toString.call(callback)==='[object Function]'){
callback(device);
}
},
getAll: function(callback){
var result;
db.all("SELECT guid,dType,displayName FROM devices", function(err,rows){
if(err!==null){
console.log(err);
return;
}
var getType={};
if(callback && getType.toString.call(callback)==='[object Function]'){
callback(rows);
}
});
},
closeDB: function(){
db.close();
}
};
};
exports.deviceService = function(){
return {
getAll: function(req,res){
var dbHelper = new exports.dbHelper();
dbHelper.getAll(function(r){
res.set({'Content-Type':'application/json'});
res.send(r);
dbHelper.closeDB();
});
},
save: function(req,res){
var dbHelper = new exports.dbHelper();
dbHelper.saveOrUpdate(req.body,function(r){
res.set({'Content-Type':'application/json'});
res.send(r);
dbHelper.closeDB();
});
}
};
};
exports.udpService = function(port,bc_addr){
var dgram = require('dgram');
var port = port;
var bc_addr = bc_addr;
var queryTxt = '{"cmd":"0"}';
var queryMsg = new Buffer(queryTxt);
var client = dgram.createSocket('udp4');
client.bind(port, function(){
this.setBroadcast(true);
this.setMulticastTTL(128);
});
client.on('message', function(msgRec,remote){
var msg = msgRec.toString();
if(msg==queryTxt){
return;
}
var cmdObj;
try {
cmdObj = JSON.parse(msg);
} catch(e) {
console.log('Improper JSON literial received.');
console.log(msg);
return;
}
if(!cmdObj.cmd){
console.log('JSON object format error.');
return;
}
console.log('From:'+remote.address+' Port:'+remote.port+' > '+msg);
if(cmdObj.cmd==2 && cmdObj.dType){
var dbHelper = new exports.dbHelper();
dbHelper.saveOrUpdate(cmdObj,function(){
dbHelper.closeDB();
});
} else {
console.log('cmd code not recognize or dType missing.');
}
});
return {
query : function(req,res){
client.send(queryMsg,0,queryMsg.length,port,bc_addr);
res.sendStatus(200);
}
};
};
三大塊,一個 dbHelper 做數據層用來和 Sqlite 資料庫交互,數據層方法除了closeDB 其他全部有 callback可配置,兩個服務分別負責 UDP 處理和 Web Service 的處理。
udpService 除了一些參數驗證之外,就是 on(“message”,….) 監聽 UDP 包到達,到達後調用 dbHelper 保存或更新值,最後 udpService 實例只開放一個方法,query,用來發出廣播 UDP 包。
deviceService 只有 save 和 getAll,兩者對應 dbHelper 裡面的方法,查詢完成後 res.send。
不複雜,然後測試一下:
整體集成測試
用node app.js 啟動。
首先用 POSTMAN 對 /query 發 get 請求,另外用工具監聽,看看 UDP 是否正常廣播。
然後用工具,發三個 UDP ,分別是 guid:0001, 0002 和 0003,模擬 ESP8266 對 cmd:0 命令的響應。
{"cmd":"2","guid":"0001","dType":"powerPlug"}
{"cmd":"2","guid":"0002","dType":"powerPlug"}
{"cmd":"2","guid":"0003","dType":"powerPlug"}
或者再發多一次 guid 0003 看看它有沒有重覆插入(當然不會)。
然後 POSTMAN 模擬 /devices/getAll 的 GET 請求,看看返回值是否正常。
再試試 PUT,對 /device 發出 PUT 請求,模擬網頁對 displayName,智能設備的顯示名進行更新,PUT 的 body 為:
{"guid":"0003","dType":"powerPlug","displayName":"主卧插座1"}
記得 Header 加上 Content-Type = application/json
最後再對 /device/getAll 發出 GET 請求看看是否更新正確:
API 初稿就這樣完成。改一下頁面,讓它觸發對應的 web service ,或許加個定時自動刷新頁面,整個項目初稿就搞定了。代碼有太多的改善空間,太混亂半成品不放 GIT 出來了,做好先。
下一篇,智能插座接線,和加上從 UDP 包接收,觸發 GPIO 高低電平控制電源開關。整個項目在下一篇就完成了。