From 689c3a222c980ba19b8aeafde2641ab2e645b1d5 Mon Sep 17 00:00:00 2001 From: D J Newbould Date: Wed, 19 Feb 2025 14:55:56 +0000 Subject: [PATCH 01/32] Add internal Traefik redirect The added coredns config map redirects all external URLs back to the internal Traefik service. This fixes issues caused by ACS instances that don't have external DNS setup correctly. --- deploy/values.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deploy/values.yaml b/deploy/values.yaml index 1bf328271..cf05d43d2 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -51,6 +51,10 @@ coredns: traefikRedirect: enabled: false +k3s: + traefikRedirect: + enabled: true + identity: # -- Whether or not to enable the Identity component enabled: true From 1a03e8e0769e7b1735f343959446da2472572f36 Mon Sep 17 00:00:00 2001 From: D J Newbould Date: Fri, 21 Feb 2025 08:48:58 +0000 Subject: [PATCH 02/32] Add comment to values option --- deploy/values.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deploy/values.yaml b/deploy/values.yaml index cf05d43d2..fe20b0319 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -52,8 +52,11 @@ coredns: enabled: false k3s: + # -- An option to enable redirecting all external URLs back to the + # internal Traefik service. ACS deployments without external DNS + # should enable this. traefikRedirect: - enabled: true + enabled: false identity: # -- Whether or not to enable the Identity component From 14dc4a8c5933b45d8f3e97a5149bf48863733ba4 Mon Sep 17 00:00:00 2001 From: D J Newbould Date: Thu, 27 Feb 2025 14:27:58 +0000 Subject: [PATCH 03/32] Add PR requested changes --- deploy/values.yaml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/deploy/values.yaml b/deploy/values.yaml index fe20b0319..648dd53b7 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -51,10 +51,13 @@ coredns: traefikRedirect: enabled: false -k3s: - # -- An option to enable redirecting all external URLs back to the - # internal Traefik service. ACS deployments without external DNS - # should enable this. +coreDNS: + # -- An option to enable the redirecting of external URL's back + # to the internal Traefik service. This is done through a config map + # override to coredns in the kube-system namespace. The override rewrites + # queries matching .*. to acs-traefik..svc.cluster.local, + # ensuring correct internal service resolution. ACS deployments without + # external DNS should enable this. traefikRedirect: enabled: false From 4acd5078a2e4b5b1197175700ca0ca05ca4eeb77 Mon Sep 17 00:00:00 2001 From: D J Newbould Date: Thu, 27 Feb 2025 14:29:33 +0000 Subject: [PATCH 04/32] Fix coredns casing --- deploy/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/values.yaml b/deploy/values.yaml index 648dd53b7..991f196d4 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -51,7 +51,7 @@ coredns: traefikRedirect: enabled: false -coreDNS: +coredns: # -- An option to enable the redirecting of external URL's back # to the internal Traefik service. This is done through a config map # override to coredns in the kube-system namespace. The override rewrites From bc36e2d62c0e4067f12f1066f329a71a54ff6189 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Mon, 4 Nov 2024 15:53:52 +0000 Subject: [PATCH 05/32] Scaffold OpenProtocol driver This is not yet implemented as we need more time to better understand the async driver interface. Also the OpenProtocol driver seems to be abandoned so not sure how much use it will be. --- .github/workflows/publish.yml | 1 + edge-openprotocol/.gitignore | 1 + edge-openprotocol/Dockerfile | 33 ++ edge-openprotocol/Makefile | 8 + edge-openprotocol/bin/driver.js | 12 + edge-openprotocol/lib/openprotocol.js | 44 ++ edge-openprotocol/package-lock.json | 824 ++++++++++++++++++++++++++ edge-openprotocol/package.json | 19 + 8 files changed, 942 insertions(+) create mode 100644 edge-openprotocol/.gitignore create mode 100644 edge-openprotocol/Dockerfile create mode 100644 edge-openprotocol/Makefile create mode 100644 edge-openprotocol/bin/driver.js create mode 100644 edge-openprotocol/lib/openprotocol.js create mode 100644 edge-openprotocol/package-lock.json create mode 100644 edge-openprotocol/package.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0f3043871..fc31ae17f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -285,6 +285,7 @@ jobs: - edge-bacnet - edge-modbus - edge-test + - edge-openprotocol - edge-tplink-smartplug - edge-ads - edge-rtde diff --git a/edge-openprotocol/.gitignore b/edge-openprotocol/.gitignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/edge-openprotocol/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/edge-openprotocol/Dockerfile b/edge-openprotocol/Dockerfile new file mode 100644 index 000000000..7163f9155 --- /dev/null +++ b/edge-openprotocol/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS build +ARG acs_npm=NO +ARG revision=unknown + +USER root +RUN <<'SHELL' + install -d -o node -g node /home/node/app +SHELL +WORKDIR /home/node/app +USER node +COPY package*.json ./ +RUN <<'SHELL' + touch /home/node/.npmrc + if [ "${acs_npm}" != NO ] + then + npm config set @amrc-factoryplus:registry "${acs_npm}" + fi + + npm install --save=false +SHELL +COPY --chown=node . . +RUN <<'SHELL' + echo "export const GIT_VERSION=\"$revision\";" > ./lib/git-version.js +SHELL + +FROM node:22-alpine AS run +# Copy across from the build container. +WORKDIR /home/node/app +COPY --from=build --chown=root:root /home/node/app ./ +USER node +CMD node bin/driver.js diff --git a/edge-openprotocol/Makefile b/edge-openprotocol/Makefile new file mode 100644 index 000000000..8cba8ec52 --- /dev/null +++ b/edge-openprotocol/Makefile @@ -0,0 +1,8 @@ +top=.. +include ${top}/mk/acs.init.mk + +repo?=edge-openprotocol + +eslint= bin lib + +include ${mk}/acs.js.mk diff --git a/edge-openprotocol/bin/driver.js b/edge-openprotocol/bin/driver.js new file mode 100644 index 000000000..a4cfb1e64 --- /dev/null +++ b/edge-openprotocol/bin/driver.js @@ -0,0 +1,12 @@ +/* + * Copyright (c) University of Sheffield AMRC 2024. + */ + +import { AsyncDriver } from '@amrc-factoryplus/edge-driver' +import { OpenProtocolHandler } from '../lib/openprotocol.js' + +const drv = new AsyncDriver({ + env: process.env, + handler: OpenProtocolHandler, +}); +drv.run(); diff --git a/edge-openprotocol/lib/openprotocol.js b/edge-openprotocol/lib/openprotocol.js new file mode 100644 index 000000000..a3a18a1e5 --- /dev/null +++ b/edge-openprotocol/lib/openprotocol.js @@ -0,0 +1,44 @@ +/* + * Copyright (c) University of Sheffield AMRC 2024. + */ + +import { createClient } from 'node-open-protocol' + +/* This is the handler interface */ +class OpenProtocolHandler { + + /* The constructor is private */ + constructor (driver, conf) { + this.driver = driver + this.conf = conf + this.log = driver.debug.bound('open-protocol') + } + + /* This is called to create a handler */ + static create (driver, conf) {} + + /* Methods required by Driver */ + connect () { + + const host = this.conf.host + const port = this.conf.port ?? 4545 + + createClient(port, host, null, (data) => { + const deviceInfo = JSON.stringify(data) + this.log(`OpenProtocol connection established with ${host}:${port}. Gave MID 0002 of ${deviceInfo}`) + return 'UP' + }) + } + + parseAddr (addr) { + + } + + subscribe (specs) { + + } + + async close () { + + } +} \ No newline at end of file diff --git a/edge-openprotocol/package-lock.json b/edge-openprotocol/package-lock.json new file mode 100644 index 000000000..05bba394e --- /dev/null +++ b/edge-openprotocol/package-lock.json @@ -0,0 +1,824 @@ +{ + "name": "edge-openprotocol", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "edge-openprotocol", + "version": "0.0.1", + "license": "ISC", + "dependencies": { + "@amrc-factoryplus/edge-driver": "^0.0.4", + "async": "^3.2.5", + "mqtt": "^5.7.2", + "node-open-protocol": "^1.1.1" + } + }, + "node_modules/@amrc-factoryplus/edge-driver": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@amrc-factoryplus/edge-driver/-/edge-driver-0.0.4.tgz", + "integrity": "sha512-8dvmbySbiLjWiVD1fSatGFgiRG7kwiWhtRbt172hlbZimWXNsKS0ws2eUjpH2WIbGLFSA7C1W8KbEWE2QySxQw==", + "dependencies": { + "async": "^3.2.5", + "mqtt": "^5.8.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", + "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.15.tgz", + "integrity": "sha512-oAZ3kw+kJFkEqyh7xORZOku1YAKvsFTogRY8kVl4vHpEKiDkfnSA/My8haRE7fvmix5Zyy+1pwzOi7yycGLBJw==", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@types/ws": { + "version": "8.5.11", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.11.tgz", + "integrity": "sha512-4+q7P5h3SpJxaBft0Dzpbr6lmMaqh0Jr2tbhJZ/luAwvD7ohSCniYkwz/pLxuT2h0EOa6QADgJj1Ko+TzRfZ+w==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bl": { + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.0.14.tgz", + "integrity": "sha512-TJfbvGdL7KFGxTsEbsED7avqpFdY56q9IW0/aiytyheJzxST/+Io6cx/4Qx0K2/u0BPRDs65mjaQzYvMZeNocQ==", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.8.0.tgz", + "integrity": "sha512-/+H04mv6goy6K5gHMNH3uS0icBzXapS+4uUf4yZyQWXi72APPZNb81bQhvkm99poEQettXVT8XETB0mPxl5Wjg==", + "dependencies": { + "@types/readable-stream": "^4.0.5", + "@types/ws": "^8.5.9", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.3.4", + "help-me": "^5.0.0", + "lru-cache": "^10.0.1", + "minimist": "^1.2.8", + "mqtt": "^5.2.0", + "mqtt-packet": "^9.0.0", + "number-allocator": "^1.0.14", + "readable-stream": "^4.4.2", + "reinterval": "^1.1.0", + "rfdc": "^1.3.0", + "split2": "^4.2.0", + "worker-timers": "^7.1.4", + "ws": "^8.17.1" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.0.tgz", + "integrity": "sha512-8v+HkX+fwbodsWAZIZTI074XIoxVBOmPeggQuDFCGg1SqNcC+uoRMWu7J6QlJPqIUIJXmjNYYHxBBLr1Y/Df4w==", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/node-open-protocol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-open-protocol/-/node-open-protocol-1.1.1.tgz", + "integrity": "sha512-w6lzLucJqo/GEfNl4QGLPcDHb+R46AB3TSMRjwer9IZZQ2jDFClG1SLnP2LlkhgZyWOZXGY2drqBF8PoLbDPnQ==" + }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + }, + "dependencies": { + "@amrc-factoryplus/edge-driver": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@amrc-factoryplus/edge-driver/-/edge-driver-0.0.4.tgz", + "integrity": "sha512-8dvmbySbiLjWiVD1fSatGFgiRG7kwiWhtRbt172hlbZimWXNsKS0ws2eUjpH2WIbGLFSA7C1W8KbEWE2QySxQw==", + "requires": { + "async": "^3.2.5", + "mqtt": "^5.8.0" + } + }, + "@babel/runtime": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", + "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "requires": { + "undici-types": "~5.26.4" + } + }, + "@types/readable-stream": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.15.tgz", + "integrity": "sha512-oAZ3kw+kJFkEqyh7xORZOku1YAKvsFTogRY8kVl4vHpEKiDkfnSA/My8haRE7fvmix5Zyy+1pwzOi7yycGLBJw==", + "requires": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "@types/ws": { + "version": "8.5.11", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.11.tgz", + "integrity": "sha512-4+q7P5h3SpJxaBft0Dzpbr6lmMaqh0Jr2tbhJZ/luAwvD7ohSCniYkwz/pLxuT2h0EOa6QADgJj1Ko+TzRfZ+w==", + "requires": { + "@types/node": "*" + } + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bl": { + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.0.14.tgz", + "integrity": "sha512-TJfbvGdL7KFGxTsEbsED7avqpFdY56q9IW0/aiytyheJzxST/+Io6cx/4Qx0K2/u0BPRDs65mjaQzYvMZeNocQ==", + "requires": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==" + }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "debug": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "requires": { + "ms": "2.1.2" + } + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "requires": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + } + }, + "help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==" + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "mqtt": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.8.0.tgz", + "integrity": "sha512-/+H04mv6goy6K5gHMNH3uS0icBzXapS+4uUf4yZyQWXi72APPZNb81bQhvkm99poEQettXVT8XETB0mPxl5Wjg==", + "requires": { + "@types/readable-stream": "^4.0.5", + "@types/ws": "^8.5.9", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.3.4", + "help-me": "^5.0.0", + "lru-cache": "^10.0.1", + "minimist": "^1.2.8", + "mqtt": "^5.2.0", + "mqtt-packet": "^9.0.0", + "number-allocator": "^1.0.14", + "readable-stream": "^4.4.2", + "reinterval": "^1.1.0", + "rfdc": "^1.3.0", + "split2": "^4.2.0", + "worker-timers": "^7.1.4", + "ws": "^8.17.1" + } + }, + "mqtt-packet": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.0.tgz", + "integrity": "sha512-8v+HkX+fwbodsWAZIZTI074XIoxVBOmPeggQuDFCGg1SqNcC+uoRMWu7J6QlJPqIUIJXmjNYYHxBBLr1Y/Df4w==", + "requires": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node-open-protocol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-open-protocol/-/node-open-protocol-1.1.1.tgz", + "integrity": "sha512-w6lzLucJqo/GEfNl4QGLPcDHb+R46AB3TSMRjwer9IZZQ2jDFClG1SLnP2LlkhgZyWOZXGY2drqBF8PoLbDPnQ==" + }, + "number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "requires": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==" + }, + "rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "requires": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "requires": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "requires": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, + "ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "requires": {} + } + } +} diff --git a/edge-openprotocol/package.json b/edge-openprotocol/package.json new file mode 100644 index 000000000..dc4975dc9 --- /dev/null +++ b/edge-openprotocol/package.json @@ -0,0 +1,19 @@ +{ + "name": "edge-openprotocol", + "version": "0.0.1", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@amrc-factoryplus/edge-driver": "^0.0.4", + "async": "^3.2.5", + "mqtt": "^5.7.2", + "node-open-protocol": "^1.1.1" + } +} From 42a414f9c6794dac60f7b42dc3aa927880c0d009 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 28 Nov 2024 16:00:33 +0000 Subject: [PATCH 06/32] Proof of concept OpenProtocol driver --- edge-openprotocol/.env.example | 4 ++ edge-openprotocol/bin/driver.js | 9 +-- edge-openprotocol/lib/openprotocol.js | 89 ++++++++++++++++++++++++--- edge-openprotocol/package-lock.json | 35 ++++++++--- edge-openprotocol/package.json | 3 +- 5 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 edge-openprotocol/.env.example diff --git a/edge-openprotocol/.env.example b/edge-openprotocol/.env.example new file mode 100644 index 000000000..081bb1b56 --- /dev/null +++ b/edge-openprotocol/.env.example @@ -0,0 +1,4 @@ +EDGE_MQTT=mqtt://127.0.0.1 +EDGE_PASSWORD= +EDGE_USERNAME= +VERBOSE=ALL,!token,!service,!sparkplug \ No newline at end of file diff --git a/edge-openprotocol/bin/driver.js b/edge-openprotocol/bin/driver.js index a4cfb1e64..7a823803e 100644 --- a/edge-openprotocol/bin/driver.js +++ b/edge-openprotocol/bin/driver.js @@ -2,11 +2,12 @@ * Copyright (c) University of Sheffield AMRC 2024. */ +import 'dotenv/config' import { AsyncDriver } from '@amrc-factoryplus/edge-driver' import { OpenProtocolHandler } from '../lib/openprotocol.js' const drv = new AsyncDriver({ - env: process.env, - handler: OpenProtocolHandler, -}); -drv.run(); + env: process.env, + handler: OpenProtocolHandler, +}) +drv.run() diff --git a/edge-openprotocol/lib/openprotocol.js b/edge-openprotocol/lib/openprotocol.js index a3a18a1e5..ce501efbf 100644 --- a/edge-openprotocol/lib/openprotocol.js +++ b/edge-openprotocol/lib/openprotocol.js @@ -2,10 +2,13 @@ * Copyright (c) University of Sheffield AMRC 2024. */ -import { createClient } from 'node-open-protocol' +import pkg from 'node-open-protocol-desoutter' +import { BufferX } from '@amrc-factoryplus/edge-driver' + +const { createClient } = pkg /* This is the handler interface */ -class OpenProtocolHandler { +export class OpenProtocolHandler { /* The constructor is private */ constructor (driver, conf) { @@ -14,8 +17,35 @@ class OpenProtocolHandler { this.log = driver.debug.bound('open-protocol') } + static validAddresses = new Set([ + 'psetSelected', + 'lockAtBatchDoneUpload', + 'jobInfo', + 'vin', + 'lastTightening', + 'alarm', + 'alarmStatus', + 'alarmAcknowledged', + 'multiSpindleStatus', + 'multiSpindleResult', + 'lastPowerMACSTighteningResultStationData', + 'jobLineControl', + 'multipleIdentifierResultParts', + 'statusExternallyMonitoredInputs', + 'relayFunction', + 'digitalInputFunction', + 'userData', + 'selectorSocketInfo', + 'toolTagID', + 'automaticManualMode', + 'openProtocolCommandsDisabled', + 'motorTuningResultData', + ]) + /* This is called to create a handler */ - static create (driver, conf) {} + static create (driver, conf) { + return new OpenProtocolHandler(driver, conf) + } /* Methods required by Driver */ connect () { @@ -23,18 +53,57 @@ class OpenProtocolHandler { const host = this.conf.host const port = this.conf.port ?? 4545 - createClient(port, host, null, (data) => { - const deviceInfo = JSON.stringify(data) - this.log(`OpenProtocol connection established with ${host}:${port}. Gave MID 0002 of ${deviceInfo}`) - return 'UP' + this.log('Connecting to Open Protocol', host, port) + + this.client = createClient(port, host, null, (data) => { + this.log('Connected to Open Protocol') + this.driver.connUp() + + // This is debug + // this.client.command('enableTool') + // this.client.command('selectPset', { payload: { parameterSetID: 11 } }) + }) - } - parseAddr (addr) { + this.client.on('error', (err) => { + this.log('Error connecting to Open Protocol') + this.driver.connFailed() + }) + OpenProtocolHandler.validAddresses.forEach(address => { + this.client.on(address, (midData) => { + this.log(midData) + this.driver.data(address, BufferX.fromJSON(midData)) + }) + }) } - subscribe (specs) { + async subscribe (specs) { + + this.log(specs) + + const promises = specs.map(addressFromManager => new Promise((resolve, reject) => { + + this.client.subscribe(addressFromManager, (err, data) => { + if (err) { + this.log('Error subscribing to Open Protocol', err) + reject(err) + } + else { + this.log(`Subscribed to ${addressFromManager}`) + resolve() + } + }) + })) + + try { + await Promise.all(promises) + return true + } + catch (err) { + this.log('Error subscribing to Open Protocol', err) + return false + } } diff --git a/edge-openprotocol/package-lock.json b/edge-openprotocol/package-lock.json index 05bba394e..7e0dad2d0 100644 --- a/edge-openprotocol/package-lock.json +++ b/edge-openprotocol/package-lock.json @@ -11,8 +11,9 @@ "dependencies": { "@amrc-factoryplus/edge-driver": "^0.0.4", "async": "^3.2.5", + "dotenv": "^16.4.5", "mqtt": "^5.7.2", - "node-open-protocol": "^1.1.1" + "node-open-protocol-desoutter": "^2.0.1" } }, "node_modules/@amrc-factoryplus/edge-driver": { @@ -182,6 +183,17 @@ } } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -308,10 +320,10 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/node-open-protocol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/node-open-protocol/-/node-open-protocol-1.1.1.tgz", - "integrity": "sha512-w6lzLucJqo/GEfNl4QGLPcDHb+R46AB3TSMRjwer9IZZQ2jDFClG1SLnP2LlkhgZyWOZXGY2drqBF8PoLbDPnQ==" + "node_modules/node-open-protocol-desoutter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-open-protocol-desoutter/-/node-open-protocol-desoutter-2.0.1.tgz", + "integrity": "sha512-yHEe0KFWIelt840RBvd/raxferogijm1tzO73/brFX+3lUaF1nNSmm6E9KKS5vFGIyiC73RSwiWhKTIK20jovQ==" }, "node_modules/number-allocator": { "version": "1.0.14", @@ -599,6 +611,11 @@ "ms": "2.1.2" } }, + "dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" + }, "event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -687,10 +704,10 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node-open-protocol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/node-open-protocol/-/node-open-protocol-1.1.1.tgz", - "integrity": "sha512-w6lzLucJqo/GEfNl4QGLPcDHb+R46AB3TSMRjwer9IZZQ2jDFClG1SLnP2LlkhgZyWOZXGY2drqBF8PoLbDPnQ==" + "node-open-protocol-desoutter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-open-protocol-desoutter/-/node-open-protocol-desoutter-2.0.1.tgz", + "integrity": "sha512-yHEe0KFWIelt840RBvd/raxferogijm1tzO73/brFX+3lUaF1nNSmm6E9KKS5vFGIyiC73RSwiWhKTIK20jovQ==" }, "number-allocator": { "version": "1.0.14", diff --git a/edge-openprotocol/package.json b/edge-openprotocol/package.json index dc4975dc9..21ff176a7 100644 --- a/edge-openprotocol/package.json +++ b/edge-openprotocol/package.json @@ -13,7 +13,8 @@ "dependencies": { "@amrc-factoryplus/edge-driver": "^0.0.4", "async": "^3.2.5", + "dotenv": "^16.4.5", "mqtt": "^5.7.2", - "node-open-protocol": "^1.1.1" + "node-open-protocol-desoutter": "^2.0.1" } } From a832fc3b58d9a9067ab72334aad1c2a3453fce7d Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Tue, 18 Feb 2025 15:04:58 +0000 Subject: [PATCH 07/32] wip: Stub out plan for NCMD for driver --- edge-openprotocol/lib/openprotocol.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/edge-openprotocol/lib/openprotocol.js b/edge-openprotocol/lib/openprotocol.js index ce501efbf..9db334d92 100644 --- a/edge-openprotocol/lib/openprotocol.js +++ b/edge-openprotocol/lib/openprotocol.js @@ -107,6 +107,23 @@ export class OpenProtocolHandler { } + async cmd (address, data) { + + // address: selectPset + // BufferX.to(data): { pSet: 14 } + + // BEFORE CLOSE - + // - Make library subscribe to cmds + // - Implement BufferX.from... + + this.log('CMD', address, data) + switch (address) { + case 'selectPset': + const parameterSetID = BufferX.fromUInt8(data); + this.client.command('selectPset', { payload: { parameterSetID } }) + } + } + async close () { } From 71931091b5981437e5d988ecfc49900f5ac345f2 Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Tue, 4 Mar 2025 16:05:39 +0000 Subject: [PATCH 08/32] Update OpenProtocol driver to handle commands --- edge-openprotocol/lib/openprotocol.js | 43 +++++++++++++++++---------- edge-openprotocol/package.json | 2 +- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/edge-openprotocol/lib/openprotocol.js b/edge-openprotocol/lib/openprotocol.js index 9db334d92..a9486c811 100644 --- a/edge-openprotocol/lib/openprotocol.js +++ b/edge-openprotocol/lib/openprotocol.js @@ -1,5 +1,5 @@ /* - * Copyright (c) University of Sheffield AMRC 2024. + * Copyright (c) University of Sheffield AMRC 2025. */ import pkg from 'node-open-protocol-desoutter' @@ -84,6 +84,13 @@ export class OpenProtocolHandler { const promises = specs.map(addressFromManager => new Promise((resolve, reject) => { + // If the address is not in the valid addresses, skip it + if (!OpenProtocolHandler.validAddresses.has(addressFromManager)) { + this.log('Invalid address', addressFromManager) + resolve() + return + } + this.client.subscribe(addressFromManager, (err, data) => { if (err) { this.log('Error subscribing to Open Protocol', err) @@ -107,20 +114,26 @@ export class OpenProtocolHandler { } - async cmd (address, data) { - - // address: selectPset - // BufferX.to(data): { pSet: 14 } - - // BEFORE CLOSE - - // - Make library subscribe to cmds - // - Implement BufferX.from... - - this.log('CMD', address, data) - switch (address) { - case 'selectPset': - const parameterSetID = BufferX.fromUInt8(data); - this.client.command('selectPset', { payload: { parameterSetID } }) + async cmd (commandName, data) { + + switch (commandName) { + case 'selectPset': + const parameterSetID = BufferX.toUInt8(data) + this.client.command('selectPset', { payload: { parameterSetID } }) + break + case 'enableTool': + const value = BufferX.toUInt8(data) + if (value === 1) { + this.client.command('enableTool') + } + else { + this.client.command('disableTool') + } + break + default: + // Throw error + this.log(`The ${commandName} command is not supported in this version of the driver`) + break } } diff --git a/edge-openprotocol/package.json b/edge-openprotocol/package.json index 21ff176a7..3290170a2 100644 --- a/edge-openprotocol/package.json +++ b/edge-openprotocol/package.json @@ -11,7 +11,7 @@ "author": "", "license": "ISC", "dependencies": { - "@amrc-factoryplus/edge-driver": "^0.0.4", + "@amrc-factoryplus/edge-driver": "^0.1.0", "async": "^3.2.5", "dotenv": "^16.4.5", "mqtt": "^5.7.2", From dc21e161669d2bac0e2e83dfd0c2a5c9e377933b Mon Sep 17 00:00:00 2001 From: Alex Godbehere Date: Thu, 12 Mar 2026 16:10:45 +0000 Subject: [PATCH 09/32] Add implementation plan for CSV origin map import/export 5 tasks: install papaparse, create composable (generation + parsing), add UI buttons and dialog to OriginMapEditor, manual testing. Co-Authored-By: Claude Opus 4.6 --- ...2026-03-12-csv-origin-map-import-export.md | 656 ++++++++++++++++++ ...csv-origin-map-import-export.md.tasks.json | 11 + 2 files changed, 667 insertions(+) create mode 100644 docs/plans/2026-03-12-csv-origin-map-import-export.md create mode 100644 docs/plans/2026-03-12-csv-origin-map-import-export.md.tasks.json diff --git a/docs/plans/2026-03-12-csv-origin-map-import-export.md b/docs/plans/2026-03-12-csv-origin-map-import-export.md new file mode 100644 index 000000000..7c3b03f93 --- /dev/null +++ b/docs/plans/2026-03-12-csv-origin-map-import-export.md @@ -0,0 +1,656 @@ +# CSV Origin Map Import/Export — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Add Download CSV and Upload CSV buttons to the OriginMapEditor sidebar so that origin map metric values can be exported to and imported from CSV files. + +**Architecture:** A `useOriginMapCsv.js` composable handles all CSV generation, parsing, and model application. The upload confirmation dialog and both buttons live inline in `OriginMapEditor.vue`, following the existing `newObjectContext` dialog pattern. PapaParse handles CSV serialisation. + +**Tech Stack:** Vue 3, PapaParse, Reka UI Dialog, vue-sonner toasts + +--- + +### Task 1: Install PapaParse dependency + +**Files:** +- Modify: `acs-admin/package.json` + +**Step 1: Install papaparse** + +Run: +```bash +cd acs-admin && npm install papaparse +``` + +**Step 2: Verify installation** + +Run: +```bash +cd acs-admin && node -e "require('papaparse'); console.log('ok')" +``` +Expected: `ok` + +**Step 3: Commit** + +```bash +git add acs-admin/package.json acs-admin/package-lock.json +git commit -m "feat: add papaparse dependency for CSV origin map import/export" +``` + +--- + +### Task 2: Create the useOriginMapCsv composable — tree walker and CSV generation + +**Files:** +- Create: `acs-admin/src/composables/useOriginMapCsv.js` + +**Context:** + +The schema tree has three node types determined by checking properties on each `schema.properties[key]`: +- `'allOf' in prop` → metric leaf +- `'properties' in prop` → object (recurse) +- `'patternProperties' in prop` → schema array (iterate model instances, recurse with pattern template as schema) + +Reserved keys to skip: `Schema_UUID`, `Instance_UUID`, `patternProperties`, `$meta`, `required`. + +After `updateDynamicSchemaObjects()` runs in OriginMapEditor (which happens on mount), schema array instances are injected into `schema.properties[instanceName]` as full schema objects. So the walker can treat them as regular objects. + +Each metric leaf's schema is at `schema.properties[key].allOf[0].properties` merged with `schema.properties[key].allOf[1].properties`. The `Sparkplug_Type.enum` array gives allowed types. + +Driver presentation is at `driverPresentation.address.title` (default: `'Device Address'`), `driverPresentation.path.title` (default: `'Metric Path'`), and `driverPresentation.path.hidden` (omit Path column when `true`). + +**Step 1: Create the composable with `collectMetricRows` and `generateCsv`** + +```javascript +import Papa from 'papaparse' + +const RESERVED_KEYS = ['Schema_UUID', 'Instance_UUID', 'patternProperties', '$meta', 'required'] + +/** + * Determine the type of a schema property node. + * Mirrors SchemaGroup.vue's type() method. + */ +function nodeType (prop) { + if ('properties' in prop) return 'object' + if ('allOf' in prop) return 'metric' + if ('patternProperties' in prop) return 'schemaArray' + return 'unknown' +} + +/** + * Walk the schema/model tree and collect one row per metric leaf. + * Returns an array of { tagPath, metricSchema, modelValues }. + */ +function collectMetricRows (schema, model, pathSegments = []) { + const rows = [] + if (!schema?.properties) return rows + + const keys = Object.keys(schema.properties).filter(k => !RESERVED_KEYS.includes(k)) + + for (const key of keys) { + const prop = schema.properties[key] + const currentPath = [...pathSegments, key] + const type = nodeType(prop) + + if (type === 'metric') { + // Merge allOf schemas to get full metric schema + const metricSchema = { ...prop.allOf[0]?.properties, ...prop.allOf[1]?.properties } + // Walk model to find current values + const modelValues = currentPath.reduce((obj, seg) => obj?.[seg], model) || {} + rows.push({ tagPath: currentPath.join('/'), metricSchema, modelValues }) + } else if (type === 'object') { + rows.push(...collectMetricRows(prop, model, currentPath)) + } else if (type === 'schemaArray') { + // Schema array instances exist in the model — iterate them + // After updateDynamicSchemaObjects, instances are also in schema.properties + const modelNode = currentPath.reduce((obj, seg) => obj?.[seg], model) + if (modelNode && typeof modelNode === 'object') { + const instanceKeys = Object.keys(modelNode).filter(k => !RESERVED_KEYS.includes(k)) + for (const instanceKey of instanceKeys) { + // The instance schema was injected into schema.properties by updateDynamicSchemaObjects + const instanceSchema = prop.properties?.[instanceKey] + if (instanceSchema && nodeType(instanceSchema) === 'object') { + rows.push(...collectMetricRows(instanceSchema, model, [...currentPath, instanceKey])) + } + } + } + } + } + + return rows +} + +/** + * Build column headers based on driver presentation. + */ +function buildHeaders (driverPresentation) { + const hidePathField = driverPresentation?.path?.hidden === true + const addressLabel = driverPresentation?.address?.title || 'Device Address' + const pathLabel = driverPresentation?.path?.title || 'Metric Path' + + const headers = ['Tag_Path', 'Sparkplug_Type', 'Allowed_Sparkplug_Types', addressLabel] + if (!hidePathField) headers.push(pathLabel) + headers.push('Value', 'Eng_Unit', 'Eng_Low', 'Eng_High', 'Deadband', 'Record_To_Historian') + + return { headers, hidePathField, addressLabel, pathLabel } +} + +/** + * Map a metric row to a CSV data row (array of values). + */ +function metricToRow (row, headerInfo) { + const { hidePathField } = headerInfo + const { tagPath, metricSchema, modelValues } = row + + const allowedTypes = metricSchema?.Sparkplug_Type?.enum?.filter(t => t !== '').join('|') || '' + + const values = [ + tagPath, + modelValues.Sparkplug_Type ?? '', + allowedTypes, + modelValues.Address ?? '', + ] + if (!hidePathField) values.push(modelValues.Path ?? '') + values.push( + modelValues.Value ?? '', + modelValues.Eng_Unit ?? '', + modelValues.Eng_Low ?? '', + modelValues.Eng_High ?? '', + modelValues.Deadband ?? '', + modelValues.Record_To_Historian ?? '', + ) + + return values +} + +/** + * Build help section rows that go below the --- delimiter. + */ +function buildHelpRows (headerInfo, driverPresentation) { + const { headers, hidePathField } = headerInfo + + const pad = (text) => { + const row = new Array(headers.length).fill('') + row[0] = text + return row + } + + const rows = [] + // Delimiter row + const delimiter = new Array(headers.length).fill('') + delimiter[0] = '---' + rows.push(delimiter) + + rows.push(pad('')) + rows.push(pad('COLUMN DESCRIPTIONS')) + rows.push(pad('Tag_Path: Metric location in the schema hierarchy. Used as the key when importing — do not modify.')) + rows.push(pad('Sparkplug_Type: The Sparkplug B data type for this metric (e.g., Double, Float, Int32).')) + rows.push(pad('Allowed_Sparkplug_Types: Valid types for this metric. Informational only — ignored on import.')) + + const addressDesc = driverPresentation?.address?.description || 'The device address for this metric.' + rows.push(pad(`${headerInfo.addressLabel}: ${addressDesc}`)) + + if (!hidePathField) { + const pathDesc = driverPresentation?.path?.description || 'The metric path on the device.' + rows.push(pad(`${headerInfo.pathLabel}: ${pathDesc}`)) + } + + rows.push(pad('Value: A static value for this metric (use instead of Address/Path for constants).')) + rows.push(pad('Eng_Unit: Engineering unit (e.g., kWh, °C, RPM).')) + rows.push(pad('Eng_Low: Lower bound of the engineering range.')) + rows.push(pad('Eng_High: Upper bound of the engineering range.')) + rows.push(pad('Deadband: Minimum change threshold before reporting a new value.')) + rows.push(pad('Record_To_Historian: Whether to record this metric to the historian (true/false).')) + rows.push(pad('')) + rows.push(pad('Everything below the --- line is ignored on import.')) + + return rows +} + +/** + * Generate a CSV string from the origin map model and schema. + * + * @param {Object} model - The origin map model + * @param {Object} schema - The hydrated schema (after updateDynamicSchemaObjects) + * @param {Object} driverPresentation - The driver presentation config (driverInfo.presentation) + * @returns {string} CSV string + */ +export function generateCsv (model, schema, driverPresentation) { + const headerInfo = buildHeaders(driverPresentation) + const metricRows = collectMetricRows(schema, model) + + const dataRows = metricRows.map(row => metricToRow(row, headerInfo)) + const helpRows = buildHelpRows(headerInfo, driverPresentation) + + const allRows = [headerInfo.headers, ...dataRows, ...helpRows] + + return Papa.unparse(allRows) +} + +/** + * Trigger a browser download of the CSV string. + * + * @param {string} csvString - The CSV content + * @param {string} filename - The download filename + */ +export function downloadCsv (csvString, filename) { + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} +``` + +**Step 2: Verify file was created correctly** + +Run: +```bash +head -5 acs-admin/src/composables/useOriginMapCsv.js +``` +Expected: First 5 lines of the file including the `import Papa` line. + +**Step 3: Commit** + +```bash +git add acs-admin/src/composables/useOriginMapCsv.js +git commit -m "feat: add CSV generation composable for origin map export" +``` + +--- + +### Task 3: Add CSV parsing and model application to the composable + +**Files:** +- Modify: `acs-admin/src/composables/useOriginMapCsv.js` + +**Context:** + +Parsing must: (1) stop at `---` delimiter row, (2) reverse-map driver-specific column headers back to canonical field names, (3) return structured rows. Model application must: walk the model tree by tag path segments, overwrite found metrics, count skipped rows. + +The canonical field names used in the model are: `Address`, `Path`, `Value`, `Sparkplug_Type`, `Eng_Unit`, `Eng_Low`, `Eng_High`, `Deadband`, `Record_To_Historian`. The CSV headers may differ for Address and Path (driver-specific labels). + +**Step 1: Add `parseCsv` and `applyCsvToModel` functions** + +Append to `acs-admin/src/composables/useOriginMapCsv.js`, before the closing (after the `downloadCsv` function): + +```javascript +/** + * Build a reverse mapping from driver-specific column headers to canonical field names. + */ +function buildHeaderMap (headers, driverPresentation) { + const addressLabel = driverPresentation?.address?.title || 'Device Address' + const pathLabel = driverPresentation?.path?.title || 'Metric Path' + + const map = {} + for (const header of headers) { + if (header === 'Tag_Path') map[header] = 'Tag_Path' + else if (header === 'Sparkplug_Type') map[header] = 'Sparkplug_Type' + else if (header === 'Allowed_Sparkplug_Types') map[header] = 'Allowed_Sparkplug_Types' + else if (header === addressLabel) map[header] = 'Address' + else if (header === pathLabel) map[header] = 'Path' + else if (header === 'Value') map[header] = 'Value' + else if (header === 'Eng_Unit') map[header] = 'Eng_Unit' + else if (header === 'Eng_Low') map[header] = 'Eng_Low' + else if (header === 'Eng_High') map[header] = 'Eng_High' + else if (header === 'Deadband') map[header] = 'Deadband' + else if (header === 'Record_To_Historian') map[header] = 'Record_To_Historian' + } + return map +} + +/** + * Parse a CSV string, stopping at the --- delimiter row. + * Returns an array of { tagPath, fields } objects. + * + * @param {string} csvString - Raw CSV text + * @param {Object} driverPresentation - Driver presentation config + * @returns {{ rows: Array<{ tagPath: string, fields: Object }> }} + */ +export function parseCsv (csvString, driverPresentation) { + const parsed = Papa.parse(csvString, { header: true, skipEmptyLines: true }) + + if (!parsed.data || parsed.data.length === 0) { + return { rows: [] } + } + + const headerMap = buildHeaderMap(parsed.meta.fields, driverPresentation) + + const rows = [] + for (const rawRow of parsed.data) { + // Check for --- delimiter — stop processing + const firstValue = Object.values(rawRow)[0] + if (firstValue && firstValue.toString().startsWith('---')) break + + const tagPath = rawRow['Tag_Path'] + if (!tagPath || tagPath.trim() === '') continue + + // Map CSV columns to canonical field names, skipping informational columns + const fields = {} + for (const [csvHeader, canonical] of Object.entries(headerMap)) { + if (canonical === 'Tag_Path' || canonical === 'Allowed_Sparkplug_Types') continue + const value = rawRow[csvHeader] + if (value !== undefined) { + fields[canonical] = value + } + } + + rows.push({ tagPath: tagPath.trim(), fields }) + } + + return { rows } +} + +/** + * Apply parsed CSV rows to the origin map model. + * Modifies the model in place. + * + * @param {Array<{ tagPath: string, fields: Object }>} rows - Parsed CSV rows + * @param {Object} model - The origin map model (mutated in place) + * @returns {{ applied: number, skipped: number }} + */ +export function applyCsvToModel (rows, model) { + let applied = 0 + let skipped = 0 + + for (const { tagPath, fields } of rows) { + const segments = tagPath.split('/') + + // Walk the model to find the metric's parent, then access the metric + let current = model + let found = true + for (const segment of segments) { + if (current && typeof current === 'object' && segment in current) { + current = current[segment] + } else { + found = false + break + } + } + + if (!found || !current || typeof current !== 'object') { + skipped++ + continue + } + + // Apply field values to the metric + for (const [field, value] of Object.entries(fields)) { + const trimmed = typeof value === 'string' ? value.trim() : value + + if (trimmed === '' || trimmed === null || trimmed === undefined) { + // Empty value — clear the field + delete current[field] + } else if (field === 'Record_To_Historian') { + // Boolean field + current[field] = trimmed.toLowerCase() === 'true' + } else if (field === 'Eng_Low' || field === 'Eng_High' || field === 'Deadband') { + // Numeric fields — store as number if valid + const num = Number(trimmed) + current[field] = isNaN(num) ? trimmed : num + } else { + current[field] = trimmed + } + } + + applied++ + } + + return { applied, skipped } +} +``` + +**Step 2: Commit** + +```bash +git add acs-admin/src/composables/useOriginMapCsv.js +git commit -m "feat: add CSV parsing and model application to origin map composable" +``` + +--- + +### Task 4: Add Download CSV and Upload CSV buttons to OriginMapEditor sidebar + +**Files:** +- Modify: `acs-admin/src/components/EdgeManager/Devices/OriginMapEditor/OriginMapEditor.vue` + +**Context:** + +The Save Changes button is at lines 47-56 inside the ``. The new buttons go immediately after it, before `` (line 57). Use `variant="outline"` for secondary appearance. The Download button calls `handleDownloadCsv()`. The Upload button opens a hidden file input. + +Driver presentation is available via `this.conn` (useConnectionStore) — the connection UUID is at `this.device.deviceInformation?.connection`. Look up the connection from `this.conn.data`, then access `configuration.driver.presentation`. + +**Step 1: Add imports** + +At the top of the `