阿里雲IoT初試

萊布尼茨發表於2021-08-02

本文從概念到實戰,以一個假想產品——”電子貨架標籤“(Electronic Shelf Label,以下簡稱ESL)為例,介紹基於阿里雲IoT的物聯網應用開發。

資料互動流程

以雲端下發命令到最終收到應答為例(虛線表示非同步):

  • LoRaWAN:ESL所採用的通訊協議;
  • LoRaWAN NS:LoRaWAN網路的中樞大腦,控制通訊引數、實現QoS、節點入網和遷移、資料加解密等。
  • MQTT:基於Pub/Sub正規化的訊息協議。它工作在 TCP/IP協議族上,是為硬體效能低下的遠端裝置以及網路狀況糟糕的情況下而設計。
  • Link WAN:阿里雲物聯網路管理平臺,可用它快速組建LoRaWAN網路;簡單地說,它主要扮演了LoRaWAN NS的角色;
  • AliIoT:阿里雲物聯網平臺,基於MQTT。處理裝置層和業務層的資料互動;
  • AMQP:訊息佇列,裝置非同步應答返回的訊息通過此訊息佇列傳遞到雲端。(廣義上說,AMQP是一個協議,RabbitMQ就是該協議的一個實現)

ESL和LoRa閘道器是通過LoRa協議通訊,LoRa可以看做是物理層面的資訊調製協議或通訊協議,沒有TCP的概念。

注意,MQTT並不侷限於LoRaWAN場景,阿里雲也在平臺上將二者作了不同入口,前者對應AliIoT,後者對應Link WAN。初次接觸不免困惑(這也是阿里雲一貫的作風),其實背後就是這個關係。我們可以裝置直連AliIoT做IoT應用開發(參看10分鐘物聯網裝置接入阿里雲IoT平臺);如果是LoRaWAN系統,也可以同時藉助 Link WAN 做LoRaWAN的網路管理。

閘道器要接入Link WAN,需要移植阿里雲提供的SDK到閘道器與通訊模組上,並且購買Link WAN金鑰安裝,並登入阿里雲物聯網路管理平臺控制檯新增閘道器。雲端開發人員只要關注AliIoT、AMQP及業務層即可。

AliIoT控制檯準備

  1. 公共例項-》建立產品。產品名稱“電子貨架標籤”;節點型別表示該產品下裝置的型別,選擇直連裝置(LoRa有IP的概念?),然後連網方式選擇LoRaWAN;因為ESL裝置收發的資料為未編碼的位元組陣列,資料格式選擇透傳/自定義,後續需要提供資料解析指令碼,將上行的自定義格式的資料轉換為Alink JSON格式,將下行的Alink JSON格式資料解析為裝置自定義格式,裝置才能與雲端進行通訊。產品建立完畢獲得ProductKey。

  2. 管理產品-》功能定義,即定義所謂的物模型。功能分為屬性、服務、事件三種型別(同定義一個類一樣,有屬性、方法、事件)。一個產品可以定義多個物模型,即一個產品下面可以有提供不同功能的多種裝置。這裡我們為ESL定義——

    • 屬性:shelfNo,所屬貨架,資料型別text。示例A.05.02,A區5排2號貨架;
    • 服務:show,顯示貨品名稱和對應價格,入參有productName:text,price:float,呼叫方式選擇非同步;
    • 事件:heart,心跳,我們可以定義一些輸出引數如電池電量batteryLevel:int32,韌體版本firmwareVersion:text,如此每次回報時這些資訊也傳給雲端。

    這樣,雲端就可以下發查詢電池電量和設定貨品名稱和對應價格的兩種命令,同時也可以被動接收裝置返回的心跳訊息。當然,物模型只是定義了介面,具體實現需要裝置端和雲端共同完成。

    物模型中服務呼叫方式可設定同步或者非同步。同步方式:物聯網平臺直接使用RRPC同步方式下行推送請求,裝置返回RRPC響應訊息。RRPC使用詳情,請參見什麼是RRPC。非同步方式:物聯網平臺採用非同步方式下行推送請求,裝置採用非同步方式返回結果。

  3. 管理產品-》資料解析。上面說到,裝置和雲端的互動資料需要中間的解析(序列化/反序列化)過程(發生在上圖第1步之後和第4步之前)。以JavaScript指令碼為例:

    var ALINK_EVENT_HEART_POST_METHOD = 'thing.event.heart.post'; //物聯網平臺Topic,裝置心跳包上報
    var ALINK_EVENT_ACK_POST_METHOD = 'thing.event.ack.post'; //物聯網平臺Topic,裝置服務應答上報
    var ALINK_PROP_REPORT_METHOD = 'thing.event.property.post'; //物聯網平臺Topic,裝置屬性上報
    var ALINK_PROP_SET_METHOD = 'thing.service.property.set'; //物聯網平臺Topic,雲端下發屬性控制指令到裝置端。
    var ALINK_PROP_SET_REPLY_METHOD = 'thing.service.property.set'; //物聯網平臺Topic,裝置上報屬性設定的結果到雲端。
    var ALINK_SERVICE_SHOW_METHOD = 'thing.service.show'; //物聯網平臺Topic,雲端呼叫裝置show服務
    
    /**
     *  將Alink協議的資料轉換為裝置能識別的格式資料,物聯網平臺給裝置下發資料時呼叫
     *  入參:jsonObj,物件,不能為空。
     *  出參:rawData,byte[]陣列,不能為空。
     * 
     * 示例資料:
     * 雲端下發屬性設定指令:
     * 傳入引數:
     *     {"method":"thing.service.property.set","id":"12345","version":"1.0","params":{"shelfNo":"A.05.02"}}
     * 注意:雲端只下發{"shelfNo":"A.05.02"},其餘結構是AliIoT封裝的。
     */
    function protocolToRawData(jsonObj) {
        var method = jsonObj['method'];
        var params = json['params'];
        //按照自定義協議格式拼接 rawData
        var rawdata = [0x5d, 0x64, 0x00];
        if (method == ALINK_PROP_SET_METHOD) { //設定屬性
            rawdata = rawdata.concat(textToByteArray(params['shelfNo']));
        } else if (method == ALINK_SERVICE_SHOW_METHOD) { //呼叫服務
            var productName = params['productName'];
            var price = params['price'];
            rawdata = rawdata.concat(textToByteArray(productName));
            rawdata = rawdata.concat(floatToByteArray(price));
        }
    
        //other commands ...
    
        return rawdata;
    }
    
    /**
     * 將裝置的自定義格式資料轉換為Alink協議的資料,裝置上報資料到物聯網平臺時呼叫。
     * 入參:rawData,byte[]陣列,不能為空。
     * 出參:jsonObj,物件,不能為空。
     * 
     * 示例資料:
     * 裝置心跳上報:
     * 傳入引數:
     *     0xFF1020010005
     * 輸出結果:
     *     {"method":"thing.event.heart.post","id":"12345678","params":{"batteryLevel":32,"firmwareVersion":"1.0.5"},"version":"1.0"}
     */
    function rawDataToProtocol(rawData) {
        var uint8Array = new Uint8Array(rawData.length);
        for (var i = 0; i < bytes.length; i++) {
            uint8Array[i] = bytes[i] & 0xff;
        }
        var dataView = new DataView(uint8Array.buffer, 0);
        var jsonObj = new Object();
        var params = {};
        
        var head = uint8Array.slice(0, 2).join(); //自定義協議包頭
        if (head[0] == 0xFF && head[1] == 0x10) {
            params['batteryLevel'] = dataView.getInt8(2);
            params['firmwareVersion'] = `${dataView.getInt8(3)}.${dataView.getInt8(4)}.${dataView.getInt8(5)}`;
            jsonObj['method'] = ALINK_EVENT_HEART_POST_METHOD;
        } else {
            //其它資料包轉換
        }
        
        jsonObj['version'] = '1.0'; //ALink JSON格式,協議版本號固定欄位。
        jsonObj['id'] = '12345678' //ALink JSON格式,標示該次請求id值。
        jsonObj['params'] = params;
    
        return jsonObj;
    }
    
    /**
     * 處理自定義Topic,本示例不涉及
     */
    function transformPayload(topic, rawData) {
        var jsonObj = {}
        return jsonObj;
    }
    

    資料解析的前提之一是裝置收發的資料格式要確定好。

    上述指令碼將業務資料和位元組陣列進行了轉換,若是擔心資料協議外洩[給阿里雲?],這部分工作也可以放在雲端,指令碼檔案只用來進行位元組陣列的轉發(這種情況下,物模型所有功能的出參入參都只需要一個,資料格式為int32array)。

  4. 管理產品-》服務端訂閱。建立AMQP訂閱,AMQP會將訊息推送給列表中的所有消費組,一個消費組可看做是一個訊息佇列,雲端作為客戶端連線某佇列得到裝置上報訊息。我們新建名稱為“電子貨架標籤-Q1”的消費組,得到一串自動生成的消費組ID。

雲端開發

以Java/Kotlin為例,先引入SDK:

//下發命令依賴
implementation("com.aliyun:aliyun-java-sdk-core:4.5.22")
implementation("com.aliyun:aliyun-java-sdk-iot:7.27.0")
//獲取應答依賴
implementation("org.apache.qpid:qpid-jms-client:0.59.0")
implementation("commons-codec:commons-codec:1.15")

下發show命令:

@Service
class AliIoTDemo {
    @Autowired
    lateinit var config: AliIoTConfig

    private lateinit var client: IAcsClient

    @PostConstruct
    fun init() {
        val profile =
            DefaultProfile.getProfile(config.regionId, config.accessKeyId, config.accessKeySecret)
        client = DefaultAcsClient(profile)
    }

    /**
    * loraId: 裝置編號,對應AliIoT的DeviceName
    */
    fun show(loraId: String) {
        val gson = GsonInstance.get()
        val jo = JsonObject()    
        jo.addProperty("productName", "康師傅方便麵")
        jo.addProperty("price", 3.50)

        val request = InvokeThingServiceRequest().apply {
            productKey = config.productKey //建立物聯網產品時得到ProductKey
            deviceName = loraId
            identifier = "show" //物模型定義的服務名稱
            args = gson.toJson(jo) //{"productName": "康師傅方便麵", "price": 3.50}
        }

        client.doAction(request)
    }
}

程式碼中的client.doAction是無法得到應答的,所以我們還要寫一個AMQP客戶端去非同步獲得應答訊息,具體參看官方示例Java SDK接入示例 - 阿里雲物聯網平臺

多條非同步命令順序執行

如果一個事務只要下發一條命令,那就等著拿結果就好了;但是有多條非同步命令需要順序執行的話,就稍微有點麻煩了,我們要考慮上下文的掛起和恢復、超時取消等機制。以下為簡單示例:

//儲存各事務對應的等待傳送的命令佇列,命令一旦傳送則須從佇列中移除
//key為裝置編號,二元組第一項表示事務開始時間,用於超時判斷
private val cmdSetMap = ConcurrentHashMap<String, Pair<Long, Queue<InvokeThingServiceRequest>>>()

internal fun putInvokeThingServiceRequest(deviceNo: String, requests: Queue<InvokeThingServiceRequest>) {
    //同樣裝置之前的命令不再執行,移除
    cmdSetMap.remove(deviceNo)
    if (requests.size == 1) { //只有一條命令則直接傳送
        client.doAction(requests.poll())
    } else {
        val request = requests.poll() //先傳送第一條
        cmdSetMap[deviceNo] = Pair(System.currentTimeMillis(), requests)  //其餘的存入待傳送列表
        client.doAction(request)
    }
}

//...

{
//應答訊息抵達後,若應答OK則執行下一條命令
    val request = cmdSetMap[deviceName]!!.second.poll()
    try { 
        client.doAction(request)
    } catch (ex: Exception) {
        logger.error(ex)
        // 發生錯誤 通知客戶端
    }
    if (cmdSetMap[deviceName]!!.second.size == 0) cmdSetMap.remove(deviceName)
}

//每分鐘清理過時事務
@Scheduled(cron = "0 * * * * *")
fun removeTimeoutCmd() {
    //...
}

在語言層面,不管是以前的回撥地獄還是後來興起的async/await、suspend、Promise等,都能處理這種場景。本質上,非同步回撥是指令定址、變數出入棧的過程,有時還涉及到執行緒上下文的切換,各種語言/框架都幫我們考慮並且做了,我們只要按照既定語法編寫業務程式碼即可。

為什麼業務端不能直接訂閱對應的topic呢,這樣不就能直接拿到資料了嗎?AliIoT似乎也沒有提供業務層直接訂閱 AliIoT topic 的入口。不過MQTT協議是基於PUB/SUB的非同步通訊模式,就算業務端能直接接收到應答,也要處理應答訊息轉發到對應的上下文、上下文掛起恢復等問題。

相關文章