@fibril/can-core 0.0.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/dist/index.js ADDED
@@ -0,0 +1,3077 @@
1
+ //#region src/ack-tracker.ts
2
+ var AckTracker = class {
3
+ #pending = /* @__PURE__ */ new Map();
4
+ #now;
5
+ constructor(now = () => Date.now()) {
6
+ this.#now = now;
7
+ }
8
+ get pendingCount() {
9
+ return this.#pending.size;
10
+ }
11
+ /** Returns false if `seq` is already tracked; the existing entry is kept. */
12
+ track(seq, deadlineMs, cb) {
13
+ const key = seq & 255;
14
+ if (this.#pending.has(key)) return false;
15
+ this.#pending.set(key, {
16
+ deadlineMs,
17
+ cb
18
+ });
19
+ return true;
20
+ }
21
+ /** Idempotent — fires `acked` if `seq` is pending. */
22
+ onAck(seq) {
23
+ const key = seq & 255;
24
+ const e = this.#pending.get(key);
25
+ if (!e) return;
26
+ this.#pending.delete(key);
27
+ e.cb({ kind: "acked" });
28
+ }
29
+ /** Idempotent — fires `naked` with the reason if `seq` is pending. */
30
+ onNak(seq, reason) {
31
+ const key = seq & 255;
32
+ const e = this.#pending.get(key);
33
+ if (!e) return;
34
+ this.#pending.delete(key);
35
+ e.cb({
36
+ kind: "naked",
37
+ reason
38
+ });
39
+ }
40
+ /** Fire `timed-out` for every entry whose deadline elapsed by `nowMs`. */
41
+ sweep(nowMs = this.#now()) {
42
+ for (const [seq, entry] of this.#pending) if (entry.deadlineMs <= nowMs) {
43
+ this.#pending.delete(seq);
44
+ entry.cb({ kind: "timed-out" });
45
+ }
46
+ }
47
+ /** Drop every entry without firing callbacks. Test-only. */
48
+ clear() {
49
+ this.#pending.clear();
50
+ }
51
+ };
52
+ //#endregion
53
+ //#region src/can-id.ts
54
+ /**
55
+ * CAN ID layout (Extended) and config-plane / NAK / svc-status / node-state
56
+ * constants. Mirrors SPEC §3, §5, §7 and `fibril_can_runtime/include/fibril_can/fcan_protocol.h`
57
+ * / `fibril_can_bridge/include/fibril_can_bridge/can_id.hpp`.
58
+ *
59
+ * Extended ID layout (29 bit, §3.2):
60
+ * [28:26] channel (3 bit)
61
+ * [25:19] node_id (7 bit, 0x7F = broadcast)
62
+ * [18:11] index (8 bit, service_index / cmd_code)
63
+ * [10:3] seq (8 bit, call_id / seq_num)
64
+ * [2] seg (1 bit, 0=SINGLE, 1=SEGMENT)
65
+ * [1:0] reserved (2 bit, 0)
66
+ *
67
+ * Standard ID (11 bit, §3.1) is dynamically allocated by the master and
68
+ * has no fixed bitfield meaning — kept here only as the wire mask.
69
+ */
70
+ const PROTOCOL_VERSION = 1;
71
+ const MAX_FRAME_PAYLOAD = 64;
72
+ const CHAN_CONFIG = 0;
73
+ const CHAN_SVC_REQUEST = 1;
74
+ const CHAN_SVC_RESPONSE = 2;
75
+ const NODE_ID_BROADCAST = 127;
76
+ const STD_ID_MASK = 2047;
77
+ const SVC_PARAM_SET = 253;
78
+ const SVC_CANCEL = 255;
79
+ const SVC_MAX_FIRST_CLASS = 252;
80
+ const CMD_DISCOVER = 1;
81
+ const CMD_ANNOUNCE = 2;
82
+ const CMD_SCHEMA_READ = 3;
83
+ const CMD_SCHEMA_DATA = 4;
84
+ const CMD_HEARTBEAT = 5;
85
+ const CMD_MASTER_HEARTBEAT = 7;
86
+ const CMD_FRAME_DEFINE = 16;
87
+ const CMD_HB_PERIOD = 19;
88
+ const CMD_START = 32;
89
+ const CMD_STOP = 33;
90
+ const CMD_ACK = 126;
91
+ const CMD_NAK = 127;
92
+ const NAK_UNKNOWN_CMD = 1;
93
+ const NAK_BAD_LENGTH = 2;
94
+ const NAK_BAD_PARAM = 3;
95
+ const NAK_NO_RESOURCE = 4;
96
+ const NAK_BAD_STATE = 5;
97
+ const NAK_ID_CONFLICT = 6;
98
+ const NAK_INDEX_RANGE = 7;
99
+ const NAK_DIR_MISMATCH = 8;
100
+ const NAK_SIZE_OVERFLOW = 9;
101
+ const STATE_UNPROVISIONED = 0;
102
+ const STATE_PROVISIONED = 1;
103
+ const STATE_RUNNING = 2;
104
+ const STATE_FAULT = 3;
105
+ const FAULT_NONE = 0;
106
+ const FAULT_NOMEM = 1;
107
+ const FAULT_INDEX_OVERFLOW = 2;
108
+ const FAULT_ID_CONFLICT = 3;
109
+ const DIR_TX_S2M = 0;
110
+ const DIR_RX_M2S = 1;
111
+ const SVC_OK = 0;
112
+ const SVC_ACCEPTED = 1;
113
+ const SVC_APP_ERROR = 2;
114
+ const SVC_BUSY = 3;
115
+ const SVC_UNAVAIL = 4;
116
+ const SVC_BAD_INDEX = 5;
117
+ const SVC_BAD_VALUE = 6;
118
+ const SVC_BAD_LENGTH = 8;
119
+ const SEG_FIRST_DATA = 61;
120
+ const SEG_REST_DATA = 63;
121
+ const SEG_MAX_TOTAL = 1024;
122
+ const SEG_MAX_SEQ = 31;
123
+ /** Byte offset of segment `seq` within the reassembled buffer (§7.4). */
124
+ function segOffset(seq) {
125
+ return seq === 0 ? 0 : 61 + (seq - 1) * 63;
126
+ }
127
+ /**
128
+ * Build a 29-bit Extended ID from its bitfields (§3.2). Inputs are masked
129
+ * down to their respective field widths.
130
+ */
131
+ function makeExtId(channel, nodeId, index, seq, seg = 0) {
132
+ return ((channel & 7) << 26 | (nodeId & 127) << 19 | (index & 255) << 11 | (seq & 255) << 3 | (seg & 1) << 2) >>> 0;
133
+ }
134
+ function extChannel(id) {
135
+ return id >>> 26 & 7;
136
+ }
137
+ function extNodeId(id) {
138
+ return id >>> 19 & 127;
139
+ }
140
+ function extIndex(id) {
141
+ return id >>> 11 & 255;
142
+ }
143
+ function extSeq(id) {
144
+ return id >>> 3 & 255;
145
+ }
146
+ function extSeg(id) {
147
+ return id >>> 2 & 1;
148
+ }
149
+ //#endregion
150
+ //#region src/dlc.ts
151
+ /**
152
+ * CAN FD valid payload sizes in ascending order (§4.3).
153
+ * Mirrors `fibril_can_core/include/fibril_can_core/packing.hpp::kCanFdDlcSteps`
154
+ * and `fibril_can_runtime/src/fcan_wire.c::fcan_quantize_dlc`.
155
+ */
156
+ const FD_DLC_STEPS = Object.freeze([
157
+ 0,
158
+ 1,
159
+ 2,
160
+ 3,
161
+ 4,
162
+ 5,
163
+ 6,
164
+ 7,
165
+ 8,
166
+ 12,
167
+ 16,
168
+ 20,
169
+ 24,
170
+ 32,
171
+ 48,
172
+ 64
173
+ ]);
174
+ /**
175
+ * Round `bytes` up to the next valid CAN FD payload step. Returns 64 for any
176
+ * input ≥ 64.
177
+ */
178
+ function quantizeDlc(bytes) {
179
+ if (bytes >= 64) return 64;
180
+ for (const step of FD_DLC_STEPS) if (step >= bytes) return step;
181
+ return 64;
182
+ }
183
+ /**
184
+ * Grow `data` to the next DLC step by appending zero bytes. Returns a new
185
+ * Uint8Array. Never shrinks: if `data` already exceeds 64 bytes (which is
186
+ * a self-inconsistent frame the slave would misparse), the original is
187
+ * returned unchanged so the backend rejects it loudly (parity with
188
+ * `pad_to_dlc` in `fibril_can_bridge/src/config_plane_codec.cpp`).
189
+ */
190
+ function padToDlc(data) {
191
+ if (data.length >= 64) return data;
192
+ const padded = new Uint8Array(quantizeDlc(data.length));
193
+ padded.set(data);
194
+ return padded;
195
+ }
196
+ //#endregion
197
+ //#region src/config-plane.ts
198
+ function readU32Le(p, off) {
199
+ return ((p[off] ?? 0) | (p[off + 1] ?? 0) << 8 | (p[off + 2] ?? 0) << 16 | (p[off + 3] ?? 0) << 24) >>> 0;
200
+ }
201
+ function readU64Le$1(p, off) {
202
+ let r = 0n;
203
+ for (let i = 0; i < 8; i++) r |= BigInt(p[off + i] ?? 0) << BigInt(i * 8);
204
+ return r;
205
+ }
206
+ function pushU16(buf, x) {
207
+ buf.push(x & 255, x >>> 8 & 255);
208
+ }
209
+ function pushU32(buf, x) {
210
+ buf.push(x & 255, x >>> 8 & 255, x >>> 16 & 255, x >>> 24 & 255);
211
+ }
212
+ function decodeAnnounce(data) {
213
+ if (data.length < 18) return null;
214
+ const k = data[17] ?? 0;
215
+ if (data.length < 18 + k) return null;
216
+ return {
217
+ schemaHash: readU64Le$1(data, 0),
218
+ protocolVersion: data[8] ?? 0,
219
+ bootId: readU32Le(data, 9),
220
+ blobLen: readU32Le(data, 13),
221
+ instanceCounts: data.slice(18, 18 + k)
222
+ };
223
+ }
224
+ function decodeHeartbeat(data) {
225
+ if (data.length < 6) return null;
226
+ return {
227
+ bootId: readU32Le(data, 0),
228
+ state: data[4] ?? 0,
229
+ faultSummary: data[5] ?? 0
230
+ };
231
+ }
232
+ function decodeSchemaData(data) {
233
+ if (data.length < 4) return null;
234
+ return {
235
+ offset: readU32Le(data, 0),
236
+ bytes: data.slice(4)
237
+ };
238
+ }
239
+ function decodeNak(data) {
240
+ if (data.length < 2) return null;
241
+ return {
242
+ cmd: data[0] ?? 0,
243
+ reason: data[1] ?? 0
244
+ };
245
+ }
246
+ function configFrame(nodeId, cmd, seq, data) {
247
+ return {
248
+ id: makeExtId(0, nodeId, cmd, seq, 0),
249
+ extended: true,
250
+ fd: true,
251
+ data
252
+ };
253
+ }
254
+ /** Broadcast DISCOVER — all nodes re-announce and clear their frame tables. */
255
+ function encodeDiscover() {
256
+ return configFrame(127, 1, 0, /* @__PURE__ */ new Uint8Array(0));
257
+ }
258
+ /** Targeted DISCOVER for a single node (§5.11 reprovisioning). */
259
+ function encodeDiscoverTo(nodeId) {
260
+ return configFrame(nodeId, 1, 0, /* @__PURE__ */ new Uint8Array(0));
261
+ }
262
+ /** Broadcast MASTER_HEARTBEAT (§5.11). Refreshes slave master-lost watchdogs. */
263
+ function encodeMasterHeartbeat() {
264
+ return configFrame(127, 7, 0, /* @__PURE__ */ new Uint8Array(0));
265
+ }
266
+ function encodeSchemaRead(nodeId, offset, length, seq) {
267
+ const buf = [];
268
+ pushU32(buf, offset);
269
+ buf.push(length & 255);
270
+ return configFrame(nodeId, 3, seq, padToDlc(new Uint8Array(buf)));
271
+ }
272
+ function encodeFrameDefine(nodeId, seq, stdId, periodUs, dir, entries) {
273
+ const buf = [];
274
+ pushU16(buf, stdId);
275
+ pushU32(buf, periodUs);
276
+ buf.push(dir & 255);
277
+ buf.push(entries.length & 255);
278
+ for (const e of entries) {
279
+ pushU16(buf, e.topicIndex);
280
+ pushU16(buf, e.fieldBitmask);
281
+ }
282
+ return configFrame(nodeId, 16, seq, padToDlc(new Uint8Array(buf)));
283
+ }
284
+ function encodeStart(nodeId, seq) {
285
+ return configFrame(nodeId, 32, seq, /* @__PURE__ */ new Uint8Array(0));
286
+ }
287
+ function encodeStop(nodeId, seq) {
288
+ return configFrame(nodeId, 33, seq, /* @__PURE__ */ new Uint8Array(0));
289
+ }
290
+ /**
291
+ * Accumulator for SCHEMA_DATA chunks. The slave DLC-pads each response (§4.3)
292
+ * so `chunk.bytes.length` is typically larger than what the master requested;
293
+ * the accumulator clips against `expectedChunkLen` and the remaining blob
294
+ * length to drop those padding bytes (parity with
295
+ * `append_schema_chunk` in `fibril_can_bridge/src/config_plane_codec.cpp`).
296
+ *
297
+ * Out-of-order chunks are rejected (returns 0 from {@link append}).
298
+ */
299
+ var SchemaAccumulator = class {
300
+ #buf = [];
301
+ #blobTotalLen;
302
+ constructor(blobTotalLen) {
303
+ this.#blobTotalLen = blobTotalLen;
304
+ }
305
+ get length() {
306
+ return this.#buf.length;
307
+ }
308
+ get blobTotalLen() {
309
+ return this.#blobTotalLen;
310
+ }
311
+ get complete() {
312
+ return this.#buf.length >= this.#blobTotalLen;
313
+ }
314
+ bytes() {
315
+ return new Uint8Array(this.#buf);
316
+ }
317
+ /**
318
+ * Append `chunk` if its offset matches the current accumulator length.
319
+ * Returns the number of bytes actually appended (0 on rejection).
320
+ */
321
+ append(chunk, expectedChunkLen) {
322
+ if (chunk.offset !== this.#buf.length) return 0;
323
+ const remaining = Math.max(0, this.#blobTotalLen - chunk.offset);
324
+ const cap = Math.min(expectedChunkLen, remaining);
325
+ const take = Math.min(chunk.bytes.length, cap);
326
+ for (let i = 0; i < take; i++) this.#buf.push(chunk.bytes[i] ?? 0);
327
+ return take;
328
+ }
329
+ };
330
+ //#endregion
331
+ //#region src/scalar-codec.ts
332
+ /**
333
+ * Scalar type enumeration and little-endian wire codecs. Mirrors
334
+ * `fibril_can_core/include/fibril_can_core/scalar_type.hpp` and
335
+ * `fibril_can_core/src/scalar_type.cpp`.
336
+ *
337
+ * Type ordinal values are part of the protobuf schema blob's enum, so they
338
+ * must not be renumbered.
339
+ */
340
+ let ScalarType = /* @__PURE__ */ function(ScalarType) {
341
+ ScalarType[ScalarType["Bool"] = 0] = "Bool";
342
+ ScalarType[ScalarType["U8"] = 1] = "U8";
343
+ ScalarType[ScalarType["I8"] = 2] = "I8";
344
+ ScalarType[ScalarType["U16"] = 3] = "U16";
345
+ ScalarType[ScalarType["I16"] = 4] = "I16";
346
+ ScalarType[ScalarType["U32"] = 5] = "U32";
347
+ ScalarType[ScalarType["I32"] = 6] = "I32";
348
+ ScalarType[ScalarType["U64"] = 7] = "U64";
349
+ ScalarType[ScalarType["I64"] = 8] = "I64";
350
+ ScalarType[ScalarType["F32"] = 9] = "F32";
351
+ ScalarType[ScalarType["F64"] = 10] = "F64";
352
+ return ScalarType;
353
+ }({});
354
+ const SCALAR_NAMES = Object.freeze([
355
+ "bool",
356
+ "u8",
357
+ "i8",
358
+ "u16",
359
+ "i16",
360
+ "u32",
361
+ "i32",
362
+ "u64",
363
+ "i64",
364
+ "f32",
365
+ "f64"
366
+ ]);
367
+ /** Wire size in bytes of one scalar element. bool is 1 byte. */
368
+ function scalarSize(type) {
369
+ switch (type) {
370
+ case 0:
371
+ case 1:
372
+ case 2: return 1;
373
+ case 3:
374
+ case 4: return 2;
375
+ case 5:
376
+ case 6:
377
+ case 9: return 4;
378
+ case 7:
379
+ case 8:
380
+ case 10: return 8;
381
+ }
382
+ }
383
+ function scalarName(type) {
384
+ return SCALAR_NAMES[type];
385
+ }
386
+ function scalarFromName(name) {
387
+ const idx = SCALAR_NAMES.indexOf(name);
388
+ return idx >= 0 ? idx : void 0;
389
+ }
390
+ /**
391
+ * Pack an integer / bool value as little-endian bytes into `out` at `offset`.
392
+ * Writes scalarSize(type) bytes — pass a wide-enough `out`.
393
+ *
394
+ * Pass `bigint` for 64-bit types (U64/I64). `number` is accepted for
395
+ * everything (it is internally widened to bigint so signed callers get the
396
+ * same sign-extension semantics as the C++ `pack_scalar_int_le(uint64_t)`).
397
+ * Signed callers pass the negative number directly; the function performs the
398
+ * 64-bit two's-complement cast.
399
+ */
400
+ function packScalarIntLe(type, value, out, offset = 0) {
401
+ const bytes = scalarSize(type);
402
+ const mask = (1n << 64n) - 1n;
403
+ const bits = (typeof value === "bigint" ? value : BigInt(value)) & mask;
404
+ for (let i = 0; i < bytes; i++) out[offset + i] = Number(bits >> BigInt(i * 8) & 255n);
405
+ }
406
+ /** Bit-cast a float32 to its IEEE-754 little-endian representation. */
407
+ function packScalarF32Le(value, out, offset = 0) {
408
+ new DataView(out.buffer, out.byteOffset, out.byteLength).setFloat32(offset, value, true);
409
+ }
410
+ /** Bit-cast a float64 to its IEEE-754 little-endian representation. */
411
+ function packScalarF64Le(value, out, offset = 0) {
412
+ new DataView(out.buffer, out.byteOffset, out.byteLength).setFloat64(offset, value, true);
413
+ }
414
+ /**
415
+ * Inverse of {@link packScalarIntLe}. Returns the raw bit pattern as bigint;
416
+ * caller interprets sign via {@link sign-extend} helpers below.
417
+ */
418
+ function unpackScalarIntLe(type, src, offset = 0) {
419
+ const bytes = scalarSize(type);
420
+ let bits = 0n;
421
+ for (let i = 0; i < bytes; i++) bits |= BigInt(src[offset + i] ?? 0) << BigInt(i * 8);
422
+ return bits;
423
+ }
424
+ function unpackScalarF32Le(src, offset = 0) {
425
+ return new DataView(src.buffer, src.byteOffset, src.byteLength).getFloat32(offset, true);
426
+ }
427
+ function unpackScalarF64Le(src, offset = 0) {
428
+ return new DataView(src.buffer, src.byteOffset, src.byteLength).getFloat64(offset, true);
429
+ }
430
+ /**
431
+ * Sign-extend a bigint that was read as an unsigned `bytes`-byte field to a
432
+ * signed bigint. Useful for I8/I16/I32/I64 after {@link unpackScalarIntLe}.
433
+ */
434
+ function signExtend(bits, bytes) {
435
+ const width = BigInt(bytes * 8);
436
+ return (bits & 1n << width - 1n) === 0n ? bits : bits - (1n << width);
437
+ }
438
+ const WIRE_FIXED64 = 1;
439
+ const WIRE_FIXED32 = 5;
440
+ var ProtoReader = class ProtoReader {
441
+ buf;
442
+ end;
443
+ pos = 0;
444
+ constructor(buf, end = buf.length) {
445
+ this.buf = buf;
446
+ this.end = end;
447
+ }
448
+ get done() {
449
+ return this.pos >= this.end;
450
+ }
451
+ get position() {
452
+ return this.pos;
453
+ }
454
+ /** Read a base-128 varint as a JS number (overflows ≥ 2^53 wrap silently). */
455
+ readVarint() {
456
+ let result = 0;
457
+ let shift = 0;
458
+ while (this.pos < this.end) {
459
+ const b = this.buf[this.pos++];
460
+ if (b === void 0) throw new ProtoError("truncated varint");
461
+ result |= (b & 127) << shift;
462
+ if ((b & 128) === 0) return result >>> 0;
463
+ shift += 7;
464
+ if (shift >= 32) {
465
+ while (this.pos < this.end) {
466
+ const c = this.buf[this.pos++];
467
+ if (c === void 0) throw new ProtoError("truncated varint");
468
+ if ((c & 128) === 0) break;
469
+ }
470
+ return result >>> 0;
471
+ }
472
+ }
473
+ throw new ProtoError("truncated varint");
474
+ }
475
+ /** Read a length-delimited byte slice (no copy). */
476
+ readBytes() {
477
+ const len = this.readVarint();
478
+ if (this.pos + len > this.end) throw new ProtoError("truncated length-delimited field");
479
+ const slice = this.buf.subarray(this.pos, this.pos + len);
480
+ this.pos += len;
481
+ return slice;
482
+ }
483
+ /** Read a length-delimited UTF-8 string. */
484
+ readString() {
485
+ const bytes = this.readBytes();
486
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
487
+ }
488
+ /** Read a length-delimited sub-message and return a new reader scoped to it. */
489
+ readMessage() {
490
+ return new ProtoReader(this.readBytes());
491
+ }
492
+ /**
493
+ * Read the next tag and return both the field number and wire type. The
494
+ * caller is expected to dispatch on field number; unknown fields can be
495
+ * passed to {@link skipValue}.
496
+ */
497
+ readTag() {
498
+ const tag = this.readVarint();
499
+ return {
500
+ field: tag >>> 3,
501
+ wire: tag & 7
502
+ };
503
+ }
504
+ /** Discard the value associated with `wire`. Used to tolerate unknown fields. */
505
+ skipValue(wire) {
506
+ switch (wire) {
507
+ case 0:
508
+ this.readVarint();
509
+ return;
510
+ case 2:
511
+ this.readBytes();
512
+ return;
513
+ case WIRE_FIXED32:
514
+ if (this.pos + 4 > this.end) throw new ProtoError("truncated fixed32");
515
+ this.pos += 4;
516
+ return;
517
+ case WIRE_FIXED64:
518
+ if (this.pos + 8 > this.end) throw new ProtoError("truncated fixed64");
519
+ this.pos += 8;
520
+ return;
521
+ default: throw new ProtoError(`unsupported wire type ${wire}`);
522
+ }
523
+ }
524
+ };
525
+ var ProtoError = class extends Error {
526
+ constructor(message) {
527
+ super(message);
528
+ this.name = "ProtoError";
529
+ }
530
+ };
531
+ //#endregion
532
+ //#region src/schema.ts
533
+ /**
534
+ * NodeSchema IR + protobuf decoder for the schema blob (§9).
535
+ *
536
+ * Mirrors `fibril_can_core/include/fibril_can_core/ir.hpp` and
537
+ * `fibril_can_core/src/schema_blob.cpp::parse_schema`. Field IDs come from
538
+ * `fibril_can_core/proto/fibril_can.proto`.
539
+ *
540
+ * Master-side notes:
541
+ * - The proto carries `topic_id` / `service_id` / `param_id` / BlockType
542
+ * `type_id` for codegen stability; the master derives equivalent indices
543
+ * from {@link computeNumbering} instead, so these are accepted but
544
+ * discarded on parse.
545
+ * - The proto wire field is `tx_disabled` while the IR holds `tx_enabled`
546
+ * (inverted, matches the C++ side).
547
+ * - `BlockArray.type_id` (proto) → `BlockArray.blockTypeIndex` (IR).
548
+ *
549
+ * The reader skips unknown fields silently so future schema additions do
550
+ * not break old masters.
551
+ */
552
+ const FIELD = {
553
+ Field: {
554
+ name: 1,
555
+ type: 2,
556
+ arrayLen: 3,
557
+ periodUsOverride: 4,
558
+ txDisabled: 5
559
+ },
560
+ RosMapping: {
561
+ rosType: 1,
562
+ rosName: 2,
563
+ fieldMap: 3
564
+ },
565
+ Topic: {
566
+ name: 2,
567
+ dir: 3,
568
+ fields: 4,
569
+ defaultPeriodUs: 5,
570
+ ros: 6
571
+ },
572
+ Service: {
573
+ name: 2,
574
+ request: 3,
575
+ response: 4,
576
+ ros: 5
577
+ },
578
+ Param: {
579
+ name: 2,
580
+ type: 3,
581
+ arrayLen: 4,
582
+ defaultValue: 5,
583
+ minValue: 6,
584
+ maxValue: 7
585
+ },
586
+ BlockType: {
587
+ name: 2,
588
+ topics: 3,
589
+ services: 4,
590
+ params: 5
591
+ },
592
+ BlockArray: {
593
+ typeId: 1,
594
+ rosNamespace: 2
595
+ },
596
+ Limits: { paramSetBatchBytes: 1 },
597
+ NodeSchema: {
598
+ protocolVersion: 1,
599
+ nodeName: 2,
600
+ types: 3,
601
+ instances: 4,
602
+ limits: 5
603
+ }
604
+ };
605
+ let Direction = /* @__PURE__ */ function(Direction) {
606
+ Direction[Direction["M2S"] = 0] = "M2S";
607
+ Direction[Direction["S2M"] = 1] = "S2M";
608
+ return Direction;
609
+ }({});
610
+ function decodeField(r) {
611
+ const f = {
612
+ name: "",
613
+ type: 0,
614
+ arrayLen: 0,
615
+ txEnabled: true
616
+ };
617
+ while (!r.done) {
618
+ const { field, wire } = r.readTag();
619
+ switch (field) {
620
+ case FIELD.Field.name:
621
+ f.name = r.readString();
622
+ break;
623
+ case FIELD.Field.type: {
624
+ const v = r.readVarint();
625
+ if (v > 10) throw new ProtoError(`Field.type out of range: ${v}`);
626
+ f.type = v;
627
+ break;
628
+ }
629
+ case FIELD.Field.arrayLen:
630
+ f.arrayLen = r.readVarint();
631
+ break;
632
+ case FIELD.Field.periodUsOverride: {
633
+ const v = r.readVarint();
634
+ if (v !== 0) f.periodUsOverride = v;
635
+ break;
636
+ }
637
+ case FIELD.Field.txDisabled:
638
+ f.txEnabled = r.readVarint() === 0;
639
+ break;
640
+ default: r.skipValue(wire);
641
+ }
642
+ }
643
+ return f;
644
+ }
645
+ function decodeRos(r) {
646
+ const ros = {
647
+ rosType: "",
648
+ rosName: "",
649
+ fieldMap: []
650
+ };
651
+ while (!r.done) {
652
+ const { field, wire } = r.readTag();
653
+ switch (field) {
654
+ case FIELD.RosMapping.rosType:
655
+ ros.rosType = r.readString();
656
+ break;
657
+ case FIELD.RosMapping.rosName:
658
+ ros.rosName = r.readString();
659
+ break;
660
+ case FIELD.RosMapping.fieldMap:
661
+ ros.fieldMap.push(r.readString());
662
+ break;
663
+ default: r.skipValue(wire);
664
+ }
665
+ }
666
+ return ros;
667
+ }
668
+ function decodeTopic(r) {
669
+ const t = {
670
+ name: "",
671
+ dir: 0,
672
+ fields: [],
673
+ defaultPeriodUs: 0,
674
+ ros: {
675
+ rosType: "",
676
+ rosName: "",
677
+ fieldMap: []
678
+ }
679
+ };
680
+ while (!r.done) {
681
+ const { field, wire } = r.readTag();
682
+ switch (field) {
683
+ case FIELD.Topic.name:
684
+ t.name = r.readString();
685
+ break;
686
+ case FIELD.Topic.dir: {
687
+ const v = r.readVarint();
688
+ if (v !== 0 && v !== 1) throw new ProtoError(`Topic.dir out of range: ${v}`);
689
+ t.dir = v;
690
+ break;
691
+ }
692
+ case FIELD.Topic.fields:
693
+ t.fields.push(decodeField(r.readMessage()));
694
+ break;
695
+ case FIELD.Topic.defaultPeriodUs:
696
+ t.defaultPeriodUs = r.readVarint();
697
+ break;
698
+ case FIELD.Topic.ros:
699
+ t.ros = decodeRos(r.readMessage());
700
+ break;
701
+ default: r.skipValue(wire);
702
+ }
703
+ }
704
+ return t;
705
+ }
706
+ function decodeService(r) {
707
+ const s = {
708
+ name: "",
709
+ request: [],
710
+ response: [],
711
+ ros: {
712
+ rosType: "",
713
+ rosName: "",
714
+ fieldMap: []
715
+ }
716
+ };
717
+ while (!r.done) {
718
+ const { field, wire } = r.readTag();
719
+ switch (field) {
720
+ case FIELD.Service.name:
721
+ s.name = r.readString();
722
+ break;
723
+ case FIELD.Service.request:
724
+ s.request.push(decodeField(r.readMessage()));
725
+ break;
726
+ case FIELD.Service.response:
727
+ s.response.push(decodeField(r.readMessage()));
728
+ break;
729
+ case FIELD.Service.ros:
730
+ s.ros = decodeRos(r.readMessage());
731
+ break;
732
+ default: r.skipValue(wire);
733
+ }
734
+ }
735
+ return s;
736
+ }
737
+ function decodeParam(r) {
738
+ const p = {
739
+ name: "",
740
+ type: 0,
741
+ arrayLen: 0,
742
+ defaultValue: /* @__PURE__ */ new Uint8Array(0),
743
+ minValue: /* @__PURE__ */ new Uint8Array(0),
744
+ maxValue: /* @__PURE__ */ new Uint8Array(0)
745
+ };
746
+ while (!r.done) {
747
+ const { field, wire } = r.readTag();
748
+ switch (field) {
749
+ case FIELD.Param.name:
750
+ p.name = r.readString();
751
+ break;
752
+ case FIELD.Param.type: {
753
+ const v = r.readVarint();
754
+ if (v > 10) throw new ProtoError(`Param.type out of range: ${v}`);
755
+ p.type = v;
756
+ break;
757
+ }
758
+ case FIELD.Param.arrayLen:
759
+ p.arrayLen = r.readVarint();
760
+ break;
761
+ case FIELD.Param.defaultValue:
762
+ p.defaultValue = new Uint8Array(r.readBytes());
763
+ break;
764
+ case FIELD.Param.minValue:
765
+ p.minValue = new Uint8Array(r.readBytes());
766
+ break;
767
+ case FIELD.Param.maxValue:
768
+ p.maxValue = new Uint8Array(r.readBytes());
769
+ break;
770
+ default: r.skipValue(wire);
771
+ }
772
+ }
773
+ return p;
774
+ }
775
+ function decodeBlockType(r) {
776
+ const b = {
777
+ name: "",
778
+ topics: [],
779
+ services: [],
780
+ params: []
781
+ };
782
+ while (!r.done) {
783
+ const { field, wire } = r.readTag();
784
+ switch (field) {
785
+ case FIELD.BlockType.name:
786
+ b.name = r.readString();
787
+ break;
788
+ case FIELD.BlockType.topics:
789
+ b.topics.push(decodeTopic(r.readMessage()));
790
+ break;
791
+ case FIELD.BlockType.services:
792
+ b.services.push(decodeService(r.readMessage()));
793
+ break;
794
+ case FIELD.BlockType.params:
795
+ b.params.push(decodeParam(r.readMessage()));
796
+ break;
797
+ default: r.skipValue(wire);
798
+ }
799
+ }
800
+ return b;
801
+ }
802
+ function decodeBlockArray(r) {
803
+ const a = {
804
+ blockTypeIndex: 0,
805
+ rosNamespace: ""
806
+ };
807
+ while (!r.done) {
808
+ const { field, wire } = r.readTag();
809
+ switch (field) {
810
+ case FIELD.BlockArray.typeId:
811
+ a.blockTypeIndex = r.readVarint();
812
+ break;
813
+ case FIELD.BlockArray.rosNamespace:
814
+ a.rosNamespace = r.readString();
815
+ break;
816
+ default: r.skipValue(wire);
817
+ }
818
+ }
819
+ return a;
820
+ }
821
+ function decodeLimits(r) {
822
+ const l = { paramSetBatchBytes: 0 };
823
+ while (!r.done) {
824
+ const { field, wire } = r.readTag();
825
+ switch (field) {
826
+ case FIELD.Limits.paramSetBatchBytes:
827
+ l.paramSetBatchBytes = r.readVarint();
828
+ break;
829
+ default: r.skipValue(wire);
830
+ }
831
+ }
832
+ return l;
833
+ }
834
+ /**
835
+ * Parse a schema blob (proto3 binary) into the master-side IR.
836
+ *
837
+ * Throws {@link ProtoError} on malformed input or on
838
+ * `protocolVersion !== 1` (the only version this codec understands —
839
+ * mirrors C++ side's UnsupportedProtocolVersion behaviour).
840
+ */
841
+ function decodeSchema(blob) {
842
+ const schema = {
843
+ protocolVersion: 0,
844
+ nodeName: "",
845
+ types: [],
846
+ instances: [],
847
+ limits: { paramSetBatchBytes: 0 }
848
+ };
849
+ const r = new ProtoReader(blob);
850
+ while (!r.done) {
851
+ const { field, wire } = r.readTag();
852
+ switch (field) {
853
+ case FIELD.NodeSchema.protocolVersion:
854
+ schema.protocolVersion = r.readVarint();
855
+ break;
856
+ case FIELD.NodeSchema.nodeName:
857
+ schema.nodeName = r.readString();
858
+ break;
859
+ case FIELD.NodeSchema.types:
860
+ schema.types.push(decodeBlockType(r.readMessage()));
861
+ break;
862
+ case FIELD.NodeSchema.instances:
863
+ schema.instances.push(decodeBlockArray(r.readMessage()));
864
+ break;
865
+ case FIELD.NodeSchema.limits:
866
+ schema.limits = decodeLimits(r.readMessage());
867
+ break;
868
+ default: r.skipValue(wire);
869
+ }
870
+ }
871
+ if (schema.protocolVersion !== 1) throw new ProtoError(`unsupported schema protocol_version: ${schema.protocolVersion}`);
872
+ for (const arr of schema.instances) if (arr.blockTypeIndex >= schema.types.length) throw new ProtoError(`BlockArray.type_id ${arr.blockTypeIndex} out of range (#types=${schema.types.length})`);
873
+ return schema;
874
+ }
875
+ /** Wire size in bytes for a single field (scalar or fixed array). */
876
+ function fieldWireSize(field) {
877
+ return scalarSize(field.type) * (field.arrayLen === 0 ? 1 : field.arrayLen);
878
+ }
879
+ //#endregion
880
+ //#region src/dynamic-codec.ts
881
+ /**
882
+ * Field-driven dict ⇄ Uint8Array codec. The runtime equivalent of what
883
+ * codegen would otherwise emit per message — encode/decode is a pure
884
+ * function of the field list ({@link SchemaField}[]) and the JS dict.
885
+ *
886
+ * Conventions (matches CLAUDE.md "値の表現規約"):
887
+ * - dict keys are the schema field name verbatim
888
+ * - scalar ↔ JS `number`, except U64/I64 ↔ `bigint`
889
+ * - bool ↔ JS `boolean`
890
+ * - fixed-length array (`arrayLen > 0`) ↔ regular JS array
891
+ * (`number[]` / `bigint[]` / `boolean[]`) of exactly `arrayLen` elements
892
+ *
893
+ * Throws on missing / wrong-shape inputs. Decode tolerates extra trailing
894
+ * bytes (mirrors C++ side: the master ignores trailing slop so that future
895
+ * schema additions don't break older decoders).
896
+ */
897
+ var DynamicCodecError = class extends Error {
898
+ constructor(message) {
899
+ super(message);
900
+ this.name = "DynamicCodecError";
901
+ }
902
+ };
903
+ function isInt64(type) {
904
+ return type === 7 || type === 8;
905
+ }
906
+ function packOneScalar$1(type, value, out, offset, fieldName) {
907
+ switch (type) {
908
+ case 0:
909
+ if (typeof value !== "boolean") throw new DynamicCodecError(`field '${fieldName}': expected boolean for bool, got ${typeof value}`);
910
+ out[offset] = value ? 1 : 0;
911
+ return;
912
+ case 9:
913
+ if (typeof value !== "number") throw new DynamicCodecError(`field '${fieldName}': expected number for f32, got ${typeof value}`);
914
+ packScalarF32Le(value, out, offset);
915
+ return;
916
+ case 10:
917
+ if (typeof value !== "number") throw new DynamicCodecError(`field '${fieldName}': expected number for f64, got ${typeof value}`);
918
+ packScalarF64Le(value, out, offset);
919
+ return;
920
+ default:
921
+ if (isInt64(type)) {
922
+ if (typeof value !== "bigint") throw new DynamicCodecError(`field '${fieldName}': expected bigint for ${scalarName(type)}, got ${typeof value}`);
923
+ } else if (typeof value !== "number") throw new DynamicCodecError(`field '${fieldName}': expected number for ${scalarName(type)}, got ${typeof value}`);
924
+ packScalarIntLe(type, value, out, offset);
925
+ return;
926
+ }
927
+ }
928
+ function unpackOneScalar(type, src, offset) {
929
+ switch (type) {
930
+ case 0: return (src[offset] ?? 0) !== 0;
931
+ case 9: return unpackScalarF32Le(src, offset);
932
+ case 10: return unpackScalarF64Le(src, offset);
933
+ case 2:
934
+ case 4:
935
+ case 6: {
936
+ const bits = unpackScalarIntLe(type, src, offset);
937
+ return Number(signExtend(bits, scalarSize(type)));
938
+ }
939
+ case 1:
940
+ case 3:
941
+ case 5: return Number(unpackScalarIntLe(type, src, offset));
942
+ case 8: return signExtend(unpackScalarIntLe(type, src, offset), 8);
943
+ case 7: return unpackScalarIntLe(type, src, offset);
944
+ }
945
+ }
946
+ /**
947
+ * Sum the wire size of a field list. The result is the byte count that
948
+ * {@link encodeFields} will produce.
949
+ */
950
+ function fieldsWireSize(fields) {
951
+ let n = 0;
952
+ for (const f of fields) n += fieldWireSize(f);
953
+ return n;
954
+ }
955
+ /**
956
+ * Encode a dict into the canonical little-endian wire bytes for `fields`.
957
+ * Returns a fresh `Uint8Array`. Throws {@link DynamicCodecError} on a
958
+ * missing or wrong-shape field.
959
+ */
960
+ function encodeFields(fields, value) {
961
+ const totalSize = fieldsWireSize(fields);
962
+ const out = new Uint8Array(totalSize);
963
+ let off = 0;
964
+ for (const f of fields) {
965
+ if (!f.txEnabled) {
966
+ off += fieldWireSize(f);
967
+ continue;
968
+ }
969
+ const v = value[f.name];
970
+ if (v === void 0) throw new DynamicCodecError(`encodeFields: missing field '${f.name}'`);
971
+ if (f.arrayLen === 0) {
972
+ packOneScalar$1(f.type, v, out, off, f.name);
973
+ off += scalarSize(f.type);
974
+ } else {
975
+ if (!Array.isArray(v)) throw new DynamicCodecError(`field '${f.name}': expected array of length ${f.arrayLen}, got ${typeof v}`);
976
+ if (v.length !== f.arrayLen) throw new DynamicCodecError(`field '${f.name}': expected array length ${f.arrayLen}, got ${v.length}`);
977
+ const stride = scalarSize(f.type);
978
+ for (let i = 0; i < f.arrayLen; i++) {
979
+ packOneScalar$1(f.type, v[i], out, off, f.name);
980
+ off += stride;
981
+ }
982
+ }
983
+ }
984
+ return out;
985
+ }
986
+ /**
987
+ * Decode wire bytes into a dict keyed by field name. Extra trailing bytes
988
+ * are ignored. Throws {@link DynamicCodecError} only if the payload is
989
+ * shorter than the field list demands.
990
+ */
991
+ function decodeFields(fields, bytes) {
992
+ const expected = fieldsWireSize(fields);
993
+ if (bytes.length < expected) throw new DynamicCodecError(`decodeFields: payload too short — need ${expected} bytes, got ${bytes.length}`);
994
+ const out = {};
995
+ let off = 0;
996
+ for (const f of fields) if (f.arrayLen === 0) {
997
+ out[f.name] = unpackOneScalar(f.type, bytes, off);
998
+ off += scalarSize(f.type);
999
+ } else {
1000
+ const stride = scalarSize(f.type);
1001
+ if (f.type === 0) {
1002
+ const arr = [];
1003
+ for (let i = 0; i < f.arrayLen; i++) {
1004
+ arr.push(unpackOneScalar(f.type, bytes, off));
1005
+ off += stride;
1006
+ }
1007
+ out[f.name] = arr;
1008
+ } else if (isInt64(f.type)) {
1009
+ const arr = [];
1010
+ for (let i = 0; i < f.arrayLen; i++) {
1011
+ arr.push(unpackOneScalar(f.type, bytes, off));
1012
+ off += stride;
1013
+ }
1014
+ out[f.name] = arr;
1015
+ } else {
1016
+ const arr = [];
1017
+ for (let i = 0; i < f.arrayLen; i++) {
1018
+ arr.push(unpackOneScalar(f.type, bytes, off));
1019
+ off += stride;
1020
+ }
1021
+ out[f.name] = arr;
1022
+ }
1023
+ }
1024
+ return out;
1025
+ }
1026
+ //#endregion
1027
+ //#region src/data-plane.ts
1028
+ const RATE_WINDOW = 16;
1029
+ function maskedFieldsList(fields, mask) {
1030
+ const out = [];
1031
+ for (let i = 0; i < fields.length; i++) {
1032
+ const f = fields[i];
1033
+ if (f === void 0) continue;
1034
+ if ((mask & 1 << i) !== 0) out.push(f);
1035
+ }
1036
+ return out;
1037
+ }
1038
+ function sumWireSize(fields) {
1039
+ let s = 0;
1040
+ for (const f of fields) s += fieldWireSize(f);
1041
+ return s;
1042
+ }
1043
+ function buildBucket(framePlan, index, nodeId) {
1044
+ let offset = 0;
1045
+ const entries = [];
1046
+ for (const e of framePlan.entries) {
1047
+ const desc = index.topicByIndex(e.topicIndex);
1048
+ if (desc === void 0) continue;
1049
+ const fields = maskedFieldsList(desc.fields, e.fieldBitmask);
1050
+ const size = sumWireSize(fields);
1051
+ entries.push({
1052
+ path: desc.path,
1053
+ fields,
1054
+ offset,
1055
+ size
1056
+ });
1057
+ offset += size;
1058
+ }
1059
+ return {
1060
+ nodeId,
1061
+ stdId: framePlan.stdId,
1062
+ dir: framePlan.dir,
1063
+ entries
1064
+ };
1065
+ }
1066
+ function topicKey(nodeId, path) {
1067
+ return `${nodeId}/${path}`;
1068
+ }
1069
+ var DataPlaneReceiver = class {
1070
+ #onError;
1071
+ #now;
1072
+ #unsubFrame;
1073
+ #buckets = /* @__PURE__ */ new Map();
1074
+ #ownedByNode = /* @__PURE__ */ new Map();
1075
+ #latest = /* @__PURE__ */ new Map();
1076
+ #listeners = /* @__PURE__ */ new Map();
1077
+ #version = 0;
1078
+ constructor(opts) {
1079
+ this.#onError = opts.onError;
1080
+ this.#now = opts.now ?? (() => Date.now());
1081
+ this.#unsubFrame = opts.transport.onFrame((f) => this.#onFrame(f));
1082
+ }
1083
+ /**
1084
+ * Claim a slave's S→M std_ids. Re-attaching the same nodeId atomically
1085
+ * detaches the prior plan first (re-provisioning case).
1086
+ */
1087
+ attachSlave(nodeId, framePlan, index) {
1088
+ this.detachSlave(nodeId);
1089
+ const owned = /* @__PURE__ */ new Set();
1090
+ for (const fp of framePlan) {
1091
+ if (fp.dir !== 1) continue;
1092
+ if (this.#buckets.has(fp.stdId)) {
1093
+ const other = this.#buckets.get(fp.stdId);
1094
+ throw new Error(`DataPlaneReceiver: std_id 0x${fp.stdId.toString(16)} claimed by slave 0x${other?.nodeId.toString(16) ?? "??"}, cannot also attach slave 0x${nodeId.toString(16)}`);
1095
+ }
1096
+ this.#buckets.set(fp.stdId, buildBucket(fp, index, nodeId));
1097
+ owned.add(fp.stdId);
1098
+ }
1099
+ this.#ownedByNode.set(nodeId, owned);
1100
+ }
1101
+ /** Release a slave's std_ids and drop its cached topic samples. */
1102
+ detachSlave(nodeId) {
1103
+ const owned = this.#ownedByNode.get(nodeId);
1104
+ if (!owned) return;
1105
+ for (const id of owned) this.#buckets.delete(id);
1106
+ this.#ownedByNode.delete(nodeId);
1107
+ const prefix = `${nodeId}/`;
1108
+ for (const k of [...this.#latest.keys()]) if (k.startsWith(prefix)) this.#latest.delete(k);
1109
+ }
1110
+ onTopic(nodeId, path, listener) {
1111
+ const key = topicKey(nodeId, path);
1112
+ let set = this.#listeners.get(key);
1113
+ if (!set) {
1114
+ set = /* @__PURE__ */ new Set();
1115
+ this.#listeners.set(key, set);
1116
+ }
1117
+ set.add(listener);
1118
+ return () => {
1119
+ const s = this.#listeners.get(key);
1120
+ if (!s) return;
1121
+ s.delete(listener);
1122
+ if (s.size === 0) this.#listeners.delete(key);
1123
+ };
1124
+ }
1125
+ latest(nodeId, path) {
1126
+ const c = this.#latest.get(topicKey(nodeId, path));
1127
+ return c === void 0 ? null : {
1128
+ value: c.value,
1129
+ tsMs: c.tsMs
1130
+ };
1131
+ }
1132
+ stats(nodeId, path) {
1133
+ const c = this.#latest.get(topicKey(nodeId, path));
1134
+ if (!c) return null;
1135
+ let rate = 0;
1136
+ if (c.ts.length >= 2) {
1137
+ const dt = (c.ts[c.ts.length - 1] ?? 0) - (c.ts[0] ?? 0);
1138
+ if (dt > 0) rate = 1e3 * (c.ts.length - 1) / dt;
1139
+ }
1140
+ return {
1141
+ count: c.count,
1142
+ lastRxMs: c.tsMs,
1143
+ ratePerSec: rate
1144
+ };
1145
+ }
1146
+ /** Paths attached for this slave (in plan order). */
1147
+ topicPathsFor(nodeId) {
1148
+ const owned = this.#ownedByNode.get(nodeId);
1149
+ if (!owned) return [];
1150
+ const out = [];
1151
+ for (const id of owned) {
1152
+ const b = this.#buckets.get(id);
1153
+ if (b) for (const e of b.entries) out.push(e.path);
1154
+ }
1155
+ return out;
1156
+ }
1157
+ /** Bumps on every successfully decoded entry — cheap change detector. */
1158
+ get version() {
1159
+ return this.#version;
1160
+ }
1161
+ dispose() {
1162
+ this.#unsubFrame();
1163
+ this.#buckets.clear();
1164
+ this.#ownedByNode.clear();
1165
+ this.#latest.clear();
1166
+ this.#listeners.clear();
1167
+ }
1168
+ #onFrame(f) {
1169
+ if (f.extended) return;
1170
+ if (f.rtr) return;
1171
+ const bucket = this.#buckets.get(f.id);
1172
+ if (bucket === void 0) return;
1173
+ const tsMs = this.#now();
1174
+ for (const e of bucket.entries) {
1175
+ if (e.offset + e.size > f.data.length) continue;
1176
+ const slice = f.data.subarray(e.offset, e.offset + e.size);
1177
+ let dict;
1178
+ try {
1179
+ dict = decodeFields(e.fields, slice);
1180
+ } catch (err) {
1181
+ this.#err(err);
1182
+ continue;
1183
+ }
1184
+ this.#publish(bucket.nodeId, e.path, dict, tsMs);
1185
+ }
1186
+ }
1187
+ #publish(nodeId, path, value, tsMs) {
1188
+ const key = topicKey(nodeId, path);
1189
+ let c = this.#latest.get(key);
1190
+ if (c === void 0) {
1191
+ c = {
1192
+ value,
1193
+ tsMs,
1194
+ count: 0,
1195
+ ts: []
1196
+ };
1197
+ this.#latest.set(key, c);
1198
+ } else {
1199
+ c.value = value;
1200
+ c.tsMs = tsMs;
1201
+ }
1202
+ c.count++;
1203
+ c.ts.push(tsMs);
1204
+ if (c.ts.length > RATE_WINDOW) c.ts.shift();
1205
+ this.#version++;
1206
+ const set = this.#listeners.get(key);
1207
+ if (set !== void 0) for (const l of set) try {
1208
+ l(value, tsMs);
1209
+ } catch (err) {
1210
+ this.#err(err);
1211
+ }
1212
+ }
1213
+ #err(e) {
1214
+ if (this.#onError === void 0) return;
1215
+ this.#onError(e instanceof Error ? e : new Error(String(e)));
1216
+ }
1217
+ };
1218
+ //#endregion
1219
+ //#region src/frame-planner.ts
1220
+ /**
1221
+ * 11-bit Standard ID allocator with a free pool for IDs released after a
1222
+ * grace window (§11.4.1). `next()` returns `null` on exhaustion rather
1223
+ * than wrapping — wrapping past 0x7FF would alias on the wire.
1224
+ */
1225
+ var StdIdAllocator = class {
1226
+ #next = 256;
1227
+ #freePool = [];
1228
+ next(nowMs = Date.now()) {
1229
+ if (this.#freePool.length > 0) {
1230
+ const head = this.#freePool[0];
1231
+ if (head !== void 0 && head.availableAtMs <= nowMs) {
1232
+ this.#freePool.shift();
1233
+ return head.id;
1234
+ }
1235
+ }
1236
+ if (this.#next > 2047) return null;
1237
+ const id = this.#next;
1238
+ this.#next += 1;
1239
+ return id;
1240
+ }
1241
+ release(id, availableAtMs) {
1242
+ let lo = 0;
1243
+ let hi = this.#freePool.length;
1244
+ while (lo < hi) {
1245
+ const mid = lo + hi >>> 1;
1246
+ const entry = this.#freePool[mid];
1247
+ if (entry !== void 0 && entry.availableAtMs < availableAtMs) lo = mid + 1;
1248
+ else hi = mid;
1249
+ }
1250
+ this.#freePool.splice(lo, 0, {
1251
+ availableAtMs,
1252
+ id
1253
+ });
1254
+ }
1255
+ reset(start = 256) {
1256
+ this.#next = start;
1257
+ this.#freePool.length = 0;
1258
+ }
1259
+ };
1260
+ function makeFrame(input) {
1261
+ const id = input.alloc.next();
1262
+ if (id === null) return null;
1263
+ const entries = [];
1264
+ let bytes = 0;
1265
+ for (const topicIndex of [...input.topics.keys()].sort((a, b) => a - b)) {
1266
+ const fields = input.topics.get(topicIndex);
1267
+ if (!fields) continue;
1268
+ let mask = 0;
1269
+ for (const r of fields) {
1270
+ mask |= 1 << r.fieldId;
1271
+ bytes += r.sizeBytes;
1272
+ }
1273
+ entries.push({
1274
+ topicIndex,
1275
+ fieldBitmask: mask
1276
+ });
1277
+ }
1278
+ return {
1279
+ stdCanId: id,
1280
+ periodUs: input.periodUs,
1281
+ dir: input.dir,
1282
+ logicalBytes: Math.min(bytes, 64),
1283
+ entries
1284
+ };
1285
+ }
1286
+ function emitOversizedTopic(fields, dir, periodUs, alloc, out) {
1287
+ const sorted = [...fields].sort((a, b) => a.fieldId - b.fieldId);
1288
+ let bin = [];
1289
+ let binBytes = 0;
1290
+ const topicIndex = sorted[0]?.topicIndex ?? 0;
1291
+ const flush = () => {
1292
+ if (bin.length === 0) return true;
1293
+ const f = makeFrame({
1294
+ topics: /* @__PURE__ */ new Map([[topicIndex, bin]]),
1295
+ dir,
1296
+ periodUs,
1297
+ alloc
1298
+ });
1299
+ if (!f) return false;
1300
+ out.push(f);
1301
+ bin = [];
1302
+ binBytes = 0;
1303
+ return true;
1304
+ };
1305
+ for (const r of sorted) {
1306
+ if (binBytes + r.sizeBytes > 64) {
1307
+ if (!flush()) return false;
1308
+ }
1309
+ bin.push(r);
1310
+ binBytes += r.sizeBytes;
1311
+ }
1312
+ return flush();
1313
+ }
1314
+ /**
1315
+ * Plan a slave's frame layout. Returns `null` if the StdIdAllocator runs
1316
+ * out of 11-bit IDs mid-plan — the caller MUST surface that; a partial
1317
+ * plan would silently drop fields.
1318
+ */
1319
+ function planFrames(requests, alloc) {
1320
+ const buckets = /* @__PURE__ */ new Map();
1321
+ for (const r of requests) {
1322
+ const key = `${r.dir}/${r.periodUs}`;
1323
+ let bucket = buckets.get(key);
1324
+ if (!bucket) {
1325
+ bucket = [];
1326
+ buckets.set(key, bucket);
1327
+ }
1328
+ bucket.push(r);
1329
+ }
1330
+ const sortedKeys = [...buckets.keys()].sort((a, b) => {
1331
+ const [aDir, aPeriod] = a.split("/").map(Number);
1332
+ const [bDir, bPeriod] = b.split("/").map(Number);
1333
+ if (aDir !== bDir) return aDir - bDir;
1334
+ return aPeriod - bPeriod;
1335
+ });
1336
+ const out = [];
1337
+ for (const key of sortedKeys) {
1338
+ const requestsInBucket = buckets.get(key);
1339
+ if (!requestsInBucket) continue;
1340
+ const [dir, periodUs] = key.split("/").map(Number);
1341
+ const byTopic = /* @__PURE__ */ new Map();
1342
+ for (const r of requestsInBucket) {
1343
+ let arr = byTopic.get(r.topicIndex);
1344
+ if (!arr) {
1345
+ arr = [];
1346
+ byTopic.set(r.topicIndex, arr);
1347
+ }
1348
+ arr.push(r);
1349
+ }
1350
+ const topicBytes = /* @__PURE__ */ new Map();
1351
+ for (const [topicIndex, fields] of byTopic) {
1352
+ let sum = 0;
1353
+ for (const r of fields) sum += r.sizeBytes;
1354
+ topicBytes.set(topicIndex, sum);
1355
+ }
1356
+ let currentBin = /* @__PURE__ */ new Map();
1357
+ let currentBytes = 0;
1358
+ const flush = () => {
1359
+ if (currentBin.size === 0) return true;
1360
+ const f = makeFrame({
1361
+ topics: currentBin,
1362
+ dir,
1363
+ periodUs,
1364
+ alloc
1365
+ });
1366
+ if (!f) return false;
1367
+ out.push(f);
1368
+ currentBin = /* @__PURE__ */ new Map();
1369
+ currentBytes = 0;
1370
+ return true;
1371
+ };
1372
+ const sortedTopics = [...byTopic.keys()].sort((a, b) => a - b);
1373
+ for (const topicIndex of sortedTopics) {
1374
+ const fields = byTopic.get(topicIndex);
1375
+ if (!fields) continue;
1376
+ const sz = topicBytes.get(topicIndex) ?? 0;
1377
+ if (sz > 64) {
1378
+ if (!flush()) return null;
1379
+ if (!emitOversizedTopic(fields, dir, periodUs, alloc, out)) return null;
1380
+ continue;
1381
+ }
1382
+ if (currentBytes + sz > 64) {
1383
+ if (!flush()) return null;
1384
+ }
1385
+ currentBin.set(topicIndex, fields);
1386
+ currentBytes += sz;
1387
+ }
1388
+ if (!flush()) return null;
1389
+ }
1390
+ return out;
1391
+ }
1392
+ //#endregion
1393
+ //#region src/default-plan.ts
1394
+ const I_TOKEN$1 = "{i}";
1395
+ function expandIToken$1(s, value) {
1396
+ if (!s.includes(I_TOKEN$1)) return s;
1397
+ return s.split(I_TOKEN$1).join(String(value));
1398
+ }
1399
+ function joinPath$1(ns, name) {
1400
+ if (ns.length === 0) return name;
1401
+ if (name.length === 0) return ns;
1402
+ return `${ns}/${name}`;
1403
+ }
1404
+ /**
1405
+ * Synthesize the default {@link FieldRequest} list from a decoded schema +
1406
+ * numbering table — every S2M topic field at its declared period
1407
+ * (`field.periodUsOverride` if present, else the owning topic's
1408
+ * `defaultPeriodUs`), skipping `txEnabled === false` fields.
1409
+ *
1410
+ * Pass `filter` to layer a runtime per-field mask on top (UI toggles,
1411
+ * per-slave overrides). Schema-disabled fields are unconditionally
1412
+ * dropped before `filter` is consulted, so callers don't have to repeat
1413
+ * the `txEnabled` check.
1414
+ *
1415
+ * This is the natural input to {@link planFrames} when the master just
1416
+ * wants to "observe everything the slave publishes" — i.e. the
1417
+ * sniffer-style auto-provision path.
1418
+ */
1419
+ function buildDefaultS2MRequests(schema, numbering, filter) {
1420
+ const out = [];
1421
+ numbering.topics.byIndex.forEach((endpoint, flatIndex) => {
1422
+ const block = schema.instances[endpoint.blockArray];
1423
+ if (!block) return;
1424
+ const type = schema.types[block.blockTypeIndex];
1425
+ if (!type) return;
1426
+ const topic = type.topics[endpoint.endpoint];
1427
+ if (!topic) return;
1428
+ if (topic.dir !== 1) return;
1429
+ let topicPath = null;
1430
+ topic.fields.forEach((field, fieldId) => {
1431
+ if (!field.txEnabled) return;
1432
+ if (filter !== void 0) {
1433
+ if (topicPath === null) topicPath = joinPath$1(expandIToken$1(block.rosNamespace, endpoint.instance), topic.name);
1434
+ if (!filter({
1435
+ topicPath,
1436
+ topicIndex: flatIndex,
1437
+ fieldId,
1438
+ fieldName: field.name,
1439
+ field
1440
+ })) return;
1441
+ }
1442
+ out.push({
1443
+ topicIndex: flatIndex,
1444
+ fieldId,
1445
+ sizeBytes: fieldWireSize(field),
1446
+ periodUs: field.periodUsOverride ?? topic.defaultPeriodUs,
1447
+ dir: topic.dir
1448
+ });
1449
+ });
1450
+ });
1451
+ return out;
1452
+ }
1453
+ /**
1454
+ * One-line FramePlanFactory that subscribes to every S2M field at its
1455
+ * declared period using a freshly-allocated {@link StdIdAllocator}.
1456
+ *
1457
+ * Pass `filter` to apply a runtime per-field mask (e.g. a UI-controlled
1458
+ * field-tx-enable map). With no filter, every schema-enabled S→M field is
1459
+ * subscribed.
1460
+ *
1461
+ * Returns `[]` when planning fails (e.g. std_id exhaustion); the caller's
1462
+ * NodeAgent will then leave the slave in `provisioning` → `awaiting-announce`
1463
+ * with no frames defined, which is the right outcome — partial plans would
1464
+ * silently drop fields.
1465
+ */
1466
+ function defaultS2MFramePlan(schema, numbering, filter) {
1467
+ const planned = planFrames(buildDefaultS2MRequests(schema, numbering, filter), new StdIdAllocator());
1468
+ if (planned === null) return [];
1469
+ return planned.map((s) => ({
1470
+ stdId: s.stdCanId,
1471
+ periodUs: s.periodUs,
1472
+ dir: s.dir,
1473
+ entries: s.entries
1474
+ }));
1475
+ }
1476
+ //#endregion
1477
+ //#region src/master-heartbeat.ts
1478
+ /**
1479
+ * Broadcasts CMD_MASTER_HEARTBEAT (§5.11) on a fixed period so slaves with
1480
+ * a `master_lost_us` watchdog configured do not silence their S→M traffic.
1481
+ *
1482
+ * Independent of any single {@link NodeAgent} — the heartbeat is a single
1483
+ * bus-wide broadcast that refreshes every slave's watchdog. Construct one
1484
+ * per transport.
1485
+ */
1486
+ var MasterHeartbeatBroadcaster = class {
1487
+ transport;
1488
+ periodMs;
1489
+ #handle = null;
1490
+ constructor(transport, periodMs = 100) {
1491
+ this.transport = transport;
1492
+ this.periodMs = periodMs;
1493
+ }
1494
+ start() {
1495
+ if (this.#handle !== null) return;
1496
+ const tick = () => {
1497
+ if (!this.transport.isOpen) return;
1498
+ this.transport.send(encodeMasterHeartbeat()).catch(() => {});
1499
+ };
1500
+ tick();
1501
+ this.#handle = setInterval(tick, this.periodMs);
1502
+ }
1503
+ stop() {
1504
+ if (this.#handle === null) return;
1505
+ clearInterval(this.#handle);
1506
+ this.#handle = null;
1507
+ }
1508
+ get running() {
1509
+ return this.#handle !== null;
1510
+ }
1511
+ };
1512
+ //#endregion
1513
+ //#region src/numbering.ts
1514
+ /**
1515
+ * Flat-numbering tables for topics, services, and parameters (§4.2).
1516
+ *
1517
+ * Port of `fibril_can_core/src/numbering.cpp`. Numbering is a pure function
1518
+ * of (schema, instance-count-vector). The same tables are derived
1519
+ * independently by codegen (with max_count for static checks), the slave
1520
+ * runtime (with actual counts at boot) and the master (with counts from the
1521
+ * ANNOUNCE frame) — keeping this in one place is what guarantees they agree.
1522
+ */
1523
+ const SERVICE_INDEX_CANCEL = 255;
1524
+ const SERVICE_INDEX_PARAM_SET = 253;
1525
+ /** Maximum first-class service_index produced by numbering (inclusive). */
1526
+ const SERVICE_INDEX_MAX = 252;
1527
+ /** Number of available first-class service slots. */
1528
+ const SERVICE_INDEX_CAPACITY = 253;
1529
+ let NumberingError = /* @__PURE__ */ function(NumberingError) {
1530
+ NumberingError["Ok"] = "ok";
1531
+ NumberingError["ServiceIndexOverflow"] = "service_index overflow (>253)";
1532
+ NumberingError["TopicIndexOverflow"] = "topic_index overflow (>65535)";
1533
+ NumberingError["ParamIndexOverflow"] = "param_index overflow (>65535)";
1534
+ NumberingError["InstanceCountMismatch"] = "actual_counts length != #instances";
1535
+ NumberingError["BlockTypeOutOfRange"] = "BlockArray::blockTypeIndex out of range";
1536
+ return NumberingError;
1537
+ }({});
1538
+ const N_TOPICS = (bt) => bt.topics.length;
1539
+ const N_SERVICES = (bt) => bt.services.length;
1540
+ const N_PARAMS = (bt) => bt.params.length;
1541
+ function buildTable(schema, counts, pick) {
1542
+ const byIndex = [];
1543
+ const bases = [];
1544
+ let base = 0;
1545
+ bases.push(base);
1546
+ for (let b = 0; b < schema.instances.length; b++) {
1547
+ const arr = schema.instances[b];
1548
+ if (!arr) continue;
1549
+ const type = schema.types[arr.blockTypeIndex];
1550
+ if (!type) continue;
1551
+ const n = pick(type);
1552
+ const count = counts[b] ?? 0;
1553
+ for (let inst = 0; inst < count; inst++) for (let ep = 0; ep < n; ep++) byIndex.push({
1554
+ blockArray: b,
1555
+ instance: inst,
1556
+ endpoint: ep
1557
+ });
1558
+ base += n * count;
1559
+ bases.push(base);
1560
+ }
1561
+ return {
1562
+ byIndex,
1563
+ bases
1564
+ };
1565
+ }
1566
+ /**
1567
+ * Compute the three numbering tables. `actualCounts` must have the same
1568
+ * length as `schema.instances`. The result has `status !== Ok` if input is
1569
+ * malformed or service_index would overflow into the reserved range
1570
+ * (§4.2 §9.4).
1571
+ *
1572
+ * On error the returned tables are empty but still safe to inspect.
1573
+ */
1574
+ function computeNumbering(schema, actualCounts) {
1575
+ const empty = {
1576
+ byIndex: [],
1577
+ bases: []
1578
+ };
1579
+ const fail = (status) => ({
1580
+ topics: empty,
1581
+ services: empty,
1582
+ params: empty,
1583
+ status
1584
+ });
1585
+ if (actualCounts.length !== schema.instances.length) return fail("actual_counts length != #instances");
1586
+ for (const arr of schema.instances) if (arr.blockTypeIndex >= schema.types.length) return fail("BlockArray::blockTypeIndex out of range");
1587
+ const topics = buildTable(schema, actualCounts, N_TOPICS);
1588
+ const services = buildTable(schema, actualCounts, N_SERVICES);
1589
+ const params = buildTable(schema, actualCounts, N_PARAMS);
1590
+ if (topics.byIndex.length > 65535) return {
1591
+ topics: empty,
1592
+ services: empty,
1593
+ params: empty,
1594
+ status: "topic_index overflow (>65535)"
1595
+ };
1596
+ if (params.byIndex.length > 65535) return {
1597
+ topics: empty,
1598
+ services: empty,
1599
+ params: empty,
1600
+ status: "param_index overflow (>65535)"
1601
+ };
1602
+ if (services.byIndex.length > 253) return {
1603
+ topics: empty,
1604
+ services: empty,
1605
+ params: empty,
1606
+ status: "service_index overflow (>253)"
1607
+ };
1608
+ return {
1609
+ topics,
1610
+ services,
1611
+ params,
1612
+ status: "ok"
1613
+ };
1614
+ }
1615
+ //#endregion
1616
+ //#region src/schema-hash.ts
1617
+ /**
1618
+ * SHA-256 helpers for the schema blob (§5.4, §9.3).
1619
+ *
1620
+ * Both codegen (C++) and the bridge (C++) hash the entire schema blob with
1621
+ * SHA-256 and pack the first 8 bytes as little-endian u64 into the ANNOUNCE
1622
+ * frame. This module is the master-side equivalent: hash the blob we
1623
+ * received over SCHEMA_DATA and compare with the schema_hash the slave
1624
+ * advertised in ANNOUNCE.
1625
+ *
1626
+ * Uses Web Crypto (`crypto.subtle`) — available in modern browsers and Node
1627
+ * ≥ 19 / Deno / Bun, no polyfill needed for our target environments.
1628
+ */
1629
+ async function sha256(bytes) {
1630
+ if (typeof crypto === "undefined" || !crypto.subtle) throw new Error("crypto.subtle is not available in this environment");
1631
+ const owned = new Uint8Array(bytes);
1632
+ const digest = await crypto.subtle.digest("SHA-256", owned);
1633
+ return new Uint8Array(digest);
1634
+ }
1635
+ /**
1636
+ * Compute the ANNOUNCE-shape schema hash: the first 8 bytes of SHA-256(blob),
1637
+ * interpreted as a little-endian u64. Mirrors
1638
+ * `fibril_can_core/sha256::hash_prefix_u64`.
1639
+ */
1640
+ async function schemaHashPrefix(blob) {
1641
+ const digest = await sha256(blob);
1642
+ let result = 0n;
1643
+ for (let i = 0; i < 8; i++) result |= BigInt(digest[i] ?? 0) << BigInt(i * 8);
1644
+ return result;
1645
+ }
1646
+ //#endregion
1647
+ //#region src/schema-index.ts
1648
+ /**
1649
+ * Schema-driven path lookup. Resolves `'<expanded_ns>/<endpoint_name>'`
1650
+ * style paths to the flat (topic|service|param) index plus the field IR
1651
+ * needed to drive {@link ./dynamic-codec.ts}.
1652
+ *
1653
+ * Paths are local to a single slave (one {@link NodeAgent}). The block-array
1654
+ * `ros_namespace` is expanded by substituting `{i}` with the instance index
1655
+ * (mirrors `expand_i_token` in `fibril_can_bridge::node_agent_internal.hpp`).
1656
+ *
1657
+ * Two endpoints of the same kind sharing a path is a schema error — the
1658
+ * index throws on construction. Same path across different kinds (e.g. a
1659
+ * topic and a service named `motor0/foo`) is allowed because the caller
1660
+ * selects the kind explicitly via `topic()` / `service()` / `param()`.
1661
+ */
1662
+ const I_TOKEN = "{i}";
1663
+ function expandIToken(s, value) {
1664
+ if (!s.includes(I_TOKEN)) return s;
1665
+ return s.split(I_TOKEN).join(String(value));
1666
+ }
1667
+ function joinPath(ns, name) {
1668
+ if (ns.length === 0) return name;
1669
+ if (name.length === 0) return ns;
1670
+ return `${ns}/${name}`;
1671
+ }
1672
+ function sumFieldsWireSize(fields) {
1673
+ let n = 0;
1674
+ for (const f of fields) n += fieldWireSize(f);
1675
+ return n;
1676
+ }
1677
+ /**
1678
+ * Path → descriptor index for one slave. Built once from
1679
+ * (schema, numbering) and reused for the lifetime of a snapshot.
1680
+ */
1681
+ var SchemaIndex = class {
1682
+ #topicsByPath = /* @__PURE__ */ new Map();
1683
+ #servicesByPath = /* @__PURE__ */ new Map();
1684
+ #paramsByPath = /* @__PURE__ */ new Map();
1685
+ #topicsByIndex = /* @__PURE__ */ new Map();
1686
+ #servicesByIndex = /* @__PURE__ */ new Map();
1687
+ #paramsByIndex = /* @__PURE__ */ new Map();
1688
+ constructor(schema, numbering, instanceCounts) {
1689
+ if (instanceCounts.length !== schema.instances.length) throw new RangeError(`SchemaIndex: instanceCounts.length (${instanceCounts.length}) != schema.instances.length (${schema.instances.length})`);
1690
+ for (let arrIdx = 0; arrIdx < schema.instances.length; arrIdx++) {
1691
+ const arr = schema.instances[arrIdx];
1692
+ if (!arr) continue;
1693
+ const block = schema.types[arr.blockTypeIndex];
1694
+ if (!block) continue;
1695
+ const topicBase = numbering.topics.bases[arrIdx];
1696
+ const serviceBase = numbering.services.bases[arrIdx];
1697
+ const paramBase = numbering.params.bases[arrIdx];
1698
+ if (topicBase === void 0 || serviceBase === void 0 || paramBase === void 0) continue;
1699
+ const instCount = instanceCounts[arrIdx] ?? 0;
1700
+ for (let inst = 0; inst < instCount; inst++) {
1701
+ const ns = expandIToken(arr.rosNamespace, inst);
1702
+ for (let ep = 0; ep < block.topics.length; ep++) {
1703
+ const t = block.topics[ep];
1704
+ if (!t) continue;
1705
+ const path = joinPath(ns, t.name);
1706
+ const idx = topicBase + inst * block.topics.length + ep;
1707
+ const desc = {
1708
+ kind: "topic",
1709
+ path,
1710
+ topicIndex: idx,
1711
+ dir: t.dir,
1712
+ fields: t.fields,
1713
+ defaultPeriodUs: t.defaultPeriodUs,
1714
+ wireSize: sumFieldsWireSize(t.fields)
1715
+ };
1716
+ if (this.#topicsByPath.has(path)) throw new Error(`SchemaIndex: duplicate topic path '${path}'`);
1717
+ this.#topicsByPath.set(path, desc);
1718
+ this.#topicsByIndex.set(idx, desc);
1719
+ }
1720
+ for (let ep = 0; ep < block.services.length; ep++) {
1721
+ const s = block.services[ep];
1722
+ if (!s) continue;
1723
+ const path = joinPath(ns, s.name);
1724
+ const idx = serviceBase + inst * block.services.length + ep;
1725
+ const desc = {
1726
+ kind: "service",
1727
+ path,
1728
+ serviceIndex: idx,
1729
+ requestFields: s.request,
1730
+ responseFields: s.response,
1731
+ requestWireSize: sumFieldsWireSize(s.request),
1732
+ responseWireSize: sumFieldsWireSize(s.response)
1733
+ };
1734
+ if (this.#servicesByPath.has(path)) throw new Error(`SchemaIndex: duplicate service path '${path}'`);
1735
+ this.#servicesByPath.set(path, desc);
1736
+ this.#servicesByIndex.set(idx, desc);
1737
+ }
1738
+ for (let ep = 0; ep < block.params.length; ep++) {
1739
+ const p = block.params[ep];
1740
+ if (!p) continue;
1741
+ const path = joinPath(ns, p.name);
1742
+ const idx = paramBase + inst * block.params.length + ep;
1743
+ const wireSize = fieldWireSize({
1744
+ name: p.name,
1745
+ type: p.type,
1746
+ arrayLen: p.arrayLen,
1747
+ txEnabled: true
1748
+ });
1749
+ const desc = {
1750
+ kind: "param",
1751
+ path,
1752
+ paramIndex: idx,
1753
+ type: p.type,
1754
+ arrayLen: p.arrayLen,
1755
+ wireSize,
1756
+ defaultValue: p.defaultValue,
1757
+ minValue: p.minValue,
1758
+ maxValue: p.maxValue
1759
+ };
1760
+ if (this.#paramsByPath.has(path)) throw new Error(`SchemaIndex: duplicate param path '${path}'`);
1761
+ this.#paramsByPath.set(path, desc);
1762
+ this.#paramsByIndex.set(idx, desc);
1763
+ }
1764
+ }
1765
+ }
1766
+ }
1767
+ topic(path) {
1768
+ return this.#topicsByPath.get(path);
1769
+ }
1770
+ service(path) {
1771
+ return this.#servicesByPath.get(path);
1772
+ }
1773
+ param(path) {
1774
+ return this.#paramsByPath.get(path);
1775
+ }
1776
+ topicByIndex(idx) {
1777
+ return this.#topicsByIndex.get(idx);
1778
+ }
1779
+ serviceByIndex(idx) {
1780
+ return this.#servicesByIndex.get(idx);
1781
+ }
1782
+ paramByIndex(idx) {
1783
+ return this.#paramsByIndex.get(idx);
1784
+ }
1785
+ /** All topic paths in numbering order. Useful for debugging / sniffer UI. */
1786
+ topicPaths() {
1787
+ return [...this.#topicsByPath.keys()];
1788
+ }
1789
+ servicePaths() {
1790
+ return [...this.#servicesByPath.keys()];
1791
+ }
1792
+ paramPaths() {
1793
+ return [...this.#paramsByPath.keys()];
1794
+ }
1795
+ };
1796
+ //#endregion
1797
+ //#region src/seq-allocator.ts
1798
+ /**
1799
+ * Sequential u8 allocator used for the config-plane `seq` field shared by
1800
+ * every command this master has issued to a given slave. Wraps at 256,
1801
+ * matching `BridgeNode::alloc_seq` in
1802
+ * `fibril_can_bridge/src/bridge_node.cpp` (the bridge wraps on the same
1803
+ * boundary; the AckTracker indexes by the wrapped value).
1804
+ */
1805
+ var SeqAllocator = class {
1806
+ #next = 0;
1807
+ next() {
1808
+ const v = this.#next & 255;
1809
+ this.#next = this.#next + 1 & 255;
1810
+ return v;
1811
+ }
1812
+ reset() {
1813
+ this.#next = 0;
1814
+ }
1815
+ };
1816
+ //#endregion
1817
+ //#region src/segmented-transfer.ts
1818
+ /**
1819
+ * Build the §7.4 SVC_REQUEST frame stream for a master→slave call. Mirrors
1820
+ * `make_svc_request_frames` in
1821
+ * `fibril_can_bridge/include/fibril_can_bridge/svc_request.hpp`.
1822
+ *
1823
+ * - ≤64 byte payload → one SINGLE frame (seg=0).
1824
+ * - Larger → seq=0 carries `[0]=0 [1..2]=total_len + ≤61B data`, subsequent
1825
+ * seqs carry `[0]=seq + ≤63B data`. Throws on payloads >1024B.
1826
+ */
1827
+ function makeSvcRequestFrames(nodeId, svcIdx, callId, payload) {
1828
+ if (payload.length > 1024) throw new RangeError(`service request payload ${payload.length} exceeds SEG_MAX_TOTAL (${SEG_MAX_TOTAL})`);
1829
+ if (payload.length <= 64) return [{
1830
+ id: makeExtId(1, nodeId, svcIdx, callId, 0),
1831
+ extended: true,
1832
+ fd: true,
1833
+ data: payload
1834
+ }];
1835
+ const total = payload.length;
1836
+ const rest = total - 61;
1837
+ const nSegs = 1 + Math.ceil(rest / 63);
1838
+ if (nSegs - 1 > 31) throw new RangeError(`segmented request would need ${nSegs} segments (cap 32)`);
1839
+ const out = [];
1840
+ for (let seq = 0; seq < nSegs; seq++) {
1841
+ let body;
1842
+ if (seq === 0) {
1843
+ const take = Math.min(total, 61);
1844
+ body = new Uint8Array(3 + take);
1845
+ body[0] = 0;
1846
+ body[1] = total & 255;
1847
+ body[2] = total >>> 8 & 255;
1848
+ body.set(payload.subarray(0, take), 3);
1849
+ } else {
1850
+ const off = segOffset(seq);
1851
+ const take = Math.min(63, total - off);
1852
+ body = new Uint8Array(1 + take);
1853
+ body[0] = seq;
1854
+ body.set(payload.subarray(off, off + take), 1);
1855
+ }
1856
+ out.push({
1857
+ id: makeExtId(1, nodeId, svcIdx, callId, 1),
1858
+ extended: true,
1859
+ fd: true,
1860
+ data: body
1861
+ });
1862
+ }
1863
+ return out;
1864
+ }
1865
+ /**
1866
+ * Build §7.4 response segments. Used by mock slaves and any TS slave runtime.
1867
+ * `channel` is the channel field (typically `CHAN_SVC_RESPONSE`); the payload
1868
+ * is `[status, ...response]` so the caller bakes status into the buffer.
1869
+ */
1870
+ function makeSvcSegmentedFrames(channel, nodeId, svcIdx, callId, payload) {
1871
+ if (payload.length > 1024) throw new RangeError(`service response payload ${payload.length} exceeds SEG_MAX_TOTAL (${SEG_MAX_TOTAL})`);
1872
+ if (payload.length <= 64) return [{
1873
+ id: makeExtId(channel, nodeId, svcIdx, callId, 0),
1874
+ extended: true,
1875
+ fd: true,
1876
+ data: payload
1877
+ }];
1878
+ const total = payload.length;
1879
+ const rest = total - 61;
1880
+ const nSegs = 1 + Math.ceil(rest / 63);
1881
+ if (nSegs - 1 > 31) throw new RangeError(`segmented response would need ${nSegs} segments (cap 32)`);
1882
+ const out = [];
1883
+ for (let seq = 0; seq < nSegs; seq++) {
1884
+ let body;
1885
+ if (seq === 0) {
1886
+ const take = Math.min(total, 61);
1887
+ body = new Uint8Array(3 + take);
1888
+ body[0] = 0;
1889
+ body[1] = total & 255;
1890
+ body[2] = total >>> 8 & 255;
1891
+ body.set(payload.subarray(0, take), 3);
1892
+ } else {
1893
+ const off = segOffset(seq);
1894
+ const take = Math.min(63, total - off);
1895
+ body = new Uint8Array(1 + take);
1896
+ body[0] = seq;
1897
+ body.set(payload.subarray(off, off + take), 1);
1898
+ }
1899
+ out.push({
1900
+ id: makeExtId(channel, nodeId, svcIdx, callId, 1),
1901
+ extended: true,
1902
+ fd: true,
1903
+ data: body
1904
+ });
1905
+ }
1906
+ return out;
1907
+ }
1908
+ /**
1909
+ * Bitmask of segment seqs required to reassemble a `totalLen`-byte payload.
1910
+ * Identical formula to `expected_mask` in
1911
+ * `fibril_can_bridge/src/node_agent.cpp` and the slave's `seg_expected_mask`.
1912
+ */
1913
+ function segExpectedMask(totalLen) {
1914
+ if (totalLen <= 0) return 0;
1915
+ if (totalLen <= 61) return 1;
1916
+ const rest = totalLen - 61;
1917
+ const nSegs = 1 + Math.ceil(rest / 63);
1918
+ if (nSegs >= 32) return 4294967295;
1919
+ return (1 << nSegs) - 1;
1920
+ }
1921
+ /**
1922
+ * Per-(svc, call_id) reassembly slot. The caller drives one of these per
1923
+ * outstanding incoming segmented transfer.
1924
+ */
1925
+ var SegmentReassembler = class {
1926
+ totalLen = 0;
1927
+ rxBitmap = 0;
1928
+ lastRxMs = 0;
1929
+ #buffer = /* @__PURE__ */ new Uint8Array(0);
1930
+ /**
1931
+ * Ingest one seg=1 frame. `data` is the frame payload (with the seq /
1932
+ * total_len header still attached). Returns the fully-assembled payload
1933
+ * once every required segment has arrived; otherwise null. Returns null
1934
+ * on protocol violations and resets the slot.
1935
+ */
1936
+ ingest(data, nowMs) {
1937
+ if (data.length === 0) return this.#reset();
1938
+ const seq = data[0] ?? 0;
1939
+ if (seq > 31) return this.#reset();
1940
+ this.lastRxMs = nowMs;
1941
+ if (seq === 0) {
1942
+ if (data.length < 3) return this.#reset();
1943
+ const total = ((data[1] ?? 0) | (data[2] ?? 0) << 8) & 65535;
1944
+ if (total === 0 || total > 1024) return this.#reset();
1945
+ if (this.totalLen !== 0 && this.totalLen !== total) {
1946
+ this.totalLen = total;
1947
+ this.rxBitmap = 0;
1948
+ this.#buffer = new Uint8Array(total);
1949
+ } else {
1950
+ this.totalLen = total;
1951
+ if (this.#buffer.length < total) {
1952
+ const grown = new Uint8Array(total);
1953
+ grown.set(this.#buffer.subarray(0, Math.min(this.#buffer.length, total)));
1954
+ this.#buffer = grown;
1955
+ }
1956
+ }
1957
+ const segDataLen = Math.min(data.length - 3, 61, total);
1958
+ if (segDataLen > 0) this.#buffer.set(data.subarray(3, 3 + segDataLen), 0);
1959
+ } else {
1960
+ const off = segOffset(seq);
1961
+ const segDataLen = Math.min(data.length - 1, 63);
1962
+ if (off + segDataLen > 1024) return this.#reset();
1963
+ if (this.totalLen !== 0 && off + segDataLen > this.totalLen) return this.#reset();
1964
+ if (this.#buffer.length < off + segDataLen) {
1965
+ const grown = new Uint8Array(off + segDataLen);
1966
+ grown.set(this.#buffer);
1967
+ this.#buffer = grown;
1968
+ }
1969
+ if (segDataLen > 0) this.#buffer.set(data.subarray(1, 1 + segDataLen), off);
1970
+ }
1971
+ this.rxBitmap |= 1 << seq;
1972
+ if (this.totalLen === 0) return null;
1973
+ const need = segExpectedMask(this.totalLen);
1974
+ if ((this.rxBitmap & ~need) !== 0) return this.#reset();
1975
+ if ((this.rxBitmap & need) !== need) return null;
1976
+ const out = this.#buffer.slice(0, this.totalLen);
1977
+ this.#reset();
1978
+ return out;
1979
+ }
1980
+ #reset() {
1981
+ this.totalLen = 0;
1982
+ this.rxBitmap = 0;
1983
+ this.#buffer = /* @__PURE__ */ new Uint8Array(0);
1984
+ return null;
1985
+ }
1986
+ };
1987
+ //#endregion
1988
+ //#region src/service-client.ts
1989
+ /** Throwable error union for `ServiceClient.call`. */
1990
+ var ServiceCallError = class extends Error {
1991
+ constructor(message, options) {
1992
+ super(message, options);
1993
+ this.name = new.target.name;
1994
+ }
1995
+ };
1996
+ var ServiceTimeoutError = class extends ServiceCallError {
1997
+ constructor(message = "service call timed out") {
1998
+ super(message);
1999
+ }
2000
+ };
2001
+ var ServiceUnavailableError = class extends ServiceCallError {
2002
+ constructor(message = "service unavailable (slave lost or reprovisioned)") {
2003
+ super(message);
2004
+ }
2005
+ };
2006
+ var ServiceCancelledError = class extends ServiceCallError {
2007
+ constructor(message = "service call cancelled") {
2008
+ super(message);
2009
+ }
2010
+ };
2011
+ var NoCallSlotError = class extends ServiceCallError {
2012
+ constructor(serviceIndex) {
2013
+ super(`no free call_id slot for service_index ${serviceIndex}`);
2014
+ }
2015
+ };
2016
+ /** Cap on simultaneous in-flight segmented transfers. Matches bridge. */
2017
+ const REASM_MAX_CONCURRENT = 16;
2018
+ /** §7.4 reassembly TTL. */
2019
+ const SEG_TIMEOUT_MS = 100;
2020
+ /**
2021
+ * Single-slave master service-call client. Port of NodeAgent's
2022
+ * begin_service_call / on_service_response / on_service_segment flow but
2023
+ * Promise-based and AbortSignal-aware.
2024
+ *
2025
+ * One {@link ServiceClient} attaches to the transport and demuxes incoming
2026
+ * SVC_RESPONSE frames for a single `nodeId`. NodeAgent owns one per slave
2027
+ * and exposes it on the running snapshot.
2028
+ */
2029
+ var ServiceClient = class {
2030
+ nodeId;
2031
+ #transport;
2032
+ #defaultTimeoutMs;
2033
+ #now;
2034
+ #unsub = null;
2035
+ #nextCallId = /* @__PURE__ */ new Map();
2036
+ #pending = /* @__PURE__ */ new Map();
2037
+ constructor(opts) {
2038
+ this.nodeId = opts.nodeId;
2039
+ this.#transport = opts.transport;
2040
+ this.#defaultTimeoutMs = opts.defaultTimeoutMs ?? 250;
2041
+ this.#now = opts.now ?? (() => Date.now());
2042
+ }
2043
+ attach() {
2044
+ if (this.#unsub !== null) return;
2045
+ this.#unsub = this.#transport.onFrame((f) => this.#onFrame(f));
2046
+ }
2047
+ detach() {
2048
+ if (this.#unsub === null) return;
2049
+ this.#unsub();
2050
+ this.#unsub = null;
2051
+ }
2052
+ /** Number of in-flight calls. Testing / introspection. */
2053
+ get pendingCount() {
2054
+ return this.#pending.size;
2055
+ }
2056
+ /**
2057
+ * Sweep call deadlines and segment-reassembly TTLs. The owning agent
2058
+ * should call this from its periodic sweep loop.
2059
+ */
2060
+ sweep(nowMs = this.#now()) {
2061
+ for (const [key, call] of this.#pending) {
2062
+ if (call.deadlineMs <= nowMs) {
2063
+ this.#pending.delete(key);
2064
+ this.#detachAbort(call);
2065
+ call.reject(new ServiceTimeoutError());
2066
+ continue;
2067
+ }
2068
+ if (call.reassembler.totalLen !== 0 && nowMs - call.reassembler.lastRxMs > SEG_TIMEOUT_MS) {
2069
+ call.reassembler.totalLen = 0;
2070
+ call.reassembler.rxBitmap = 0;
2071
+ }
2072
+ }
2073
+ }
2074
+ /** Reject every in-flight call as Unavailable. NodeAgent calls this on lost / reprovision. */
2075
+ failAll(reason = "slave reprovisioning") {
2076
+ const pending = [...this.#pending.values()];
2077
+ this.#pending.clear();
2078
+ for (const c of pending) {
2079
+ this.#detachAbort(c);
2080
+ c.reject(new ServiceUnavailableError(reason));
2081
+ }
2082
+ }
2083
+ async call(serviceIndex, opts) {
2084
+ if (serviceIndex < 0 || serviceIndex > 255) throw new RangeError(`serviceIndex out of range: ${serviceIndex}`);
2085
+ const callId = this.#allocCallId(serviceIndex);
2086
+ if (callId === 255) throw new NoCallSlotError(serviceIndex);
2087
+ const timeoutMs = opts.timeoutMs ?? this.#defaultTimeoutMs;
2088
+ const key = serviceIndex << 8 | callId;
2089
+ if (this.#pending.size >= REASM_MAX_CONCURRENT * 4) throw new NoCallSlotError(serviceIndex);
2090
+ return new Promise((resolve, reject) => {
2091
+ const call = {
2092
+ serviceIndex,
2093
+ callId,
2094
+ deadlineMs: this.#now() + timeoutMs,
2095
+ timeoutMs,
2096
+ resolve,
2097
+ reject,
2098
+ reassembler: new SegmentReassembler(),
2099
+ acceptedSeen: false
2100
+ };
2101
+ if (opts.signal) {
2102
+ call.signal = opts.signal;
2103
+ if (opts.signal.aborted) {
2104
+ reject(new ServiceCancelledError());
2105
+ return;
2106
+ }
2107
+ call.abortListener = () => {
2108
+ if (!this.#pending.has(key)) return;
2109
+ this.#pending.delete(key);
2110
+ this.#detachAbort(call);
2111
+ this.#sendCancel(call.serviceIndex, call.callId).catch(() => {});
2112
+ reject(new ServiceCancelledError());
2113
+ };
2114
+ opts.signal.addEventListener("abort", call.abortListener, { once: true });
2115
+ }
2116
+ this.#pending.set(key, call);
2117
+ const frames = makeSvcRequestFrames(this.nodeId, serviceIndex, callId, opts.request);
2118
+ for (const f of frames) this.#transport.send(f).catch(() => {});
2119
+ });
2120
+ }
2121
+ #onFrame(f) {
2122
+ if (!f.extended) return;
2123
+ if (extNodeId(f.id) !== this.nodeId) return;
2124
+ if (extChannel(f.id) !== 2) return;
2125
+ const svcIdx = extIndex(f.id);
2126
+ const callId = extSeq(f.id);
2127
+ const seg = extSeg(f.id);
2128
+ const key = svcIdx << 8 | callId;
2129
+ const call = this.#pending.get(key);
2130
+ if (!call) return;
2131
+ if (seg === 0) {
2132
+ if (f.data.length === 0) return;
2133
+ const status = f.data[0] ?? 0;
2134
+ const data = f.data.slice(1);
2135
+ this.#finishMaybeAccepted(key, call, status, data);
2136
+ return;
2137
+ }
2138
+ const assembled = call.reassembler.ingest(f.data, this.#now());
2139
+ if (assembled === null) return;
2140
+ if (assembled.length === 0) return;
2141
+ const status = assembled[0] ?? 0;
2142
+ const data = assembled.slice(1);
2143
+ this.#finishMaybeAccepted(key, call, status, data);
2144
+ }
2145
+ #finishMaybeAccepted(key, call, status, data) {
2146
+ if (status === 1) {
2147
+ if (!call.acceptedSeen) {
2148
+ call.acceptedSeen = true;
2149
+ call.deadlineMs = this.#now() + call.timeoutMs;
2150
+ }
2151
+ return;
2152
+ }
2153
+ this.#pending.delete(key);
2154
+ this.#detachAbort(call);
2155
+ call.resolve({
2156
+ status,
2157
+ data
2158
+ });
2159
+ }
2160
+ #allocCallId(svcIdx) {
2161
+ let next = this.#nextCallId.get(svcIdx) ?? 0;
2162
+ for (let i = 0; i < 255; i++) {
2163
+ if (next === 255) next = 0;
2164
+ const key = svcIdx << 8 | next;
2165
+ if (!this.#pending.has(key)) {
2166
+ this.#nextCallId.set(svcIdx, next + 1 & 255);
2167
+ return next;
2168
+ }
2169
+ next = next + 1 & 255;
2170
+ }
2171
+ return 255;
2172
+ }
2173
+ async #sendCancel(svcIdx, callId) {
2174
+ const payload = padToDlc(new Uint8Array([svcIdx & 255, callId & 255]));
2175
+ await this.#transport.send({
2176
+ id: makeExtId(1, this.nodeId, 255, 0, 0),
2177
+ extended: true,
2178
+ fd: true,
2179
+ data: payload
2180
+ });
2181
+ }
2182
+ #detachAbort(call) {
2183
+ if (call.signal && call.abortListener) {
2184
+ call.signal.removeEventListener("abort", call.abortListener);
2185
+ call.abortListener = void 0;
2186
+ }
2187
+ }
2188
+ };
2189
+ //#endregion
2190
+ //#region src/typed-param.ts
2191
+ /**
2192
+ * Schema-driven typed wrapper around PARAM_SET (SPEC §8.2, reserved
2193
+ * service_index 0xFD).
2194
+ *
2195
+ * Per SPEC §8.1 PARAM is M→S one-way: the master holds the source of truth
2196
+ * and re-injects defaults at provisioning time, so this wrapper exposes
2197
+ * `.set()` only — there's no `.get()` that queries the slave. The
2198
+ * descriptor's decoded default is available via `defaultValue()` for
2199
+ * reference / hydration of master-side state.
2200
+ *
2201
+ * The wire shape for one PARAM_SET request is:
2202
+ * [0] count = 1
2203
+ * [1..2] u16 param_index (LE)
2204
+ * [3..] value (scalar little-endian or fixed-array contiguous)
2205
+ *
2206
+ * Response is `[0] status [1..2] failed_param_index (when status != OK)`.
2207
+ * `set()` resolves with the status code; non-OK includes `failedIndex` so
2208
+ * the caller can correlate with a multi-param batch (single-param sets
2209
+ * always blame this param).
2210
+ */
2211
+ var ParamCodecError = class extends Error {
2212
+ constructor(message) {
2213
+ super(message);
2214
+ this.name = "ParamCodecError";
2215
+ }
2216
+ };
2217
+ function packValueInto(desc, value, out, offset) {
2218
+ if (desc.arrayLen === 0) {
2219
+ packOneScalar(desc.type, value, out, offset, desc.path);
2220
+ return;
2221
+ }
2222
+ if (!Array.isArray(value)) throw new ParamCodecError(`param '${desc.path}': expected array of length ${desc.arrayLen}, got ${typeof value}`);
2223
+ if (value.length !== desc.arrayLen) throw new ParamCodecError(`param '${desc.path}': expected array length ${desc.arrayLen}, got ${value.length}`);
2224
+ const stride = scalarSize(desc.type);
2225
+ for (let i = 0; i < desc.arrayLen; i++) packOneScalar(desc.type, value[i], out, offset + i * stride, desc.path);
2226
+ }
2227
+ function packOneScalar(type, value, out, offset, pathForErr) {
2228
+ switch (type) {
2229
+ case 0:
2230
+ if (typeof value !== "boolean") throw new ParamCodecError(`param '${pathForErr}': expected boolean for bool, got ${typeof value}`);
2231
+ out[offset] = value ? 1 : 0;
2232
+ return;
2233
+ case 9:
2234
+ if (typeof value !== "number") throw new ParamCodecError(`param '${pathForErr}': expected number for f32, got ${typeof value}`);
2235
+ packScalarF32Le(value, out, offset);
2236
+ return;
2237
+ case 10:
2238
+ if (typeof value !== "number") throw new ParamCodecError(`param '${pathForErr}': expected number for f64, got ${typeof value}`);
2239
+ packScalarF64Le(value, out, offset);
2240
+ return;
2241
+ default:
2242
+ if (type === 7 || type === 8) {
2243
+ if (typeof value !== "bigint") throw new ParamCodecError(`param '${pathForErr}': expected bigint for ${scalarName(type)}, got ${typeof value}`);
2244
+ } else if (typeof value !== "number") throw new ParamCodecError(`param '${pathForErr}': expected number for ${scalarName(type)}, got ${typeof value}`);
2245
+ packScalarIntLe(type, value, out, offset);
2246
+ }
2247
+ }
2248
+ function unpackScalar(type, src, offset) {
2249
+ switch (type) {
2250
+ case 0: return (src[offset] ?? 0) !== 0;
2251
+ case 9: return unpackScalarF32Le(src, offset);
2252
+ case 10: return unpackScalarF64Le(src, offset);
2253
+ case 2:
2254
+ case 4:
2255
+ case 6: {
2256
+ const bits = unpackScalarIntLe(type, src, offset);
2257
+ return Number(signExtend(bits, scalarSize(type)));
2258
+ }
2259
+ case 1:
2260
+ case 3:
2261
+ case 5: return Number(unpackScalarIntLe(type, src, offset));
2262
+ case 8: return signExtend(unpackScalarIntLe(type, src, offset), 8);
2263
+ case 7: return unpackScalarIntLe(type, src, offset);
2264
+ }
2265
+ }
2266
+ function unpackValue(desc, src) {
2267
+ if (src.length < desc.wireSize) return null;
2268
+ if (desc.arrayLen === 0) return unpackScalar(desc.type, src, 0);
2269
+ const stride = scalarSize(desc.type);
2270
+ const out = [];
2271
+ for (let i = 0; i < desc.arrayLen; i++) out.push(unpackScalar(desc.type, src, i * stride));
2272
+ return out;
2273
+ }
2274
+ /** Build a PARAM_SET request payload for a single (descriptor, value) pair. */
2275
+ function encodeParamSetSingle(desc, value) {
2276
+ const out = new Uint8Array(3 + desc.wireSize);
2277
+ out[0] = 1;
2278
+ out[1] = desc.paramIndex & 255;
2279
+ out[2] = desc.paramIndex >>> 8 & 255;
2280
+ packValueInto(desc, value, out, 3);
2281
+ return out;
2282
+ }
2283
+ var TypedParam = class {
2284
+ path;
2285
+ descriptor;
2286
+ #client;
2287
+ constructor(client, descriptor) {
2288
+ this.#client = client;
2289
+ this.descriptor = descriptor;
2290
+ this.path = descriptor.path;
2291
+ }
2292
+ /**
2293
+ * Decode the schema-embedded default value (SPEC §9.1 `default`). Useful
2294
+ * to seed the master-side persistence store. Returns `null` if the
2295
+ * descriptor carries no default payload.
2296
+ */
2297
+ defaultValue() {
2298
+ if (this.descriptor.defaultValue.length === 0) return null;
2299
+ return unpackValue(this.descriptor, this.descriptor.defaultValue);
2300
+ }
2301
+ /** Send a PARAM_SET with a single entry for this param. */
2302
+ async set(value, opts = {}) {
2303
+ const request = encodeParamSetSingle(this.descriptor, value);
2304
+ const result = await this.#client.call(253, {
2305
+ request,
2306
+ timeoutMs: opts.timeoutMs,
2307
+ signal: opts.signal
2308
+ });
2309
+ let failedIndex = null;
2310
+ if (result.status !== 0 && result.data.length >= 2) failedIndex = (result.data[0] ?? 0) | (result.data[1] ?? 0) << 8;
2311
+ return {
2312
+ status: result.status,
2313
+ failedIndex
2314
+ };
2315
+ }
2316
+ };
2317
+ //#endregion
2318
+ //#region src/typed-service.ts
2319
+ var TypedService = class {
2320
+ path;
2321
+ descriptor;
2322
+ #client;
2323
+ constructor(client, descriptor) {
2324
+ this.#client = client;
2325
+ this.descriptor = descriptor;
2326
+ this.path = descriptor.path;
2327
+ }
2328
+ /** Encode `request`, dispatch via the service plane, decode the response. */
2329
+ async call(request, opts = {}) {
2330
+ const encoded = encodeFields(this.descriptor.requestFields, request);
2331
+ const result = await this.#client.call(this.descriptor.serviceIndex, {
2332
+ request: encoded,
2333
+ timeoutMs: opts.timeoutMs,
2334
+ signal: opts.signal
2335
+ });
2336
+ let data = null;
2337
+ if (this.descriptor.responseFields.length === 0) data = Object.freeze({});
2338
+ else if (result.data.length >= this.descriptor.responseWireSize) data = decodeFields(this.descriptor.responseFields, result.data);
2339
+ return {
2340
+ status: result.status,
2341
+ data
2342
+ };
2343
+ }
2344
+ };
2345
+ //#endregion
2346
+ //#region src/node-agent.ts
2347
+ const DEFAULT_OPTS = {
2348
+ ackTimeoutMs: 250,
2349
+ schemaChunkSize: 59,
2350
+ schemaReadRetries: 3,
2351
+ heartbeatTimeoutMs: 350
2352
+ };
2353
+ /**
2354
+ * Per-slave provisioning state machine. Mirrors
2355
+ * `fibril_can_bridge::NodeAgent` lifecycle entry points but trimmed for the
2356
+ * single-threaded TS master — no ROS entity creation, no PARAM_SET
2357
+ * re-injection (P5), no plan computation (P6).
2358
+ *
2359
+ * Lifecycle: `idle` → `awaiting-announce` → `schema-reading` (skipped if
2360
+ * blob already cached on this agent instance) → `numbering` →
2361
+ * `provisioning` → `running`. HEARTBEAT loss drops to `lost`; the next
2362
+ * ANNOUNCE restarts the flow.
2363
+ */
2364
+ var NodeAgent = class {
2365
+ nodeId;
2366
+ #transport;
2367
+ #framePlan;
2368
+ #ackTimeoutMs;
2369
+ #schemaChunkSize;
2370
+ #schemaReadRetries;
2371
+ #heartbeatTimeoutMs;
2372
+ #autoStart;
2373
+ #onState;
2374
+ #onError;
2375
+ #now;
2376
+ #seq = new SeqAllocator();
2377
+ #acks;
2378
+ #services;
2379
+ #unsubFrame = null;
2380
+ #sweepHandle = null;
2381
+ #stopped = false;
2382
+ #state = { kind: "idle" };
2383
+ #announce = null;
2384
+ #schemaRead = null;
2385
+ #provisioning = null;
2386
+ #pendingStart = null;
2387
+ #snapshot = null;
2388
+ #generation = 0;
2389
+ #blobCache = /* @__PURE__ */ new Map();
2390
+ #lastHeartbeatMs = null;
2391
+ constructor(opts) {
2392
+ if (opts.nodeId < 1 || opts.nodeId > 126) throw new RangeError(`nodeId must be 1..0x7E, got ${opts.nodeId}`);
2393
+ this.nodeId = opts.nodeId;
2394
+ this.#transport = opts.transport;
2395
+ this.#framePlan = opts.framePlan;
2396
+ this.#ackTimeoutMs = opts.ackTimeoutMs ?? DEFAULT_OPTS.ackTimeoutMs;
2397
+ this.#schemaChunkSize = opts.schemaChunkSize ?? DEFAULT_OPTS.schemaChunkSize;
2398
+ this.#schemaReadRetries = opts.schemaReadRetries ?? DEFAULT_OPTS.schemaReadRetries;
2399
+ this.#heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? DEFAULT_OPTS.heartbeatTimeoutMs;
2400
+ this.#autoStart = opts.autoStart ?? (() => true);
2401
+ this.#onState = opts.onState;
2402
+ this.#onError = opts.onError;
2403
+ this.#now = opts.now ?? (() => Date.now());
2404
+ this.#acks = new AckTracker(this.#now);
2405
+ this.#services = new ServiceClient({
2406
+ nodeId: this.nodeId,
2407
+ transport: this.#transport,
2408
+ defaultTimeoutMs: opts.serviceTimeoutMs ?? this.#ackTimeoutMs,
2409
+ now: this.#now
2410
+ });
2411
+ }
2412
+ /** Service-plane RPC client for this slave. Always available. */
2413
+ get services() {
2414
+ return this.#services;
2415
+ }
2416
+ get state() {
2417
+ return this.#state;
2418
+ }
2419
+ get snapshot() {
2420
+ return this.#snapshot;
2421
+ }
2422
+ /**
2423
+ * Attach to the transport and wait for ANNOUNCE. Resolves immediately —
2424
+ * progress flows via {@link state} / {@link snapshot} / `onState`. Call
2425
+ * `stop()` to detach.
2426
+ */
2427
+ async start() {
2428
+ if (this.#stopped) throw new Error("NodeAgent.start() after stop() is not supported — construct a new agent");
2429
+ if (this.#unsubFrame !== null) return;
2430
+ this.#unsubFrame = this.#transport.onFrame((f) => this.#onFrame(f));
2431
+ this.#services.attach();
2432
+ this.#sweepHandle = setInterval(() => this.#sweep(), 50);
2433
+ this.#setState({ kind: "awaiting-announce" });
2434
+ }
2435
+ /**
2436
+ * Re-run the FRAME_DEFINE / START handshake against the live slave using
2437
+ * the most recent ANNOUNCE + cached schema. The {@link FramePlanFactory}
2438
+ * is invoked again — callers that key off an external mutable state
2439
+ * (e.g. per-field TX-enable overrides) will see the latest values.
2440
+ *
2441
+ * The agent must have observed an ANNOUNCE already; `reprovision()`
2442
+ * before that throws. RUNNING is required so that the slave is known to
2443
+ * be reachable — calling during `lost` would race the heartbeat
2444
+ * watchdog. Pending service calls are failed and the current snapshot is
2445
+ * torn down before the new bring-up begins, mirroring what happens on a
2446
+ * boot_id change.
2447
+ */
2448
+ async reprovision() {
2449
+ if (this.#stopped) throw new Error("NodeAgent.reprovision() after stop()");
2450
+ const a = this.#announce;
2451
+ if (a === null) throw new Error("NodeAgent.reprovision() called before ANNOUNCE");
2452
+ const cached = this.#blobCache.get(a.schemaHash);
2453
+ if (!cached) throw new Error("NodeAgent.reprovision(): no cached schema for current ANNOUNCE");
2454
+ this.#reprovisionState();
2455
+ await this.#beginBringUp({
2456
+ schemaHash: a.schemaHash,
2457
+ bootId: a.bootId,
2458
+ blobLen: a.blobLen,
2459
+ protocolVersion: a.protocolVersion,
2460
+ instanceCounts: a.instanceCounts
2461
+ }, cached.schema, cached.blob);
2462
+ }
2463
+ /** Detach, send STOP if currently RUNNING, clear all in-flight state. */
2464
+ async stop() {
2465
+ if (this.#stopped) return;
2466
+ this.#stopped = true;
2467
+ if (this.#sweepHandle !== null) {
2468
+ clearInterval(this.#sweepHandle);
2469
+ this.#sweepHandle = null;
2470
+ }
2471
+ if (this.#unsubFrame !== null) {
2472
+ this.#unsubFrame();
2473
+ this.#unsubFrame = null;
2474
+ }
2475
+ this.#services.detach();
2476
+ this.#services.failAll("NodeAgent.stop()");
2477
+ if (this.#state.kind === "running" && this.#transport.isOpen) try {
2478
+ await this.#transport.send(encodeStop(this.nodeId, this.#seq.next()));
2479
+ } catch (e) {
2480
+ this.#error(e);
2481
+ }
2482
+ this.#acks.clear();
2483
+ this.#schemaRead = null;
2484
+ this.#provisioning = null;
2485
+ this.#pendingStart = null;
2486
+ this.#snapshot = null;
2487
+ this.#setState({ kind: "idle" });
2488
+ }
2489
+ #onFrame(f) {
2490
+ if (!f.extended) return;
2491
+ if (extNodeId(f.id) !== this.nodeId) return;
2492
+ if (extChannel(f.id) !== 0) return;
2493
+ const cmd = extIndex(f.id);
2494
+ const seq = extSeq(f.id);
2495
+ switch (cmd) {
2496
+ case 2:
2497
+ this.#handleAnnounce(f.data);
2498
+ break;
2499
+ case 5:
2500
+ this.#handleHeartbeat(f.data);
2501
+ break;
2502
+ case 4:
2503
+ this.#handleSchemaData(seq, f.data);
2504
+ break;
2505
+ case 126:
2506
+ this.#acks.onAck(seq);
2507
+ break;
2508
+ case 127: {
2509
+ const m = decodeNak(f.data);
2510
+ if (m) this.#acks.onNak(seq, m.reason);
2511
+ break;
2512
+ }
2513
+ default: break;
2514
+ }
2515
+ }
2516
+ #sweep() {
2517
+ const now = this.#now();
2518
+ this.#acks.sweep(now);
2519
+ this.#services.sweep(now);
2520
+ if (this.#lastHeartbeatMs !== null && this.#state.kind !== "lost") {
2521
+ if (now - this.#lastHeartbeatMs > this.#heartbeatTimeoutMs) this.#enterLost("heartbeat timeout");
2522
+ }
2523
+ if (this.#schemaRead && this.#schemaRead.inflightDeadlineMs <= now) this.#onSchemaReadStall();
2524
+ }
2525
+ #handleAnnounce(data) {
2526
+ const a = decodeAnnounce(data);
2527
+ if (!a) {
2528
+ this.#error(/* @__PURE__ */ new Error("malformed ANNOUNCE"));
2529
+ return;
2530
+ }
2531
+ const prev = this.#announce;
2532
+ const isReboot = prev !== null && prev.bootId !== a.bootId;
2533
+ const isHashChange = prev !== null && prev.schemaHash !== a.schemaHash;
2534
+ this.#announce = {
2535
+ schemaHash: a.schemaHash,
2536
+ bootId: a.bootId,
2537
+ blobLen: a.blobLen,
2538
+ protocolVersion: a.protocolVersion,
2539
+ instanceCounts: a.instanceCounts
2540
+ };
2541
+ if (this.#state.kind === "running" && !isReboot && !isHashChange) return;
2542
+ if (this.#state.kind === "provisioning" && !isReboot && !isHashChange) return;
2543
+ if (this.#state.kind === "awaiting-start" && !isReboot && !isHashChange) return;
2544
+ if (this.#state.kind === "schema-reading" && !isReboot && !isHashChange) return;
2545
+ this.#reprovisionState();
2546
+ const cached = this.#blobCache.get(a.schemaHash);
2547
+ if (cached) {
2548
+ this.#beginBringUp(a, cached.schema, cached.blob);
2549
+ return;
2550
+ }
2551
+ this.#startSchemaRead(a);
2552
+ }
2553
+ #handleHeartbeat(data) {
2554
+ const h = decodeHeartbeat(data);
2555
+ if (!h) {
2556
+ this.#error(/* @__PURE__ */ new Error("malformed HEARTBEAT"));
2557
+ return;
2558
+ }
2559
+ this.#lastHeartbeatMs = this.#now();
2560
+ if (this.#announce && this.#announce.bootId !== h.bootId) {
2561
+ this.#announce = null;
2562
+ this.#reprovisionState();
2563
+ this.#setState({ kind: "awaiting-announce" });
2564
+ return;
2565
+ }
2566
+ if (this.#state.kind === "lost") {
2567
+ if (this.#snapshot !== null) this.#setState({ kind: "running" });
2568
+ else if (this.#pendingStart !== null) this.#setState({ kind: "awaiting-start" });
2569
+ }
2570
+ }
2571
+ #startSchemaRead(a) {
2572
+ const progress = {
2573
+ expectedTotal: a.blobLen,
2574
+ acc: new SchemaAccumulator(a.blobLen),
2575
+ inflightSeq: 0,
2576
+ inflightLen: 0,
2577
+ retriesLeft: this.#schemaReadRetries,
2578
+ inflightDeadlineMs: 0
2579
+ };
2580
+ this.#schemaRead = progress;
2581
+ this.#setState({
2582
+ kind: "schema-reading",
2583
+ received: 0,
2584
+ total: a.blobLen
2585
+ });
2586
+ this.#issueSchemaReadChunk(progress, false);
2587
+ }
2588
+ #issueSchemaReadChunk(p, retry) {
2589
+ const remaining = Math.max(0, p.expectedTotal - p.acc.length);
2590
+ if (remaining === 0) return;
2591
+ const len = Math.min(remaining, this.#schemaChunkSize);
2592
+ const seq = retry ? p.inflightSeq : this.#seq.next();
2593
+ p.inflightSeq = seq;
2594
+ p.inflightLen = len;
2595
+ p.inflightDeadlineMs = this.#now() + this.#ackTimeoutMs;
2596
+ this.#transport.send(encodeSchemaRead(this.nodeId, p.acc.length, len, seq)).catch((e) => this.#error(e));
2597
+ }
2598
+ #onSchemaReadStall() {
2599
+ const p = this.#schemaRead;
2600
+ if (!p) return;
2601
+ if (p.retriesLeft <= 0) {
2602
+ this.#error(/* @__PURE__ */ new Error(`schema-read stalled (offset ${p.acc.length} of ${p.expectedTotal})`));
2603
+ this.#schemaRead = null;
2604
+ this.#setState({ kind: "awaiting-announce" });
2605
+ return;
2606
+ }
2607
+ p.retriesLeft -= 1;
2608
+ this.#issueSchemaReadChunk(p, true);
2609
+ }
2610
+ #handleSchemaData(seq, data) {
2611
+ const p = this.#schemaRead;
2612
+ if (!p) return;
2613
+ const m = decodeSchemaData(data);
2614
+ if (!m) {
2615
+ this.#error(/* @__PURE__ */ new Error("malformed SCHEMA_DATA"));
2616
+ return;
2617
+ }
2618
+ if (m.offset !== p.acc.length) return;
2619
+ if (p.acc.append(m, p.inflightLen) === 0) return;
2620
+ p.retriesLeft = this.#schemaReadRetries;
2621
+ if (!p.acc.complete) {
2622
+ this.#setState({
2623
+ kind: "schema-reading",
2624
+ received: p.acc.length,
2625
+ total: p.expectedTotal
2626
+ });
2627
+ this.#issueSchemaReadChunk(p, false);
2628
+ return;
2629
+ }
2630
+ const blob = p.acc.bytes();
2631
+ this.#schemaRead = null;
2632
+ this.#finishSchemaRead(blob);
2633
+ }
2634
+ async #finishSchemaRead(blob) {
2635
+ const a = this.#announce;
2636
+ if (!a) return;
2637
+ let actualHash;
2638
+ try {
2639
+ actualHash = await schemaHashPrefix(blob);
2640
+ } catch (e) {
2641
+ this.#error(e);
2642
+ this.#setState({ kind: "awaiting-announce" });
2643
+ return;
2644
+ }
2645
+ if (actualHash !== a.schemaHash) {
2646
+ this.#error(/* @__PURE__ */ new Error(`schema hash mismatch: announced ${a.schemaHash.toString(16)}, actual ${actualHash.toString(16)}`));
2647
+ this.#setState({ kind: "awaiting-announce" });
2648
+ return;
2649
+ }
2650
+ let schema;
2651
+ try {
2652
+ schema = decodeSchema(blob);
2653
+ } catch (e) {
2654
+ this.#error(e);
2655
+ this.#setState({ kind: "awaiting-announce" });
2656
+ return;
2657
+ }
2658
+ this.#blobCache.set(a.schemaHash, {
2659
+ blob,
2660
+ schema
2661
+ });
2662
+ await this.#beginBringUp(a, schema, blob);
2663
+ }
2664
+ async #beginBringUp(a, schema, blob) {
2665
+ this.#setState({ kind: "numbering" });
2666
+ const numbering = computeNumbering(schema, Array.from(a.instanceCounts));
2667
+ if (numbering.status !== "ok") {
2668
+ this.#error(/* @__PURE__ */ new Error(`numbering failed: ${numbering.status}`));
2669
+ this.#setState({
2670
+ kind: "blocked",
2671
+ reason: `numbering: ${numbering.status}`
2672
+ });
2673
+ return;
2674
+ }
2675
+ const plan = typeof this.#framePlan === "function" ? this.#framePlan(schema, numbering, { nodeId: this.nodeId }) : this.#framePlan;
2676
+ try {
2677
+ await this.#transport.send(encodeDiscoverTo(this.nodeId));
2678
+ } catch (e) {
2679
+ this.#error(e);
2680
+ this.#setState({ kind: "awaiting-announce" });
2681
+ return;
2682
+ }
2683
+ const gen = ++this.#generation;
2684
+ const stdIdToTopicIndex = /* @__PURE__ */ new Map();
2685
+ for (const fp of plan) for (const entry of fp.entries) if (!stdIdToTopicIndex.has(fp.stdId)) stdIdToTopicIndex.set(fp.stdId, entry.topicIndex);
2686
+ const progress = {
2687
+ generation: gen,
2688
+ pendingDefines: plan.length,
2689
+ anyFailure: false,
2690
+ firstNakReason: 0,
2691
+ plan,
2692
+ stdIdToTopicIndex
2693
+ };
2694
+ this.#provisioning = progress;
2695
+ if (plan.length === 0) {
2696
+ this.#provisioning.pendingDefines = 0;
2697
+ this.#setState({
2698
+ kind: "provisioning",
2699
+ pendingDefines: 0
2700
+ });
2701
+ this.#maybeIssueStart(a, schema, blob, numbering, gen, progress);
2702
+ return;
2703
+ }
2704
+ this.#setState({
2705
+ kind: "provisioning",
2706
+ pendingDefines: plan.length
2707
+ });
2708
+ const deadline = this.#now() + this.#ackTimeoutMs;
2709
+ for (const fp of plan) {
2710
+ const seq = this.#seq.next();
2711
+ this.#acks.track(seq, deadline, (outcome) => this.#onFrameDefineOutcome(a, schema, blob, numbering, gen, outcome));
2712
+ const wireDir = fp.dir === 1 ? 0 : 1;
2713
+ this.#transport.send(encodeFrameDefine(this.nodeId, seq, fp.stdId, fp.periodUs, wireDir, fp.entries)).catch((e) => this.#error(e));
2714
+ }
2715
+ }
2716
+ #onFrameDefineOutcome(a, schema, blob, numbering, gen, outcome) {
2717
+ const p = this.#provisioning;
2718
+ if (!p || p.generation !== gen) return;
2719
+ if (outcome.kind !== "acked") {
2720
+ if (!p.anyFailure) {
2721
+ p.anyFailure = true;
2722
+ p.firstNakReason = outcome.kind === "naked" ? outcome.reason : 0;
2723
+ }
2724
+ }
2725
+ if (p.pendingDefines === 0) return;
2726
+ p.pendingDefines -= 1;
2727
+ this.#setState({
2728
+ kind: "provisioning",
2729
+ pendingDefines: p.pendingDefines
2730
+ });
2731
+ if (p.pendingDefines > 0) return;
2732
+ if (p.anyFailure) {
2733
+ this.#error(/* @__PURE__ */ new Error(`FRAME_DEFINE failed (first NAK reason ${p.firstNakReason})`));
2734
+ this.#provisioning = null;
2735
+ this.#setState({ kind: "awaiting-announce" });
2736
+ return;
2737
+ }
2738
+ this.#maybeIssueStart(a, schema, blob, numbering, gen, p);
2739
+ }
2740
+ /**
2741
+ * FRAME_DEFINE has succeeded (or been skipped). Consult the autoStart
2742
+ * gate and either fire START immediately or park in `awaiting-start`,
2743
+ * remembering enough context that `startNow()` can resume without
2744
+ * re-running FRAME_DEFINE.
2745
+ */
2746
+ #maybeIssueStart(a, schema, blob, numbering, gen, progress) {
2747
+ if (this.#autoStart()) {
2748
+ this.#issueStart(a, schema, blob, numbering, gen);
2749
+ return;
2750
+ }
2751
+ this.#pendingStart = {
2752
+ a,
2753
+ schema,
2754
+ blob,
2755
+ numbering,
2756
+ generation: gen,
2757
+ progress
2758
+ };
2759
+ this.#setState({ kind: "awaiting-start" });
2760
+ }
2761
+ /**
2762
+ * Commit the deferred START when {@link NodeAgentOptions.autoStart}
2763
+ * gated the previous bring-up. Only legal in `awaiting-start`; any
2764
+ * other state throws so callers spot logic bugs instead of racing
2765
+ * with a fresh ANNOUNCE.
2766
+ */
2767
+ async startNow() {
2768
+ if (this.#stopped) throw new Error("NodeAgent.startNow() after stop()");
2769
+ if (this.#state.kind !== "awaiting-start" || this.#pendingStart === null) throw new Error(`NodeAgent.startNow() called in state '${this.#state.kind}'`);
2770
+ const p = this.#pendingStart;
2771
+ this.#pendingStart = null;
2772
+ this.#setState({
2773
+ kind: "provisioning",
2774
+ pendingDefines: 0
2775
+ });
2776
+ this.#issueStart(p.a, p.schema, p.blob, p.numbering, p.generation);
2777
+ }
2778
+ #issueStart(a, schema, blob, numbering, gen) {
2779
+ const seq = this.#seq.next();
2780
+ const deadline = this.#now() + this.#ackTimeoutMs;
2781
+ this.#acks.track(seq, deadline, (outcome) => this.#onStartOutcome(a, schema, blob, numbering, gen, outcome));
2782
+ this.#transport.send(encodeStart(this.nodeId, seq)).catch((e) => this.#error(e));
2783
+ }
2784
+ #onStartOutcome(a, schema, blob, numbering, gen, outcome) {
2785
+ const p = this.#provisioning;
2786
+ if (!p || p.generation !== gen) return;
2787
+ if (outcome.kind !== "acked") {
2788
+ this.#error(/* @__PURE__ */ new Error(`START ${outcome.kind === "naked" ? `NAKed reason=${outcome.reason}` : "timed out"}`));
2789
+ this.#provisioning = null;
2790
+ this.#setState({ kind: "awaiting-announce" });
2791
+ return;
2792
+ }
2793
+ const index = new SchemaIndex(schema, numbering, Array.from(a.instanceCounts));
2794
+ const client = this.#services;
2795
+ this.#snapshot = {
2796
+ nodeId: this.nodeId,
2797
+ schemaHash: a.schemaHash,
2798
+ bootId: a.bootId,
2799
+ instanceCounts: a.instanceCounts,
2800
+ blob,
2801
+ schema,
2802
+ numbering,
2803
+ framePlan: p.plan,
2804
+ stdIdToTopicIndex: p.stdIdToTopicIndex,
2805
+ services: client,
2806
+ index,
2807
+ service(path) {
2808
+ const desc = index.service(path);
2809
+ if (!desc) throw new Error(`unknown service path '${path}'`);
2810
+ return new TypedService(client, desc);
2811
+ },
2812
+ param(path) {
2813
+ const desc = index.param(path);
2814
+ if (!desc) throw new Error(`unknown param path '${path}'`);
2815
+ return new TypedParam(client, desc);
2816
+ }
2817
+ };
2818
+ this.#provisioning = null;
2819
+ this.#setState({ kind: "running" });
2820
+ }
2821
+ #reprovisionState() {
2822
+ this.#acks.clear();
2823
+ this.#services.failAll("NodeAgent reprovision");
2824
+ this.#schemaRead = null;
2825
+ this.#provisioning = null;
2826
+ this.#pendingStart = null;
2827
+ this.#snapshot = null;
2828
+ }
2829
+ #enterLost(reason) {
2830
+ if (this.#state.kind === "lost") return;
2831
+ this.#setState({
2832
+ kind: "lost",
2833
+ reason
2834
+ });
2835
+ }
2836
+ #setState(s) {
2837
+ this.#state = s;
2838
+ this.#onState?.(s);
2839
+ }
2840
+ #error(e) {
2841
+ const err = e instanceof Error ? e : new Error(String(e));
2842
+ this.#onError?.(err);
2843
+ }
2844
+ };
2845
+ //#endregion
2846
+ //#region src/master-controller.ts
2847
+ function readU64Le(p, off) {
2848
+ let r = 0n;
2849
+ for (let i = 0; i < 8; i++) r |= BigInt(p[off + i] ?? 0) << BigInt(i * 8);
2850
+ return r;
2851
+ }
2852
+ var MasterController = class {
2853
+ #transport;
2854
+ #framePlan;
2855
+ #heartbeatBroadcaster;
2856
+ #dataPlane;
2857
+ #discoverPeriodMs;
2858
+ #initialDiscover;
2859
+ #ackTimeoutMs;
2860
+ #heartbeatTimeoutMs;
2861
+ #serviceTimeoutMs;
2862
+ #autoStart;
2863
+ #now;
2864
+ #onError;
2865
+ #slaves = /* @__PURE__ */ new Map();
2866
+ #listeners = /* @__PURE__ */ new Set();
2867
+ #unsubFrame = null;
2868
+ #discoverHandle = null;
2869
+ #started = false;
2870
+ constructor(opts) {
2871
+ this.#transport = opts.transport;
2872
+ this.#framePlan = opts.framePlan;
2873
+ this.#heartbeatBroadcaster = new MasterHeartbeatBroadcaster(opts.transport, opts.heartbeatPeriodMs ?? 100);
2874
+ this.#dataPlane = new DataPlaneReceiver({
2875
+ transport: opts.transport,
2876
+ ...opts.onError !== void 0 ? { onError: opts.onError } : {},
2877
+ ...opts.now !== void 0 ? { now: opts.now } : {}
2878
+ });
2879
+ this.#discoverPeriodMs = opts.discoverPeriodMs ?? 0;
2880
+ this.#initialDiscover = opts.initialDiscover ?? true;
2881
+ this.#ackTimeoutMs = opts.ackTimeoutMs ?? 250;
2882
+ this.#heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? 350;
2883
+ this.#serviceTimeoutMs = opts.serviceTimeoutMs;
2884
+ this.#autoStart = opts.autoStart ?? (() => true);
2885
+ this.#now = opts.now ?? (() => Date.now());
2886
+ this.#onError = opts.onError;
2887
+ }
2888
+ /**
2889
+ * Master-side decoder for S→M topic frames. Auto-attached / detached as
2890
+ * each slave transitions in and out of `running`. Subscribe with
2891
+ * `dataPlane.onTopic(nodeId, path, listener)`; read latest values with
2892
+ * `dataPlane.latest(nodeId, path)`.
2893
+ */
2894
+ get dataPlane() {
2895
+ return this.#dataPlane;
2896
+ }
2897
+ /**
2898
+ * Attach to the transport, start MASTER_HEARTBEAT, fire one initial
2899
+ * broadcast DISCOVER to flush stale slave state from a previous master
2900
+ * session. New slaves auto-spawn a NodeAgent on first sight.
2901
+ */
2902
+ start() {
2903
+ if (this.#started) return;
2904
+ this.#started = true;
2905
+ this.#heartbeatBroadcaster.start();
2906
+ this.#unsubFrame = this.#transport.onFrame((f) => this.#onFrame(f));
2907
+ if (this.#initialDiscover) this.#transport.send(encodeDiscover()).catch((e) => this.#error(e));
2908
+ if (this.#discoverPeriodMs > 0) this.#discoverHandle = setInterval(() => {
2909
+ this.#transport.send(encodeDiscover()).catch((e) => this.#error(e));
2910
+ }, this.#discoverPeriodMs);
2911
+ }
2912
+ /** Stop all internals, stop every NodeAgent, detach from the transport. */
2913
+ async stop() {
2914
+ if (!this.#started) return;
2915
+ this.#started = false;
2916
+ if (this.#discoverHandle !== null) {
2917
+ clearInterval(this.#discoverHandle);
2918
+ this.#discoverHandle = null;
2919
+ }
2920
+ this.#heartbeatBroadcaster.stop();
2921
+ if (this.#unsubFrame) {
2922
+ this.#unsubFrame();
2923
+ this.#unsubFrame = null;
2924
+ }
2925
+ const stops = [...this.#slaves.values()].map((r) => r.agent.stop().catch(() => {}));
2926
+ this.#slaves.clear();
2927
+ this.#dataPlane.dispose();
2928
+ await Promise.all(stops);
2929
+ this.#emit();
2930
+ }
2931
+ /** Snapshot of all known slaves. Stable identity per call. */
2932
+ snapshots() {
2933
+ return [...this.#slaves.values()].map((r) => ({
2934
+ nodeId: r.agent.nodeId,
2935
+ state: r.state,
2936
+ running: r.agent.snapshot,
2937
+ lastHeartbeatMs: r.lastHeartbeatMs,
2938
+ bootId: r.bootId,
2939
+ schemaHash: r.schemaHash
2940
+ })).sort((a, b) => a.nodeId - b.nodeId);
2941
+ }
2942
+ /** Subscribe to slave-set / state-change updates. Fires on every change. */
2943
+ onChange(cb) {
2944
+ this.#listeners.add(cb);
2945
+ cb(this.snapshots());
2946
+ return () => {
2947
+ this.#listeners.delete(cb);
2948
+ };
2949
+ }
2950
+ /**
2951
+ * Access the NodeAgent for one slave, if it exists. Returns null until
2952
+ * the slave has been observed. Useful for service / param calls from
2953
+ * code that already knows the nodeId.
2954
+ */
2955
+ agent(nodeId) {
2956
+ return this.#slaves.get(nodeId)?.agent ?? null;
2957
+ }
2958
+ /** Issue an ad-hoc broadcast DISCOVER (between periodic firings). */
2959
+ async discoverNow() {
2960
+ await this.#transport.send(encodeDiscover());
2961
+ }
2962
+ /**
2963
+ * Commit the deferred START on one slave that is parked in
2964
+ * `awaiting-start` because the autoStart gate blocked its bring-up.
2965
+ * Returns `false` if the slave is unknown or not currently waiting
2966
+ * for a manual start; otherwise resolves once START has been queued
2967
+ * on the wire (the RUNNING transition arrives later via `onChange`).
2968
+ */
2969
+ async startNode(nodeId) {
2970
+ const rec = this.#slaves.get(nodeId);
2971
+ if (!rec) return false;
2972
+ if (rec.state.kind !== "awaiting-start") return false;
2973
+ try {
2974
+ await rec.agent.startNow();
2975
+ return true;
2976
+ } catch (e) {
2977
+ this.#error(e);
2978
+ return false;
2979
+ }
2980
+ }
2981
+ /**
2982
+ * Re-run the FRAME_DEFINE / START handshake for one slave so the
2983
+ * {@link FramePlanFactory} is consulted again. Use when external state
2984
+ * the factory closes over has changed (e.g. per-field TX-enable
2985
+ * toggles). Returns `false` when the slave is unknown or has never
2986
+ * ANNOUNCEd; otherwise resolves once the new bring-up has been kicked
2987
+ * off (it does not await the next RUNNING transition — listen via
2988
+ * {@link onChange} instead).
2989
+ */
2990
+ async reprovision(nodeId) {
2991
+ const rec = this.#slaves.get(nodeId);
2992
+ if (!rec) return false;
2993
+ try {
2994
+ await rec.agent.reprovision();
2995
+ return true;
2996
+ } catch (e) {
2997
+ this.#error(e);
2998
+ return false;
2999
+ }
3000
+ }
3001
+ #onFrame(f) {
3002
+ if (!f.extended) return;
3003
+ if (extChannel(f.id) !== 0) return;
3004
+ const nodeId = extNodeId(f.id);
3005
+ if (nodeId === 0 || nodeId === 127) return;
3006
+ const cmd = extIndex(f.id);
3007
+ if (cmd === 2) {
3008
+ const rec = this.#ensureSlave(nodeId);
3009
+ if (f.data.length >= 14) {
3010
+ rec.schemaHash = readU64Le(f.data, 0);
3011
+ rec.bootId = f.data[9] ?? rec.bootId ?? null;
3012
+ }
3013
+ this.#emit();
3014
+ return;
3015
+ }
3016
+ if (cmd === 5) {
3017
+ const rec = this.#ensureSlave(nodeId);
3018
+ rec.lastHeartbeatMs = this.#now();
3019
+ if (f.data.length >= 1) rec.bootId = f.data[0] ?? rec.bootId ?? null;
3020
+ this.#emit();
3021
+ }
3022
+ }
3023
+ #ensureSlave(nodeId) {
3024
+ let rec = this.#slaves.get(nodeId);
3025
+ if (rec) return rec;
3026
+ const agent = new NodeAgent({
3027
+ nodeId,
3028
+ transport: this.#transport,
3029
+ framePlan: this.#framePlan,
3030
+ ackTimeoutMs: this.#ackTimeoutMs,
3031
+ heartbeatTimeoutMs: this.#heartbeatTimeoutMs,
3032
+ ...this.#serviceTimeoutMs !== void 0 ? { serviceTimeoutMs: this.#serviceTimeoutMs } : {},
3033
+ autoStart: this.#autoStart,
3034
+ now: this.#now,
3035
+ onState: (s) => {
3036
+ const r = this.#slaves.get(nodeId);
3037
+ if (!r) return;
3038
+ r.state = s;
3039
+ if (s.kind === "running" && r.agent.snapshot !== null) try {
3040
+ this.#dataPlane.attachSlave(nodeId, r.agent.snapshot.framePlan, r.agent.snapshot.index);
3041
+ } catch (e) {
3042
+ this.#error(e);
3043
+ }
3044
+ else this.#dataPlane.detachSlave(nodeId);
3045
+ this.#emit();
3046
+ },
3047
+ onError: (e) => this.#error(e)
3048
+ });
3049
+ rec = {
3050
+ agent,
3051
+ state: { kind: "idle" },
3052
+ lastHeartbeatMs: null,
3053
+ bootId: null,
3054
+ schemaHash: null
3055
+ };
3056
+ this.#slaves.set(nodeId, rec);
3057
+ agent.start();
3058
+ this.#emit();
3059
+ return rec;
3060
+ }
3061
+ #emit() {
3062
+ const snap = this.snapshots();
3063
+ for (const cb of this.#listeners) try {
3064
+ cb(snap);
3065
+ } catch (e) {
3066
+ this.#error(e);
3067
+ }
3068
+ }
3069
+ #error(e) {
3070
+ if (!this.#onError) return;
3071
+ this.#onError(e instanceof Error ? e : new Error(String(e)));
3072
+ }
3073
+ };
3074
+ //#endregion
3075
+ export { AckTracker, CHAN_CONFIG, CHAN_SVC_REQUEST, CHAN_SVC_RESPONSE, CMD_ACK, CMD_ANNOUNCE, CMD_DISCOVER, CMD_FRAME_DEFINE, CMD_HB_PERIOD, CMD_HEARTBEAT, CMD_MASTER_HEARTBEAT, CMD_NAK, CMD_SCHEMA_DATA, CMD_SCHEMA_READ, CMD_START, CMD_STOP, DIR_RX_M2S, DIR_TX_S2M, DataPlaneReceiver, Direction, DynamicCodecError, FAULT_ID_CONFLICT, FAULT_INDEX_OVERFLOW, FAULT_NOMEM, FAULT_NONE, FD_DLC_STEPS, MAX_FRAME_PAYLOAD, MasterController, MasterHeartbeatBroadcaster, NAK_BAD_LENGTH, NAK_BAD_PARAM, NAK_BAD_STATE, NAK_DIR_MISMATCH, NAK_ID_CONFLICT, NAK_INDEX_RANGE, NAK_NO_RESOURCE, NAK_SIZE_OVERFLOW, NAK_UNKNOWN_CMD, NODE_ID_BROADCAST, NoCallSlotError, NodeAgent, NumberingError, PROTOCOL_VERSION, ParamCodecError, ProtoError, ProtoReader, SCALAR_NAMES, SEG_FIRST_DATA, SEG_MAX_SEQ, SEG_MAX_TOTAL, SEG_REST_DATA, SERVICE_INDEX_CANCEL, SERVICE_INDEX_CAPACITY, SERVICE_INDEX_MAX, SERVICE_INDEX_PARAM_SET, STATE_FAULT, STATE_PROVISIONED, STATE_RUNNING, STATE_UNPROVISIONED, STD_ID_MASK, SVC_ACCEPTED, SVC_APP_ERROR, SVC_BAD_INDEX, SVC_BAD_LENGTH, SVC_BAD_VALUE, SVC_BUSY, SVC_CANCEL, SVC_MAX_FIRST_CLASS, SVC_OK, SVC_PARAM_SET, SVC_UNAVAIL, ScalarType, SchemaAccumulator, SchemaIndex, SegmentReassembler, SeqAllocator, ServiceCallError, ServiceCancelledError, ServiceClient, ServiceTimeoutError, ServiceUnavailableError, StdIdAllocator, TypedParam, TypedService, buildDefaultS2MRequests, computeNumbering, decodeAnnounce, decodeFields, decodeHeartbeat, decodeNak, decodeSchema, decodeSchemaData, defaultS2MFramePlan, encodeDiscover, encodeDiscoverTo, encodeFields, encodeFrameDefine, encodeMasterHeartbeat, encodeParamSetSingle, encodeSchemaRead, encodeStart, encodeStop, extChannel, extIndex, extNodeId, extSeg, extSeq, fieldWireSize, fieldsWireSize, makeExtId, makeSvcRequestFrames, makeSvcSegmentedFrames, packScalarF32Le, packScalarF64Le, packScalarIntLe, padToDlc, planFrames, quantizeDlc, scalarFromName, scalarName, scalarSize, schemaHashPrefix, segExpectedMask, segOffset, sha256, signExtend, unpackScalarF32Le, unpackScalarF64Le, unpackScalarIntLe };
3076
+
3077
+ //# sourceMappingURL=index.js.map