@camstack/addon-provider-reolink 1.2.221 → 1.2.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -105254,6 +105254,12 @@ var numberProcessor = (schema, ctx, _json, _params) => {
105254
105254
  var booleanProcessor = (_schema, _ctx, json, _params) => {
105255
105255
  json.type = "boolean";
105256
105256
  };
105257
+ var bigintProcessor = (_schema, ctx, _json, _params) => {
105258
+ if (ctx.unrepresentable === "throw") throw new Error("BigInt cannot be represented in JSON Schema");
105259
+ };
105260
+ var symbolProcessor = (_schema, ctx, _json, _params) => {
105261
+ if (ctx.unrepresentable === "throw") throw new Error("Symbols cannot be represented in JSON Schema");
105262
+ };
105257
105263
  var nullProcessor = (_schema, ctx, json, _params) => {
105258
105264
  if (ctx.target === "openapi-3.0") {
105259
105265
  json.type = "string";
@@ -105261,12 +105267,17 @@ var nullProcessor = (_schema, ctx, json, _params) => {
105261
105267
  json.enum = [null];
105262
105268
  } else json.type = "null";
105263
105269
  };
105270
+ var undefinedProcessor = (_schema, ctx, _json, _params) => {
105271
+ if (ctx.unrepresentable === "throw") throw new Error("Undefined cannot be represented in JSON Schema");
105272
+ };
105264
105273
  var voidProcessor = (_schema, ctx, _json, _params) => {
105265
105274
  if (ctx.unrepresentable === "throw") throw new Error("Void cannot be represented in JSON Schema");
105266
105275
  };
105267
105276
  var neverProcessor = (_schema, _ctx, json, _params) => {
105268
105277
  json.not = {};
105269
105278
  };
105279
+ var anyProcessor = (_schema, _ctx, _json, _params) => {};
105280
+ var unknownProcessor = (_schema, _ctx, _json, _params) => {};
105270
105281
  var dateProcessor = (_schema, ctx, _json, _params) => {
105271
105282
  if (ctx.unrepresentable === "throw") throw new Error("Date cannot be represented in JSON Schema");
105272
105283
  };
@@ -105298,12 +105309,53 @@ var literalProcessor = (schema, ctx, json, _params) => {
105298
105309
  json.enum = vals;
105299
105310
  }
105300
105311
  };
105312
+ var nanProcessor = (_schema, ctx, _json, _params) => {
105313
+ if (ctx.unrepresentable === "throw") throw new Error("NaN cannot be represented in JSON Schema");
105314
+ };
105315
+ var templateLiteralProcessor = (schema, _ctx, json, _params) => {
105316
+ const _json = json;
105317
+ const pattern = schema._zod.pattern;
105318
+ if (!pattern) throw new Error("Pattern not found in template literal");
105319
+ _json.type = "string";
105320
+ _json.pattern = pattern.source;
105321
+ };
105322
+ var fileProcessor = (schema, _ctx, json, _params) => {
105323
+ const _json = json;
105324
+ const file = {
105325
+ type: "string",
105326
+ format: "binary",
105327
+ contentEncoding: "binary"
105328
+ };
105329
+ const { minimum, maximum, mime } = schema._zod.bag;
105330
+ if (minimum !== void 0) file.minLength = minimum;
105331
+ if (maximum !== void 0) file.maxLength = maximum;
105332
+ if (mime) if (mime.length === 1) {
105333
+ file.contentMediaType = mime[0];
105334
+ Object.assign(_json, file);
105335
+ } else {
105336
+ Object.assign(_json, file);
105337
+ _json.anyOf = mime.map((m) => ({ contentMediaType: m }));
105338
+ }
105339
+ else Object.assign(_json, file);
105340
+ };
105341
+ var successProcessor = (_schema, _ctx, json, _params) => {
105342
+ json.type = "boolean";
105343
+ };
105301
105344
  var customProcessor = (_schema, ctx, _json, _params) => {
105302
105345
  if (ctx.unrepresentable === "throw") throw new Error("Custom types cannot be represented in JSON Schema");
105303
105346
  };
105347
+ var functionProcessor = (_schema, ctx, _json, _params) => {
105348
+ if (ctx.unrepresentable === "throw") throw new Error("Function types cannot be represented in JSON Schema");
105349
+ };
105304
105350
  var transformProcessor = (_schema, ctx, _json, _params) => {
105305
105351
  if (ctx.unrepresentable === "throw") throw new Error("Transforms cannot be represented in JSON Schema");
105306
105352
  };
105353
+ var mapProcessor = (_schema, ctx, _json, _params) => {
105354
+ if (ctx.unrepresentable === "throw") throw new Error("Map cannot be represented in JSON Schema");
105355
+ };
105356
+ var setProcessor = (_schema, ctx, _json, _params) => {
105357
+ if (ctx.unrepresentable === "throw") throw new Error("Set cannot be represented in JSON Schema");
105358
+ };
105307
105359
  var arrayProcessor = (schema, ctx, _json, params) => {
105308
105360
  const json = _json;
105309
105361
  const def = schema._zod.def;
@@ -105508,6 +105560,12 @@ var readonlyProcessor = (schema, ctx, json, params) => {
105508
105560
  seen.ref = def.innerType;
105509
105561
  json.readOnly = true;
105510
105562
  };
105563
+ var promiseProcessor = (schema, ctx, _json, params) => {
105564
+ const def = schema._zod.def;
105565
+ process$1(def.innerType, ctx, params);
105566
+ const seen = ctx.seen.get(schema);
105567
+ seen.ref = def.innerType;
105568
+ };
105511
105569
  var optionalProcessor = (schema, ctx, _json, params) => {
105512
105570
  const def = schema._zod.def;
105513
105571
  process$1(def.innerType, ctx, params);
@@ -105520,6 +105578,81 @@ var lazyProcessor = (schema, ctx, _json, params) => {
105520
105578
  const seen = ctx.seen.get(schema);
105521
105579
  seen.ref = innerType;
105522
105580
  };
105581
+ var allProcessors = {
105582
+ string: stringProcessor,
105583
+ number: numberProcessor,
105584
+ boolean: booleanProcessor,
105585
+ bigint: bigintProcessor,
105586
+ symbol: symbolProcessor,
105587
+ null: nullProcessor,
105588
+ undefined: undefinedProcessor,
105589
+ void: voidProcessor,
105590
+ never: neverProcessor,
105591
+ any: anyProcessor,
105592
+ unknown: unknownProcessor,
105593
+ date: dateProcessor,
105594
+ enum: enumProcessor,
105595
+ literal: literalProcessor,
105596
+ nan: nanProcessor,
105597
+ template_literal: templateLiteralProcessor,
105598
+ file: fileProcessor,
105599
+ success: successProcessor,
105600
+ custom: customProcessor,
105601
+ function: functionProcessor,
105602
+ transform: transformProcessor,
105603
+ map: mapProcessor,
105604
+ set: setProcessor,
105605
+ array: arrayProcessor,
105606
+ object: objectProcessor,
105607
+ union: unionProcessor,
105608
+ intersection: intersectionProcessor,
105609
+ tuple: tupleProcessor,
105610
+ record: recordProcessor,
105611
+ nullable: nullableProcessor,
105612
+ nonoptional: nonoptionalProcessor,
105613
+ default: defaultProcessor,
105614
+ prefault: prefaultProcessor,
105615
+ catch: catchProcessor,
105616
+ pipe: pipeProcessor,
105617
+ readonly: readonlyProcessor,
105618
+ promise: promiseProcessor,
105619
+ optional: optionalProcessor,
105620
+ lazy: lazyProcessor
105621
+ };
105622
+ function toJSONSchema(input, params) {
105623
+ if ("_idmap" in input) {
105624
+ const registry = input;
105625
+ const ctx = initializeContext({
105626
+ ...params,
105627
+ processors: allProcessors
105628
+ });
105629
+ const defs = {};
105630
+ for (const entry of registry._idmap.entries()) {
105631
+ const [_, schema] = entry;
105632
+ process$1(schema, ctx);
105633
+ }
105634
+ const schemas = {};
105635
+ ctx.external = {
105636
+ registry,
105637
+ uri: params?.uri,
105638
+ defs
105639
+ };
105640
+ for (const entry of registry._idmap.entries()) {
105641
+ const [key, schema] = entry;
105642
+ extractDefs(ctx, schema);
105643
+ schemas[key] = finalize(ctx, schema);
105644
+ }
105645
+ if (Object.keys(defs).length > 0) schemas.__shared = { [ctx.target === "draft-2020-12" ? "$defs" : "definitions"]: defs };
105646
+ return { schemas };
105647
+ }
105648
+ const ctx = initializeContext({
105649
+ ...params,
105650
+ processors: allProcessors
105651
+ });
105652
+ process$1(input, ctx);
105653
+ extractDefs(ctx, input);
105654
+ return finalize(ctx, input);
105655
+ }
105523
105656
  //#endregion
105524
105657
  //#region ../../node_modules/zod/v4/classic/iso.js
105525
105658
  var ZodISODateTime = /*@__PURE__*/ $constructor("ZodISODateTime", (inst, def) => {
@@ -106544,7 +106677,7 @@ var ZodIssueCode = {
106544
106677
  var ZodFirstPartyTypeKind;
106545
106678
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
106546
106679
  //#endregion
106547
- //#region ../types/dist/sleep-BKClVk0K.mjs
106680
+ //#region ../types/dist/sleep-D1jjWAr8.mjs
106548
106681
  /**
106549
106682
  * The audio chunk plane's byte format, and the ONE expansion from a coded
106550
106683
  * window to float samples (D455).
@@ -106699,6 +106832,9 @@ function isSecretMarkerLike(value) {
106699
106832
  function isSecretClear(value) {
106700
106833
  return isRecord$7(value) && value["$secretClear"] === true;
106701
106834
  }
106835
+ function isSecretFieldView(value) {
106836
+ return isRecord$7(value) && value["set"] === true && isSecretMarkerLike(value);
106837
+ }
106702
106838
  /**
106703
106839
  * The UI view of a stored secret field value: a marker becomes "set", inline
106704
106840
  * plaintext becomes "set (inline)", nothing becomes `null`. A value never
@@ -107141,7 +107277,7 @@ var NOT_RUN = {
107141
107277
  * throws: an unreachable or refusing engine is logged and reported `declared: false`,
107142
107278
  * because the hub (and this addon's boot) must not hang on a migration.
107143
107279
  */
107144
- async function runAddonMigrations(host, migrations, logger) {
107280
+ async function runAddonMigrations(host, migrations, logger, options = {}) {
107145
107281
  const first = migrations[0];
107146
107282
  if (first === void 0) return NOT_RUN;
107147
107283
  const owner = first.meta.owner;
@@ -107170,7 +107306,9 @@ async function runAddonMigrations(host, migrations, logger) {
107170
107306
  else if (status === "blocked") blocked.push(id);
107171
107307
  else skipped.push(id);
107172
107308
  };
107309
+ const selected = options.only === void 0 ? null : new Set(options.only);
107173
107310
  for (const step of declaration.steps) {
107311
+ if (selected !== null && !selected.has(step.id)) continue;
107174
107312
  const bound = byId.get(step.id);
107175
107313
  if (bound === void 0 || step.action === "skip") {
107176
107314
  skipped.push(step.id);
@@ -107348,11 +107486,36 @@ function projectStore(store, perNodeKeys, nodeId) {
107348
107486
  }
107349
107487
  return out;
107350
107488
  }
107489
+ /**
107490
+ * The address of one keychain value: `secret://<owner>/<scope>/<fieldPath>`.
107491
+ *
107492
+ * A ref is DETERMINISTIC from where the value lives — the owner addon, the
107493
+ * scope (global, one node, one device, one integration, one row of a business
107494
+ * collection) and the field path inside that scope — so saving the same field
107495
+ * twice updates the same ref, and the ref itself says which settings blob
107496
+ * points at it. It is also the AAD of the value's ciphertext: a ciphertext
107497
+ * copied onto another ref does not decrypt.
107498
+ *
107499
+ * Array items are addressed by ITEM ID, never by index
107500
+ * (`providers[authentik].clientSecret`), so reordering a list keeps every ref.
107501
+ *
107502
+ * Pure and browser-safe: no node builtins, no I/O.
107503
+ */
107504
+ /** The URI scheme of every keychain ref. */
107505
+ var SECRET_REF_SCHEME = "secret://";
107351
107506
  var GLOBAL_SCOPE = { kind: "global" };
107352
107507
  /** The scope every addon-wide (not per-node, not per-device) setting lives in. */
107353
107508
  function globalSecretScope() {
107354
107509
  return GLOBAL_SCOPE;
107355
107510
  }
107511
+ /**
107512
+ * An addon id as a ref owner. A hub in-process builtin's context id is
107513
+ * `addon:<id>`; the owner of its secrets is `<id>`, exactly as for the same
107514
+ * addon running forked.
107515
+ */
107516
+ function normalizeSecretOwner(addonId) {
107517
+ return addonId.startsWith("addon:") ? addonId.slice(6) : addonId;
107518
+ }
107356
107519
  function encodeSegment(value) {
107357
107520
  return encodeURIComponent(value);
107358
107521
  }
@@ -107363,6 +107526,16 @@ function decodeSegment(value) {
107363
107526
  return null;
107364
107527
  }
107365
107528
  }
107529
+ /** The scope as it appears inside a ref (`global`, `node/<id>`, …). */
107530
+ function formatSecretScope(scope) {
107531
+ switch (scope.kind) {
107532
+ case "global": return "global";
107533
+ case "node": return `node/${encodeSegment(scope.nodeId)}`;
107534
+ case "device": return `device/${scope.deviceId}`;
107535
+ case "integration": return `integration/${encodeSegment(scope.integrationId)}`;
107536
+ case "row": return `row/${encodeSegment(scope.collection)}/${encodeSegment(scope.rowId)}`;
107537
+ }
107538
+ }
107366
107539
  /**
107367
107540
  * The field path of a value inside an array item, addressed by the item's id:
107368
107541
  * `arrayItemFieldPath('providers', 'authentik', 'clientSecret')` →
@@ -107371,6 +107544,13 @@ function decodeSegment(value) {
107371
107544
  function arrayItemFieldPath(arrayKey, itemId, fieldKey) {
107372
107545
  return `${arrayKey}[${encodeSegment(itemId)}].${fieldKey}`;
107373
107546
  }
107547
+ /** Build a ref. Throws on an empty owner or field path — both are bugs. */
107548
+ function buildSecretRef(parts) {
107549
+ const owner = normalizeSecretOwner(parts.owner);
107550
+ if (owner.length === 0) throw new Error("buildSecretRef: owner is empty");
107551
+ if (parts.fieldPath.length === 0) throw new Error("buildSecretRef: fieldPath is empty");
107552
+ return `${SECRET_REF_SCHEME}${encodeSegment(owner)}/${formatSecretScope(parts.scope)}/${parts.fieldPath}`;
107553
+ }
107374
107554
  function parseScope(segments) {
107375
107555
  switch (segments[0]) {
107376
107556
  case "global": return {
@@ -108288,14 +108468,18 @@ var BaseAddon = class {
108288
108468
  * nothing; the boot continues. A migration that FAILED (or was blocked by one)
108289
108469
  * and declares `onFailure: 'block-owner'` makes this throw, so the addon does not
108290
108470
  * serve data whose shape it cannot trust; `tolerate` migrations never do.
108471
+ *
108472
+ * ALWAYS pass the whole catalogue; a phase that must run only some of it passes
108473
+ * `{ only: [ids] }` — never a filtered catalogue (the engine would judge the
108474
+ * omitted applied ids "newer").
108291
108475
  */
108292
- async runMigrations(migrations) {
108476
+ async runMigrations(migrations, options) {
108293
108477
  const api = this.ctx.api.migrations;
108294
108478
  const report = await runAddonMigrations({
108295
108479
  declareOwner: (input) => api.declareOwner.mutate(input),
108296
108480
  startMigration: (input) => api.startMigration.mutate(input),
108297
108481
  finishMigration: (input) => api.finishMigration.mutate(input)
108298
- }, migrations, this.ctx.logger);
108482
+ }, migrations, this.ctx.logger, options);
108299
108483
  if (report.blockOwner.length > 0) throw new AddonMigrationsBlockedError(report.blockOwner);
108300
108484
  return report;
108301
108485
  }
@@ -108636,6 +108820,10 @@ var BaseAddon = class {
108636
108820
  const stored = keychain ? await this.readAddonStoreWithRetry() : {};
108637
108821
  const patch = {};
108638
108822
  for (const key of keys) {
108823
+ if (tree.get(key)?.kind === "array") {
108824
+ patch[key] = [];
108825
+ continue;
108826
+ }
108639
108827
  const keychainStored = tree.has(key) || collectSecretMarkerRefs(stored[key]).length > 0;
108640
108828
  patch[key] = keychain && keychainStored ? SECRET_CLEAR : "";
108641
108829
  }
@@ -109275,6 +109463,45 @@ function normalizeAddonInitResult(result) {
109275
109463
  if (Array.isArray(result)) return { providers: result };
109276
109464
  return result;
109277
109465
  }
109466
+ var PREFIX = "records/";
109467
+ /** The base of every records failure. `detail` is the human part of the message. */
109468
+ var RecordsError = class extends Error {
109469
+ code;
109470
+ detail;
109471
+ constructor(code, detail) {
109472
+ super(`${PREFIX}${code}: ${detail}`);
109473
+ this.name = "RecordsError";
109474
+ this.code = code;
109475
+ this.detail = detail;
109476
+ }
109477
+ };
109478
+ /** A write guarded by `ifRev` found another revision (or an existing row for `ifRev: 0`). */
109479
+ var RevConflictError = class extends RecordsError {
109480
+ constructor(detail) {
109481
+ super("rev-conflict", detail);
109482
+ this.name = "RevConflictError";
109483
+ }
109484
+ };
109485
+ /** The keychain could not store or serve a value: explicit, never an empty string. */
109486
+ var RecordSecretUnavailableError = class extends RecordsError {
109487
+ constructor(detail) {
109488
+ super("secret-unavailable", detail);
109489
+ this.name = "RecordSecretUnavailableError";
109490
+ }
109491
+ };
109492
+ var InvalidRecordError = class extends RecordsError {
109493
+ constructor(detail) {
109494
+ super("invalid-record", detail);
109495
+ this.name = "InvalidRecordError";
109496
+ }
109497
+ };
109498
+ /** A kind declaration the engine refuses (non-additive change, bad shape, wrong file). */
109499
+ var DeclarationRefusedError = class extends RecordsError {
109500
+ constructor(detail) {
109501
+ super("declaration-refused", detail);
109502
+ this.name = "DeclarationRefusedError";
109503
+ }
109504
+ };
109278
109505
  /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
109279
109506
  /** The files a kind may live in. `history` is device-history's (D850), never records. */
109280
109507
  var RECORD_DATABASES = ["settings", "events"];
@@ -109290,6 +109517,183 @@ var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
109290
109517
  var RECORD_MAX_DATA_BYTES = 1024 * 1024;
109291
109518
  var RECORD_BATCH_MAX = 1e3;
109292
109519
  var RECORD_QUERY_MAX = 1e3;
109520
+ /** A kind in the `settings` file may not bound itself above this (D447: high volume is `events`). */
109521
+ var RECORD_SETTINGS_MAX_ROWS = 5e4;
109522
+ /** The `Column`-free proof prefix: a ref collection for a kind's secret fields. */
109523
+ var RECORD_SECRET_COLLECTION_PREFIX = "records:";
109524
+ /**
109525
+ * Secret fields of a record, addressed the way a Zod schema declares them
109526
+ * (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
109527
+ * (`auth.accessToken`, `headers[].value`).
109528
+ *
109529
+ * `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
109530
+ * names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
109531
+ * key releases exactly that key's secret.
109532
+ *
109533
+ * Array elements are matched by a DECLARED id key (`secretArrayKeys`:
109534
+ * `{ headers: 'name' }`), never by index, so reordering a list keeps every
109535
+ * keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
109536
+ * leaf names its element: `headers[X-Api].value`.
109537
+ *
109538
+ * Pure and browser-safe. Nothing here mutates: every transform returns a new value.
109539
+ */
109540
+ /** The array prefixes (declared form, e.g. `headers` or `a.headers`) of a path. */
109541
+ function parseSecretPath(path) {
109542
+ if (path.length === 0) throw new InvalidRecordError("an empty secret path");
109543
+ return path.split(".").map((raw) => {
109544
+ const each = raw.endsWith("[]");
109545
+ const map = raw.endsWith("{}");
109546
+ const key = each || map ? raw.slice(0, -2) : raw;
109547
+ if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
109548
+ return {
109549
+ key,
109550
+ each,
109551
+ map
109552
+ };
109553
+ });
109554
+ }
109555
+ /** The declared array prefixes of `path` that need an id key (`headers`, `a.rows`). */
109556
+ function secretArrayPrefixes(path) {
109557
+ const segments = parseSecretPath(path);
109558
+ const prefixes = [];
109559
+ const walked = [];
109560
+ for (const segment of segments) {
109561
+ walked.push(segment.key);
109562
+ if (segment.each) prefixes.push(walked.join("."));
109563
+ }
109564
+ return prefixes;
109565
+ }
109566
+ function isRecord$4(value) {
109567
+ return typeof value === "object" && value !== null && !Array.isArray(value);
109568
+ }
109569
+ function elementId(element, idKey, declared, prefix) {
109570
+ const id = isRecord$4(element) ? element[idKey] : void 0;
109571
+ if (typeof id !== "string" || id.length === 0) throw new InvalidRecordError(`"${declared}": every element of "${prefix}" needs a non-empty string "${idKey}" to carry a secret`);
109572
+ return id;
109573
+ }
109574
+ /**
109575
+ * Visit every secret leaf PRESENT in `data` (an absent branch has nothing to
109576
+ * visit). `replace` returns the new leaf value, or {@link REMOVE_LEAF}; the
109577
+ * result is a new body, `data` is untouched.
109578
+ */
109579
+ var REMOVE_LEAF = Symbol("remove-leaf");
109580
+ function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arrayKeys, visit) {
109581
+ if (!isRecord$4(node)) return node;
109582
+ const segment = segments[index];
109583
+ if (segment === void 0) return node;
109584
+ if (!Object.prototype.hasOwnProperty.call(node, segment.key)) return node;
109585
+ const child = node[segment.key];
109586
+ const keys = [...walkedKeys, segment.key];
109587
+ const last = index === segments.length - 1;
109588
+ if (segment.each) {
109589
+ if (!Array.isArray(child)) return node;
109590
+ const prefix = keys.join(".");
109591
+ const idKey = arrayKeys[prefix];
109592
+ if (idKey === void 0) throw new InvalidRecordError(`"${declared}": no id key declared for the array "${prefix}"`);
109593
+ const mapped = child.map((element) => {
109594
+ const id = elementId(element, idKey, declared, prefix);
109595
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}[${encodeURIComponent(id)}]`;
109596
+ return last ? element : mapAt(element, segments, index + 1, keys, concrete, declared, arrayKeys, visit);
109597
+ });
109598
+ return {
109599
+ ...node,
109600
+ [segment.key]: mapped
109601
+ };
109602
+ }
109603
+ if (segment.map) {
109604
+ if (!isRecord$4(child)) return node;
109605
+ const kept = Object.entries(child).map(([mapKey, value]) => {
109606
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
109607
+ if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
109608
+ return [mapKey, visit({
109609
+ declared,
109610
+ concrete,
109611
+ value
109612
+ })];
109613
+ }).filter(([, value]) => value !== REMOVE_LEAF);
109614
+ return {
109615
+ ...node,
109616
+ [segment.key]: Object.fromEntries(kept)
109617
+ };
109618
+ }
109619
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
109620
+ if (last) {
109621
+ const replacement = visit({
109622
+ declared,
109623
+ concrete,
109624
+ value: child
109625
+ });
109626
+ if (replacement === REMOVE_LEAF) return Object.fromEntries(Object.entries(node).filter(([key]) => key !== segment.key));
109627
+ return {
109628
+ ...node,
109629
+ [segment.key]: replacement
109630
+ };
109631
+ }
109632
+ return {
109633
+ ...node,
109634
+ [segment.key]: mapAt(child, segments, index + 1, keys, concrete, declared, arrayKeys, visit)
109635
+ };
109636
+ }
109637
+ /** Replace/remove every present secret leaf; returns a NEW body. */
109638
+ function mapSecretLeaves(data, paths, arrayKeys, visit) {
109639
+ let current = data;
109640
+ for (const declared of paths) current = mapAt(current, parseSecretPath(declared), 0, [], "", declared, arrayKeys, visit);
109641
+ return current;
109642
+ }
109643
+ /** Every present secret leaf, read-only. */
109644
+ function collectSecretLeaves(data, paths, arrayKeys) {
109645
+ const found = [];
109646
+ mapSecretLeaves(data, paths, arrayKeys, (leaf) => {
109647
+ found.push(leaf);
109648
+ return leaf.value;
109649
+ });
109650
+ return found;
109651
+ }
109652
+ /**
109653
+ * The keychain collection name a kind's secrets live under. Prefixed so a
109654
+ * record's refs never collide with the refs a legacy collection of the same name
109655
+ * already holds during an adoption.
109656
+ */
109657
+ function recordSecretCollection(kind) {
109658
+ return `${RECORD_SECRET_COLLECTION_PREFIX}${kind}`;
109659
+ }
109660
+ /** The ONE ref a row's secret field may carry: the engine and the facade both build it here. */
109661
+ function recordSecretRef(owner, kind, rowId, concretePath) {
109662
+ return buildSecretRef({
109663
+ owner,
109664
+ scope: {
109665
+ kind: "row",
109666
+ collection: recordSecretCollection(kind),
109667
+ rowId
109668
+ },
109669
+ fieldPath: concretePath
109670
+ });
109671
+ }
109672
+ /**
109673
+ * A stored secret, as an admin read shows it: a VIEW naming the row's own
109674
+ * keychain ref (`set: true`), never the value and no longer the bare redaction
109675
+ * token. The form hands it to the secret field, which reveals the value by ref
109676
+ * through the audited `keychain-admin.reveal`; sent back untouched it keeps the
109677
+ * stored secret (the records door reads a view as KEEP).
109678
+ *
109679
+ * ONE implementation for the entries service and for every addon that serves
109680
+ * its own instances (`RecordIntegrationInstances`).
109681
+ */
109682
+ function isRecord$3(value) {
109683
+ return typeof value === "object" && value !== null && !Array.isArray(value);
109684
+ }
109685
+ function secretViewsOf(owner, kind, id, decl, data) {
109686
+ if (decl.secretPaths.length === 0) return data;
109687
+ const shown = mapSecretLeaves(data, decl.secretPaths, decl.secretArrayKeys, (leaf) => {
109688
+ if (leaf.value !== "__camstack_redacted__") return leaf.value;
109689
+ return {
109690
+ $secret: recordSecretRef(owner, kind, id, leaf.concrete),
109691
+ v: 1,
109692
+ set: true
109693
+ };
109694
+ });
109695
+ return isRecord$3(shown) ? shown : data;
109696
+ }
109293
109697
  /**
109294
109698
  * The share-view scope kinds a share-link token can carry. The hub mints
109295
109699
  * and validates them (`ShareViewGrantSchema` builds its
@@ -109362,7 +109766,8 @@ function isZodSecret(schema) {
109362
109766
  }
109363
109767
  /**
109364
109768
  * Every declared secret path in a config schema: dotted keys, `[]` for "each
109365
- * element of this array" (`connection.password`, `rows[].token`). A declared
109769
+ * element of this array" (`connection.password`, `rows[].token`), `{}` for "each value of this
109770
+ * map" (`applianceSecrets{}`). A declared
109366
109771
  * object or array is reported as one path and not descended into — everything
109367
109772
  * under it is secret.
109368
109773
  */
@@ -109373,6 +109778,7 @@ function collectZodSecretPaths(schema, prefix = "") {
109373
109778
  const def = defOf(layer);
109374
109779
  if (def.type === "object" && def.shape) for (const [key, child] of Object.entries(def.shape)) paths.push(...collectZodSecretPaths(child, prefix ? `${prefix}.${key}` : key));
109375
109780
  else if (def.type === "array" && def.element) paths.push(...collectZodSecretPaths(def.element, `${prefix}[]`));
109781
+ else if (def.type === "record" && def.valueType) paths.push(...collectZodSecretPaths(def.valueType, `${prefix}{}`));
109376
109782
  else if (def.type === "union" && def.options) for (const option of def.options) paths.push(...collectZodSecretPaths(option, prefix));
109377
109783
  else if (def.type === "intersection") {
109378
109784
  if (def.left) paths.push(...collectZodSecretPaths(def.left, prefix));
@@ -109447,6 +109853,126 @@ var RecordKindDeclarationSchema = object({
109447
109853
  }).strict();
109448
109854
  /** The kind names: lower-case, dash separated, short enough for an index name. */
109449
109855
  var RecordKindNameSchema = string().regex(/^[a-z][a-z0-9-]{0,47}$/);
109856
+ /** Cross-field rules a declaration must satisfy. Throws {@link DeclarationRefusedError}. */
109857
+ function assertValidDeclaration(name, decl) {
109858
+ const refuse = (why) => {
109859
+ throw new DeclarationRefusedError(`kind "${name}": ${why}`);
109860
+ };
109861
+ if (!RecordKindNameSchema.safeParse(name).success) refuse("the name must be lower-case-dashed");
109862
+ const seen = /* @__PURE__ */ new Set();
109863
+ for (const index of decl.indexes) {
109864
+ if (seen.has(index.name)) refuse(`index name "${index.name}" is declared twice`);
109865
+ seen.add(index.name);
109866
+ }
109867
+ const { retention } = decl;
109868
+ const hasSecrets = decl.secretPaths.length > 0;
109869
+ if ("ttlMs" in retention) {
109870
+ if (retention.from === "at" && decl.at !== "required") refuse("a ttl from \"at\" needs at: \"required\"");
109871
+ if (hasSecrets) refuse("a kind with secret fields cannot use engine TTL (its keychain values would be orphaned)");
109872
+ }
109873
+ if ("maxRows" in retention || "maxRowsPerDevice" in retention) {
109874
+ if (retention.maxRowsPerDevice !== void 0 && decl.device !== "required") refuse("maxRowsPerDevice needs device: \"required\"");
109875
+ if (decl.at !== "required") refuse("a row bound trims the oldest \"at\": it needs at: \"required\"");
109876
+ if (hasSecrets) refuse("a kind with secret fields cannot use an engine row bound");
109877
+ const bound = Math.max(retention.maxRows ?? 0, retention.maxRowsPerDevice ?? 0);
109878
+ if (decl.database === "settings" && bound > 5e4) refuse(`a row bound above ${RECORD_SETTINGS_MAX_ROWS} belongs in database: "events" (a big delete on the settings thread stalls every configuration read)`);
109879
+ }
109880
+ if (decl.deviceRemoval === "delete" && decl.device === "none") refuse("deviceRemoval: \"delete\" needs a device: \"required\" or \"optional\"");
109881
+ for (const path of decl.secretPaths) for (const prefix of secretArrayPrefixes(path)) if (decl.secretArrayKeys[prefix] === void 0) refuse(`secret path "${path}" crosses the array "${prefix}": declare its id key in secretArrayKeys`);
109882
+ }
109883
+ function bodyFieldsOf(schema) {
109884
+ return { ...toJSONSchema(schema, {
109885
+ unrepresentable: "any",
109886
+ io: "input"
109887
+ }) };
109888
+ }
109889
+ function isRecordLike(value) {
109890
+ return typeof value === "object" && value !== null && !Array.isArray(value);
109891
+ }
109892
+ /** Does every branch of the body schema carry `field`? (A discriminated union has several.) */
109893
+ function hasNameField(json, field) {
109894
+ const branches = json["oneOf"] ?? json["anyOf"];
109895
+ if (Array.isArray(branches)) return branches.length > 0 && branches.every((b) => isRecordLike(b) && hasNameField(b, field));
109896
+ const properties = json["properties"];
109897
+ return isRecordLike(properties) && Object.prototype.hasOwnProperty.call(properties, field);
109898
+ }
109899
+ /**
109900
+ * Declare the kinds of a package. The keys are the kind names. The result is
109901
+ * what `ctx.records.declare` sends to the engine and what `ctx.records.kind`
109902
+ * types its handle from.
109903
+ */
109904
+ function defineRecordKinds(specs) {
109905
+ const out = {};
109906
+ for (const [name, spec] of Object.entries(specs)) {
109907
+ const secretPaths = collectZodSecretPaths(spec.schema);
109908
+ const nameField = spec.entry?.nameField ?? "name";
109909
+ const fields = bodyFieldsOf(spec.schema);
109910
+ if (spec.entry !== void 0 && !hasNameField(fields, nameField)) throw new DeclarationRefusedError(`kind "${name}": an entry kind's schema needs a "${nameField}" field`);
109911
+ const parsed = RecordKindDeclarationSchema.parse({
109912
+ database: spec.database,
109913
+ class: spec.class,
109914
+ writeTrigger: spec.writeTrigger,
109915
+ device: spec.device,
109916
+ at: spec.at,
109917
+ indexes: spec.indexes === void 0 ? void 0 : [...spec.indexes],
109918
+ retention: spec.retention,
109919
+ deviceRemoval: spec.deviceRemoval,
109920
+ maxDataBytes: spec.maxDataBytes,
109921
+ secretPaths: [...new Set([...secretPaths, ...spec.retiredSecretPaths ?? []])],
109922
+ secretArrayKeys: spec.secretArrayKeys === void 0 ? void 0 : { ...spec.secretArrayKeys },
109923
+ transactFamily: spec.transactFamily,
109924
+ entry: spec.entry === void 0 ? void 0 : {
109925
+ title: spec.entry.title,
109926
+ nameField,
109927
+ fields
109928
+ }
109929
+ });
109930
+ assertValidDeclaration(name, parsed);
109931
+ out[name] = {
109932
+ name,
109933
+ schema: spec.schema,
109934
+ declaration: parsed
109935
+ };
109936
+ }
109937
+ return out;
109938
+ }
109939
+ /**
109940
+ * Entry kinds (D881): every list of user-created entries (notification
109941
+ * targets, brokers, LLM profiles) is ONE record kind with an id, a name and a
109942
+ * kind-specific body, and the admin UI lists/adds/edits/deletes all of them
109943
+ * through the `record-entries` capability with password fields derived from
109944
+ * the schema (`.meta({ secret: true })`).
109945
+ */
109946
+ /** The body of a plain entry: a name and the fields the kind adds. */
109947
+ function entryBody(shape) {
109948
+ return object({
109949
+ name: string().min(1),
109950
+ ...shape
109951
+ });
109952
+ }
109953
+ /**
109954
+ * Declare one entry kind. An entry is configuration: never trimmed by the
109955
+ * engine, never aged, belonging to no device.
109956
+ */
109957
+ function defineEntryKind(name, spec) {
109958
+ const defined = defineRecordKinds({ [name]: {
109959
+ schema: spec.schema,
109960
+ class: "config",
109961
+ writeTrigger: spec.writeTrigger ?? "an operator adds or edits an entry in the entries UI",
109962
+ retention: { none: "an entry goes only when the operator deletes it" },
109963
+ ...spec.maxDataBytes === void 0 ? {} : { maxDataBytes: spec.maxDataBytes },
109964
+ ...spec.database === void 0 ? {} : { database: spec.database },
109965
+ ...spec.indexes === void 0 ? {} : { indexes: spec.indexes },
109966
+ ...spec.secretArrayKeys === void 0 ? {} : { secretArrayKeys: spec.secretArrayKeys },
109967
+ ...spec.retiredSecretPaths === void 0 ? {} : { retiredSecretPaths: spec.retiredSecretPaths },
109968
+ entry: {
109969
+ title: spec.title,
109970
+ ...spec.nameField === void 0 ? {} : { nameField: spec.nameField }
109971
+ }
109972
+ } })[name];
109973
+ if (defined === void 0) throw new DeclarationRefusedError(`entry kind "${name}" was not built`);
109974
+ return defined;
109975
+ }
109450
109976
  object({
109451
109977
  field: string().min(1),
109452
109978
  on: literal("advance")
@@ -112251,7 +112777,7 @@ function sleep(ms) {
112251
112777
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
112252
112778
  }
112253
112779
  //#endregion
112254
- //#region ../types/dist/composition-BodkvOV0.mjs
112780
+ //#region ../types/dist/composition-CRNwcFYY.mjs
112255
112781
  /**
112256
112782
  * The ffmpeg build every CamStack node downloads, per platform and arch.
112257
112783
  *
@@ -113014,38 +113540,6 @@ function validateExpressionSource(src) {
113014
113540
  return null;
113015
113541
  }
113016
113542
  /**
113017
- * One power reading becomes a washer, dryer or dishwasher (D944 helper).
113018
- *
113019
- * Standby of the control board is under 5 W; a motor or a heater is far
113020
- * above that, so 5 W is the line. A door lock or a short pump spike is not
113021
- * a cycle, so running starts only after 30 s above the line.
113022
- *
113023
- * A wash, dry or dishwasher pause (soak, heat-wait, heater off between
113024
- * bursts) lasts several minutes. Ending the cycle on the first dip would
113025
- * report "finished" in the middle of the program, so the stop dwell is
113026
- * 10 minutes on every type. The number is stored per type so one machine
113027
- * can move later without moving the others.
113028
- */
113029
- var APPLIANCE_TEMPLATE_ID = "appliance";
113030
- var APPLIANCE_KINDS = [
113031
- "washer",
113032
- "dryer",
113033
- "dishwasher"
113034
- ];
113035
- DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher;
113036
- /**
113037
- * A dumb plug-in stove or heater, built from a smart plug (a `Heater`).
113038
- *
113039
- * The plug is the actuator: `switch.setState` is a FORWARD to the plug's own
113040
- * (D967), so the heater switches exactly as the plug does. Running is the
113041
- * plug being on; when a power reading is also named, running is the draw
113042
- * staying above the line instead (a plug left on with the stove's own
113043
- * thermostat open is not heating). 50 W is far above any standby and below
113044
- * the smallest radiator.
113045
- */
113046
- var HEATER_TEMPLATE_ID = "heater";
113047
- DeviceType.Heater;
113048
- /**
113049
113543
  * Statistics and integrals over a history read (D944).
113050
113544
  *
113051
113545
  * Pure: the same points always give the same answer, so a restart recomputes
@@ -113189,34 +113683,6 @@ var HelperSpecSchema = discriminatedUnion("kind", [
113189
113683
  /** Actuator groups only. Readings leave this unset. */
113190
113684
  active: ActiveCriterionSchema.optional()
113191
113685
  }),
113192
- object({
113193
- kind: literal("appliance"),
113194
- templateId: literal(APPLIANCE_TEMPLATE_ID),
113195
- appliance: _enum(APPLIANCE_KINDS),
113196
- thresholdWatts: number().positive(),
113197
- startDwellMs: number().int().nonnegative(),
113198
- stopDwellMs: number().int().nonnegative(),
113199
- member: HelperMemberSchema,
113200
- /**
113201
- * The plug that powers the appliance (`switch.on`). Optional: when set, the
113202
- * composition carries a `power-source` feature, so the hub can cut and restore
113203
- * that plug (power-source link of a NEW-target composition).
113204
- */
113205
- powerSwitch: HelperMemberSchema.optional(),
113206
- /** How long a forced restart keeps the plug off; the cap's default when unset. */
113207
- offSeconds: number().optional()
113208
- }),
113209
- object({
113210
- kind: literal("heater"),
113211
- templateId: literal(HEATER_TEMPLATE_ID),
113212
- /** The plug, read and commanded at `switch.on`. */
113213
- plug: HelperMemberSchema,
113214
- /** The plug's power reading, when it has one: running is then the draw, not the switch. */
113215
- meter: HelperMemberSchema.optional(),
113216
- thresholdWatts: number().positive(),
113217
- startDwellMs: number().int().nonnegative(),
113218
- stopDwellMs: number().int().nonnegative()
113219
- }),
113220
113686
  object({
113221
113687
  kind: literal("statistic"),
113222
113688
  stat: _enum(STATISTIC_STATS),
@@ -113440,7 +113906,7 @@ var CompositionSchema = object({
113440
113906
  kind: literal("existing"),
113441
113907
  device: CompositionSourceRefSchema
113442
113908
  })]),
113443
- features: array(CompositionFeatureSchema).min(1).max(16),
113909
+ features: array(CompositionFeatureSchema).min(0).max(16),
113444
113910
  /**
113445
113911
  * Present when the block is a helper template. Absent on a free
113446
113912
  * composition (the room example, device 4603). The editor reads this,
@@ -113679,6 +114145,205 @@ var IntegrationInstanceRefSchema = object({
113679
114145
  ]),
113680
114146
  candidateStableId: string().optional()
113681
114147
  });
114148
+ /**
114149
+ * `RecordIntegrationInstances` - the `integration-instances` provider over ONE
114150
+ * entry kind, so an addon that holds a connection serves it in ~30 lines.
114151
+ *
114152
+ * Every instance is one record of the addon's own kind, keyed by the
114153
+ * `integrations` registry id. The records door does the secret work: a field the
114154
+ * kind marks `.meta({ secret: true })` is a keychain marker bound to the row, so
114155
+ * deleting the row deletes the secret, and one row's value is never another's.
114156
+ *
114157
+ * Patch rules for a secret (`updateInstance`): omitted, `''`, the redaction
114158
+ * token or the view the form was given KEEP the stored secret;
114159
+ * `{ $secretClear: true }` clears it; any other string replaces it.
114160
+ */
114161
+ /** The most instances one addon lists (an integrations page, not a table). */
114162
+ var INSTANCES_LIST_MAX = 1e3;
114163
+ /** The keys of the kind's secret fields that sit at the top level of the body. */
114164
+ function topLevelSecretKeys(paths) {
114165
+ return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
114166
+ }
114167
+ /** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
114168
+ function secretMapKeys(paths) {
114169
+ return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
114170
+ }
114171
+ /**
114172
+ * A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
114173
+ * view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
114174
+ * or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
114175
+ */
114176
+ function secretMapPatch(value) {
114177
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
114178
+ const out = {};
114179
+ for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
114180
+ else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
114181
+ return Object.keys(out).length > 0 ? out : null;
114182
+ }
114183
+ /** Does the kind's body schema carry an `enabled` field? */
114184
+ function declaresEnabled(kind) {
114185
+ const json = toJSONSchema(kind.schema, {
114186
+ unrepresentable: "any",
114187
+ io: "input"
114188
+ });
114189
+ const properties = Reflect.get(json, "properties");
114190
+ return typeof properties === "object" && properties !== null && "enabled" in properties;
114191
+ }
114192
+ var RecordIntegrationInstances = class {
114193
+ records;
114194
+ kind;
114195
+ hooks;
114196
+ handle;
114197
+ secretKeys;
114198
+ secretMaps;
114199
+ nameField;
114200
+ /** The `integrations` registry owns the on/off switch (`role: devices`); the body has none. */
114201
+ registryOwnsEnabled;
114202
+ constructor(records, kind, hooks) {
114203
+ this.records = records;
114204
+ this.kind = kind;
114205
+ this.hooks = hooks;
114206
+ this.handle = records.kind(kind);
114207
+ this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
114208
+ this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
114209
+ this.nameField = kind.declaration.entry?.nameField ?? "name";
114210
+ this.registryOwnsEnabled = hooks.description.role === "devices";
114211
+ if (this.registryOwnsEnabled && declaresEnabled(kind)) throw new Error(`kind "${kind.name}": the integrations registry owns the on/off switch of a role "devices" addon, so the body must not declare "enabled"`);
114212
+ }
114213
+ /**
114214
+ * One instance read for the addon's own runtime; null when absent. When the keychain is
114215
+ * locked the row is served WITHOUT its secrets and says so (`secrets: 'unavailable'`), so the
114216
+ * caller can keep what it had instead of reconfiguring with an empty secret (D49).
114217
+ */
114218
+ async readResolved(id) {
114219
+ try {
114220
+ const record = await this.handle.getResolved(id);
114221
+ return record === null ? null : {
114222
+ id,
114223
+ body: record.data,
114224
+ enabled: record.data.enabled !== false,
114225
+ secrets: "resolved"
114226
+ };
114227
+ } catch (err) {
114228
+ if (!(err instanceof RecordSecretUnavailableError)) throw err;
114229
+ const stored = await this.handle.get(id);
114230
+ if (stored === null) return null;
114231
+ return {
114232
+ id,
114233
+ body: Object.fromEntries(Object.entries(stored.data).filter(([key]) => !this.secretKeys.has(key))),
114234
+ enabled: stored.data.enabled !== false,
114235
+ secrets: "unavailable"
114236
+ };
114237
+ }
114238
+ }
114239
+ /** The body of {@link readResolved} alone; a locked keychain yields the row without secrets. */
114240
+ async getResolved(id) {
114241
+ return (await this.readResolved(id))?.body ?? null;
114242
+ }
114243
+ /** Every instance, secrets resolved where the keychain allows; an absent one is skipped. */
114244
+ async listResolved() {
114245
+ const page = await this.handle.query({ limit: INSTANCES_LIST_MAX });
114246
+ const out = [];
114247
+ for (const record of page.records) {
114248
+ const read = await this.readResolved(record.id);
114249
+ if (read !== null) out.push(read);
114250
+ }
114251
+ return out;
114252
+ }
114253
+ viewOf(record) {
114254
+ const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
114255
+ const rawName = record.data[this.nameField];
114256
+ const kind = this.hooks.kindOf?.(record.data);
114257
+ const health = this.hooks.health?.(record.data, record.id);
114258
+ return {
114259
+ id: record.id,
114260
+ name: typeof rawName === "string" ? rawName : record.id,
114261
+ configured: this.hooks.configured(record.data),
114262
+ ...this.registryOwnsEnabled ? {} : { enabled: record.data.enabled !== false },
114263
+ formValues: shown,
114264
+ ...kind !== void 0 ? { kind } : {},
114265
+ ...health?.status !== void 0 ? { status: health.status } : {},
114266
+ ...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
114267
+ };
114268
+ }
114269
+ describe = async () => this.hooks.mintId === void 0 ? this.hooks.description : {
114270
+ ...this.hooks.description,
114271
+ mintsId: true
114272
+ };
114273
+ instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
114274
+ listInstances = async () => {
114275
+ return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
114276
+ };
114277
+ getInstance = async ({ id }) => {
114278
+ const record = await this.handle.get(id);
114279
+ return record === null ? null : this.viewOf(record);
114280
+ };
114281
+ /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
114282
+ createInstance = async ({ id, name, kind }) => {
114283
+ const minted = id ?? this.hooks.mintId?.({
114284
+ name,
114285
+ ...kind !== void 0 ? { kind } : {}
114286
+ });
114287
+ if (minted === void 0) throw new Error("createInstance needs an id: this provider does not mint one");
114288
+ await this.handle.put({
114289
+ id: minted,
114290
+ data: this.hooks.initialBody(name, kind)
114291
+ }, { ifRev: 0 });
114292
+ await this.hooks.onChanged?.(minted, "created");
114293
+ return { id: minted };
114294
+ };
114295
+ updateInstance = async ({ id, name, values }) => {
114296
+ const patch = {};
114297
+ for (const [key, value] of Object.entries(values)) {
114298
+ if (value === void 0) continue;
114299
+ if (this.secretMaps.has(key)) {
114300
+ const entries = secretMapPatch(value);
114301
+ if (entries !== null) patch[key] = entries;
114302
+ } else if (!this.secretKeys.has(key)) patch[key] = value;
114303
+ else if (isSecretClear(value)) patch[key] = null;
114304
+ else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
114305
+ }
114306
+ if (name !== void 0) patch[this.nameField] = name;
114307
+ await this.handle.patch(id, patch);
114308
+ await this.hooks.onChanged?.(id, "updated");
114309
+ return { ok: true };
114310
+ };
114311
+ /** The switch lives in the record body; the addon is told so it applies it (D996). */
114312
+ setInstanceEnabled = async ({ id, enabled }) => {
114313
+ if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
114314
+ await this.handle.patch(id, { enabled });
114315
+ await this.hooks.onChanged?.(id, "enabled");
114316
+ return { ok: true };
114317
+ };
114318
+ /** The row and its keychain refs go together (the records door). */
114319
+ deleteInstance = async ({ id }) => {
114320
+ await this.handle.delete(id);
114321
+ await this.hooks.onChanged?.(id, "deleted");
114322
+ return { ok: true };
114323
+ };
114324
+ testInstance = async (input) => {
114325
+ if (this.hooks.test === void 0) return {
114326
+ success: false,
114327
+ status: "unsupported",
114328
+ error: "This integration declares no connection test"
114329
+ };
114330
+ return this.hooks.test(input);
114331
+ };
114332
+ /** The flat provider object to hand to `registerProvider`. */
114333
+ toProvider() {
114334
+ return {
114335
+ describe: this.describe,
114336
+ instanceFormSchema: this.instanceFormSchema,
114337
+ listInstances: this.listInstances,
114338
+ getInstance: this.getInstance,
114339
+ createInstance: this.createInstance,
114340
+ updateInstance: this.updateInstance,
114341
+ setInstanceEnabled: this.setInstanceEnabled,
114342
+ deleteInstance: this.deleteInstance,
114343
+ testInstance: this.testInstance
114344
+ };
114345
+ }
114346
+ };
113682
114347
  var EncodeProfileSchema = object({
113683
114348
  video: object({
113684
114349
  codec: _enum([
@@ -114744,7 +115409,13 @@ var MigrationLedgerEntrySchema = object({
114744
115409
  * joined by the engine from the catalogues it knows this boot. Absent when no
114745
115410
  * catalogue lists the id (a newer database).
114746
115411
  */
114747
- meta: MigrationMetaSchema.optional()
115412
+ meta: MigrationMetaSchema.optional(),
115413
+ /**
115414
+ * Set when this is an applied row of an addon owner whose catalogue the answering
115415
+ * process has not loaded (the owner has not declared yet): the row is applied, the
115416
+ * process just cannot judge it. Never `skipped-newer` — that needs the owner loaded.
115417
+ */
115418
+ ownerNotLoaded: boolean().optional()
114748
115419
  });
114749
115420
  /** A catalogued migration that is not applied, with where it stands. */
114750
115421
  var MigrationPendingEntrySchema = object({
@@ -114778,6 +115449,304 @@ var MigrationOwnerStepSchema = object({
114778
115449
  state: union([literal("pending"), MigrationStatusSchema])
114779
115450
  });
114780
115451
  /**
115452
+ * Move an integration's legacy `integration_settings` rows into a typed record of the addon's own
115453
+ * kind (planning#416, the one store). The record is keyed by the `integrations` registry id, so
115454
+ * the integration id, the devices stamped with it and the automations that name it do not change.
115455
+ *
115456
+ * Order, so a crash or a locked keychain is safe at every point:
115457
+ *
115458
+ * 1. Phase A, read only: every row of every integration of the addon is read with its secrets
115459
+ * resolved. One that cannot be read (a locked keychain) throws, naming the integration, before
115460
+ * anything is written; `block-owner` then holds the addon.
115461
+ * 2. Phase B, per integration: the addon's `bodyFrom` builds the body; the record is put with
115462
+ * `ifRev: 0` (an existing record is never overwritten). A record this pass CREATED is read back
115463
+ * and every secret leaf must equal what the rows held, else the record is deleted and the pass
115464
+ * throws; an EXISTING record must hold every secret the rows carry, else the rows are kept and
115465
+ * a warning names the path. Only then are the rows (and their keychain values) cleared.
115466
+ *
115467
+ * Idempotent: an integration whose rows are gone and whose record exists is skipped, so a rerun is
115468
+ * a no-op. A default integration with no rows still gets its record (an empty body, D985).
115469
+ * Never logs or throws a secret value; messages name integration ids and secret PATHS only.
115470
+ */
115471
+ function secretLeavesOf(body, spec) {
115472
+ const out = /* @__PURE__ */ new Map();
115473
+ for (const leaf of collectSecretLeaves(body, spec.secretPaths, spec.secretArrayKeys ?? {})) if (typeof leaf.value === "string" && leaf.value.length > 0) out.set(leaf.concrete, leaf.value);
115474
+ return out;
115475
+ }
115476
+ function why(err) {
115477
+ return err instanceof Error ? err.message : String(err);
115478
+ }
115479
+ async function moveIntegrationRowsToRecords(ports, spec) {
115480
+ const integrations = await ports.listIntegrations(spec.addonId);
115481
+ if (integrations.length === 0) return {
115482
+ outcome: "already-done",
115483
+ summary: { integrations: 0 }
115484
+ };
115485
+ const rowsOf = /* @__PURE__ */ new Map();
115486
+ for (const integration of integrations) try {
115487
+ rowsOf.set(integration.id, await ports.readRows(integration.id));
115488
+ } catch (err) {
115489
+ throw new Error(`integration '${integration.id}': the legacy settings cannot be read with their secrets (${why(err)}) - nothing moved`, { cause: err });
115490
+ }
115491
+ let created = 0;
115492
+ let cleared = 0;
115493
+ let kept = 0;
115494
+ for (const integration of integrations) {
115495
+ const values = rowsOf.get(integration.id) ?? {};
115496
+ const hasRows = Object.keys(values).length > 0;
115497
+ const body = spec.bodyFrom(values, ports, integration);
115498
+ const put = await ports.putRecord(integration.id, body);
115499
+ if (put === "created") created += 1;
115500
+ if (!hasRows) continue;
115501
+ const wanted = secretLeavesOf(body, spec);
115502
+ const back = await ports.readRecord(integration.id);
115503
+ if (back === null) throw new Error(`integration '${integration.id}': the record is gone after it was put`);
115504
+ const held = secretLeavesOf(back, spec);
115505
+ if (put === "created") {
115506
+ const wrong = [...wanted].filter(([path, value]) => held.get(path) !== value).map(([p]) => p);
115507
+ if (wrong.length > 0) {
115508
+ await ports.deleteRecord(integration.id);
115509
+ throw new Error(`integration '${integration.id}': ${wrong.join(", ")} did not read back from the record - the record was removed and the legacy settings are kept`);
115510
+ }
115511
+ } else {
115512
+ const missing = [...wanted].filter(([path]) => !held.has(path)).map(([p]) => p);
115513
+ if (missing.length > 0) {
115514
+ ports.warn(`integration '${integration.id}': the existing record holds no ${missing.join(", ")}; the legacy settings are kept, not cleared`);
115515
+ kept += 1;
115516
+ continue;
115517
+ }
115518
+ }
115519
+ await ports.clearRows(integration.id);
115520
+ cleared += 1;
115521
+ }
115522
+ return created === 0 && cleared === 0 ? {
115523
+ outcome: "already-done",
115524
+ summary: {
115525
+ integrations: integrations.length,
115526
+ kept
115527
+ }
115528
+ } : {
115529
+ outcome: "done",
115530
+ summary: {
115531
+ integrations: integrations.length,
115532
+ created,
115533
+ cleared,
115534
+ kept
115535
+ }
115536
+ };
115537
+ }
115538
+ /**
115539
+ * The addon-side ports of `moveIntegrationRowsToRecords` (planning#416): an addon runs the move
115540
+ * from its own process, so the legacy rows are reached through the hub (`ctx.api.integrations`:
115541
+ * `list`, `getSettings` - which answers the OWNER its secrets resolved - and `clearSettings`) and
115542
+ * the new record through the addon's own records handle. The hub deletes the rows and their
115543
+ * keychain values; the addon never touches the registry.
115544
+ */
115545
+ function integrationRowsPortsOverApi(deps) {
115546
+ return {
115547
+ warn: (message) => deps.log.warn(message),
115548
+ listIntegrations: async (addonId) => (await deps.api.list()).filter((i) => i.addonId === addonId).map((i) => ({
115549
+ id: i.id,
115550
+ name: i.name
115551
+ })),
115552
+ readRows: (integrationId) => deps.api.getSettings({ id: integrationId }),
115553
+ putRecord: async (id, body) => {
115554
+ if (await deps.records.get(id) !== null) return "exists";
115555
+ try {
115556
+ await deps.records.put({
115557
+ id,
115558
+ data: body
115559
+ }, { ifRev: 0 });
115560
+ } catch (err) {
115561
+ if (err instanceof RevConflictError) return "exists";
115562
+ throw err;
115563
+ }
115564
+ return "created";
115565
+ },
115566
+ readRecord: async (id) => (await deps.records.getResolved(id))?.data ?? null,
115567
+ deleteRecord: async (id) => {
115568
+ await deps.records.delete(id);
115569
+ },
115570
+ clearRows: async (integrationId) => {
115571
+ await deps.api.clearSettings({ id: integrationId });
115572
+ }
115573
+ };
115574
+ }
115575
+ function ordinalOf(id) {
115576
+ const digits = id.replace(/\D+/g, "");
115577
+ return digits.length > 0 ? Number(digits) : Number.POSITIVE_INFINITY;
115578
+ }
115579
+ /** The lowest-numbered eligible integration, or null. */
115580
+ function defaultIntegrationOf(integrations, eligible) {
115581
+ return [...eligible === void 0 ? integrations : integrations.filter(eligible)].sort((a, b) => ordinalOf(a.id) - ordinalOf(b.id) || (a.id < b.id ? -1 : 1))[0] ?? null;
115582
+ }
115583
+ function unstamped(device) {
115584
+ return device.integrationId === void 0 || device.integrationId === null || device.integrationId === "";
115585
+ }
115586
+ /**
115587
+ * An allocation reservation is not a device (D376): `allocateDeviceId` writes a row of type
115588
+ * `generic`, named by its stable id, that is not registered and holds no configuration. The hub
115589
+ * listing does not carry the `registered` bit, so the shape picks the candidates and the missing
115590
+ * configuration decides.
115591
+ */
115592
+ async function isReservation(ports, device) {
115593
+ if (device.type !== "generic" || device.name === void 0 || device.name !== device.stableId) return false;
115594
+ return !await ports.hasConfig(device.id);
115595
+ }
115596
+ async function claimDevicesToDefaultIntegration(ports, spec) {
115597
+ const all = await ports.listDevices(spec.addonId);
115598
+ const byId = new Map(all.map((d) => [d.id, d]));
115599
+ const reservations = /* @__PURE__ */ new Set();
115600
+ for (const device of all) if (unstamped(device) && await isReservation(ports, device)) reservations.add(device.id);
115601
+ const orphans = all.filter((d) => unstamped(d) && !reservations.has(d.id));
115602
+ const summaryBase = { reservations: reservations.size };
115603
+ if (orphans.length === 0) return {
115604
+ outcome: "already-done",
115605
+ summary: {
115606
+ claimed: 0,
115607
+ unresolved: 0,
115608
+ ...summaryBase
115609
+ }
115610
+ };
115611
+ const fallback = defaultIntegrationOf(await ports.listIntegrations(spec.addonId), spec.eligible);
115612
+ const plan = /* @__PURE__ */ new Map();
115613
+ const unresolved = /* @__PURE__ */ new Set();
115614
+ const failed = /* @__PURE__ */ new Set();
115615
+ const resolve = (device, seen) => {
115616
+ if (!unstamped(device)) return device.integrationId ?? null;
115617
+ const planned = plan.get(device.id);
115618
+ if (planned !== void 0) return planned;
115619
+ if (failed.has(device.id)) return null;
115620
+ let target = null;
115621
+ const parentId = device.parentDeviceId ?? null;
115622
+ if (parentId === null) target = fallback?.id ?? null;
115623
+ else {
115624
+ const parent = byId.get(parentId);
115625
+ if (parent !== void 0 && !reservations.has(parentId) && !seen.has(parentId)) target = resolve(parent, new Set([...seen, device.id]));
115626
+ }
115627
+ if (target === null) failed.add(device.id);
115628
+ else plan.set(device.id, target);
115629
+ return target;
115630
+ };
115631
+ if (orphans.some((d) => (d.parentDeviceId ?? null) === null) && fallback === null) throw new Error(`${orphans.length} device(s) of '${spec.addonId}' carry no integration and the addon has no integration to claim them to`);
115632
+ for (const device of orphans) if (resolve(device, new Set([device.id])) === null) unresolved.add(device.id);
115633
+ for (const id of unresolved) ports.warnDevice(`device ${id} of '${spec.addonId}' carries no integration and its owner chain does not resolve to one: left unstamped`, id);
115634
+ for (const [deviceId, integrationId] of plan) await ports.setIntegrationId(deviceId, integrationId);
115635
+ const wrong = [];
115636
+ for (const [deviceId, integrationId] of plan) if (await ports.readIntegrationId(deviceId) !== integrationId) wrong.push(deviceId);
115637
+ if (wrong.length > 0) throw new Error(`device(s) ${wrong.join(", ")} did not read back their integration after the claim`);
115638
+ const claimed = plan.size;
115639
+ ports.info(`${claimed} device(s) of '${spec.addonId}' claimed to their owner's integration or the default; ${unresolved.size} unresolved, ${reservations.size} reservation(s) skipped`);
115640
+ return {
115641
+ outcome: claimed > 0 ? "done" : "already-done",
115642
+ summary: {
115643
+ claimed,
115644
+ unresolved: unresolved.size,
115645
+ ...summaryBase
115646
+ }
115647
+ };
115648
+ }
115649
+ /**
115650
+ * The two migrations of a device addon whose integrations hold NO settings of their own
115651
+ * (planning#416, S-A): every integration becomes a record of the addon's own kind with a name and
115652
+ * nothing else (D985, the default integration is an instance with an empty body), then the devices
115653
+ * that carry no `integrationId` are claimed to the addon's default integration.
115654
+ *
115655
+ * 1. `rows-to-record` (block-owner): `moveIntegrationRowsToRecords` over the hub API. Rows an
115656
+ * addon of this shape does not own (a key nothing reads) are named in a warning, by key, and
115657
+ * dropped with the rows once the record is read back.
115658
+ * 2. `claim-devices` (tolerate): `claimDevicesToDefaultIntegration`; with no integration to claim
115659
+ * to it fails and is retried on the next boot.
115660
+ *
115661
+ * Both run in the addon process through the hub API (`ctx.api`), BEFORE the addon registers its
115662
+ * `integration-instances` provider: while a provider is registered the hub answers `getSettings`
115663
+ * from the record, not from the rows.
115664
+ */
115665
+ function defaultIntegrationPortsOver(deps) {
115666
+ return {
115667
+ ...integrationRowsPortsOverApi({
115668
+ api: deps.api,
115669
+ records: deps.records.kind({
115670
+ name: deps.kind.name,
115671
+ declaration: deps.kind.declaration,
115672
+ schema: record(string(), unknown())
115673
+ }),
115674
+ log: { warn: deps.warn }
115675
+ }),
115676
+ ...claimDevicesPortsOver({
115677
+ api: deps.api,
115678
+ info: deps.info,
115679
+ warnDevice: deps.warnDevice
115680
+ })
115681
+ };
115682
+ }
115683
+ /** The hub side of the device claim, over `ctx.api`. */
115684
+ function claimDevicesPortsOver(deps) {
115685
+ return {
115686
+ listIntegrations: async (addonId) => (await deps.api.list()).filter((i) => i.addonId === addonId).map((i) => ({
115687
+ id: i.id,
115688
+ name: i.name,
115689
+ info: i.info ?? {}
115690
+ })),
115691
+ listDevices: (addonId) => deps.api.listDevices(addonId),
115692
+ hasConfig: (deviceId) => deps.api.readDeviceHasConfig(deviceId),
115693
+ setIntegrationId: (deviceId, integrationId) => deps.api.setDeviceIntegrationId(deviceId, integrationId),
115694
+ readIntegrationId: (deviceId) => deps.api.readDeviceIntegrationId(deviceId),
115695
+ info: deps.info,
115696
+ warnDevice: deps.warnDevice
115697
+ };
115698
+ }
115699
+ function defaultIntegrationMigrationIds(addonId, date) {
115700
+ return {
115701
+ rowsToRecord: `${addonId}/${date}-integration-rows-to-record`,
115702
+ claimDevices: `${addonId}/${date}-claim-devices-to-default-integration`
115703
+ };
115704
+ }
115705
+ /**
115706
+ * The device claim alone, for an addon that keeps its integration settings where they are for now
115707
+ * (its own wave moves them) but whose persisted devices must name their default integration.
115708
+ */
115709
+ function claimDevicesMigration(spec) {
115710
+ return {
115711
+ meta: {
115712
+ id: defaultIntegrationMigrationIds(spec.addonId, spec.date).claimDevices,
115713
+ owner: spec.addonId,
115714
+ level: "addon-state",
115715
+ seq: spec.seq,
115716
+ dependsOn: [...spec.dependsOn],
115717
+ store: "settings",
115718
+ scope: "cluster",
115719
+ onFailure: "tolerate",
115720
+ bodyKind: "procedure",
115721
+ description: "Claim the persisted devices that carry no integration to the addon's default integration, keeping their device and stable ids",
115722
+ fixtureMark: "a device config field the claim does not know"
115723
+ },
115724
+ procedure: (ports) => claimDevicesToDefaultIntegration(ports, {
115725
+ addonId: spec.addonId,
115726
+ ...spec.eligible !== void 0 ? { eligible: spec.eligible } : {}
115727
+ })
115728
+ };
115729
+ }
115730
+ function defaultIntegrationApiOver(api) {
115731
+ return {
115732
+ list: () => api.integrations.list.query(),
115733
+ getSettings: (input) => api.integrations.getSettings.query(input),
115734
+ clearSettings: (input) => api.integrations.clearSettings.mutate(input),
115735
+ listDevices: (addonId) => api.deviceManager.listAll.query({
115736
+ addonId,
115737
+ projection: "slim"
115738
+ }),
115739
+ setDeviceIntegrationId: async (deviceId, integrationId) => {
115740
+ await api.deviceManager.setIntegrationId.mutate({
115741
+ deviceId,
115742
+ integrationId
115743
+ });
115744
+ },
115745
+ readDeviceHasConfig: async (deviceId) => Object.keys((await api.deviceManager.getDevice.query({ deviceId }))?.config ?? {}).length > 0,
115746
+ readDeviceIntegrationId: async (deviceId) => (await api.deviceManager.getDevice.query({ deviceId }))?.integrationId ?? null
115747
+ };
115748
+ }
115749
+ /**
114781
115750
  * The trainer contract — what the hub hands the cloud trainer and what it
114782
115751
  * gets back (cloud training, D805+).
114783
115752
  *
@@ -150752,117 +151721,6 @@ function actuator(input) {
150752
151721
  };
150753
151722
  }
150754
151723
  new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
150755
- var PowerSourceRefusalReasonSchema = _enum([
150756
- "no-power-source",
150757
- "switch-missing",
150758
- "switch-offline",
150759
- "switch-unknown",
150760
- "restart-in-progress",
150761
- "switch-call-failed"
150762
- ]);
150763
- var PowerSourceActionResultSchema = discriminatedUnion("accepted", [object({ accepted: literal(true) }), object({
150764
- accepted: literal(false),
150765
- reason: PowerSourceRefusalReasonSchema,
150766
- /** One line for the operator, naming the switch. */
150767
- message: string()
150768
- })]);
150769
- var PowerSourceInfoSchema = object({
150770
- /** False when the device has no power source linked; the rest is then empty. */
150771
- linked: boolean(),
150772
- switchDeviceId: number().nullable(),
150773
- switchName: string().nullable(),
150774
- /** The switch's on/off state, or null when it is unknown. Never false for unknown (D393). */
150775
- switchOn: boolean().nullable(),
150776
- switchOnline: boolean().nullable(),
150777
- restartInProgress: boolean(),
150778
- offSeconds: number().int()
150779
- });
150780
- /** The composed slice: the switch state the link mirrors, plus the stored off time. */
150781
- var PowerSourceStateSchema = object({
150782
- /** Null while the switch has not reported: unknown is never `false` (D393). */
150783
- switchOn: boolean().nullable(),
150784
- offSeconds: number().int(),
150785
- lastFetchedAt: number()
150786
- });
150787
- var POWER_SOURCE_DEVICE_TYPES = [
150788
- DeviceType.Gateway,
150789
- DeviceType.Hub,
150790
- DeviceType.Camera,
150791
- DeviceType.Sensor,
150792
- DeviceType.MediaPlayer,
150793
- DeviceType.Vacuum,
150794
- DeviceType.Generic,
150795
- DeviceType.Washer,
150796
- DeviceType.Dryer,
150797
- DeviceType.Dishwasher,
150798
- DeviceType.PetFountain,
150799
- DeviceType.Fan,
150800
- DeviceType.Humidifier,
150801
- DeviceType.Climate,
150802
- DeviceType.WaterHeater
150803
- ];
150804
- var powerSourceCustomize = {
150805
- version: 1,
150806
- targets: [{
150807
- id: "switch",
150808
- cardinality: "scalar",
150809
- vocabulary: {
150810
- cap: "power-source",
150811
- stateField: "switchOn"
150812
- },
150813
- deviceTypes: POWER_SOURCE_DEVICE_TYPES,
150814
- apply: "add",
150815
- path: "switchOn",
150816
- sources: [{
150817
- kind: "switch",
150818
- transforms: [{ kind: "identity" }]
150819
- }],
150820
- companions: [{
150821
- kind: "constant",
150822
- path: "offSeconds",
150823
- value: 10,
150824
- when: "add"
150825
- }, {
150826
- kind: "clock",
150827
- path: "lastFetchedAt",
150828
- when: "always"
150829
- }],
150830
- sourceReport: { status: "assignment-status" }
150831
- }]
150832
- };
150833
- var deviceInput$2 = object({ deviceId: number().int().nonnegative() });
150834
- var powerSourceCapability = {
150835
- name: "power-source",
150836
- scope: "device",
150837
- mode: "singleton",
150838
- deviceTypes: POWER_SOURCE_DEVICE_TYPES,
150839
- methods: {
150840
- getStatus: systemMethod(deviceInput$2, PowerSourceInfoSchema, { auth: "admin" }),
150841
- powerOn: systemMethod(deviceInput$2, PowerSourceActionResultSchema, {
150842
- kind: "mutation",
150843
- auth: "admin"
150844
- }),
150845
- powerOff: systemMethod(deviceInput$2, PowerSourceActionResultSchema, {
150846
- kind: "mutation",
150847
- auth: "admin"
150848
- }),
150849
- forceRestart: systemMethod(deviceInput$2, PowerSourceActionResultSchema, {
150850
- kind: "mutation",
150851
- auth: "admin"
150852
- })
150853
- },
150854
- runtimeState: PowerSourceStateSchema,
150855
- customize: powerSourceCustomize,
150856
- /**
150857
- * Runtime-state durability: **session** — mirrored from the linked switch on
150858
- * its next report; a restored value would outlive the reading it names.
150859
- *
150860
- * See `RuntimeStateDurability`. Enforced by
150861
- * `scripts/check-runtime-state-durability.ts`.
150862
- */
150863
- durability: "session",
150864
- history: "none"
150865
- };
150866
151724
  DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
150867
151725
  DeviceType.Camera;
150868
151726
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
@@ -150890,6 +151748,165 @@ var ComposableCapabilitiesSchema = object({ caps: array(object({
150890
151748
  }).optional()
150891
151749
  })) });
150892
151750
  /**
151751
+ * A saved Customize assignment (D936). The schema is the type: callers use
151752
+ * `z.infer`, they do not restate the shape.
151753
+ */
151754
+ var OverrideSourceSchema = object({
151755
+ addonId: string().min(1),
151756
+ stableId: string().min(1),
151757
+ cap: string().min(1),
151758
+ fieldPath: string().min(1)
151759
+ });
151760
+ var IdentityTransformDraftSchema = object({ kind: literal("identity") });
151761
+ var InvertTransformDraftSchema = object({ kind: literal("invert") });
151762
+ var ThresholdTransformDraftSchema = object({
151763
+ kind: literal("threshold"),
151764
+ comparison: _enum(["gte", "lte"]),
151765
+ threshold: number(),
151766
+ startDwellMs: number().nonnegative(),
151767
+ stopDwellMs: number().nonnegative()
151768
+ });
151769
+ var CustomizeTransformDraftSchema = discriminatedUnion("kind", [
151770
+ IdentityTransformDraftSchema,
151771
+ InvertTransformDraftSchema,
151772
+ ThresholdTransformDraftSchema,
151773
+ object({
151774
+ kind: literal("linear"),
151775
+ scale: number(),
151776
+ offset: number(),
151777
+ clampMin: number().optional(),
151778
+ clampMax: number().optional()
151779
+ }),
151780
+ object({
151781
+ kind: literal("enum-map"),
151782
+ mapping: record(string(), string().nullable())
151783
+ }),
151784
+ object({ kind: literal("number") })
151785
+ ]);
151786
+ var ScalarOverrideDraftSchema = object({
151787
+ kind: literal("scalar"),
151788
+ cap: string().min(1),
151789
+ targetId: string().min(1),
151790
+ source: OverrideSourceSchema,
151791
+ transform: CustomizeTransformDraftSchema
151792
+ });
151793
+ var KeyedArrayOverrideDraftSchema = object({
151794
+ kind: literal("keyedArray"),
151795
+ cap: string().min(1),
151796
+ targetId: string().min(1),
151797
+ key: string().min(1),
151798
+ label: string().min(1),
151799
+ fieldPath: string().min(1),
151800
+ source: OverrideSourceSchema,
151801
+ transform: CustomizeTransformDraftSchema
151802
+ });
151803
+ var PulseOverrideDraftSchema = object({
151804
+ kind: literal("pulse"),
151805
+ cap: string().min(1),
151806
+ targetId: string().min(1),
151807
+ enabled: boolean(),
151808
+ debounceMs: number().nonnegative(),
151809
+ edges: array(_enum([
151810
+ "rising",
151811
+ "button",
151812
+ "native-event"
151813
+ ])).min(1).optional(),
151814
+ sources: array(OverrideSourceSchema).min(1)
151815
+ });
151816
+ var SpanSourceDraftSchema = OverrideSourceSchema.extend({ mode: _enum([
151817
+ "in-addition",
151818
+ "confirm",
151819
+ "replace"
151820
+ ]).optional() });
151821
+ discriminatedUnion("kind", [
151822
+ ScalarOverrideDraftSchema,
151823
+ KeyedArrayOverrideDraftSchema,
151824
+ PulseOverrideDraftSchema,
151825
+ object({
151826
+ kind: literal("span"),
151827
+ cap: string().min(1),
151828
+ targetId: string().min(1),
151829
+ timeoutMs: number().nonnegative(),
151830
+ cooldownMs: number().nonnegative().optional(),
151831
+ sources: array(SpanSourceDraftSchema)
151832
+ })
151833
+ ]);
151834
+ object({
151835
+ targetId: string().min(1),
151836
+ configured: boolean(),
151837
+ source: _enum(["native", "assigned"]),
151838
+ state: _enum([
151839
+ "active",
151840
+ "stale",
151841
+ "released",
151842
+ "unavailable"
151843
+ ]),
151844
+ reason: string().nullable()
151845
+ });
151846
+ /**
151847
+ * What a running-threshold proposal says (planning#405). The schema is the
151848
+ * type: the algorithm, the capability method and the UI all read `z.infer`.
151849
+ *
151850
+ * A proposal is read-only advice: it carries the numbers it was derived from
151851
+ * (`evidence`) so the operator can judge it, and an `insufficient` answer names
151852
+ * WHY with a typed reason and the catalog defaults, never an invented number.
151853
+ */
151854
+ var RunningProposalTierSchema = _enum(["raw", "5m"]);
151855
+ var RunningProposalConfidenceSchema = _enum([
151856
+ "high",
151857
+ "medium",
151858
+ "low"
151859
+ ]);
151860
+ var RunningProposalInsufficientReasonSchema = _enum([
151861
+ "no-history",
151862
+ "span-too-short",
151863
+ "truncated",
151864
+ "no-idle-floor",
151865
+ "no-runs",
151866
+ "no-separation"
151867
+ ]);
151868
+ var RunningProposalReplaySchema = object({
151869
+ /** Intervals the real dwell expression held true over the window. */
151870
+ intervals: number().int().nonnegative(),
151871
+ /** Intervals outside every run, or a second interval inside one. */
151872
+ extra: number().int().nonnegative(),
151873
+ /** Runs without an interval of their own (none, or merged into a neighbour). */
151874
+ missed: number().int().nonnegative()
151875
+ });
151876
+ var RunningProposalEvidenceSchema = object({
151877
+ tier: RunningProposalTierSchema,
151878
+ spanMs: number().nonnegative(),
151879
+ from: number(),
151880
+ to: number(),
151881
+ points: number().int().nonnegative(),
151882
+ runsFound: number().int().nonnegative(),
151883
+ pausesFound: number().int().nonnegative(),
151884
+ longestPauseMs: number().nonnegative(),
151885
+ idleFloorW: number(),
151886
+ noiseCeilingW: number(),
151887
+ plateauW: number(),
151888
+ replay: RunningProposalReplaySchema
151889
+ });
151890
+ var RunningProposalInsufficientEvidenceSchema = object({
151891
+ spanMs: number().nonnegative(),
151892
+ points: number().int().nonnegative(),
151893
+ runsFound: number().int().nonnegative()
151894
+ });
151895
+ var RunningThresholdProposalSchema = discriminatedUnion("state", [object({
151896
+ state: literal("proposal"),
151897
+ transform: ThresholdTransformDraftSchema,
151898
+ evidence: RunningProposalEvidenceSchema,
151899
+ confidence: RunningProposalConfidenceSchema,
151900
+ /** Why the confidence is not higher; empty when nothing held it back. */
151901
+ notes: array(string())
151902
+ }), object({
151903
+ state: literal("insufficient"),
151904
+ reason: RunningProposalInsufficientReasonSchema,
151905
+ evidence: RunningProposalInsufficientEvidenceSchema,
151906
+ /** The catalog defaults, offered as what stands when nothing better is known. */
151907
+ fallback: ThresholdTransformDraftSchema
151908
+ })]);
151909
+ /**
150893
151910
  * The report of a one-shot "move this owner's plaintext secrets into the
150894
151911
  * keychain" run (D878). COUNTS only - never a value, never a ref.
150895
151912
  *
@@ -151179,6 +152196,14 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
151179
152196
  kind: "mutation",
151180
152197
  auth: "admin",
151181
152198
  access: "view"
152199
+ }), method(object({
152200
+ deviceId: number().int(),
152201
+ cap: string().min(1),
152202
+ fieldPath: string().min(1),
152203
+ deviceType: _enum(DeviceType).optional()
152204
+ }), RunningThresholdProposalSchema, {
152205
+ auth: "admin",
152206
+ access: "view"
151182
152207
  }), method(object({
151183
152208
  blockId: string().min(1),
151184
152209
  trace: AutomationRunTraceSchema.optional(),
@@ -152155,7 +153180,27 @@ method(object({
152155
153180
  * exposes only the COMMON surface: link state, supported device
152156
153181
  * kinds (so the UI can filter the device picker), and per-device
152157
153182
  * expose/unexpose.
152158
- */
153183
+ *
153184
+ * INSTANCES. One export addon can serve several independent targets (a
153185
+ * HomeKit bridge, a Home Assistant broker). `listInstances` names them.
153186
+ * `instanceId` on a READ is optional: absent = the whole addon (linked if any
153187
+ * enabled instance is, `exposedDeviceCount` = distinct devices, `setup` = the
153188
+ * default instance's); present = that instance; an id the provider does not
153189
+ * serve is refused with `ExportInstanceUnknownError` (`export-instance-unknown`),
153190
+ * never answered empty. `instanceId` on a WRITE is REQUIRED: a membership
153191
+ * change always names the target it changes. A single-instance provider
153192
+ * accepts an absent id on a read or its own id, and refuses any other.
153193
+ */
153194
+ /** Which instance of an export addon a call addresses. Absent on a read = the whole addon. */
153195
+ var InstanceIdSchema$1 = string().min(1);
153196
+ var InstancePinSchema = object({ instanceId: InstanceIdSchema$1.optional() });
153197
+ /** One independent export target of an addon. */
153198
+ var ExportInstanceSchema = object({
153199
+ instanceId: InstanceIdSchema$1,
153200
+ name: string(),
153201
+ enabled: boolean(),
153202
+ kind: string().optional()
153203
+ });
152159
153204
  var LinkStateSchema = _enum([
152160
153205
  "unlinked",
152161
153206
  "linked",
@@ -152206,10 +153251,14 @@ var ExposedDeviceSchema = object({
152206
153251
  capabilities: array(string()).optional()
152207
153252
  });
152208
153253
  var ExposeInputSchema = object({
153254
+ instanceId: InstanceIdSchema$1,
152209
153255
  deviceId: string(),
152210
153256
  capabilities: array(string()).optional()
152211
153257
  });
152212
- var UnexposeInputSchema = object({ deviceId: string() });
153258
+ var UnexposeInputSchema = object({
153259
+ instanceId: InstanceIdSchema$1,
153260
+ deviceId: string()
153261
+ });
152213
153262
  /**
152214
153263
  * What an exporter may expose besides devices (D968). An action and a
152215
153264
  * notification target are LIST ENTRIES, never devices (D954): they enter an
@@ -152238,15 +153287,21 @@ var ExposedEntitySchema = object({
152238
153287
  entity: ExportEntityRefSchema,
152239
153288
  exposedAs: string().optional()
152240
153289
  });
152241
- var ExposeEntityInputSchema = object({ entity: ExportEntityRefSchema });
152242
- var UnexposeEntityInputSchema = object({ entity: ExportEntityRefSchema });
152243
- method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), {
153290
+ var ExposeEntityInputSchema = object({
153291
+ instanceId: InstanceIdSchema$1,
153292
+ entity: ExportEntityRefSchema
153293
+ });
153294
+ var UnexposeEntityInputSchema = object({
153295
+ instanceId: InstanceIdSchema$1,
153296
+ entity: ExportEntityRefSchema
153297
+ });
153298
+ method(InstancePinSchema, DeviceExportStatusSchema), method(_void(), array(ExportInstanceSchema)), method(InstancePinSchema, array(DeviceKindSchema)), method(InstancePinSchema, array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), {
152244
153299
  kind: "mutation",
152245
153300
  auth: "admin"
152246
153301
  }), method(UnexposeInputSchema, _void(), {
152247
153302
  kind: "mutation",
152248
153303
  auth: "admin"
152249
- }), optionalMethod(_void(), array(ExportEntityKindSchema)), optionalMethod(_void(), array(ExposedEntitySchema)), optionalMethod(ExposeEntityInputSchema, _void(), {
153304
+ }), optionalMethod(_void(), array(ExportEntityKindSchema)), optionalMethod(InstancePinSchema, array(ExposedEntitySchema)), optionalMethod(ExposeEntityInputSchema, _void(), {
152250
153305
  kind: "mutation",
152251
153306
  auth: "admin"
152252
153307
  }), optionalMethod(UnexposeEntityInputSchema, _void(), {
@@ -155869,15 +156924,15 @@ var FanPresetCaptureResultSchema = object({
155869
156924
  items: array(FanCaptureItemSchema),
155870
156925
  omitted: array(FanCaptureOmissionSchema)
155871
156926
  });
155872
- var deviceInput$1 = object({ deviceId: number().int().nonnegative() });
156927
+ var deviceInput$2 = object({ deviceId: number().int().nonnegative() });
155873
156928
  DeviceType.Fan;
155874
- method(deviceInput$1, array(FanPresetSchema), { auth: "protected" }), method(deviceInput$1.extend({ presets: array(FanPresetSchema).max(12) }), FanPresetsSetResultSchema, {
156929
+ method(deviceInput$2, array(FanPresetSchema), { auth: "protected" }), method(deviceInput$2.extend({ presets: array(FanPresetSchema).max(12) }), FanPresetsSetResultSchema, {
155875
156930
  kind: "mutation",
155876
156931
  auth: "admin"
155877
- }), method(deviceInput$1.extend({ presetId: string().min(1) }), FanPresetApplyResultSchema, {
156932
+ }), method(deviceInput$2.extend({ presetId: string().min(1) }), FanPresetApplyResultSchema, {
155878
156933
  kind: "mutation",
155879
156934
  auth: "protected"
155880
- }), method(deviceInput$1.extend({
156935
+ }), method(deviceInput$2.extend({
155881
156936
  fields: array(FanCaptureFieldSchema).optional(),
155882
156937
  steps: array(ActionStepSchema).max(20).default([])
155883
156938
  }), FanPresetCaptureResultSchema, { auth: "protected" });
@@ -156760,6 +157815,12 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
156760
157815
  }), object({ success: boolean() }), {
156761
157816
  kind: "mutation",
156762
157817
  auth: "admin"
157818
+ }), method(object({ id: string() }), object({
157819
+ rows: number().int().nonnegative(),
157820
+ secrets: number().int().nonnegative()
157821
+ }), {
157822
+ kind: "mutation",
157823
+ auth: "admin"
156763
157824
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
156764
157825
  addonId: string(),
156765
157826
  settings: record(string(), unknown())
@@ -156788,13 +157849,65 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
156788
157849
  */
156789
157850
  var ConfigSchemaPassthrough$3 = custom();
156790
157851
  var IntegrationInstanceIdSchema = string().min(1);
157852
+ var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
157853
+ /**
157854
+ * One kind of instance a provider can create (an adapter, an account type). The sign-in and
157855
+ * connect fields follow the `broker` contract (`broker.cap.ts`): `signIn.basePath` is a
157856
+ * hub-relative route set speaking the sign-in contract, `connectPath` / `disconnectPath` open an
157857
+ * interactive account link (popup) and may carry `{{brokerId}}`, which the UI replaces with the
157858
+ * instance id.
157859
+ */
157860
+ var IntegrationInstanceKindSchema = object({
157861
+ /** Stable id of the kind, sent back as `createInstance.kind`. */
157862
+ id: string().min(1),
157863
+ label: string().min(1),
157864
+ /** The kind signs in through a server-held interactive session. */
157865
+ signIn: object({
157866
+ basePath: string().min(1),
157867
+ methods: array(_enum(["device-code", "proxy"])).optional()
157868
+ }).optional(),
157869
+ /** Relative hub path to start an interactive account link of an instance of this kind. */
157870
+ connectPath: string().min(1).optional(),
157871
+ /** Relative hub path to disconnect that account. */
157872
+ disconnectPath: string().min(1).optional()
157873
+ });
156791
157874
  var IntegrationInstanceDescriptionSchema = object({
157875
+ /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
157876
+ role: _enum([
157877
+ "devices",
157878
+ "services",
157879
+ "both"
157880
+ ]),
157881
+ /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
157882
+ discovery: object({
157883
+ mode: _enum([
157884
+ "none",
157885
+ "lan",
157886
+ "account"
157887
+ ]),
157888
+ scopedToInstance: boolean()
157889
+ }).optional(),
156792
157890
  /** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
156793
- instanceMode: _enum(["single", "multiple"]),
157891
+ instanceMode: IntegrationInstanceModeSchema,
156794
157892
  /** What the instance is, for the UI (`destination`, `relay`, `account`). */
156795
157893
  kind: string(),
156796
157894
  /** True when `testInstance` really probes the remote. */
156797
- canTest: boolean()
157895
+ canTest: boolean(),
157896
+ /**
157897
+ * The kinds an instance can be of. Absent or one entry: nothing to choose; more than one: the
157898
+ * Add flow asks which first and sends it as `createInstance.kind`.
157899
+ */
157900
+ kinds: array(IntegrationInstanceKindSchema).optional(),
157901
+ /**
157902
+ * The provider accepts `createInstance.id` for an id that once existed (an adopted id, because
157903
+ * devices carry it). The UI then offers "restore under its old id" on Add. Absent means no.
157904
+ */
157905
+ allowRestoreId: boolean().optional(),
157906
+ /**
157907
+ * `createInstance` without an `id` works: the provider mints it. Absent means the caller must
157908
+ * send an id (the UI then mints an opaque one).
157909
+ */
157910
+ mintsId: boolean().optional()
156798
157911
  });
156799
157912
  var IntegrationInstanceStatusSchema = object({
156800
157913
  state: _enum([
@@ -156803,7 +157916,9 @@ var IntegrationInstanceStatusSchema = object({
156803
157916
  "error",
156804
157917
  "unknown"
156805
157918
  ]),
156806
- detail: string().optional()
157919
+ detail: string().optional(),
157920
+ /** A short human word for the state ("Signed in", "Token expired"); shown instead of `state`. */
157921
+ label: string().optional()
156807
157922
  });
156808
157923
  var IntegrationInstanceSchema = object({
156809
157924
  id: IntegrationInstanceIdSchema,
@@ -156811,13 +157926,18 @@ var IntegrationInstanceSchema = object({
156811
157926
  /** The addon's own rule (an URL is present, a client id is present...). */
156812
157927
  configured: boolean(),
156813
157928
  /**
156814
- * The instance's own on/off switch (D996): always present, derived from the record body
156815
- * (`enabled !== false`, so a row written before the field existed is on).
157929
+ * The instance's own on/off switch (D996), derived from the record body (`enabled !== false`,
157930
+ * so a row written before the field existed is on). Absent when the `integrations` registry
157931
+ * owns the switch (`describe().role` is `devices`): read it from the registry row.
156816
157932
  */
156817
- enabled: boolean(),
157933
+ enabled: boolean().optional(),
157934
+ /** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
157935
+ kind: string().optional(),
156818
157936
  /** Values for the instance form; a secret is a `SecretFieldView`, never text. */
156819
157937
  formValues: record(string(), unknown()),
156820
- status: IntegrationInstanceStatusSchema.optional()
157938
+ status: IntegrationInstanceStatusSchema.optional(),
157939
+ /** Epoch ms of the last probe of the remote that produced `status`; absent when never checked. */
157940
+ lastCheckedAt: number().optional()
156821
157941
  });
156822
157942
  var IntegrationInstancePatchSchema = object({
156823
157943
  id: IntegrationInstanceIdSchema,
@@ -156825,31 +157945,57 @@ var IntegrationInstancePatchSchema = object({
156825
157945
  /** Omitted / '' / redaction token keeps a secret; `{ $secretClear: true }` clears it. */
156826
157946
  values: record(string(), unknown())
156827
157947
  });
156828
- method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }), method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }), method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }), method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }), method(object({
156829
- id: IntegrationInstanceIdSchema,
156830
- name: string().min(1)
156831
- }), object({ id: string() }), {
156832
- kind: "mutation",
156833
- auth: "admin"
156834
- }), method(IntegrationInstancePatchSchema, object({ ok: literal(true) }), {
156835
- kind: "mutation",
156836
- auth: "admin"
156837
- }), method(object({
156838
- id: IntegrationInstanceIdSchema,
156839
- enabled: boolean()
156840
- }), object({ ok: literal(true) }), {
156841
- kind: "mutation",
156842
- auth: "admin"
156843
- }), method(object({ id: IntegrationInstanceIdSchema }), object({ ok: literal(true) }), {
156844
- kind: "mutation",
156845
- auth: "admin"
156846
- }), optionalMethod(object({
156847
- id: IntegrationInstanceIdSchema.nullable(),
156848
- values: record(string(), unknown())
156849
- }), TestConnectionResultSchema$1, {
156850
- kind: "mutation",
156851
- auth: "admin"
156852
- });
157948
+ var integrationInstancesCapability = {
157949
+ name: "integration-instances",
157950
+ scope: "system",
157951
+ mode: "collection",
157952
+ methods: {
157953
+ describe: method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }),
157954
+ /** The form; `id: null` is the create form. */
157955
+ instanceFormSchema: method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }),
157956
+ listInstances: method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }),
157957
+ getInstance: method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }),
157958
+ /**
157959
+ * Insert the typed record. With `id` it is stored under that id (a duplicate id is refused);
157960
+ * without one the PROVIDER mints it and answers it. `kind` picks one of `describe().kinds`.
157961
+ */
157962
+ createInstance: method(object({
157963
+ id: IntegrationInstanceIdSchema.optional(),
157964
+ name: string().min(1),
157965
+ kind: string().min(1).optional()
157966
+ }), object({ id: string() }), {
157967
+ kind: "mutation",
157968
+ auth: "admin"
157969
+ }),
157970
+ updateInstance: method(IntegrationInstancePatchSchema, object({ ok: literal(true) }), {
157971
+ kind: "mutation",
157972
+ auth: "admin"
157973
+ }),
157974
+ /**
157975
+ * Switch ONE instance on or off. A row control, not a form field: `updateInstance.values` is the
157976
+ * form patch (D62, D996). An off instance keeps its record and secrets and does no work.
157977
+ */
157978
+ setInstanceEnabled: method(object({
157979
+ id: IntegrationInstanceIdSchema,
157980
+ enabled: boolean()
157981
+ }), object({ ok: literal(true) }), {
157982
+ kind: "mutation",
157983
+ auth: "admin"
157984
+ }),
157985
+ /** Delete the record and its keychain refs together. */
157986
+ deleteInstance: method(object({ id: IntegrationInstanceIdSchema }), object({ ok: literal(true) }), {
157987
+ kind: "mutation",
157988
+ auth: "admin"
157989
+ }),
157990
+ testInstance: optionalMethod(object({
157991
+ id: IntegrationInstanceIdSchema.nullable(),
157992
+ values: record(string(), unknown())
157993
+ }), TestConnectionResultSchema$1, {
157994
+ kind: "mutation",
157995
+ auth: "admin"
157996
+ })
157997
+ }
157998
+ };
156853
157999
  /**
156854
158000
  * Two-way audio / talk-back cap. Clients open a WebRTC audio-only
156855
158001
  * session (server-initiated offer pattern, identical to the main
@@ -158888,6 +160034,11 @@ var LogEntrySchema = object({
158888
160034
  * flight, and not asking for the next slice ends the scan.
158889
160035
  */
158890
160036
  var LogReadQuerySchema = object({
160037
+ /**
160038
+ * Which destination INSTANCE answers (a destination addon may hold several records). Absent ⇒
160039
+ * the provider's first readable one, so a caller written before instances existed is unchanged.
160040
+ */
160041
+ instanceId: string().min(1).max(200).optional(),
158891
160042
  /** Correlates `cancelRead`. Caller-chosen, unique per in-flight read. */
158892
160043
  requestId: string().min(1).max(64).optional(),
158893
160044
  /** The device the line is about (`tags.deviceId`, numeric). */
@@ -158950,6 +160101,8 @@ var LogReadResultSchema = object({
158950
160101
  /** True when `cancelRead` ended this slice early. */
158951
160102
  cancelled: boolean().optional()
158952
160103
  });
160104
+ /** Names one destination instance; absent ⇒ the provider's first readable one. */
160105
+ var LogInstanceSelectorSchema = object({ instanceId: string().min(1).max(200).optional() });
158953
160106
  var LogReadCapabilitiesSchema = object({
158954
160107
  /** False ⇒ this destination only ships lines (console, forwarder, an unconfigured Loki). */
158955
160108
  readable: boolean(),
@@ -158968,7 +160121,7 @@ var LogReadCapabilitiesSchema = object({
158968
160121
  /** How far back the store reaches, when the provider knows (days). */
158969
160122
  retentionDays: number().nullable()
158970
160123
  });
158971
- method(LogEntrySchema, _void(), { kind: "mutation" }), optionalMethod(_void(), LogReadCapabilitiesSchema, { auth: "admin" }), optionalMethod(LogReadQuerySchema, LogReadResultSchema, { auth: "admin" }), optionalMethod(object({ requestId: string().min(1).max(64) }), object({ cancelled: boolean() }), {
160124
+ method(LogEntrySchema, _void(), { kind: "mutation" }), optionalMethod(LogInstanceSelectorSchema, LogReadCapabilitiesSchema, { auth: "admin" }), optionalMethod(LogReadQuerySchema, LogReadResultSchema, { auth: "admin" }), optionalMethod(object({ requestId: string().min(1).max(64) }), object({ cancelled: boolean() }), {
158972
160125
  kind: "mutation",
158973
160126
  auth: "admin"
158974
160127
  });
@@ -171361,6 +172514,14 @@ var powerMeterCapability = {
171361
172514
  deviceNative: true,
171362
172515
  mode: "singleton",
171363
172516
  deviceTypes: POWER_METER_DEVICE_TYPES,
172517
+ deviceConfig: { ui: {
172518
+ kind: "widget",
172519
+ widgetId: "host/power-meter-line",
172520
+ tab: "power",
172521
+ placement: "hero",
172522
+ label: "Power",
172523
+ order: 4
172524
+ } },
171364
172525
  methods: {},
171365
172526
  status: {
171366
172527
  schema: PowerMeterStatusSchema,
@@ -171420,6 +172581,117 @@ var powerMeterCapability = {
171420
172581
  volatileStateFields: ["lastFetchedAt"],
171421
172582
  customize: powerMeterCustomize
171422
172583
  };
172584
+ var PowerSourceRefusalReasonSchema = _enum([
172585
+ "no-power-source",
172586
+ "switch-missing",
172587
+ "switch-offline",
172588
+ "switch-unknown",
172589
+ "restart-in-progress",
172590
+ "switch-call-failed"
172591
+ ]);
172592
+ var PowerSourceActionResultSchema = discriminatedUnion("accepted", [object({ accepted: literal(true) }), object({
172593
+ accepted: literal(false),
172594
+ reason: PowerSourceRefusalReasonSchema,
172595
+ /** One line for the operator, naming the switch. */
172596
+ message: string()
172597
+ })]);
172598
+ var PowerSourceInfoSchema = object({
172599
+ /** False when the device has no power source linked; the rest is then empty. */
172600
+ linked: boolean(),
172601
+ switchDeviceId: number().nullable(),
172602
+ switchName: string().nullable(),
172603
+ /** The switch's on/off state, or null when it is unknown. Never false for unknown (D393). */
172604
+ switchOn: boolean().nullable(),
172605
+ switchOnline: boolean().nullable(),
172606
+ restartInProgress: boolean(),
172607
+ offSeconds: number().int()
172608
+ });
172609
+ /** The composed slice: the switch state the link mirrors, plus the stored off time. */
172610
+ var PowerSourceStateSchema = object({
172611
+ /** Null while the switch has not reported: unknown is never `false` (D393). */
172612
+ switchOn: boolean().nullable(),
172613
+ offSeconds: number().int(),
172614
+ lastFetchedAt: number()
172615
+ });
172616
+ var POWER_SOURCE_DEVICE_TYPES = [
172617
+ DeviceType.Gateway,
172618
+ DeviceType.Hub,
172619
+ DeviceType.Camera,
172620
+ DeviceType.Sensor,
172621
+ DeviceType.MediaPlayer,
172622
+ DeviceType.Vacuum,
172623
+ DeviceType.Generic,
172624
+ DeviceType.Washer,
172625
+ DeviceType.Dryer,
172626
+ DeviceType.Dishwasher,
172627
+ DeviceType.PetFountain,
172628
+ DeviceType.Fan,
172629
+ DeviceType.Humidifier,
172630
+ DeviceType.Climate,
172631
+ DeviceType.WaterHeater
172632
+ ];
172633
+ var powerSourceCustomize = {
172634
+ version: 1,
172635
+ targets: [{
172636
+ id: "switch",
172637
+ cardinality: "scalar",
172638
+ vocabulary: {
172639
+ cap: "power-source",
172640
+ stateField: "switchOn"
172641
+ },
172642
+ deviceTypes: POWER_SOURCE_DEVICE_TYPES,
172643
+ apply: "add",
172644
+ path: "switchOn",
172645
+ sources: [{
172646
+ kind: "switch",
172647
+ transforms: [{ kind: "identity" }]
172648
+ }],
172649
+ companions: [{
172650
+ kind: "constant",
172651
+ path: "offSeconds",
172652
+ value: 10,
172653
+ when: "add"
172654
+ }, {
172655
+ kind: "clock",
172656
+ path: "lastFetchedAt",
172657
+ when: "always"
172658
+ }],
172659
+ sourceReport: { status: "assignment-status" }
172660
+ }]
172661
+ };
172662
+ var deviceInput$1 = object({ deviceId: number().int().nonnegative() });
172663
+ var powerSourceCapability = {
172664
+ name: "power-source",
172665
+ scope: "device",
172666
+ mode: "singleton",
172667
+ deviceTypes: POWER_SOURCE_DEVICE_TYPES,
172668
+ methods: {
172669
+ getStatus: systemMethod(deviceInput$1, PowerSourceInfoSchema, { auth: "admin" }),
172670
+ powerOn: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
172671
+ kind: "mutation",
172672
+ auth: "admin"
172673
+ }),
172674
+ powerOff: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
172675
+ kind: "mutation",
172676
+ auth: "admin"
172677
+ }),
172678
+ forceRestart: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
172679
+ kind: "mutation",
172680
+ auth: "admin"
172681
+ })
172682
+ },
172683
+ runtimeState: PowerSourceStateSchema,
172684
+ customize: powerSourceCustomize,
172685
+ /**
172686
+ * Runtime-state durability: **session** — mirrored from the linked switch on
172687
+ * its next report; a restored value would outlive the reading it names.
172688
+ *
172689
+ * See `RuntimeStateDurability`. Enforced by
172690
+ * `scripts/check-runtime-state-durability.ts`.
172691
+ */
172692
+ durability: "session",
172693
+ history: "none"
172694
+ };
171423
172695
  /**
171424
172696
  * Presence cap. Models HA `person.*` and `device_tracker.*` entities
171425
172697
  * on `DeviceType.Presence`. Read-only — no setters: presence is
@@ -181900,6 +183172,25 @@ function buildStreamParamsConfigSchema(options, status) {
181900
183172
  if (sections.length === 0) return null;
181901
183173
  return { sections };
181902
183174
  }
183175
+ /**
183176
+ * What a remote key is CALLED and what it LOOKS like, for every role (D963),
183177
+ * shared by the admin UI and the viewer. A role is data on the wire; each
183178
+ * client resolves its name from `label` (English and Italian side by side) and
183179
+ * its glyph from `icon`, a lucide id from the shared icon catalogue — the
183180
+ * server sends an id, a client maps it to its own icon set.
183181
+ *
183182
+ * `legend` is text printed on the key INSTEAD of a glyph (a digit has no
183183
+ * pictogram): a client that can draw text shows it, one that cannot falls back
183184
+ * to `icon`.
183185
+ */
183186
+ function digit(n) {
183187
+ return {
183188
+ label: t(n, n),
183189
+ icon: "hash",
183190
+ legend: n
183191
+ };
183192
+ }
183193
+ digit("0"), digit("1"), digit("2"), digit("3"), digit("4"), digit("5"), digit("6"), digit("7"), digit("8"), digit("9");
181903
183194
  DeviceFeature.MediaPlayerPower, DeviceFeature.MediaPlayerVolumeStep, DeviceFeature.MediaPlayerChannelStep, DeviceFeature.MediaPlayerMute, DeviceFeature.MediaPlayerStop, DeviceFeature.MediaPlayerNext, DeviceFeature.MediaPlayerPrevious;
181904
183195
  var btn = (idSuffix, row, col, init) => ({
181905
183196
  idSuffix,
@@ -182028,6 +183319,34 @@ discriminatedUnion("kind", [object({
182028
183319
  code: string()
182029
183320
  })]);
182030
183321
  /**
183322
+ * Propose a running threshold from a device's own power history (planning#405).
183323
+ *
183324
+ * Pure and deterministic: the same series gives the same answer. The series is
183325
+ * a HELD-VALUE step series — a point's value stands until the next point — which
183326
+ * is how device-history stores a gauge, so "time at a level" is exact, not a
183327
+ * count of samples.
183328
+ *
183329
+ * 1. idle floor F = time-weighted median; noise ceiling N = highest value seen
183330
+ * near F. A device that is not near one level most of the time has no floor.
183331
+ * 2. coarse activity = above max(2N, N + 5 W); consecutive stretches are episodes.
183332
+ * 3. gaps between episodes split in two by the largest log-scale jump (>= 3x):
183333
+ * the short ones are pauses INSIDE a run (a soak), the long ones separate runs.
183334
+ * 4. T = geometric mean of N and the plateau's 10th percentile, snapped to a
183335
+ * grid a person would type; stop dwell covers the longest pause; start dwell
183336
+ * covers the longest standby blip above T.
183337
+ * 5. the numbers are proved by REPLAY through the real `dwell` evaluation: each
183338
+ * run must hold exactly one true interval and nothing may flap outside.
183339
+ */
183340
+ var SECOND_MS = 1e3;
183341
+ var MINUTE_MS$1 = 60 * SECOND_MS;
183342
+ 24 * (60 * MINUTE_MS$1);
183343
+ 10 * MINUTE_MS$1;
183344
+ 30 * SECOND_MS;
183345
+ 60 * SECOND_MS;
183346
+ 20 * MINUTE_MS$1;
183347
+ 10 * SECOND_MS;
183348
+ 10 * SECOND_MS;
183349
+ /**
182031
183350
  * Which of OUR object detections a clip window holds — the ONE overlap rule
182032
183351
  * and the ONE label ranking, for every clip source (D923).
182033
183352
  *
@@ -185280,6 +186599,13 @@ Object.freeze({
185280
186599
  access: "create",
185281
186600
  role: "admin"
185282
186601
  },
186602
+ "coreBlocks.proposeRunningThreshold": {
186603
+ capName: "core-blocks",
186604
+ capScope: "system",
186605
+ addonId: null,
186606
+ access: "view",
186607
+ role: "admin"
186608
+ },
185283
186609
  "coreBlocks.reportAutomationRun": {
185284
186610
  capName: "core-blocks",
185285
186611
  capScope: "system",
@@ -185791,6 +187117,13 @@ Object.freeze({
185791
187117
  access: "view",
185792
187118
  role: "admin"
185793
187119
  },
187120
+ "deviceExport.listInstances": {
187121
+ capName: "device-export",
187122
+ capScope: "system",
187123
+ addonId: null,
187124
+ access: "view",
187125
+ role: "admin"
187126
+ },
185794
187127
  "deviceExport.listSupportedDeviceKinds": {
185795
187128
  capName: "device-export",
185796
187129
  capScope: "system",
@@ -187443,6 +188776,13 @@ Object.freeze({
187443
188776
  access: "create",
187444
188777
  role: "admin"
187445
188778
  },
188779
+ "integrations.clearSettings": {
188780
+ capName: "integrations",
188781
+ capScope: "system",
188782
+ addonId: null,
188783
+ access: "delete",
188784
+ role: "admin"
188785
+ },
187446
188786
  "integrations.create": {
187447
188787
  capName: "integrations",
187448
188788
  capScope: "system",
@@ -194890,6 +196230,11 @@ Object.freeze({
194890
196230
  form: "single",
194891
196231
  optional: false
194892
196232
  }],
196233
+ "coreBlocks.proposeRunningThreshold": [{
196234
+ name: "deviceId",
196235
+ form: "single",
196236
+ optional: false
196237
+ }],
194893
196238
  "cover.close": [{
194894
196239
  name: "deviceId",
194895
196240
  form: "single",
@@ -197553,99 +198898,6 @@ Object.freeze({
197553
198898
  "network-access": "ingress",
197554
198899
  "smtp-provider": "email"
197555
198900
  });
197556
- /**
197557
- * A saved Customize assignment (D936). The schema is the type: callers use
197558
- * `z.infer`, they do not restate the shape.
197559
- */
197560
- var OverrideSourceSchema = object({
197561
- addonId: string().min(1),
197562
- stableId: string().min(1),
197563
- cap: string().min(1),
197564
- fieldPath: string().min(1)
197565
- });
197566
- var CustomizeTransformDraftSchema = discriminatedUnion("kind", [
197567
- object({ kind: literal("identity") }),
197568
- object({ kind: literal("invert") }),
197569
- object({
197570
- kind: literal("threshold"),
197571
- comparison: _enum(["gte", "lte"]),
197572
- threshold: number(),
197573
- startDwellMs: number().nonnegative(),
197574
- stopDwellMs: number().nonnegative()
197575
- }),
197576
- object({
197577
- kind: literal("linear"),
197578
- scale: number(),
197579
- offset: number(),
197580
- clampMin: number().optional(),
197581
- clampMax: number().optional()
197582
- }),
197583
- object({
197584
- kind: literal("enum-map"),
197585
- mapping: record(string(), string().nullable())
197586
- }),
197587
- object({ kind: literal("number") })
197588
- ]);
197589
- var ScalarOverrideDraftSchema = object({
197590
- kind: literal("scalar"),
197591
- cap: string().min(1),
197592
- targetId: string().min(1),
197593
- source: OverrideSourceSchema,
197594
- transform: CustomizeTransformDraftSchema
197595
- });
197596
- var KeyedArrayOverrideDraftSchema = object({
197597
- kind: literal("keyedArray"),
197598
- cap: string().min(1),
197599
- targetId: string().min(1),
197600
- key: string().min(1),
197601
- label: string().min(1),
197602
- fieldPath: string().min(1),
197603
- source: OverrideSourceSchema,
197604
- transform: CustomizeTransformDraftSchema
197605
- });
197606
- var PulseOverrideDraftSchema = object({
197607
- kind: literal("pulse"),
197608
- cap: string().min(1),
197609
- targetId: string().min(1),
197610
- enabled: boolean(),
197611
- debounceMs: number().nonnegative(),
197612
- edges: array(_enum([
197613
- "rising",
197614
- "button",
197615
- "native-event"
197616
- ])).min(1).optional(),
197617
- sources: array(OverrideSourceSchema).min(1)
197618
- });
197619
- var SpanSourceDraftSchema = OverrideSourceSchema.extend({ mode: _enum([
197620
- "in-addition",
197621
- "confirm",
197622
- "replace"
197623
- ]).optional() });
197624
- discriminatedUnion("kind", [
197625
- ScalarOverrideDraftSchema,
197626
- KeyedArrayOverrideDraftSchema,
197627
- PulseOverrideDraftSchema,
197628
- object({
197629
- kind: literal("span"),
197630
- cap: string().min(1),
197631
- targetId: string().min(1),
197632
- timeoutMs: number().nonnegative(),
197633
- cooldownMs: number().nonnegative().optional(),
197634
- sources: array(SpanSourceDraftSchema)
197635
- })
197636
- ]);
197637
- object({
197638
- targetId: string().min(1),
197639
- configured: boolean(),
197640
- source: _enum(["native", "assigned"]),
197641
- state: _enum([
197642
- "active",
197643
- "stale",
197644
- "released",
197645
- "unavailable"
197646
- ]),
197647
- reason: string().nullable()
197648
- });
197649
198901
  var G711_SCALE_CORRECTION_DB = {
197650
198902
  PCMU: 20 * Math.log10(4),
197651
198903
  PCMA: 20 * Math.log10(8)
@@ -219657,6 +220909,285 @@ async function startLegacyWebhookIntake(deps) {
219657
220909
  };
219658
220910
  }
219659
220911
  //#endregion
220912
+ //#region src/reolink-discovery-map.ts
220913
+ /**
220914
+ * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
220915
+ * discovery candidate mapping so a host-keyed camera is detected as already onboarded on re-scan.
220916
+ */
220917
+ function slugifyReolinkHost(host) {
220918
+ return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
220919
+ }
220920
+ /**
220921
+ * Decide whether a discovered host is a device this provider already owns.
220922
+ *
220923
+ * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
220924
+ * wins even when the host differs, because the address is the thing that moves.
220925
+ */
220926
+ function findOnboardedMatch(discovered, onboarded) {
220927
+ if (discovered.uid !== void 0 && discovered.uid !== "") {
220928
+ const byUid = onboarded.find((o) => o.uid === discovered.uid);
220929
+ if (byUid !== void 0) return byUid;
220930
+ }
220931
+ return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
220932
+ }
220933
+ /** Baichuan control port of an NVR / Home Hub (the scan only ever reports the HTTP/ONVIF one). */
220934
+ var HUB_BAICHUAN_PORT = 9e3;
220935
+ /**
220936
+ * `true` for a model string that names an NVR or a Home Hub: a device that fronts channels
220937
+ * rather than being one camera. Such a device is offered as an INTEGRATION to set up (D985), never
220938
+ * as a camera to add.
220939
+ */
220940
+ function looksLikeReolinkHub(model) {
220941
+ if (model === void 0) return false;
220942
+ return /\b(nvr|rln\d*|home\s*hub|hub)\b/i.test(model);
220943
+ }
220944
+ /**
220945
+ * The stableId of an NVR / Home Hub candidate. UID first (it survives a DHCP lease change), host
220946
+ * second. The boot migration derives the SAME id for an old Hub device, so the integration it
220947
+ * creates hides this candidate through `originStableId`.
220948
+ */
220949
+ function reolinkHubStableId(identity) {
220950
+ const uid = identity.uid?.trim() ?? "";
220951
+ if (uid !== "") return `reolink-hub-uid-${slugifyReolinkHost(uid)}`;
220952
+ return `reolink-hub-host-${slugifyReolinkHost(identity.host)}`;
220953
+ }
220954
+ function isKnownHub(d, known) {
220955
+ return known.some((k) => k.uid !== void 0 && k.uid !== "" && k.uid === d.uid || k.host !== void 0 && k.host !== "" && k.host === d.host);
220956
+ }
220957
+ /**
220958
+ * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
220959
+ * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
220960
+ * first responder for a host wins. Pure: no I/O.
220961
+ *
220962
+ * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
220963
+ * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
220964
+ * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
220965
+ * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
220966
+ * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
220967
+ * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
220968
+ * never a scan result.
220969
+ *
220970
+ * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
220971
+ * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
220972
+ * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
220973
+ * in the first place, so a scan says so instead of offering it again.
220974
+ */
220975
+ function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = [], hubIntegrations = []) {
220976
+ const username = credentials.username?.trim() ?? "";
220977
+ const password = credentials.password ?? "";
220978
+ const byHost = /* @__PURE__ */ new Map();
220979
+ for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
220980
+ return [...byHost.values()].flatMap((d) => {
220981
+ const displayName = d.name ?? d.model ?? d.host;
220982
+ if (looksLikeReolinkHub(d.model)) {
220983
+ if (isKnownHub(d, hubIntegrations)) return [];
220984
+ return [{
220985
+ stableId: reolinkHubStableId(d),
220986
+ type: DeviceType.Gateway,
220987
+ suggestedName: displayName,
220988
+ prefilledConfig: {
220989
+ host: d.host,
220990
+ port: HUB_BAICHUAN_PORT,
220991
+ ...d.uid ? { uid: d.uid } : {},
220992
+ ...username ? { username } : {}
220993
+ },
220994
+ setupIntegration: true
220995
+ }];
220996
+ }
220997
+ const match = findOnboardedMatch(d, onboarded);
220998
+ return [{
220999
+ stableId: `host-${slugifyReolinkHost(d.host)}`,
221000
+ type: DeviceType.Camera,
221001
+ suggestedName: displayName,
221002
+ prefilledConfig: {
221003
+ name: displayName,
221004
+ host: d.host,
221005
+ transport: "auto",
221006
+ ...d.uid ? { uid: d.uid } : {},
221007
+ ...username ? { username } : {},
221008
+ ...password ? { password } : {}
221009
+ },
221010
+ ...match !== void 0 ? {
221011
+ alreadyOnboarded: true,
221012
+ onboardedDeviceId: match.deviceId,
221013
+ onboardedName: match.name
221014
+ } : {}
221015
+ }];
221016
+ });
221017
+ }
221018
+ //#endregion
221019
+ //#region src/reolink-hub-migration.ts
221020
+ /**
221021
+ * D985 — boot migration of the old Reolink Hub DEVICE into an INTEGRATION.
221022
+ *
221023
+ * A Hub used to be a device with the NVR's credentials in its config and its channels as
221024
+ * parented child cameras. Now the NVR is an integration (credentials in its keychain-backed
221025
+ * settings) and the channels are top-level cameras stamped with its id. For every Hub row:
221026
+ *
221027
+ * 1. create (or reuse) an integration named after it, config + password moved as settings;
221028
+ * 2. keep every child's deviceId and stableId, stamp it with the integrationId, un-parent it
221029
+ * and make sure its persisted blob can be restored on its own;
221030
+ * 3. only AFTER the children restored and the hub has none left, retire the hub by tombstone.
221031
+ *
221032
+ * Idempotent (a second run finds no Hub rows) and all-or-nothing per hub: a step that throws
221033
+ * leaves the hub device and its children's parentage exactly as they were, and the next boot
221034
+ * starts over. A locked secret is never read as an empty password and no replacement is minted.
221035
+ */
221036
+ /** The marker an integration carries so a re-run reuses it instead of minting a second one. */
221037
+ var MIGRATED_FROM_HUB_KEY = "migratedFromHubDeviceId";
221038
+ function positivePort(value) {
221039
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
221040
+ }
221041
+ var CONNECTION_FIELDS = [
221042
+ "host",
221043
+ "port",
221044
+ "username",
221045
+ "transport"
221046
+ ];
221047
+ function str(value) {
221048
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
221049
+ }
221050
+ function migratedChildConfig(child, hub, integrationId) {
221051
+ const filled = {};
221052
+ for (const key of CONNECTION_FIELDS) {
221053
+ const own = child[key];
221054
+ const inherited = hub[key];
221055
+ if ((own === void 0 || own === null || own === "") && inherited !== void 0) filled[key] = inherited;
221056
+ }
221057
+ return {
221058
+ ...child,
221059
+ ...filled,
221060
+ integrationId
221061
+ };
221062
+ }
221063
+ async function migrateOneHub(hub, children, integrations, ports) {
221064
+ const read = readHubConnection(hub.config);
221065
+ if (read.kind !== "ok") throw new Error(`hub connection unusable (${read.kind}) — left in place`);
221066
+ const { connection } = read;
221067
+ const uid = str(hub.config["uid"]);
221068
+ const integrationId = integrations.find((i) => i.addonId === ports.addonId && i.info["migratedFromHubDeviceId"] === hub.id)?.id ?? (await ports.createIntegration({
221069
+ addonId: ports.addonId,
221070
+ name: hub.name,
221071
+ info: { ["migratedFromHubDeviceId"]: hub.id },
221072
+ originStableId: reolinkHubStableId({
221073
+ ...uid !== void 0 ? { uid } : {},
221074
+ host: connection.host
221075
+ }),
221076
+ settings: {
221077
+ host: connection.host,
221078
+ port: connection.port,
221079
+ rtspPort: positivePort(hub.config["rtspPort"]) ?? connection.rtspPort,
221080
+ username: connection.username,
221081
+ password: connection.password,
221082
+ transport: connection.transport,
221083
+ ...uid !== void 0 ? { uid } : {},
221084
+ debugGeneral: connection.debugGeneral,
221085
+ debugSocketLogs: connection.debugSocketLogs
221086
+ },
221087
+ secretKeys: ["password"]
221088
+ })).id;
221089
+ const rewritten = [];
221090
+ for (const child of children) {
221091
+ const config = migratedChildConfig(child.config, hub.config, integrationId);
221092
+ await ports.persistConfig(child.stableId, config);
221093
+ await ports.setIntegrationId(child.id, integrationId);
221094
+ rewritten.push({
221095
+ ...child,
221096
+ config,
221097
+ parentDeviceId: null
221098
+ });
221099
+ }
221100
+ return {
221101
+ rows: rewritten,
221102
+ retirement: {
221103
+ hubDeviceId: hub.id,
221104
+ hubName: hub.name,
221105
+ integrationId
221106
+ }
221107
+ };
221108
+ }
221109
+ /** Plan and persist the migration of every Hub row in `saved`. Never throws. */
221110
+ async function planHubMigration(saved, ports) {
221111
+ const hubs = saved.filter((row) => row.type === DeviceType.Hub);
221112
+ if (hubs.length === 0) return {
221113
+ rows: saved,
221114
+ retirements: []
221115
+ };
221116
+ let integrations;
221117
+ try {
221118
+ integrations = await ports.listIntegrations();
221119
+ } catch (err) {
221120
+ ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
221121
+ return {
221122
+ rows: saved.filter((row) => row.type !== DeviceType.Hub),
221123
+ retirements: []
221124
+ };
221125
+ }
221126
+ const hubIds = new Set(hubs.map((h) => h.id));
221127
+ const rewrittenById = /* @__PURE__ */ new Map();
221128
+ const retirements = [];
221129
+ for (const hub of hubs) {
221130
+ const children = saved.filter((row) => row.parentDeviceId === hub.id);
221131
+ try {
221132
+ const done = await migrateOneHub(hub, children, integrations, ports);
221133
+ for (const row of done.rows) rewrittenById.set(row.id, row);
221134
+ retirements.push(done.retirement);
221135
+ ports.logger.info("Reolink hub migrated to an integration", {
221136
+ tags: { deviceId: hub.id },
221137
+ meta: {
221138
+ integrationId: done.retirement.integrationId,
221139
+ children: children.length,
221140
+ name: hub.name
221141
+ }
221142
+ });
221143
+ } catch (err) {
221144
+ ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
221145
+ tags: { deviceId: hub.id },
221146
+ meta: {
221147
+ error: errMsg$3(err),
221148
+ name: hub.name
221149
+ }
221150
+ });
221151
+ }
221152
+ }
221153
+ return {
221154
+ rows: saved.filter((row) => !hubIds.has(row.id)).map((row) => rewrittenById.get(row.id) ?? row),
221155
+ retirements
221156
+ };
221157
+ }
221158
+ /**
221159
+ * Retire the hubs whose children all restored. A hub that still has a child in the store is left
221160
+ * and named: removing it would cascade the camera away with it.
221161
+ */
221162
+ async function retireMigratedHubs(retirements, ports) {
221163
+ let retired = 0;
221164
+ for (const r of retirements) try {
221165
+ const remaining = await ports.childIdsOf(r.hubDeviceId);
221166
+ if (remaining.length > 0) {
221167
+ ports.logger.warn("Reolink hub kept — a child did not restore, retiring it would remove it", {
221168
+ tags: { deviceId: r.hubDeviceId },
221169
+ meta: {
221170
+ remaining: [...remaining],
221171
+ integrationId: r.integrationId
221172
+ }
221173
+ });
221174
+ continue;
221175
+ }
221176
+ await ports.removeDevice(r.hubDeviceId);
221177
+ retired++;
221178
+ ports.logger.info("Reolink hub device retired", {
221179
+ tags: { deviceId: r.hubDeviceId },
221180
+ meta: { integrationId: r.integrationId }
221181
+ });
221182
+ } catch (err) {
221183
+ ports.logger.warn("Reolink hub retirement failed — retried next boot", {
221184
+ tags: { deviceId: r.hubDeviceId },
221185
+ meta: { error: errMsg$3(err) }
221186
+ });
221187
+ }
221188
+ return retired;
221189
+ }
221190
+ //#endregion
219660
221191
  //#region src/migrations/drop-webhook-intake-settings.ts
219661
221192
  /**
219662
221193
  * The camera webhook is a plane of the addon's HTTP data plane, not a private listener
@@ -219715,7 +221246,7 @@ function text(stored, key) {
219715
221246
  const value = stored[key];
219716
221247
  return typeof value === "string" && value.length > 0 ? value : null;
219717
221248
  }
219718
- function portOf(stored, ports) {
221249
+ function portOf$1(stored, ports) {
219719
221250
  const value = stored["emailPushPort"];
219720
221251
  const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
219721
221252
  if (Number.isInteger(numeric) && numeric >= 1 && numeric <= 65535) return numeric;
@@ -219737,7 +221268,7 @@ async function moveEmailPushToMailbox(ports) {
219737
221268
  };
219738
221269
  const bindHost = text(stored, "emailPushBindHost");
219739
221270
  await ports.stashPending({
219740
- port: portOf(stored, ports),
221271
+ port: portOf$1(stored, ports),
219741
221272
  ...bindHost === null ? {} : { bindHost },
219742
221273
  domain: text(stored, "emailPushDomain") ?? DEFAULT_DOMAIN,
219743
221274
  requireAuth: stored["emailPushRequireAuth"] !== false,
@@ -219749,6 +221280,188 @@ async function moveEmailPushToMailbox(ports) {
219749
221280
  summary: { moved: true }
219750
221281
  };
219751
221282
  }
221283
+ var reolinkConnectionsEntryKind = defineEntryKind("connections", {
221284
+ title: "Reolink integrations",
221285
+ schema: entryBody({
221286
+ host: string().nullish(),
221287
+ port: number().int().min(1).max(65535).nullish(),
221288
+ rtspPort: number().int().min(1).max(65535).nullish(),
221289
+ username: string().nullish(),
221290
+ password: string().optional().meta({ secret: true }),
221291
+ transport: _enum(["tcp", "udp"]).nullish(),
221292
+ uid: string().nullish(),
221293
+ debugGeneral: boolean().nullish(),
221294
+ debugSocketLogs: array(ReolinkSocketDebugFlagSchema).nullish()
221295
+ }),
221296
+ writeTrigger: "an operator adds, edits or removes a Reolink integration, or a Hub device is migrated into one"
221297
+ });
221298
+ /** Every kind this addon declares, keyed by its name. */
221299
+ var reolinkRecordKinds = { connections: reolinkConnectionsEntryKind };
221300
+ /** The record body as the connection form's flat values: no name, no unset keys. */
221301
+ function hubSettingsOf(body) {
221302
+ const out = {};
221303
+ for (const [key, value] of Object.entries(body)) {
221304
+ if (key === "name" || value === null || value === void 0) continue;
221305
+ out[key] = value;
221306
+ }
221307
+ return out;
221308
+ }
221309
+ /**
221310
+ * What a resolved record says about its hub. A keychain that is locked yields a record WITHOUT
221311
+ * its password: that is `password-locked`, never an empty password (D49).
221312
+ */
221313
+ function hubConnectionOf(resolved) {
221314
+ const read = readHubConnection(hubSettingsOf(resolved.body));
221315
+ return read.kind === "no-password" && resolved.secrets === "unavailable" ? { kind: "password-locked" } : read;
221316
+ }
221317
+ /**
221318
+ * What each enabled integration's RECORD says about its hub: the one reader of the connection.
221319
+ * A record that is absent reads as `no-host` (no session), exactly as an empty settings blob did.
221320
+ */
221321
+ async function readHubConnections(instances, integrations) {
221322
+ const out = [];
221323
+ for (const integration of integrations) {
221324
+ const resolved = await instances.readResolved(integration.id);
221325
+ out.push({
221326
+ id: integration.id,
221327
+ name: integration.name,
221328
+ read: resolved === null ? { kind: "no-host" } : hubConnectionOf(resolved)
221329
+ });
221330
+ }
221331
+ return out;
221332
+ }
221333
+ function buildReolinkInstances(records, deps) {
221334
+ return new RecordIntegrationInstances(records, reolinkConnectionsEntryKind, {
221335
+ description: {
221336
+ role: "devices",
221337
+ discovery: {
221338
+ mode: "lan",
221339
+ scopedToInstance: false
221340
+ },
221341
+ instanceMode: "multiple",
221342
+ kind: "connection",
221343
+ canTest: false
221344
+ },
221345
+ formSchema: () => buildHubInstanceSchema(),
221346
+ initialBody: (name) => ({ name }),
221347
+ configured: (body) => (body.host ?? "").length === 0 || (body.password ?? "").length > 0,
221348
+ onChanged: () => deps.onChanged()
221349
+ });
221350
+ }
221351
+ /**
221352
+ * The provider handed to the hub: the default integration (no connection) shows no hub form, an
221353
+ * NVR / Home Hub one does, and a create wizard (no id yet) sets up a hub.
221354
+ */
221355
+ function reolinkInstancesProvider(instances, deps) {
221356
+ return {
221357
+ ...instances.toProvider(),
221358
+ instanceFormSchema: async ({ id }) => {
221359
+ if (id === null) return buildHubInstanceSchema();
221360
+ const [evidence, body] = await Promise.all([deps.registryEvidenceOf(id), instances.getResolved(id)]);
221361
+ return evidence !== null && isHubIntegration({
221362
+ originStableId: evidence.originStableId,
221363
+ info: evidence.info,
221364
+ settings: body === null ? {} : hubSettingsOf(body)
221365
+ }) || body === null ? buildHubInstanceSchema() : { sections: [] };
221366
+ }
221367
+ };
221368
+ }
221369
+ //#endregion
221370
+ //#region src/migrations/integration-rows-to-record.ts
221371
+ /**
221372
+ * Every Reolink integration moves from legacy `integration_settings` rows into its own
221373
+ * `connections` record under the registry id (planning#416, D1007). An NVR / Home Hub integration
221374
+ * carries its connection (host, ports, username, transport, uid, the two debug fields) and its
221375
+ * password, a keychain secret declared by the kind; the default integration carries a name only.
221376
+ * Each integration's record is built from ITS OWN rows, never from another's.
221377
+ *
221378
+ * Keys nothing reads any more are named in a warning, by key, and dropped with the rows once the
221379
+ * record has been read back. A value of the wrong type is left out and named by key as well.
221380
+ */
221381
+ var REOLINK_ROWS_TO_RECORD_ID = "provider-reolink/2026-11-05-integration-rows-to-record";
221382
+ var TEXT_KEYS = [
221383
+ "host",
221384
+ "username",
221385
+ "uid"
221386
+ ];
221387
+ var PORT_KEYS = ["port", "rtspPort"];
221388
+ function portOf(value) {
221389
+ const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
221390
+ return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
221391
+ }
221392
+ function textOf(value) {
221393
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
221394
+ }
221395
+ /** The record body of one integration, from its resolved legacy rows. Never logs a value. */
221396
+ function reolinkBodyFromRows(values, log, integration) {
221397
+ const body = { name: integration.name };
221398
+ const unusable = [];
221399
+ const known = new Set([
221400
+ ...TEXT_KEYS,
221401
+ ...PORT_KEYS,
221402
+ "password",
221403
+ "transport",
221404
+ "debugGeneral",
221405
+ "debugSocketLogs"
221406
+ ]);
221407
+ for (const key of TEXT_KEYS) {
221408
+ if (!(key in values)) continue;
221409
+ const text = textOf(values[key]);
221410
+ if (text === null) unusable.push(key);
221411
+ else body[key] = text;
221412
+ }
221413
+ for (const key of PORT_KEYS) {
221414
+ if (!(key in values)) continue;
221415
+ const port = portOf(values[key]);
221416
+ if (port === null) unusable.push(key);
221417
+ else body[key] = port;
221418
+ }
221419
+ if ("password" in values) {
221420
+ const password = values["password"];
221421
+ if (typeof password === "string" && password.length > 0) body["password"] = password;
221422
+ else unusable.push("password");
221423
+ }
221424
+ if ("transport" in values) {
221425
+ const transport = values["transport"];
221426
+ if (transport === "tcp" || transport === "udp") body["transport"] = transport;
221427
+ else unusable.push("transport");
221428
+ }
221429
+ if ("debugGeneral" in values) {
221430
+ const flag = values["debugGeneral"];
221431
+ if (typeof flag === "boolean") body["debugGeneral"] = flag;
221432
+ else unusable.push("debugGeneral");
221433
+ }
221434
+ if ("debugSocketLogs" in values) {
221435
+ const flags = values["debugSocketLogs"];
221436
+ const parsed = Array.isArray(flags) ? flags.map((f) => ReolinkSocketDebugFlagSchema.safeParse(f)) : null;
221437
+ if (parsed !== null && parsed.every((p) => p.success)) body["debugSocketLogs"] = parsed.flatMap((p) => p.success ? [p.data] : []);
221438
+ else unusable.push("debugSocketLogs");
221439
+ }
221440
+ const unknown = Object.keys(values).filter((key) => !known.has(key));
221441
+ if (unknown.length > 0) log.warn(`integration '${integration.id}': legacy settings nothing reads any more are dropped: ${unknown.join(", ")}`);
221442
+ if (unusable.length > 0) log.warn(`integration '${integration.id}': legacy settings with an unusable value are left out: ${unusable.join(", ")}`);
221443
+ return reolinkConnectionsEntryKind.schema.parse(body);
221444
+ }
221445
+ var REOLINK_ROWS_TO_RECORD_MIGRATION = {
221446
+ meta: {
221447
+ id: REOLINK_ROWS_TO_RECORD_ID,
221448
+ owner: "provider-reolink",
221449
+ level: "addon-state",
221450
+ seq: 4,
221451
+ dependsOn: [],
221452
+ store: "settings",
221453
+ scope: "cluster",
221454
+ onFailure: "block-owner",
221455
+ bodyKind: "procedure",
221456
+ description: "Move every Reolink integration (the default one and each NVR / Home Hub) into its own connection record keyed by its registry id, the password as a keychain secret, then clear its legacy integration settings rows",
221457
+ fixtureMark: "a legacy setting key the migration does not know"
221458
+ },
221459
+ procedure: (ports) => moveIntegrationRowsToRecords(ports, {
221460
+ addonId: "provider-reolink",
221461
+ secretPaths: reolinkConnectionsEntryKind.declaration.secretPaths,
221462
+ bodyFrom: reolinkBodyFromRows
221463
+ })
221464
+ };
219752
221465
  /** The `provider-reolink` catalogue (D987): offered to the engine when the addon initialises. */
219753
221466
  var REOLINK_MIGRATIONS = [{
219754
221467
  meta: {
@@ -219781,6 +221494,18 @@ var REOLINK_MIGRATIONS = [{
219781
221494
  },
219782
221495
  plan: planDropWebhookIntakeSettings
219783
221496
  }];
221497
+ /**
221498
+ * Every persisted camera that names no integration is claimed to the addon's default one. The
221499
+ * integration a hub migration created is never the default, so it is not eligible. The integration
221500
+ * settings themselves stay where they are until this addon's own wave moves them.
221501
+ */
221502
+ var REOLINK_CLAIM_MIGRATION = claimDevicesMigration({
221503
+ addonId: "provider-reolink",
221504
+ date: "2026-11-04",
221505
+ seq: 3,
221506
+ dependsOn: [],
221507
+ eligible: (integration) => integration.info[MIGRATED_FROM_HUB_KEY] === void 0
221508
+ });
219784
221509
  //#endregion
219785
221510
  //#region src/reolink-adoption-provider.ts
219786
221511
  var DEVICES_FILTER = {
@@ -219946,113 +221671,6 @@ function buildReolinkAdoptionProvider(deps) {
219946
221671
  };
219947
221672
  }
219948
221673
  //#endregion
219949
- //#region src/reolink-discovery-map.ts
219950
- /**
219951
- * Flatten a host (IP / hostname) into a flat row-key slug — shared by `generateStableId` and the
219952
- * discovery candidate mapping so a host-keyed camera is detected as already onboarded on re-scan.
219953
- */
219954
- function slugifyReolinkHost(host) {
219955
- return host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
219956
- }
219957
- /**
219958
- * Decide whether a discovered host is a device this provider already owns.
219959
- *
219960
- * Matching is by UID first (stable across a DHCP lease change) and by host second. A UID match
219961
- * wins even when the host differs, because the address is the thing that moves.
219962
- */
219963
- function findOnboardedMatch(discovered, onboarded) {
219964
- if (discovered.uid !== void 0 && discovered.uid !== "") {
219965
- const byUid = onboarded.find((o) => o.uid === discovered.uid);
219966
- if (byUid !== void 0) return byUid;
219967
- }
219968
- return onboarded.find((o) => o.host !== void 0 && o.host === discovered.host);
219969
- }
219970
- /** Baichuan control port of an NVR / Home Hub (the scan only ever reports the HTTP/ONVIF one). */
219971
- var HUB_BAICHUAN_PORT = 9e3;
219972
- /**
219973
- * `true` for a model string that names an NVR or a Home Hub: a device that fronts channels
219974
- * rather than being one camera. Such a device is offered as an INTEGRATION to set up (D985), never
219975
- * as a camera to add.
219976
- */
219977
- function looksLikeReolinkHub(model) {
219978
- if (model === void 0) return false;
219979
- return /\b(nvr|rln\d*|home\s*hub|hub)\b/i.test(model);
219980
- }
219981
- /**
219982
- * The stableId of an NVR / Home Hub candidate. UID first (it survives a DHCP lease change), host
219983
- * second. The boot migration derives the SAME id for an old Hub device, so the integration it
219984
- * creates hides this candidate through `originStableId`.
219985
- */
219986
- function reolinkHubStableId(identity) {
219987
- const uid = identity.uid?.trim() ?? "";
219988
- if (uid !== "") return `reolink-hub-uid-${slugifyReolinkHost(uid)}`;
219989
- return `reolink-hub-host-${slugifyReolinkHost(identity.host)}`;
219990
- }
219991
- function isKnownHub(d, known) {
219992
- return known.some((k) => k.uid !== void 0 && k.uid !== "" && k.uid === d.uid || k.host !== void 0 && k.host !== "" && k.host === d.host);
219993
- }
219994
- /**
219995
- * Map discovered Reolink hosts to adoption {@link DiscoveryCandidate}s, de-duplicated by host (the
219996
- * same camera can answer on more than one discovery method — UDP broadcast, ONVIF, HTTP scan). The
219997
- * first responder for a host wins. Pure: no I/O.
219998
- *
219999
- * **The scan never supplies `port`.** `port` in the Reolink config schema is the BAICHUAN port
220000
- * (default 9000). Discovery reports `httpPort`, the HTTP/ONVIF listener — on a Reolink Home Hub
220001
- * that is 8000, while Baichuan lives on `mediaPort` 9000. Writing the discovered value into `port`
220002
- * pointed every Baichuan login at the ONVIF listener, which accepts the TCP connection and then
220003
- * closes it: `Baichuan socket closed`, 316 times in 12 hours, taking the hub and all three of its
220004
- * child cameras offline. The default is correct; a non-default Baichuan port is an operator edit,
220005
- * never a scan result.
220006
- *
220007
- * The authoritative stableId is `mac-<mac>` (learned during autodetect at adopt time), which discovery
220008
- * can't produce — so candidates carry `alreadyOnboarded` rather than relying on a stableId match,
220009
- * which never fires for a MAC-keyed device. Re-adding an owned device is how the port got clobbered
220010
- * in the first place, so a scan says so instead of offering it again.
220011
- */
220012
- function mapReolinkDiscoveryToCandidates(devices, credentials, onboarded = [], hubIntegrations = []) {
220013
- const username = credentials.username?.trim() ?? "";
220014
- const password = credentials.password ?? "";
220015
- const byHost = /* @__PURE__ */ new Map();
220016
- for (const d of devices) if (!byHost.has(d.host)) byHost.set(d.host, d);
220017
- return [...byHost.values()].flatMap((d) => {
220018
- const displayName = d.name ?? d.model ?? d.host;
220019
- if (looksLikeReolinkHub(d.model)) {
220020
- if (isKnownHub(d, hubIntegrations)) return [];
220021
- return [{
220022
- stableId: reolinkHubStableId(d),
220023
- type: DeviceType.Gateway,
220024
- suggestedName: displayName,
220025
- prefilledConfig: {
220026
- host: d.host,
220027
- port: HUB_BAICHUAN_PORT,
220028
- ...d.uid ? { uid: d.uid } : {},
220029
- ...username ? { username } : {}
220030
- },
220031
- setupIntegration: true
220032
- }];
220033
- }
220034
- const match = findOnboardedMatch(d, onboarded);
220035
- return [{
220036
- stableId: `host-${slugifyReolinkHost(d.host)}`,
220037
- type: DeviceType.Camera,
220038
- suggestedName: displayName,
220039
- prefilledConfig: {
220040
- name: displayName,
220041
- host: d.host,
220042
- transport: "auto",
220043
- ...d.uid ? { uid: d.uid } : {},
220044
- ...username ? { username } : {},
220045
- ...password ? { password } : {}
220046
- },
220047
- ...match !== void 0 ? {
220048
- alreadyOnboarded: true,
220049
- onboardedDeviceId: match.deviceId,
220050
- onboardedName: match.name
220051
- } : {}
220052
- }];
220053
- });
220054
- }
220055
- //#endregion
220056
221674
  //#region src/reolink-gateway-device.ts
220057
221675
  var reolinkGatewayDeviceSchema = object({
220058
221676
  integrationId: string().min(1).describe("Integration (hub session) the device belongs to"),
@@ -220157,7 +221775,7 @@ var ReolinkHubManager = class {
220157
221775
  const { logger } = this.#deps;
220158
221776
  const wanted = /* @__PURE__ */ new Set();
220159
221777
  for (const integration of integrations) {
220160
- const read = readHubConnection(integration.settings);
221778
+ const { read } = integration;
220161
221779
  if (read.kind === "no-host") continue;
220162
221780
  wanted.add(integration.id);
220163
221781
  if (read.kind !== "ok") {
@@ -220209,158 +221827,6 @@ function createReolinkHubManager(deps, registry) {
220209
221827
  connect: connectReolinkHub
220210
221828
  });
220211
221829
  }
220212
- function positivePort(value) {
220213
- return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
220214
- }
220215
- var CONNECTION_FIELDS = [
220216
- "host",
220217
- "port",
220218
- "username",
220219
- "transport"
220220
- ];
220221
- function str(value) {
220222
- return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
220223
- }
220224
- function migratedChildConfig(child, hub, integrationId) {
220225
- const filled = {};
220226
- for (const key of CONNECTION_FIELDS) {
220227
- const own = child[key];
220228
- const inherited = hub[key];
220229
- if ((own === void 0 || own === null || own === "") && inherited !== void 0) filled[key] = inherited;
220230
- }
220231
- return {
220232
- ...child,
220233
- ...filled,
220234
- integrationId
220235
- };
220236
- }
220237
- async function migrateOneHub(hub, children, integrations, ports) {
220238
- const read = readHubConnection(hub.config);
220239
- if (read.kind !== "ok") throw new Error(`hub connection unusable (${read.kind}) — left in place`);
220240
- const { connection } = read;
220241
- const uid = str(hub.config["uid"]);
220242
- const integrationId = integrations.find((i) => i.addonId === ports.addonId && i.info["migratedFromHubDeviceId"] === hub.id)?.id ?? (await ports.createIntegration({
220243
- addonId: ports.addonId,
220244
- name: hub.name,
220245
- info: { ["migratedFromHubDeviceId"]: hub.id },
220246
- originStableId: reolinkHubStableId({
220247
- ...uid !== void 0 ? { uid } : {},
220248
- host: connection.host
220249
- }),
220250
- settings: {
220251
- host: connection.host,
220252
- port: connection.port,
220253
- rtspPort: positivePort(hub.config["rtspPort"]) ?? connection.rtspPort,
220254
- username: connection.username,
220255
- password: connection.password,
220256
- transport: connection.transport,
220257
- ...uid !== void 0 ? { uid } : {},
220258
- debugGeneral: connection.debugGeneral,
220259
- debugSocketLogs: connection.debugSocketLogs
220260
- },
220261
- secretKeys: ["password"]
220262
- })).id;
220263
- const rewritten = [];
220264
- for (const child of children) {
220265
- const config = migratedChildConfig(child.config, hub.config, integrationId);
220266
- await ports.persistConfig(child.stableId, config);
220267
- await ports.setIntegrationId(child.id, integrationId);
220268
- rewritten.push({
220269
- ...child,
220270
- config,
220271
- parentDeviceId: null
220272
- });
220273
- }
220274
- return {
220275
- rows: rewritten,
220276
- retirement: {
220277
- hubDeviceId: hub.id,
220278
- hubName: hub.name,
220279
- integrationId
220280
- }
220281
- };
220282
- }
220283
- /** Plan and persist the migration of every Hub row in `saved`. Never throws. */
220284
- async function planHubMigration(saved, ports) {
220285
- const hubs = saved.filter((row) => row.type === DeviceType.Hub);
220286
- if (hubs.length === 0) return {
220287
- rows: saved,
220288
- retirements: []
220289
- };
220290
- let integrations;
220291
- try {
220292
- integrations = await ports.listIntegrations();
220293
- } catch (err) {
220294
- ports.logger.warn("Reolink hub migration: integrations unreadable — hubs left in place", { meta: { error: errMsg$3(err) } });
220295
- return {
220296
- rows: saved.filter((row) => row.type !== DeviceType.Hub),
220297
- retirements: []
220298
- };
220299
- }
220300
- const hubIds = new Set(hubs.map((h) => h.id));
220301
- const rewrittenById = /* @__PURE__ */ new Map();
220302
- const retirements = [];
220303
- for (const hub of hubs) {
220304
- const children = saved.filter((row) => row.parentDeviceId === hub.id);
220305
- try {
220306
- const done = await migrateOneHub(hub, children, integrations, ports);
220307
- for (const row of done.rows) rewrittenById.set(row.id, row);
220308
- retirements.push(done.retirement);
220309
- ports.logger.info("Reolink hub migrated to an integration", {
220310
- tags: { deviceId: hub.id },
220311
- meta: {
220312
- integrationId: done.retirement.integrationId,
220313
- children: children.length,
220314
- name: hub.name
220315
- }
220316
- });
220317
- } catch (err) {
220318
- ports.logger.warn("Reolink hub migration failed — the hub device is left in place", {
220319
- tags: { deviceId: hub.id },
220320
- meta: {
220321
- error: errMsg$3(err),
220322
- name: hub.name
220323
- }
220324
- });
220325
- }
220326
- }
220327
- return {
220328
- rows: saved.filter((row) => !hubIds.has(row.id)).map((row) => rewrittenById.get(row.id) ?? row),
220329
- retirements
220330
- };
220331
- }
220332
- /**
220333
- * Retire the hubs whose children all restored. A hub that still has a child in the store is left
220334
- * and named: removing it would cascade the camera away with it.
220335
- */
220336
- async function retireMigratedHubs(retirements, ports) {
220337
- let retired = 0;
220338
- for (const r of retirements) try {
220339
- const remaining = await ports.childIdsOf(r.hubDeviceId);
220340
- if (remaining.length > 0) {
220341
- ports.logger.warn("Reolink hub kept — a child did not restore, retiring it would remove it", {
220342
- tags: { deviceId: r.hubDeviceId },
220343
- meta: {
220344
- remaining: [...remaining],
220345
- integrationId: r.integrationId
220346
- }
220347
- });
220348
- continue;
220349
- }
220350
- await ports.removeDevice(r.hubDeviceId);
220351
- retired++;
220352
- ports.logger.info("Reolink hub device retired", {
220353
- tags: { deviceId: r.hubDeviceId },
220354
- meta: { integrationId: r.integrationId }
220355
- });
220356
- } catch (err) {
220357
- ports.logger.warn("Reolink hub retirement failed — retried next boot", {
220358
- tags: { deviceId: r.hubDeviceId },
220359
- meta: { error: errMsg$3(err) }
220360
- });
220361
- }
220362
- return retired;
220363
- }
220364
221830
  //#endregion
220365
221831
  //#region src/reolink-subnet-scan.ts
220366
221832
  /**
@@ -220677,6 +222143,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220677
222143
  */
220678
222144
  autodetectCache = new AutodetectCache();
220679
222145
  hubManager = null;
222146
+ instances = null;
220680
222147
  /**
220681
222148
  * The camera mailbox bridge (`email-push-bridge.ts`, D999): battery cameras mail motion to a
220682
222149
  * mailbox of the `smtp-inbound` broker, the bridge turns each message into the camera's
@@ -220922,25 +222389,17 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
220922
222389
  }
220923
222390
  /**
220924
222391
  * Make the hub sessions match the live integration list: one per enabled integration with a
220925
- * host. Idempotent; runs at boot, after a migration and on every lifecycle event. When any
220926
- * integration's settings cannot be read the whole pass is skipped — an unreadable list must
222392
+ * host. Idempotent; runs at boot, after a migration, after an instance change and on every
222393
+ * lifecycle event. When any integration's record cannot be read the whole pass is skipped — an unreadable list must
220927
222394
  * never close a healthy session.
220928
222395
  */
220929
222396
  async reconcileIntegrations() {
220930
222397
  const manager = this.hubManager;
220931
- if (manager === null) return;
222398
+ const instances = this.instances;
222399
+ if (manager === null || instances === null) return;
220932
222400
  try {
220933
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
220934
- const withSettings = [];
220935
- for (const integration of mine) {
220936
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
220937
- withSettings.push({
220938
- id: integration.id,
220939
- name: integration.name,
220940
- settings
220941
- });
220942
- }
220943
- await manager.reconcile(withSettings);
222401
+ const withConnection = await readHubConnections(instances, (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled));
222402
+ await manager.reconcile(withConnection);
220944
222403
  } catch (err) {
220945
222404
  this.ctx.logger.warn("Reolink integration reconcile failed — sessions left as they were", { meta: { error: errMsg$3(err) } });
220946
222405
  }
@@ -221113,28 +222572,6 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221113
222572
  globalSettingsSchema() {
221114
222573
  return { sections: [...buildClipRefreshSettingsSchema().sections] };
221115
222574
  }
221116
- /**
221117
- * Per-instance form (D985): an NVR / Home Hub integration shows its connection only; the
221118
- * default integration keeps the full schema (`null`). A create wizard has no id yet and is
221119
- * always setting up a NEW instance — the default one already exists — so it gets the hub form.
221120
- */
221121
- async integrationSettingsSchema(instance) {
221122
- if (instance.integrationId === null) return buildHubInstanceSchema();
221123
- try {
221124
- const [integration, settings] = await Promise.all([this.ctx.api.integrations.get.query({ id: instance.integrationId }), this.ctx.api.integrations.getSettings.query({ id: instance.integrationId })]);
221125
- return isHubIntegration({
221126
- originStableId: integration.originStableId,
221127
- info: integration.info,
221128
- settings
221129
- }) ? buildHubInstanceSchema() : null;
221130
- } catch (err) {
221131
- this.ctx.logger.warn("Reolink integration form: instance lookup failed — full schema shown", { meta: {
221132
- integrationId: instance.integrationId,
221133
- error: errMsg$3(err)
221134
- } });
221135
- return null;
221136
- }
221137
- }
221138
222575
  async onInitialize() {
221139
222576
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
221140
222577
  this.logChannels = createLogChannelsProvider(this.ctx.logger.child("log-channels"));
@@ -221179,7 +222616,42 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221179
222616
  this.wireClipCatalogRefreshers();
221180
222617
  await this.serveClipPlanes(this.clipService);
221181
222618
  await this.serveWebhookPlane();
221182
- await this.runMigrations(defineAddonMigrations(REOLINK_MIGRATIONS, this.migrationPorts()));
222619
+ const records = this.ctx.records;
222620
+ if (records === void 0) throw new Error("provider-reolink: ctx.records is required (hub-only addon)");
222621
+ await records.declare(reolinkRecordKinds);
222622
+ const integrationPorts = defaultIntegrationPortsOver({
222623
+ api: defaultIntegrationApiOver(this.ctx.api),
222624
+ records,
222625
+ kind: reolinkConnectionsEntryKind,
222626
+ info: (message) => this.ctx.logger.info(message),
222627
+ warn: (message) => this.ctx.logger.warn(message),
222628
+ warnDevice: (message, deviceId) => this.ctx.logger.warn(message, { tags: { deviceId } })
222629
+ });
222630
+ const registryEvidenceOf = async (id) => {
222631
+ try {
222632
+ const integration = await this.ctx.api.integrations.get.query({ id });
222633
+ return {
222634
+ originStableId: integration.originStableId,
222635
+ info: { ...integration.info }
222636
+ };
222637
+ } catch (err) {
222638
+ this.ctx.logger.warn("Reolink integration form: instance lookup failed — hub form shown", { meta: {
222639
+ integrationId: id,
222640
+ error: errMsg$3(err)
222641
+ } });
222642
+ return null;
222643
+ }
222644
+ };
222645
+ const instances = buildReolinkInstances(records, {
222646
+ onChanged: () => this.reconcileIntegrations(),
222647
+ registryEvidenceOf
222648
+ });
222649
+ this.instances = instances;
222650
+ await this.runMigrations([
222651
+ ...defineAddonMigrations(REOLINK_MIGRATIONS, this.migrationPorts()),
222652
+ ...defineAddonMigrations([REOLINK_ROWS_TO_RECORD_MIGRATION], integrationPorts),
222653
+ ...defineAddonMigrations([REOLINK_CLAIM_MIGRATION], integrationPorts)
222654
+ ]);
221183
222655
  this.wireEmailBridge();
221184
222656
  const wireSleepPushResolvers = (event, via) => {
221185
222657
  const data = event.data;
@@ -221218,7 +222690,11 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
221218
222690
  capability: deviceRemovalHookCapability,
221219
222691
  provider: createEmailPushRemovalHook({ resync: () => this.ensureEmailBridge().sync() })
221220
222692
  });
221221
- return regs;
222693
+ regs.push({
222694
+ capability: integrationInstancesCapability,
222695
+ provider: reolinkInstancesProvider(instances, { registryEvidenceOf })
222696
+ });
222697
+ return { providers: regs };
221222
222698
  }
221223
222699
  /** What the catalogue's migration reads and writes (D987). */
221224
222700
  migrationPorts() {