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