@synefex/node-red-sila2 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -0
- package/NOTICE +24 -0
- package/README.md +119 -0
- package/package.json +56 -0
- package/protos/SiLAFramework.proto +132 -0
- package/protos/SiLAService.proto +81 -0
- package/src/call-command/sila-call-command.html +137 -0
- package/src/call-command/sila-call-command.js +110 -0
- package/src/connection/sila-connection.html +101 -0
- package/src/connection/sila-connection.js +148 -0
- package/src/get-property/sila-get-property.html +125 -0
- package/src/get-property/sila-get-property.js +114 -0
- package/src/lib/client.js +216 -0
- package/src/lib/picker/picker.js +174 -0
- package/src/lib/unwrap.js +35 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
module.exports = function (RED) {
|
|
4
|
+
function SilaGetPropertyNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
|
|
8
|
+
node.connection = RED.nodes.getNode(config.connection);
|
|
9
|
+
node.feature = config.feature || "";
|
|
10
|
+
node.featureType = config.featureType || "str";
|
|
11
|
+
node.property = config.property || "";
|
|
12
|
+
node.propertyType = config.propertyType || "str";
|
|
13
|
+
node.outputProperty = config.outputProperty || "payload";
|
|
14
|
+
node.outputPropertyType = config.outputPropertyType || "msg";
|
|
15
|
+
node.unwrapSingle = config.unwrapSingle !== false; // default true
|
|
16
|
+
|
|
17
|
+
if (!node.connection) {
|
|
18
|
+
node.status({ fill: "red", shape: "ring", text: "no connection" });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyConnStatus(status) {
|
|
23
|
+
switch (status.state) {
|
|
24
|
+
case "ok":
|
|
25
|
+
node.status({ fill: "green", shape: "dot", text: status.detail || "ok" });
|
|
26
|
+
break;
|
|
27
|
+
case "ready":
|
|
28
|
+
node.status({ fill: "grey", shape: "ring", text: "ready" });
|
|
29
|
+
break;
|
|
30
|
+
case "error":
|
|
31
|
+
node.status({ fill: "red", shape: "ring", text: status.detail || "error" });
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
node.status({ fill: "grey", shape: "ring", text: status.state || "" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const unsub = node.connection.onStatus(applyConnStatus);
|
|
38
|
+
|
|
39
|
+
node.on("input", async (msg, send, done) => {
|
|
40
|
+
let feature, property;
|
|
41
|
+
try {
|
|
42
|
+
feature = RED.util.evaluateNodeProperty(node.feature, node.featureType, node, msg);
|
|
43
|
+
property = RED.util.evaluateNodeProperty(node.property, node.propertyType, node, msg);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (done) done(err); else node.error(err, msg);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof feature !== "string" || !feature) {
|
|
50
|
+
const err = new Error("SiLA get: feature name is missing");
|
|
51
|
+
if (done) done(err); else node.error(err, msg);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (typeof property !== "string" || !property) {
|
|
55
|
+
const err = new Error("SiLA get: property name is missing");
|
|
56
|
+
if (done) done(err); else node.error(err, msg);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// SiLA convention: property getter is `Get_<PropertyName>`. If the
|
|
61
|
+
// user already prefixed Get_, use it verbatim.
|
|
62
|
+
const methodName = property.startsWith("Get_") ? property : `Get_${property}`;
|
|
63
|
+
|
|
64
|
+
let resp;
|
|
65
|
+
try {
|
|
66
|
+
resp = await node.connection.callUnary(feature, methodName, {});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
node.status({ fill: "red", shape: "ring", text: "get failed" });
|
|
69
|
+
if (done) done(err); else node.error(err, msg);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Typical case: _Responses has a single field holding the property
|
|
74
|
+
// value. After SiLA-unwrap that single field IS the value. Surface
|
|
75
|
+
// it directly so `payload` is the value, not a wrapper. Keep the
|
|
76
|
+
// wrapper for multi-field responses (rare, but FDL allows it).
|
|
77
|
+
let payload = resp;
|
|
78
|
+
if (
|
|
79
|
+
node.unwrapSingle &&
|
|
80
|
+
resp &&
|
|
81
|
+
typeof resp === "object" &&
|
|
82
|
+
!Array.isArray(resp)
|
|
83
|
+
) {
|
|
84
|
+
const keys = Object.keys(resp);
|
|
85
|
+
if (keys.length === 1) payload = resp[keys[0]];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (node.outputPropertyType === "msg") {
|
|
90
|
+
RED.util.setMessageProperty(msg, node.outputProperty, payload, true);
|
|
91
|
+
} else if (
|
|
92
|
+
node.outputPropertyType === "flow" ||
|
|
93
|
+
node.outputPropertyType === "global"
|
|
94
|
+
) {
|
|
95
|
+
node.context()[node.outputPropertyType].set(node.outputProperty, payload);
|
|
96
|
+
} else {
|
|
97
|
+
RED.util.setMessageProperty(msg, node.outputProperty, payload, true);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (done) done(err); else node.error(err, msg);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
send(msg);
|
|
105
|
+
if (done) done();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
node.on("close", () => {
|
|
109
|
+
try { unsub(); } catch (_) { /* ignore */ }
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
RED.nodes.registerType("sila-get-property", SilaGetPropertyNode);
|
|
114
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const grpc = require("@grpc/grpc-js");
|
|
6
|
+
const protoLoader = require("@grpc/proto-loader");
|
|
7
|
+
const { unwrap } = require("./unwrap");
|
|
8
|
+
|
|
9
|
+
// Bundled protos: framework wrapper types + SiLAService (mandatory on every
|
|
10
|
+
// SiLA server) + a sample domain feature (Mettler Toledo AX205 balance) for
|
|
11
|
+
// out-of-the-box demos.
|
|
12
|
+
const BUNDLED_PROTOS_DIR = path.join(__dirname, "..", "..", "protos");
|
|
13
|
+
|
|
14
|
+
function listProtoFiles(dir) {
|
|
15
|
+
if (!dir || !fs.existsSync(dir)) return [];
|
|
16
|
+
return fs
|
|
17
|
+
.readdirSync(dir)
|
|
18
|
+
.filter((n) => n.endsWith(".proto"))
|
|
19
|
+
.map((n) => path.join(dir, n));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load proto files via @grpc/proto-loader, then turn them into a grpc
|
|
24
|
+
* package object via grpc.loadPackageDefinition. The result is a nested
|
|
25
|
+
* namespace whose leaves are either Service constructors (callable as
|
|
26
|
+
* `new Cls(target, creds)`) or message-type definitions.
|
|
27
|
+
*
|
|
28
|
+
* `keepCase: true` keeps SiLA's PascalCase field names verbatim. Without
|
|
29
|
+
* it, proto-loader lowercases the first letter — would break callers that
|
|
30
|
+
* expect e.g. `WeightValue` instead of `weightValue`.
|
|
31
|
+
*/
|
|
32
|
+
function loadProtos({ extraProtoDirs = [], extraProtoFiles = [] } = {}) {
|
|
33
|
+
const includeDirs = [BUNDLED_PROTOS_DIR, ...extraProtoDirs.filter(Boolean)];
|
|
34
|
+
const files = [
|
|
35
|
+
...listProtoFiles(BUNDLED_PROTOS_DIR),
|
|
36
|
+
...extraProtoDirs.flatMap(listProtoFiles),
|
|
37
|
+
...extraProtoFiles.filter(Boolean),
|
|
38
|
+
];
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
throw new Error("SiLA: no .proto files found (check extraProtoDirs config)");
|
|
41
|
+
}
|
|
42
|
+
const packageDef = protoLoader.loadSync(files, {
|
|
43
|
+
includeDirs,
|
|
44
|
+
keepCase: true,
|
|
45
|
+
longs: String,
|
|
46
|
+
enums: String,
|
|
47
|
+
defaults: true,
|
|
48
|
+
oneofs: true,
|
|
49
|
+
});
|
|
50
|
+
return grpc.loadPackageDefinition(packageDef);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walk the loaded grpc package recursively. Build:
|
|
55
|
+
* - byFull: map of dotted fully-qualified service name → ServiceClass
|
|
56
|
+
* - byShort: map of lowercase short name (last segment) → [{fq, ServiceClass}]
|
|
57
|
+
* Short-name collisions surface as a clear ambiguity error at lookup time.
|
|
58
|
+
*/
|
|
59
|
+
function indexServices(grpcPkg) {
|
|
60
|
+
const byShort = new Map();
|
|
61
|
+
const byFull = new Map();
|
|
62
|
+
|
|
63
|
+
function visit(node, prefix) {
|
|
64
|
+
if (!node || typeof node !== "object") return;
|
|
65
|
+
for (const key of Object.keys(node)) {
|
|
66
|
+
const child = node[key];
|
|
67
|
+
const fq = prefix ? `${prefix}.${key}` : key;
|
|
68
|
+
// grpc-js Service classes are functions decorated with .service +
|
|
69
|
+
// .serviceName by loadPackageDefinition. Message types are plain
|
|
70
|
+
// objects with codec methods — we don't index those.
|
|
71
|
+
if (typeof child === "function" && child.service && child.serviceName) {
|
|
72
|
+
byFull.set(fq, child);
|
|
73
|
+
const shortKey = key.toLowerCase();
|
|
74
|
+
const list = byShort.get(shortKey) || [];
|
|
75
|
+
list.push({ fq, ServiceClass: child, shortName: key });
|
|
76
|
+
byShort.set(shortKey, list);
|
|
77
|
+
} else if (child && typeof child === "object") {
|
|
78
|
+
visit(child, fq);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
visit(grpcPkg, "");
|
|
83
|
+
return { byShort, byFull };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a SiLA client wrapper. Owns one grpc client instance per service
|
|
88
|
+
* (lazy), reusing the underlying channel via the same address+credentials.
|
|
89
|
+
*/
|
|
90
|
+
function createSilaClient({
|
|
91
|
+
target,
|
|
92
|
+
credentials,
|
|
93
|
+
extraProtoDirs = [],
|
|
94
|
+
extraProtoFiles = [],
|
|
95
|
+
}) {
|
|
96
|
+
const grpcPkg = loadProtos({ extraProtoDirs, extraProtoFiles });
|
|
97
|
+
const { byShort, byFull } = indexServices(grpcPkg);
|
|
98
|
+
const clientByService = new Map(); // fq → grpc client instance
|
|
99
|
+
|
|
100
|
+
function resolveService(featureName) {
|
|
101
|
+
if (!featureName) throw new Error("SiLA: feature name is required");
|
|
102
|
+
if (featureName.includes(".") && byFull.has(featureName)) {
|
|
103
|
+
return { fq: featureName, ServiceClass: byFull.get(featureName) };
|
|
104
|
+
}
|
|
105
|
+
const entries = byShort.get(featureName.toLowerCase()) || [];
|
|
106
|
+
if (entries.length === 0) {
|
|
107
|
+
const known = [...byFull.keys()].join(", ") || "(none)";
|
|
108
|
+
throw new Error(
|
|
109
|
+
`SiLA: feature "${featureName}" not found. Loaded services: ${known}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (entries.length > 1) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`SiLA: short name "${featureName}" is ambiguous: ` +
|
|
115
|
+
entries.map((e) => e.fq).join(", "),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { fq: entries[0].fq, ServiceClass: entries[0].ServiceClass };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getClient(fq, ServiceClass) {
|
|
122
|
+
let c = clientByService.get(fq);
|
|
123
|
+
if (!c) {
|
|
124
|
+
c = new ServiceClass(target, credentials);
|
|
125
|
+
clientByService.set(fq, c);
|
|
126
|
+
}
|
|
127
|
+
return c;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Call an unobservable command (or property getter — same wire mechanism).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} featureName short name (case-insensitive, e.g.
|
|
134
|
+
* "MettlerToledoAX205Controller") OR fully
|
|
135
|
+
* qualified (e.g.
|
|
136
|
+
* "sila2.master.thesis.weighing.mettlertoledoax205controller.v1.MettlerToledoAX205Controller")
|
|
137
|
+
* @param {string} methodName verbatim method on the service
|
|
138
|
+
* @param {object} params fields keyed by name as in the .proto
|
|
139
|
+
* @param {grpc.Metadata} [metadata]
|
|
140
|
+
* @returns {Promise<object>} decoded + SiLA-unwrapped response
|
|
141
|
+
*/
|
|
142
|
+
async function callUnary(featureName, methodName, params, metadata) {
|
|
143
|
+
const { fq, ServiceClass } = resolveService(featureName);
|
|
144
|
+
const client = getClient(fq, ServiceClass);
|
|
145
|
+
if (typeof client[methodName] !== "function") {
|
|
146
|
+
// Methods come from the .service descriptor; list them for the error.
|
|
147
|
+
const methods = Object.keys(ServiceClass.service || {});
|
|
148
|
+
throw new Error(
|
|
149
|
+
`SiLA: method "${methodName}" not found on feature "${featureName}". ` +
|
|
150
|
+
`Available: ${methods.join(", ")}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resp = await new Promise((resolve, reject) => {
|
|
155
|
+
const cb = (err, value) => (err ? reject(err) : resolve(value));
|
|
156
|
+
if (metadata) client[methodName](params || {}, metadata, cb);
|
|
157
|
+
else client[methodName](params || {}, cb);
|
|
158
|
+
});
|
|
159
|
+
return unwrap(resp);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* List loaded services (the union of bundled protos + any extras the
|
|
164
|
+
* connection was configured with). Returns fully-qualified names.
|
|
165
|
+
*/
|
|
166
|
+
function listServices() {
|
|
167
|
+
return [...byFull.keys()];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Editor picker support: list every loaded service with its short name,
|
|
172
|
+
* fully-qualified name, and methods classified as "command" vs
|
|
173
|
+
* "property" by the SiLA naming convention (`Get_<X>` → property
|
|
174
|
+
* getter for property `<X>`; everything else → command).
|
|
175
|
+
*
|
|
176
|
+
* The classification is name-only — we don't inspect request-message
|
|
177
|
+
* fields. SiLA convention is strict enough that this is reliable for
|
|
178
|
+
* standard features; vendor-defined commands that happen to start
|
|
179
|
+
* with `Get_` and take no parameters would be misclassified, but
|
|
180
|
+
* that's a SiLA naming antipattern.
|
|
181
|
+
*/
|
|
182
|
+
function listFeaturesForPicker() {
|
|
183
|
+
const features = [];
|
|
184
|
+
for (const [fq, ServiceClass] of byFull) {
|
|
185
|
+
const shortName = fq.split(".").pop();
|
|
186
|
+
const svcDesc = ServiceClass.service || {};
|
|
187
|
+
const methods = Object.keys(svcDesc)
|
|
188
|
+
.map((name) => {
|
|
189
|
+
if (name.startsWith("Get_")) {
|
|
190
|
+
return { name, kind: "property", propertyName: name.slice(4) };
|
|
191
|
+
}
|
|
192
|
+
return { name, kind: "command" };
|
|
193
|
+
})
|
|
194
|
+
.sort((a, b) => {
|
|
195
|
+
// Sort by display name (propertyName for properties, name for commands)
|
|
196
|
+
const an = a.kind === "property" ? a.propertyName : a.name;
|
|
197
|
+
const bn = b.kind === "property" ? b.propertyName : b.name;
|
|
198
|
+
return an.localeCompare(bn);
|
|
199
|
+
});
|
|
200
|
+
features.push({ shortName, fqName: fq, methods });
|
|
201
|
+
}
|
|
202
|
+
features.sort((a, b) => a.shortName.localeCompare(b.shortName));
|
|
203
|
+
return { features };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function close() {
|
|
207
|
+
for (const c of clientByService.values()) {
|
|
208
|
+
try { c.close(); } catch (_) { /* ignore */ }
|
|
209
|
+
}
|
|
210
|
+
clientByService.clear();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { callUnary, listServices, listFeaturesForPicker, close };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { createSilaClient, BUNDLED_PROTOS_DIR };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Browser-side SiLA picker widget. Loaded once via the sila-connection
|
|
2
|
+
// editor HTML; exposes window.SilaPicker.attach({ ... }) for action nodes.
|
|
3
|
+
//
|
|
4
|
+
// Two modes (set via opts.kind):
|
|
5
|
+
// "command" — pick a SiLA command (for sila-call-command)
|
|
6
|
+
// "property" — pick a SiLA property (for sila-get-property)
|
|
7
|
+
//
|
|
8
|
+
// Cascade: connection -> feature -> command/property leaf
|
|
9
|
+
//
|
|
10
|
+
// Backed by GET /sila/conn/:id/features which returns the introspected
|
|
11
|
+
// list of loaded features and their methods classified by name pattern.
|
|
12
|
+
(function () {
|
|
13
|
+
if (window.SilaPicker) return; // idempotent
|
|
14
|
+
|
|
15
|
+
const $ = window.jQuery || window.$;
|
|
16
|
+
|
|
17
|
+
// jQuery rather than fetch so the editor's adminAuth bearer token is
|
|
18
|
+
// attached automatically by Node-RED's beforeSend hook.
|
|
19
|
+
function fetchJSON(url) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
$.ajax({ url: url, dataType: "json", method: "GET" })
|
|
22
|
+
.done(resolve)
|
|
23
|
+
.fail(function (xhr) {
|
|
24
|
+
let body = {};
|
|
25
|
+
try { body = JSON.parse(xhr.responseText || "{}"); } catch (_) { /* ignore */ }
|
|
26
|
+
const err = new Error(body.error || ("HTTP " + xhr.status));
|
|
27
|
+
err.status = xhr.status;
|
|
28
|
+
err.body = body;
|
|
29
|
+
reject(err);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fillSelect($sel, items, opts) {
|
|
35
|
+
const { valueKey, labelFn, placeholder } = opts;
|
|
36
|
+
$sel.empty();
|
|
37
|
+
if (placeholder) {
|
|
38
|
+
$sel.append($("<option>").val("").text(placeholder));
|
|
39
|
+
}
|
|
40
|
+
for (const it of items || []) {
|
|
41
|
+
const v = valueKey ? it[valueKey] : "";
|
|
42
|
+
$sel.append($("<option>").val(v).text(labelFn(it)));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function attach(opts) {
|
|
47
|
+
const kind = opts.kind || "command"; // "command" | "property"
|
|
48
|
+
const $form = $(opts.formSelector || "form.dialog-form, .red-ui-editor-form");
|
|
49
|
+
const $conn = $("#" + (opts.connectionFieldId || "node-input-connection"));
|
|
50
|
+
const $feature = $("#" + (opts.featureFieldId || "node-input-feature"));
|
|
51
|
+
const $methodOut = $("#" + opts.methodFieldId);
|
|
52
|
+
const $container = $methodOut.closest(".form-row");
|
|
53
|
+
|
|
54
|
+
// Idempotent: rebuild on each attach call (Node-RED reuses the
|
|
55
|
+
// dialog DOM across edits of different nodes).
|
|
56
|
+
$form.find(".sila-picker-rows").remove();
|
|
57
|
+
|
|
58
|
+
const leafLabel = (kind === "command") ? "Command" : "Property";
|
|
59
|
+
const leafIcon = (kind === "command") ? "fa-bolt" : "fa-eye";
|
|
60
|
+
|
|
61
|
+
const $rows = $(`
|
|
62
|
+
<div class="sila-picker-rows" style="border:1px dashed #d8d8d8;padding:10px;margin-bottom:8px;border-radius:4px;background:#fafafa">
|
|
63
|
+
<div class="sila-pick-header" style="font-size:11px;color:#888;margin-bottom:6px;">
|
|
64
|
+
<i class="fa fa-sitemap"></i> Browse loaded features (or type below for manual entry)
|
|
65
|
+
</div>
|
|
66
|
+
<div class="sila-pick-info" style="display:none;padding:8px;font-size:12px;border-radius:3px;margin-bottom:4px;"></div>
|
|
67
|
+
<div class="form-row" style="margin-bottom:6px">
|
|
68
|
+
<label><i class="fa fa-cube"></i> Feature</label>
|
|
69
|
+
<select class="sila-pick-feature" style="width:70%"></select>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="form-row" style="margin-bottom:6px">
|
|
72
|
+
<label><i class="fa ${leafIcon}"></i> ${leafLabel}</label>
|
|
73
|
+
<select class="sila-pick-method" style="width:70%"></select>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
`);
|
|
77
|
+
$container.before($rows);
|
|
78
|
+
|
|
79
|
+
const $featureSel = $rows.find(".sila-pick-feature");
|
|
80
|
+
const $methodSel = $rows.find(".sila-pick-method");
|
|
81
|
+
const $info = $rows.find(".sila-pick-info");
|
|
82
|
+
|
|
83
|
+
let cachedFeatures = [];
|
|
84
|
+
|
|
85
|
+
function showInfo(msg, color) {
|
|
86
|
+
$info.text(msg)
|
|
87
|
+
.css("background", color || "#fff3cd")
|
|
88
|
+
.css("color", color === "#f8d7da" ? "#721c24" : "#856404")
|
|
89
|
+
.show();
|
|
90
|
+
}
|
|
91
|
+
function clearInfo() { $info.hide(); }
|
|
92
|
+
|
|
93
|
+
async function loadFeatures() {
|
|
94
|
+
const connId = $conn.val();
|
|
95
|
+
if (!connId) {
|
|
96
|
+
showInfo("Pick a connection first.");
|
|
97
|
+
fillSelect($featureSel, [], { placeholder: "—" });
|
|
98
|
+
fillSelect($methodSel, [], { placeholder: "—" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const data = await fetchJSON("sila/conn/" + encodeURIComponent(connId) + "/features");
|
|
103
|
+
cachedFeatures = data.features || [];
|
|
104
|
+
clearInfo();
|
|
105
|
+
// Show only features that have at least one method of our kind.
|
|
106
|
+
const visibleFeatures = cachedFeatures.filter(
|
|
107
|
+
(f) => f.methods.some((m) => m.kind === kind),
|
|
108
|
+
);
|
|
109
|
+
fillSelect($featureSel, visibleFeatures, {
|
|
110
|
+
valueKey: "shortName",
|
|
111
|
+
labelFn: (f) => f.shortName,
|
|
112
|
+
placeholder: visibleFeatures.length
|
|
113
|
+
? "— pick a feature —"
|
|
114
|
+
: `(no features with ${kind}s loaded)`,
|
|
115
|
+
});
|
|
116
|
+
// Preselect from existing manual value if it matches a loaded feature.
|
|
117
|
+
const cur = $feature.val();
|
|
118
|
+
if (cur && visibleFeatures.some((f) => f.shortName === cur)) {
|
|
119
|
+
$featureSel.val(cur);
|
|
120
|
+
}
|
|
121
|
+
renderMethods();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err.status === 404) {
|
|
124
|
+
showInfo("Connection not deployed yet — deploy first to enable picker.");
|
|
125
|
+
} else {
|
|
126
|
+
showInfo("Picker error: " + err.message, "#f8d7da");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderMethods() {
|
|
132
|
+
const featName = $featureSel.val();
|
|
133
|
+
const feat = cachedFeatures.find((f) => f.shortName === featName);
|
|
134
|
+
if (!feat) {
|
|
135
|
+
fillSelect($methodSel, [], { placeholder: "—" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const filtered = feat.methods.filter((m) => m.kind === kind);
|
|
139
|
+
fillSelect($methodSel, filtered, {
|
|
140
|
+
valueKey: kind === "command" ? "name" : "propertyName",
|
|
141
|
+
labelFn: (m) => kind === "command" ? m.name : m.propertyName,
|
|
142
|
+
placeholder: filtered.length ? `— pick a ${kind} —` : `(no ${kind}s on this feature)`,
|
|
143
|
+
});
|
|
144
|
+
// Preselect from existing manual value if it matches.
|
|
145
|
+
const curMethod = $methodOut.val();
|
|
146
|
+
if (curMethod) {
|
|
147
|
+
const opt = filtered.find((m) =>
|
|
148
|
+
(kind === "command" ? m.name : m.propertyName) === curMethod,
|
|
149
|
+
);
|
|
150
|
+
if (opt) $methodSel.val(curMethod);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Wire interactions:
|
|
155
|
+
// - Feature change → write to manual feature field, refresh method list,
|
|
156
|
+
// clear method (user must re-pick to avoid silently keeping a stale value).
|
|
157
|
+
// - Method change → write to manual method field.
|
|
158
|
+
// - Connection change → refresh everything from scratch.
|
|
159
|
+
$featureSel.on("change", function () {
|
|
160
|
+
const v = $featureSel.val();
|
|
161
|
+
$feature.val(v);
|
|
162
|
+
$methodOut.val("");
|
|
163
|
+
renderMethods();
|
|
164
|
+
});
|
|
165
|
+
$methodSel.on("change", function () {
|
|
166
|
+
$methodOut.val($methodSel.val());
|
|
167
|
+
});
|
|
168
|
+
$conn.on("change", loadFeatures);
|
|
169
|
+
|
|
170
|
+
loadFeatures();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
window.SilaPicker = { attach };
|
|
174
|
+
})();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unwrap SiLA basic-type wrappers from a decoded gRPC response object.
|
|
5
|
+
*
|
|
6
|
+
* Every SiLA basic type (String, Real, Integer, Boolean, ...) is encoded
|
|
7
|
+
* as a single-field protobuf message named `value`. Recursively peel
|
|
8
|
+
* those wrappers off so callers see native values:
|
|
9
|
+
*
|
|
10
|
+
* { Status: { value: "S S" }, WeightValue: { value: 82.68 } }
|
|
11
|
+
* → { Status: "S S", WeightValue: 82.68 }
|
|
12
|
+
*
|
|
13
|
+
* Lists pass through with element-wise unwrap. Structures (multi-field
|
|
14
|
+
* messages) are recursed into. Buffers are passed through verbatim
|
|
15
|
+
* (SiLA's Binary type may be raw bytes or a transfer UUID — handled at
|
|
16
|
+
* the call site, not here).
|
|
17
|
+
*/
|
|
18
|
+
function unwrap(obj) {
|
|
19
|
+
if (obj == null || typeof obj !== "object") return obj;
|
|
20
|
+
if (Buffer.isBuffer(obj) || obj instanceof Uint8Array) return obj;
|
|
21
|
+
if (Array.isArray(obj)) return obj.map(unwrap);
|
|
22
|
+
|
|
23
|
+
const keys = Object.keys(obj);
|
|
24
|
+
// Single-field wrapper named "value" → SiLA basic type. Unwrap.
|
|
25
|
+
if (keys.length === 1 && keys[0] === "value") {
|
|
26
|
+
return unwrap(obj.value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Otherwise it's a Structure (or _Responses message): recurse field-wise.
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const k of keys) out[k] = unwrap(obj[k]);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { unwrap };
|