sen-ether-client 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/API.md +239 -0
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/bin/node-sen-probe.js +426 -0
- package/bin/node-sen-scan.js +77 -0
- package/index.js +75 -0
- package/lib/bus.js +740 -0
- package/lib/client.js +634 -0
- package/lib/codec.js +501 -0
- package/lib/crc32.js +26 -0
- package/lib/discovery.js +439 -0
- package/lib/hash32.js +40 -0
- package/lib/protocol/generated.js +157 -0
- package/lib/sen.js +1346 -0
- package/lib/values.js +421 -0
- package/package.json +31 -0
- package/resources/protocol/ether/discovery.stl +19 -0
- package/resources/protocol/ether/runtime.stl +40 -0
- package/resources/protocol/kernel/basic_types.stl +274 -0
- package/resources/protocol/kernel/bus_protocol.stl +198 -0
- package/resources/protocol/kernel/type_specs.stl +554 -0
- package/resources/protocol/protocol.json +15 -0
- package/scripts/generate-protocol.mjs +111 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { EtherClient } from '../lib/client.js';
|
|
4
|
+
import { scan, scanTcpDiscoveryHub } from '../lib/discovery.js';
|
|
5
|
+
import { decodePropertyValues } from '../lib/values.js';
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const options = {
|
|
9
|
+
timeout: 3000,
|
|
10
|
+
listen: 10000,
|
|
11
|
+
bus: 'scenario.control'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
15
|
+
const arg = argv[i];
|
|
16
|
+
if (arg === '--timeout') {
|
|
17
|
+
options.timeout = Number(argv[++i]);
|
|
18
|
+
} else if (arg === '--listen') {
|
|
19
|
+
options.listen = Number(argv[++i]);
|
|
20
|
+
} else if (arg === '--group') {
|
|
21
|
+
options.group = argv[++i];
|
|
22
|
+
} else if (arg === '--port') {
|
|
23
|
+
options.port = Number(argv[++i]);
|
|
24
|
+
} else if (arg === '--interface') {
|
|
25
|
+
options.interfaceAddress = argv[++i];
|
|
26
|
+
} else if (arg === '--tcp-hub') {
|
|
27
|
+
options.tcpHub = argv[++i];
|
|
28
|
+
} else if (arg === '--session') {
|
|
29
|
+
options.session = argv[++i];
|
|
30
|
+
} else if (arg === '--app') {
|
|
31
|
+
options.app = argv[++i];
|
|
32
|
+
} else if (arg === '--bus') {
|
|
33
|
+
options.bus = argv[++i];
|
|
34
|
+
} else if (arg === '--query') {
|
|
35
|
+
options.query = argv[++i];
|
|
36
|
+
} else if (arg === '--force-bus') {
|
|
37
|
+
options.forceBus = true;
|
|
38
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
39
|
+
options.help = true;
|
|
40
|
+
} else {
|
|
41
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return options;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function printHelp() {
|
|
49
|
+
console.log(`Usage: sen-ether-probe [options]
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--timeout <ms> Discovery timeout. Default: 3000
|
|
53
|
+
--listen <ms> Time to listen after starting the interest. Default: 10000
|
|
54
|
+
--group <address> Discovery multicast group. Default: 239.255.0.44
|
|
55
|
+
--port <port> Discovery multicast port. Default: 60543
|
|
56
|
+
--interface <addr> Local interface address or interface name for multicast membership
|
|
57
|
+
--tcp-hub <host:port>
|
|
58
|
+
Use SEN TcpDiscoveryHub instead of multicast discovery
|
|
59
|
+
--session <name> SEN session name filter
|
|
60
|
+
--app <name> SEN appName substring filter
|
|
61
|
+
--bus <name> Bus to join. Default: scenario.control
|
|
62
|
+
--query <query> Interest query. Default: SELECT * FROM <bus>
|
|
63
|
+
--force-bus Join even if the remote process has not announced the bus
|
|
64
|
+
-h, --help Show this help
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
sen-ether-probe --bus scenario.control
|
|
68
|
+
sen-ether-probe --tcp-hub 127.0.0.1:64222 --bus scenario.control
|
|
69
|
+
sen-ether-probe --bus world1.environment --query "SELECT * FROM world1.environment"
|
|
70
|
+
|
|
71
|
+
Environment:
|
|
72
|
+
SEN_ETHER_DISCOVERY_PORT Default multicast discovery port
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseHostPort(value) {
|
|
77
|
+
const text = String(value || '').trim();
|
|
78
|
+
const idx = text.lastIndexOf(':');
|
|
79
|
+
if (idx <= 0) {
|
|
80
|
+
throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
|
|
81
|
+
}
|
|
82
|
+
const host = text.slice(0, idx);
|
|
83
|
+
const port = Number(text.slice(idx + 1));
|
|
84
|
+
if (!host || !Number.isInteger(port) || port <= 0) {
|
|
85
|
+
throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
|
|
86
|
+
}
|
|
87
|
+
return { host, port };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function etherBusName(sessionName, bus) {
|
|
91
|
+
const session = String(sessionName || '').trim();
|
|
92
|
+
const text = String(bus || '').trim();
|
|
93
|
+
const prefix = `${session}.`;
|
|
94
|
+
return session && text.startsWith(prefix) ? text.slice(prefix.length) : text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function queryBusName(sessionName, bus) {
|
|
98
|
+
const session = String(sessionName || '').trim();
|
|
99
|
+
const text = String(bus || '').trim();
|
|
100
|
+
return text.includes('.') || !session ? text : `${session}.${text}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findTarget(processes, options) {
|
|
104
|
+
let candidates = processes;
|
|
105
|
+
if (options.session) {
|
|
106
|
+
candidates = candidates.filter(item => item.session?.name === options.session);
|
|
107
|
+
}
|
|
108
|
+
if (options.app) {
|
|
109
|
+
const app = String(options.app).toLowerCase();
|
|
110
|
+
candidates = candidates.filter(item => String(item.process?.appName || '').toLowerCase().includes(app));
|
|
111
|
+
}
|
|
112
|
+
if (!candidates.length) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return candidates[0];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function wait(ms) {
|
|
119
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function waitForEvent(emitter, event, timeoutMs) {
|
|
123
|
+
const timeout = new Promise((_, reject) => {
|
|
124
|
+
setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeoutMs);
|
|
125
|
+
});
|
|
126
|
+
return await Promise.race([once(emitter, event), timeout]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function waitForRemoteBus(emitter, remoteBuses, busName, timeoutMs) {
|
|
130
|
+
if (remoteBuses.has(busName)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
const timeout = setTimeout(() => {
|
|
136
|
+
emitter.off('busJoined', onBusJoined);
|
|
137
|
+
const announced = [...remoteBuses].sort().join(', ') || '<none>';
|
|
138
|
+
reject(new Error(`remote process did not announce bus "${busName}" within ${timeoutMs}ms; announced: ${announced}`));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
|
|
141
|
+
const onBusJoined = value => {
|
|
142
|
+
if (value.busName !== busName) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
clearTimeout(timeout);
|
|
146
|
+
emitter.off('busJoined', onBusJoined);
|
|
147
|
+
resolve();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
emitter.on('busJoined', onBusJoined);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function printProcess(item, prefix = '-') {
|
|
155
|
+
console.log(`${prefix} session=${item.session?.name || '<empty>'} app=${item.process?.appName || '<unknown>'} host=${item.process?.hostName || '<unknown>'}`);
|
|
156
|
+
for (const endpoint of item.endpoints ?? []) {
|
|
157
|
+
console.log(` endpoint=${endpoint.host}:${endpoint.port}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function printTypeSpec(response) {
|
|
162
|
+
const spec = response.spec;
|
|
163
|
+
const data = spec.data;
|
|
164
|
+
console.log(`[types] ${response.type} hash=${response.classHash ?? '<non-class>'} type=${spec.qualifiedName} kind=${data.type}`);
|
|
165
|
+
|
|
166
|
+
if (data.type !== 'ClassTypeSpec') {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const classSpec = data.value;
|
|
171
|
+
console.log(` properties=${classSpec.properties.length} methods=${classSpec.methods.length} events=${classSpec.events.length} parents=${classSpec.parents.length}`);
|
|
172
|
+
for (const property of classSpec.properties) {
|
|
173
|
+
console.log(` property ${property.name}: ${property.type} ${property.category} ${property.transportMode}${property.checkedSet ? ' checkedSet' : ''}`);
|
|
174
|
+
}
|
|
175
|
+
for (const method of classSpec.methods) {
|
|
176
|
+
const args = method.args.map(arg => `${arg.name}: ${arg.type}`).join(', ');
|
|
177
|
+
console.log(` method ${method.name}(${args}) -> ${method.returnType || 'void'} ${method.constness} ${method.transportMode} ${method.propertyRelation}`);
|
|
178
|
+
}
|
|
179
|
+
for (const event of classSpec.events) {
|
|
180
|
+
const args = event.args.map(arg => `${arg.name}: ${arg.type}`).join(', ');
|
|
181
|
+
console.log(` event ${event.name}(${args}) ${event.transportMode}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatValue(value) {
|
|
186
|
+
if (typeof value === 'bigint') {
|
|
187
|
+
return `${value}n`;
|
|
188
|
+
}
|
|
189
|
+
if (value && typeof value === 'object') {
|
|
190
|
+
return JSON.stringify(value);
|
|
191
|
+
}
|
|
192
|
+
return String(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function classSpecForObject(object, typeRegistry) {
|
|
196
|
+
return object ? typeRegistry.get(object.className) : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function applyPropertyValues(objectState, propertyValues) {
|
|
200
|
+
for (const property of propertyValues) {
|
|
201
|
+
if (property.decoded && property.name) {
|
|
202
|
+
objectState.snapshot[property.name] = property.value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatPropertyValues(propertyValues) {
|
|
208
|
+
return propertyValues
|
|
209
|
+
.map(update => {
|
|
210
|
+
if (!update.name) {
|
|
211
|
+
return `0x${update.id.toString(16).padStart(8, '0')}:${update.size}`;
|
|
212
|
+
}
|
|
213
|
+
if (!update.decoded) {
|
|
214
|
+
return `${update.name}<${update.type}>:${update.size}`;
|
|
215
|
+
}
|
|
216
|
+
return `${update.name}=${formatValue(update.value)}`;
|
|
217
|
+
})
|
|
218
|
+
.join(', ');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const options = parseArgs(process.argv.slice(2));
|
|
223
|
+
if (options.help) {
|
|
224
|
+
printHelp();
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log(`[scan] timeout=${options.timeout}ms${options.tcpHub ? ` tcpHub=${options.tcpHub}` : ''}`);
|
|
229
|
+
const processes = options.tcpHub
|
|
230
|
+
? await scanTcpDiscoveryHub({ ...parseHostPort(options.tcpHub), timeout: options.timeout })
|
|
231
|
+
: await scan(options);
|
|
232
|
+
if (!processes.length) {
|
|
233
|
+
throw new Error('no SEN ether processes discovered');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.log(`[scan] discovered ${processes.length} process(es)`);
|
|
237
|
+
for (const item of processes) {
|
|
238
|
+
printProcess(item);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const target = findTarget(processes, options);
|
|
242
|
+
if (!target) {
|
|
243
|
+
throw new Error('no discovered process matches the requested filters');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log('[target]');
|
|
247
|
+
printProcess(target, '*');
|
|
248
|
+
|
|
249
|
+
const bus = etherBusName(target.session.name, options.bus);
|
|
250
|
+
const query = options.query ?? `SELECT * FROM ${queryBusName(target.session.name, options.bus)}`;
|
|
251
|
+
if (bus !== options.bus) {
|
|
252
|
+
console.log(`[bus] normalized ${options.bus} -> ${bus} for ether session=${target.session.name}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const client = new EtherClient({
|
|
256
|
+
sessionName: target.session.name,
|
|
257
|
+
appName: 'sen-ether-probe'
|
|
258
|
+
});
|
|
259
|
+
const remoteBuses = new Set();
|
|
260
|
+
const requestedTypeHashes = new Set();
|
|
261
|
+
const objectsById = new Map();
|
|
262
|
+
const typeRegistry = new Map();
|
|
263
|
+
const stateRequestedObjectIds = new Set();
|
|
264
|
+
|
|
265
|
+
function requestReadyObjectStates() {
|
|
266
|
+
const requestsByInterest = new Map();
|
|
267
|
+
for (const object of objectsById.values()) {
|
|
268
|
+
if (stateRequestedObjectIds.has(object.id)) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!classSpecForObject(object, typeRegistry)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
stateRequestedObjectIds.add(object.id);
|
|
275
|
+
const ids = requestsByInterest.get(object.interestId) ?? [];
|
|
276
|
+
ids.push(object.id);
|
|
277
|
+
requestsByInterest.set(object.interestId, ids);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (requestsByInterest.size) {
|
|
281
|
+
client.requestObjectStates(bus, [...requestsByInterest].map(([interestId, objectIds]) => ({ interestId, objectIds })));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
client.on('remoteProcess', hello => {
|
|
286
|
+
console.log(`[ether] remote hello app=${hello.info.appName} session=${hello.info.sessionName}`);
|
|
287
|
+
});
|
|
288
|
+
client.on('ready', () => {
|
|
289
|
+
console.log('[ether] ready');
|
|
290
|
+
});
|
|
291
|
+
client.on('busJoined', value => {
|
|
292
|
+
remoteBuses.add(value.busName);
|
|
293
|
+
console.log(`[ether] remote bus joined name=${value.busName} id=${value.busId} participant=${value.participantId}`);
|
|
294
|
+
});
|
|
295
|
+
client.on('busParticipantReady', value => {
|
|
296
|
+
console.log(`[bus] participant ready bus=${value.busName} local=${value.participantId} remote=${value.remoteParticipantId}`);
|
|
297
|
+
});
|
|
298
|
+
client.on('interestStarted', value => {
|
|
299
|
+
console.log(`[bus] interest started id=${value.id} query=${value.query}`);
|
|
300
|
+
});
|
|
301
|
+
client.on('typesInfoRequested', value => {
|
|
302
|
+
console.log(`[types] requested ${value.requests.length} type spec(s): ${value.requests.join(', ')}`);
|
|
303
|
+
});
|
|
304
|
+
client.on('typesInfoResponse', event => {
|
|
305
|
+
console.log(`[types] response owner=${event.ownerId} count=${event.types.length}`);
|
|
306
|
+
const dependentTypeHashes = new Set();
|
|
307
|
+
for (const type of event.types) {
|
|
308
|
+
typeRegistry.set(type.spec.qualifiedName, type.spec);
|
|
309
|
+
for (const hash of type.dependentTypes ?? []) {
|
|
310
|
+
if (!requestedTypeHashes.has(hash)) {
|
|
311
|
+
requestedTypeHashes.add(hash);
|
|
312
|
+
dependentTypeHashes.add(hash);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
printTypeSpec(type);
|
|
316
|
+
}
|
|
317
|
+
if (dependentTypeHashes.size) {
|
|
318
|
+
client.requestTypes(bus, dependentTypeHashes);
|
|
319
|
+
}
|
|
320
|
+
requestReadyObjectStates();
|
|
321
|
+
});
|
|
322
|
+
client.on('typesInfoRejection', event => {
|
|
323
|
+
console.warn(`[types] rejection owner=${event.ownerId}: ${event.rejections.join('; ')}`);
|
|
324
|
+
});
|
|
325
|
+
client.on('objectsPublished', event => {
|
|
326
|
+
let total = 0;
|
|
327
|
+
const newTypeHashes = new Set();
|
|
328
|
+
for (const discovery of event.discoveries ?? []) {
|
|
329
|
+
total += discovery.objects?.length ?? 0;
|
|
330
|
+
}
|
|
331
|
+
console.log(`[objects] published owner=${event.ownerId} discoveries=${event.discoveries?.length ?? 0} objects=${total}`);
|
|
332
|
+
for (const discovery of event.discoveries ?? []) {
|
|
333
|
+
for (const object of discovery.objects ?? []) {
|
|
334
|
+
objectsById.set(object.id, {
|
|
335
|
+
...object,
|
|
336
|
+
interestId: discovery.interestId,
|
|
337
|
+
snapshot: {}
|
|
338
|
+
});
|
|
339
|
+
console.log(` interest=${discovery.interestId} id=${object.id} class=${object.className} name=${object.name} stateBytes=${object.state.length}`);
|
|
340
|
+
if (!requestedTypeHashes.has(object.typeHash)) {
|
|
341
|
+
requestedTypeHashes.add(object.typeHash);
|
|
342
|
+
newTypeHashes.add(object.typeHash);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (newTypeHashes.size) {
|
|
347
|
+
client.requestTypes(bus, newTypeHashes);
|
|
348
|
+
}
|
|
349
|
+
requestReadyObjectStates();
|
|
350
|
+
});
|
|
351
|
+
client.on('objectsRemoved', event => {
|
|
352
|
+
console.log(`[objects] removed groups=${event.removals?.length ?? 0}`);
|
|
353
|
+
for (const removal of event.removals ?? []) {
|
|
354
|
+
for (const id of removal.ids ?? []) {
|
|
355
|
+
objectsById.delete(id);
|
|
356
|
+
stateRequestedObjectIds.delete(id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
client.on('objectsStateRequested', event => {
|
|
361
|
+
const count = event.requests.reduce((acc, request) => acc + request.objectIds.length, 0);
|
|
362
|
+
console.log(`[state] requested objects=${count}`);
|
|
363
|
+
});
|
|
364
|
+
client.on('objectsStateResponse', event => {
|
|
365
|
+
let count = 0;
|
|
366
|
+
for (const response of event.responses ?? []) {
|
|
367
|
+
for (const objectState of response.objectStates ?? []) {
|
|
368
|
+
count += 1;
|
|
369
|
+
const object = objectsById.get(objectState.id);
|
|
370
|
+
const classSpec = classSpecForObject(object, typeRegistry);
|
|
371
|
+
const values = decodePropertyValues(objectState.state, classSpec, typeRegistry);
|
|
372
|
+
if (object) {
|
|
373
|
+
object.lastStateTimestamp = objectState.timestamp;
|
|
374
|
+
applyPropertyValues(object, values);
|
|
375
|
+
}
|
|
376
|
+
console.log(`[state] object id=${objectState.id} properties=${values.length}${values.length ? ` [${formatPropertyValues(values)}]` : ''}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
console.log(`[state] response owner=${event.ownerId} objects=${count}`);
|
|
380
|
+
});
|
|
381
|
+
client.on('runtimeObjectUpdate', event => {
|
|
382
|
+
const object = objectsById.get(event.update.objectId);
|
|
383
|
+
const classSpec = classSpecForObject(object, typeRegistry);
|
|
384
|
+
const values = decodePropertyValues(event.update.properties, classSpec, typeRegistry);
|
|
385
|
+
if (object) {
|
|
386
|
+
object.lastUpdateTimestamp = event.update.time;
|
|
387
|
+
applyPropertyValues(object, values);
|
|
388
|
+
}
|
|
389
|
+
const ids = formatPropertyValues(values);
|
|
390
|
+
console.log(`[runtime] object update id=${event.update.objectId} properties=${event.update.propertyUpdates.length} bytes=${event.update.properties.length}${ids ? ` [${ids}]` : ''}`);
|
|
391
|
+
});
|
|
392
|
+
client.on('runtimeEvents', event => {
|
|
393
|
+
console.log(`[runtime] events bytes=${event.payload.length}`);
|
|
394
|
+
});
|
|
395
|
+
client.on('error', error => {
|
|
396
|
+
console.error(`[error] ${error?.stack ?? error}`);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await client.connect(target);
|
|
400
|
+
await waitForEvent(client, 'ready', 3000);
|
|
401
|
+
if (!options.forceBus) {
|
|
402
|
+
await waitForRemoteBus(client, remoteBuses, bus, 3000).catch(error => {
|
|
403
|
+
client.close();
|
|
404
|
+
throw error;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
client.joinBus(bus);
|
|
409
|
+
await waitForEvent(client, 'busParticipantReady', 5000).catch(error => {
|
|
410
|
+
console.warn(`[warn] ${error.message}; starting interest anyway`);
|
|
411
|
+
});
|
|
412
|
+
const interest = client.startInterest(bus, query);
|
|
413
|
+
|
|
414
|
+
console.log(`[listen] ${options.listen}ms`);
|
|
415
|
+
await wait(options.listen);
|
|
416
|
+
console.log(`[bus] stopping interest id=${interest.id}`);
|
|
417
|
+
client.stopInterest(bus, interest.id);
|
|
418
|
+
await wait(500);
|
|
419
|
+
console.log(`[bus] leaving bus=${bus}`);
|
|
420
|
+
client.leaveBus(bus);
|
|
421
|
+
await wait(500);
|
|
422
|
+
client.close();
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error(error?.stack ?? String(error));
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { scan, scanTcpDiscoveryHub } from '../lib/discovery.js';
|
|
3
|
+
|
|
4
|
+
function parseArgs(argv) {
|
|
5
|
+
const options = {};
|
|
6
|
+
|
|
7
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
8
|
+
const arg = argv[i];
|
|
9
|
+
if (arg === '--timeout') {
|
|
10
|
+
options.timeout = Number(argv[++i]);
|
|
11
|
+
} else if (arg === '--group') {
|
|
12
|
+
options.group = argv[++i];
|
|
13
|
+
} else if (arg === '--port') {
|
|
14
|
+
options.port = Number(argv[++i]);
|
|
15
|
+
} else if (arg === '--interface') {
|
|
16
|
+
options.interfaceAddress = argv[++i];
|
|
17
|
+
} else if (arg === '--bind') {
|
|
18
|
+
options.bindAddress = argv[++i];
|
|
19
|
+
} else if (arg === '--tcp-hub') {
|
|
20
|
+
options.tcpHub = argv[++i];
|
|
21
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
22
|
+
options.help = true;
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return options;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function printHelp() {
|
|
32
|
+
console.log(`Usage: sen-ether-scan [options]
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--timeout <ms> Scan duration. Default: 3000
|
|
36
|
+
--group <address> Discovery multicast group. Default: 239.255.0.44
|
|
37
|
+
--port <port> Discovery multicast port. Default: 60543
|
|
38
|
+
--interface <addr> Local interface address or interface name for multicast membership
|
|
39
|
+
--bind <addr> Local bind address. Default: multicast group on POSIX
|
|
40
|
+
--tcp-hub <host:port>
|
|
41
|
+
Use SEN TcpDiscoveryHub instead of multicast discovery
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
|
|
44
|
+
Environment:
|
|
45
|
+
SEN_ETHER_DISCOVERY_PORT Default multicast discovery port
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseHostPort(value) {
|
|
50
|
+
const text = String(value || '').trim();
|
|
51
|
+
const idx = text.lastIndexOf(':');
|
|
52
|
+
if (idx <= 0) {
|
|
53
|
+
throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
|
|
54
|
+
}
|
|
55
|
+
const host = text.slice(0, idx);
|
|
56
|
+
const port = Number(text.slice(idx + 1));
|
|
57
|
+
if (!host || !Number.isInteger(port) || port <= 0) {
|
|
58
|
+
throw new Error(`invalid --tcp-hub value, expected host:port: ${text}`);
|
|
59
|
+
}
|
|
60
|
+
return { host, port };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const options = parseArgs(process.argv.slice(2));
|
|
65
|
+
if (options.help) {
|
|
66
|
+
printHelp();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const processes = options.tcpHub
|
|
71
|
+
? await scanTcpDiscoveryHub({ ...parseHostPort(options.tcpHub), timeout: options.timeout })
|
|
72
|
+
: await scan(options);
|
|
73
|
+
console.log(JSON.stringify(processes, null, 2));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(error?.stack ?? String(error));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public sen-ether-client API.
|
|
3
|
+
*
|
|
4
|
+
* This package is a high-level JavaScript client for existing SEN kernels. It
|
|
5
|
+
* intentionally hides the ether codec, discovery frames and low-level transport
|
|
6
|
+
* classes from package consumers.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { Sen } from 'sen-ether-client';
|
|
10
|
+
*
|
|
11
|
+
* const sen = await Sen.connect();
|
|
12
|
+
*
|
|
13
|
+
* console.log(sen.listSessions());
|
|
14
|
+
* const hmi = await sen.session('hmi');
|
|
15
|
+
* console.log(hmi.listBuses());
|
|
16
|
+
*
|
|
17
|
+
* const diagnostics = await sen.interest('SELECT * FROM hmi.diagnostics');
|
|
18
|
+
* const world = await sen.interest('SELECT * FROM world1.environment');
|
|
19
|
+
* const probe = await diagnostics.waitFor('EtherProbe');
|
|
20
|
+
*
|
|
21
|
+
* probe.on('change:label', ({ value }) => console.log(value));
|
|
22
|
+
* console.log(await probe.get('label'));
|
|
23
|
+
* await probe.set('label', 'from-js');
|
|
24
|
+
* console.log(await probe.call('ping', ['hello']));
|
|
25
|
+
*
|
|
26
|
+
* await sen.close();
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} SenConnectOptions
|
|
31
|
+
* @property {string} [tcpHub] Optional SEN TCP discovery hub as `host:port`. If omitted, multicast discovery is used.
|
|
32
|
+
* @property {string} [session] Optional SEN session name. Omit it to let
|
|
33
|
+
* `interest(query)` connect to the session named in the query.
|
|
34
|
+
* @property {string} [app] Remote process appName substring filter.
|
|
35
|
+
* @property {number} [timeout=3000] Discovery and operation timeout in ms.
|
|
36
|
+
* @property {number} [discoverySettleMs=100] Discovery settle time after the first process is found.
|
|
37
|
+
* @property {number} [participantReadyTimeoutMs=1000] Short grace timeout for non-fatal bus participant acknowledgements.
|
|
38
|
+
* @property {boolean} [reconnect=true] Reconnect and restart interests after disconnection.
|
|
39
|
+
* @property {number} [reconnectDelayMs=500] Delay between reconnect attempts.
|
|
40
|
+
* @property {number} [maxReconnectAttempts=10] Maximum reconnect attempts.
|
|
41
|
+
* @property {boolean} [socketKeepAlive=true] Enable TCP keepalive on SEN ether connections.
|
|
42
|
+
* @property {number} [socketKeepAliveInitialDelayMs=1000] TCP keepalive initial delay.
|
|
43
|
+
* @property {number} [socketIdleTimeoutMs=0] Optional transport idle timeout in ms. `0` disables it.
|
|
44
|
+
* @property {string} [interfaceAddress] Local interface address or interface name for multicast discovery.
|
|
45
|
+
* @property {object} [target] Already discovered/direct SEN target.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} SenInterestOptions
|
|
50
|
+
* @property {string} [bus] Explicit bus name when it cannot be inferred from the query.
|
|
51
|
+
* @property {boolean} [forceBus=false] Join without waiting for the remote process to announce the bus.
|
|
52
|
+
* @property {number} [timeout] Operation timeout in ms.
|
|
53
|
+
* @property {string[]|string} [properties] Optional property names to decode and emit.
|
|
54
|
+
* @property {'individual'|'batch'|'both'} [changeMode='individual'] Change emission mode.
|
|
55
|
+
* @property {number} [batchIntervalMs=16] Batched change flush interval in ms.
|
|
56
|
+
* @property {number} [batchMaxSize=1000] Batched change flush size.
|
|
57
|
+
* @property {number} [maxQueuedChanges=10000] Batched change queue limit.
|
|
58
|
+
* @property {'drop-oldest'|'drop-newest'|'error'} [backpressure='drop-oldest'] Queue overflow policy.
|
|
59
|
+
* @property {boolean} [coalesce=false] Keep only latest queued change per object/property.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} SenListBusesOptions
|
|
64
|
+
* @property {boolean} [qualified=false] Return session-qualified bus names.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {string | number | ((object: SenRemoteObject) => boolean)} SenObjectSelector
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
Sen,
|
|
73
|
+
SenInterest,
|
|
74
|
+
SenRemoteObject
|
|
75
|
+
} from './lib/sen.js';
|