From 8c5068cf40bf713efca4ee4eae553a748ee989e9 Mon Sep 17 00:00:00 2001 From: Sterfive's NodeWoT team Date: Sat, 18 Oct 2025 16:29:36 +0200 Subject: [PATCH] chore: revisit opcua binding examples --- packages/examples/package.json | 13 +- packages/examples/src/bindings/README.md | 15 ++ .../examples/src/bindings/opcua/README.md | 26 +++ .../opcua/demo-opcua-thing-description.ts | 5 +- .../opcua/opcua-coffee-machine-demo.ts | 183 ++++++++++++++++-- .../opcua-coffee-machine-thing-description.ts | 157 +++++++++++++-- 6 files changed, 363 insertions(+), 36 deletions(-) create mode 100644 packages/examples/src/bindings/README.md create mode 100644 packages/examples/src/bindings/opcua/README.md diff --git a/packages/examples/package.json b/packages/examples/package.json index d63ff530a..ee4979e46 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -17,7 +17,18 @@ "build": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "prettier --write \"src/**/*.ts\" \"**/*.json\"" + "format": "prettier --write \"src/**/*.ts\" \"**/*.json\"", + "bindings:coap:server": "node dist/bindings/coap/example-server.js", + "bindings:coap:client": "node dist/bindings/coap/example-client.js", + "bindings:http:server": "node dist/bindings/http/example-server.js", + "bindings:http:server-secure": "node dist/bindings/http/example-server-secure.js", + "bindings:http:client": "node dist/bindings/http/example-client.js", + "bindings:opcua:1": "node dist/bindings/opcua/opcua-demo1.js", + "bindings:opcua:2": "node dist/bindings/opcua/opcua-demo2.js", + "bindings:opcua:coffee-machine": "node dist/bindings/opcua/opcua-coffee-machine-demo.js", + "quickstart:smart-clock": "node dist/quickstart/smart-clock.js", + "quickstart:simple-coffee-machine": "node dist/quickstart/simple-coffee-machine.js", + "quickstart:presence-sensor": "node dist/quickstart/presence-sensor.js" }, "bugs": { "url": "https://github.com/eclipse-thingweb/node-wot/issues" diff --git a/packages/examples/src/bindings/README.md b/packages/examples/src/bindings/README.md new file mode 100644 index 000000000..f877d5ce8 --- /dev/null +++ b/packages/examples/src/bindings/README.md @@ -0,0 +1,15 @@ +## Binding Examples + +This folder contains examples for different binding protocols. + +It demonstrates how to create Things that take their properties, actions, and events from different protocol bindings. + +For each use case a Thing Description is provided that describes the Thing in a protocol-agnostic way. +Then a Servient is created that uses the respective binding protocol to expose the Thing. +A console client is also provided to interact with the Thing. + +Examples are located in + +- `bindings\coap` +- `bindings\http` +- [`bindings\opcua`](./opcua/README.md) diff --git a/packages/examples/src/bindings/opcua/README.md b/packages/examples/src/bindings/opcua/README.md new file mode 100644 index 000000000..ec7d4cb63 --- /dev/null +++ b/packages/examples/src/bindings/opcua/README.md @@ -0,0 +1,26 @@ +## OPCUA + +For inializing an OPCUA client Servient, we need to import the `OPCUAClientFactory` from the `@node-wot/binding-opcua` package. + +```typescript +const servient = new Servient(); +servient.addClientFactory(new OPCUAClientFactory()); +const wot = await servient.start(); +const thing = await wot.consume(thingDescription); +``` + +Then we can interact with the Thing as usual: + +```typescript +// now interact with the things +await thing.invokeAction(...); +await thing.readProperty(...); +await thing.subscribeEvent(...); + +``` + +Finally, we can shutdown the servient: + +```typescript +await servient.shutdown(); +``` diff --git a/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts b/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts index bec245db5..ed6721bb5 100644 --- a/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts +++ b/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts @@ -25,6 +25,7 @@ export const thingDescription: WoT.ThingDescription = { security: "nosec_sc", title: "servient", description: "node-wot CLI Servient", + base: endpointUrl, properties: { pumpSpeed: { description: "the pump speed", @@ -34,7 +35,7 @@ export const thingDescription: WoT.ThingDescription = { type: "number", forms: [ { - href: endpointUrl + "?id=ns=1;s=PumpSpeed", + href: "?id=ns=1;s=PumpSpeed", op: ["readproperty", "observeproperty"], }, ], @@ -47,7 +48,7 @@ export const thingDescription: WoT.ThingDescription = { type: "number", forms: [ { - href: endpointUrl + "?id=ns=1;s=Temperature", + href: "?id=ns=1;s=Temperature", op: ["readproperty", "observeproperty"], }, ], diff --git a/packages/examples/src/bindings/opcua/opcua-coffee-machine-demo.ts b/packages/examples/src/bindings/opcua/opcua-coffee-machine-demo.ts index 57788c5d0..5d0323578 100644 --- a/packages/examples/src/bindings/opcua/opcua-coffee-machine-demo.ts +++ b/packages/examples/src/bindings/opcua/opcua-coffee-machine-demo.ts @@ -14,7 +14,7 @@ ********************************************************************************/ /* eslint no-console: "off" */ - +import util from "util"; import { Servient } from "@node-wot/core"; import { OPCUAClientFactory } from "@node-wot/binding-opcua"; import { thingDescription } from "./opcua-coffee-machine-thing-description"; @@ -25,37 +25,192 @@ const pause = async (ms: number) => new Promise((resolve) => setTimeout(resolve, servient.addClientFactory(new OPCUAClientFactory()); const wot = await servient.start(); + const thing = await wot.consume(thingDescription); + let lastTemperature = NaN; + let lastWaterTankLevel = NaN; + let lastCoffeeBeanLevel = NaN; + let lastCurrentState = NaN; + let lastGrindingDuration = NaN; + let lastGrinderStatus = NaN; + let lastHeaterStatus = NaN; + let lastPumpStatus = NaN; + let lastValveStatus = NaN; + + const recordedActions: string[] = []; + const recordAction = (actionName: string) => { + recordedActions.push(`${new Date().toISOString()} - ${actionName}`); + }; + process.stdout.write("\x1Bc"); // clear console + process.stdout.write("\x1B[?25l"); // hide cursor + const currentStateEnum = ["Off", "Standby", "Error", "Cleaning", "Serving Coffee", "Under Maintenance"]; + const grinderStates = ["Off", "On", "Jammed", "Malfunctioning"]; + const heaterStates = ["Off", "Heating", "Ready", "Malfunctioning"]; + const pumpStates = ["Off", "On", "Malfunctioning"]; + const valveStates = ["Open", "Opening", "Close", "Closing", "Malfunctioning"]; + + const waitingMachineCoffeeStandByState = async () => { + await pause(1000); + let state = lastCurrentState; + while (state !== 1) { + // Standby + await pause(1000); + state = lastCurrentState; + } + }; + const writeLine = (...args: unknown[]) => { + process.stdout.write(util.format(...args) + " \n"); + }; + const displayOnlineStatus = () => { + process.stdout.write("\x1B[1;1H"); // move cursor to top left + writeLine(`======== Coffee Machine Status ======== ${new Date().toISOString()}`); + writeLine( + ` 🔄 Current State : ${ + isNaN(lastCurrentState) ? "n/a" : (currentStateEnum[lastCurrentState] ?? lastCurrentState) + }` + ); + writeLine( + ` 🔥 Heater Status : ${ + isNaN(lastHeaterStatus) ? "n/a" : (heaterStates[lastHeaterStatus] ?? lastHeaterStatus) + }` + ); + writeLine( + ` 🌡️ Boiler Temperature : ${isNaN(lastTemperature) ? "n/a" : lastTemperature.toFixed(2) + " °C"}` + ); + writeLine( + ` 🚰 Pump Status : ${ + isNaN(lastPumpStatus) ? "n/a" : (pumpStates[lastPumpStatus] ?? lastPumpStatus) + }` + ); + writeLine( + ` 🚪 Valve Status : ${ + isNaN(lastValveStatus) ? "n/a" : (valveStates[lastValveStatus] ?? lastValveStatus) + }` + ); + writeLine( + ` 💧 Water Tank Level : ${isNaN(lastWaterTankLevel) ? "n/a" : lastWaterTankLevel.toFixed(2) + " ml"}` + ); + writeLine( + ` ⚙️ Grinder Status : ${ + isNaN(lastGrinderStatus) ? "n/a" : (grinderStates[lastGrinderStatus] ?? lastGrinderStatus) + }` + ); + writeLine( + ` ⏱️ Grinding Duration : ${ + isNaN(lastGrindingDuration) ? "n/a" : lastGrindingDuration.toFixed(2) + " s" + }` + ); + writeLine( + ` ☕ Coffee Bean Level : ${isNaN(lastCoffeeBeanLevel) ? "n/a" : lastCoffeeBeanLevel.toFixed(2) + " g"}` + ); + writeLine("========================================"); + writeLine("---- Recorded Actions (last 5) ----"); + recordedActions + .slice(-5) + .forEach((action) => writeLine(action + " ")); + writeLine("-----------------------------------"); + }; try { thing .observeProperty("waterTankLevel", async (data) => { - const waterTankLevel = await data.value(); - console.log("------------------------------"); - console.log("tankLevel : ", waterTankLevel, "ml"); - console.log("------------------------------"); + lastWaterTankLevel = (await data.value()) as number; + displayOnlineStatus(); }) .catch((err) => { console.error("Error observing waterTankLevel property:", err); }); thing .observeProperty("coffeeBeanLevel", async (data) => { - const coffeBeanLevel = await data.value(); - console.log("------------------------------"); - console.log("bean level : ", coffeBeanLevel, "g"); - console.log("------------------------------"); + lastCoffeeBeanLevel = (await data.value()) as number; + displayOnlineStatus(); }) .catch((err) => { console.error("Error observing coffeeBeanLevel property:", err); }); + thing + .observeProperty("temperature", async (data) => { + lastTemperature = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing temperature property:", err); + }); + thing + .observeProperty("currentState", async (data) => { + lastCurrentState = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing currentState property:", err); + }); + thing + .observeProperty("grinderStatus", async (data) => { + lastGrinderStatus = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing grinderStatus property:", err); + }); + thing + .observeProperty("grindingDuration", async (data) => { + lastGrindingDuration = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing grindingDuration property:", err); + }); + thing + .observeProperty("heaterStatus", async (data) => { + lastHeaterStatus = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing heaterStatus property:", err); + }); + thing + .observeProperty("pumpStatus", async (data) => { + lastPumpStatus = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing pumpStatus property:", err); + }); + thing + .observeProperty("valveStatus", async (data) => { + lastValveStatus = (await data.value()) as number; + displayOnlineStatus(); + }) + .catch((err) => { + console.error("Error observing valveStatus property:", err); + }); + + // give some time to gather initial values + await pause(2000); + await waitingMachineCoffeeStandByState(); + recordAction("Machine is ready !"); + + await pause(10000); + + recordAction("Invoking brewCoffee(Mocha) action..."); + await thing.invokeAction("brewCoffee", { RecipeName: "Mocha" }); + await waitingMachineCoffeeStandByState(); + recordAction("Coffee is ready !"); + + await pause(10000); + + recordAction("Invoking brewCoffee(Americano) action..."); + await thing.invokeAction("brewCoffee", { RecipeName: "Americano" }); + await waitingMachineCoffeeStandByState(); + recordAction("Coffee is ready !"); - await thing.invokeAction("brewCoffee", { CoffeeType: 1 }); - await pause(5000); - await thing.invokeAction("brewCoffee", { CoffeeType: 0 }); - await pause(5000); + await pause(10000); + recordAction("Invoking fillTank action..."); await thing.invokeAction("fillTank"); - await pause(5000); + await waitingMachineCoffeeStandByState(); + recordAction("Tank is refilled !"); + recordAction("Done !"); } finally { await servient.shutdown(); } diff --git a/packages/examples/src/bindings/opcua/opcua-coffee-machine-thing-description.ts b/packages/examples/src/bindings/opcua/opcua-coffee-machine-thing-description.ts index 099a92566..b21132c69 100644 --- a/packages/examples/src/bindings/opcua/opcua-coffee-machine-thing-description.ts +++ b/packages/examples/src/bindings/opcua/opcua-coffee-machine-thing-description.ts @@ -14,9 +14,21 @@ ********************************************************************************/ const endpointUrl = "opc.tcp://opcuademo.sterfive.com:26543"; +const coffeeMachine = "1:CoffeeMachineA"; + export const thingDescription: WoT.ThingDescription = { - "@context": "https://www.w3.org/2019/wot/td/v1", + "@context": [ + "https://www.w3.org/2019/wot/td/v1", + { + uav: "http://opcfoundation.org/UA/WoT-Binding/", + "1": "http://example.namespace.com/demo/pump", + "2": "http://opcfoundation.org/UA/DI/", + "7": "http://opcfoundation.org/UA/CommercialKitchenEquipment/", + "17": "http://sterfive.com/UA/CoffeeMachine/", + }, + ], "@type": ["Thing"], + base: endpointUrl, securityDefinitions: { nosec_sc: { scheme: "nosec", @@ -27,12 +39,11 @@ export const thingDescription: WoT.ThingDescription = { description: "node-wot CLI Servient", properties: { deviceHealth: { - // type: "number", observable: true, readOnly: true, forms: [ { - href: endpointUrl, + href: "/", op: ["readproperty", "observeproperty"], "opcua:nodeId": { root: "i=84", @@ -42,34 +53,139 @@ export const thingDescription: WoT.ThingDescription = { ], }, waterTankLevel: { - // type: "number", observable: true, readOnly: true, forms: [ { - href: endpointUrl, + href: "/", op: ["readproperty", "observeproperty"], "opcua:nodeId": { root: "i=84", - path: "/Objects/2:DeviceSet/1:CoffeeMachine/2:ParameterSet/9:WaterTankLevel", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:WaterTankLevel`, }, }, ], + type: "number", }, coffeeBeanLevel: { - // type: "number", observable: true, readOnly: true, forms: [ { - href: endpointUrl, + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:CoffeeBeanLevel`, + }, + }, + ], + type: "number", + }, + temperature: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/7:BoilerTempWater`, + }, + }, + ], + type: "number", + }, + currentState: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/7:CurrentState`, + }, + }, + ], + type: "number", + }, + grinderStatus: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:GrinderStatus`, + }, + }, + ], + type: "number", + }, + heaterStatus: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", op: ["readproperty", "observeproperty"], "opcua:nodeId": { root: "i=84", - path: "/Objects/2:DeviceSet/1:CoffeeMachine/2:ParameterSet/9:CoffeeBeanLevel", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:HeaterStatus`, }, }, ], + type: "number", + }, + pumpStatus: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:PumpStatus`, + }, + }, + ], + type: "number", + }, + valveStatus: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:ValveStatus`, + }, + }, + ], + type: "number", + }, + grindingDuration: { + observable: true, + readOnly: true, + forms: [ + { + href: "/", + op: ["readproperty", "observeproperty"], + "opcua:nodeId": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/7:Parameters/17:GrindingDuration`, + }, + }, + ], + type: "number", }, }, actions: { @@ -77,33 +193,36 @@ export const thingDescription: WoT.ThingDescription = { forms: [ { type: "object", - href: endpointUrl, + href: "/", op: ["invokeaction"], - "opcua:nodeId": { root: "i=84", path: "/Objects/2:DeviceSet/1:CoffeeMachine" }, - "opcua:method": { root: "i=84", path: "/Objects/2:DeviceSet/1:CoffeeMachine/2:MethodSet/9:Start" }, + "opcua:nodeId": { root: "i=84", path: `/Objects/2:DeviceSet/${coffeeMachine}` }, + "opcua:method": { + root: "i=84", + path: `/Objects/2:DeviceSet/${coffeeMachine}/2:MethodSet/17:MakeCoffee`, + }, }, ], input: { type: "object", properties: { - CoffeeType: { - title: "1 for Americano, 2 for Expressp", - type: "number", + RecipeName: { + title: "Americano or Espresso or Mocha (see available Recipes in OPCUA server)", + type: "string", }, }, - required: ["CoffeeType"], + required: ["RecipeName"], }, }, fillTank: { forms: [ { type: "object", - href: endpointUrl, + href: "/", op: ["invokeaction"], - "opcua:nodeId": { root: "i=84", path: "/Objects/2:DeviceSet/1:CoffeeMachine" }, + "opcua:nodeId": { root: "i=84", path: `/Objects/2:DeviceSet/${coffeeMachine}` }, "opcua:method": { root: "i=84", - path: "/Objects/2:DeviceSet/1:CoffeeMachine/2:MethodSet/9:FillTank", + path: `/Objects/2:DeviceSet/${coffeeMachine}/2:MethodSet/17:FillTank`, }, }, ],