sen-ether-client 0.1.0 → 0.1.2

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 CHANGED
@@ -45,14 +45,21 @@ Connection options:
45
45
  - `timeout`: discovery and operation timeout in ms.
46
46
  - `discoverySettleMs`: discovery settle time after the first process is found.
47
47
  Defaults to `100`.
48
+ - `busDiscoverySettleMs`: max wait after a lightweight session connection while
49
+ bus announcements arrive. Defaults to at least `300`.
48
50
  - `reconnect`: whether to reconnect and restart interests.
49
51
  - `reconnectDelayMs`: delay between reconnect attempts.
50
- - `maxReconnectAttempts`: maximum reconnect attempts.
52
+ - `maxReconnectAttempts`: maximum reconnect attempts. Defaults to `0`, which
53
+ means unlimited retries.
51
54
  - `participantReadyTimeoutMs`: short non-fatal grace timeout for bus
52
55
  participant acknowledgements. Defaults to `1000`.
53
56
  - `socketKeepAlive`: enable TCP keepalive. Defaults to `true`.
54
57
  - `socketIdleTimeoutMs`: optional TCP idle timeout. Defaults to `0` because
55
58
  valid SEN connections can be quiet on TCP while bus data flows separately.
59
+ - `presenceTimeoutMs`: close and reconnect when the connected SEN process stops
60
+ announcing ether presence beams. Defaults to `5000`; set `0` to disable.
61
+ - `presenceCheckIntervalMs`: presence watchdog check interval. Defaults to
62
+ `1000`.
56
63
 
57
64
  `Sen.connect()` uses multicast discovery. `sen-ether-client` reads this SEN environment
58
65
  variable as its multicast default:
@@ -104,6 +111,7 @@ Main methods:
104
111
  - `await sen.connect(options)`
105
112
  - `await sen.interest(query, options)`
106
113
  - `await sen.session(name)`
114
+ - `await sen.discoverBuses(options)`
107
115
  - `sen.listSessions()`
108
116
  - `sen.listBuses(options)`
109
117
  - `await sen.bus(name, options)`
@@ -117,6 +125,9 @@ Session and bus navigation:
117
125
  ```js
118
126
  const sen = await Sen.connect();
119
127
 
128
+ console.log(await sen.discoverBuses());
129
+ // [{ session: 'hmi', bus: 'diagnostics', qualified: 'hmi.diagnostics' }]
130
+
120
131
  for (const sessionName of sen.listSessions()) {
121
132
  const session = await sen.session(sessionName);
122
133
  console.log(sessionName, session.listBuses());
@@ -125,6 +136,10 @@ for (const sessionName of sen.listSessions()) {
125
136
  const diagnostics = await sen.session('hmi').then(hmi => hmi.bus('diagnostics'));
126
137
  ```
127
138
 
139
+ `discoverBuses()` does not create interests and does not join any SEN bus. It
140
+ does open a lightweight process connection per discovered session, because SEN
141
+ presence beams announce sessions/processes but not the bus list.
142
+
128
143
  Main events:
129
144
 
130
145
  - `connect`
package/README.md CHANGED
@@ -84,6 +84,11 @@ const sen = await Sen.connect({
84
84
  });
85
85
  ```
86
86
 
87
+ Connected sessions are monitored through SEN ether presence beams. If the
88
+ remote process stops announcing itself for `presenceTimeoutMs` milliseconds
89
+ (default `5000`), the client closes the stale connection and restarts the
90
+ configured interests.
91
+
87
92
  `sen-ether-client` can work with several SEN sessions from the same client. The session
88
93
  is inferred from the query:
89
94
 
@@ -98,6 +103,8 @@ You can also navigate explicitly through sessions and buses:
98
103
  const sen = await Sen.connect();
99
104
 
100
105
  console.log(sen.listSessions());
106
+ console.log(await sen.discoverBuses());
107
+ // [{ session: 'hmi', bus: 'diagnostics', qualified: 'hmi.diagnostics' }]
101
108
 
102
109
  const hmi = await sen.session('hmi');
103
110
  console.log(hmi.listBuses());
@@ -106,6 +113,11 @@ const diagnostics = await hmi.bus('diagnostics');
106
113
  const probe = await diagnostics.waitFor('EtherProbe');
107
114
  ```
108
115
 
116
+ `discoverBuses()` does not create interests and does not join any SEN bus. It
117
+ uses discovery to find sessions and opens lightweight process connections only
118
+ to read bus announcements. If buses are not announced immediately after the
119
+ process connection, it waits up to `busDiscoverySettleMs` milliseconds.
120
+
109
121
  You can also connect to one explicit session:
110
122
 
111
123
  ```js
@@ -254,7 +254,9 @@ try {
254
254
 
255
255
  const client = new EtherClient({
256
256
  sessionName: target.session.name,
257
- appName: 'sen-ether-probe'
257
+ appName: 'sen-ether-probe',
258
+ interfaceAddress: options.interfaceAddress,
259
+ discoveryPort: options.port
258
260
  });
259
261
  const remoteBuses = new Set();
260
262
  const requestedTypeHashes = new Set();
@@ -405,7 +407,7 @@ try {
405
407
  });
406
408
  }
407
409
 
408
- client.joinBus(bus);
410
+ await client.joinBus(bus);
409
411
  await waitForEvent(client, 'busParticipantReady', 5000).catch(error => {
410
412
  console.warn(`[warn] ${error.message}; starting interest anyway`);
411
413
  });
package/index.js CHANGED
@@ -34,13 +34,16 @@
34
34
  * @property {string} [app] Remote process appName substring filter.
35
35
  * @property {number} [timeout=3000] Discovery and operation timeout in ms.
36
36
  * @property {number} [discoverySettleMs=100] Discovery settle time after the first process is found.
37
+ * @property {number} [busDiscoverySettleMs=300] Max wait after lightweight session connect before reading bus announcements.
37
38
  * @property {number} [participantReadyTimeoutMs=1000] Short grace timeout for non-fatal bus participant acknowledgements.
38
39
  * @property {boolean} [reconnect=true] Reconnect and restart interests after disconnection.
39
40
  * @property {number} [reconnectDelayMs=500] Delay between reconnect attempts.
40
- * @property {number} [maxReconnectAttempts=10] Maximum reconnect attempts.
41
+ * @property {number} [maxReconnectAttempts=0] Maximum reconnect attempts. `0` means unlimited.
41
42
  * @property {boolean} [socketKeepAlive=true] Enable TCP keepalive on SEN ether connections.
42
43
  * @property {number} [socketKeepAliveInitialDelayMs=1000] TCP keepalive initial delay.
43
44
  * @property {number} [socketIdleTimeoutMs=0] Optional transport idle timeout in ms. `0` disables it.
45
+ * @property {number} [presenceTimeoutMs=5000] Close and reconnect when the connected SEN process stops announcing presence beams. `0` disables it.
46
+ * @property {number} [presenceCheckIntervalMs=1000] Presence watchdog check interval in ms.
44
47
  * @property {string} [interfaceAddress] Local interface address or interface name for multicast discovery.
45
48
  * @property {object} [target] Already discovered/direct SEN target.
46
49
  */
@@ -64,6 +67,13 @@
64
67
  * @property {boolean} [qualified=false] Return session-qualified bus names.
65
68
  */
66
69
 
70
+ /**
71
+ * @typedef {object} SenBusSummary
72
+ * @property {string} session SEN session name.
73
+ * @property {string} bus Bus name local to the session.
74
+ * @property {string} qualified Session-qualified bus name usable in `SELECT * FROM <qualified>`.
75
+ */
76
+
67
77
  /**
68
78
  * @typedef {string | number | ((object: SenRemoteObject) => boolean)} SenObjectSelector
69
79
  */
package/lib/client.js CHANGED
@@ -24,6 +24,18 @@ import { crc32 } from './crc32.js';
24
24
 
25
25
  const LINUX_OS_KIND = 1;
26
26
  const X64_CPU_ARCH = 1;
27
+ const DEFAULT_DISCOVERY_PORT = 60543;
28
+ const DEFAULT_BUS_MULTICAST_PORT = 50985;
29
+ const BUS_HASH_SEED = 15071983;
30
+ const FNV1A_OFFSET_BASIS = 0x811c9dc5;
31
+ const FNV1A_PRIME = 0x01000193;
32
+ const HASH_COMBINE_MAGIC = 0x9e3779b9;
33
+ const DEFAULT_MULTICAST_RANGE = Object.freeze([
34
+ { min: 224, max: 239 },
35
+ { min: 0, max: 255 },
36
+ { min: 0, max: 255 },
37
+ { min: 0, max: 255 }
38
+ ]);
27
39
 
28
40
  function randomUInt32() {
29
41
  return randomBytes(4).readUInt32LE(0);
@@ -57,6 +69,87 @@ function detectCpuArch() {
57
69
  }
58
70
  }
59
71
 
72
+ function discoveryPortFromEnv() {
73
+ const value = process.env.SEN_ETHER_DISCOVERY_PORT;
74
+ if (!value) {
75
+ return undefined;
76
+ }
77
+ const port = Number(value);
78
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
79
+ throw new Error(`invalid SEN discovery port in environment: ${value}`);
80
+ }
81
+ return port;
82
+ }
83
+
84
+ function resolveInterfaceAddress(value) {
85
+ const text = String(value || '').trim();
86
+ if (!text) {
87
+ return undefined;
88
+ }
89
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(text)) {
90
+ return text;
91
+ }
92
+
93
+ const interfaces = os.networkInterfaces();
94
+ const candidates = interfaces[text];
95
+ if (!candidates) {
96
+ return text;
97
+ }
98
+
99
+ const ipv4 = candidates.find(item => (item.family === 'IPv4' || item.family === 4) && !item.internal);
100
+ if (!ipv4) {
101
+ throw new Error(`network interface "${text}" has no non-internal IPv4 address`);
102
+ }
103
+ return ipv4.address;
104
+ }
105
+
106
+ function normalizeMulticastRange(value) {
107
+ const ranges = Array.isArray(value) && value.length === 4 ? value : DEFAULT_MULTICAST_RANGE;
108
+ return ranges.map((range, index) => {
109
+ const fallback = DEFAULT_MULTICAST_RANGE[index];
110
+ const min = Number(range?.min ?? fallback.min);
111
+ const max = Number(range?.max ?? fallback.max);
112
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max > 255 || min > max) {
113
+ throw new Error(`invalid SEN bus multicast range at byte ${index}`);
114
+ }
115
+ return { min, max };
116
+ });
117
+ }
118
+
119
+ function computeByte(range, hashByte) {
120
+ const length = range.max - range.min;
121
+ return length !== 0 ? range.min + (hashByte % length) : range.min;
122
+ }
123
+
124
+ function hashIntegral(value, byteSize) {
125
+ let hash = FNV1A_OFFSET_BASIS;
126
+ for (let shift = (byteSize - 1) * 8; shift >= 0; shift -= 8) {
127
+ hash ^= (value >>> shift) & 0xff;
128
+ hash = Math.imul(hash, FNV1A_PRIME) >>> 0;
129
+ }
130
+ return hash >>> 0;
131
+ }
132
+
133
+ function combineHashed(seed, hashed) {
134
+ return (seed ^ (
135
+ (hashed + HASH_COMBINE_MAGIC + ((seed << 6) >>> 0) + (seed >>> 2)) >>> 0
136
+ )) >>> 0;
137
+ }
138
+
139
+ function computeBusMulticastGroup(sessionId, busId, discoveryPort, ranges) {
140
+ let hash = BUS_HASH_SEED;
141
+ hash = combineHashed(hash, hashIntegral(sessionId >>> 0, 4));
142
+ hash = combineHashed(hash, hashIntegral(busId >>> 0, 4));
143
+ hash = combineHashed(hash, hashIntegral(discoveryPort >>> 0, 2));
144
+ const bytes = [
145
+ computeByte(ranges[0], hash & 0xff),
146
+ computeByte(ranges[1], (hash >>> 8) & 0xff),
147
+ computeByte(ranges[2], (hash >>> 16) & 0xff),
148
+ computeByte(ranges[3], (hash >>> 24) & 0xff)
149
+ ];
150
+ return bytes.map((byte, index) => Math.min(Math.max(byte, ranges[index].min), ranges[index].max)).join('.');
151
+ }
152
+
60
153
  /**
61
154
  * Create a ProcessInfo compatible with sen::kernel::getOwnProcessInfo.
62
155
  *
@@ -139,9 +232,14 @@ export class EtherClient extends EventEmitter {
139
232
  socketKeepAlive: true,
140
233
  socketKeepAliveInitialDelayMs: 1000,
141
234
  socketIdleTimeoutMs: 0,
235
+ discoveryPort: discoveryPortFromEnv() ?? DEFAULT_DISCOVERY_PORT,
236
+ busMulticastPort: DEFAULT_BUS_MULTICAST_PORT,
237
+ busMulticastRange: DEFAULT_MULTICAST_RANGE,
142
238
  ...options
143
239
  };
144
240
  this.processInfo = createProcessInfo(this.options);
241
+ this.interfaceAddress = resolveInterfaceAddress(this.options.interfaceAddress);
242
+ this.busMulticastRange = normalizeMulticastRange(this.options.busMulticastRange);
145
243
  this.socket = undefined;
146
244
  this.udpSocket = undefined;
147
245
  this.receiveBuffer = Buffer.alloc(0);
@@ -242,6 +340,14 @@ export class EtherClient extends EventEmitter {
242
340
  udpSocket.close(resolve);
243
341
  }));
244
342
  }
343
+ for (const busState of this.buses.values()) {
344
+ if (busState.multicastSocket) {
345
+ closing.push(new Promise(resolve => {
346
+ busState.multicastSocket.close(resolve);
347
+ }));
348
+ }
349
+ }
350
+ this.buses.clear();
245
351
 
246
352
  await Promise.allSettled(closing);
247
353
  }
@@ -252,7 +358,7 @@ export class EtherClient extends EventEmitter {
252
358
  * @param {string} busName
253
359
  * @param {{ participantId?: number }} [options]
254
360
  */
255
- joinBus(busName, options = {}) {
361
+ async joinBus(busName, options = {}) {
256
362
  if (!this.socket) {
257
363
  throw new Error('EtherClient is not connected');
258
364
  }
@@ -264,10 +370,21 @@ export class EtherClient extends EventEmitter {
264
370
  busId,
265
371
  participantId,
266
372
  readyRemoteParticipants: new Set(),
267
- interests: new Map()
373
+ interests: new Map(),
374
+ multicastSocket: undefined,
375
+ multicastGroup: undefined
268
376
  };
269
377
 
270
378
  this.buses.set(busId, bus);
379
+ try {
380
+ if (this.options.busMulticast !== false) {
381
+ await this.#openBusMulticastSocket(bus);
382
+ }
383
+ } catch (error) {
384
+ this.buses.delete(busId);
385
+ throw error;
386
+ }
387
+
271
388
  this.#sendControlPayload(encodeEtherControlMessage({
272
389
  type: 'BusJoined',
273
390
  value: {
@@ -277,8 +394,20 @@ export class EtherClient extends EventEmitter {
277
394
  }
278
395
  }));
279
396
 
280
- this.emit('busJoinedLocal', { busName, busId, participantId });
281
- return { busName, busId, participantId };
397
+ this.emit('busJoinedLocal', {
398
+ busName,
399
+ busId,
400
+ participantId,
401
+ multicastGroup: bus.multicastGroup,
402
+ multicastPort: this.options.busMulticastPort
403
+ });
404
+ return {
405
+ busName,
406
+ busId,
407
+ participantId,
408
+ multicastGroup: bus.multicastGroup,
409
+ multicastPort: this.options.busMulticastPort
410
+ };
282
411
  }
283
412
 
284
413
  /**
@@ -426,6 +555,10 @@ export class EtherClient extends EventEmitter {
426
555
  }
427
556
  }));
428
557
  this.buses.delete(busState.busId);
558
+ if (busState.multicastSocket) {
559
+ busState.multicastSocket.close();
560
+ busState.multicastSocket = undefined;
561
+ }
429
562
  this.emit('busLeftLocal', {
430
563
  busName: busState.busName,
431
564
  busId: busState.busId,
@@ -573,6 +706,81 @@ export class EtherClient extends EventEmitter {
573
706
  }
574
707
  }
575
708
 
709
+ #onMulticastBusDatagram(busState, message, remote) {
710
+ try {
711
+ if (message.length < 8) {
712
+ throw new RangeError(`SEN multicast bus datagram too small: ${message.length}`);
713
+ }
714
+ const processId = message.readUInt32LE(0);
715
+ const payloadSize = message.readUInt32LE(4);
716
+ if (processId === this.processInfo.processId) {
717
+ return;
718
+ }
719
+ if (payloadSize !== message.length - 8) {
720
+ throw new RangeError(
721
+ `SEN multicast bus payload size mismatch: expected ${payloadSize}, got ${message.length - 8}`
722
+ );
723
+ }
724
+
725
+ const frame = {
726
+ to: busState.participantId,
727
+ busId: busState.busId,
728
+ message: message.subarray(8)
729
+ };
730
+ const busMessage = decodeBusMessage(frame.message);
731
+ this.emit('busFrame', { ...frame, busMessage, remote, multicast: true });
732
+ if (busMessage.categoryName === 'controlMessage') {
733
+ this.emit('busControlMessage', { ...frame, control: busMessage.control, remote, multicast: true });
734
+ return;
735
+ }
736
+ this.emit(busMessage.categoryName, { ...frame, ...busMessage, remote, multicast: true });
737
+ } catch (error) {
738
+ this.emit('error', error);
739
+ }
740
+ }
741
+
742
+ async #openBusMulticastSocket(busState) {
743
+ const group = computeBusMulticastGroup(
744
+ this.processInfo.sessionId,
745
+ busState.busId,
746
+ this.options.discoveryPort,
747
+ this.busMulticastRange
748
+ );
749
+ const port = this.options.busMulticastPort;
750
+ const bindAddress = process.platform === 'win32' ? undefined : group;
751
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
752
+ busState.multicastSocket = socket;
753
+ busState.multicastGroup = group;
754
+
755
+ socket.on('message', (message, remote) => this.#onMulticastBusDatagram(busState, message, remote));
756
+ socket.on('error', error => this.emit('error', error));
757
+
758
+ await new Promise((resolve, reject) => {
759
+ const onError = error => {
760
+ socket.off('listening', onListening);
761
+ reject(error);
762
+ };
763
+ const onListening = () => {
764
+ socket.off('error', onError);
765
+ try {
766
+ socket.addMembership(group, this.interfaceAddress);
767
+ socket.setMulticastLoopback(true);
768
+ if (this.interfaceAddress) {
769
+ socket.setMulticastInterface(this.interfaceAddress);
770
+ }
771
+ } catch (error) {
772
+ reject(error);
773
+ return;
774
+ }
775
+ resolve();
776
+ };
777
+
778
+ socket.once('error', onError);
779
+ socket.once('listening', onListening);
780
+ socket.bind(port, bindAddress);
781
+ });
782
+ }
783
+
576
784
  #onRemoteParticipantReady(busState, frame, value) {
577
785
  if (value.id !== busState.participantId) {
578
786
  return;
package/lib/sen.js CHANGED
@@ -2,7 +2,7 @@ import { once } from 'node:events';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { decodePropertyValues, decodeValue, encodeArguments, decodeArguments } from './values.js';
4
4
  import { EtherClient } from './client.js';
5
- import { scan, scanTcpDiscoveryHub } from './discovery.js';
5
+ import { EtherDiscoveryScanner, TcpDiscoveryHubScanner, scan, scanTcpDiscoveryHub } from './discovery.js';
6
6
  import { methodHash } from './hash32.js';
7
7
 
8
8
  function wait(ms) {
@@ -48,15 +48,24 @@ function queryBusName(sessionName, bus) {
48
48
  return text.includes('.') || !session ? text : `${session}.${text}`;
49
49
  }
50
50
 
51
- function findTarget(processes, options) {
52
- let candidates = processes;
51
+ function targetSessionName(target) {
52
+ return target?.session?.name ?? target?.info?.sessionName ?? '';
53
+ }
54
+
55
+ function filterTargets(processes, options = {}) {
56
+ let candidates = [...processes];
53
57
  if (options.session) {
54
- candidates = candidates.filter(item => item.session?.name === options.session);
58
+ candidates = candidates.filter(item => targetSessionName(item) === options.session);
55
59
  }
56
60
  if (options.app) {
57
61
  const app = String(options.app).toLowerCase();
58
62
  candidates = candidates.filter(item => String(item.process?.appName || '').toLowerCase().includes(app));
59
63
  }
64
+ return candidates;
65
+ }
66
+
67
+ function findTarget(processes, options) {
68
+ const candidates = filterTargets(processes, options);
60
69
  if (!candidates.length) {
61
70
  return null;
62
71
  }
@@ -107,6 +116,19 @@ function sessionNameFromBusName(busName) {
107
116
  return idx > 0 ? text.slice(0, idx) : '';
108
117
  }
109
118
 
119
+ function busSummary(sessionName, busName) {
120
+ const session = String(sessionName || '').trim();
121
+ const bus = etherBusName(session, busName);
122
+ if (!session || !bus) {
123
+ return null;
124
+ }
125
+ return {
126
+ session,
127
+ bus,
128
+ qualified: queryBusName(session, bus)
129
+ };
130
+ }
131
+
110
132
  function selectorDescription(selector) {
111
133
  return typeof selector === 'function' ? '<predicate>' : String(selector);
112
134
  }
@@ -129,6 +151,24 @@ function normalizeTimestampNs(value) {
129
151
  return typeof value === 'bigint' ? value : BigInt(value);
130
152
  }
131
153
 
154
+ function stateRequestKey(interestId, objectId) {
155
+ return `${interestId >>> 0}:${objectId >>> 0}`;
156
+ }
157
+
158
+ async function waitForSessionBuses(session, timeoutMs) {
159
+ let buses = session.listBuses();
160
+ if (buses.length || timeoutMs <= 0) {
161
+ return buses;
162
+ }
163
+
164
+ const deadline = Date.now() + timeoutMs;
165
+ while (!buses.length && Date.now() < deadline) {
166
+ await wait(Math.min(50, Math.max(1, deadline - Date.now())));
167
+ buses = session.listBuses();
168
+ }
169
+ return buses;
170
+ }
171
+
132
172
  class ChangeBatcher {
133
173
  constructor(interest, options = {}) {
134
174
  this.interest = interest;
@@ -251,19 +291,40 @@ export class Sen extends EventEmitter {
251
291
  return await sen.connect(options);
252
292
  }
253
293
 
294
+ /**
295
+ * Discover visible SEN buses without creating interests or joining buses.
296
+ *
297
+ * SEN discovery beams expose sessions/processes. Bus names are announced only
298
+ * after a lightweight process connection, so this method connects to each
299
+ * discovered session long enough to read its remote bus announcements.
300
+ *
301
+ * @param {object} [options]
302
+ * @returns {Promise<Array<{session:string,bus:string,qualified:string}>>}
303
+ */
304
+ static async discoverBuses(options = {}) {
305
+ const sen = new Sen(options);
306
+ try {
307
+ return await sen.discoverBuses(options);
308
+ } finally {
309
+ await sen.close().catch(() => {});
310
+ }
311
+ }
312
+
254
313
  constructor(options = {}) {
255
314
  super();
256
315
  this.options = {
257
316
  appName: 'sen-ether-client',
258
317
  reconnect: true,
259
318
  reconnectDelayMs: 500,
260
- maxReconnectAttempts: 10,
319
+ maxReconnectAttempts: 0,
261
320
  timeout: 3000,
262
321
  discoverySettleMs: 100,
263
322
  participantReadyTimeoutMs: 1000,
264
323
  socketKeepAlive: true,
265
324
  socketKeepAliveInitialDelayMs: 1000,
266
325
  socketIdleTimeoutMs: 0,
326
+ presenceTimeoutMs: 5000,
327
+ presenceCheckIntervalMs: 1000,
267
328
  ...options
268
329
  };
269
330
  this.target = undefined;
@@ -271,9 +332,13 @@ export class Sen extends EventEmitter {
271
332
  this.connectOptions = undefined;
272
333
  this.manualClose = false;
273
334
  this.reconnecting = false;
335
+ this.presenceScanner = undefined;
336
+ this.presenceTimer = undefined;
337
+ this.presenceLastSeen = 0;
274
338
  this.remoteBuses = new Set();
275
339
  this.buses = new Map();
276
340
  this.sessions = new Map();
341
+ this.targets = [];
277
342
  this.targetsBySession = new Map();
278
343
  }
279
344
 
@@ -298,8 +363,9 @@ export class Sen extends EventEmitter {
298
363
  if (!targets.length) {
299
364
  throw new Error('no SEN ether processes discovered');
300
365
  }
366
+ this.targets = targets;
301
367
  for (const target of targets) {
302
- const sessionName = target.session?.name ?? target.info?.sessionName;
368
+ const sessionName = targetSessionName(target);
303
369
  if (sessionName && !this.targetsBySession.has(sessionName)) {
304
370
  this.targetsBySession.set(sessionName, target);
305
371
  }
@@ -324,22 +390,43 @@ export class Sen extends EventEmitter {
324
390
  if (!sessionName) {
325
391
  throw new Error('cannot connect without a SEN session name');
326
392
  }
393
+ if (!this.targets.includes(target)) {
394
+ this.targets.push(target);
395
+ }
396
+ if (!this.targetsBySession.has(sessionName)) {
397
+ this.targetsBySession.set(sessionName, target);
398
+ }
327
399
 
328
400
  const client = new EtherClient({
329
401
  sessionName,
330
402
  appName: config.appName,
331
403
  socketKeepAlive: config.socketKeepAlive,
332
404
  socketKeepAliveInitialDelayMs: config.socketKeepAliveInitialDelayMs,
333
- socketIdleTimeoutMs: config.socketIdleTimeoutMs
405
+ socketIdleTimeoutMs: config.socketIdleTimeoutMs,
406
+ interfaceAddress: config.interfaceAddress,
407
+ discoveryPort: config.port,
408
+ busMulticast: config.busMulticast,
409
+ busMulticastPort: config.busMulticastPort,
410
+ busMulticastRange: config.busMulticastRange
334
411
  });
335
412
  this.client = client;
336
413
  this.target = target;
337
414
  this.#wireClient(client);
338
415
 
339
- await client.connect(target);
340
- await waitForEvent(client, 'ready', config.timeout ?? 3000);
341
- this.emit('connect', { target, sessionName });
342
- return this;
416
+ try {
417
+ await client.connect(target);
418
+ await waitForEvent(client, 'ready', config.timeout ?? 3000);
419
+ this.#startPresenceWatchdog(target, config);
420
+ this.emit('connect', { target, sessionName });
421
+ return this;
422
+ } catch (error) {
423
+ await client.close().catch(closeError => this.emit('warning', closeError));
424
+ if (this.client === client) {
425
+ this.client = undefined;
426
+ this.target = undefined;
427
+ }
428
+ throw error;
429
+ }
343
430
  }
344
431
 
345
432
  /**
@@ -375,7 +462,7 @@ export class Sen extends EventEmitter {
375
462
 
376
463
  let senBus = this.buses.get(bus);
377
464
  if (!senBus) {
378
- const joined = this.client.joinBus(bus);
465
+ const joined = await this.client.joinBus(bus);
379
466
  const participantReadyTimeoutMs = this.#participantReadyTimeout(options, timeout);
380
467
  await waitForEvent(this.client, 'busParticipantReady', participantReadyTimeoutMs).catch(error => {
381
468
  this.emit('warning', error);
@@ -429,7 +516,7 @@ export class Sen extends EventEmitter {
429
516
 
430
517
  let senBus = this.buses.get(bus);
431
518
  if (!senBus) {
432
- const joined = this.client.joinBus(bus);
519
+ const joined = await this.client.joinBus(bus);
433
520
  const participantReadyTimeoutMs = this.#participantReadyTimeout(options, timeout);
434
521
  await waitForEvent(this.client, 'busParticipantReady', participantReadyTimeoutMs).catch(error => {
435
522
  this.emit('warning', error);
@@ -501,9 +588,10 @@ export class Sen extends EventEmitter {
501
588
  }
502
589
 
503
590
  return [...new Set([
591
+ ...this.targets.map(targetSessionName),
504
592
  ...this.targetsBySession.keys(),
505
593
  ...this.sessions.keys()
506
- ])].sort();
594
+ ].filter(Boolean))].sort();
507
595
  }
508
596
 
509
597
  listBuses(options = {}) {
@@ -519,6 +607,104 @@ export class Sen extends EventEmitter {
519
607
  .map(busName => options.qualified ? queryBusName(sessionName, busName) : busName);
520
608
  }
521
609
 
610
+ /**
611
+ * Discover visible SEN buses without creating interests or joining buses.
612
+ *
613
+ * @param {object} [options]
614
+ * @param {string} [options.session] Optional session filter.
615
+ * @param {number} [options.busDiscoverySettleMs] Delay after lightweight session connect before reading announced buses.
616
+ * @returns {Promise<Array<{session:string,bus:string,qualified:string}>>}
617
+ */
618
+ async discoverBuses(options = {}) {
619
+ const config = { ...this.options, ...options };
620
+ const settleMs = Math.max(0, Number(config.busDiscoverySettleMs ?? Math.max(config.discoverySettleMs ?? 100, 1000)) || 0);
621
+
622
+ if (this.client) {
623
+ const sessionName = this.target?.session?.name ?? this.client.processInfo.sessionName;
624
+ return (await waitForSessionBuses(this, settleMs))
625
+ .map(busName => busSummary(sessionName, busName))
626
+ .filter(Boolean)
627
+ .sort((a, b) => a.qualified.localeCompare(b.qualified));
628
+ }
629
+
630
+ if (!this.targets.length && !this.sessions.size) {
631
+ const targets = await this.#discoverTargets(config);
632
+ if (!targets.length) {
633
+ throw new Error('no SEN ether processes discovered');
634
+ }
635
+ this.targets = targets;
636
+ for (const target of targets) {
637
+ const sessionName = targetSessionName(target);
638
+ if (sessionName && !this.targetsBySession.has(sessionName)) {
639
+ this.targetsBySession.set(sessionName, target);
640
+ }
641
+ }
642
+ }
643
+
644
+ const summaries = new Map();
645
+ const addBus = (sessionName, busName) => {
646
+ const summary = busSummary(sessionName, busName);
647
+ if (summary) {
648
+ summaries.set(summary.qualified, summary);
649
+ }
650
+ };
651
+
652
+ for (const session of this.sessions.values()) {
653
+ const sessionName = session.target?.session?.name ?? session.client?.processInfo?.sessionName;
654
+ for (const busName of await waitForSessionBuses(session, settleMs)) {
655
+ addBus(sessionName, busName);
656
+ }
657
+ }
658
+
659
+ const targets = filterTargets(this.targets, config);
660
+ const discoveries = targets.map(async target => {
661
+ const sessionName = targetSessionName(target);
662
+ if (!sessionName) {
663
+ return;
664
+ }
665
+
666
+ const session = new Sen({
667
+ ...config,
668
+ session: sessionName,
669
+ reconnect: false
670
+ });
671
+ session.on('warning', error => this.emit('warning', error));
672
+ session.on('error', error => this.emit('warning', error));
673
+ try {
674
+ await session.connect({
675
+ ...config,
676
+ session: sessionName,
677
+ target,
678
+ reconnect: false
679
+ });
680
+ for (const busName of await waitForSessionBuses(session, settleMs)) {
681
+ addBus(sessionName, busName);
682
+ }
683
+ } finally {
684
+ await session.close().catch(error => this.emit('warning', error));
685
+ }
686
+ });
687
+
688
+ const results = await Promise.allSettled(discoveries);
689
+ const failures = [];
690
+ for (const result of results) {
691
+ if (result.status === 'rejected') {
692
+ failures.push(result.reason);
693
+ this.emit('warning', result.reason);
694
+ }
695
+ }
696
+
697
+ if (!summaries.size && targets.length && failures.length === targets.length) {
698
+ const sessions = [...new Set(targets.map(targetSessionName).filter(Boolean))].join(', ');
699
+ const error = new Error(`could not read SEN bus announcements from any discovered target${sessions ? ` in sessions: ${sessions}` : ''}`);
700
+ error.code = 'SEN_BUS_DISCOVERY_FAILED';
701
+ error.cause = failures[0];
702
+ throw error;
703
+ }
704
+
705
+ return [...summaries.values()].sort((a, b) => a.qualified.localeCompare(b.qualified));
706
+ }
707
+
522
708
  objects() {
523
709
  if (!this.client) {
524
710
  return [...this.sessions.values()].flatMap(session => session.objects());
@@ -561,13 +747,14 @@ export class Sen extends EventEmitter {
561
747
  async close() {
562
748
  this.manualClose = true;
563
749
  for (const session of this.sessions.values()) {
564
- await session.close();
750
+ await session.close().catch(error => this.emit('warning', error));
565
751
  }
566
752
  for (const bus of this.buses.values()) {
567
753
  bus.close();
568
754
  }
755
+ this.#stopPresenceWatchdog();
569
756
  await wait(50);
570
- await this.client?.close();
757
+ await this.client?.close().catch(error => this.emit('warning', error));
571
758
  this.client = undefined;
572
759
  this.sessions.clear();
573
760
  this.buses.clear();
@@ -675,9 +862,10 @@ export class Sen extends EventEmitter {
675
862
  this.emit('error', error);
676
863
  });
677
864
  client.on('close', hadError => {
865
+ this.#stopPresenceWatchdog();
678
866
  this.emit('close', hadError);
679
867
  if (!this.manualClose && this.options.reconnect !== false) {
680
- this.#reconnect().catch(error => this.emit('error', error));
868
+ this.#reconnect().catch(error => this.emit('warning', error));
681
869
  }
682
870
  });
683
871
  }
@@ -688,12 +876,15 @@ export class Sen extends EventEmitter {
688
876
  }
689
877
 
690
878
  this.reconnecting = true;
879
+ this.#stopPresenceWatchdog();
691
880
  await this.client?.close().catch(error => this.emit('warning', error));
692
881
  this.emit('reconnecting');
693
- const maxAttempts = this.connectOptions.maxReconnectAttempts ?? this.options.maxReconnectAttempts ?? 10;
882
+ const configuredMaxAttempts = this.connectOptions.maxReconnectAttempts ?? this.options.maxReconnectAttempts ?? 0;
883
+ const maxAttempts = Number(configuredMaxAttempts);
884
+ const unlimited = !Number.isFinite(maxAttempts) || maxAttempts <= 0;
694
885
  const delayMs = this.connectOptions.reconnectDelayMs ?? this.options.reconnectDelayMs ?? 500;
695
886
 
696
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
887
+ for (let attempt = 1; unlimited || attempt <= maxAttempts; attempt += 1) {
697
888
  let client;
698
889
  try {
699
890
  await wait(delayMs);
@@ -727,6 +918,7 @@ export class Sen extends EventEmitter {
727
918
 
728
919
  await client.connect(target);
729
920
  await waitForEvent(client, 'ready', config.timeout ?? 3000);
921
+ this.#startPresenceWatchdog(target, config);
730
922
 
731
923
  for (const bus of this.buses.values()) {
732
924
  await bus.rejoin(config.timeout ?? 3000);
@@ -737,6 +929,10 @@ export class Sen extends EventEmitter {
737
929
  return;
738
930
  } catch (error) {
739
931
  await client?.close().catch(closeError => this.emit('warning', closeError));
932
+ if (this.client === client) {
933
+ this.client = undefined;
934
+ this.target = undefined;
935
+ }
740
936
  if (this.manualClose) {
741
937
  this.reconnecting = false;
742
938
  return;
@@ -754,6 +950,74 @@ export class Sen extends EventEmitter {
754
950
  throw new Error(`failed to reconnect SEN ether after ${maxAttempts} attempt(s)`);
755
951
  }
756
952
 
953
+ #startPresenceWatchdog(target, config) {
954
+ this.#stopPresenceWatchdog();
955
+ const key = target?.key;
956
+ if (!key) {
957
+ return;
958
+ }
959
+
960
+ const timeoutMs = Number(config.presenceTimeoutMs ?? this.options.presenceTimeoutMs ?? 0);
961
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
962
+ return;
963
+ }
964
+
965
+ const intervalMs = Math.max(
966
+ 250,
967
+ Number(config.presenceCheckIntervalMs ?? this.options.presenceCheckIntervalMs ?? 1000) || 1000
968
+ );
969
+
970
+ const scanner = config.tcpHub
971
+ ? new TcpDiscoveryHubScanner(parseHostPort(config.tcpHub))
972
+ : new EtherDiscoveryScanner(config);
973
+
974
+ this.presenceScanner = scanner;
975
+ this.presenceLastSeen = Date.now();
976
+
977
+ scanner.on('beam', process => {
978
+ if (process.key === key) {
979
+ this.presenceLastSeen = Date.now();
980
+ }
981
+ });
982
+ scanner.on('error', error => this.emit('warning', error));
983
+ scanner.on('close', hadError => {
984
+ if (!this.manualClose && !this.reconnecting) {
985
+ this.emit('warning', new Error(`SEN ether discovery watchdog closed${hadError ? ' with error' : ''}`));
986
+ }
987
+ });
988
+ scanner.start().catch(error => this.emit('warning', error));
989
+
990
+ this.presenceTimer = setInterval(() => {
991
+ if (this.manualClose || this.reconnecting || !this.client) {
992
+ return;
993
+ }
994
+ const elapsedMs = Date.now() - this.presenceLastSeen;
995
+ if (elapsedMs <= timeoutMs) {
996
+ return;
997
+ }
998
+ const error = new Error(`SEN ether presence timeout after ${elapsedMs}ms without beam from ${key}`);
999
+ error.code = 'SEN_PRESENCE_TIMEOUT';
1000
+ this.emit('warning', error);
1001
+ this.client.socket?.destroy(error);
1002
+ }, intervalMs);
1003
+ this.presenceTimer.unref?.();
1004
+ }
1005
+
1006
+ #stopPresenceWatchdog() {
1007
+ if (this.presenceTimer) {
1008
+ clearInterval(this.presenceTimer);
1009
+ this.presenceTimer = undefined;
1010
+ }
1011
+
1012
+ const scanner = this.presenceScanner;
1013
+ this.presenceScanner = undefined;
1014
+ this.presenceLastSeen = 0;
1015
+ if (scanner) {
1016
+ scanner.removeAllListeners();
1017
+ scanner.stop().catch(error => this.emit('warning', error));
1018
+ }
1019
+ }
1020
+
757
1021
  #busForEvent(event) {
758
1022
  if (event.bus?.busName) {
759
1023
  return this.buses.get(event.bus.busName);
@@ -814,18 +1078,29 @@ export class SenBus extends EventEmitter {
814
1078
  const interestId = typeof id === 'object' ? id.id : id;
815
1079
  this.sen.client.stopInterest(this.name, interestId);
816
1080
  const interest = this.interests.get(interestId);
1081
+ this.#detachInterestObjects(interestId, interest);
817
1082
  this.interests.delete(interestId);
818
1083
  interest?.closeLocal();
819
1084
  interest?.emit('close');
820
1085
  }
821
1086
 
822
1087
  close() {
823
- for (const interest of this.interests.values()) {
824
- interest.closeLocal();
825
- this.sen.client.stopInterest(this.name, interest.id);
1088
+ for (const interest of [...this.interests.values()]) {
1089
+ try {
1090
+ this.stopInterest(interest.id);
1091
+ } catch (error) {
1092
+ this.#detachInterestObjects(interest.id, interest);
1093
+ this.interests.delete(interest.id);
1094
+ interest.closeLocal();
1095
+ interest.emit('close');
1096
+ this.sen.emit('warning', error);
1097
+ }
1098
+ }
1099
+ try {
1100
+ this.sen.client.leaveBus(this.name);
1101
+ } catch (error) {
1102
+ this.sen.emit('warning', error);
826
1103
  }
827
- this.interests.clear();
828
- this.sen.client.leaveBus(this.name);
829
1104
  }
830
1105
 
831
1106
  prepareReconnect() {
@@ -854,7 +1129,7 @@ export class SenBus extends EventEmitter {
854
1129
  await this.sen.waitForRemoteBus(this.name, busReadyTimeoutMs).catch(error => {
855
1130
  this.sen.emit('warning', error);
856
1131
  });
857
- const joined = this.sen.client.joinBus(this.name);
1132
+ const joined = await this.sen.client.joinBus(this.name);
858
1133
  this.id = joined.busId;
859
1134
  const participantReadyTimeoutMs = Math.min(timeoutMs, this.sen.options.participantReadyTimeoutMs ?? 1000);
860
1135
  await waitForEvent(this.sen.client, 'busParticipantReady', participantReadyTimeoutMs).catch(error => {
@@ -884,24 +1159,36 @@ export class SenBus extends EventEmitter {
884
1159
  const newTypeHashes = new Set();
885
1160
  for (const discovery of event.discoveries ?? []) {
886
1161
  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);
1162
+ let object = this.objectsById.get(info.id);
1163
+ const isNewObject = !object;
1164
+ if (!object) {
1165
+ object = new SenRemoteObject(this, {
1166
+ ...info,
1167
+ ownerId: event.ownerId,
1168
+ interestId: discovery.interestId
1169
+ });
1170
+ this.objectsById.set(object.id, object);
1171
+ } else {
1172
+ object.attachInterest(discovery.interestId);
1173
+ object.updateDiscoveryInfo({
1174
+ ...info,
1175
+ ownerId: event.ownerId
1176
+ });
1177
+ }
893
1178
  const interest = this.interests.get(discovery.interestId);
894
1179
  interest?.objectsById.set(object.id, object);
895
1180
  if (info.state?.length) {
896
- object.applyState(info.state, 'state', info.time);
1181
+ object.applyState(info.state, 'state', info.time, { interestId: discovery.interestId });
897
1182
  }
898
1183
  if (!this.requestedTypeHashes.has(info.typeHash)) {
899
1184
  this.requestedTypeHashes.add(info.typeHash);
900
1185
  newTypeHashes.add(info.typeHash);
901
1186
  }
902
1187
  interest?.emit('object', object);
903
- this.emit('object', object);
904
- this.sen.emit('object', object);
1188
+ if (isNewObject) {
1189
+ this.emit('object', object);
1190
+ this.sen.emit('object', object);
1191
+ }
905
1192
  }
906
1193
  }
907
1194
  if (newTypeHashes.size) {
@@ -914,20 +1201,47 @@ export class SenBus extends EventEmitter {
914
1201
  for (const removal of event.removals ?? []) {
915
1202
  for (const id of removal.ids ?? []) {
916
1203
  const object = this.objectsById.get(id);
917
- this.objectsById.delete(id);
918
- this.stateRequestedObjectIds.delete(id);
919
1204
  const interest = this.interests.get(removal.interestId);
920
1205
  interest?.objectsById.delete(id);
1206
+ this.stateRequestedObjectIds.delete(stateRequestKey(removal.interestId, id));
921
1207
  if (object) {
922
- object.emit('remove');
1208
+ object.detachInterest(removal.interestId);
1209
+ object.emit('remove', { interestId: removal.interestId });
923
1210
  interest?.emit('remove', object);
924
- this.emit('remove', object);
925
- this.sen.emit('remove', object);
1211
+ if (object.interestIds.size === 0) {
1212
+ this.objectsById.delete(id);
1213
+ this.requestedTypeHashes.delete(object.typeHash);
1214
+ this.emit('remove', object);
1215
+ this.sen.emit('remove', object);
1216
+ }
926
1217
  }
927
1218
  }
928
1219
  }
929
1220
  }
930
1221
 
1222
+ #detachInterestObjects(interestId, interest) {
1223
+ const normalizedInterestId = interestId >>> 0;
1224
+ const keyPrefix = `${normalizedInterestId}:`;
1225
+ for (const key of [...this.stateRequestedObjectIds]) {
1226
+ if (key.startsWith(keyPrefix)) {
1227
+ this.stateRequestedObjectIds.delete(key);
1228
+ }
1229
+ }
1230
+
1231
+ if (!interest) {
1232
+ return;
1233
+ }
1234
+
1235
+ for (const object of interest.objectsById.values()) {
1236
+ object.detachInterest(normalizedInterestId);
1237
+ if (object.interestIds.size === 0) {
1238
+ this.objectsById.delete(object.id);
1239
+ this.requestedTypeHashes.delete(object.typeHash);
1240
+ }
1241
+ }
1242
+ interest.objectsById.clear();
1243
+ }
1244
+
931
1245
  handleTypesInfoResponse(event) {
932
1246
  const dependentTypeHashes = new Set();
933
1247
  for (const type of event.types ?? []) {
@@ -962,7 +1276,7 @@ export class SenBus extends EventEmitter {
962
1276
  if (!object) {
963
1277
  continue;
964
1278
  }
965
- object.applyState(state.state, 'state', state.timestamp);
1279
+ object.applyState(state.state, 'state', state.timestamp, { interestId: response.interestId });
966
1280
  }
967
1281
  }
968
1282
  }
@@ -1042,14 +1356,20 @@ export class SenBus extends EventEmitter {
1042
1356
 
1043
1357
  #requestReadyObjectStates() {
1044
1358
  const requestsByInterest = new Map();
1045
- for (const object of this.objectsById.values()) {
1046
- if (this.stateRequestedObjectIds.has(object.id) || !object.spec) {
1047
- continue;
1359
+ for (const interest of this.interests.values()) {
1360
+ for (const object of interest.objectsById.values()) {
1361
+ if (!object.spec) {
1362
+ continue;
1363
+ }
1364
+ const key = stateRequestKey(interest.id, object.id);
1365
+ if (this.stateRequestedObjectIds.has(key)) {
1366
+ continue;
1367
+ }
1368
+ this.stateRequestedObjectIds.add(key);
1369
+ const ids = requestsByInterest.get(interest.id) ?? [];
1370
+ ids.push(object.id);
1371
+ requestsByInterest.set(interest.id, ids);
1048
1372
  }
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
1373
  }
1054
1374
 
1055
1375
  if (requestsByInterest.size) {
@@ -1066,7 +1386,8 @@ export class SenBus extends EventEmitter {
1066
1386
  object.applyState(
1067
1387
  object.pendingState.buffer,
1068
1388
  object.pendingState.source,
1069
- object.pendingState.timestampNs
1389
+ object.pendingState.timestampNs,
1390
+ { interestId: object.pendingState.interestId }
1070
1391
  );
1071
1392
  }
1072
1393
  }
@@ -1164,6 +1485,10 @@ export class SenRemoteObject extends EventEmitter {
1164
1485
  this.typeHash = info.typeHash;
1165
1486
  this.ownerId = info.ownerId;
1166
1487
  this.interestId = info.interestId;
1488
+ this.interestIds = new Set();
1489
+ if (info.interestId !== undefined) {
1490
+ this.interestIds.add(info.interestId);
1491
+ }
1167
1492
  this.snapshot = {};
1168
1493
  this.spec = undefined;
1169
1494
  this.pendingState = undefined;
@@ -1186,6 +1511,29 @@ export class SenRemoteObject extends EventEmitter {
1186
1511
  return this.name === selector || this.className === selector || String(this.id) === String(selector);
1187
1512
  }
1188
1513
 
1514
+ attachInterest(interestId) {
1515
+ if (interestId !== undefined) {
1516
+ this.interestIds.add(interestId);
1517
+ this.interestId = interestId;
1518
+ }
1519
+ }
1520
+
1521
+ detachInterest(interestId) {
1522
+ if (interestId !== undefined) {
1523
+ this.interestIds.delete(interestId);
1524
+ if (this.interestId === interestId) {
1525
+ this.interestId = this.interestIds.values().next().value;
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ updateDiscoveryInfo(info) {
1531
+ this.name = info.name ?? this.name;
1532
+ this.className = info.className ?? this.className;
1533
+ this.typeHash = info.typeHash ?? this.typeHash;
1534
+ this.ownerId = info.ownerId ?? this.ownerId;
1535
+ }
1536
+
1189
1537
  property(name) {
1190
1538
  return findByName(collectClassMembers(this.spec, this.bus.typeRegistry, 'properties'), name);
1191
1539
  }
@@ -1259,17 +1607,18 @@ export class SenRemoteObject extends EventEmitter {
1259
1607
  return await this.bus.callObjectMethod(this, method, args, options);
1260
1608
  }
1261
1609
 
1262
- applyState(buffer, source, timestamp) {
1610
+ applyState(buffer, source, timestamp, options = {}) {
1263
1611
  const timestampNs = normalizeTimestampNs(timestamp);
1264
1612
  this.#rememberObjectTimestamp(source, timestampNs);
1265
1613
 
1266
1614
  if (!this.spec) {
1267
- this.pendingState = { buffer, source, timestampNs };
1615
+ this.pendingState = { buffer, source, timestampNs, interestId: options.interestId };
1268
1616
  return;
1269
1617
  }
1270
1618
 
1271
- const interest = this.bus.interests.get(this.interestId);
1272
- const values = decodePropertyValues(buffer, this.spec, this.bus.typeRegistry, interest?.decodeOptions());
1619
+ const interests = this.#targetInterests(options.interestId);
1620
+ const decodeInterest = options.interestId !== undefined || interests.length === 1 ? interests[0] : undefined;
1621
+ const values = decodePropertyValues(buffer, this.spec, this.bus.typeRegistry, decodeInterest?.decodeOptions());
1273
1622
  let complete = true;
1274
1623
  for (const value of values) {
1275
1624
  if (!value.decoded) {
@@ -1292,9 +1641,10 @@ export class SenRemoteObject extends EventEmitter {
1292
1641
  previous,
1293
1642
  property: value.property
1294
1643
  };
1295
- if (interest) {
1644
+ for (const interest of interests) {
1296
1645
  interest.publishChange(change);
1297
- } else {
1646
+ }
1647
+ if (!interests.length) {
1298
1648
  this.emit('change', change);
1299
1649
  this.emit(`change:${value.name}`, change);
1300
1650
  this.bus.emit('change', change);
@@ -1302,7 +1652,22 @@ export class SenRemoteObject extends EventEmitter {
1302
1652
  }
1303
1653
  }
1304
1654
 
1305
- this.pendingState = complete ? undefined : { buffer, source, timestampNs };
1655
+ this.pendingState = complete ? undefined : { buffer, source, timestampNs, interestId: options.interestId };
1656
+ }
1657
+
1658
+ #targetInterests(interestId) {
1659
+ if (interestId !== undefined) {
1660
+ const interest = this.bus.interests.get(interestId);
1661
+ return interest ? [interest] : [];
1662
+ }
1663
+
1664
+ const interests = [];
1665
+ for (const interest of this.bus.interests.values()) {
1666
+ if (interest.objectsById.has(this.id)) {
1667
+ interests.push(interest);
1668
+ }
1669
+ }
1670
+ return interests;
1306
1671
  }
1307
1672
 
1308
1673
  #rememberObjectTimestamp(source, timestampNs) {
package/lib/values.js CHANGED
@@ -70,9 +70,7 @@ function collectClassProperties(spec, typeRegistry, seen = new Set()) {
70
70
 
71
71
  function decodeEnum(reader, spec) {
72
72
  const storage = spec.data.value.storageType;
73
- const key = decodeValueFromReader(reader, storage);
74
- const item = spec.data.value.enums.find(candidate => candidate.key === key);
75
- return item?.name ?? key;
73
+ return decodeValueFromReader(reader, storage);
76
74
  }
77
75
 
78
76
  function decodeStruct(reader, spec, typeRegistry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sen-ether-client",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pure JavaScript SEN client for existing kernels over ether",
5
5
  "senCompatibility": {
6
6
  "kernelProtocolVersion": 9,