@fibril/can-core 0.1.2 → 0.3.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 CHANGED
@@ -160,11 +160,7 @@ function extSeg(id) {
160
160
  }
161
161
  //#endregion
162
162
  //#region src/dlc.ts
163
- /**
164
- * CAN FD valid payload sizes in ascending order (§4.3).
165
- * Mirrors `fibril_can_core/include/fibril_can_core/packing.hpp::kCanFdDlcSteps`
166
- * and `fibril_can_runtime/src/fcan_wire.c::fcan_quantize_dlc`.
167
- */
163
+ /** Valid CAN FD payload sizes, ascending. */
168
164
  const FD_DLC_STEPS = Object.freeze([
169
165
  0,
170
166
  1,
@@ -183,21 +179,15 @@ const FD_DLC_STEPS = Object.freeze([
183
179
  48,
184
180
  64
185
181
  ]);
186
- /**
187
- * Round `bytes` up to the next valid CAN FD payload step. Returns 64 for any
188
- * input ≥ 64.
189
- */
182
+ /** Round `bytes` up to the next valid CAN FD payload step (caps at 64). */
190
183
  function quantizeDlc(bytes) {
191
184
  if (bytes >= 64) return 64;
192
185
  for (const step of FD_DLC_STEPS) if (step >= bytes) return step;
193
186
  return 64;
194
187
  }
195
188
  /**
196
- * Grow `data` to the next DLC step by appending zero bytes. Returns a new
197
- * Uint8Array. Never shrinks: if `data` already exceeds 64 bytes (which is
198
- * a self-inconsistent frame the slave would misparse), the original is
199
- * returned unchanged so the backend rejects it loudly (parity with
200
- * `pad_to_dlc` in `fibril_can_bridge/src/config_plane_codec.cpp`).
189
+ * Pad `data` to the next DLC step with zeros. Over-length input (>64 B, which
190
+ * the slave would misparse) is returned unchanged so the backend rejects it.
201
191
  */
202
192
  function padToDlc(data) {
203
193
  if (data.length >= 64) return data;
@@ -300,13 +290,9 @@ function encodeStop(nodeId, seq) {
300
290
  return configFrame(nodeId, 33, seq, /* @__PURE__ */ new Uint8Array(0));
301
291
  }
302
292
  /**
303
- * Accumulator for SCHEMA_DATA chunks. The slave DLC-pads each response (§4.3)
304
- * so `chunk.bytes.length` is typically larger than what the master requested;
305
- * the accumulator clips against `expectedChunkLen` and the remaining blob
306
- * length to drop those padding bytes (parity with
307
- * `append_schema_chunk` in `fibril_can_bridge/src/config_plane_codec.cpp`).
308
- *
309
- * Out-of-order chunks are rejected (returns 0 from {@link append}).
293
+ * SCHEMA_DATA reassembly. Clips each chunk against `expectedChunkLen` and
294
+ * the remaining blob length so DLC padding is discarded. Out-of-order
295
+ * chunks are rejected ({@link append} returns 0).
310
296
  */
311
297
  var SchemaAccumulator = class {
312
298
  #buf = [];
@@ -342,12 +328,8 @@ var SchemaAccumulator = class {
342
328
  //#endregion
343
329
  //#region src/scalar-codec.ts
344
330
  /**
345
- * Scalar type enumeration and little-endian wire codecs. Mirrors
346
- * `fibril_can_core/include/fibril_can_core/scalar_type.hpp` and
347
- * `fibril_can_core/src/scalar_type.cpp`.
348
- *
349
- * Type ordinal values are part of the protobuf schema blob's enum, so they
350
- * must not be renumbered.
331
+ * Scalar type enum and little-endian wire codecs. The ordinal values are
332
+ * part of the protobuf schema blob and must not be renumbered.
351
333
  */
352
334
  let ScalarType = /* @__PURE__ */ function(ScalarType) {
353
335
  ScalarType[ScalarType["Bool"] = 0] = "Bool";
@@ -400,14 +382,9 @@ function scalarFromName(name) {
400
382
  return idx >= 0 ? idx : void 0;
401
383
  }
402
384
  /**
403
- * Pack an integer / bool value as little-endian bytes into `out` at `offset`.
404
- * Writes scalarSize(type) bytes — pass a wide-enough `out`.
405
- *
406
- * Pass `bigint` for 64-bit types (U64/I64). `number` is accepted for
407
- * everything (it is internally widened to bigint so signed callers get the
408
- * same sign-extension semantics as the C++ `pack_scalar_int_le(uint64_t)`).
409
- * Signed callers pass the negative number directly; the function performs the
410
- * 64-bit two's-complement cast.
385
+ * Pack an int/bool as little-endian into `out[offset..offset+scalarSize(type))`.
386
+ * `number` is widened to bigint so signed negatives sign-extend to 64-bit
387
+ * two's complement before truncation, matching C++ `pack_scalar_int_le`.
411
388
  */
412
389
  function packScalarIntLe(type, value, out, offset = 0) {
413
390
  const bytes = scalarSize(type);
@@ -463,7 +440,7 @@ var ProtoReader = class ProtoReader {
463
440
  get position() {
464
441
  return this.pos;
465
442
  }
466
- /** Read a base-128 varint as a JS number (overflows ≥ 2^53 wrap silently). */
443
+ /** Read a base-128 varint as a JS number (schema uses uint32 only). */
467
444
  readVarint() {
468
445
  let result = 0;
469
446
  let shift = 0;
@@ -501,11 +478,7 @@ var ProtoReader = class ProtoReader {
501
478
  readMessage() {
502
479
  return new ProtoReader(this.readBytes());
503
480
  }
504
- /**
505
- * Read the next tag and return both the field number and wire type. The
506
- * caller is expected to dispatch on field number; unknown fields can be
507
- * passed to {@link skipValue}.
508
- */
481
+ /** Read a tag as `{field, wire}`. Unknown fields dispatch to {@link skipValue}. */
509
482
  readTag() {
510
483
  const tag = this.readVarint();
511
484
  return {
@@ -543,23 +516,13 @@ var ProtoError = class extends Error {
543
516
  //#endregion
544
517
  //#region src/schema.ts
545
518
  /**
546
- * NodeSchema IR + protobuf decoder for the schema blob (§9).
547
- *
548
- * Mirrors `fibril_can_core/include/fibril_can_core/ir.hpp` and
549
- * `fibril_can_core/src/schema_blob.cpp::parse_schema`. Field IDs come from
550
- * `fibril_can_core/proto/fibril_can.proto`.
551
- *
552
- * Master-side notes:
553
- * - The proto carries `topic_id` / `service_id` / `param_id` / BlockType
554
- * `type_id` for codegen stability; the master derives equivalent indices
555
- * from {@link computeNumbering} instead, so these are accepted but
556
- * discarded on parse.
557
- * - The proto wire field is `tx_disabled` while the IR holds `tx_enabled`
558
- * (inverted, matches the C++ side).
519
+ * NodeSchema IR + protobuf decoder for the schema blob. Master-side notes:
520
+ * - Proto `topic_id` / `service_id` / `param_id` / BlockType `type_id`
521
+ * are accepted but discarded — the master derives indices from
522
+ * {@link computeNumbering}.
523
+ * - Proto wire `tx_disabled` inverts to IR `tx_enabled`.
559
524
  * - `BlockArray.type_id` (proto) → `BlockArray.blockTypeIndex` (IR).
560
- *
561
- * The reader skips unknown fields silently so future schema additions do
562
- * not break old masters.
525
+ * Unknown fields are skipped so future schema additions don't break masters.
563
526
  */
564
527
  const FIELD = {
565
528
  Field: {
@@ -896,20 +859,8 @@ function fieldWireSize(field) {
896
859
  //#endregion
897
860
  //#region src/dynamic-codec.ts
898
861
  /**
899
- * Field-driven dict ⇄ Uint8Array codec. The runtime equivalent of what
900
- * codegen would otherwise emit per message — encode/decode is a pure
901
- * function of the field list ({@link SchemaField}[]) and the JS dict.
902
- *
903
- * Conventions (matches CLAUDE.md "値の表現規約"):
904
- * - dict keys are the schema field name verbatim
905
- * - scalar ↔ JS `number`, except U64/I64 ↔ `bigint`
906
- * - bool ↔ JS `boolean`
907
- * - fixed-length array (`arrayLen > 0`) ↔ regular JS array
908
- * (`number[]` / `bigint[]` / `boolean[]`) of exactly `arrayLen` elements
909
- *
910
- * Throws on missing / wrong-shape inputs. Decode tolerates extra trailing
911
- * bytes (mirrors C++ side: the master ignores trailing slop so that future
912
- * schema additions don't break older decoders).
862
+ * Field-driven dict ⇄ Uint8Array codec. Throws on shape mismatch; decode
863
+ * tolerates trailing bytes so future schema additions don't break decoders.
913
864
  */
914
865
  var DynamicCodecError = class extends Error {
915
866
  constructor(message) {
@@ -969,11 +920,7 @@ function fieldsWireSize(fields) {
969
920
  for (const f of fields) n += fieldWireSize(f);
970
921
  return n;
971
922
  }
972
- /**
973
- * Encode a dict into the canonical little-endian wire bytes for `fields`.
974
- * Returns a fresh `Uint8Array`. Throws {@link DynamicCodecError} on a
975
- * missing or wrong-shape field.
976
- */
923
+ /** Encode a dict into little-endian wire bytes. Throws on missing / wrong-shape fields. */
977
924
  function encodeFields(fields, value) {
978
925
  const totalSize = fieldsWireSize(fields);
979
926
  const out = new Uint8Array(totalSize);
@@ -1000,11 +947,7 @@ function encodeFields(fields, value) {
1000
947
  }
1001
948
  return out;
1002
949
  }
1003
- /**
1004
- * Decode wire bytes into a dict keyed by field name. Extra trailing bytes
1005
- * are ignored. Throws {@link DynamicCodecError} only if the payload is
1006
- * shorter than the field list demands.
1007
- */
950
+ /** Decode wire bytes into a dict. Trailing bytes are ignored; throws only if too short. */
1008
951
  function decodeFields(fields, bytes) {
1009
952
  const expected = fieldsWireSize(fields);
1010
953
  if (bytes.length < expected) throw new DynamicCodecError(`decodeFields: payload too short — need ${expected} bytes, got ${bytes.length}`);
@@ -1097,10 +1040,7 @@ var DataPlaneReceiver = class {
1097
1040
  this.#now = opts.now ?? (() => Date.now());
1098
1041
  this.#unsubFrame = opts.transport.onFrame((f) => this.#onFrame(f));
1099
1042
  }
1100
- /**
1101
- * Claim a slave's S→M std_ids. Re-attaching the same nodeId atomically
1102
- * detaches the prior plan first (re-provisioning case).
1103
- */
1043
+ /** Claim a slave's S→M std_ids. Re-attach atomically detaches the prior plan. */
1104
1044
  attachSlave(nodeId, framePlan, index) {
1105
1045
  this.detachSlave(nodeId);
1106
1046
  const owned = /* @__PURE__ */ new Set();
@@ -1235,9 +1175,8 @@ var DataPlaneReceiver = class {
1235
1175
  //#endregion
1236
1176
  //#region src/frame-planner.ts
1237
1177
  /**
1238
- * 11-bit Standard ID allocator with a free pool for IDs released after a
1239
- * grace window (§11.4.1). `next()` returns `null` on exhaustion rather
1240
- * than wrapping — wrapping past 0x7FF would alias on the wire.
1178
+ * 11-bit std_id allocator with a delayed free pool. `next()` returns `null`
1179
+ * on exhaustion — wrapping past 0x7FF would alias on the wire.
1241
1180
  */
1242
1181
  var StdIdAllocator = class {
1243
1182
  #next = 256;
@@ -1329,9 +1268,8 @@ function emitOversizedTopic(fields, dir, periodUs, alloc, out) {
1329
1268
  return flush();
1330
1269
  }
1331
1270
  /**
1332
- * Plan a slave's frame layout. Returns `null` if the StdIdAllocator runs
1333
- * out of 11-bit IDs mid-plan — the caller MUST surface that; a partial
1334
- * plan would silently drop fields.
1271
+ * Plan a slave's frame layout. Returns `null` on std_id exhaustion — a
1272
+ * partial plan would silently drop fields.
1335
1273
  */
1336
1274
  function planFrames(requests, alloc) {
1337
1275
  const buckets = /* @__PURE__ */ new Map();
@@ -1419,19 +1357,10 @@ function joinPath$1(ns, name) {
1419
1357
  return `${ns}/${name}`;
1420
1358
  }
1421
1359
  /**
1422
- * Synthesize the default {@link FieldRequest} list from a decoded schema +
1423
- * numbering table — every S2M topic field at its declared period
1424
- * (`field.periodUsOverride` if present, else the owning topic's
1425
- * `defaultPeriodUs`), skipping `txEnabled === false` fields.
1426
- *
1427
- * Pass `filter` to layer a runtime per-field mask on top (UI toggles,
1428
- * per-slave overrides). Schema-disabled fields are unconditionally
1429
- * dropped before `filter` is consulted, so callers don't have to repeat
1430
- * the `txEnabled` check.
1431
- *
1432
- * This is the natural input to {@link planFrames} when the master just
1433
- * wants to "observe everything the slave publishes" — i.e. the
1434
- * sniffer-style auto-provision path.
1360
+ * Every schema-enabled S→M field at its declared period
1361
+ * (`field.periodUsOverride` ?? topic's `defaultPeriodUs`), optionally masked
1362
+ * further by `filter`. Feeds {@link planFrames} for the observe-everything
1363
+ * auto-provision path.
1435
1364
  */
1436
1365
  function buildDefaultS2MRequests(schema, numbering, filter) {
1437
1366
  const out = [];
@@ -1468,17 +1397,10 @@ function buildDefaultS2MRequests(schema, numbering, filter) {
1468
1397
  return out;
1469
1398
  }
1470
1399
  /**
1471
- * One-line FramePlanFactory that subscribes to every S2M field at its
1472
- * declared period using a freshly-allocated {@link StdIdAllocator}.
1473
- *
1474
- * Pass `filter` to apply a runtime per-field mask (e.g. a UI-controlled
1475
- * field-tx-enable map). With no filter, every schema-enabled S→M field is
1476
- * subscribed.
1477
- *
1478
- * Returns `[]` when planning fails (e.g. std_id exhaustion); the caller's
1479
- * NodeAgent will then leave the slave in `provisioning` → `awaiting-announce`
1480
- * with no frames defined, which is the right outcome — partial plans would
1481
- * silently drop fields.
1400
+ * FramePlanFactory subscribing to every schema-enabled S→M field (optionally
1401
+ * masked by `filter`) with a fresh {@link StdIdAllocator}. Returns `[]` on
1402
+ * planning failure so NodeAgent stalls in `awaiting-announce` — a partial
1403
+ * plan would silently drop fields.
1482
1404
  */
1483
1405
  function defaultS2MFramePlan(schema, numbering, filter) {
1484
1406
  const planned = planFrames(buildDefaultS2MRequests(schema, numbering, filter), new StdIdAllocator());
@@ -1529,19 +1451,13 @@ var MasterHeartbeatBroadcaster = class {
1529
1451
  //#endregion
1530
1452
  //#region src/numbering.ts
1531
1453
  /**
1532
- * Flat-numbering tables for topics, services, and parameters (§4.2).
1533
- *
1534
- * Port of `fibril_can_core/src/numbering.cpp`. Numbering is a pure function
1535
- * of (schema, instance-count-vector). The same tables are derived
1536
- * independently by codegen (with max_count for static checks), the slave
1537
- * runtime (with actual counts at boot) and the master (with counts from the
1538
- * ANNOUNCE frame) — keeping this in one place is what guarantees they agree.
1454
+ * Flat-numbering tables for topics, services, and parameters — a pure
1455
+ * function of (schema, instance-count-vector). Codegen, slave runtime, and
1456
+ * master all derive these tables through the same function.
1539
1457
  */
1540
1458
  const SERVICE_INDEX_CANCEL = 255;
1541
1459
  const SERVICE_INDEX_PARAM_SET = 253;
1542
- /** Maximum first-class service_index produced by numbering (inclusive). */
1543
1460
  const SERVICE_INDEX_MAX = 252;
1544
- /** Number of available first-class service slots. */
1545
1461
  const SERVICE_INDEX_CAPACITY = 253;
1546
1462
  let NumberingError = /* @__PURE__ */ function(NumberingError) {
1547
1463
  NumberingError["Ok"] = "ok";
@@ -1581,12 +1497,8 @@ function buildTable(schema, counts, pick) {
1581
1497
  };
1582
1498
  }
1583
1499
  /**
1584
- * Compute the three numbering tables. `actualCounts` must have the same
1585
- * length as `schema.instances`. The result has `status !== Ok` if input is
1586
- * malformed or service_index would overflow into the reserved range
1587
- * (§4.2 §9.4).
1588
- *
1589
- * On error the returned tables are empty but still safe to inspect.
1500
+ * `actualCounts.length` must equal `schema.instances.length`. On any error
1501
+ * the returned tables are empty and `status !== Ok`.
1590
1502
  */
1591
1503
  function computeNumbering(schema, actualCounts) {
1592
1504
  const empty = {
@@ -1631,29 +1543,13 @@ function computeNumbering(schema, actualCounts) {
1631
1543
  }
1632
1544
  //#endregion
1633
1545
  //#region src/schema-hash.ts
1634
- /**
1635
- * SHA-256 helpers for the schema blob (§5.4, §9.3).
1636
- *
1637
- * Both codegen (C++) and the bridge (C++) hash the entire schema blob with
1638
- * SHA-256 and pack the first 8 bytes as little-endian u64 into the ANNOUNCE
1639
- * frame. This module is the master-side equivalent: hash the blob we
1640
- * received over SCHEMA_DATA and compare with the schema_hash the slave
1641
- * advertised in ANNOUNCE.
1642
- *
1643
- * Uses Web Crypto (`crypto.subtle`) — available in modern browsers and Node
1644
- * ≥ 19 / Deno / Bun, no polyfill needed for our target environments.
1645
- */
1646
1546
  async function sha256(bytes) {
1647
1547
  if (typeof crypto === "undefined" || !crypto.subtle) throw new Error("crypto.subtle is not available in this environment");
1648
1548
  const owned = new Uint8Array(bytes);
1649
1549
  const digest = await crypto.subtle.digest("SHA-256", owned);
1650
1550
  return new Uint8Array(digest);
1651
1551
  }
1652
- /**
1653
- * Compute the ANNOUNCE-shape schema hash: the first 8 bytes of SHA-256(blob),
1654
- * interpreted as a little-endian u64. Mirrors
1655
- * `fibril_can_core/sha256::hash_prefix_u64`.
1656
- */
1552
+ /** First 8 bytes of SHA-256(blob) as little-endian u64 — the ANNOUNCE shape. */
1657
1553
  async function schemaHashPrefix(blob) {
1658
1554
  const digest = await sha256(blob);
1659
1555
  let result = 0n;
@@ -1663,18 +1559,9 @@ async function schemaHashPrefix(blob) {
1663
1559
  //#endregion
1664
1560
  //#region src/schema-index.ts
1665
1561
  /**
1666
- * Schema-driven path lookup. Resolves `'<expanded_ns>/<endpoint_name>'`
1667
- * style paths to the flat (topic|service|param) index plus the field IR
1668
- * needed to drive {@link ./dynamic-codec.ts}.
1669
- *
1670
- * Paths are local to a single slave (one {@link NodeAgent}). The block-array
1671
- * `ros_namespace` is expanded by substituting `{i}` with the instance index
1672
- * (mirrors `expand_i_token` in `fibril_can_bridge::node_agent_internal.hpp`).
1673
- *
1674
- * Two endpoints of the same kind sharing a path is a schema error — the
1675
- * index throws on construction. Same path across different kinds (e.g. a
1676
- * topic and a service named `motor0/foo`) is allowed because the caller
1677
- * selects the kind explicitly via `topic()` / `service()` / `param()`.
1562
+ * Path (`<expanded_ns>/<endpoint_name>`) → descriptor lookup for one slave.
1563
+ * Duplicate path within the same kind throws; the same path across kinds
1564
+ * is allowed (caller selects via `topic()` / `service()` / `param()`).
1678
1565
  */
1679
1566
  const I_TOKEN = "{i}";
1680
1567
  function expandIToken(s, value) {
@@ -1691,10 +1578,6 @@ function sumFieldsWireSize(fields) {
1691
1578
  for (const f of fields) n += fieldWireSize(f);
1692
1579
  return n;
1693
1580
  }
1694
- /**
1695
- * Path → descriptor index for one slave. Built once from
1696
- * (schema, numbering) and reused for the lifetime of a snapshot.
1697
- */
1698
1581
  var SchemaIndex = class {
1699
1582
  #topicsByPath = /* @__PURE__ */ new Map();
1700
1583
  #servicesByPath = /* @__PURE__ */ new Map();
@@ -1812,13 +1695,7 @@ var SchemaIndex = class {
1812
1695
  };
1813
1696
  //#endregion
1814
1697
  //#region src/seq-allocator.ts
1815
- /**
1816
- * Sequential u8 allocator used for the config-plane `seq` field shared by
1817
- * every command this master has issued to a given slave. Wraps at 256,
1818
- * matching `BridgeNode::alloc_seq` in
1819
- * `fibril_can_bridge/src/bridge_node.cpp` (the bridge wraps on the same
1820
- * boundary; the AckTracker indexes by the wrapped value).
1821
- */
1698
+ /** Wrapping u8 counter for the config-plane `seq` byte. */
1822
1699
  var SeqAllocator = class {
1823
1700
  #next = 0;
1824
1701
  next() {
@@ -1833,13 +1710,9 @@ var SeqAllocator = class {
1833
1710
  //#endregion
1834
1711
  //#region src/segmented-transfer.ts
1835
1712
  /**
1836
- * Build the §7.4 SVC_REQUEST frame stream for a master→slave call. Mirrors
1837
- * `make_svc_request_frames` in
1838
- * `fibril_can_bridge/include/fibril_can_bridge/svc_request.hpp`.
1839
- *
1840
- * - ≤64 byte payload → one SINGLE frame (seg=0).
1841
- * - Larger → seq=0 carries `[0]=0 [1..2]=total_len + ≤61B data`, subsequent
1842
- * seqs carry `[0]=seq + ≤63B data`. Throws on payloads >1024B.
1713
+ * SVC_REQUEST frames for a master→slave call. ≤64 B → one SINGLE frame;
1714
+ * larger → seq 0 carries `[0]=0 [1..2]=total + ≤61 B data`, later seqs
1715
+ * carry `[0]=seq + ≤63 B data`. Throws on payloads >1024 B.
1843
1716
  */
1844
1717
  function makeSvcRequestFrames(nodeId, svcIdx, callId, payload) {
1845
1718
  if (payload.length > 1024) throw new RangeError(`service request payload ${payload.length} exceeds SEG_MAX_TOTAL (${SEG_MAX_TOTAL})`);
@@ -1880,9 +1753,8 @@ function makeSvcRequestFrames(nodeId, svcIdx, callId, payload) {
1880
1753
  return out;
1881
1754
  }
1882
1755
  /**
1883
- * Build §7.4 response segments. Used by mock slaves and any TS slave runtime.
1884
- * `channel` is the channel field (typically `CHAN_SVC_RESPONSE`); the payload
1885
- * is `[status, ...response]` so the caller bakes status into the buffer.
1756
+ * Response segments (for mock slaves / TS slave runtime). `payload` must
1757
+ * already carry `[status, ...body]`; `channel` is typically CHAN_SVC_RESPONSE.
1886
1758
  */
1887
1759
  function makeSvcSegmentedFrames(channel, nodeId, svcIdx, callId, payload) {
1888
1760
  if (payload.length > 1024) throw new RangeError(`service response payload ${payload.length} exceeds SEG_MAX_TOTAL (${SEG_MAX_TOTAL})`);
@@ -1922,11 +1794,7 @@ function makeSvcSegmentedFrames(channel, nodeId, svcIdx, callId, payload) {
1922
1794
  }
1923
1795
  return out;
1924
1796
  }
1925
- /**
1926
- * Bitmask of segment seqs required to reassemble a `totalLen`-byte payload.
1927
- * Identical formula to `expected_mask` in
1928
- * `fibril_can_bridge/src/node_agent.cpp` and the slave's `seg_expected_mask`.
1929
- */
1797
+ /** Bitmask of seg-seqs required to reassemble a `totalLen`-byte payload. */
1930
1798
  function segExpectedMask(totalLen) {
1931
1799
  if (totalLen <= 0) return 0;
1932
1800
  if (totalLen <= 61) return 1;
@@ -1935,20 +1803,16 @@ function segExpectedMask(totalLen) {
1935
1803
  if (nSegs >= 32) return 4294967295;
1936
1804
  return (1 << nSegs) - 1;
1937
1805
  }
1938
- /**
1939
- * Per-(svc, call_id) reassembly slot. The caller drives one of these per
1940
- * outstanding incoming segmented transfer.
1941
- */
1806
+ /** Per-(svc, call_id) reassembly slot for one segmented transfer. */
1942
1807
  var SegmentReassembler = class {
1943
1808
  totalLen = 0;
1944
1809
  rxBitmap = 0;
1945
1810
  lastRxMs = 0;
1946
1811
  #buffer = /* @__PURE__ */ new Uint8Array(0);
1947
1812
  /**
1948
- * Ingest one seg=1 frame. `data` is the frame payload (with the seq /
1949
- * total_len header still attached). Returns the fully-assembled payload
1950
- * once every required segment has arrived; otherwise null. Returns null
1951
- * on protocol violations and resets the slot.
1813
+ * Ingest one seg=1 frame (payload with seq / total_len header attached).
1814
+ * Returns the assembled payload once complete, else null. Resets the slot
1815
+ * on protocol violation.
1952
1816
  */
1953
1817
  ingest(data, nowMs) {
1954
1818
  if (data.length === 0) return this.#reset();
@@ -2035,13 +1899,8 @@ const REASM_MAX_CONCURRENT = 16;
2035
1899
  /** §7.4 reassembly TTL. */
2036
1900
  const SEG_TIMEOUT_MS = 100;
2037
1901
  /**
2038
- * Single-slave master service-call client. Port of NodeAgent's
2039
- * begin_service_call / on_service_response / on_service_segment flow but
2040
- * Promise-based and AbortSignal-aware.
2041
- *
2042
- * One {@link ServiceClient} attaches to the transport and demuxes incoming
2043
- * SVC_RESPONSE frames for a single `nodeId`. NodeAgent owns one per slave
2044
- * and exposes it on the running snapshot.
1902
+ * Promise-based, AbortSignal-aware service-call client for one slave.
1903
+ * NodeAgent owns one per slave and exposes it on the running snapshot.
2045
1904
  */
2046
1905
  var ServiceClient = class {
2047
1906
  nodeId;
@@ -2206,24 +2065,8 @@ var ServiceClient = class {
2206
2065
  //#endregion
2207
2066
  //#region src/typed-param.ts
2208
2067
  /**
2209
- * Schema-driven typed wrapper around PARAM_SET (SPEC §8.2, reserved
2210
- * service_index 0xFD).
2211
- *
2212
- * Per SPEC §8.1 PARAM is M→S one-way: the master holds the source of truth
2213
- * and re-injects defaults at provisioning time, so this wrapper exposes
2214
- * `.set()` only — there's no `.get()` that queries the slave. The
2215
- * descriptor's decoded default is available via `defaultValue()` for
2216
- * reference / hydration of master-side state.
2217
- *
2218
- * The wire shape for one PARAM_SET request is:
2219
- * [0] count = 1
2220
- * [1..2] u16 param_index (LE)
2221
- * [3..] value (scalar little-endian or fixed-array contiguous)
2222
- *
2223
- * Response is `[0] status [1..2] failed_param_index (when status != OK)`.
2224
- * `set()` resolves with the status code; non-OK includes `failedIndex` so
2225
- * the caller can correlate with a multi-param batch (single-param sets
2226
- * always blame this param).
2068
+ * Typed wrapper around PARAM_SET (service_index 0xFD). PARAM is M→S
2069
+ * one-way, so this exposes `.set()` and `.defaultValue()` only.
2227
2070
  */
2228
2071
  var ParamCodecError = class extends Error {
2229
2072
  constructor(message) {
@@ -2306,11 +2149,7 @@ var TypedParam = class {
2306
2149
  this.descriptor = descriptor;
2307
2150
  this.path = descriptor.path;
2308
2151
  }
2309
- /**
2310
- * Decode the schema-embedded default value (SPEC §9.1 `default`). Useful
2311
- * to seed the master-side persistence store. Returns `null` if the
2312
- * descriptor carries no default payload.
2313
- */
2152
+ /** Decoded schema default; `null` if the descriptor carries no default. */
2314
2153
  defaultValue() {
2315
2154
  if (this.descriptor.defaultValue.length === 0) return null;
2316
2155
  return unpackValue(this.descriptor, this.descriptor.defaultValue);
@@ -2368,15 +2207,10 @@ const DEFAULT_OPTS = {
2368
2207
  heartbeatTimeoutMs: 350
2369
2208
  };
2370
2209
  /**
2371
- * Per-slave provisioning state machine. Mirrors
2372
- * `fibril_can_bridge::NodeAgent` lifecycle entry points but trimmed for the
2373
- * single-threaded TS master — no ROS entity creation, no PARAM_SET
2374
- * re-injection (P5), no plan computation (P6).
2375
- *
2376
- * Lifecycle: `idle` → `awaiting-announce` → `schema-reading` (skipped if
2377
- * blob already cached on this agent instance) → `numbering` →
2378
- * `provisioning` → `running`. HEARTBEAT loss drops to `lost`; the next
2379
- * ANNOUNCE restarts the flow.
2210
+ * Per-slave provisioning state machine.
2211
+ * `idle` → `awaiting-announce` → `schema-reading` (skipped on cache hit) →
2212
+ * `numbering` → `provisioning` → `running`. HEARTBEAT loss drops to `lost`;
2213
+ * the next ANNOUNCE restarts the flow.
2380
2214
  */
2381
2215
  var NodeAgent = class {
2382
2216
  nodeId;
@@ -2437,9 +2271,8 @@ var NodeAgent = class {
2437
2271
  return this.#snapshot;
2438
2272
  }
2439
2273
  /**
2440
- * Attach to the transport and wait for ANNOUNCE. Resolves immediately —
2441
- * progress flows via {@link state} / {@link snapshot} / `onState`. Call
2442
- * `stop()` to detach.
2274
+ * Attach and wait for ANNOUNCE. Resolves immediately; progress flows via
2275
+ * {@link state} / {@link snapshot} / `onState`.
2443
2276
  */
2444
2277
  async start() {
2445
2278
  if (this.#stopped) throw new Error("NodeAgent.start() after stop() is not supported — construct a new agent");
@@ -2450,17 +2283,11 @@ var NodeAgent = class {
2450
2283
  this.#setState({ kind: "awaiting-announce" });
2451
2284
  }
2452
2285
  /**
2453
- * Re-run the FRAME_DEFINE / START handshake against the live slave using
2454
- * the most recent ANNOUNCE + cached schema. The {@link FramePlanFactory}
2455
- * is invoked again — callers that key off an external mutable state
2456
- * (e.g. per-field TX-enable overrides) will see the latest values.
2457
- *
2458
- * The agent must have observed an ANNOUNCE already; `reprovision()`
2459
- * before that throws. RUNNING is required so that the slave is known to
2460
- * be reachable — calling during `lost` would race the heartbeat
2461
- * watchdog. Pending service calls are failed and the current snapshot is
2462
- * torn down before the new bring-up begins, mirroring what happens on a
2463
- * boot_id change.
2286
+ * Re-run FRAME_DEFINE / START against the live slave with the most recent
2287
+ * ANNOUNCE + cached schema. Invokes {@link FramePlanFactory} again so
2288
+ * per-slave overrides pick up their latest values. Requires a prior
2289
+ * ANNOUNCE. Tears down the current snapshot and fails pending service
2290
+ * calls, mirroring a boot_id change.
2464
2291
  */
2465
2292
  async reprovision() {
2466
2293
  if (this.#stopped) throw new Error("NodeAgent.reprovision() after stop()");
@@ -2762,10 +2589,8 @@ var NodeAgent = class {
2762
2589
  this.#maybeIssueStart(a, schema, blob, numbering, gen, p);
2763
2590
  }
2764
2591
  /**
2765
- * FRAME_DEFINE has succeeded (or been skipped). Consult the autoStart
2766
- * gate and either fire START immediately or park in `awaiting-start`,
2767
- * remembering enough context that `startNow()` can resume without
2768
- * re-running FRAME_DEFINE.
2592
+ * Fire START if autoStart allows, else park in `awaiting-start` with
2593
+ * enough context that `startNow()` can resume without re-running FRAME_DEFINE.
2769
2594
  */
2770
2595
  #maybeIssueStart(a, schema, blob, numbering, gen, progress) {
2771
2596
  if (this.#autoStart()) {
@@ -2783,10 +2608,8 @@ var NodeAgent = class {
2783
2608
  this.#setState({ kind: "awaiting-start" });
2784
2609
  }
2785
2610
  /**
2786
- * Commit the deferred START when {@link NodeAgentOptions.autoStart}
2787
- * gated the previous bring-up. Only legal in `awaiting-start`; any
2788
- * other state throws so callers spot logic bugs instead of racing
2789
- * with a fresh ANNOUNCE.
2611
+ * Commit the deferred START. Only legal in `awaiting-start`; throws
2612
+ * elsewhere so callers spot logic bugs instead of racing a fresh ANNOUNCE.
2790
2613
  */
2791
2614
  async startNow() {
2792
2615
  if (this.#stopped) throw new Error("NodeAgent.startNow() after stop()");
@@ -2910,19 +2733,12 @@ var MasterController = class {
2910
2733
  this.#onError = opts.onError;
2911
2734
  }
2912
2735
  /**
2913
- * Master-side decoder for S→M topic frames. Auto-attached / detached as
2914
- * each slave transitions in and out of `running`. Subscribe with
2915
- * `dataPlane.onTopic(nodeId, path, listener)`; read latest values with
2916
- * `dataPlane.latest(nodeId, path)`.
2736
+ * S→M topic decoder. Auto-attaches / detaches on each slave's `running`
2737
+ * transition; subscribe via `dataPlane.onTopic` / read via `dataPlane.latest`.
2917
2738
  */
2918
2739
  get dataPlane() {
2919
2740
  return this.#dataPlane;
2920
2741
  }
2921
- /**
2922
- * Attach to the transport, start MASTER_HEARTBEAT, fire one initial
2923
- * broadcast DISCOVER to flush stale slave state from a previous master
2924
- * session. New slaves auto-spawn a NodeAgent on first sight.
2925
- */
2926
2742
  start() {
2927
2743
  if (this.#started) return;
2928
2744
  this.#started = true;
@@ -2963,7 +2779,7 @@ var MasterController = class {
2963
2779
  schemaHash: r.schemaHash
2964
2780
  })).sort((a, b) => a.nodeId - b.nodeId);
2965
2781
  }
2966
- /** Subscribe to slave-set / state-change updates. Fires on every change. */
2782
+ /** Subscribe to slave-set / state-change updates. Fires immediately with the current snapshot, then on every change. */
2967
2783
  onChange(cb) {
2968
2784
  this.#listeners.add(cb);
2969
2785
  cb(this.snapshots());
@@ -2971,24 +2787,18 @@ var MasterController = class {
2971
2787
  this.#listeners.delete(cb);
2972
2788
  };
2973
2789
  }
2974
- /**
2975
- * Access the NodeAgent for one slave, if it exists. Returns null until
2976
- * the slave has been observed. Useful for service / param calls from
2977
- * code that already knows the nodeId.
2978
- */
2790
+ /** NodeAgent for one slave, or null if not yet observed. */
2979
2791
  agent(nodeId) {
2980
2792
  return this.#slaves.get(nodeId)?.agent ?? null;
2981
2793
  }
2982
- /** Issue an ad-hoc broadcast DISCOVER (between periodic firings). */
2794
+ /** Ad-hoc broadcast DISCOVER. */
2983
2795
  async discoverNow() {
2984
2796
  await this.#transport.send(encodeDiscover());
2985
2797
  }
2986
2798
  /**
2987
- * Commit the deferred START on one slave that is parked in
2988
- * `awaiting-start` because the autoStart gate blocked its bring-up.
2989
- * Returns `false` if the slave is unknown or not currently waiting
2990
- * for a manual start; otherwise resolves once START has been queued
2991
- * on the wire (the RUNNING transition arrives later via `onChange`).
2799
+ * Commit the deferred START on a slave parked in `awaiting-start`.
2800
+ * Returns `false` if the slave is unknown or not waiting; the RUNNING
2801
+ * transition arrives later via `onChange`.
2992
2802
  */
2993
2803
  async startNode(nodeId) {
2994
2804
  const rec = this.#slaves.get(nodeId);
@@ -3003,13 +2813,10 @@ var MasterController = class {
3003
2813
  }
3004
2814
  }
3005
2815
  /**
3006
- * Re-run the FRAME_DEFINE / START handshake for one slave so the
3007
- * {@link FramePlanFactory} is consulted again. Use when external state
3008
- * the factory closes over has changed (e.g. per-field TX-enable
3009
- * toggles). Returns `false` when the slave is unknown or has never
3010
- * ANNOUNCEd; otherwise resolves once the new bring-up has been kicked
3011
- * off (it does not await the next RUNNING transition — listen via
3012
- * {@link onChange} instead).
2816
+ * Re-run FRAME_DEFINE / START so the {@link FramePlanFactory} is consulted
2817
+ * again (e.g. after a UI-driven field-tx-enable change). Returns `false`
2818
+ * if unknown / never ANNOUNCEd; resolves once bring-up is kicked off, not
2819
+ * when RUNNING is reached — listen via {@link onChange}.
3013
2820
  */
3014
2821
  async reprovision(nodeId) {
3015
2822
  const rec = this.#slaves.get(nodeId);