@camstack/addon-matter-broker 0.2.148 → 0.2.150

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/addon.js CHANGED
@@ -13,7 +13,7 @@ let node_stream_promises = require("node:stream/promises");
13
13
  let node_net = require("node:net");
14
14
  let node_dgram = require("node:dgram");
15
15
  node_dgram = require_esm.__toESM(node_dgram, 1);
16
- //#region ../types/dist/event-category-DHXxB8t9.mjs
16
+ //#region ../types/dist/event-category-CT-hz5Rb.mjs
17
17
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
18
18
  EventCategory["SystemBoot"] = "system.boot";
19
19
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -339,6 +339,14 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
339
339
  /** The recovery edge of {@link PipelineMotionNoisy}: the motion fell back, or a detection arrived. */
340
340
  EventCategory["PipelineMotionCalm"] = "pipeline.motion-calm";
341
341
  /**
342
+ * A camera's package LLM confirm has failed closed three times in a row
343
+ * (D931, 2026-10-26). One edge per camera until
344
+ * {@link PipelinePackageLlmAnswered}.
345
+ */
346
+ EventCategory["PipelinePackageLlmSilent"] = "pipeline.package-llm-silent";
347
+ /** The recovery edge of {@link PipelinePackageLlmSilent}: a confirm returned yes or no. */
348
+ EventCategory["PipelinePackageLlmAnswered"] = "pipeline.package-llm-answered";
349
+ /**
342
350
  * Per-camera pipeline config was mutated by the orchestrator
343
351
  * (a settings change via `setAgentInferenceDevices` /
344
352
  * `setCameraStepToggle` / `setCameraStepSettings`). Orchestrator
@@ -5581,7 +5589,7 @@ var ZodIssueCode = {
5581
5589
  var ZodFirstPartyTypeKind;
5582
5590
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5583
5591
  //#endregion
5584
- //#region ../types/dist/sleep-CARd6wZF.mjs
5592
+ //#region ../types/dist/sleep-bykKrTgu.mjs
5585
5593
  /**
5586
5594
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5587
5595
  * window to float samples (D455).
@@ -6596,7 +6604,7 @@ var SecretOverwriteRefusedError = class extends Error {
6596
6604
  this.name = "SecretOverwriteRefusedError";
6597
6605
  }
6598
6606
  };
6599
- function isRecord$4(value) {
6607
+ function isRecord$4$1(value) {
6600
6608
  return typeof value === "object" && value !== null && !Array.isArray(value);
6601
6609
  }
6602
6610
  function resolvedValue(pass, marker) {
@@ -6625,7 +6633,7 @@ function inArray(pass, incoming, stored) {
6625
6633
  return incoming.map((item, index) => {
6626
6634
  const twin = stored[index];
6627
6635
  if (isSecretMarker(twin)) return typeof item === "string" && item === resolvedValue(pass, twin) ? twin : item;
6628
- if (isRecord$4(item) && isRecord$4(twin)) return inArrayRow(pass, item, twin);
6636
+ if (isRecord$4$1(item) && isRecord$4$1(twin)) return inArrayRow(pass, item, twin);
6629
6637
  if (Array.isArray(item) && Array.isArray(twin)) return inArray(pass, item, twin);
6630
6638
  return item;
6631
6639
  });
@@ -6636,7 +6644,7 @@ function inArrayRow(pass, incoming, stored) {
6636
6644
  const twin = stored[key];
6637
6645
  if (isSecretMarker(twin)) {
6638
6646
  if (typeof value === "string" && value === resolvedValue(pass, twin)) out[key] = twin;
6639
- } else if (isRecord$4(value) && isRecord$4(twin)) out[key] = inArrayRow(pass, value, twin);
6647
+ } else if (isRecord$4$1(value) && isRecord$4$1(twin)) out[key] = inArrayRow(pass, value, twin);
6640
6648
  else if (Array.isArray(value) && Array.isArray(twin)) out[key] = inArray(pass, value, twin);
6641
6649
  }
6642
6650
  return out;
@@ -6644,7 +6652,7 @@ function inArrayRow(pass, incoming, stored) {
6644
6652
  async function walk(pass, incoming, stored) {
6645
6653
  if (isSecretMarker(stored)) return atMarker(pass, incoming, stored);
6646
6654
  if (Array.isArray(incoming) && Array.isArray(stored)) return inArray(pass, incoming, stored);
6647
- if (!isRecord$4(incoming) || !isRecord$4(stored)) return incoming;
6655
+ if (!isRecord$4$1(incoming) || !isRecord$4$1(stored)) return incoming;
6648
6656
  const out = { ...incoming };
6649
6657
  for (const [key, value] of Object.entries(incoming)) out[key] = await walk(pass, value, stored[key]);
6650
6658
  return out;
@@ -6675,7 +6683,7 @@ async function restoreUnchangedSecrets(input) {
6675
6683
  function isJsonEqual(a, b) {
6676
6684
  if (Object.is(a, b)) return true;
6677
6685
  if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((v, i) => isJsonEqual(v, b[i]));
6678
- if (isRecord$4(a) && isRecord$4(b)) {
6686
+ if (isRecord$4$1(a) && isRecord$4$1(b)) {
6679
6687
  const keys = Object.keys(a);
6680
6688
  if (keys.length !== Object.keys(b).length) return false;
6681
6689
  return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isJsonEqual(a[k], b[k]));
@@ -6699,7 +6707,7 @@ function markerKeysOf(blob, owner) {
6699
6707
  }
6700
6708
  function nodeFromStored(value, owner) {
6701
6709
  if (isSecretMarker(value)) return isSecretRefOwnedBy(value.$secret, owner) ? { kind: "leaf" } : null;
6702
- if (!isRecord$4(value)) return null;
6710
+ if (!isRecord$4$1(value)) return null;
6703
6711
  const children = /* @__PURE__ */ new Map();
6704
6712
  for (const [key, child] of Object.entries(value)) {
6705
6713
  const node = nodeFromStored(child, owner);
@@ -8362,6 +8370,53 @@ var READING_QUANTITIES = [
8362
8370
  ];
8363
8371
  var ReadingQuantitySchema = _enum(READING_QUANTITIES);
8364
8372
  new Set(READING_QUANTITIES);
8373
+ /**
8374
+ * Stored unit symbol for a quantity. `none` means the number is dimensionless
8375
+ * (or an index / ratio / count). `device` means the cap has no single unit.
8376
+ */
8377
+ var QUANTITY_BASE = {
8378
+ temperature: "°C",
8379
+ pressure: "hPa",
8380
+ speed: "m/s",
8381
+ precipitation: "mm",
8382
+ "precipitation-rate": "mm/h",
8383
+ length: "m",
8384
+ area: "m²",
8385
+ illuminance: "lx",
8386
+ irradiance: "W/m²",
8387
+ power: "W",
8388
+ energy: "kWh",
8389
+ voltage: "V",
8390
+ current: "A",
8391
+ "apparent-power": "VA",
8392
+ humidity: "%",
8393
+ percentage: "%",
8394
+ "concentration-volume": "ppm",
8395
+ "concentration-mass": "µg/m³",
8396
+ "concentration-ppb": "ppb",
8397
+ volume: "L",
8398
+ duration: "s",
8399
+ "duration-ms": "ms",
8400
+ information: "B",
8401
+ "sound-level": "dBFS",
8402
+ "signal-level": "dBm",
8403
+ "colour-temperature": "mired",
8404
+ angle: "°",
8405
+ bitrate: "kbps",
8406
+ rate: "fps",
8407
+ mass: "kg",
8408
+ index: "none",
8409
+ ratio: "none",
8410
+ count: "none",
8411
+ none: "none",
8412
+ device: "device"
8413
+ };
8414
+ /** The stored unit, or `undefined` when the quantity has no unit symbol. */
8415
+ function baseUnitOf(quantity) {
8416
+ const symbol = QUANTITY_BASE[quantity];
8417
+ if (symbol === "none" || symbol === "device") return void 0;
8418
+ return symbol;
8419
+ }
8365
8420
  function readingMeta(quantity, extras) {
8366
8421
  return {
8367
8422
  quantity,
@@ -8915,6 +8970,8 @@ var CAP_NAMES = Object.freeze([
8915
8970
  "filesystem-browse",
8916
8971
  "flood",
8917
8972
  "gas",
8973
+ "group-commands",
8974
+ "group-status",
8918
8975
  "heating-valve",
8919
8976
  "humidifier",
8920
8977
  "humidity-sensor",
@@ -9733,6 +9790,20 @@ Object.freeze({
9733
9790
  "path": "detected",
9734
9791
  "valueType": "boolean"
9735
9792
  }],
9793
+ "group-status": [
9794
+ {
9795
+ "path": "included",
9796
+ "valueType": "number"
9797
+ },
9798
+ {
9799
+ "path": "excluded",
9800
+ "valueType": "number"
9801
+ },
9802
+ {
9803
+ "path": "total",
9804
+ "valueType": "number"
9805
+ }
9806
+ ],
9736
9807
  "heating-valve": [{
9737
9808
  "path": "position",
9738
9809
  "valueType": "number"
@@ -11074,7 +11145,7 @@ function errMsg(err) {
11074
11145
  return String(err);
11075
11146
  }
11076
11147
  //#endregion
11077
- //#region ../types/dist/composition-kEl9mevu.mjs
11148
+ //#region ../types/dist/composition-OhpuespA.mjs
11078
11149
  /**
11079
11150
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11080
11151
  *
@@ -11187,7 +11258,7 @@ var ExpressionEvalError = class extends Error {
11187
11258
  this.name = "ExpressionEvalError";
11188
11259
  }
11189
11260
  };
11190
- function asFiniteNumber(value, name, index) {
11261
+ function asFiniteNumber$1(value, name, index) {
11191
11262
  if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11192
11263
  return value;
11193
11264
  }
@@ -11200,7 +11271,7 @@ function finiteResult(value, name) {
11200
11271
  return value;
11201
11272
  }
11202
11273
  function allFiniteNumbers(args, name) {
11203
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11274
+ return args.map((a, idx) => asFiniteNumber$1(a, name, idx));
11204
11275
  }
11205
11276
  function asBoolean$2(value, name, index) {
11206
11277
  if (typeof value !== "boolean") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a boolean`);
@@ -11237,29 +11308,29 @@ var table = {
11237
11308
  abs: {
11238
11309
  minArgs: 1,
11239
11310
  maxArgs: 1,
11240
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11311
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber$1(args[0], "abs", 0)), "abs")
11241
11312
  },
11242
11313
  floor: {
11243
11314
  minArgs: 1,
11244
11315
  maxArgs: 1,
11245
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11316
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber$1(args[0], "floor", 0)), "floor")
11246
11317
  },
11247
11318
  ceil: {
11248
11319
  minArgs: 1,
11249
11320
  maxArgs: 1,
11250
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11321
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber$1(args[0], "ceil", 0)), "ceil")
11251
11322
  },
11252
11323
  sqrt: {
11253
11324
  minArgs: 1,
11254
11325
  maxArgs: 1,
11255
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11326
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber$1(args[0], "sqrt", 0)), "sqrt")
11256
11327
  },
11257
11328
  round: {
11258
11329
  minArgs: 1,
11259
11330
  maxArgs: 2,
11260
11331
  apply: (args) => {
11261
- const x = asFiniteNumber(args[0], "round", 0);
11262
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11332
+ const x = asFiniteNumber$1(args[0], "round", 0);
11333
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber$1(args[1], "round", 1)) : 0;
11263
11334
  if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11264
11335
  const factor = 10 ** digits;
11265
11336
  return finiteResult(Math.round(x * factor) / factor, "round");
@@ -11268,15 +11339,15 @@ var table = {
11268
11339
  pow: {
11269
11340
  minArgs: 2,
11270
11341
  maxArgs: 2,
11271
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11342
+ apply: (args) => finiteResult(asFiniteNumber$1(args[0], "pow", 0) ** asFiniteNumber$1(args[1], "pow", 1), "pow")
11272
11343
  },
11273
11344
  clamp: {
11274
11345
  minArgs: 3,
11275
11346
  maxArgs: 3,
11276
11347
  apply: (args) => {
11277
- const x = asFiniteNumber(args[0], "clamp", 0);
11278
- const lo = asFiniteNumber(args[1], "clamp", 1);
11279
- const hi = asFiniteNumber(args[2], "clamp", 2);
11348
+ const x = asFiniteNumber$1(args[0], "clamp", 0);
11349
+ const lo = asFiniteNumber$1(args[1], "clamp", 1);
11350
+ const hi = asFiniteNumber$1(args[2], "clamp", 2);
11280
11351
  if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11281
11352
  return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11282
11353
  }
@@ -11305,13 +11376,13 @@ var table = {
11305
11376
  age: {
11306
11377
  minArgs: 2,
11307
11378
  maxArgs: 2,
11308
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11379
+ apply: (args) => finiteResult(asFiniteNumber$1(args[0], "age", 0) - asFiniteNumber$1(args[1], "age", 1), "age")
11309
11380
  },
11310
11381
  convert: {
11311
11382
  minArgs: 3,
11312
11383
  maxArgs: 3,
11313
11384
  apply: (args, hooks) => {
11314
- const x = asFiniteNumber(args[0], "convert", 0);
11385
+ const x = asFiniteNumber$1(args[0], "convert", 0);
11315
11386
  const from = asString(args[1], "convert", 1).trim();
11316
11387
  const to = asString(args[2], "convert", 2).trim();
11317
11388
  if (hooks.convert) {
@@ -11337,11 +11408,11 @@ var table = {
11337
11408
  minArgs: 2,
11338
11409
  maxArgs: 2,
11339
11410
  apply: (args, hooks) => {
11340
- const windowMs = asFiniteNumber(args[1], "within", 1);
11411
+ const windowMs = asFiniteNumber$1(args[1], "within", 1);
11341
11412
  if (windowMs < 0) throw new ExpressionEvalError("within: the window must not be negative");
11342
11413
  const at = args[0];
11343
11414
  if (at === null) return false;
11344
- const ts = asFiniteNumber(at, "within", 0);
11415
+ const ts = asFiniteNumber$1(at, "within", 0);
11345
11416
  const now = hooks.now;
11346
11417
  if (now === void 0 || !Number.isFinite(now)) throw new ExpressionEvalError("within: no clock was supplied to this evaluation");
11347
11418
  const inside = now - ts <= windowMs;
@@ -11358,7 +11429,7 @@ var table = {
11358
11429
  minArgs: 1,
11359
11430
  maxArgs: INF,
11360
11431
  apply: (args) => {
11361
- const present = args.flatMap((a, i) => a === null ? [] : [asFiniteNumber(a, "latest", i)]);
11432
+ const present = args.flatMap((a, i) => a === null ? [] : [asFiniteNumber$1(a, "latest", i)]);
11362
11433
  return present.length === 0 ? null : finiteResult(Math.max(...present), "latest");
11363
11434
  }
11364
11435
  },
@@ -11829,15 +11900,74 @@ function validateExpressionSource(src) {
11829
11900
  }
11830
11901
  return null;
11831
11902
  }
11832
- /** A composed device's stableId is this prefix plus the block id. */
11833
- var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
11834
- /** Longest snippet a `code` source may carry (slice 4). */
11835
- var MAX_COMPOSITION_CODE_LENGTH = 2e4;
11903
+ /**
11904
+ * A composition source identity: `(addonId, stableId)`, never a numeric device
11905
+ * id. Split out so helper schemas can name it without a cycle through
11906
+ * `composition.ts` (D659).
11907
+ */
11836
11908
  var CompositionSourceRefSchema = object({
11837
11909
  /** The owning addon; `stableId` is unique only within it. */
11838
11910
  addonId: string$2().min(1),
11839
11911
  stableId: string$2().min(1)
11840
11912
  });
11913
+ var GROUP_TEMPLATE_IDS = [
11914
+ "temperature",
11915
+ "humidity",
11916
+ "pressure",
11917
+ "illuminance",
11918
+ "power",
11919
+ "energy",
11920
+ "battery",
11921
+ "numeric",
11922
+ "motion",
11923
+ "contact",
11924
+ "leak",
11925
+ "smoke",
11926
+ "occupancy",
11927
+ "binary",
11928
+ "switch",
11929
+ "light"
11930
+ ];
11931
+ var NUMERIC_AGGREGATES = [
11932
+ "mean",
11933
+ "median",
11934
+ "min",
11935
+ "max",
11936
+ "sum"
11937
+ ];
11938
+ var BINARY_AGGREGATES = ["any", "all"];
11939
+ /** How an actuator group decides it is on. Unknown members never count as off. */
11940
+ var ACTIVE_CRITERIA = ["at-least-one", "all"];
11941
+ var AGGREGATE_REDUCERS = [
11942
+ "mean",
11943
+ "median",
11944
+ "min",
11945
+ "max",
11946
+ "sum",
11947
+ "any",
11948
+ "all"
11949
+ ];
11950
+ var GroupTemplateIdSchema = _enum(GROUP_TEMPLATE_IDS);
11951
+ var AggregateReducerSchema = _enum(AGGREGATE_REDUCERS);
11952
+ var ActiveCriterionSchema = _enum(ACTIVE_CRITERIA);
11953
+ /** One member. The field is stored so occupancy can name motion or binary. */
11954
+ var HelperMemberSchema = object({
11955
+ source: CompositionSourceRefSchema,
11956
+ cap: string$2().min(1),
11957
+ fieldPath: string$2().min(1)
11958
+ });
11959
+ var HelperSpecSchema = discriminatedUnion("kind", [object({
11960
+ kind: literal("group"),
11961
+ templateId: GroupTemplateIdSchema,
11962
+ aggregate: AggregateReducerSchema,
11963
+ members: array(HelperMemberSchema).min(1).max(64),
11964
+ /** Actuator groups only. Readings leave this unset. */
11965
+ active: ActiveCriterionSchema.optional()
11966
+ })]);
11967
+ /** A composed device's stableId is this prefix plus the block id. */
11968
+ var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
11969
+ /** Longest snippet a `code` source may carry (slice 4). */
11970
+ var MAX_COMPOSITION_CODE_LENGTH = 2e4;
11841
11971
  /** One field of one capability of one source device. */
11842
11972
  var CompositionFieldReadSchema = object({
11843
11973
  source: CompositionSourceRefSchema,
@@ -11881,7 +12011,33 @@ var CompositionCodeSourceSchema = object({
11881
12011
  var CompositionFieldSourceSchema = discriminatedUnion("kind", [
11882
12012
  CompositionFromSourceSchema,
11883
12013
  CompositionExpressionSourceSchema,
11884
- CompositionCodeSourceSchema
12014
+ CompositionCodeSourceSchema,
12015
+ object({
12016
+ kind: literal("aggregate"),
12017
+ reducer: _enum([
12018
+ "mean",
12019
+ "median",
12020
+ "min",
12021
+ "max",
12022
+ "sum",
12023
+ "any",
12024
+ "all"
12025
+ ]),
12026
+ reads: array(CompositionFieldReadSchema).min(1).max(64),
12027
+ /** `value` is the reading; the others are the member counts. */
12028
+ publish: _enum([
12029
+ "value",
12030
+ "included",
12031
+ "excluded",
12032
+ "total"
12033
+ ]),
12034
+ /** What a `value` publish does when nobody is included. Counts ignore this. */
12035
+ empty: _enum([
12036
+ "null",
12037
+ "omit",
12038
+ "offline"
12039
+ ])
12040
+ })
11885
12041
  ]);
11886
12042
  var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
11887
12043
  kind: literal("forward"),
@@ -11927,7 +12083,13 @@ var CompositionSchema = object({
11927
12083
  kind: literal("existing"),
11928
12084
  device: CompositionSourceRefSchema
11929
12085
  })]),
11930
- features: array(CompositionFeatureSchema).min(1).max(16)
12086
+ features: array(CompositionFeatureSchema).min(1).max(16),
12087
+ /**
12088
+ * Present when the block is a helper template. Absent on a free
12089
+ * composition (the room example, device 4603). The editor reads this,
12090
+ * never the compiled features.
12091
+ */
12092
+ helper: HelperSpecSchema.optional()
11931
12093
  }).superRefine((composition, ctx) => {
11932
12094
  const seen = /* @__PURE__ */ new Set();
11933
12095
  composition.features.forEach((feature, index) => {
@@ -18185,7 +18347,10 @@ var CompositionProblemSchema = object({
18185
18347
  "target-missing",
18186
18348
  "target-unreadable",
18187
18349
  "not-replaceable",
18188
- "native-capability-replace"
18350
+ "native-capability-replace",
18351
+ "mixed-quantity",
18352
+ "helper-mismatch",
18353
+ "member-constraint"
18189
18354
  ]),
18190
18355
  path: string$2(),
18191
18356
  message: string$2()
@@ -18206,7 +18371,8 @@ var CompositionUnavailableModeSchema = _enum([
18206
18371
  "null",
18207
18372
  "offline",
18208
18373
  "unknown",
18209
- "release"
18374
+ "release",
18375
+ "omit"
18210
18376
  ]);
18211
18377
  /**
18212
18378
  * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
@@ -18420,6 +18586,275 @@ Object.freeze({
18420
18586
  result: ["number", "null"]
18421
18587
  }
18422
18588
  });
18589
+ /**
18590
+ * One catalog row per base reading a group helper can build (D944 §3).
18591
+ * Data, not a form default written into the block.
18592
+ */
18593
+ var NOW = {
18594
+ fieldPath: "lastFetchedAt",
18595
+ expr: "now"
18596
+ };
18597
+ var CHANGED = {
18598
+ fieldPath: "lastChangedAt",
18599
+ expr: "now"
18600
+ };
18601
+ function numeric(input) {
18602
+ return {
18603
+ ...input,
18604
+ family: "numeric",
18605
+ deviceType: DeviceType.Sensor,
18606
+ memberDeviceType: DeviceType.Sensor,
18607
+ commandCaps: [],
18608
+ baseUnit: baseUnitOf(input.quantity),
18609
+ aggregates: NUMERIC_AGGREGATES,
18610
+ accept: [{
18611
+ cap: input.outputCap,
18612
+ fieldPath: input.outputField
18613
+ }]
18614
+ };
18615
+ }
18616
+ function binary(input) {
18617
+ return {
18618
+ ...input,
18619
+ family: "binary",
18620
+ deviceType: DeviceType.Sensor,
18621
+ memberDeviceType: DeviceType.Sensor,
18622
+ commandCaps: [],
18623
+ quantity: "none",
18624
+ baseUnit: void 0,
18625
+ aggregates: BINARY_AGGREGATES,
18626
+ empty: "offline"
18627
+ };
18628
+ }
18629
+ var GROUP_TEMPLATES = [
18630
+ numeric({
18631
+ id: "temperature",
18632
+ summary: "Mean, median, min, max or sum of temperatures. Offline and unknown members are left out, never read as 0 °C.",
18633
+ role: DeviceRole.TemperatureSensor,
18634
+ outputCap: "temperature-sensor",
18635
+ outputField: "celsius",
18636
+ quantity: "temperature",
18637
+ defaultAggregate: "mean",
18638
+ empty: "null",
18639
+ companions: [NOW]
18640
+ }),
18641
+ numeric({
18642
+ id: "humidity",
18643
+ summary: "Humidity in percent. Offline and unknown members are left out, never read as 0 %.",
18644
+ role: DeviceRole.HumiditySensor,
18645
+ outputCap: "humidity-sensor",
18646
+ outputField: "percent",
18647
+ quantity: "humidity",
18648
+ defaultAggregate: "mean",
18649
+ empty: "null",
18650
+ companions: [NOW]
18651
+ }),
18652
+ numeric({
18653
+ id: "pressure",
18654
+ summary: "Pressure in hectopascals. Offline and unknown members are left out, never read as 0 hPa.",
18655
+ role: DeviceRole.PressureSensor,
18656
+ outputCap: "pressure-sensor",
18657
+ outputField: "hpa",
18658
+ quantity: "pressure",
18659
+ defaultAggregate: "mean",
18660
+ empty: "null",
18661
+ companions: [NOW]
18662
+ }),
18663
+ numeric({
18664
+ id: "illuminance",
18665
+ summary: "Illuminance in lux. Offline and unknown members are left out, never read as 0 lx.",
18666
+ role: DeviceRole.AmbientLightSensor,
18667
+ outputCap: "ambient-light-sensor",
18668
+ outputField: "lux",
18669
+ quantity: "illuminance",
18670
+ defaultAggregate: "mean",
18671
+ empty: "null",
18672
+ companions: [NOW]
18673
+ }),
18674
+ numeric({
18675
+ id: "power",
18676
+ summary: "Power in watts, summed by default. Nobody reporting omits the key rather than writing 0 W.",
18677
+ role: DeviceRole.PowerSensor,
18678
+ outputCap: "power-meter",
18679
+ outputField: "watts",
18680
+ quantity: "power",
18681
+ defaultAggregate: "sum",
18682
+ empty: "omit",
18683
+ companions: [NOW]
18684
+ }),
18685
+ numeric({
18686
+ id: "energy",
18687
+ summary: "Energy in kilowatt-hours, summed by default. Nobody reporting omits the key rather than writing 0 kWh.",
18688
+ role: DeviceRole.EnergySensor,
18689
+ outputCap: "power-meter",
18690
+ outputField: "kwhTotal",
18691
+ quantity: "energy",
18692
+ defaultAggregate: "sum",
18693
+ empty: "omit",
18694
+ companions: [NOW]
18695
+ }),
18696
+ numeric({
18697
+ id: "battery",
18698
+ summary: "Battery charge in percent. Offline and unknown members are left out, never read as 0 %.",
18699
+ role: DeviceRole.BatterySensor,
18700
+ outputCap: "battery",
18701
+ outputField: "percentage",
18702
+ quantity: "percentage",
18703
+ defaultAggregate: "mean",
18704
+ empty: "null",
18705
+ companions: [
18706
+ {
18707
+ fieldPath: "charging",
18708
+ expr: "\"unknown\""
18709
+ },
18710
+ {
18711
+ fieldPath: "sleeping",
18712
+ expr: "false"
18713
+ },
18714
+ {
18715
+ fieldPath: "lastUpdated",
18716
+ expr: "now"
18717
+ }
18718
+ ]
18719
+ }),
18720
+ numeric({
18721
+ id: "numeric",
18722
+ summary: "A numeric reading in the members’ shared quantity. Mixed quantities are refused. Nobody reporting publishes null, not 0.",
18723
+ role: DeviceRole.NumericSensor,
18724
+ outputCap: "numeric-sensor",
18725
+ outputField: "value",
18726
+ quantity: "device",
18727
+ defaultAggregate: "mean",
18728
+ empty: "null",
18729
+ companions: [NOW]
18730
+ }),
18731
+ binary({
18732
+ id: "motion",
18733
+ summary: "Any or all motion. Offline and unknown members are left out, never read as off. Nobody reporting takes the device offline.",
18734
+ role: DeviceRole.MotionSensor,
18735
+ outputCap: "motion",
18736
+ outputField: "detected",
18737
+ defaultAggregate: "any",
18738
+ accept: [{
18739
+ cap: "motion",
18740
+ fieldPath: "detected"
18741
+ }],
18742
+ companions: [{
18743
+ fieldPath: "lastDetectedAt",
18744
+ expr: "null"
18745
+ }, {
18746
+ fieldPath: "autoClearAfterMs",
18747
+ expr: "null"
18748
+ }]
18749
+ }),
18750
+ binary({
18751
+ id: "contact",
18752
+ summary: "Any or all open contacts. Offline and unknown members are left out, never read as closed.",
18753
+ role: DeviceRole.ContactSensor,
18754
+ outputCap: "contact",
18755
+ outputField: "entryOpen",
18756
+ defaultAggregate: "any",
18757
+ accept: [{
18758
+ cap: "contact",
18759
+ fieldPath: "entryOpen"
18760
+ }],
18761
+ companions: [CHANGED]
18762
+ }),
18763
+ binary({
18764
+ id: "leak",
18765
+ summary: "Any or all leak detections. Offline and unknown members are left out, never read as dry.",
18766
+ role: DeviceRole.LeakSensor,
18767
+ outputCap: "flood",
18768
+ outputField: "flooded",
18769
+ defaultAggregate: "any",
18770
+ accept: [{
18771
+ cap: "flood",
18772
+ fieldPath: "flooded"
18773
+ }],
18774
+ companions: [CHANGED]
18775
+ }),
18776
+ binary({
18777
+ id: "smoke",
18778
+ summary: "Any or all smoke detections. Offline and unknown members are left out, never read as clear.",
18779
+ role: DeviceRole.SmokeSensor,
18780
+ outputCap: "smoke",
18781
+ outputField: "detected",
18782
+ defaultAggregate: "any",
18783
+ accept: [{
18784
+ cap: "smoke",
18785
+ fieldPath: "detected"
18786
+ }],
18787
+ companions: [CHANGED]
18788
+ }),
18789
+ binary({
18790
+ id: "occupancy",
18791
+ summary: "Any or all of motion or binary sensors, published as a binary. Presence zones are not aggregated. Nobody reporting is offline, not off.",
18792
+ role: DeviceRole.BinarySensor,
18793
+ outputCap: "binary",
18794
+ outputField: "on",
18795
+ defaultAggregate: "any",
18796
+ accept: [{
18797
+ cap: "motion",
18798
+ fieldPath: "detected"
18799
+ }, {
18800
+ cap: "binary",
18801
+ fieldPath: "on"
18802
+ }],
18803
+ companions: [CHANGED]
18804
+ }),
18805
+ binary({
18806
+ id: "binary",
18807
+ summary: "Any or all generic binary sensors. Offline and unknown members are left out, never read as off.",
18808
+ role: DeviceRole.BinarySensor,
18809
+ outputCap: "binary",
18810
+ outputField: "on",
18811
+ defaultAggregate: "any",
18812
+ accept: [{
18813
+ cap: "binary",
18814
+ fieldPath: "on"
18815
+ }],
18816
+ companions: [CHANGED]
18817
+ }),
18818
+ actuator({
18819
+ id: "switch",
18820
+ summary: "On when at least one switch is on, or when all of them are. Offline and unknown members are left out of that decision, never read as off, and still receive the command.",
18821
+ deviceType: DeviceType.Switch,
18822
+ outputCap: "switch",
18823
+ commandCaps: ["switch"]
18824
+ }),
18825
+ actuator({
18826
+ id: "light",
18827
+ summary: "On when at least one light is on, or when all of them are. Brightness and colour are offered only when every member has them. A command is not sent to a member that lacks it.",
18828
+ deviceType: DeviceType.Light,
18829
+ outputCap: "switch",
18830
+ commandCaps: [
18831
+ "switch",
18832
+ "brightness",
18833
+ "color"
18834
+ ]
18835
+ })
18836
+ ];
18837
+ function actuator(input) {
18838
+ return {
18839
+ ...input,
18840
+ family: "actuator",
18841
+ role: void 0,
18842
+ memberDeviceType: input.deviceType,
18843
+ outputField: "on",
18844
+ quantity: "none",
18845
+ baseUnit: void 0,
18846
+ aggregates: BINARY_AGGREGATES,
18847
+ defaultAggregate: "any",
18848
+ empty: "offline",
18849
+ accept: [{
18850
+ cap: "switch",
18851
+ fieldPath: "on"
18852
+ }],
18853
+ companions: [CHANGED]
18854
+ };
18855
+ }
18856
+ new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
18857
+ DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
18423
18858
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
18424
18859
  /**
18425
18860
  * What a composition may BUILD on a device type (G2, D676): the caps a new
@@ -45239,6 +45674,8 @@ var NcSystemEventKindSchema = _enum([
45239
45674
  "detection-blind",
45240
45675
  "motion-noisy",
45241
45676
  "motion-calm",
45677
+ "package-llm-silent",
45678
+ "package-llm-answered",
45242
45679
  "recording-stalled",
45243
45680
  "recording-healthy",
45244
45681
  "recording-degraded",
@@ -56330,6 +56767,28 @@ var PackageVersionInfoSchema = object({
56330
56767
  deprecated: string$2().optional(),
56331
56768
  distTags: array(string$2()).readonly()
56332
56769
  });
56770
+ /**
56771
+ * One published document. `ok` is markdown from the registry. `missing` means
56772
+ * the registry answered and the file was not there. `error` is a registry or
56773
+ * tarball failure — never an empty string standing in for either.
56774
+ */
56775
+ var PackageDocBodySchema = discriminatedUnion("status", [
56776
+ object({
56777
+ status: literal("ok"),
56778
+ markdown: string$2()
56779
+ }),
56780
+ object({ status: literal("missing") }),
56781
+ object({
56782
+ status: literal("error"),
56783
+ message: string$2()
56784
+ })
56785
+ ]);
56786
+ var PackageDocsSchema = object({
56787
+ name: string$2(),
56788
+ version: string$2(),
56789
+ readme: PackageDocBodySchema,
56790
+ changelog: PackageDocBodySchema
56791
+ });
56333
56792
  var SearchResultSchema = object({
56334
56793
  name: string$2(),
56335
56794
  version: string$2(),
@@ -56548,7 +57007,10 @@ method(object({ nodeId: string$2().optional() }).optional(), array(AddonListItem
56548
57007
  }), object({ success: literal(true) }), {
56549
57008
  kind: "mutation",
56550
57009
  auth: "admin"
56551
- }), method(object({ name: string$2() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string$2() }), RestartAddonResultSchema, {
57010
+ }), method(object({ name: string$2() }), array(PackageVersionInfoSchema).readonly()), method(object({
57011
+ name: string$2().min(1),
57012
+ version: string$2().min(1).optional()
57013
+ }), PackageDocsSchema, { auth: "admin" }), method(object({ addonId: string$2() }), RestartAddonResultSchema, {
56552
57014
  kind: "mutation",
56553
57015
  auth: "admin"
56554
57016
  }), method(object({ packageName: string$2() }), object({ success: literal(true) }), {
@@ -59884,6 +60346,88 @@ var gasCapability = {
59884
60346
  * whether persisting is worth a SQLite commit. */
59885
60347
  volatileStateFields: ["lastChangedAt"]
59886
60348
  };
60349
+ var GroupCommandsSchema = object({
60350
+ /** One row per member that has been commanded. A later command replaces that member's row. */
60351
+ results: array(object({
60352
+ deviceId: number().int().nonnegative(),
60353
+ cap: string$2().min(1),
60354
+ method: string$2().min(1),
60355
+ ok: boolean(),
60356
+ error: string$2().optional(),
60357
+ /** Ms epoch of the attempt. */
60358
+ at: number()
60359
+ })) });
60360
+ var groupCommandsCapability = {
60361
+ name: "group-commands",
60362
+ scope: "device",
60363
+ deviceNative: true,
60364
+ mode: "singleton",
60365
+ deviceTypes: [DeviceType.Switch, DeviceType.Light],
60366
+ methods: {},
60367
+ status: {
60368
+ schema: GroupCommandsSchema,
60369
+ kind: "push"
60370
+ },
60371
+ runtimeState: GroupCommandsSchema,
60372
+ /**
60373
+ * Runtime-state durability: **session** — the last attempt. A restored
60374
+ * failure would outlive the command that caused it.
60375
+ *
60376
+ * See `RuntimeStateDurability`. Enforced by
60377
+ * `scripts/check-runtime-state-durability.ts`.
60378
+ */
60379
+ durability: "session",
60380
+ history: "none"
60381
+ };
60382
+ /**
60383
+ * How many members of a helper group answered (D944). Counts are integers,
60384
+ * not measurements: zero included is a real count, never a stand-in reading.
60385
+ * The reading itself stays on the output capability. History is none — the
60386
+ * series that later statistics read is the output cap's own history block.
60387
+ */
60388
+ var GroupStatusSchema = object({
60389
+ /** Members whose current value entered the aggregate. */
60390
+ included: number().int().nonnegative().meta(readingMeta("count", {
60391
+ precision: 0,
60392
+ label: "Reporting"
60393
+ })),
60394
+ /** Members left out: offline, unknown, or not a finite reading (D393). */
60395
+ excluded: number().int().nonnegative().meta(readingMeta("count", {
60396
+ precision: 0,
60397
+ label: "Excluded"
60398
+ })),
60399
+ /** `included` + `excluded`. */
60400
+ total: number().int().nonnegative().meta(readingMeta("count", {
60401
+ precision: 0,
60402
+ label: "Members"
60403
+ }))
60404
+ });
60405
+ var groupStatusCapability = {
60406
+ name: "group-status",
60407
+ scope: "device",
60408
+ deviceNative: true,
60409
+ mode: "singleton",
60410
+ deviceTypes: [
60411
+ DeviceType.Sensor,
60412
+ DeviceType.Switch,
60413
+ DeviceType.Light
60414
+ ],
60415
+ methods: {},
60416
+ status: {
60417
+ schema: GroupStatusSchema,
60418
+ kind: "push"
60419
+ },
60420
+ runtimeState: GroupStatusSchema,
60421
+ /**
60422
+ * Runtime-state durability: **session** — recomputed from members on the
60423
+ * next source event; a restored count would outlive the readings it named.
60424
+ *
60425
+ * See `RuntimeStateDurability`. Enforced by
60426
+ * `scripts/check-runtime-state-durability.ts`.
60427
+ */
60428
+ durability: "session",
60429
+ history: "none"
60430
+ };
59887
60431
  var HeatingValveStatusSchema = object({
59888
60432
  /** Radiator valve opening 0 (closed) .. 100 (open). Null = not yet known. */
59889
60433
  position: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
@@ -61653,12 +62197,18 @@ var motionCapability = {
61653
62197
  methods: {
61654
62198
  /**
61655
62199
  * Pull the current motion state synchronously. Convenience shortcut
61656
- * for consumers that don't need the full status object; equivalent
61657
- * to `getStatus()?.detected ?? false`. Will likely be folded into
61658
- * `getStatus` once the auto-injected status surface lands in every
61659
- * consumer.
62200
+ * for consumers that don't need the full status object. A composed
62201
+ * group answers from its slice (D944): unknown throws, it is not off.
61660
62202
  */
61661
62203
  isDetected: method(object({ deviceId: number() }), boolean()) },
62204
+ /**
62205
+ * A composed motion group serves `isDetected` from `detected` (D944).
62206
+ * Without this the cap stays uncomposable (`has-commands`).
62207
+ */
62208
+ composedMethods: { isDetected: {
62209
+ kind: "slice",
62210
+ field: "detected"
62211
+ } },
61662
62212
  events: {
61663
62213
  /**
61664
62214
  * Fires every time the runner transitions a camera between
@@ -69466,6 +70016,8 @@ var DEVICE_LOCAL_STATE_CAPS = {
69466
70016
  featureProbe: featureProbeCapability,
69467
70017
  flood: floodCapability,
69468
70018
  gas: gasCapability,
70019
+ groupCommands: groupCommandsCapability,
70020
+ groupStatus: groupStatusCapability,
69469
70021
  heatingValve: heatingValveCapability,
69470
70022
  humidifier: humidifierCapability,
69471
70023
  humiditySensor: humiditySensorCapability,
@@ -70842,6 +71394,13 @@ Object.freeze({
70842
71394
  access: "view",
70843
71395
  role: "admin"
70844
71396
  },
71397
+ "addons.getPackageDocs": {
71398
+ capName: "addons",
71399
+ capScope: "system",
71400
+ addonId: null,
71401
+ access: "view",
71402
+ role: "admin"
71403
+ },
70845
71404
  "addons.getVersions": {
70846
71405
  capName: "addons",
70847
71406
  capScope: "system",
@@ -73950,6 +74509,20 @@ Object.freeze({
73950
74509
  access: "view",
73951
74510
  role: "viewer"
73952
74511
  },
74512
+ "groupCommands.getStatus": {
74513
+ capName: "group-commands",
74514
+ capScope: "device",
74515
+ addonId: null,
74516
+ access: "view",
74517
+ role: "viewer"
74518
+ },
74519
+ "groupStatus.getStatus": {
74520
+ capName: "group-status",
74521
+ capScope: "device",
74522
+ addonId: null,
74523
+ access: "view",
74524
+ role: "viewer"
74525
+ },
73953
74526
  "heatingValve.getStatus": {
73954
74527
  capName: "heating-valve",
73955
74528
  capScope: "device",
@@ -83772,6 +84345,37 @@ var SENSOR_CAP_VOCABULARY = {
83772
84345
  icon: "flame"
83773
84346
  }]
83774
84347
  },
84348
+ "group-commands": {
84349
+ scope: "device",
84350
+ noun: t("Group command", "Comando di gruppo")
84351
+ },
84352
+ "group-status": {
84353
+ scope: "device",
84354
+ noun: t("Group", "Gruppo"),
84355
+ state: [
84356
+ {
84357
+ kind: "numeric",
84358
+ field: "included",
84359
+ quantity: t("the members reporting", "i membri che rispondono"),
84360
+ label: t("Reporting", "Rispondono"),
84361
+ icon: "users"
84362
+ },
84363
+ {
84364
+ kind: "numeric",
84365
+ field: "excluded",
84366
+ quantity: t("the members left out", "i membri esclusi"),
84367
+ label: t("Excluded", "Esclusi"),
84368
+ icon: "users"
84369
+ },
84370
+ {
84371
+ kind: "numeric",
84372
+ field: "total",
84373
+ quantity: t("the member count", "il numero di membri"),
84374
+ label: t("Members", "Membri"),
84375
+ icon: "sigma"
84376
+ }
84377
+ ]
84378
+ },
83775
84379
  "humidity-sensor": {
83776
84380
  scope: "device",
83777
84381
  noun: t("Humidity", "Umidità"),
@@ -85237,6 +85841,16 @@ Object.freeze({
85237
85841
  form: "single",
85238
85842
  optional: false
85239
85843
  }],
85844
+ "groupCommands.getStatus": [{
85845
+ name: "deviceId",
85846
+ form: "single",
85847
+ optional: false
85848
+ }],
85849
+ "groupStatus.getStatus": [{
85850
+ name: "deviceId",
85851
+ form: "single",
85852
+ optional: false
85853
+ }],
85240
85854
  "heatingValve.getStatus": [{
85241
85855
  name: "deviceId",
85242
85856
  form: "single",
@@ -87221,6 +87835,8 @@ new Map(Object.entries({
87221
87835
  "detection-blind": "device-events",
87222
87836
  "motion-noisy": "device-events",
87223
87837
  "motion-calm": "device-events",
87838
+ "package-llm-silent": "device-events",
87839
+ "package-llm-answered": "device-events",
87224
87840
  "recording-stalled": "device-events",
87225
87841
  "recording-healthy": "device-events",
87226
87842
  "recording-degraded": "device-events",
@@ -87304,6 +87920,12 @@ var NC_SYSTEM_EVENT_KIND_GROUP_SPECS = [
87304
87920
  label: "Motion without a subject",
87305
87921
  kinds: ["motion-noisy", "motion-calm"]
87306
87922
  },
87923
+ {
87924
+ id: "package-llm",
87925
+ family: "device-events",
87926
+ label: "Package confirm",
87927
+ kinds: ["package-llm-silent", "package-llm-answered"]
87928
+ },
87307
87929
  {
87308
87930
  id: "recording",
87309
87931
  family: "device-events",
@@ -87485,6 +88107,8 @@ var DEVICE_EVENTS = [
87485
88107
  "detection-blind",
87486
88108
  "motion-noisy",
87487
88109
  "motion-calm",
88110
+ "package-llm-silent",
88111
+ "package-llm-answered",
87488
88112
  "recording-stalled",
87489
88113
  "recording-healthy",
87490
88114
  "recording-degraded",
@@ -87753,6 +88377,15 @@ new Map([
87753
88377
  snapshotLabel: "Cameras with constant motion now",
87754
88378
  snapshotSection: null
87755
88379
  },
88380
+ {
88381
+ id: "package-llm-confirm",
88382
+ family: "device-events",
88383
+ label: "Package confirm (not answering / answering again)",
88384
+ bad: ["package-llm-silent"],
88385
+ recovery: ["package-llm-answered"],
88386
+ snapshotLabel: "Cameras whose package confirm is not answering",
88387
+ snapshotSection: null
88388
+ },
87756
88389
  {
87757
88390
  id: "recording-health",
87758
88391
  family: "device-events",
@@ -196883,6 +197516,7 @@ var StubMatterController = class {
196883
197516
  async removeNode(_nodeId) {
196884
197517
  throw new MatterControllerNotWiredError("removeNode");
196885
197518
  }
197519
+ async syncSubscriptions() {}
196886
197520
  };
196887
197521
  /**
196888
197522
  * Factory seam (stub default): build a {@link StubMatterController}.
@@ -197055,7 +197689,13 @@ var MatterAttributeId = {
197055
197689
  /** ElectricalPowerMeasurement.ActivePower (int64, mW). */
197056
197690
  ActivePower: 8,
197057
197691
  /** ElectricalEnergyMeasurement.CumulativeEnergyImported (energy struct, mWh). */
197058
- CumulativeEnergyImported: 1
197692
+ CumulativeEnergyImported: 1,
197693
+ /** DoorLock.LockState (enum: 0 NotFullyLocked, 1 Locked, 2 Unlocked, 3 Unlatched). */
197694
+ LockState: 0,
197695
+ /** DoorLock.DoorState (enum; 2 = DoorJammed). Present only with the door sensor. */
197696
+ DoorState: 3,
197697
+ /** DoorLock.RequirePinForRemoteOperation (bool). */
197698
+ RequirePinForRemoteOperation: 51
197059
197699
  };
197060
197700
  /** Thermostat.SystemMode enum → CamStack HvacMode (climate-control). */
197061
197701
  var SYSTEM_MODE_TO_HVAC = new Map([
@@ -197087,8 +197727,11 @@ function asBoolean(value) {
197087
197727
  * matter.js may surface a `MeasuredValue` either as a plain scaled int or wrapped
197088
197728
  * in an energy-measurement struct (`{ energy }`). Pull the numeric payload out.
197089
197729
  */
197730
+ function isRecord$4(value) {
197731
+ return typeof value === "object" && value !== null;
197732
+ }
197090
197733
  function asEnergyMilliWattHours(value) {
197091
- if (typeof value === "object" && value !== null && "energy" in value) return asNumber(value.energy);
197734
+ if (isRecord$4(value) && "energy" in value) return asNumber(value["energy"]);
197092
197735
  return asNumber(value);
197093
197736
  }
197094
197737
  /**
@@ -197110,9 +197753,77 @@ function decodeMatterAttribute(input) {
197110
197753
  case MatterClusterId.Thermostat: return decodeThermostat(attributeId, value);
197111
197754
  case MatterClusterId.ElectricalPowerMeasurement: return decodeElectricalPower(attributeId, value);
197112
197755
  case MatterClusterId.ElectricalEnergyMeasurement: return decodeElectricalEnergy(attributeId, value);
197756
+ case MatterClusterId.DoorLock: return decodeDoorLock(attributeId, value);
197113
197757
  default: return null;
197114
197758
  }
197115
197759
  }
197760
+ /** LockState enum → cap state. NotFullyLocked has no cap value of its own. */
197761
+ var LOCK_STATE_TO_CAP = new Map([
197762
+ [0, {
197763
+ state: "jammed",
197764
+ detail: "not-fully-locked"
197765
+ }],
197766
+ [1, { state: "locked" }],
197767
+ [2, { state: "unlocked" }],
197768
+ [3, {
197769
+ state: "unlocked",
197770
+ detail: "unlatched"
197771
+ }]
197772
+ ]);
197773
+ /** DoorState enum → a detail. Only DoorJammed also moves the lock cap. */
197774
+ var DOOR_STATE_DETAIL = new Map([
197775
+ [0, "door-open"],
197776
+ [1, "door-closed"],
197777
+ [2, "door-jammed"],
197778
+ [3, "door-forced-open"],
197779
+ [4, "door-unspecified"],
197780
+ [5, "door-ajar"]
197781
+ ]);
197782
+ function decodeDoorLock(attributeId, value) {
197783
+ if (attributeId === MatterAttributeId.LockState) {
197784
+ const raw = asNumber(value);
197785
+ if (raw === null) return null;
197786
+ const mapped = LOCK_STATE_TO_CAP.get(raw);
197787
+ if (mapped === void 0) return null;
197788
+ return {
197789
+ cap: "lock-control",
197790
+ patch: {
197791
+ state: mapped.state,
197792
+ lastChangedAt: Date.now()
197793
+ },
197794
+ ...mapped.detail !== void 0 ? { detail: mapped.detail } : {}
197795
+ };
197796
+ }
197797
+ if (attributeId === MatterAttributeId.DoorState) {
197798
+ const raw = asNumber(value);
197799
+ if (raw === null) return null;
197800
+ const detail = DOOR_STATE_DETAIL.get(raw);
197801
+ if (detail === void 0) return null;
197802
+ if (detail === "door-jammed") return {
197803
+ cap: "lock-control",
197804
+ patch: {
197805
+ state: "jammed",
197806
+ lastChangedAt: Date.now()
197807
+ },
197808
+ detail
197809
+ };
197810
+ return {
197811
+ cap: "lock-control",
197812
+ patch: {},
197813
+ detail
197814
+ };
197815
+ }
197816
+ if (attributeId === MatterAttributeId.RequirePinForRemoteOperation) {
197817
+ const required = asBoolean(value);
197818
+ if (required === null) return null;
197819
+ return {
197820
+ cap: "lock-control",
197821
+ patch: {},
197822
+ detail: required ? "pin-required" : "pin-not-required"
197823
+ };
197824
+ }
197825
+ return null;
197826
+ }
197116
197827
  function decodeOnOff(attributeId, value) {
197117
197828
  if (attributeId !== MatterAttributeId.OnOff) return null;
197118
197829
  const on = asBoolean(value);
@@ -197336,7 +198047,10 @@ var ATTR_NAME_TO_ID = new Map([
197336
198047
  ["voltage", MatterAttributeId.Voltage],
197337
198048
  ["activeCurrent", MatterAttributeId.ActiveCurrent],
197338
198049
  ["activePower", MatterAttributeId.ActivePower],
197339
- ["cumulativeEnergyImported", MatterAttributeId.CumulativeEnergyImported]
198050
+ ["cumulativeEnergyImported", MatterAttributeId.CumulativeEnergyImported],
198051
+ ["lockState", MatterAttributeId.LockState],
198052
+ ["doorState", MatterAttributeId.DoorState],
198053
+ ["requirePinForRemoteOperation", MatterAttributeId.RequirePinForRemoteOperation]
197340
198054
  ]);
197341
198055
  /** clusterId → matter.js behavior id (camelCase) for outbound command clusters. */
197342
198056
  var CLUSTER_ID_TO_BEHAVIOR_ID = new Map([
@@ -197348,14 +198062,14 @@ var CLUSTER_ID_TO_BEHAVIOR_ID = new Map([
197348
198062
  [258, "windowCovering"]
197349
198063
  ]);
197350
198064
  /** True when `value` is a non-null object (cast-free record narrowing). */
197351
- function isRecord$1(value) {
198065
+ function isRecord$3(value) {
197352
198066
  return typeof value === "object" && value !== null;
197353
198067
  }
197354
198068
  /** Read a finite numeric cluster id from a `Behavior.Type`'s `.cluster.id`. */
197355
198069
  function clusterIdOf(behaviorType) {
197356
- if (!isRecord$1(behaviorType)) return null;
198070
+ if (!isRecord$3(behaviorType)) return null;
197357
198071
  const cluster = behaviorType["cluster"];
197358
- if (!isRecord$1(cluster)) return null;
198072
+ if (!isRecord$3(cluster)) return null;
197359
198073
  const id = cluster["id"];
197360
198074
  if (typeof id === "number" && Number.isFinite(id)) return id;
197361
198075
  if (typeof id === "bigint") return Number(id);
@@ -197366,13 +198080,13 @@ function clusterIdOf(behaviorType) {
197366
198080
  * the matter.js `Endpoint` interop at one typed boundary.
197367
198081
  */
197368
198082
  function asDynamicEndpoint(value) {
197369
- if (!isRecord$1(value)) return null;
198083
+ if (!isRecord$3(value)) return null;
197370
198084
  const number = value["number"];
197371
198085
  const behaviors = value["behaviors"];
197372
198086
  const parts = value["parts"];
197373
198087
  const stateOf = value["stateOf"];
197374
198088
  const commandsOf = value["commandsOf"];
197375
- if (typeof number === "number" && isRecord$1(behaviors) && "supported" in behaviors && typeof stateOf === "function" && typeof commandsOf === "function" && isIterable$1(parts)) return {
198089
+ if (typeof number === "number" && isRecord$3(behaviors) && "supported" in behaviors && typeof stateOf === "function" && typeof commandsOf === "function" && isIterable$2(parts)) return {
197376
198090
  number,
197377
198091
  behaviors: { supported: behaviors["supported"] },
197378
198092
  parts: mapIterable(parts, asDynamicEndpointOrThrow),
@@ -197396,7 +198110,7 @@ async function invokeNodeCommand(input) {
197396
198110
  const behaviorId = CLUSTER_ID_TO_BEHAVIOR_ID.get(clusterId);
197397
198111
  if (behaviorId === void 0) throw new Error(`matter-broker: cluster 0x${clusterId.toString(16)} has no command binding`);
197398
198112
  const commands = target.commandsOf(behaviorId);
197399
- if (!isRecord$1(commands)) throw new Error(`matter-broker: endpoint ${endpointId} has no '${behaviorId}' commands`);
198113
+ if (!isRecord$3(commands)) throw new Error(`matter-broker: endpoint ${endpointId} has no '${behaviorId}' commands`);
197400
198114
  const command = commands[commandName];
197401
198115
  const fn = asCommandFn(command);
197402
198116
  if (fn === null) throw new Error(`matter-broker: command '${commandName}' unavailable on '${behaviorId}'`);
@@ -197421,8 +198135,8 @@ function asDynamicEndpointOrThrow(value) {
197421
198135
  if (ep === null) throw new TypeError("matter-broker: unexpected non-endpoint child part");
197422
198136
  return ep;
197423
198137
  }
197424
- function isIterable$1(value) {
197425
- return isRecord$1(value) && typeof Reflect.get(value, Symbol.iterator) === "function";
198138
+ function isIterable$2(value) {
198139
+ return isRecord$3(value) && typeof Reflect.get(value, Symbol.iterator) === "function";
197426
198140
  }
197427
198141
  function* mapIterable(it, fn) {
197428
198142
  for (const v of it) yield fn(v);
@@ -197443,7 +198157,7 @@ function collectEndpoint(endpoint, out) {
197443
198157
  const supported = endpoint.behaviors.supported;
197444
198158
  const clusterIds = [];
197445
198159
  const attributes = [];
197446
- if (isRecord$1(supported)) for (const [behaviorId, behaviorType] of Object.entries(supported)) {
198160
+ if (isRecord$3(supported)) for (const [behaviorId, behaviorType] of Object.entries(supported)) {
197447
198161
  const clusterId = clusterIdOf(behaviorType);
197448
198162
  if (clusterId === null) continue;
197449
198163
  clusterIds.push(clusterId);
@@ -197463,7 +198177,7 @@ function readBehaviorAttributes(endpoint, behaviorId, clusterId, attributes) {
197463
198177
  } catch {
197464
198178
  return;
197465
198179
  }
197466
- if (!isRecord$1(struct)) return;
198180
+ if (!isRecord$3(struct)) return;
197467
198181
  for (const [attrName, value] of Object.entries(struct)) {
197468
198182
  const attributeId = ATTR_NAME_TO_ID.get(attrName);
197469
198183
  if (attributeId === void 0) continue;
@@ -197475,35 +198189,413 @@ function readBehaviorAttributes(endpoint, behaviorId, clusterId, attributes) {
197475
198189
  }
197476
198190
  }
197477
198191
  //#endregion
198192
+ //#region src/matter-commission-progress.ts
198193
+ /**
198194
+ * Commissioning progress the admin UI polls, plus the named failures a share
198195
+ * code can produce. Pure — no matter.js import.
198196
+ *
198197
+ * Phases follow the on-network share path: discover the device, establish
198198
+ * PASE, then commission onto this fabric. A missing or malformed code never
198199
+ * becomes a later phase.
198200
+ */
198201
+ var CommissionPhase = {
198202
+ Idle: "idle",
198203
+ Discovering: "discovering",
198204
+ Pase: "pase",
198205
+ Commissioning: "commissioning",
198206
+ Done: "done",
198207
+ Failed: "failed"
198208
+ };
198209
+ var CommissionFailureCode = {
198210
+ CodeExpired: "code-expired",
198211
+ DeviceNotFound: "device-not-found",
198212
+ WrongCode: "wrong-code",
198213
+ InvalidCode: "invalid-code",
198214
+ Unknown: "unknown"
198215
+ };
198216
+ var IDLE_COMMISSION_PROGRESS = {
198217
+ phase: CommissionPhase.Idle,
198218
+ failure: null,
198219
+ message: null
198220
+ };
198221
+ /** Stable prefix the UI matches. The human text follows the colon. */
198222
+ function commissionFailureMessage(code, detail) {
198223
+ return `matter-commission:${code}: ${detail}`;
198224
+ }
198225
+ /**
198226
+ * Map a controller or matter.js error onto a named failure. Order matters:
198227
+ * an expired commissioning window is not "device not found", and a rejected
198228
+ * passcode is not a network miss.
198229
+ */
198230
+ function classifyCommissionError(message) {
198231
+ const text = message.toLowerCase();
198232
+ if (text.includes("invalid pairing code") || text.includes("11 or 21")) return CommissionFailureCode.InvalidCode;
198233
+ if (text.includes("expired") || text.includes("commissioning window") || text.includes("window closed")) return CommissionFailureCode.CodeExpired;
198234
+ if (text.includes("passcode") || text.includes("wrong code") || text.includes("pase") || text.includes("authentication failed") || text.includes("status 0x7e") || text.includes("invalid code")) return CommissionFailureCode.WrongCode;
198235
+ if (text.includes("not found") || text.includes("no device") || text.includes("no commissionable") || text.includes("discovery") || text.includes("timed out") || text.includes("timeout") || text.includes("unreachable")) return CommissionFailureCode.DeviceNotFound;
198236
+ return CommissionFailureCode.Unknown;
198237
+ }
198238
+ //#endregion
198239
+ //#region src/matter-subscription.ts
198240
+ /**
198241
+ * Per-node Matter attribute subscriptions. The matter.js call itself lives in
198242
+ * {@link import('./matter-peer-subscribe.js')}; this module owns the lifecycle:
198243
+ * open, resubscribe after the session drops or the node returns, and a bounded
198244
+ * backoff. A report whose value is missing is not a value (D393) and is dropped.
198245
+ */
198246
+ /** First wait after a dropped subscription. */
198247
+ var SUBSCRIPTION_BACKOFF_INITIAL_MS = 1e3;
198248
+ /** Cap so a sleepy Thread node cannot be hammered. */
198249
+ var SUBSCRIPTION_BACKOFF_MAX_MS = 6e4;
198250
+ /**
198251
+ * One supervisor per fabric. `sync` is safe to call on start, after commission,
198252
+ * and on the reconcile timer (the backstop when a lifecycle event was missed).
198253
+ */
198254
+ var MatterSubscriptionSupervisor = class {
198255
+ #brokerId;
198256
+ #logger;
198257
+ #resolveDeviceId;
198258
+ #onReport;
198259
+ #slots = /* @__PURE__ */ new Map();
198260
+ #stopped = false;
198261
+ constructor(options) {
198262
+ this.#brokerId = options.brokerId;
198263
+ this.#logger = options.logger;
198264
+ this.#resolveDeviceId = options.resolveDeviceId;
198265
+ this.#onReport = options.onReport;
198266
+ }
198267
+ /** How many times `subscribe` has been entered for a node (tests). */
198268
+ subscribeAttempts(nodeId) {
198269
+ return this.#slots.get(nodeId)?.generation ?? 0;
198270
+ }
198271
+ phase(nodeId) {
198272
+ return this.#slots.get(nodeId)?.phase ?? null;
198273
+ }
198274
+ async sync(peers) {
198275
+ if (this.#stopped) return;
198276
+ const live = /* @__PURE__ */ new Set();
198277
+ for (const peer of peers) {
198278
+ live.add(peer.nodeId);
198279
+ const slot = this.#slots.get(peer.nodeId);
198280
+ if (slot === void 0) {
198281
+ await this.#open(peer);
198282
+ continue;
198283
+ }
198284
+ slot.peer = peer;
198285
+ if (slot.phase !== "subscribed" && peer.online() && slot.phase !== "resubscribing") {
198286
+ slot.attempt = 0;
198287
+ await this.#open(peer);
198288
+ }
198289
+ }
198290
+ for (const [nodeId, slot] of this.#slots) {
198291
+ if (live.has(nodeId)) continue;
198292
+ await this.#drop(slot);
198293
+ this.#slots.delete(nodeId);
198294
+ }
198295
+ }
198296
+ async stop() {
198297
+ this.#stopped = true;
198298
+ for (const slot of this.#slots.values()) await this.#drop(slot);
198299
+ this.#slots.clear();
198300
+ }
198301
+ async #open(peer) {
198302
+ if (this.#stopped) return;
198303
+ const existing = this.#slots.get(peer.nodeId);
198304
+ if (existing?.phase === "subscribed" && existing.subscription !== null) return;
198305
+ if (existing?.timer !== null && existing?.timer !== void 0) {
198306
+ clearTimeout(existing.timer);
198307
+ existing.timer = null;
198308
+ }
198309
+ const generation = (existing?.generation ?? 0) + 1;
198310
+ const slot = existing ?? this.#fresh(peer);
198311
+ slot.peer = peer;
198312
+ slot.generation = generation;
198313
+ slot.phase = "resubscribing";
198314
+ this.#slots.set(peer.nodeId, slot);
198315
+ this.#listen(slot);
198316
+ this.#log(peer.nodeId, "resubscribing");
198317
+ let subscription;
198318
+ try {
198319
+ subscription = await peer.subscribe({
198320
+ onReport: (report) => {
198321
+ if (report.value === void 0 || report.value === null) return;
198322
+ this.#onReport(report);
198323
+ },
198324
+ onClosed: () => {
198325
+ const current = this.#slots.get(peer.nodeId);
198326
+ if (current === void 0 || current.generation !== generation) return;
198327
+ this.#onClosed(peer.nodeId);
198328
+ }
198329
+ });
198330
+ } catch (err) {
198331
+ this.#log(peer.nodeId, "subscribe-failed", { error: errMsg(err) });
198332
+ const current = this.#slots.get(peer.nodeId);
198333
+ if (current === void 0 || current.generation !== generation) return;
198334
+ current.phase = "subscribe-failed";
198335
+ this.#schedule(peer.nodeId);
198336
+ return;
198337
+ }
198338
+ if (this.#stopped || this.#slots.get(peer.nodeId)?.generation !== generation) {
198339
+ await subscription.close().catch(() => void 0);
198340
+ return;
198341
+ }
198342
+ slot.subscription = subscription;
198343
+ slot.phase = "subscribed";
198344
+ slot.attempt = 0;
198345
+ this.#log(peer.nodeId, "subscribed");
198346
+ }
198347
+ #onClosed(nodeId) {
198348
+ const slot = this.#slots.get(nodeId);
198349
+ if (slot === void 0 || slot.dropping) return;
198350
+ if (slot.phase !== "subscribed" && slot.phase !== "resubscribing") return;
198351
+ slot.subscription = null;
198352
+ slot.phase = "closed";
198353
+ this.#log(nodeId, "closed");
198354
+ if (!slot.peer.online()) {
198355
+ slot.phase = "offline";
198356
+ this.#log(nodeId, "offline");
198357
+ return;
198358
+ }
198359
+ this.#schedule(nodeId);
198360
+ }
198361
+ #schedule(nodeId) {
198362
+ const slot = this.#slots.get(nodeId);
198363
+ if (slot === void 0 || this.#stopped) return;
198364
+ if (slot.timer !== null) clearTimeout(slot.timer);
198365
+ const delay = Math.min(SUBSCRIPTION_BACKOFF_MAX_MS, SUBSCRIPTION_BACKOFF_INITIAL_MS * 2 ** slot.attempt);
198366
+ slot.attempt += 1;
198367
+ slot.phase = "backing-off";
198368
+ this.#log(nodeId, "backing-off", {
198369
+ delayMs: delay,
198370
+ attempt: slot.attempt
198371
+ });
198372
+ slot.timer = setTimeout(() => {
198373
+ slot.timer = null;
198374
+ this.#open(slot.peer);
198375
+ }, delay);
198376
+ if (typeof slot.timer.unref === "function") slot.timer.unref();
198377
+ }
198378
+ #listen(slot) {
198379
+ if (slot.listening) return;
198380
+ slot.listening = true;
198381
+ slot.unlisten.push(slot.peer.onOffline(() => {
198382
+ const current = this.#slots.get(slot.peer.nodeId);
198383
+ if (current === void 0) return;
198384
+ if (current.phase === "offline") return;
198385
+ current.phase = "offline";
198386
+ this.#log(slot.peer.nodeId, "offline");
198387
+ }));
198388
+ slot.unlisten.push(slot.peer.onOnline(() => {
198389
+ const current = this.#slots.get(slot.peer.nodeId);
198390
+ if (current === void 0 || this.#stopped) return;
198391
+ current.attempt = 0;
198392
+ if (current.timer !== null) {
198393
+ clearTimeout(current.timer);
198394
+ current.timer = null;
198395
+ }
198396
+ this.#log(slot.peer.nodeId, "online");
198397
+ this.#open(current.peer);
198398
+ }));
198399
+ }
198400
+ async #drop(slot) {
198401
+ slot.dropping = true;
198402
+ if (slot.timer !== null) clearTimeout(slot.timer);
198403
+ slot.timer = null;
198404
+ for (const unlisten of slot.unlisten) unlisten();
198405
+ slot.unlisten = [];
198406
+ const subscription = slot.subscription;
198407
+ slot.subscription = null;
198408
+ if (subscription !== null) try {
198409
+ await subscription.close();
198410
+ } catch (err) {
198411
+ this.#logger.warn("Matter subscription close failed", {
198412
+ tags: this.#tags(slot.peer.nodeId),
198413
+ meta: {
198414
+ nodeId: slot.peer.nodeId,
198415
+ error: errMsg(err)
198416
+ }
198417
+ });
198418
+ }
198419
+ }
198420
+ #fresh(peer) {
198421
+ return {
198422
+ peer,
198423
+ phase: "resubscribing",
198424
+ attempt: 0,
198425
+ timer: null,
198426
+ subscription: null,
198427
+ unlisten: [],
198428
+ generation: 0,
198429
+ listening: false,
198430
+ dropping: false
198431
+ };
198432
+ }
198433
+ #tags(nodeId) {
198434
+ const deviceId = this.#resolveDeviceId(nodeId);
198435
+ if (deviceId !== null) return { deviceId };
198436
+ return { brokerId: this.#brokerId };
198437
+ }
198438
+ #log(nodeId, phase, extra) {
198439
+ const deviceId = this.#resolveDeviceId(nodeId);
198440
+ this.#logger.info("Matter subscription state", {
198441
+ tags: deviceId !== null ? { deviceId } : { brokerId: this.#brokerId },
198442
+ meta: {
198443
+ nodeId,
198444
+ phase,
198445
+ ...extra
198446
+ }
198447
+ });
198448
+ }
198449
+ };
198450
+ function isRecord$2(value) {
198451
+ return typeof value === "object" && value !== null;
198452
+ }
198453
+ function asFiniteNumber(value) {
198454
+ if (typeof value === "number" && Number.isFinite(value)) return value;
198455
+ if (typeof value === "bigint") {
198456
+ const n = Number(value);
198457
+ return Number.isFinite(n) ? n : null;
198458
+ }
198459
+ return null;
198460
+ }
198461
+ function isIterable$1(value) {
198462
+ return isRecord$2(value) && typeof Reflect.get(value, Symbol.iterator) === "function";
198463
+ }
198464
+ function isAsyncIterable(value) {
198465
+ return isRecord$2(value) && typeof Reflect.get(value, Symbol.asyncIterator) === "function";
198466
+ }
198467
+ /**
198468
+ * Pull attribute values out of a matter.js `ReadResult` (or a recorded fixture
198469
+ * of the same shape). Status reports and missing values are skipped — they are
198470
+ * not a measurement.
198471
+ */
198472
+ async function collectAttributeReports(nodeId, update) {
198473
+ const out = [];
198474
+ const chunks = isAsyncIterable(update) ? update : isIterable$1(update) ? update : [];
198475
+ for await (const chunk of chunks) {
198476
+ const reports = isIterable$1(chunk) ? chunk : [];
198477
+ for (const report of reports) {
198478
+ const decoded = reportFromRecord(nodeId, report);
198479
+ if (decoded !== null) out.push(decoded);
198480
+ }
198481
+ }
198482
+ return out;
198483
+ }
198484
+ function reportFromRecord(nodeId, report) {
198485
+ if (!isRecord$2(report)) return null;
198486
+ if (report["kind"] !== "attr-value") return null;
198487
+ if (report["value"] === void 0 || report["value"] === null) return null;
198488
+ const path = report["path"];
198489
+ if (!isRecord$2(path)) return null;
198490
+ const endpointId = asFiniteNumber(path["endpointId"]);
198491
+ const clusterId = asFiniteNumber(path["clusterId"]);
198492
+ const attributeId = asFiniteNumber(path["attributeId"]);
198493
+ if (endpointId === null || clusterId === null || attributeId === null) return null;
198494
+ return {
198495
+ nodeId,
198496
+ endpointId,
198497
+ clusterId,
198498
+ attributeId,
198499
+ value: report["value"]
198500
+ };
198501
+ }
198502
+ function isRecord$1(value) {
198503
+ return typeof value === "object" && value !== null;
198504
+ }
198505
+ function peerOnline$1(peer) {
198506
+ const lifecycle = peer["lifecycle"];
198507
+ if (!isRecord$1(lifecycle)) return false;
198508
+ return lifecycle["isOnline"] === true;
198509
+ }
198510
+ function listenLifecycle(peer, event, handler) {
198511
+ const lifecycle = peer["lifecycle"];
198512
+ if (!isRecord$1(lifecycle)) return () => void 0;
198513
+ const observable = lifecycle[event];
198514
+ if (!isRecord$1(observable)) return () => void 0;
198515
+ const on = observable["on"];
198516
+ const off = observable["off"];
198517
+ if (typeof on !== "function" || typeof off !== "function") return () => void 0;
198518
+ Reflect.apply(on, observable, [handler]);
198519
+ return () => {
198520
+ Reflect.apply(off, observable, [handler]);
198521
+ };
198522
+ }
198523
+ /** Stop matter.js's own wildcard subscription so this addon owns the session. */
198524
+ function disableAutoSubscribe(peer) {
198525
+ const stateOf = peer["stateOf"];
198526
+ if (typeof stateOf !== "function") return;
198527
+ let network;
198528
+ try {
198529
+ network = Reflect.apply(stateOf, peer, ["network"]);
198530
+ } catch {
198531
+ return;
198532
+ }
198533
+ if (!isRecord$1(network)) return;
198534
+ if ("autoSubscribe" in network) network["autoSubscribe"] = false;
198535
+ }
198536
+ function subscriptionFrom(result) {
198537
+ return { close: async () => {
198538
+ if (!isRecord$1(result)) return;
198539
+ const fn = result["close"];
198540
+ if (typeof fn !== "function") return;
198541
+ await Promise.resolve(Reflect.apply(fn, result, []));
198542
+ } };
198543
+ }
198544
+ /**
198545
+ * Build a subscribable view of a commissioned peer, or null when the peer has
198546
+ * no `interaction.subscribe` (nothing to attach — the caller logs that).
198547
+ */
198548
+ function subscribableFromPeer(peer, nodeId) {
198549
+ if (!isRecord$1(peer)) return null;
198550
+ const interaction = peer["interaction"];
198551
+ if (!isRecord$1(interaction) || typeof interaction["subscribe"] !== "function") return null;
198552
+ const subscribe = interaction["subscribe"];
198553
+ return {
198554
+ nodeId,
198555
+ online: () => peerOnline$1(peer),
198556
+ onOnline: (handler) => listenLifecycle(peer, "online", handler),
198557
+ onOffline: (handler) => listenLifecycle(peer, "offline", handler),
198558
+ subscribe: async (handlers) => {
198559
+ disableAutoSubscribe(peer);
198560
+ const request = {
198561
+ sustain: true,
198562
+ keepSubscriptions: false,
198563
+ fabricFilter: true,
198564
+ attributes: [{}],
198565
+ minIntervalFloor: require_esm.Seconds(0),
198566
+ maxIntervalCeiling: require_esm.Seconds(60),
198567
+ bootstrapWithRead: true,
198568
+ updated: async (update) => {
198569
+ const reports = await collectAttributeReports(nodeId, update);
198570
+ for (const report of reports) handlers.onReport(report);
198571
+ },
198572
+ closed: () => {
198573
+ handlers.onClosed();
198574
+ }
198575
+ };
198576
+ return subscriptionFrom(await Promise.resolve(Reflect.apply(subscribe, interaction, [request])));
198577
+ }
198578
+ };
198579
+ }
198580
+ //#endregion
197478
198581
  //#region src/matter-controller-impl.ts
197479
198582
  /**
197480
- * Real {@link MatterController} backed by matter.js v0.17 (`@matter/main` +
197481
- * `@matter/nodejs`).
197482
- *
197483
- * matter.js v0.17 uses the "node API": a {@link ServerNode} owns the controller
197484
- * fabric, and commissioned devices are {@link ClientNode} peers reachable via
197485
- * `serverNode.peers`. This class wraps exactly one `ServerNode` per broker
197486
- * (= one fabric), persisting it under the addon `dataDir` via the matter.js
197487
- * `Environment`'s `storage.path` variable.
197488
- *
197489
- * Bound API (matter.js 0.17.3):
197490
- * - `ServerNode.create({ environment, id })` — fabric owner; storage at dataDir.
197491
- * - `serverNode.peers.commission({ pairingCode })` — commission a node from a
197492
- * QR (`MT:…`) or manual pairing code; resolves to a `ClientNode`.
197493
- * - `serverNode.peers` — iterable of commissioned `ClientNode`s.
197494
- * - `clientNode.lifecycle.isOnline`, `clientNode.peerAddress.nodeId` — liveness
197495
- * + stable node id.
197496
- * - `clientNode.decommission()` — remove a node from the fabric.
197497
- * - endpoint `commandsOf(behaviorId)[name](args)` — outbound cluster commands.
197498
- *
197499
- * The dynamic endpoint/cluster/attribute introspection lives in
197500
- * `matter-endpoint-introspect.ts` (the single matter.js dynamic-shape boundary).
197501
- * This class polls each node's endpoint state on reconcile + emits decoded
197502
- * attribute reports, and re-emits on node online/offline transitions.
197503
- *
197504
- * BLE commissioning (`@matter/nodejs-ble`) is NOT wired — that package is not
197505
- * installed. IP / on-network commissioning is the supported path; see the class
197506
- * `commissionNode` note.
198583
+ * Real {@link MatterController} backed by matter.js (`@matter/main` +
198584
+ * `@matter/nodejs`). ServerNode / ClientNode is the API this controller uses.
198585
+ *
198586
+ * One `ServerNode` per broker (= one fabric). Storage is matter.js's own
198587
+ * files under the addon `dataDir` (`Environment` `storage.path`) — the
198588
+ * library-owned exception for fabric root / NOC / node registry, same class
198589
+ * as HAP pairing files. Not the keychain.
198590
+ *
198591
+ * Bound API:
198592
+ * - `ServerNode.create({ environment, id })` — fabric owner.
198593
+ * - `serverNode.peers.commission({ pairingCode, continueCommissioningAfterPase })`.
198594
+ * - `clientNode.interaction.subscribe({ sustain: true })` — attribute reports.
198595
+ * - `clientNode.lifecycle` online / offline — resubscribe.
198596
+ * - endpoint `commandsOf(behaviorId)[name](args)` — outbound commands.
198597
+ *
198598
+ * BLE commissioning is not wired. IP / on-network share codes only.
197507
198599
  */
197508
198600
  /** A commissioning failure (bad code / device unreachable), distinct from runtime. */
197509
198601
  var MatterCommissioningError = class extends Error {
@@ -197523,12 +198615,19 @@ var MatterJsController = class {
197523
198615
  #options;
197524
198616
  #logger;
197525
198617
  #handlers = /* @__PURE__ */ new Set();
198618
+ #subscriptions;
197526
198619
  #server = null;
197527
198620
  /** Last emitted value per `node:endpoint:cluster:attribute` (change detection). */
197528
198621
  #lastValues = /* @__PURE__ */ new Map();
197529
198622
  constructor(options, logger) {
197530
198623
  this.#options = options;
197531
198624
  this.#logger = logger;
198625
+ this.#subscriptions = new MatterSubscriptionSupervisor({
198626
+ brokerId: options.brokerId,
198627
+ logger,
198628
+ resolveDeviceId: options.resolveDeviceId ?? (() => null),
198629
+ onReport: (report) => this.#emitIfChanged(report)
198630
+ });
197532
198631
  }
197533
198632
  get options() {
197534
198633
  return this.#options;
@@ -197536,19 +198635,17 @@ var MatterJsController = class {
197536
198635
  /** Boot the ServerNode fabric, loading/persisting state from `dataDir`. */
197537
198636
  async start() {
197538
198637
  if (this.#server !== null) return;
197539
- const environment = this.#buildEnvironment();
197540
- const server = await ServerNode.create({
197541
- environment,
197542
- id: this.#options.brokerId
197543
- });
198638
+ const server = this.#options.openServer !== void 0 ? await this.#options.openServer() : await this.#openProductionServer();
197544
198639
  this.#server = server;
197545
198640
  await server.start();
197546
198641
  this.#logger.info("MatterJsController: ServerNode online", { tags: { brokerId: this.#options.brokerId } });
198642
+ await this.syncSubscriptions();
197547
198643
  this.#emitAllReports();
197548
198644
  }
197549
- /** Close the ServerNode FIRST (frees OS sockets/subscriptions), then clear. */
198645
+ /** Close subscriptions FIRST, then the ServerNode. */
197550
198646
  async stop() {
197551
198647
  this.#handlers.clear();
198648
+ await this.#subscriptions.stop();
197552
198649
  const server = this.#server;
197553
198650
  this.#server = null;
197554
198651
  this.#lastValues.clear();
@@ -197569,19 +198666,49 @@ var MatterJsController = class {
197569
198666
  */
197570
198667
  async commissionNode(pairingCode) {
197571
198668
  const server = this.#requireServer("commissionNode");
198669
+ this.#progress({
198670
+ phase: CommissionPhase.Discovering,
198671
+ failure: null,
198672
+ message: null
198673
+ });
197572
198674
  try {
197573
- const node = await server.peers.commission({ pairingCode });
198675
+ const node = await server.commission({
198676
+ pairingCode,
198677
+ onPase: () => {
198678
+ this.#progress({
198679
+ phase: CommissionPhase.Pase,
198680
+ failure: null,
198681
+ message: null
198682
+ });
198683
+ this.#progress({
198684
+ phase: CommissionPhase.Commissioning,
198685
+ failure: null,
198686
+ message: null
198687
+ });
198688
+ }
198689
+ });
197574
198690
  const view = this.#viewPeer(node);
197575
198691
  this.#logger.info("MatterJsController: node commissioned", {
197576
198692
  tags: { brokerId: this.#options.brokerId },
197577
198693
  meta: { nodeId: view?.id ?? "(unknown)" }
197578
198694
  });
198695
+ await this.syncSubscriptions();
197579
198696
  this.#emitAllReports();
197580
198697
  if (view === null) throw new MatterCommissioningError("commissioned node exposed no operational id");
198698
+ this.#progress({
198699
+ phase: CommissionPhase.Done,
198700
+ failure: null,
198701
+ message: null
198702
+ });
197581
198703
  return { nodeId: view.id };
197582
198704
  } catch (err) {
197583
- if (err instanceof MatterCommissioningError) throw err;
197584
- throw new MatterCommissioningError(errMsg(err), { cause: err });
198705
+ if (err instanceof MatterCommissioningError && err.message.includes("no operational id")) {
198706
+ this.#failCommission(err.message);
198707
+ throw err;
198708
+ }
198709
+ const message = errMsg(err);
198710
+ this.#failCommission(message);
198711
+ throw new MatterCommissioningError(commissionFailureMessage(classifyCommissionError(message), message), { cause: err });
197585
198712
  }
197586
198713
  }
197587
198714
  /** Pull the live commissioned-node inventory off `serverNode.peers`. */
@@ -197589,12 +198716,31 @@ var MatterJsController = class {
197589
198716
  const server = this.#server;
197590
198717
  if (server === null) return [];
197591
198718
  const out = [];
197592
- for (const peer of this.#peers(server)) {
198719
+ for (const peer of server.listPeers()) {
197593
198720
  const snapshot = this.#snapshot(peer);
197594
198721
  if (snapshot !== null) out.push(snapshot);
197595
198722
  }
197596
198723
  return out;
197597
198724
  }
198725
+ async syncSubscriptions() {
198726
+ const server = this.#server;
198727
+ if (server === null) return;
198728
+ const peers = [];
198729
+ for (const peer of server.listPeers()) {
198730
+ const view = this.#viewPeer(peer);
198731
+ if (view === null) continue;
198732
+ const subscribable = subscribableFromPeer(peer, view.id);
198733
+ if (subscribable === null) {
198734
+ this.#logger.warn("MatterJsController: peer has no attribute subscription", {
198735
+ tags: { brokerId: this.#options.brokerId },
198736
+ meta: { nodeId: view.id }
198737
+ });
198738
+ continue;
198739
+ }
198740
+ peers.push(subscribable);
198741
+ }
198742
+ await this.#subscriptions.sync(peers);
198743
+ }
197598
198744
  async getNode(nodeId) {
197599
198745
  return (await this.listNodes()).find((n) => n.nodeId === nodeId) ?? null;
197600
198746
  }
@@ -197635,6 +198781,25 @@ var MatterJsController = class {
197635
198781
  throw err;
197636
198782
  }
197637
198783
  }
198784
+ async #openProductionServer() {
198785
+ const environment = this.#buildEnvironment();
198786
+ const server = await ServerNode.create({
198787
+ environment,
198788
+ id: this.#options.brokerId
198789
+ });
198790
+ return {
198791
+ start: () => server.start(),
198792
+ close: () => server.close(),
198793
+ listPeers: () => this.#serverPeers(server),
198794
+ commission: ({ pairingCode, onPase }) => server.peers.commission({
198795
+ pairingCode,
198796
+ continueCommissioningAfterPase: () => {
198797
+ onPase();
198798
+ return true;
198799
+ }
198800
+ })
198801
+ };
198802
+ }
197638
198803
  #buildEnvironment() {
197639
198804
  const env = require_esm.Environment.default;
197640
198805
  env.vars.set("storage.path", this.#options.dataDir);
@@ -197647,13 +198812,13 @@ var MatterJsController = class {
197647
198812
  return this.#server;
197648
198813
  }
197649
198814
  /** Iterate the commissioned peers of a ServerNode (cast-free). */
197650
- #peers(server) {
198815
+ #serverPeers(server) {
197651
198816
  const peers = server.peers;
197652
198817
  if (isIterable(peers)) return peers;
197653
198818
  return [];
197654
198819
  }
197655
198820
  #findPeer(server, nodeId) {
197656
- for (const peer of this.#peers(server)) {
198821
+ for (const peer of server.listPeers()) {
197657
198822
  const view = this.#viewPeer(peer);
197658
198823
  if (view !== null && view.id === nodeId) return view;
197659
198824
  }
@@ -197661,12 +198826,23 @@ var MatterJsController = class {
197661
198826
  }
197662
198827
  /** Raw (un-viewed) peer object for operations that call its methods. */
197663
198828
  #rawPeer(server, nodeId) {
197664
- for (const peer of this.#peers(server)) {
198829
+ for (const peer of server.listPeers()) {
197665
198830
  const view = this.#viewPeer(peer);
197666
198831
  if (view !== null && view.id === nodeId) return peer;
197667
198832
  }
197668
198833
  return null;
197669
198834
  }
198835
+ #progress(progress) {
198836
+ this.#options.onCommissionProgress?.(progress);
198837
+ }
198838
+ #failCommission(message) {
198839
+ const failure = classifyCommissionError(message);
198840
+ this.#progress({
198841
+ phase: CommissionPhase.Failed,
198842
+ failure,
198843
+ message: commissionFailureMessage(failure, message)
198844
+ });
198845
+ }
197670
198846
  /** Reduce a peer to the read-only view the addon models. */
197671
198847
  #viewPeer(peer) {
197672
198848
  if (!isRecord(peer)) return null;
@@ -197710,7 +198886,7 @@ var MatterJsController = class {
197710
198886
  #emitAllReports() {
197711
198887
  const server = this.#server;
197712
198888
  if (server === null) return;
197713
- for (const peer of this.#peers(server)) {
198889
+ for (const peer of server.listPeers()) {
197714
198890
  const view = this.#viewPeer(peer);
197715
198891
  if (view === null) continue;
197716
198892
  for (const ep of readNodeEndpoints(view.rootEndpoint)) for (const attr of ep.attributes) {
@@ -197726,6 +198902,7 @@ var MatterJsController = class {
197726
198902
  }
197727
198903
  }
197728
198904
  #emitIfChanged(report) {
198905
+ if (report.value === void 0 || report.value === null) return;
197729
198906
  const key = `${report.nodeId}:${report.endpointId}:${report.clusterId}:${report.attributeId}`;
197730
198907
  const prev = this.#lastValues.get(key);
197731
198908
  if (prev !== void 0 && shallowEqual(prev, report.value)) return;
@@ -197811,12 +198988,24 @@ var MatterNodeResolver = class {
197811
198988
  #attrs = /* @__PURE__ */ new Map();
197812
198989
  /** Live attribute-report subscribers (endpoint device classes). */
197813
198990
  #listeners = /* @__PURE__ */ new Set();
198991
+ /** Adopted container id per Matter node, for subscription logs. */
198992
+ #deviceIds = /* @__PURE__ */ new Map();
197814
198993
  /**
197815
198994
  * Subscribe to live attribute reports across all brokers. Endpoint device
197816
198995
  * classes subscribe on activate (filtering to their own node/endpoint) so a
197817
198996
  * push report decodes straight into their runtime-state slice. Returns a
197818
198997
  * disposer.
197819
198998
  */
198999
+ /** Remember the CamStack container id for a commissioned node. */
199000
+ bindDevice(nodeId, deviceId) {
199001
+ this.#deviceIds.set(nodeId, deviceId);
199002
+ }
199003
+ unbindDevice(nodeId) {
199004
+ this.#deviceIds.delete(nodeId);
199005
+ }
199006
+ deviceIdFor(nodeId) {
199007
+ return this.#deviceIds.get(nodeId) ?? null;
199008
+ }
197820
199009
  onAttributeReport(listener) {
197821
199010
  this.#listeners.add(listener);
197822
199011
  return () => {
@@ -197878,6 +199067,7 @@ var MatterNodeResolver = class {
197878
199067
  this.#nodes.clear();
197879
199068
  this.#attrs.clear();
197880
199069
  this.#listeners.clear();
199070
+ this.#deviceIds.clear();
197881
199071
  }
197882
199072
  };
197883
199073
  /** The single in-process per-broker node resolver shared across the addon. */
@@ -197937,6 +199127,7 @@ var MatterIntegrationManager = class {
197937
199127
  #nodeCount = 0;
197938
199128
  #lastCheckedAt = null;
197939
199129
  #error = null;
199130
+ #commission = IDLE_COMMISSION_PROGRESS;
197940
199131
  constructor(options) {
197941
199132
  this.#id = options.id;
197942
199133
  this.#name = options.name;
@@ -197947,7 +199138,17 @@ var MatterIntegrationManager = class {
197947
199138
  this.#onDisconnected = options.onDisconnected;
197948
199139
  this.#onNodesChanged = options.onNodesChanged;
197949
199140
  this.#onAttributeReport = options.onAttributeReport;
197950
- this.#makeController = options.makeController ?? ((opts) => new MatterJsController(opts, this.#logger));
199141
+ this.#makeController = options.makeController ?? ((opts) => new MatterJsController({
199142
+ ...opts,
199143
+ onCommissionProgress: (progress) => {
199144
+ this.#commission = progress;
199145
+ },
199146
+ resolveDeviceId: (nodeId) => matterNodes.deviceIdFor(nodeId)
199147
+ }, this.#logger));
199148
+ }
199149
+ /** Latest commissioning phase for `broker.getState({ key: 'commission' })`. */
199150
+ getCommissionProgress() {
199151
+ return this.#commission;
197951
199152
  }
197952
199153
  /** The connection settings the manager is currently configured with. */
197953
199154
  getConnection() {
@@ -198022,6 +199223,7 @@ var MatterIntegrationManager = class {
198022
199223
  if (controller === null) return;
198023
199224
  try {
198024
199225
  const nodes = await controller.listNodes();
199226
+ await controller.syncSubscriptions();
198025
199227
  this.#nodeCount = nodes.length;
198026
199228
  this.#lastCheckedAt = Date.now();
198027
199229
  matterNodes.setNodes(this.#id, nodes);
@@ -198202,6 +199404,10 @@ var MatterBrokerRegistry = class {
198202
199404
  if (!mgr) throw new Error(`MatterBrokerRegistry: unknown broker id "${brokerId}"`);
198203
199405
  return mgr.commissionNode(pairingCode);
198204
199406
  }
199407
+ getCommissionProgress(brokerId) {
199408
+ const mgr = this.#managers.get(brokerId);
199409
+ return mgr ? mgr.getCommissionProgress() : null;
199410
+ }
198205
199411
  async removeNode(brokerId, nodeId) {
198206
199412
  const mgr = this.#managers.get(brokerId);
198207
199413
  if (!mgr) throw new Error(`MatterBrokerRegistry: unknown broker id "${brokerId}"`);
@@ -198304,6 +199510,26 @@ function toRecord(value) {
198304
199510
  function settingsToMatterConnection(settings) {
198305
199511
  return matterConnectionSchema.parse(toRecord(settings));
198306
199512
  }
199513
+ /**
199514
+ * Settings readers see an empty string for both secrets. The stored value
199515
+ * stays in the keychain (records `secret` meta). An empty round-trip must
199516
+ * not wipe it — see {@link mergeMatterSecrets}.
199517
+ */
199518
+ function publicMatterConnection(connection) {
199519
+ return {
199520
+ ...connection,
199521
+ wifiPassword: "",
199522
+ threadDataset: ""
199523
+ };
199524
+ }
199525
+ /** Keep a stored secret when the form sent back the redacted empty string. */
199526
+ function mergeMatterSecrets(existing, incoming) {
199527
+ return {
199528
+ ...incoming,
199529
+ wifiPassword: incoming.wifiPassword.length > 0 ? incoming.wifiPassword : existing.wifiPassword,
199530
+ threadDataset: incoming.threadDataset.length > 0 ? incoming.threadDataset : existing.threadDataset
199531
+ };
199532
+ }
198307
199533
  /** Hand-written connection form for the broker/integration creation UI. */
198308
199534
  function buildConnectionFormSchema() {
198309
199535
  return { sections: [{
@@ -198315,9 +199541,10 @@ function buildConnectionFormSchema() {
198315
199541
  columns: 1,
198316
199542
  fields: [
198317
199543
  {
198318
- type: "text",
199544
+ type: "password",
198319
199545
  key: "threadDataset",
198320
199546
  secret: true,
199547
+ showToggle: true,
198321
199548
  label: "Thread operational dataset (hex, optional)",
198322
199549
  labelKey: "matter-broker:connection.s_matter-fabric.threadDataset.label",
198323
199550
  placeholder: "0e08000000000001…"
@@ -198394,17 +199621,14 @@ var MATTER_KIND = "matter";
198394
199621
  * Construct the `broker` cap provider for the Matter addon. Pure builder.
198395
199622
  * Mirrors `buildEcowittBrokerProvider`. The defining Matter operation —
198396
199623
  * commissioning — is surfaced through `publish` with
198397
- * `target: { action: 'commissionNode' }` and `payload: { pairingCode }`. The
198398
- * Wi-Fi password is the only secret; it is redacted on settings reads and
198399
- * preserved on an empty round-trip.
199624
+ * `target: { action: 'commissionNode' }` and `payload: { pairingCode }`.
199625
+ * `threadDataset` and `wifiPassword` are redacted on settings reads and
199626
+ * preserved on an empty round-trip. Fabric keys stay in matter.js storage.
198400
199627
  */
198401
199628
  function buildMatterBrokerProvider(deps) {
198402
199629
  const { ownerAddonId, registry, getBrokers, persistBrokers, cascadeRemoveDevices, logger } = deps;
198403
199630
  const owns = (id) => getBrokers().some((b) => b.id === id);
198404
- const redact = (entry) => ({
198405
- ...entry.connection,
198406
- wifiPassword: ""
198407
- });
199631
+ const redact = (entry) => publicMatterConnection(entry.connection);
198408
199632
  return {
198409
199633
  list: async ({ kind }) => {
198410
199634
  if (kind && kind !== MATTER_KIND) return [];
@@ -198472,10 +199696,7 @@ function buildMatterBrokerProvider(deps) {
198472
199696
  const existing = getBrokers().find((b) => b.id === id);
198473
199697
  if (!existing) return;
198474
199698
  const parsed = settingsToMatterConnection(settings);
198475
- const merged = {
198476
- ...parsed,
198477
- wifiPassword: parsed.wifiPassword.length > 0 ? parsed.wifiPassword : existing.connection.wifiPassword
198478
- };
199699
+ const merged = mergeMatterSecrets(existing.connection, parsed);
198479
199700
  const updated = await registry.updateEntry(id, merged);
198480
199701
  await persistBrokers(getBrokers().map((b) => b.id === id ? updated : b));
198481
199702
  },
@@ -198511,7 +199732,7 @@ function buildMatterBrokerProvider(deps) {
198511
199732
  if (!owns(brokerId)) throw new Error(`matter: unknown broker ${brokerId}`);
198512
199733
  const action = typeof target["action"] === "string" ? target["action"] : void 0;
198513
199734
  if (action === "commissionNode") {
198514
- const code = typeof payload === "object" && payload !== null && "pairingCode" in payload ? String(payload["pairingCode"] ?? "") : "";
199735
+ const code = pairingCodeFromPayload(payload);
198515
199736
  parsePairingCode(code);
198516
199737
  return registry.commissionNode(brokerId, code);
198517
199738
  }
@@ -198519,13 +199740,23 @@ function buildMatterBrokerProvider(deps) {
198519
199740
  },
198520
199741
  subscribe: async () => ({ subscriptionId: "" }),
198521
199742
  unsubscribe: async () => void 0,
198522
- getState: async () => null,
199743
+ getState: async ({ brokerId, key }) => {
199744
+ if (key !== "commission") return null;
199745
+ return registry.getCommissionProgress(brokerId);
199746
+ },
198523
199747
  getStatus: async () => ({
198524
199748
  brokerCount: registry.size(),
198525
199749
  connectedCount: registry.connectedCount()
198526
199750
  })
198527
199751
  };
198528
199752
  }
199753
+ function pairingCodeFromPayload(payload) {
199754
+ if (typeof payload !== "object" || payload === null || !("pairingCode" in payload)) return "";
199755
+ const code = payload.pairingCode;
199756
+ if (typeof code === "string") return code;
199757
+ if (typeof code === "number" && Number.isFinite(code)) return String(code);
199758
+ return "";
199759
+ }
198529
199760
  //#endregion
198530
199761
  //#region src/matter-discovery.ts
198531
199762
  /**
@@ -198756,6 +199987,27 @@ function setBrokerDevicesOnline(devices, brokerId, online) {
198756
199987
  return count;
198757
199988
  }
198758
199989
  //#endregion
199990
+ //#region src/matter-lock-command.ts
199991
+ /**
199992
+ * Decide the command arguments. `pinRequired` comes from the live
199993
+ * RequirePinForRemoteOperation attribute, not from a stored secret.
199994
+ */
199995
+ function planDoorLockCommand(input) {
199996
+ const code = input.code?.trim() ?? "";
199997
+ if (input.pinRequired && code.length === 0) return {
199998
+ ok: false,
199999
+ reason: "pin-required"
200000
+ };
200001
+ if (code.length > 0) return {
200002
+ ok: true,
200003
+ args: { pinCode: code }
200004
+ };
200005
+ return {
200006
+ ok: true,
200007
+ args: {}
200008
+ };
200009
+ }
200010
+ //#endregion
198759
200011
  //#region src/devices/matter-endpoint-device.ts
198760
200012
  /**
198761
200013
  * Persisted config every Matter endpoint child carries. `nodeId`/`endpointId`
@@ -198847,7 +200099,8 @@ var COLD_POWER = { lastFetchedAt: 0 };
198847
200099
  * arrives, each cap reads its cold-start default.
198848
200100
  */
198849
200101
  var MatterEndpointDevice = class extends BaseDevice {
198850
- features = [];
200102
+ /** Live RequirePinForRemoteOperation. Commands read this, not a stored PIN. */
200103
+ pinRequired = false;
198851
200104
  brokerId;
198852
200105
  nodeId;
198853
200106
  endpointId;
@@ -198861,12 +200114,17 @@ var MatterEndpointDevice = class extends BaseDevice {
198861
200114
  this.nodeId = persisted.nodeId;
198862
200115
  this.endpointId = persisted.endpointId;
198863
200116
  this.caps = persisted.capabilities.filter(isKnownCap);
200117
+ this.pinRequired = matterNodes.getAttribute(this.brokerId, this.nodeId, this.endpointId, MatterClusterId.DoorLock, MatterAttributeId.RequirePinForRemoteOperation) === true;
198864
200118
  this.online = matterNodes.getNode(this.brokerId, this.nodeId)?.online ?? true;
198865
200119
  this.updateSourceInfo({
198866
200120
  id: `${this.nodeId}:${this.endpointId}`,
198867
200121
  system: "matter"
198868
200122
  });
198869
200123
  }
200124
+ /** PIN-required locks advertise the feature the lock UI already understands. */
200125
+ get features() {
200126
+ return this.pinRequired ? [DeviceFeature.LockPinRequired] : [];
200127
+ }
198870
200128
  async onActivate() {
198871
200129
  await super.onActivate();
198872
200130
  for (const cap of this.caps) {
@@ -198894,7 +200152,29 @@ var MatterEndpointDevice = class extends BaseDevice {
198894
200152
  value: report.value
198895
200153
  });
198896
200154
  if (decoded === null) return;
200155
+ if (decoded.detail === "pin-required" || decoded.detail === "pin-not-required") {
200156
+ const next = decoded.detail === "pin-required";
200157
+ if (next !== this.pinRequired) {
200158
+ this.pinRequired = next;
200159
+ this.ctx.logger.info("Matter lock PIN requirement changed", {
200160
+ tags: { deviceId: this.id },
200161
+ meta: {
200162
+ nodeId: this.nodeId,
200163
+ endpointId: this.endpointId,
200164
+ pinRequired: next
200165
+ }
200166
+ });
200167
+ }
200168
+ } else if (decoded.detail !== void 0) this.ctx.logger.info("Matter lock detail", {
200169
+ tags: { deviceId: this.id },
200170
+ meta: {
200171
+ nodeId: this.nodeId,
200172
+ endpointId: this.endpointId,
200173
+ detail: decoded.detail
200174
+ }
200175
+ });
198897
200176
  if (!this.registeredCaps.has(decoded.cap)) return;
200177
+ if (Object.keys(decoded.patch).length === 0) return;
198898
200178
  this.runtimeState.patchCapState(CAP_SLICE_NAME[decoded.cap], decoded.patch);
198899
200179
  }
198900
200180
  /** Invoke a cluster command on this endpoint via the live controller. */
@@ -198990,30 +200270,52 @@ var MatterEndpointDevice = class extends BaseDevice {
198990
200270
  };
198991
200271
  this.ctx.registerNativeCap(lockControlCapability, {
198992
200272
  getStatus: async () => this.runtimeState.getCapState("lock-control") ?? COLD,
198993
- lock: async () => {
198994
- await this.invoke(MatterClusterId.DoorLock, "lockDoor");
198995
- this.runtimeState.setCapState("lock-control", {
198996
- state: "locked",
198997
- lastChangedAt: Date.now()
198998
- });
200273
+ lock: async ({ code }) => {
200274
+ await this.invokeLock("lockDoor", "locking", code);
198999
200275
  },
199000
- unlock: async () => {
199001
- await this.invoke(MatterClusterId.DoorLock, "unlockDoor");
199002
- this.runtimeState.setCapState("lock-control", {
199003
- state: "unlocked",
199004
- lastChangedAt: Date.now()
199005
- });
200276
+ unlock: async ({ code }) => {
200277
+ await this.invokeLock("unlockDoor", "unlocking", code);
199006
200278
  },
199007
200279
  open: async () => {
199008
- await this.invoke(MatterClusterId.DoorLock, "unlockDoor");
199009
- this.runtimeState.setCapState("lock-control", {
199010
- state: "unlocked",
199011
- lastChangedAt: Date.now()
199012
- });
200280
+ await this.invokeLock("unlockDoor", "unlocking", void 0);
199013
200281
  }
199014
200282
  });
199015
200283
  this.runtimeState.setCapState("lock-control", COLD);
199016
200284
  }
200285
+ /**
200286
+ * Send lockDoor / unlockDoor. The final locked/unlocked state arrives on the
200287
+ * attribute subscription — this method only records the in-flight transition.
200288
+ * A PIN is forwarded on the command and nowhere else.
200289
+ */
200290
+ async invokeLock(commandName, pending, code) {
200291
+ const plan = planDoorLockCommand({
200292
+ pinRequired: this.pinRequired,
200293
+ code
200294
+ });
200295
+ if (!plan.ok) {
200296
+ this.ctx.logger.warn("Matter lock command refused: PIN required and none was provided", {
200297
+ tags: { deviceId: this.id },
200298
+ meta: {
200299
+ nodeId: this.nodeId,
200300
+ endpointId: this.endpointId,
200301
+ commandName
200302
+ }
200303
+ });
200304
+ throw new Error("matter-broker: this lock requires a PIN for remote operation");
200305
+ }
200306
+ const previous = this.runtimeState.getCapState("lock-control");
200307
+ this.runtimeState.setCapState("lock-control", {
200308
+ state: pending,
200309
+ lastChangedAt: Date.now()
200310
+ });
200311
+ try {
200312
+ const args = "pinCode" in plan.args ? plan.args : void 0;
200313
+ await this.invoke(MatterClusterId.DoorLock, commandName, args);
200314
+ } catch (err) {
200315
+ if (previous !== void 0) this.runtimeState.setCapState("lock-control", previous);
200316
+ throw err;
200317
+ }
200318
+ }
199017
200319
  registerCover() {
199018
200320
  const COLD = {
199019
200321
  state: "closed",
@@ -199234,11 +200536,16 @@ var MatterNodeDevice = class extends BaseDevice {
199234
200536
  this.nodeId = cfg.nodeId;
199235
200537
  this.integrationId = cfg.integrationId;
199236
200538
  this.online = matterNodes.getNode(this.brokerId, this.nodeId)?.online ?? false;
200539
+ matterNodes.bindDevice(this.nodeId, this.id);
199237
200540
  this.updateSourceInfo({
199238
200541
  id: this.nodeId,
199239
200542
  system: "matter"
199240
200543
  });
199241
200544
  }
200545
+ async removeDevice() {
200546
+ matterNodes.unbindDevice(this.nodeId);
200547
+ await super.removeDevice();
200548
+ }
199242
200549
  getAccessoryChildren() {
199243
200550
  const specs = [];
199244
200551
  for (const endpoint of this.config.values.endpoints) {