@camstack/addon-pipeline-orchestrator 1.2.324 → 1.2.326

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5418,7 +5418,7 @@ var ZodIssueCode = {
5418
5418
  var ZodFirstPartyTypeKind;
5419
5419
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5420
5420
  //#endregion
5421
- //#region ../types/dist/sleep-DAX0-2v9.mjs
5421
+ //#region ../types/dist/sleep-DRqxtc0C.mjs
5422
5422
  /**
5423
5423
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5424
5424
  * window to float samples (D455).
@@ -8758,6 +8758,10 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
8758
8758
  kind: "mutation",
8759
8759
  auth: "admin"
8760
8760
  }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
8761
+ object({
8762
+ code: string().regex(/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/),
8763
+ params: record(string(), union([string(), number()])).optional()
8764
+ });
8761
8765
  /**
8762
8766
  * Promise-based timer helpers — used everywhere the codebase needs to
8763
8767
  * wait, back off, or schedule a retry. Before these helpers landed, each
@@ -8783,7 +8787,7 @@ function sleep(ms) {
8783
8787
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
8784
8788
  }
8785
8789
  //#endregion
8786
- //#region ../types/dist/err-msg-DX6i_MY4.mjs
8790
+ //#region ../types/dist/err-msg-DLerKiVO.mjs
8787
8791
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
8788
8792
  DeviceType["Camera"] = "camera";
8789
8793
  DeviceType["Hub"] = "hub";
@@ -9033,6 +9037,22 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
9033
9037
  DeviceRole["Nightvision"] = "nightvision";
9034
9038
  DeviceRole["PrivacyMask"] = "privacy-mask";
9035
9039
  DeviceRole["Doorbell"] = "doorbell";
9040
+ /** Interior blind / shade / roller blind. HA `device_class: blind|shade`. */
9041
+ DeviceRole["CoverBlind"] = "cover-blind";
9042
+ /** Exterior roller shutter. HA `device_class: shutter`; Homematic BROLL. */
9043
+ DeviceRole["CoverShutter"] = "cover-shutter";
9044
+ /** Exterior awning. HA `device_class: awning`. */
9045
+ DeviceRole["CoverAwning"] = "cover-awning";
9046
+ /** Curtain / drape. HA `device_class: curtain`. */
9047
+ DeviceRole["CoverCurtain"] = "cover-curtain";
9048
+ /** Garage door. HA `device_class: garage`. A security actuator: exports gate it. */
9049
+ DeviceRole["CoverGarage"] = "cover-garage";
9050
+ /** Gate. HA `device_class: gate`. A security actuator: exports gate it. */
9051
+ DeviceRole["CoverGate"] = "cover-gate";
9052
+ /** Door. HA `device_class: door`. A security actuator: exports gate it. */
9053
+ DeviceRole["CoverDoor"] = "cover-door";
9054
+ /** Window. HA `device_class: window`. */
9055
+ DeviceRole["CoverWindow"] = "cover-window";
9036
9056
  /** Virtual HA toggle (input_boolean.*) — distinguishable from a
9037
9057
  * real Switch device for UI rendering / export adapters. */
9038
9058
  DeviceRole["BinaryHelper"] = "binary-helper";
@@ -9113,7 +9133,7 @@ function errMsg$2(err) {
9113
9133
  return String(err);
9114
9134
  }
9115
9135
  //#endregion
9116
- //#region ../types/dist/composition-C-WpVYTX.mjs
9136
+ //#region ../types/dist/composition-EQ9Ok0Ee.mjs
9117
9137
  /**
9118
9138
  * The ffmpeg build every CamStack node downloads, per platform and arch.
9119
9139
  *
@@ -12778,11 +12798,10 @@ var ClipModelMetaSchema = object({
12778
12798
  /**
12779
12799
  * The `vector-store` index this model's vectors live in. ONE feature space
12780
12800
  * per index: two models share an index only when their vectors are ranked
12781
- * against each other, which for CLIP means never across families. MobileCLIP
12782
- * S1 and S2 share `object-clip` for history (separated by the row's
12783
- * `modelId`); any other model gets its own.
12801
+ * against each other, which for CLIP means never across families. Every
12802
+ * model gets its own.
12784
12803
  */
12785
- vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
12804
+ vectorIndex: string().regex(/^object-clip-[a-z0-9][a-z0-9-]*$/),
12786
12805
  /**
12787
12806
  * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
12788
12807
  * must be unique per tokenizer: every model's siblings land in ONE flat
@@ -13251,7 +13270,8 @@ var ConvertResultSchema = object({
13251
13270
  })).readonly()
13252
13271
  });
13253
13272
  _enum(["new-session", "step-up"]);
13254
- _enum(["en", "it"]);
13273
+ /** The languages the hub's app landing pages are written in (D858). */
13274
+ var AppPageLangSchema = _enum(["en", "it"]);
13255
13275
  /** How a native app starts one login method (D858), as `auth.listLoginMethods` reports it. */
13256
13276
  var AppLoginPathSchema = discriminatedUnion("kind", [object({ kind: literal("native") }), object({
13257
13277
  kind: literal("browser"),
@@ -13748,7 +13768,9 @@ var ConfigTabDeclarationSchema = object({
13748
13768
  var ConfigSectionWithValuesSchema = object({
13749
13769
  id: string(),
13750
13770
  title: string(),
13771
+ titleKey: string().optional(),
13751
13772
  description: string().optional(),
13773
+ descriptionKey: string().optional(),
13752
13774
  style: _enum(["card", "accordion"]).optional(),
13753
13775
  defaultCollapsed: boolean().optional(),
13754
13776
  columns: union([
@@ -17031,6 +17053,9 @@ method(object({
17031
17053
  }), method(object({ refs: RefListSchema }), KeychainResolveResultSchema, { auth: "admin" }), method(object({ refs: RefListSchema }), object({ deleted: number().int() }), {
17032
17054
  kind: "mutation",
17033
17055
  auth: "admin"
17056
+ }), method(object({ deviceId: number().int() }), object({ refs: array(string()).readonly() }), {
17057
+ kind: "mutation",
17058
+ auth: "admin"
17034
17059
  }), method(object({}), array(KeychainSecretMetaSchema).readonly(), { auth: "admin" }), method(object({}), KeychainStateSchema, { auth: "admin" });
17035
17060
  /**
17036
17061
  * device-history — the ONE mechanism for numeric history (D850).
@@ -38434,6 +38459,38 @@ var DeviceRegisterPayloadSchema = object({
38434
38459
  * omits it and the keyword backstop applies. */
38435
38460
  secretConfigPaths: array(string()).optional()
38436
38461
  });
38462
+ /** One deleted device, kept by device-manager so an addon that missed the
38463
+ * deletion can still learn of it (D865). Written by the delete path ONLY. */
38464
+ var DeviceTombstoneSchema = object({
38465
+ deviceId: number().int().positive(),
38466
+ /** Epoch ms the device was deleted. */
38467
+ deletedAt: number(),
38468
+ /** The addon that owned the device when it was deleted. */
38469
+ providerAddonId: string()
38470
+ });
38471
+ /** Data held for one device that device-manager no longer knows. */
38472
+ var OrphanDeviceDataSchema = object({
38473
+ deviceId: number().int().positive(),
38474
+ /** Rows/files per store (`<addonId>:<store>`). */
38475
+ stores: record(string(), number().int().nonnegative())
38476
+ });
38477
+ var CleanupDeletedDevicesResultSchema = object({
38478
+ /**
38479
+ * `skipped` — the absence check could not be trusted (a device-list read
38480
+ * failed or the two reads disagreed); nothing was listed or deleted.
38481
+ * `dry-run` — `devices` is what a confirmed run would delete.
38482
+ * `cleaned` — `devices` is what was deleted.
38483
+ */
38484
+ status: _enum([
38485
+ "skipped",
38486
+ "dry-run",
38487
+ "cleaned"
38488
+ ]),
38489
+ reason: string().nullable(),
38490
+ devices: array(OrphanDeviceDataSchema),
38491
+ /** Implementers that could not be asked (their stores are not in `devices`). */
38492
+ unreachable: array(string())
38493
+ });
38437
38494
  var DeviceRemovePayloadSchema = object({ deviceId: number() });
38438
38495
  var DevicePersistConfigPayloadSchema = object({
38439
38496
  deviceId: number(),
@@ -38672,6 +38729,9 @@ method(object({
38672
38729
  }), method(object({ deviceId: number() }), object({ success: literal(true) }), {
38673
38730
  kind: "mutation",
38674
38731
  auth: "admin"
38732
+ }), method(object({ sinceMs: number() }), array(DeviceTombstoneSchema).readonly(), { auth: "admin" }), method(object({ confirm: boolean() }), CleanupDeletedDevicesResultSchema, {
38733
+ kind: "mutation",
38734
+ auth: "admin"
38675
38735
  }), method(object({ integrationId: string() }), object({ removed: number() }), {
38676
38736
  kind: "mutation",
38677
38737
  auth: "admin"
@@ -39134,6 +39194,72 @@ method(object({
39134
39194
  auth: "admin"
39135
39195
  });
39136
39196
  /**
39197
+ * `device-removal-hook` — the reliable way an addon learns a device was DELETED,
39198
+ * so it can delete what it keeps for that device (D865).
39199
+ *
39200
+ * ## Why an event was not enough
39201
+ *
39202
+ * `DeviceUnregistered` is telemetry and may be dropped (D8, D11). An addon that
39203
+ * hung its per-device clean-up on it left durable garbage whenever the event was
39204
+ * lost, the addon was down, or it was restarting at that moment — footage,
39205
+ * analytics rows and settings of device 4483 outlived the device. Loss of this
39206
+ * signal corrupts durable state, so it travels by RPC with a recorded fact
39207
+ * behind it (D8: "if loss would corrupt durable state, use RPC plus a reconcile").
39208
+ *
39209
+ * ## The three pieces
39210
+ *
39211
+ * 1. device-manager records a TOMBSTONE per deleted device
39212
+ * (`deviceManager.listDeletedSince`). The tombstone is written by the delete
39213
+ * path only — a device that is unregistered, offline or whose provider is
39214
+ * restarting is NOT deleted and gets none.
39215
+ * 2. After a delete, device-manager calls `onDeviceRemoved` on EVERY provider of
39216
+ * this collection, one by one, with bounded retries, logged.
39217
+ * 3. An implementer that was down asks `deviceManager.listDeletedSince` at start
39218
+ * (and periodically) and runs its own `onDeviceRemoved` for each tombstone it
39219
+ * may have missed. Reconcile is driven by tombstones, never by "the device is
39220
+ * not in the current list": a failed or partial list read must not destroy data.
39221
+ *
39222
+ * ## Contract for an implementer
39223
+ *
39224
+ * - **Idempotent.** It is called again by a retry, by a reconcile and by the
39225
+ * admin cleanup of data that predates tombstones. A device with nothing left
39226
+ * answers zero counts.
39227
+ * - **One key per store** in `counts`, deleted rows/files per store, so the log
39228
+ * line and the admin dry run can say where the data was.
39229
+ * - **Training datasets are NOT device data** and are never deleted by a hook.
39230
+ * - **Per-device SETTINGS are not hooked.** Configuration lives in the standard
39231
+ * per-device store (`ctx.settings.*DeviceStore`), which device-manager clears
39232
+ * for every addon centrally at delete time. This hook is for device DATA
39233
+ * that is not settings: footage, event media, analytics rows, history.
39234
+ * - A provider that cannot tell is allowed to throw; the caller retries it.
39235
+ *
39236
+ * `listDeviceData` is the read side of the admin "Clean data of deleted devices"
39237
+ * action: which device ids this addon holds data for, and how much per store. It
39238
+ * never deletes. device-manager decides which of those ids are absent from the
39239
+ * fleet, from TWO consistent reads of the device list (D49).
39240
+ */
39241
+ /** What a hook is told about the deletion. */
39242
+ var DeviceRemovedInputSchema = object({
39243
+ deviceId: number().int().positive(),
39244
+ /** Epoch ms the device was deleted (the tombstone's `deletedAt`). */
39245
+ deletedAt: number(),
39246
+ /** The addon that owned the device, as device-manager last knew it. */
39247
+ providerAddonId: string()
39248
+ });
39249
+ /** Rows/files/refs deleted, per store this implementer owns. */
39250
+ var DeviceRemovedResultSchema = object({ counts: record(string(), number().int().nonnegative()) });
39251
+ /** One (store, device) pair an implementer holds data for. */
39252
+ var DeviceDataEntrySchema = object({
39253
+ store: string(),
39254
+ deviceId: number().int().positive(),
39255
+ /** Rows (or files) held for the device in the store. */
39256
+ count: number().int().nonnegative()
39257
+ });
39258
+ method(DeviceRemovedInputSchema, DeviceRemovedResultSchema, {
39259
+ kind: "mutation",
39260
+ auth: "admin"
39261
+ }), method(_void(), array(DeviceDataEntrySchema).readonly(), { auth: "admin" });
39262
+ /**
39137
39263
  * `failure-contribution` — the capability an addon reports its OWN losses
39138
39264
  * through, per camera, with the denominator attached. It stores nothing.
39139
39265
  *
@@ -56603,7 +56729,19 @@ var TlsStatusSchema = object({
56603
56729
  sans: array(string()),
56604
56730
  caCertPem: string().nullable(),
56605
56731
  reissueError: string().nullable(),
56606
- restartRequired: boolean()
56732
+ restartRequired: boolean(),
56733
+ /** An uploaded certificate is served but no CA is kept for it and it is not publicly signed (D873). */
56734
+ caMissing: boolean(),
56735
+ /** Does the hub's own generated CA carry a critical name constraint? `null` while it has none. */
56736
+ localCaConstrained: boolean().nullable(),
56737
+ /** ISO time the generated CA was replaced by a constrained one; devices must re-download it. */
56738
+ caReplacedAt: string().nullable(),
56739
+ /** The uploaded certificate kept on disk, selectable again after "Revert to generated". */
56740
+ storedUpload: object({
56741
+ leafFingerprintSha256: string(),
56742
+ validTo: string(),
56743
+ hasCa: boolean()
56744
+ }).nullable()
56607
56745
  });
56608
56746
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
56609
56747
  /**
@@ -56659,6 +56797,9 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
56659
56797
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
56660
56798
  kind: "mutation",
56661
56799
  auth: "admin"
56800
+ }), method(_void(), TlsStatusSchema, {
56801
+ kind: "mutation",
56802
+ auth: "admin"
56662
56803
  });
56663
56804
  object({
56664
56805
  /** Lifecycle state of the lock. `jammed` means the motor reported
@@ -64702,6 +64843,12 @@ Object.freeze({
64702
64843
  addonId: null,
64703
64844
  access: "create"
64704
64845
  },
64846
+ "deviceManager.cleanupDeletedDevicesData": {
64847
+ capName: "device-manager",
64848
+ capScope: "system",
64849
+ addonId: null,
64850
+ access: "create"
64851
+ },
64705
64852
  "deviceManager.createDevice": {
64706
64853
  capName: "device-manager",
64707
64854
  capScope: "system",
@@ -64882,6 +65029,12 @@ Object.freeze({
64882
65029
  addonId: null,
64883
65030
  access: "view"
64884
65031
  },
65032
+ "deviceManager.listDeletedSince": {
65033
+ capName: "device-manager",
65034
+ capScope: "system",
65035
+ addonId: null,
65036
+ access: "view"
65037
+ },
64885
65038
  "deviceManager.listDeviceIdsByCaps": {
64886
65039
  capName: "device-manager",
64887
65040
  capScope: "system",
@@ -65290,6 +65443,18 @@ Object.freeze({
65290
65443
  addonId: null,
65291
65444
  access: "create"
65292
65445
  },
65446
+ "deviceRemovalHook.listDeviceData": {
65447
+ capName: "device-removal-hook",
65448
+ capScope: "system",
65449
+ addonId: null,
65450
+ access: "view"
65451
+ },
65452
+ "deviceRemovalHook.onDeviceRemoved": {
65453
+ capName: "device-removal-hook",
65454
+ capScope: "system",
65455
+ addonId: null,
65456
+ access: "create"
65457
+ },
65293
65458
  "deviceState.claimFields": {
65294
65459
  capName: "device-state",
65295
65460
  capScope: "system",
@@ -65722,6 +65887,12 @@ Object.freeze({
65722
65887
  addonId: null,
65723
65888
  access: "delete"
65724
65889
  },
65890
+ "keychain.deleteDeviceScope": {
65891
+ capName: "keychain",
65892
+ capScope: "system",
65893
+ addonId: null,
65894
+ access: "delete"
65895
+ },
65725
65896
  "keychain.getState": {
65726
65897
  capName: "keychain",
65727
65898
  capScope: "system",
@@ -66160,6 +66331,12 @@ Object.freeze({
66160
66331
  addonId: null,
66161
66332
  access: "create"
66162
66333
  },
66334
+ "localNetwork.useStoredUploadedCertificate": {
66335
+ capName: "local-network",
66336
+ capScope: "system",
66337
+ addonId: null,
66338
+ access: "create"
66339
+ },
66163
66340
  "lockControl.getStatus": {
66164
66341
  capName: "lock-control",
66165
66342
  capScope: "device",
@@ -71762,6 +71939,11 @@ Object.freeze({
71762
71939
  form: "single",
71763
71940
  optional: false
71764
71941
  }],
71942
+ "deviceRemovalHook.onDeviceRemoved": [{
71943
+ name: "deviceId",
71944
+ form: "single",
71945
+ optional: false
71946
+ }],
71765
71947
  "deviceState.claimFields": [{
71766
71948
  name: "deviceId",
71767
71949
  form: "single",
@@ -71951,6 +72133,11 @@ Object.freeze({
71951
72133
  form: "single",
71952
72134
  optional: false
71953
72135
  }],
72136
+ "keychain.deleteDeviceScope": [{
72137
+ name: "deviceId",
72138
+ form: "single",
72139
+ optional: false
72140
+ }],
71954
72141
  "lawnMowerControl.dock": [{
71955
72142
  name: "deviceId",
71956
72143
  form: "single",
@@ -74299,6 +74486,8 @@ new Set([
74299
74486
  "audio-tools",
74300
74487
  "audio-silence"
74301
74488
  ]);
74489
+ AppPageLangSchema.options;
74490
+ record(string().min(1), string());
74302
74491
  /**
74303
74492
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
74304
74493
  *
@@ -74577,7 +74766,11 @@ var REMOVED_MODEL_IDS = {
74577
74766
  "face-embedding": ["arcface-r100", "inception-resnet-v1"],
74578
74767
  "plate-detection": ["yolov8n-plate"],
74579
74768
  "plate-ocr": ["vgg-english-g2"],
74580
- "clip-embedding": ["mobileclip-s1", "mobileclip-s2"],
74769
+ "clip-embedding": [
74770
+ "mobileclip-s0",
74771
+ "mobileclip-s1",
74772
+ "mobileclip-s2"
74773
+ ],
74581
74774
  "package-detection": ["yolov8n-package"],
74582
74775
  "bird-classifier": ["bird-classifier-aiy-edgetpu", "bird-nabirds-404"],
74583
74776
  "animal-classifier": ["animals-10"],
@@ -76000,6 +76193,25 @@ var DETAIL_CROP_PADDING_FIELD = {
76000
76193
  default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
76001
76194
  };
76002
76195
  /**
76196
+ * The cadence of the automatic whole-archive face rescore (D866).
76197
+ *
76198
+ * One cluster-wide knob in `pipeline-orchestrator`'s GLOBAL settings, read by
76199
+ * `pipeline-analytics` over the hub-routed `addon-settings` cap on every tick.
76200
+ * It lives here for the same reason the detail-crop convention does: the
76201
+ * writer (orchestrator) and the reader (analytics) are different addons, they
76202
+ * never import each other, and a key copied by hand is how a setting silently
76203
+ * stops arriving.
76204
+ */
76205
+ var FACE_RESCORE_SECTION_ID = "face-rescore";
76206
+ var FACE_RESCORE_INTERVAL_KEY = "faceRescoreIntervalMinutes";
76207
+ /** Slider bounds. `0` is "disabled"; anything in 1..9 is raised to `min`. */
76208
+ var FACE_RESCORE_INTERVAL_FIELD = {
76209
+ min: 0,
76210
+ max: 1440,
76211
+ step: 5,
76212
+ default: 60
76213
+ };
76214
+ /**
76003
76215
  * THE native-frame **lease** knobs — hold depth, RAM budget, demand window and
76004
76216
  * subject-tile budget for the decode worker's native-resolution retention.
76005
76217
  *
@@ -76455,6 +76667,22 @@ var DurableLedger = class DurableLedger {
76455
76667
  }
76456
76668
  }
76457
76669
  /**
76670
+ * Drop every MIRRORED row about `deviceId` (per the spec's `deviceIdOf`),
76671
+ * leaving the durable rows to the caller's `deleteWhere` (D871). Returns how
76672
+ * many mirror entries went; a spec without `deviceIdOf` evicts nothing.
76673
+ */
76674
+ evictDevice(deviceId) {
76675
+ const of = this.spec.deviceIdOf;
76676
+ if (of === void 0) return 0;
76677
+ let evicted = 0;
76678
+ for (const [key, row] of [...this.mirror.entries()]) {
76679
+ if (of(row) !== deviceId) continue;
76680
+ this.mirror.delete(key);
76681
+ evicted += 1;
76682
+ }
76683
+ return evicted;
76684
+ }
76685
+ /**
76458
76686
  * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
76459
76687
  *
76460
76688
  * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
@@ -83763,67 +83991,20 @@ function buildRunnerConfig(base, overrides) {
83763
83991
  preRollInferenceFrames: config.preRollInferenceFrames
83764
83992
  };
83765
83993
  }
83766
- var DEVICE_FEATURES_SPEC = {
83767
- collection: "pipeline-orchestrator:device-features",
83768
- columns: [
83769
- {
83770
- name: "deviceId",
83771
- type: "TEXT",
83772
- primaryKey: true,
83773
- notNull: true
83774
- },
83775
- (
83776
- /** The feature name list, verbatim. */
83777
- {
83778
- name: "features",
83779
- type: "JSON",
83780
- notNull: true
83781
- }),
83782
- {
83783
- name: "updatedAt",
83784
- type: "INTEGER",
83785
- notNull: true
83786
- }
83787
- ],
83788
- writeMode: "write-behind",
83789
- keyOf: (row) => String(row.deviceId),
83790
- toValue: (row) => ({
83791
- features: [...row.features],
83792
- updatedAt: row.updatedAt
83793
- }),
83794
- fromRecord: (key, data) => {
83795
- const deviceId = Number(key);
83796
- const raw = data["features"];
83797
- if (!Number.isFinite(deviceId) || !Array.isArray(raw)) return null;
83798
- const features = raw.filter((f) => typeof f === "string");
83799
- if (features.length === 0) return null;
83800
- const updatedAt = Number(data["updatedAt"]);
83801
- return {
83802
- deviceId,
83803
- features,
83804
- updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
83805
- restored: true
83806
- };
83807
- },
83808
- deviceIdOf: (row) => row.deviceId
83809
- };
83994
+ //#endregion
83995
+ //#region src/device-features-mirror.ts
83996
+ /** The per-device settings key a device's mirrored features live under. */
83997
+ var DEVICE_FEATURES_KEY = "deviceFeatures";
83810
83998
  var DeviceFeaturesMirror = class {
83811
83999
  /** Process-local fallback, used only when no store was supplied. */
83812
84000
  local = /* @__PURE__ */ new Map();
83813
- durable;
84001
+ settings;
83814
84002
  logger;
83815
84003
  now;
83816
84004
  constructor(deps) {
83817
84005
  this.logger = deps.logger;
83818
84006
  this.now = deps.now ?? (() => Date.now());
83819
- this.durable = deps.store === void 0 ? null : new DurableLedger({
83820
- spec: DEVICE_FEATURES_SPEC,
83821
- store: deps.store,
83822
- logger: deps.logger
83823
- });
83824
- }
83825
- static declare(store) {
83826
- return DurableLedger.declare(store, DEVICE_FEATURES_SPEC);
84007
+ this.settings = deps.settings ?? null;
83827
84008
  }
83828
84009
  /**
83829
84010
  * Seed the mirror from the last session. Call once at boot, after `declare`
@@ -83831,13 +84012,29 @@ var DeviceFeaturesMirror = class {
83831
84012
  * already there when the first read fails.
83832
84013
  */
83833
84014
  async hydrate() {
83834
- if (this.durable === null) return 0;
83835
- const rows = await this.durable.load();
83836
- this.logger.info("device-features mirror restored", { meta: { devices: rows.length } });
83837
- return rows.length;
84015
+ const settings = this.settings;
84016
+ if (settings === null) return 0;
84017
+ const list = settings.listDeviceStoreIds;
84018
+ if (list !== void 0) {
84019
+ const ids = await list.call(settings);
84020
+ const batch = settings.readDeviceStoreBatch;
84021
+ const blobs = batch === void 0 ? new Map(await Promise.all(ids.map(async (id) => [id, await settings.readDeviceStore(id)]))) : await batch.call(settings, ids);
84022
+ for (const [deviceId, blob] of blobs) {
84023
+ const row = rowOf(deviceId, blob[DEVICE_FEATURES_KEY]);
84024
+ if (row !== null) this.local.set(deviceId, row);
84025
+ }
84026
+ }
84027
+ this.logger.info("device-features mirror restored", { meta: { devices: this.local.size } });
84028
+ return this.local.size;
83838
84029
  }
83839
84030
  held(deviceId) {
83840
- return this.durable === null ? this.local.get(deviceId) : this.durable.get(String(deviceId));
84031
+ return this.local.get(deviceId);
84032
+ }
84033
+ async persist(deviceId, features, updatedAt) {
84034
+ await this.settings?.writeDeviceStore(deviceId, { [DEVICE_FEATURES_KEY]: {
84035
+ features: [...features],
84036
+ updatedAt
84037
+ } });
83841
84038
  }
83842
84039
  remember(deviceId, features) {
83843
84040
  const row = {
@@ -83846,20 +84043,19 @@ var DeviceFeaturesMirror = class {
83846
84043
  updatedAt: this.now(),
83847
84044
  restored: false
83848
84045
  };
83849
- if (this.durable === null) {
83850
- this.local.set(deviceId, row);
83851
- return;
83852
- }
83853
- const held = this.durable.get(String(deviceId));
84046
+ const held = this.local.get(deviceId);
84047
+ this.local.set(deviceId, row);
83854
84048
  if (held !== void 0 && !held.restored && sameFeatures(held.features, features)) return;
83855
- this.durable.put(row);
84049
+ this.persist(deviceId, features, row.updatedAt).catch((err) => {
84050
+ this.logger.warn("device-features mirror not persisted", {
84051
+ tags: { deviceId },
84052
+ meta: { error: err instanceof Error ? err.message : String(err) }
84053
+ });
84054
+ });
83856
84055
  }
83857
84056
  drop(deviceId) {
83858
- if (this.durable === null) {
83859
- this.local.delete(deviceId);
83860
- return;
83861
- }
83862
- this.durable.forget(String(deviceId));
84057
+ this.local.delete(deviceId);
84058
+ this.settings?.writeDeviceStore(deviceId, { [DEVICE_FEATURES_KEY]: void 0 });
83863
84059
  }
83864
84060
  /**
83865
84061
  * Resolve a device's features, preferring a fresh read but never letting a
@@ -83908,7 +84104,7 @@ var DeviceFeaturesMirror = class {
83908
84104
  this.drop(deviceId);
83909
84105
  return [];
83910
84106
  }
83911
- /** Drop a device's mirror — call when the device is removed. */
84107
+ /** Drop a device's mirror. (A removed device's settings are cleared centrally, D865.) */
83912
84108
  forget(deviceId) {
83913
84109
  this.drop(deviceId);
83914
84110
  }
@@ -83919,6 +84115,20 @@ function sameFeatures(a, b) {
83919
84115
  const held = new Set(a);
83920
84116
  return b.every((f) => held.has(f));
83921
84117
  }
84118
+ function rowOf(deviceId, raw) {
84119
+ if (typeof raw !== "object" || raw === null) return null;
84120
+ const features = Reflect.get(raw, "features");
84121
+ if (!Array.isArray(features)) return null;
84122
+ const kept = features.filter((f) => typeof f === "string");
84123
+ if (kept.length === 0) return null;
84124
+ const updatedAt = Number(Reflect.get(raw, "updatedAt"));
84125
+ return {
84126
+ deviceId,
84127
+ features: kept,
84128
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
84129
+ restored: true
84130
+ };
84131
+ }
83922
84132
  //#endregion
83923
84133
  //#region src/onboard-forwarding-reconcile.ts
83924
84134
  /** The `detectionSources` member that means "this camera's own firmware". */
@@ -84077,9 +84287,10 @@ var DetectionWiringController = class {
84077
84287
  featuresMirror;
84078
84288
  constructor(deps) {
84079
84289
  this.deps = deps;
84290
+ const settings = deps.persistFeatures === true ? deps.ctx().settings : void 0;
84080
84291
  this.featuresMirror = new DeviceFeaturesMirror({
84081
84292
  logger: deps.logger,
84082
- ...deps.featuresStore !== void 0 ? { store: deps.featuresStore } : {}
84293
+ ...settings !== void 0 ? { settings } : {}
84083
84294
  });
84084
84295
  }
84085
84296
  /**
@@ -84088,8 +84299,7 @@ var DetectionWiringController = class {
84088
84299
  * that would have needed it.
84089
84300
  */
84090
84301
  async hydrateFeaturesMirror() {
84091
- if (this.deps.featuresStore === void 0) return;
84092
- await DeviceFeaturesMirror.declare(this.deps.featuresStore);
84302
+ if (this.deps.persistFeatures !== true) return;
84093
84303
  await this.featuresMirror.hydrate();
84094
84304
  }
84095
84305
  /** `activeDetections.get(deviceId)`. */
@@ -87470,25 +87680,33 @@ var NodeStressController = class NodeStressController {
87470
87680
  //#region src/durable/lts-aggregator.ts
87471
87681
  /** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
87472
87682
  var LTS_BUCKET_MS = 5 * 6e4;
87683
+ /** Buckets written per transaction — bounds one statement, not the flush. */
87684
+ var FLUSH_BATCH = 200;
87473
87685
  /** How often the row cap is enforced. Rarely: it is a bound, not a deadline. */
87474
87686
  var CAP_SWEEP_INTERVAL_MS = 6 * 36e5;
87687
+ /**
87688
+ * The row's identity IS its four key columns (D871): a composite
87689
+ * `primaryKey` + `WITHOUT ROWID`, so the table is its own index and there is no
87690
+ * TEXT `id` repeating what the row already says. The old layout kept
87691
+ * `<subject>|<series>|<scope>|<bucketStart>` as a TEXT primary key beside the
87692
+ * four columns — 85 MB of rows plus a 44 MB autoindex for 1.07 M rows. A
87693
+ * composite-key collection is written with `upsertMany` (a bucket flushed twice
87694
+ * replaces itself) and read with `query` / `count` / `deleteWhere`; the calls
87695
+ * that address one row by id are refused by the engine.
87696
+ */
87697
+ var LTS_KEY_COLUMNS = [
87698
+ "subject",
87699
+ "series",
87700
+ "scope",
87701
+ "bucketStart"
87702
+ ];
87475
87703
  var LTS_COLUMNS = [
87476
- (
87477
- /** `<subject>|<series>|<scope>|<bucketStart>` — deterministic, so a bucket
87478
- * flushed twice replaces itself rather than doubling. */
87479
- {
87480
- name: "id",
87481
- type: "TEXT",
87482
- primaryKey: true,
87483
- notNull: true
87484
- }),
87485
87704
  (
87486
87705
  /** The camera id, or the node id. One column, because every query is
87487
87706
  * "this thing over time" and the thing is one or the other. */
87488
87707
  {
87489
87708
  name: "subject",
87490
- type: "TEXT",
87491
- notNull: true
87709
+ type: "TEXT"
87492
87710
  }),
87493
87711
  (
87494
87712
  /** Numeric mirror of `subject` for camera rows, so a per-camera question is
@@ -87500,22 +87718,19 @@ var LTS_COLUMNS = [
87500
87718
  }),
87501
87719
  {
87502
87720
  name: "series",
87503
- type: "TEXT",
87504
- notNull: true
87721
+ type: "TEXT"
87505
87722
  },
87506
87723
  (
87507
87724
  /** Sub-scope within a series: a zoneId for occupancy, `''` otherwise. */
87508
87725
  {
87509
87726
  name: "scope",
87510
- type: "TEXT",
87511
- notNull: true
87727
+ type: "TEXT"
87512
87728
  }),
87513
87729
  (
87514
87730
  /** Bucket start, wall-clock aligned to {@link LTS_BUCKET_MS}. */
87515
87731
  {
87516
87732
  name: "bucketStart",
87517
- type: "INTEGER",
87518
- notNull: true
87733
+ type: "INTEGER"
87519
87734
  }),
87520
87735
  {
87521
87736
  name: "samples",
@@ -87538,12 +87753,15 @@ var LTS_COLUMNS = [
87538
87753
  notNull: true
87539
87754
  }
87540
87755
  ];
87756
+ /**
87757
+ * The primary key serves every per-subject read (`subject, series, scope` then a
87758
+ * `bucketStart` range, already in bucket order); this index serves the read that
87759
+ * names a series without a subject. The old `deviceId` index went with the TEXT
87760
+ * id: nothing queries a series by the numeric mirror.
87761
+ */
87541
87762
  var LTS_INDEXES = [{
87542
87763
  name: "idx_lts_series_bucket",
87543
87764
  columns: ["series", "bucketStart"]
87544
- }, {
87545
- name: "idx_lts_device",
87546
- columns: ["deviceId"]
87547
87765
  }];
87548
87766
  function ltsBucketStart(at, bucketMs = LTS_BUCKET_MS) {
87549
87767
  return Math.floor(at / bucketMs) * bucketMs;
@@ -87572,6 +87790,8 @@ var LtsAggregator = class {
87572
87790
  return store.declareCollection.mutate({
87573
87791
  collection,
87574
87792
  columns: [...LTS_COLUMNS],
87793
+ primaryKey: [...LTS_KEY_COLUMNS],
87794
+ withoutRowid: true,
87575
87795
  indexes: [...LTS_INDEXES]
87576
87796
  });
87577
87797
  }
@@ -87631,43 +87851,60 @@ var LtsAggregator = class {
87631
87851
  return 0;
87632
87852
  }
87633
87853
  let written = 0;
87634
- for (const bucket of due) {
87635
- const key = rowId(bucket.subject, bucket.series, bucket.scope, bucket.bucketStart);
87636
- try {
87637
- await this.store.set.mutate({
87638
- collection: this.collection,
87639
- key,
87640
- value: {
87641
- subject: bucket.subject,
87642
- ...bucket.deviceId !== void 0 ? { deviceId: bucket.deviceId } : {},
87643
- series: bucket.series,
87644
- scope: bucket.scope,
87645
- bucketStart: bucket.bucketStart,
87646
- samples: bucket.samples,
87647
- sum: bucket.sum,
87648
- min: bucket.min,
87649
- max: bucket.max
87650
- }
87651
- });
87652
- written += 1;
87653
- this.groups.add(groupKey(bucket.subject, bucket.series, bucket.scope));
87654
- } catch (err) {
87655
- this.logger.warn("lts bucket write failed — this interval will be missing", {
87656
- ...bucket.deviceId !== void 0 ? { tags: { deviceId: bucket.deviceId } } : {},
87657
- meta: {
87658
- collection: this.collection,
87659
- series: bucket.series,
87660
- subject: bucket.subject,
87661
- bucketStart: bucket.bucketStart,
87662
- error: String(err)
87663
- }
87664
- });
87665
- }
87666
- this.open.delete(key);
87854
+ for (const batch of chunk(due, FLUSH_BATCH)) {
87855
+ written += await this.writeBatch(batch);
87856
+ for (const bucket of batch) this.open.delete(rowId(bucket.subject, bucket.series, bucket.scope, bucket.bucketStart));
87667
87857
  }
87668
87858
  await this.maybeSweepCap(now);
87669
87859
  return written;
87670
87860
  }
87861
+ /**
87862
+ * Upsert one batch in a single transaction. If the batch is refused as a
87863
+ * whole (all-or-nothing), each bucket is retried alone so one bad row costs
87864
+ * one interval, not forty. Returns the number of rows written.
87865
+ */
87866
+ async writeBatch(batch) {
87867
+ try {
87868
+ await this.upsert(batch);
87869
+ for (const bucket of batch) this.groups.add(groupKey(bucket.subject, bucket.series, bucket.scope));
87870
+ return batch.length;
87871
+ } catch (err) {
87872
+ if (batch.length > 1) {
87873
+ let written = 0;
87874
+ for (const bucket of batch) written += await this.writeBatch([bucket]);
87875
+ return written;
87876
+ }
87877
+ const bucket = batch[0];
87878
+ this.logger.warn("lts bucket write failed — this interval will be missing", {
87879
+ ...bucket?.deviceId !== void 0 ? { tags: { deviceId: bucket.deviceId } } : {},
87880
+ meta: {
87881
+ collection: this.collection,
87882
+ series: bucket?.series,
87883
+ subject: bucket?.subject,
87884
+ bucketStart: bucket?.bucketStart,
87885
+ error: String(err)
87886
+ }
87887
+ });
87888
+ return 0;
87889
+ }
87890
+ }
87891
+ async upsert(batch) {
87892
+ await this.store.upsertMany.mutate({
87893
+ collection: this.collection,
87894
+ conflict: [...LTS_KEY_COLUMNS],
87895
+ records: batch.map((bucket) => ({ data: {
87896
+ subject: bucket.subject,
87897
+ deviceId: bucket.deviceId ?? null,
87898
+ series: bucket.series,
87899
+ scope: bucket.scope,
87900
+ bucketStart: bucket.bucketStart,
87901
+ samples: bucket.samples,
87902
+ sum: bucket.sum,
87903
+ min: bucket.min,
87904
+ max: bucket.max
87905
+ } }))
87906
+ });
87907
+ }
87671
87908
  /** Read closed buckets back. The read surface every chart will use. */
87672
87909
  async read(query = {}) {
87673
87910
  const where = {};
@@ -87752,6 +87989,11 @@ var LtsAggregator = class {
87752
87989
  }
87753
87990
  }
87754
87991
  };
87992
+ function chunk(items, size) {
87993
+ const out = [];
87994
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
87995
+ return out;
87996
+ }
87755
87997
  function groupKey(subject, series, scope) {
87756
87998
  return `${subject}${series}${scope}`;
87757
87999
  }
@@ -87810,7 +88052,7 @@ function recordToRow(data) {
87810
88052
  * write="one row per (node, series, 5-min bucket), written ONCE when the bucket closes; a node reporting no samples writes nothing"
87811
88053
  * retention="row CAP per (subject, series, scope) — 105,120 rows ≈ one year of 5-minute buckets. Not an age sweep: an LTS row summarises an interval and has no owner to be orphaned from."
87812
88054
  */
87813
- var NODE_STRESS_LTS_COLLECTION = "pipeline-orchestrator:stats-5m";
88055
+ var NODE_STRESS_LTS_COLLECTION = "pipeline-orchestrator:stats-5m-v2";
87814
88056
  var NodeStressLts = class {
87815
88057
  lts;
87816
88058
  constructor(deps) {
@@ -94387,7 +94629,7 @@ async function buildOrchestratorControllers(deps) {
94387
94629
  reconcile.scheduleReconcile();
94388
94630
  const detectionWiring = new DetectionWiringController({
94389
94631
  ctx: () => deps.ctx(),
94390
- featuresStore: deps.ctx().api.settingsStore,
94632
+ persistFeatures: true,
94391
94633
  ledger,
94392
94634
  placement,
94393
94635
  audio,
@@ -94607,6 +94849,24 @@ function buildGlobalSettingsSections(options) {
94607
94849
  default: DEFAULT_DETAIL_CROP_CONVENTION.square
94608
94850
  }]
94609
94851
  },
94852
+ {
94853
+ id: FACE_RESCORE_SECTION_ID,
94854
+ title: "Face suggestions",
94855
+ tab: "pipeline",
94856
+ description: "How often the people suggestions on unassigned faces are refreshed against the current gallery. The job only runs when the gallery or the set of unassigned faces changed since the last run; otherwise it does nothing.",
94857
+ fields: [{
94858
+ key: FACE_RESCORE_INTERVAL_KEY,
94859
+ type: "slider",
94860
+ label: "Suggestion refresh interval",
94861
+ description: "0 = off (use the Recompute suggestions button). Values from 1 to 9 run every 10 minutes. The first run happens one interval after the addon starts.",
94862
+ min: FACE_RESCORE_INTERVAL_FIELD.min,
94863
+ max: FACE_RESCORE_INTERVAL_FIELD.max,
94864
+ step: FACE_RESCORE_INTERVAL_FIELD.step,
94865
+ default: FACE_RESCORE_INTERVAL_FIELD.default,
94866
+ showValue: true,
94867
+ unit: "min"
94868
+ }]
94869
+ },
94610
94870
  {
94611
94871
  id: NATIVE_LEASE_SECTION_ID,
94612
94872
  title: "Native frame lease",