Node-OPCUA-建置心得(ES6)

使用Node-OPCUA With ES6建置Client,以及 使用MELSEC三菱與KEYENCE基恩斯的OPCUA採坑小總結!!

Node-OPCUA-建置心得(ES6)


作者在工業控制經常使用opcua,再加上要配合叢集Server部屬
會使用到Kubernetes(K8S)、資料庫Master/Slave、全離線部屬
需要部屬前後端與中控系統等,因此採用Node OPCUA(Node.js),方便管理
Node OPCUA 官方網站
Node OPCUA GitHub
Node OPCUA 官方使用說明書(約$249.00 USD)
之前作者買的是2020的樣子?
電子書不知被我丟到哪了,看完就丟XD

最後還會有使用MELSEC三菱與KEYENCE基恩斯的OPCUA採坑小總結(超賽!)

另外作者在架構OPC UA與PLC串連時, 發現一個很猛的概念?
通常我們在進行工業4.0?的外掛部屬時, 會用到一些軟體進行通訊協議轉換
若PLC都是Q系列, 一個IQ-R OPC UA 模組可以吃掉8台...(7+1本身)
重點: 不限制點位數量!!(一萬個Tags很夠用了)
不會被kepware綁架點位, 不會被計算每開一個點位要花多少錢!!!


Intro

本篇會介紹Server/Client如何建置,Server不會詳細!
因大部分廠商在PLC或軟體上,會配合自己的通訊協議建立opcua  server,並將AIO/DIO等數值定期更新到自己的server上,沒必要自己弄一遍(Data Delay可能會更慘XD)

因此大部分使用的重心會在opcua client上
*作者經常使用MELSEC三菱與KEYENCE基恩斯為主

那為何要使用OPCUA呢? 我總結了使用心得

  1. opcua的pub/sub機制,不像傳統的polling,一直訪問PLC,opcua為有事件則觸發,可省流(OPC UA底層其實也是一直在通訊,只是流量很小很小)
  2. opcua具備多對多通訊機制,若採用mx-component、MCpotocol等,大部分通訊為一對一,當你的系統需要被其他人訪問時,這時候還要自己建立排隊機制或多開幾組IP,很不人性化,且生產中的機台哪是說停就停...
    (使用1組IP就通吃,不香嗎?)
  3. 進行大量的數據上報與少數的控制,且需求不需要到1 ms那麼快
  4. MQTT雖然輕量,但真的太輕了,我還要工控呢! XD

因此基於上述優點與需求,我會採用opcua作為上位者的角度去建置系統


Intro - PLC OPCUA With Third-Party

首先我們可以了解在OPCUA與PLC的簡易通訊架構(透過MELSEC iQ-R說明)

PLC通訊架構簡易版
  1. PLC的通訊背板以3GB/s的傳輸速度將物理數值搬運到OPCUA Server的資料庫上
  2. 在現有的硬體,大部分採用都是1Gb/s的乙太網路卡
  3. 不用怕PLC太慢,因為現階段的PC跟不上PLC刷Data的速率
  4. 要注意OPC UA Server與底層PLC的刷新速率
  5. MELSEC iQ-R最快200ms,實際使用上約300ms
  6. KEYENCE KV8000最快10ms,很猛! (但可惜有嚴重的BUG,該問題在文末)

總結:
第三方Client是訪問PLC OPCUA Server的資料庫(且資料無時無刻的透過模組搬運)
因此當建立多對多的通訊時,大部分的Loading是在模組上,因此塞爆也不會影響PLC CPU

而大部分的通訊協議是根據通訊協議的優先度(如LB、LW>光纖>乙太網路等)
訪問PLC,再透過PLC讀取數值回傳給第三方,Loading在PLC的CPU上,塞爆可能就GG了

因此在Loading上有不同的區別!


1. 建立環境

# install node.js
node.js version: v16.20.1

# install
mkdir mytest
cd mytest
npm init 
npm install node-opcua

#package.json 記得使用ES6要加上"type": "module"
{
  "name": "node_server",
  "type": "module",
  "scripts": {
    "test": ""
  },
  "dependencies": {
    "node-opcua": "^2.118.0"
  }
}

2. 建立OPCUA Server

可以參考官方OPCUA Server作法(ES5)

簡化版(ES6)

import opcua, { StatusCodes } from "node-opcua"

// Server初始化設定
const server = new opcua.OPCUAServer({
    port: 48040, // 端口
    resourcePath: "/UA/MyLittleServer", // opc ua url, etc. opc.tcp://DESKTOP-XXXXX:48040/UA/MyLittleServer
     buildInfo : {
        productName: "MySampleServer1",
        buildNumber: "7658",
        buildDate: new Date(2014,5,2)
    }
});

function post_initialize() {
    // initialized
    function construct_my_address_space(server) {
    
        // namespace引用
        const addressSpace = server.engine.addressSpace;
        const namespace = addressSpace.getOwnNamespace();
    
        // 1. declare a new object 建立一個塞Tag的群組
        const device = namespace.addObject({
            organizedBy: addressSpace.rootFolder.objects,
            browseName: "MyDevice"
        });

        let variable1 = 1;
        
        // 2. emulate variable1 changing every 500 ms 弄一個計時器,每秒更新variable1
        setInterval(function(){  variable1+=1; }, 1000);
        
        // 3. 新增node Tags(node節點)的定義
        namespace.addVariable({
            componentOf: device,        // 選擇Tags的Group
            browseName: "MyVariable1",  // Tags的名稱
            dataType: "Double",         // Tags的資料型態(Tags Types)
            value: {                    // 塞數值
                get: function () {      // 可讀(Let Node Can Read)
                    return new opcua.Variant({dataType: opcua.DataType.Double, value: variable1 });
                },
                set: (variant) => {     // 可寫(Let Node Can Write)
                    variable1 = variant.value
                    return StatusCodes.Good
                }
            
            }
        });
        
    }
    // 生成Tages定義
    construct_my_address_space(server);

    // Server Start
    server.start(function() {
        console.log("Server is now listening ... ( press CTRL+C to stop)");
        console.log("port ", server.endpoints[0].port);
        const endpointUrl = server.endpoints[0].endpointDescriptions()[0].endpointUrl;
        console.log(" the primary server endpoint url is ", endpointUrl );
    });
}
server.initialize(post_initialize);
ES6 Node OPC UA SERVER

發布的opc.tcp URL可以用PC名稱或IPv4區網連線,預設是全開!!
opc.tcp://DESKTOP-T1ME1U9:48040/UA/MyLittleServer
opc.tcp://192.168.0.10:48040/UA/MyLittleServer

Tip. 若要讓Node可以讀寫(Let Server Node Can Read and Write)

請注意,Node變量需要可讀寫,要設定get與set參數!!

// namespace的定義
namespace.addVariable({
            componentOf: device,        // 選擇Tags的Group
            browseName: "MyVariable1",  // Tags的名稱
            dataType: "Double",         // Tags的資料型態(Tags Types)
            value: {                    // 塞數值
                get: function () {      // 可讀(Let Node Can Read)
                    return new opcua.Variant({dataType: opcua.DataType.Double, value: variable1 });
                },
                set: (variant) => {     // 可寫(Let Node Can Write)
                    variable1 = variant.value
                    return StatusCodes.Good
                }
            
            }
        });
Wanna let Node Can Read Write, U Need Use Get and Set 

3. 建立OPCUA Client

這是官方OPCUA Client作法

統整後就這樣

import opcua from "node-opcua"

// OPC UA Client定義 Connection URL
const connectionStrategy = {
    initialDelay: 1000,
    maxRetry: 1 // 若設為0-> 初始Client啟動時,Server未啟動,則會斷線(程式報錯) | 非0則等待,直到Server On
    /*
        maxRetry: number
        initialDelay: number
        maxDelay: number
        randomisationFactor: number

        maxRetry: the number of retries before the reconnection process will give up and return a connection error:
        initialDelay: the delay in miliseconds before the first reconnexion occur. This delay will increase linearly if reconnexion is still failing ( until it reaches maxDelay)
        maxDelay: the maximum delay between two attempts to reconnect
   */
}
const options = {
        applicationName: 'GateWay',
        connectionStrategy,
        securityMode: opcua.MessageSecurityMode.None,   // 有簽證,要改Sign(2)/SignAndEncrypt(3)
        securityPolicy: opcua.SecurityPolicy.None,      // 密碼的Type, etc. Basic256Sha256、Basic128Rsa15
        endpointMustExist: false
}
const endpointUrl = 'opc.tcp://192.168.1.50:48040'      // OPC UA Server IP and port
const opcclient = opcua.OPCUAClient.create(options)
await opcclient.connect(endpointUrl)                    // Client連線
const session = await opcclient.createSession()         // 創建Client Session

// 引用Session, 創建Pub/Sub訂閱
const subscription = opcua.ClientSubscription.create(session, {
    requestedPublishingInterval: 500, // 每秒讀一次
    requestedLifetimeCount: 100,
    requestedMaxKeepAliveCount: 10,
    maxNotificationsPerPublish: 9000,
    publishingEnabled: true,
    priority: 10
})

// Subscription監控的node參數
const parameters = {
    samplingInterval: 500, // 取樣間隔, 若為500ms, 有N次變化,則取最後一次變化,要注意!
    discardOldest: true,
    queueSize: 10
}

//------------------------------------Sub by Group----------------------------
// 定義欲讀取的Node Tage(Node節點)
// 主要用於一次性監控N的節點,避免程式過程與重複宣告
const MyVariable1_Gruop = [
    {
        attributeId: opcua.AttributeIds.Value,
        nodeId: 'ns=1;i=1001'
    },
    {
        attributeId: opcua.AttributeIds.Value,
        nodeId: 'ns=1;i=1001'
    }
]
const Gruop_Abbr = opcua.ClientMonitoredItemGroup.create(subscription, MyVariable1_Gruop, parameters, opcua.TimestampsToReturn.Both)
Gruop_Abbr.on('changed', function (monitoredItem, dataValue, index) { // 節點定義, 節點變數, Group內的第幾個節點變化
    console.log(dataValue.value.value,index)
})

//------------------------------------Sub by Item----------------------------
const MyVariable1_Item = {
    attributeId: opcua.AttributeIds.Value,
    nodeId: 'ns=1;i=1001'
}
const Item_Abbr = opcua.ClientMonitoredItem.create(subscription, MyVariable1_Item, parameters, opcua.TimestampsToReturn.Both)
Item_Abbr.on('changed', function (dataValue) { // 節點變數
    console.log(dataValue.value.value)
})

//------------------------------------Node Read----------------------------
const readValue = await session.read({ nodeId: 'ns=1;i=1001', attributeId: opcua.AttributeIds.Value })
const readValue1 = await session.read({ nodeId: 'ns=1;i=1001'})
console.log(readValue.value.value, readValue1.value.value)

//------------------------------------Node Write----------------------------
const nodeToWrite = {
    nodeId: 'ns=1;i=1001',
    attributeId: opcua.AttributeIds.Value,
    value: /* DataValue */{
        value: /* Variant */{
            dataType: opcua.DataType.Double,
            value: 1098
        }
    },
}
const writeValue = await session.write(nodeToWrite)
console.log(writeValue)
const writeAnser = await session.read({ nodeId: 'ns=1;i=1001'})
console.log(writeAnser.value.value)
ES6 Node OPC UA Client
Node OPAUA Sub by Group
Node OPAUA Sub by Item
Node OPAUA Read Node
Node OPAUA Write Node

Node OPAUA Client Write重新打包思路

重新封裝Node Write,減少重複利用的代碼長度

//------------------------------------Node Write----------------------------
const NodeWrite = (nodeId, value, dataType) =>{
    return {
        nodeId,
        attributeId: opcua.AttributeIds.Value,
        value: /* DataValue */{
            value: /* Variant */{
                dataType,
                value
            }
        },
    }
}
await session.write(NodeWrite('ns=1;i=1001', 4, opcua.DataType.Double))

Node OPAUA Client Subscription取樣間隔

取樣間隔設太高,會抓不到Node節點的變化喔!

// Subscription的node參數
const parameters = {
    samplingInterval: 500, // 取樣間隔, 若為500ms, 有N次變化,則取最後一次變化
    discardOldest: true,
    queueSize: 10
}

Node OPAUA Client小總結.

  1. 訂閱有分為群組(Group)與單點(Item)
  2. Read可簡寫, Write定義比較多,但可重新打包
  3. 設定Pub/Sub的parameters的samplingInterval與discardOldest要注意! OPC UA Queue官方說明
    discardOldest=True => 當queqe塞滿時,會拋棄最舊的數值
    discardOldest=False => 當queqe塞滿時,保留最舊的值且最新的值會覆蓋第二新的值
    設定錯誤可能導致Data Lose!!

使用OPCUA與PLC的踩坑心得(圖文)

作者有遇到需要一次性溝通17~20台設備, 但又不能吃現有的光纖迴路
然後又不想寫階梯圖懷疑人生~~, 所以弄了一個適合PC Base的架構
以下為KEYENCE KV-8000、MELSEC-Q Series、MELSEC iQ-R Series

常用PLC外接架構

1. PC Base的控制邏輯通常與CIM或MES有關, 大多複雜運算會交給PC處理
2. 由上面的KV-8000架構舉例: 當我需要回控時, PLC需要寫一段階梯圖, 不管是點位宣告或邏輯運算, 會有重複工作的疑慮
3. 對於開發者而言, 要同時維護PC與PLC是一個很大的Loading, 較不方便
讀取: 透過簡易CPU通訊或MC protocol
寫入: 統一使用MC protocol

修正後的PLC外接架構

1. 若有大量點位的讀取需求, 可以直接透過IQ-R的CPU簡易通訊讀取, 降低CPU負載
2. 可以從OPC UA Server模組與現場的PLC建立Polling, 進行讀寫
3. 基於OPC UA Server模組特點, 可以直接從PC透過OPC UA Write寫到現場的PLC
4. 從以上架構, 可以實現不須寫任何階梯圖, 即可部屬純PC Base的系統, 方便維護
5. 走這套其實跟kepware類似, 但不會被計算點位的價格!!!!(很重要!!!)
讀取: 透過簡易CPU通訊或OPC UA Polling
寫入: 統一使用OPC UA Write


使用OPCUA與PLC的踩坑心得(文字抱怨版)

KEYENCE KV-8000 OPCUA Server 問題

作者使用MELSEC-Q Series將Address偏移到KEYENCE PLC上,再使用KEYENCE建立OPCUA Server,進行雙向讀寫時發現,透過OPCUA Client寫入到KEYENCE OPCUA Server,KEYENCE不會將數值自動寫入MELSEC-Q Series,詢問原廠後發現,原來KEYENCE只能走簡易CPU通訊(讀寫是分開的),所以OPC UA就會這樣,WTF
也不知道時麼時候會修正-.-

寫入還要寫階梯圖自己偏移,這不就是在浪費人生嗎...
因此這套架構預計會改成MELSEC iQ-R Series(先用MC protocol應急)

而使用MELSEC-Q Series將Address偏移到MELSEC iQ-R Series,並建立OPCUA Server,再使用自己的OPCUA Client寫入MELSEC iQ-R Series的OPCUA Server模組,該數值會同步寫入MELSEC-Q Series,最終是雙向的! 讚讚!!

KEYENCE KV-8000 Device Address偏移兼容性

之前被強迫使用KEYENCE讀取MELSEC,在Device Address偏移時,因為MELSEC有虛擬的address
例如LW 14XXX,超過KEYENCE可讀到的W點位,原地爆炸
後來的解法是改讀HMI人機的點位...

三菱MELSEC有一套OPCUA Server軟體,但不支援OPC UA Pub/Sub

作者想說省略PLC,直接上乙太網路,但可惜的是,軟體是不支援pub/sub訂閱機制
有請原廠也測試過了,他們的還沒有這個功能,規劃系統時,要注意這點!!!
若有OPC UA pub/sub訂閱需求,還是要買一套PLC+OPCUA模組

目前三菱MELSEC在OPCUA的軟體上不支援OPC UA Pub/Sub,但硬體可以!!