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
package/lib/sen.js
ADDED
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { decodePropertyValues, decodeValue, encodeArguments, decodeArguments } from './values.js';
|
|
4
|
+
import { EtherClient } from './client.js';
|
|
5
|
+
import { scan, scanTcpDiscoveryHub } from './discovery.js';
|
|
6
|
+
import { methodHash } from './hash32.js';
|
|
7
|
+
|
|
8
|
+
function wait(ms) {
|
|
9
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function waitForEvent(emitter, event, timeoutMs) {
|
|
13
|
+
let timeoutId;
|
|
14
|
+
const timeout = new Promise((_, reject) => {
|
|
15
|
+
timeoutId = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeoutMs);
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return await Promise.race([once(emitter, event), timeout]);
|
|
19
|
+
} finally {
|
|
20
|
+
clearTimeout(timeoutId);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseHostPort(value) {
|
|
25
|
+
const text = String(value || '').trim();
|
|
26
|
+
const idx = text.lastIndexOf(':');
|
|
27
|
+
if (idx <= 0) {
|
|
28
|
+
throw new Error(`invalid SEN tcp hub, expected host:port: ${text}`);
|
|
29
|
+
}
|
|
30
|
+
const host = text.slice(0, idx);
|
|
31
|
+
const port = Number(text.slice(idx + 1));
|
|
32
|
+
if (!host || !Number.isInteger(port) || port <= 0) {
|
|
33
|
+
throw new Error(`invalid SEN tcp hub, expected host:port: ${text}`);
|
|
34
|
+
}
|
|
35
|
+
return { host, port };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function etherBusName(sessionName, bus) {
|
|
39
|
+
const session = String(sessionName || '').trim();
|
|
40
|
+
const text = String(bus || '').trim();
|
|
41
|
+
const prefix = `${session}.`;
|
|
42
|
+
return session && text.startsWith(prefix) ? text.slice(prefix.length) : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function queryBusName(sessionName, bus) {
|
|
46
|
+
const session = String(sessionName || '').trim();
|
|
47
|
+
const text = String(bus || '').trim();
|
|
48
|
+
return text.includes('.') || !session ? text : `${session}.${text}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findTarget(processes, options) {
|
|
52
|
+
let candidates = processes;
|
|
53
|
+
if (options.session) {
|
|
54
|
+
candidates = candidates.filter(item => item.session?.name === options.session);
|
|
55
|
+
}
|
|
56
|
+
if (options.app) {
|
|
57
|
+
const app = String(options.app).toLowerCase();
|
|
58
|
+
candidates = candidates.filter(item => String(item.process?.appName || '').toLowerCase().includes(app));
|
|
59
|
+
}
|
|
60
|
+
if (!candidates.length) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return candidates[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function classSpecData(spec) {
|
|
67
|
+
return spec?.data?.type === 'ClassTypeSpec' ? spec.data.value : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findTypeSpec(typeRegistry, typeName) {
|
|
71
|
+
return typeRegistry?.get?.(typeName) ?? typeRegistry?.[typeName];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function collectClassMembers(spec, typeRegistry, member, seen = new Set()) {
|
|
75
|
+
const data = classSpecData(spec);
|
|
76
|
+
const key = spec?.qualifiedName ?? spec?.name;
|
|
77
|
+
if (!data || seen.has(key)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
seen.add(key);
|
|
81
|
+
|
|
82
|
+
return [
|
|
83
|
+
...(data.parents ?? []).flatMap(parent => collectClassMembers(findTypeSpec(typeRegistry, parent), typeRegistry, member, seen)),
|
|
84
|
+
...(data[member] ?? [])
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findByName(items, name) {
|
|
89
|
+
return items.find(item => item.name === name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setterName(propertyName) {
|
|
93
|
+
return `setNext${propertyName.slice(0, 1).toUpperCase()}${propertyName.slice(1)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function inferBusNameFromInterest(query) {
|
|
97
|
+
const match = String(query || '').match(/\bfrom\s+([^\s;]+)/i);
|
|
98
|
+
if (!match) {
|
|
99
|
+
throw new Error(`cannot infer SEN bus from interest query: ${query}`);
|
|
100
|
+
}
|
|
101
|
+
return match[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sessionNameFromBusName(busName) {
|
|
105
|
+
const text = String(busName || '').trim();
|
|
106
|
+
const idx = text.indexOf('.');
|
|
107
|
+
return idx > 0 ? text.slice(0, idx) : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function selectorDescription(selector) {
|
|
111
|
+
return typeof selector === 'function' ? '<predicate>' : String(selector);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizePropertyNames(properties) {
|
|
115
|
+
if (!properties) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const values = Array.isArray(properties)
|
|
119
|
+
? properties
|
|
120
|
+
: String(properties).split(',');
|
|
121
|
+
const names = values.map(value => String(value).trim()).filter(Boolean);
|
|
122
|
+
return names.length ? new Set(names) : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeTimestampNs(value) {
|
|
126
|
+
if (value === undefined || value === null) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return typeof value === 'bigint' ? value : BigInt(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
class ChangeBatcher {
|
|
133
|
+
constructor(interest, options = {}) {
|
|
134
|
+
this.interest = interest;
|
|
135
|
+
this.intervalMs = options.batchIntervalMs ?? options.batch?.intervalMs ?? 16;
|
|
136
|
+
this.maxSize = options.batchMaxSize ?? options.batch?.maxSize ?? 1000;
|
|
137
|
+
this.maxQueued = options.maxQueuedChanges ?? options.batch?.maxQueued ?? 10000;
|
|
138
|
+
this.backpressure = options.backpressure ?? options.batch?.backpressure ?? 'drop-oldest';
|
|
139
|
+
this.coalesce = Boolean(options.coalesce ?? options.batch?.coalesce ?? false);
|
|
140
|
+
this.queue = [];
|
|
141
|
+
this.coalesced = new Map();
|
|
142
|
+
this.timer = undefined;
|
|
143
|
+
this.dropped = 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
push(change) {
|
|
147
|
+
if (this.coalesce) {
|
|
148
|
+
const key = `${change.object.id}:${change.name}`;
|
|
149
|
+
if (!this.coalesced.has(key)) {
|
|
150
|
+
this.queue.push(key);
|
|
151
|
+
}
|
|
152
|
+
this.coalesced.set(key, change);
|
|
153
|
+
} else {
|
|
154
|
+
this.queue.push(change);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
while (this.queue.length > this.maxQueued) {
|
|
158
|
+
if (this.backpressure === 'error') {
|
|
159
|
+
const error = new Error(`SEN change queue exceeded ${this.maxQueued} item(s)`);
|
|
160
|
+
error.code = 'SEN_CHANGE_BACKPRESSURE';
|
|
161
|
+
this.interest.emit('backpressure', error);
|
|
162
|
+
this.interest.bus.emit('warning', error);
|
|
163
|
+
this.interest.bus.sen.emit('warning', error);
|
|
164
|
+
const newest = this.queue.pop();
|
|
165
|
+
if (this.coalesce) {
|
|
166
|
+
this.coalesced.delete(newest);
|
|
167
|
+
}
|
|
168
|
+
this.dropped += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (this.backpressure === 'drop-newest') {
|
|
172
|
+
const newest = this.queue.pop();
|
|
173
|
+
if (this.coalesce) {
|
|
174
|
+
this.coalesced.delete(newest);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
const oldest = this.queue.shift();
|
|
178
|
+
if (this.coalesce) {
|
|
179
|
+
this.coalesced.delete(oldest);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.dropped += 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.queue.length >= this.maxSize) {
|
|
186
|
+
this.flush();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!this.timer) {
|
|
191
|
+
this.timer = setTimeout(() => this.flush(), this.intervalMs);
|
|
192
|
+
this.timer.unref?.();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
flush() {
|
|
197
|
+
if (this.timer) {
|
|
198
|
+
clearTimeout(this.timer);
|
|
199
|
+
this.timer = undefined;
|
|
200
|
+
}
|
|
201
|
+
if (!this.queue.length) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const items = this.coalesce
|
|
206
|
+
? this.queue.map(key => this.coalesced.get(key)).filter(Boolean)
|
|
207
|
+
: this.queue;
|
|
208
|
+
this.queue = [];
|
|
209
|
+
this.coalesced.clear();
|
|
210
|
+
|
|
211
|
+
const batch = {
|
|
212
|
+
interest: this.interest,
|
|
213
|
+
bus: this.interest.bus,
|
|
214
|
+
changes: items,
|
|
215
|
+
dropped: this.dropped
|
|
216
|
+
};
|
|
217
|
+
this.dropped = 0;
|
|
218
|
+
|
|
219
|
+
this.interest.emit('changes', batch);
|
|
220
|
+
this.interest.bus.emit('changes', batch);
|
|
221
|
+
this.interest.bus.sen.emit('changes', batch);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
close() {
|
|
225
|
+
this.flush();
|
|
226
|
+
if (this.timer) {
|
|
227
|
+
clearTimeout(this.timer);
|
|
228
|
+
this.timer = undefined;
|
|
229
|
+
}
|
|
230
|
+
this.queue = [];
|
|
231
|
+
this.coalesced.clear();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* High-level pure JavaScript SEN ether client.
|
|
237
|
+
*
|
|
238
|
+
* It connects to an existing SEN kernel/process over ether and exposes remote
|
|
239
|
+
* objects discovered through native SEN interests. It does not load `.so`
|
|
240
|
+
* packages; type information comes from `TypesInfoResponse`.
|
|
241
|
+
*/
|
|
242
|
+
export class Sen extends EventEmitter {
|
|
243
|
+
/**
|
|
244
|
+
* Create, connect and return a SEN ether client.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} [options]
|
|
247
|
+
* @returns {Promise<Sen>}
|
|
248
|
+
*/
|
|
249
|
+
static async connect(options = {}) {
|
|
250
|
+
const sen = new Sen(options);
|
|
251
|
+
return await sen.connect(options);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
constructor(options = {}) {
|
|
255
|
+
super();
|
|
256
|
+
this.options = {
|
|
257
|
+
appName: 'sen-ether-client',
|
|
258
|
+
reconnect: true,
|
|
259
|
+
reconnectDelayMs: 500,
|
|
260
|
+
maxReconnectAttempts: 10,
|
|
261
|
+
timeout: 3000,
|
|
262
|
+
discoverySettleMs: 100,
|
|
263
|
+
participantReadyTimeoutMs: 1000,
|
|
264
|
+
socketKeepAlive: true,
|
|
265
|
+
socketKeepAliveInitialDelayMs: 1000,
|
|
266
|
+
socketIdleTimeoutMs: 0,
|
|
267
|
+
...options
|
|
268
|
+
};
|
|
269
|
+
this.target = undefined;
|
|
270
|
+
this.client = undefined;
|
|
271
|
+
this.connectOptions = undefined;
|
|
272
|
+
this.manualClose = false;
|
|
273
|
+
this.reconnecting = false;
|
|
274
|
+
this.remoteBuses = new Set();
|
|
275
|
+
this.buses = new Map();
|
|
276
|
+
this.sessions = new Map();
|
|
277
|
+
this.targetsBySession = new Map();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Discover and connect to one existing SEN ether process.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} [options]
|
|
284
|
+
* @param {string} [options.tcpHub] Discovery hub as `host:port`.
|
|
285
|
+
* @param {string} [options.session] Session filter.
|
|
286
|
+
* @param {string} [options.app] Remote appName substring filter.
|
|
287
|
+
* @param {number} [options.timeout] Discovery and ready timeout in ms.
|
|
288
|
+
* @param {number} [options.discoverySettleMs] TCP discovery settle time after the first process is found.
|
|
289
|
+
* @param {{host:string, port:number}|object} [options.target] Direct target.
|
|
290
|
+
*/
|
|
291
|
+
async connect(options = {}) {
|
|
292
|
+
const config = { ...this.options, ...options };
|
|
293
|
+
this.connectOptions = config;
|
|
294
|
+
this.manualClose = false;
|
|
295
|
+
|
|
296
|
+
if (!config.session && !config.target) {
|
|
297
|
+
const targets = await this.#discoverTargets(config);
|
|
298
|
+
if (!targets.length) {
|
|
299
|
+
throw new Error('no SEN ether processes discovered');
|
|
300
|
+
}
|
|
301
|
+
for (const target of targets) {
|
|
302
|
+
const sessionName = target.session?.name ?? target.info?.sessionName;
|
|
303
|
+
if (sessionName && !this.targetsBySession.has(sessionName)) {
|
|
304
|
+
this.targetsBySession.set(sessionName, target);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
this.emit('connect', {
|
|
308
|
+
sessions: [...this.targetsBySession.keys()],
|
|
309
|
+
targets
|
|
310
|
+
});
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return await this.#connectSingle(config);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async #connectSingle(config) {
|
|
318
|
+
const target = config.target ?? await this.#discoverTarget(config);
|
|
319
|
+
if (!target) {
|
|
320
|
+
throw new Error('no SEN ether process matches the requested filters');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sessionName = target.session?.name ?? target.info?.sessionName ?? config.session;
|
|
324
|
+
if (!sessionName) {
|
|
325
|
+
throw new Error('cannot connect without a SEN session name');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const client = new EtherClient({
|
|
329
|
+
sessionName,
|
|
330
|
+
appName: config.appName,
|
|
331
|
+
socketKeepAlive: config.socketKeepAlive,
|
|
332
|
+
socketKeepAliveInitialDelayMs: config.socketKeepAliveInitialDelayMs,
|
|
333
|
+
socketIdleTimeoutMs: config.socketIdleTimeoutMs
|
|
334
|
+
});
|
|
335
|
+
this.client = client;
|
|
336
|
+
this.target = target;
|
|
337
|
+
this.#wireClient(client);
|
|
338
|
+
|
|
339
|
+
await client.connect(target);
|
|
340
|
+
await waitForEvent(client, 'ready', config.timeout ?? 3000);
|
|
341
|
+
this.emit('connect', { target, sessionName });
|
|
342
|
+
return this;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Join a bus and start an interest. By default the interest is
|
|
347
|
+
* `SELECT * FROM <session>.<bus>`.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} busName Session-qualified or ether-local bus name.
|
|
350
|
+
* @param {object} [options]
|
|
351
|
+
* @param {string} [options.query]
|
|
352
|
+
* @param {boolean} [options.forceBus]
|
|
353
|
+
* @param {number} [options.timeout]
|
|
354
|
+
*/
|
|
355
|
+
async subscribe(busName, options = {}) {
|
|
356
|
+
if (!this.client) {
|
|
357
|
+
const sessionName = this.#sessionNameForBus(busName, options);
|
|
358
|
+
const session = await this.session(sessionName);
|
|
359
|
+
return await session.subscribe(busName, options);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!this.client || !this.target) {
|
|
363
|
+
throw new Error('Sen is not connected');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const sessionName = this.target.session?.name ?? this.client.processInfo.sessionName;
|
|
367
|
+
this.#assertBusBelongsToSession(busName, sessionName);
|
|
368
|
+
const bus = etherBusName(sessionName, busName);
|
|
369
|
+
const query = options.query ?? `SELECT * FROM ${queryBusName(sessionName, busName)}`;
|
|
370
|
+
const timeout = options.timeout ?? this.options.timeout ?? 3000;
|
|
371
|
+
|
|
372
|
+
if (!options.forceBus) {
|
|
373
|
+
await this.#waitForRemoteBus(bus, timeout);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let senBus = this.buses.get(bus);
|
|
377
|
+
if (!senBus) {
|
|
378
|
+
const joined = this.client.joinBus(bus);
|
|
379
|
+
const participantReadyTimeoutMs = this.#participantReadyTimeout(options, timeout);
|
|
380
|
+
await waitForEvent(this.client, 'busParticipantReady', participantReadyTimeoutMs).catch(error => {
|
|
381
|
+
this.emit('warning', error);
|
|
382
|
+
});
|
|
383
|
+
senBus = new SenBus(this, bus, joined.busId);
|
|
384
|
+
this.buses.set(bus, senBus);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
senBus.startInterest(query, options);
|
|
388
|
+
return senBus;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Start a native SEN interest and return a live object collection.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} query Native SEN interest query, for example `SELECT * FROM hmi.hud`.
|
|
395
|
+
* @param {object} [options]
|
|
396
|
+
* @param {string} [options.bus] Explicit bus when it cannot be inferred from the query.
|
|
397
|
+
* @param {boolean} [options.forceBus]
|
|
398
|
+
* @param {number} [options.timeout]
|
|
399
|
+
* @param {string[]|string} [options.properties] Optional property names to decode and emit.
|
|
400
|
+
* @param {'individual'|'batch'|'both'} [options.changeMode] Defaults to `individual`.
|
|
401
|
+
* @param {number} [options.batchIntervalMs] Batch flush interval in ms.
|
|
402
|
+
* @param {number} [options.batchMaxSize] Batch flush size.
|
|
403
|
+
* @param {number} [options.maxQueuedChanges] Backpressure queue limit for batched changes.
|
|
404
|
+
* @param {'drop-oldest'|'drop-newest'|'error'} [options.backpressure]
|
|
405
|
+
* @param {boolean} [options.coalesce] Keep only the latest queued change per object/property.
|
|
406
|
+
*/
|
|
407
|
+
async interest(query, options = {}) {
|
|
408
|
+
const busName = options.bus ?? inferBusNameFromInterest(query);
|
|
409
|
+
if (!this.client) {
|
|
410
|
+
const sessionName = this.#sessionNameForBus(busName, options);
|
|
411
|
+
const session = await this.session(sessionName);
|
|
412
|
+
return await session.interest(query, options);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!this.client || !this.target) {
|
|
416
|
+
throw new Error('Sen is not connected');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const sessionName = this.target.session?.name ?? this.client.processInfo.sessionName;
|
|
420
|
+
if (!options.bus) {
|
|
421
|
+
this.#assertBusBelongsToSession(busName, sessionName);
|
|
422
|
+
}
|
|
423
|
+
const bus = etherBusName(sessionName, busName);
|
|
424
|
+
const timeout = options.timeout ?? this.options.timeout ?? 3000;
|
|
425
|
+
|
|
426
|
+
if (!options.forceBus) {
|
|
427
|
+
await this.#waitForRemoteBus(bus, timeout);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let senBus = this.buses.get(bus);
|
|
431
|
+
if (!senBus) {
|
|
432
|
+
const joined = this.client.joinBus(bus);
|
|
433
|
+
const participantReadyTimeoutMs = this.#participantReadyTimeout(options, timeout);
|
|
434
|
+
await waitForEvent(this.client, 'busParticipantReady', participantReadyTimeoutMs).catch(error => {
|
|
435
|
+
this.emit('warning', error);
|
|
436
|
+
});
|
|
437
|
+
senBus = new SenBus(this, bus, joined.busId);
|
|
438
|
+
this.buses.set(bus, senBus);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return senBus.startInterest(query, options);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async bus(name, options = {}) {
|
|
445
|
+
return await this.subscribe(name, options);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async session(name) {
|
|
449
|
+
const sessionName = String(name || '').trim();
|
|
450
|
+
if (!sessionName) {
|
|
451
|
+
throw new Error('SEN session name is required');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (this.client) {
|
|
455
|
+
const current = this.target?.session?.name ?? this.client.processInfo.sessionName;
|
|
456
|
+
if (current !== sessionName) {
|
|
457
|
+
throw new Error(`Sen is connected to session "${current}", not "${sessionName}"`);
|
|
458
|
+
}
|
|
459
|
+
return this;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const existing = this.sessions.get(sessionName);
|
|
463
|
+
if (existing) {
|
|
464
|
+
return existing;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const baseConfig = this.connectOptions ?? this.options;
|
|
468
|
+
let target = this.targetsBySession.get(sessionName);
|
|
469
|
+
if (!target) {
|
|
470
|
+
target = await this.#discoverTarget({ ...baseConfig, session: sessionName });
|
|
471
|
+
if (!target) {
|
|
472
|
+
throw new Error(`no SEN ether process found for session "${sessionName}"`);
|
|
473
|
+
}
|
|
474
|
+
this.targetsBySession.set(sessionName, target);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const session = new Sen({
|
|
478
|
+
...baseConfig,
|
|
479
|
+
session: sessionName
|
|
480
|
+
});
|
|
481
|
+
this.#wireSession(session);
|
|
482
|
+
this.sessions.set(sessionName, session);
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
await session.connect({
|
|
486
|
+
...baseConfig,
|
|
487
|
+
session: sessionName,
|
|
488
|
+
target
|
|
489
|
+
});
|
|
490
|
+
} catch (error) {
|
|
491
|
+
this.sessions.delete(sessionName);
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return session;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
listSessions() {
|
|
499
|
+
if (this.client) {
|
|
500
|
+
return [this.target?.session?.name ?? this.client.processInfo.sessionName].filter(Boolean);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return [...new Set([
|
|
504
|
+
...this.targetsBySession.keys(),
|
|
505
|
+
...this.sessions.keys()
|
|
506
|
+
])].sort();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
listBuses(options = {}) {
|
|
510
|
+
if (!this.client) {
|
|
511
|
+
return [...this.sessions.values()]
|
|
512
|
+
.flatMap(session => session.listBuses({ qualified: true }))
|
|
513
|
+
.sort();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const sessionName = this.target?.session?.name ?? this.client.processInfo.sessionName;
|
|
517
|
+
return [...this.remoteBuses]
|
|
518
|
+
.sort()
|
|
519
|
+
.map(busName => options.qualified ? queryBusName(sessionName, busName) : busName);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
objects() {
|
|
523
|
+
if (!this.client) {
|
|
524
|
+
return [...this.sessions.values()].flatMap(session => session.objects());
|
|
525
|
+
}
|
|
526
|
+
return [...this.buses.values()].flatMap(bus => bus.objects());
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
getObject(selector) {
|
|
530
|
+
return this.objects().find(object => object.matches(selector));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async waitForRemoteBus(busName, timeoutMs = this.options.timeout ?? 3000) {
|
|
534
|
+
await this.#waitForRemoteBus(busName, timeoutMs);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async waitForObject(selector, options = {}) {
|
|
538
|
+
const existing = this.getObject(selector);
|
|
539
|
+
if (existing) {
|
|
540
|
+
return existing;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const timeoutMs = options.timeout ?? this.options.timeout ?? 3000;
|
|
544
|
+
return await new Promise((resolve, reject) => {
|
|
545
|
+
const timeout = setTimeout(() => {
|
|
546
|
+
this.off('object', onObject);
|
|
547
|
+
reject(new Error(`timeout waiting for SEN object ${selectorDescription(selector)}`));
|
|
548
|
+
}, timeoutMs);
|
|
549
|
+
const onObject = object => {
|
|
550
|
+
if (!object.matches(selector)) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
clearTimeout(timeout);
|
|
554
|
+
this.off('object', onObject);
|
|
555
|
+
resolve(object);
|
|
556
|
+
};
|
|
557
|
+
this.on('object', onObject);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async close() {
|
|
562
|
+
this.manualClose = true;
|
|
563
|
+
for (const session of this.sessions.values()) {
|
|
564
|
+
await session.close();
|
|
565
|
+
}
|
|
566
|
+
for (const bus of this.buses.values()) {
|
|
567
|
+
bus.close();
|
|
568
|
+
}
|
|
569
|
+
await wait(50);
|
|
570
|
+
await this.client?.close();
|
|
571
|
+
this.client = undefined;
|
|
572
|
+
this.sessions.clear();
|
|
573
|
+
this.buses.clear();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async #discoverTargets(options) {
|
|
577
|
+
return options.tcpHub
|
|
578
|
+
? await scanTcpDiscoveryHub({
|
|
579
|
+
...parseHostPort(options.tcpHub),
|
|
580
|
+
timeout: options.timeout,
|
|
581
|
+
settleMs: options.discoverySettleMs
|
|
582
|
+
})
|
|
583
|
+
: await scan({
|
|
584
|
+
...options,
|
|
585
|
+
settleMs: options.discoverySettleMs
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async #discoverTarget(options) {
|
|
590
|
+
const processes = await this.#discoverTargets(options);
|
|
591
|
+
const target = findTarget(processes, options);
|
|
592
|
+
if (!target) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
return target;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
#sessionNameForBus(busName, options = {}) {
|
|
599
|
+
const explicit = String(options.session || '').trim();
|
|
600
|
+
if (explicit) {
|
|
601
|
+
return explicit;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const fromBus = sessionNameFromBusName(busName);
|
|
605
|
+
if (fromBus) {
|
|
606
|
+
return fromBus;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (this.sessions.size === 1) {
|
|
610
|
+
return this.sessions.keys().next().value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (this.targetsBySession.size === 1) {
|
|
614
|
+
return this.targetsBySession.keys().next().value;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
throw new Error(`cannot infer SEN session from bus "${busName}"; use a session-qualified query such as SELECT * FROM hmi.${busName}`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
#assertBusBelongsToSession(busName, sessionName) {
|
|
621
|
+
const requestedSession = sessionNameFromBusName(busName);
|
|
622
|
+
if (requestedSession && requestedSession !== sessionName) {
|
|
623
|
+
throw new Error(`query targets SEN session "${requestedSession}" but this client is connected to session "${sessionName}"`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#participantReadyTimeout(options, timeoutMs) {
|
|
628
|
+
const configured = options.participantReadyTimeoutMs ?? this.options.participantReadyTimeoutMs ?? 1000;
|
|
629
|
+
return Math.min(timeoutMs, configured);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#wireSession(session) {
|
|
633
|
+
const forward = type => value => this.emit(type, value);
|
|
634
|
+
for (const type of [
|
|
635
|
+
'connect',
|
|
636
|
+
'close',
|
|
637
|
+
'reconnecting',
|
|
638
|
+
'reconnect',
|
|
639
|
+
'reconnectError',
|
|
640
|
+
'warning',
|
|
641
|
+
'object',
|
|
642
|
+
'remove',
|
|
643
|
+
'change',
|
|
644
|
+
'changes',
|
|
645
|
+
'event'
|
|
646
|
+
]) {
|
|
647
|
+
session.on(type, forward(type));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
#wireClient(client) {
|
|
652
|
+
client.on('remoteProcess', value => this.emit('remoteProcess', value));
|
|
653
|
+
client.on('ready', value => this.emit('ready', value));
|
|
654
|
+
client.on('busJoined', value => {
|
|
655
|
+
this.remoteBuses.add(value.busName);
|
|
656
|
+
this.emit('busAvailable', value);
|
|
657
|
+
});
|
|
658
|
+
client.on('busLeft', value => {
|
|
659
|
+
this.remoteBuses.delete(value.busName);
|
|
660
|
+
this.emit('busUnavailable', value);
|
|
661
|
+
});
|
|
662
|
+
client.on('objectsPublished', event => this.#busForEvent(event)?.handleObjectsPublished(event));
|
|
663
|
+
client.on('objectsRemoved', event => this.#busForEvent(event)?.handleObjectsRemoved(event));
|
|
664
|
+
client.on('typesInfoResponse', event => this.#busForEvent(event)?.handleTypesInfoResponse(event));
|
|
665
|
+
client.on('typesInfoRejection', event => this.#busForEvent(event)?.emit('typesInfoRejection', event));
|
|
666
|
+
client.on('objectsStateResponse', event => this.#busForEvent(event)?.handleObjectsStateResponse(event));
|
|
667
|
+
client.on('runtimeObjectUpdate', event => this.#busForEvent(event)?.handleRuntimeObjectUpdate(event));
|
|
668
|
+
client.on('runtimeEvents', event => this.#busForEvent(event)?.handleRuntimeEvents(event));
|
|
669
|
+
client.on('runtimeMethodResponse', event => this.#busForEvent(event)?.handleRuntimeMethodResponse(event));
|
|
670
|
+
client.on('error', error => {
|
|
671
|
+
if (this.manualClose || this.reconnecting || this.options.reconnect !== false) {
|
|
672
|
+
this.emit('warning', error);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
this.emit('error', error);
|
|
676
|
+
});
|
|
677
|
+
client.on('close', hadError => {
|
|
678
|
+
this.emit('close', hadError);
|
|
679
|
+
if (!this.manualClose && this.options.reconnect !== false) {
|
|
680
|
+
this.#reconnect().catch(error => this.emit('error', error));
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async #reconnect() {
|
|
686
|
+
if (this.manualClose || this.reconnecting || !this.connectOptions) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.reconnecting = true;
|
|
691
|
+
await this.client?.close().catch(error => this.emit('warning', error));
|
|
692
|
+
this.emit('reconnecting');
|
|
693
|
+
const maxAttempts = this.connectOptions.maxReconnectAttempts ?? this.options.maxReconnectAttempts ?? 10;
|
|
694
|
+
const delayMs = this.connectOptions.reconnectDelayMs ?? this.options.reconnectDelayMs ?? 500;
|
|
695
|
+
|
|
696
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
697
|
+
let client;
|
|
698
|
+
try {
|
|
699
|
+
await wait(delayMs);
|
|
700
|
+
if (this.manualClose) {
|
|
701
|
+
this.reconnecting = false;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
this.remoteBuses.clear();
|
|
706
|
+
for (const bus of this.buses.values()) {
|
|
707
|
+
bus.prepareReconnect();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const config = this.connectOptions;
|
|
711
|
+
const target = config.target ?? await this.#discoverTarget(config);
|
|
712
|
+
if (!target) {
|
|
713
|
+
throw new Error('no SEN ether process matches the requested filters');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const sessionName = target.session?.name ?? target.info?.sessionName ?? config.session;
|
|
717
|
+
client = new EtherClient({
|
|
718
|
+
sessionName,
|
|
719
|
+
appName: config.appName,
|
|
720
|
+
socketKeepAlive: config.socketKeepAlive,
|
|
721
|
+
socketKeepAliveInitialDelayMs: config.socketKeepAliveInitialDelayMs,
|
|
722
|
+
socketIdleTimeoutMs: config.socketIdleTimeoutMs
|
|
723
|
+
});
|
|
724
|
+
this.client = client;
|
|
725
|
+
this.target = target;
|
|
726
|
+
this.#wireClient(client);
|
|
727
|
+
|
|
728
|
+
await client.connect(target);
|
|
729
|
+
await waitForEvent(client, 'ready', config.timeout ?? 3000);
|
|
730
|
+
|
|
731
|
+
for (const bus of this.buses.values()) {
|
|
732
|
+
await bus.rejoin(config.timeout ?? 3000);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
this.reconnecting = false;
|
|
736
|
+
this.emit('reconnect', { attempt, target, sessionName });
|
|
737
|
+
return;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
await client?.close().catch(closeError => this.emit('warning', closeError));
|
|
740
|
+
if (this.manualClose) {
|
|
741
|
+
this.reconnecting = false;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
this.emit('reconnectError', { attempt, error });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (this.manualClose) {
|
|
749
|
+
this.reconnecting = false;
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
this.reconnecting = false;
|
|
754
|
+
throw new Error(`failed to reconnect SEN ether after ${maxAttempts} attempt(s)`);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
#busForEvent(event) {
|
|
758
|
+
if (event.bus?.busName) {
|
|
759
|
+
return this.buses.get(event.bus.busName);
|
|
760
|
+
}
|
|
761
|
+
if (event.busId !== undefined) {
|
|
762
|
+
return [...this.buses.values()].find(bus => bus.id === event.busId);
|
|
763
|
+
}
|
|
764
|
+
return undefined;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async #waitForRemoteBus(busName, timeoutMs) {
|
|
768
|
+
if (this.remoteBuses.has(busName)) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
await new Promise((resolve, reject) => {
|
|
773
|
+
const timeout = setTimeout(() => {
|
|
774
|
+
this.off('busAvailable', onBusAvailable);
|
|
775
|
+
const announced = [...this.remoteBuses].sort().join(', ') || '<none>';
|
|
776
|
+
reject(new Error(`remote process did not announce bus "${busName}" within ${timeoutMs}ms; announced: ${announced}`));
|
|
777
|
+
}, timeoutMs);
|
|
778
|
+
const onBusAvailable = value => {
|
|
779
|
+
if (value.busName !== busName) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
clearTimeout(timeout);
|
|
783
|
+
this.off('busAvailable', onBusAvailable);
|
|
784
|
+
resolve();
|
|
785
|
+
};
|
|
786
|
+
this.on('busAvailable', onBusAvailable);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export class SenBus extends EventEmitter {
|
|
792
|
+
constructor(sen, name, id) {
|
|
793
|
+
super();
|
|
794
|
+
this.sen = sen;
|
|
795
|
+
this.name = name;
|
|
796
|
+
this.id = id;
|
|
797
|
+
this.objectsById = new Map();
|
|
798
|
+
this.typeRegistry = new Map();
|
|
799
|
+
this.requestedTypeHashes = new Set();
|
|
800
|
+
this.stateRequestedObjectIds = new Set();
|
|
801
|
+
this.interests = new Map();
|
|
802
|
+
this.pendingCalls = new Map();
|
|
803
|
+
this.nextTicketId = 1;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
startInterest(query, options = {}) {
|
|
807
|
+
const started = this.sen.client.startInterest(this.name, query);
|
|
808
|
+
const interest = new SenInterest(this, started.id, query, options);
|
|
809
|
+
this.interests.set(interest.id, interest);
|
|
810
|
+
return interest;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
stopInterest(id) {
|
|
814
|
+
const interestId = typeof id === 'object' ? id.id : id;
|
|
815
|
+
this.sen.client.stopInterest(this.name, interestId);
|
|
816
|
+
const interest = this.interests.get(interestId);
|
|
817
|
+
this.interests.delete(interestId);
|
|
818
|
+
interest?.closeLocal();
|
|
819
|
+
interest?.emit('close');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
close() {
|
|
823
|
+
for (const interest of this.interests.values()) {
|
|
824
|
+
interest.closeLocal();
|
|
825
|
+
this.sen.client.stopInterest(this.name, interest.id);
|
|
826
|
+
}
|
|
827
|
+
this.interests.clear();
|
|
828
|
+
this.sen.client.leaveBus(this.name);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
prepareReconnect() {
|
|
832
|
+
for (const call of this.pendingCalls.values()) {
|
|
833
|
+
clearTimeout(call.timeout);
|
|
834
|
+
call.reject(new Error('SEN connection closed before method response'));
|
|
835
|
+
}
|
|
836
|
+
this.pendingCalls.clear();
|
|
837
|
+
for (const object of this.objectsById.values()) {
|
|
838
|
+
object.stale = true;
|
|
839
|
+
object.emit('stale');
|
|
840
|
+
}
|
|
841
|
+
this.objectsById.clear();
|
|
842
|
+
this.typeRegistry.clear();
|
|
843
|
+
this.requestedTypeHashes.clear();
|
|
844
|
+
this.stateRequestedObjectIds.clear();
|
|
845
|
+
for (const interest of this.interests.values()) {
|
|
846
|
+
interest.closeLocal();
|
|
847
|
+
interest.objectsById.clear();
|
|
848
|
+
interest.emit('stale');
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async rejoin(timeoutMs) {
|
|
853
|
+
const busReadyTimeoutMs = Math.min(timeoutMs, this.sen.options.participantReadyTimeoutMs ?? 1000);
|
|
854
|
+
await this.sen.waitForRemoteBus(this.name, busReadyTimeoutMs).catch(error => {
|
|
855
|
+
this.sen.emit('warning', error);
|
|
856
|
+
});
|
|
857
|
+
const joined = this.sen.client.joinBus(this.name);
|
|
858
|
+
this.id = joined.busId;
|
|
859
|
+
const participantReadyTimeoutMs = Math.min(timeoutMs, this.sen.options.participantReadyTimeoutMs ?? 1000);
|
|
860
|
+
await waitForEvent(this.sen.client, 'busParticipantReady', participantReadyTimeoutMs).catch(error => {
|
|
861
|
+
this.sen.emit('warning', error);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
const interests = [...this.interests.values()];
|
|
865
|
+
this.interests.clear();
|
|
866
|
+
for (const interest of interests) {
|
|
867
|
+
const started = this.sen.client.startInterest(this.name, interest.query);
|
|
868
|
+
interest.id = started.id;
|
|
869
|
+
interest.resetLocal();
|
|
870
|
+
this.interests.set(interest.id, interest);
|
|
871
|
+
interest.emit('restart', interest);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
objects() {
|
|
876
|
+
return [...this.objectsById.values()];
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
getObject(selector) {
|
|
880
|
+
return this.objects().find(object => object.matches(selector));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
handleObjectsPublished(event) {
|
|
884
|
+
const newTypeHashes = new Set();
|
|
885
|
+
for (const discovery of event.discoveries ?? []) {
|
|
886
|
+
for (const info of discovery.objects ?? []) {
|
|
887
|
+
const object = new SenRemoteObject(this, {
|
|
888
|
+
...info,
|
|
889
|
+
ownerId: event.ownerId,
|
|
890
|
+
interestId: discovery.interestId
|
|
891
|
+
});
|
|
892
|
+
this.objectsById.set(object.id, object);
|
|
893
|
+
const interest = this.interests.get(discovery.interestId);
|
|
894
|
+
interest?.objectsById.set(object.id, object);
|
|
895
|
+
if (info.state?.length) {
|
|
896
|
+
object.applyState(info.state, 'state', info.time);
|
|
897
|
+
}
|
|
898
|
+
if (!this.requestedTypeHashes.has(info.typeHash)) {
|
|
899
|
+
this.requestedTypeHashes.add(info.typeHash);
|
|
900
|
+
newTypeHashes.add(info.typeHash);
|
|
901
|
+
}
|
|
902
|
+
interest?.emit('object', object);
|
|
903
|
+
this.emit('object', object);
|
|
904
|
+
this.sen.emit('object', object);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (newTypeHashes.size) {
|
|
908
|
+
this.sen.client.requestTypes(this.name, newTypeHashes);
|
|
909
|
+
}
|
|
910
|
+
this.#requestReadyObjectStates();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
handleObjectsRemoved(event) {
|
|
914
|
+
for (const removal of event.removals ?? []) {
|
|
915
|
+
for (const id of removal.ids ?? []) {
|
|
916
|
+
const object = this.objectsById.get(id);
|
|
917
|
+
this.objectsById.delete(id);
|
|
918
|
+
this.stateRequestedObjectIds.delete(id);
|
|
919
|
+
const interest = this.interests.get(removal.interestId);
|
|
920
|
+
interest?.objectsById.delete(id);
|
|
921
|
+
if (object) {
|
|
922
|
+
object.emit('remove');
|
|
923
|
+
interest?.emit('remove', object);
|
|
924
|
+
this.emit('remove', object);
|
|
925
|
+
this.sen.emit('remove', object);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
handleTypesInfoResponse(event) {
|
|
932
|
+
const dependentTypeHashes = new Set();
|
|
933
|
+
for (const type of event.types ?? []) {
|
|
934
|
+
this.typeRegistry.set(type.spec.qualifiedName, type.spec);
|
|
935
|
+
if (type.classHash !== undefined) {
|
|
936
|
+
for (const object of this.objectsById.values()) {
|
|
937
|
+
if (object.typeHash === type.classHash) {
|
|
938
|
+
object.spec = type.spec;
|
|
939
|
+
object.emit('type', type.spec);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
for (const hash of type.dependentTypes ?? []) {
|
|
944
|
+
if (!this.requestedTypeHashes.has(hash)) {
|
|
945
|
+
this.requestedTypeHashes.add(hash);
|
|
946
|
+
dependentTypeHashes.add(hash);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
this.emit('type', type);
|
|
950
|
+
}
|
|
951
|
+
if (dependentTypeHashes.size) {
|
|
952
|
+
this.sen.client.requestTypes(this.name, dependentTypeHashes);
|
|
953
|
+
}
|
|
954
|
+
this.#retryPendingStates();
|
|
955
|
+
this.#requestReadyObjectStates();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
handleObjectsStateResponse(event) {
|
|
959
|
+
for (const response of event.responses ?? []) {
|
|
960
|
+
for (const state of response.objectStates ?? []) {
|
|
961
|
+
const object = this.objectsById.get(state.id);
|
|
962
|
+
if (!object) {
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
object.applyState(state.state, 'state', state.timestamp);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
handleRuntimeObjectUpdate(event) {
|
|
971
|
+
const object = this.objectsById.get(event.update.objectId);
|
|
972
|
+
if (!object) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
object.applyState(event.update.properties, 'update', event.update.time);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
handleRuntimeEvents(event) {
|
|
979
|
+
for (const item of event.events ?? []) {
|
|
980
|
+
const object = this.objectsById.get(item.producerId);
|
|
981
|
+
if (!object) {
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
object.emitRuntimeEvent(item);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
handleRuntimeMethodResponse(event) {
|
|
989
|
+
const response = event.response;
|
|
990
|
+
const pending = this.pendingCalls.get(response.ticketId);
|
|
991
|
+
if (!pending) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
clearTimeout(pending.timeout);
|
|
995
|
+
this.pendingCalls.delete(response.ticketId);
|
|
996
|
+
|
|
997
|
+
if (response.result !== 'success') {
|
|
998
|
+
const error = new Error(response.error || response.result);
|
|
999
|
+
error.code = `SEN_${response.result}`;
|
|
1000
|
+
pending.reject(error);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
try {
|
|
1005
|
+
if (!pending.method.returnType || pending.method.returnType === 'void') {
|
|
1006
|
+
pending.resolve(undefined);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
pending.resolve(decodeValue(response.returnValue, pending.method.returnType, this.typeRegistry));
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
pending.reject(error);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
callObjectMethod(object, method, args, options = {}) {
|
|
1016
|
+
const ticketId = this.nextTicketId++ >>> 0;
|
|
1017
|
+
const timeoutMs = options.timeout ?? 5000;
|
|
1018
|
+
const argumentsBuffer = encodeArguments(args, method.args, this.typeRegistry);
|
|
1019
|
+
|
|
1020
|
+
return new Promise((resolve, reject) => {
|
|
1021
|
+
const timeout = setTimeout(() => {
|
|
1022
|
+
this.pendingCalls.delete(ticketId);
|
|
1023
|
+
reject(new Error(`timeout waiting for SEN method ${method.name} response`));
|
|
1024
|
+
}, timeoutMs);
|
|
1025
|
+
this.pendingCalls.set(ticketId, { resolve, reject, timeout, method });
|
|
1026
|
+
try {
|
|
1027
|
+
this.sen.client.sendRuntimeMethodCall(this.name, {
|
|
1028
|
+
to: object.ownerId,
|
|
1029
|
+
objectId: object.id,
|
|
1030
|
+
methodId: method.id,
|
|
1031
|
+
ticketId,
|
|
1032
|
+
confirmed: method.transportMode === 'confirmed' || options.confirmed,
|
|
1033
|
+
argumentsBuffer
|
|
1034
|
+
});
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
clearTimeout(timeout);
|
|
1037
|
+
this.pendingCalls.delete(ticketId);
|
|
1038
|
+
reject(error);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
#requestReadyObjectStates() {
|
|
1044
|
+
const requestsByInterest = new Map();
|
|
1045
|
+
for (const object of this.objectsById.values()) {
|
|
1046
|
+
if (this.stateRequestedObjectIds.has(object.id) || !object.spec) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
this.stateRequestedObjectIds.add(object.id);
|
|
1050
|
+
const ids = requestsByInterest.get(object.interestId) ?? [];
|
|
1051
|
+
ids.push(object.id);
|
|
1052
|
+
requestsByInterest.set(object.interestId, ids);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (requestsByInterest.size) {
|
|
1056
|
+
this.sen.client.requestObjectStates(this.name, [...requestsByInterest].map(([interestId, objectIds]) => ({
|
|
1057
|
+
interestId,
|
|
1058
|
+
objectIds
|
|
1059
|
+
})));
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
#retryPendingStates() {
|
|
1064
|
+
for (const object of this.objectsById.values()) {
|
|
1065
|
+
if (object.pendingState) {
|
|
1066
|
+
object.applyState(
|
|
1067
|
+
object.pendingState.buffer,
|
|
1068
|
+
object.pendingState.source,
|
|
1069
|
+
object.pendingState.timestampNs
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export class SenInterest extends EventEmitter {
|
|
1077
|
+
constructor(bus, id, query, options = {}) {
|
|
1078
|
+
super();
|
|
1079
|
+
this.bus = bus;
|
|
1080
|
+
this.id = id;
|
|
1081
|
+
this.query = query;
|
|
1082
|
+
this.options = { ...options };
|
|
1083
|
+
this.propertyNames = normalizePropertyNames(options.properties ?? options.propertyNames);
|
|
1084
|
+
this.changeMode = options.changeMode ?? (options.batch ? 'batch' : 'individual');
|
|
1085
|
+
if (!['individual', 'batch', 'both'].includes(this.changeMode)) {
|
|
1086
|
+
throw new Error(`invalid SEN interest changeMode: ${this.changeMode}`);
|
|
1087
|
+
}
|
|
1088
|
+
this.batcher = this.changeMode === 'individual' ? undefined : new ChangeBatcher(this, options);
|
|
1089
|
+
this.objectsById = new Map();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
objects() {
|
|
1093
|
+
return [...this.objectsById.values()];
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
get(selector) {
|
|
1097
|
+
return this.objects().find(object => object.matches(selector));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async waitFor(selector, options = {}) {
|
|
1101
|
+
const existing = this.get(selector);
|
|
1102
|
+
if (existing) {
|
|
1103
|
+
return existing;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const timeoutMs = options.timeout ?? this.bus.sen.options.timeout ?? 3000;
|
|
1107
|
+
return await new Promise((resolve, reject) => {
|
|
1108
|
+
const timeout = setTimeout(() => {
|
|
1109
|
+
this.off('object', onObject);
|
|
1110
|
+
reject(new Error(`timeout waiting for SEN object ${selectorDescription(selector)}`));
|
|
1111
|
+
}, timeoutMs);
|
|
1112
|
+
const onObject = object => {
|
|
1113
|
+
if (!object.matches(selector)) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
clearTimeout(timeout);
|
|
1117
|
+
this.off('object', onObject);
|
|
1118
|
+
resolve(object);
|
|
1119
|
+
};
|
|
1120
|
+
this.on('object', onObject);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
close() {
|
|
1125
|
+
this.bus.stopInterest(this.id);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
closeLocal() {
|
|
1129
|
+
this.batcher?.close();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
resetLocal() {
|
|
1133
|
+
this.batcher?.close();
|
|
1134
|
+
this.batcher = this.changeMode === 'individual' ? undefined : new ChangeBatcher(this, this.options);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
decodeOptions() {
|
|
1138
|
+
return {
|
|
1139
|
+
propertyNames: this.propertyNames
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
publishChange(change) {
|
|
1144
|
+
if (this.changeMode === 'individual' || this.changeMode === 'both') {
|
|
1145
|
+
change.object.emit('change', change);
|
|
1146
|
+
change.object.emit(`change:${change.name}`, change);
|
|
1147
|
+
this.emit('change', change);
|
|
1148
|
+
this.bus.emit('change', change);
|
|
1149
|
+
this.bus.sen.emit('change', change);
|
|
1150
|
+
}
|
|
1151
|
+
if (this.batcher) {
|
|
1152
|
+
this.batcher.push(change);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export class SenRemoteObject extends EventEmitter {
|
|
1158
|
+
constructor(bus, info) {
|
|
1159
|
+
super();
|
|
1160
|
+
this.bus = bus;
|
|
1161
|
+
this.id = info.id;
|
|
1162
|
+
this.name = info.name;
|
|
1163
|
+
this.className = info.className;
|
|
1164
|
+
this.typeHash = info.typeHash;
|
|
1165
|
+
this.ownerId = info.ownerId;
|
|
1166
|
+
this.interestId = info.interestId;
|
|
1167
|
+
this.snapshot = {};
|
|
1168
|
+
this.spec = undefined;
|
|
1169
|
+
this.pendingState = undefined;
|
|
1170
|
+
this.timestamp = undefined;
|
|
1171
|
+
this.timestampNs = undefined;
|
|
1172
|
+
this.lastStateTimestamp = undefined;
|
|
1173
|
+
this.lastStateTimestampNs = undefined;
|
|
1174
|
+
this.lastUpdateTimestamp = undefined;
|
|
1175
|
+
this.lastUpdateTimestampNs = undefined;
|
|
1176
|
+
this.propertyTimestamps = new Map();
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
matches(selector) {
|
|
1180
|
+
if (typeof selector === 'function') {
|
|
1181
|
+
return Boolean(selector(this));
|
|
1182
|
+
}
|
|
1183
|
+
if (typeof selector === 'number') {
|
|
1184
|
+
return this.id === selector;
|
|
1185
|
+
}
|
|
1186
|
+
return this.name === selector || this.className === selector || String(this.id) === String(selector);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
property(name) {
|
|
1190
|
+
return findByName(collectClassMembers(this.spec, this.bus.typeRegistry, 'properties'), name);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
method(name) {
|
|
1194
|
+
return findByName(collectClassMembers(this.spec, this.bus.typeRegistry, 'methods'), name);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
event(name) {
|
|
1198
|
+
return findByName(collectClassMembers(this.spec, this.bus.typeRegistry, 'events'), name);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async waitForType(options = {}) {
|
|
1202
|
+
if (this.spec) {
|
|
1203
|
+
return this.spec;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const timeoutMs = options.timeout ?? 3000;
|
|
1207
|
+
return await new Promise((resolve, reject) => {
|
|
1208
|
+
const timeout = setTimeout(() => {
|
|
1209
|
+
this.off('type', onType);
|
|
1210
|
+
reject(new Error(`timeout waiting for SEN type ${this.className}`));
|
|
1211
|
+
}, timeoutMs);
|
|
1212
|
+
const onType = spec => {
|
|
1213
|
+
clearTimeout(timeout);
|
|
1214
|
+
this.off('type', onType);
|
|
1215
|
+
resolve(spec);
|
|
1216
|
+
};
|
|
1217
|
+
this.on('type', onType);
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async get(name) {
|
|
1222
|
+
return this.snapshot[name];
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
getPropertyTimestamp(name) {
|
|
1226
|
+
return this.propertyTimestamps.get(name);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async set(name, value, options = {}) {
|
|
1230
|
+
await this.waitForType(options);
|
|
1231
|
+
const property = this.property(name);
|
|
1232
|
+
if (!property) {
|
|
1233
|
+
throw new Error(`SEN property not found: ${name}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (!property.category?.endsWith('RW')) {
|
|
1236
|
+
throw new Error(`SEN property is read-only: ${this.className}.${name}`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const methodName = setterName(property.name);
|
|
1240
|
+
await this.bus.callObjectMethod(this, {
|
|
1241
|
+
id: methodHash(methodName),
|
|
1242
|
+
name: methodName,
|
|
1243
|
+
args: [{ name: 'value', type: property.type }],
|
|
1244
|
+
returnType: 'void',
|
|
1245
|
+
transportMode: property.transportMode
|
|
1246
|
+
}, [value], options);
|
|
1247
|
+
this.snapshot[name] = value;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async call(name, args = [], options = {}) {
|
|
1251
|
+
await this.waitForType(options);
|
|
1252
|
+
const method = this.method(name);
|
|
1253
|
+
if (!method) {
|
|
1254
|
+
throw new Error(`SEN method not found: ${this.className}.${name}`);
|
|
1255
|
+
}
|
|
1256
|
+
if (method.localOnly) {
|
|
1257
|
+
throw new Error(`SEN method is localOnly and cannot be called remotely: ${this.className}.${name}`);
|
|
1258
|
+
}
|
|
1259
|
+
return await this.bus.callObjectMethod(this, method, args, options);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
applyState(buffer, source, timestamp) {
|
|
1263
|
+
const timestampNs = normalizeTimestampNs(timestamp);
|
|
1264
|
+
this.#rememberObjectTimestamp(source, timestampNs);
|
|
1265
|
+
|
|
1266
|
+
if (!this.spec) {
|
|
1267
|
+
this.pendingState = { buffer, source, timestampNs };
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const interest = this.bus.interests.get(this.interestId);
|
|
1272
|
+
const values = decodePropertyValues(buffer, this.spec, this.bus.typeRegistry, interest?.decodeOptions());
|
|
1273
|
+
let complete = true;
|
|
1274
|
+
for (const value of values) {
|
|
1275
|
+
if (!value.decoded) {
|
|
1276
|
+
complete = false;
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
const previous = this.snapshot[value.name];
|
|
1280
|
+
this.snapshot[value.name] = value.value;
|
|
1281
|
+
if (timestampNs !== undefined) {
|
|
1282
|
+
this.propertyTimestamps.set(value.name, timestampNs);
|
|
1283
|
+
}
|
|
1284
|
+
const change = {
|
|
1285
|
+
object: this,
|
|
1286
|
+
source,
|
|
1287
|
+
timestamp: timestampNs,
|
|
1288
|
+
timestampNs,
|
|
1289
|
+
name: value.name,
|
|
1290
|
+
type: value.type,
|
|
1291
|
+
value: value.value,
|
|
1292
|
+
previous,
|
|
1293
|
+
property: value.property
|
|
1294
|
+
};
|
|
1295
|
+
if (interest) {
|
|
1296
|
+
interest.publishChange(change);
|
|
1297
|
+
} else {
|
|
1298
|
+
this.emit('change', change);
|
|
1299
|
+
this.emit(`change:${value.name}`, change);
|
|
1300
|
+
this.bus.emit('change', change);
|
|
1301
|
+
this.bus.sen.emit('change', change);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
this.pendingState = complete ? undefined : { buffer, source, timestampNs };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
#rememberObjectTimestamp(source, timestampNs) {
|
|
1309
|
+
if (timestampNs === undefined) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
this.timestamp = timestampNs;
|
|
1313
|
+
this.timestampNs = timestampNs;
|
|
1314
|
+
if (source === 'state') {
|
|
1315
|
+
this.lastStateTimestamp = timestampNs;
|
|
1316
|
+
this.lastStateTimestampNs = timestampNs;
|
|
1317
|
+
} else if (source === 'update') {
|
|
1318
|
+
this.lastUpdateTimestamp = timestampNs;
|
|
1319
|
+
this.lastUpdateTimestampNs = timestampNs;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
emitRuntimeEvent(item) {
|
|
1324
|
+
const eventSpec = collectClassMembers(this.spec, this.bus.typeRegistry, 'events')
|
|
1325
|
+
.find(candidate => candidate.id === item.eventId);
|
|
1326
|
+
const args = eventSpec
|
|
1327
|
+
? decodeArguments(item.argumentsBuffer, eventSpec.args, this.bus.typeRegistry)
|
|
1328
|
+
: undefined;
|
|
1329
|
+
const event = {
|
|
1330
|
+
object: this,
|
|
1331
|
+
id: item.eventId,
|
|
1332
|
+
name: eventSpec?.name,
|
|
1333
|
+
creationTime: item.creationTime,
|
|
1334
|
+
creationTimeNs: normalizeTimestampNs(item.creationTime),
|
|
1335
|
+
args,
|
|
1336
|
+
raw: item.argumentsBuffer
|
|
1337
|
+
};
|
|
1338
|
+
this.emit('event', event);
|
|
1339
|
+
if (event.name) {
|
|
1340
|
+
this.emit(event.name, event);
|
|
1341
|
+
}
|
|
1342
|
+
this.bus.interests.get(this.interestId)?.emit('event', event);
|
|
1343
|
+
this.bus.emit('event', event);
|
|
1344
|
+
this.bus.sen.emit('event', event);
|
|
1345
|
+
}
|
|
1346
|
+
}
|