@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.mjs CHANGED
@@ -5390,7 +5390,7 @@ var ZodIssueCode = {
5390
5390
  var ZodFirstPartyTypeKind;
5391
5391
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5392
5392
  //#endregion
5393
- //#region ../types/dist/sleep-DAX0-2v9.mjs
5393
+ //#region ../types/dist/sleep-DRqxtc0C.mjs
5394
5394
  /**
5395
5395
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5396
5396
  * window to float samples (D455).
@@ -8730,6 +8730,10 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
8730
8730
  kind: "mutation",
8731
8731
  auth: "admin"
8732
8732
  }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
8733
+ object({
8734
+ code: string().regex(/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/),
8735
+ params: record(string(), union([string(), number()])).optional()
8736
+ });
8733
8737
  /**
8734
8738
  * Promise-based timer helpers — used everywhere the codebase needs to
8735
8739
  * wait, back off, or schedule a retry. Before these helpers landed, each
@@ -8755,7 +8759,7 @@ function sleep(ms) {
8755
8759
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
8756
8760
  }
8757
8761
  //#endregion
8758
- //#region ../types/dist/err-msg-DX6i_MY4.mjs
8762
+ //#region ../types/dist/err-msg-DLerKiVO.mjs
8759
8763
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
8760
8764
  DeviceType["Camera"] = "camera";
8761
8765
  DeviceType["Hub"] = "hub";
@@ -9005,6 +9009,22 @@ var DeviceRole = /* @__PURE__ */ function(DeviceRole) {
9005
9009
  DeviceRole["Nightvision"] = "nightvision";
9006
9010
  DeviceRole["PrivacyMask"] = "privacy-mask";
9007
9011
  DeviceRole["Doorbell"] = "doorbell";
9012
+ /** Interior blind / shade / roller blind. HA `device_class: blind|shade`. */
9013
+ DeviceRole["CoverBlind"] = "cover-blind";
9014
+ /** Exterior roller shutter. HA `device_class: shutter`; Homematic BROLL. */
9015
+ DeviceRole["CoverShutter"] = "cover-shutter";
9016
+ /** Exterior awning. HA `device_class: awning`. */
9017
+ DeviceRole["CoverAwning"] = "cover-awning";
9018
+ /** Curtain / drape. HA `device_class: curtain`. */
9019
+ DeviceRole["CoverCurtain"] = "cover-curtain";
9020
+ /** Garage door. HA `device_class: garage`. A security actuator: exports gate it. */
9021
+ DeviceRole["CoverGarage"] = "cover-garage";
9022
+ /** Gate. HA `device_class: gate`. A security actuator: exports gate it. */
9023
+ DeviceRole["CoverGate"] = "cover-gate";
9024
+ /** Door. HA `device_class: door`. A security actuator: exports gate it. */
9025
+ DeviceRole["CoverDoor"] = "cover-door";
9026
+ /** Window. HA `device_class: window`. */
9027
+ DeviceRole["CoverWindow"] = "cover-window";
9008
9028
  /** Virtual HA toggle (input_boolean.*) — distinguishable from a
9009
9029
  * real Switch device for UI rendering / export adapters. */
9010
9030
  DeviceRole["BinaryHelper"] = "binary-helper";
@@ -9085,7 +9105,7 @@ function errMsg$2(err) {
9085
9105
  return String(err);
9086
9106
  }
9087
9107
  //#endregion
9088
- //#region ../types/dist/composition-C-WpVYTX.mjs
9108
+ //#region ../types/dist/composition-EQ9Ok0Ee.mjs
9089
9109
  /**
9090
9110
  * The ffmpeg build every CamStack node downloads, per platform and arch.
9091
9111
  *
@@ -12750,11 +12770,10 @@ var ClipModelMetaSchema = object({
12750
12770
  /**
12751
12771
  * The `vector-store` index this model's vectors live in. ONE feature space
12752
12772
  * per index: two models share an index only when their vectors are ranked
12753
- * against each other, which for CLIP means never across families. MobileCLIP
12754
- * S1 and S2 share `object-clip` for history (separated by the row's
12755
- * `modelId`); any other model gets its own.
12773
+ * against each other, which for CLIP means never across families. Every
12774
+ * model gets its own.
12756
12775
  */
12757
- vectorIndex: string().regex(/^object-clip(-[a-z0-9][a-z0-9-]*)?$/),
12776
+ vectorIndex: string().regex(/^object-clip-[a-z0-9][a-z0-9-]*$/),
12758
12777
  /**
12759
12778
  * The text encoder's tokenizer, a declared SIBLING file of its onnx. The name
12760
12779
  * must be unique per tokenizer: every model's siblings land in ONE flat
@@ -13223,7 +13242,8 @@ var ConvertResultSchema = object({
13223
13242
  })).readonly()
13224
13243
  });
13225
13244
  _enum(["new-session", "step-up"]);
13226
- _enum(["en", "it"]);
13245
+ /** The languages the hub's app landing pages are written in (D858). */
13246
+ var AppPageLangSchema = _enum(["en", "it"]);
13227
13247
  /** How a native app starts one login method (D858), as `auth.listLoginMethods` reports it. */
13228
13248
  var AppLoginPathSchema = discriminatedUnion("kind", [object({ kind: literal("native") }), object({
13229
13249
  kind: literal("browser"),
@@ -13720,7 +13740,9 @@ var ConfigTabDeclarationSchema = object({
13720
13740
  var ConfigSectionWithValuesSchema = object({
13721
13741
  id: string(),
13722
13742
  title: string(),
13743
+ titleKey: string().optional(),
13723
13744
  description: string().optional(),
13745
+ descriptionKey: string().optional(),
13724
13746
  style: _enum(["card", "accordion"]).optional(),
13725
13747
  defaultCollapsed: boolean().optional(),
13726
13748
  columns: union([
@@ -17003,6 +17025,9 @@ method(object({
17003
17025
  }), method(object({ refs: RefListSchema }), KeychainResolveResultSchema, { auth: "admin" }), method(object({ refs: RefListSchema }), object({ deleted: number().int() }), {
17004
17026
  kind: "mutation",
17005
17027
  auth: "admin"
17028
+ }), method(object({ deviceId: number().int() }), object({ refs: array(string()).readonly() }), {
17029
+ kind: "mutation",
17030
+ auth: "admin"
17006
17031
  }), method(object({}), array(KeychainSecretMetaSchema).readonly(), { auth: "admin" }), method(object({}), KeychainStateSchema, { auth: "admin" });
17007
17032
  /**
17008
17033
  * device-history — the ONE mechanism for numeric history (D850).
@@ -38406,6 +38431,38 @@ var DeviceRegisterPayloadSchema = object({
38406
38431
  * omits it and the keyword backstop applies. */
38407
38432
  secretConfigPaths: array(string()).optional()
38408
38433
  });
38434
+ /** One deleted device, kept by device-manager so an addon that missed the
38435
+ * deletion can still learn of it (D865). Written by the delete path ONLY. */
38436
+ var DeviceTombstoneSchema = object({
38437
+ deviceId: number().int().positive(),
38438
+ /** Epoch ms the device was deleted. */
38439
+ deletedAt: number(),
38440
+ /** The addon that owned the device when it was deleted. */
38441
+ providerAddonId: string()
38442
+ });
38443
+ /** Data held for one device that device-manager no longer knows. */
38444
+ var OrphanDeviceDataSchema = object({
38445
+ deviceId: number().int().positive(),
38446
+ /** Rows/files per store (`<addonId>:<store>`). */
38447
+ stores: record(string(), number().int().nonnegative())
38448
+ });
38449
+ var CleanupDeletedDevicesResultSchema = object({
38450
+ /**
38451
+ * `skipped` — the absence check could not be trusted (a device-list read
38452
+ * failed or the two reads disagreed); nothing was listed or deleted.
38453
+ * `dry-run` — `devices` is what a confirmed run would delete.
38454
+ * `cleaned` — `devices` is what was deleted.
38455
+ */
38456
+ status: _enum([
38457
+ "skipped",
38458
+ "dry-run",
38459
+ "cleaned"
38460
+ ]),
38461
+ reason: string().nullable(),
38462
+ devices: array(OrphanDeviceDataSchema),
38463
+ /** Implementers that could not be asked (their stores are not in `devices`). */
38464
+ unreachable: array(string())
38465
+ });
38409
38466
  var DeviceRemovePayloadSchema = object({ deviceId: number() });
38410
38467
  var DevicePersistConfigPayloadSchema = object({
38411
38468
  deviceId: number(),
@@ -38644,6 +38701,9 @@ method(object({
38644
38701
  }), method(object({ deviceId: number() }), object({ success: literal(true) }), {
38645
38702
  kind: "mutation",
38646
38703
  auth: "admin"
38704
+ }), method(object({ sinceMs: number() }), array(DeviceTombstoneSchema).readonly(), { auth: "admin" }), method(object({ confirm: boolean() }), CleanupDeletedDevicesResultSchema, {
38705
+ kind: "mutation",
38706
+ auth: "admin"
38647
38707
  }), method(object({ integrationId: string() }), object({ removed: number() }), {
38648
38708
  kind: "mutation",
38649
38709
  auth: "admin"
@@ -39106,6 +39166,72 @@ method(object({
39106
39166
  auth: "admin"
39107
39167
  });
39108
39168
  /**
39169
+ * `device-removal-hook` — the reliable way an addon learns a device was DELETED,
39170
+ * so it can delete what it keeps for that device (D865).
39171
+ *
39172
+ * ## Why an event was not enough
39173
+ *
39174
+ * `DeviceUnregistered` is telemetry and may be dropped (D8, D11). An addon that
39175
+ * hung its per-device clean-up on it left durable garbage whenever the event was
39176
+ * lost, the addon was down, or it was restarting at that moment — footage,
39177
+ * analytics rows and settings of device 4483 outlived the device. Loss of this
39178
+ * signal corrupts durable state, so it travels by RPC with a recorded fact
39179
+ * behind it (D8: "if loss would corrupt durable state, use RPC plus a reconcile").
39180
+ *
39181
+ * ## The three pieces
39182
+ *
39183
+ * 1. device-manager records a TOMBSTONE per deleted device
39184
+ * (`deviceManager.listDeletedSince`). The tombstone is written by the delete
39185
+ * path only — a device that is unregistered, offline or whose provider is
39186
+ * restarting is NOT deleted and gets none.
39187
+ * 2. After a delete, device-manager calls `onDeviceRemoved` on EVERY provider of
39188
+ * this collection, one by one, with bounded retries, logged.
39189
+ * 3. An implementer that was down asks `deviceManager.listDeletedSince` at start
39190
+ * (and periodically) and runs its own `onDeviceRemoved` for each tombstone it
39191
+ * may have missed. Reconcile is driven by tombstones, never by "the device is
39192
+ * not in the current list": a failed or partial list read must not destroy data.
39193
+ *
39194
+ * ## Contract for an implementer
39195
+ *
39196
+ * - **Idempotent.** It is called again by a retry, by a reconcile and by the
39197
+ * admin cleanup of data that predates tombstones. A device with nothing left
39198
+ * answers zero counts.
39199
+ * - **One key per store** in `counts`, deleted rows/files per store, so the log
39200
+ * line and the admin dry run can say where the data was.
39201
+ * - **Training datasets are NOT device data** and are never deleted by a hook.
39202
+ * - **Per-device SETTINGS are not hooked.** Configuration lives in the standard
39203
+ * per-device store (`ctx.settings.*DeviceStore`), which device-manager clears
39204
+ * for every addon centrally at delete time. This hook is for device DATA
39205
+ * that is not settings: footage, event media, analytics rows, history.
39206
+ * - A provider that cannot tell is allowed to throw; the caller retries it.
39207
+ *
39208
+ * `listDeviceData` is the read side of the admin "Clean data of deleted devices"
39209
+ * action: which device ids this addon holds data for, and how much per store. It
39210
+ * never deletes. device-manager decides which of those ids are absent from the
39211
+ * fleet, from TWO consistent reads of the device list (D49).
39212
+ */
39213
+ /** What a hook is told about the deletion. */
39214
+ var DeviceRemovedInputSchema = object({
39215
+ deviceId: number().int().positive(),
39216
+ /** Epoch ms the device was deleted (the tombstone's `deletedAt`). */
39217
+ deletedAt: number(),
39218
+ /** The addon that owned the device, as device-manager last knew it. */
39219
+ providerAddonId: string()
39220
+ });
39221
+ /** Rows/files/refs deleted, per store this implementer owns. */
39222
+ var DeviceRemovedResultSchema = object({ counts: record(string(), number().int().nonnegative()) });
39223
+ /** One (store, device) pair an implementer holds data for. */
39224
+ var DeviceDataEntrySchema = object({
39225
+ store: string(),
39226
+ deviceId: number().int().positive(),
39227
+ /** Rows (or files) held for the device in the store. */
39228
+ count: number().int().nonnegative()
39229
+ });
39230
+ method(DeviceRemovedInputSchema, DeviceRemovedResultSchema, {
39231
+ kind: "mutation",
39232
+ auth: "admin"
39233
+ }), method(_void(), array(DeviceDataEntrySchema).readonly(), { auth: "admin" });
39234
+ /**
39109
39235
  * `failure-contribution` — the capability an addon reports its OWN losses
39110
39236
  * through, per camera, with the denominator attached. It stores nothing.
39111
39237
  *
@@ -56575,7 +56701,19 @@ var TlsStatusSchema = object({
56575
56701
  sans: array(string()),
56576
56702
  caCertPem: string().nullable(),
56577
56703
  reissueError: string().nullable(),
56578
- restartRequired: boolean()
56704
+ restartRequired: boolean(),
56705
+ /** An uploaded certificate is served but no CA is kept for it and it is not publicly signed (D873). */
56706
+ caMissing: boolean(),
56707
+ /** Does the hub's own generated CA carry a critical name constraint? `null` while it has none. */
56708
+ localCaConstrained: boolean().nullable(),
56709
+ /** ISO time the generated CA was replaced by a constrained one; devices must re-download it. */
56710
+ caReplacedAt: string().nullable(),
56711
+ /** The uploaded certificate kept on disk, selectable again after "Revert to generated". */
56712
+ storedUpload: object({
56713
+ leafFingerprintSha256: string(),
56714
+ validTo: string(),
56715
+ hasCa: boolean()
56716
+ }).nullable()
56579
56717
  });
56580
56718
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
56581
56719
  /**
@@ -56631,6 +56769,9 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
56631
56769
  }), method(_void(), object({ pem: string() })), method(_void(), TlsStatusSchema, {
56632
56770
  kind: "mutation",
56633
56771
  auth: "admin"
56772
+ }), method(_void(), TlsStatusSchema, {
56773
+ kind: "mutation",
56774
+ auth: "admin"
56634
56775
  });
56635
56776
  object({
56636
56777
  /** Lifecycle state of the lock. `jammed` means the motor reported
@@ -64674,6 +64815,12 @@ Object.freeze({
64674
64815
  addonId: null,
64675
64816
  access: "create"
64676
64817
  },
64818
+ "deviceManager.cleanupDeletedDevicesData": {
64819
+ capName: "device-manager",
64820
+ capScope: "system",
64821
+ addonId: null,
64822
+ access: "create"
64823
+ },
64677
64824
  "deviceManager.createDevice": {
64678
64825
  capName: "device-manager",
64679
64826
  capScope: "system",
@@ -64854,6 +65001,12 @@ Object.freeze({
64854
65001
  addonId: null,
64855
65002
  access: "view"
64856
65003
  },
65004
+ "deviceManager.listDeletedSince": {
65005
+ capName: "device-manager",
65006
+ capScope: "system",
65007
+ addonId: null,
65008
+ access: "view"
65009
+ },
64857
65010
  "deviceManager.listDeviceIdsByCaps": {
64858
65011
  capName: "device-manager",
64859
65012
  capScope: "system",
@@ -65262,6 +65415,18 @@ Object.freeze({
65262
65415
  addonId: null,
65263
65416
  access: "create"
65264
65417
  },
65418
+ "deviceRemovalHook.listDeviceData": {
65419
+ capName: "device-removal-hook",
65420
+ capScope: "system",
65421
+ addonId: null,
65422
+ access: "view"
65423
+ },
65424
+ "deviceRemovalHook.onDeviceRemoved": {
65425
+ capName: "device-removal-hook",
65426
+ capScope: "system",
65427
+ addonId: null,
65428
+ access: "create"
65429
+ },
65265
65430
  "deviceState.claimFields": {
65266
65431
  capName: "device-state",
65267
65432
  capScope: "system",
@@ -65694,6 +65859,12 @@ Object.freeze({
65694
65859
  addonId: null,
65695
65860
  access: "delete"
65696
65861
  },
65862
+ "keychain.deleteDeviceScope": {
65863
+ capName: "keychain",
65864
+ capScope: "system",
65865
+ addonId: null,
65866
+ access: "delete"
65867
+ },
65697
65868
  "keychain.getState": {
65698
65869
  capName: "keychain",
65699
65870
  capScope: "system",
@@ -66132,6 +66303,12 @@ Object.freeze({
66132
66303
  addonId: null,
66133
66304
  access: "create"
66134
66305
  },
66306
+ "localNetwork.useStoredUploadedCertificate": {
66307
+ capName: "local-network",
66308
+ capScope: "system",
66309
+ addonId: null,
66310
+ access: "create"
66311
+ },
66135
66312
  "lockControl.getStatus": {
66136
66313
  capName: "lock-control",
66137
66314
  capScope: "device",
@@ -71734,6 +71911,11 @@ Object.freeze({
71734
71911
  form: "single",
71735
71912
  optional: false
71736
71913
  }],
71914
+ "deviceRemovalHook.onDeviceRemoved": [{
71915
+ name: "deviceId",
71916
+ form: "single",
71917
+ optional: false
71918
+ }],
71737
71919
  "deviceState.claimFields": [{
71738
71920
  name: "deviceId",
71739
71921
  form: "single",
@@ -71923,6 +72105,11 @@ Object.freeze({
71923
72105
  form: "single",
71924
72106
  optional: false
71925
72107
  }],
72108
+ "keychain.deleteDeviceScope": [{
72109
+ name: "deviceId",
72110
+ form: "single",
72111
+ optional: false
72112
+ }],
71926
72113
  "lawnMowerControl.dock": [{
71927
72114
  name: "deviceId",
71928
72115
  form: "single",
@@ -74271,6 +74458,8 @@ new Set([
74271
74458
  "audio-tools",
74272
74459
  "audio-silence"
74273
74460
  ]);
74461
+ AppPageLangSchema.options;
74462
+ record(string().min(1), string());
74274
74463
  /**
74275
74464
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
74276
74465
  *
@@ -74549,7 +74738,11 @@ var REMOVED_MODEL_IDS = {
74549
74738
  "face-embedding": ["arcface-r100", "inception-resnet-v1"],
74550
74739
  "plate-detection": ["yolov8n-plate"],
74551
74740
  "plate-ocr": ["vgg-english-g2"],
74552
- "clip-embedding": ["mobileclip-s1", "mobileclip-s2"],
74741
+ "clip-embedding": [
74742
+ "mobileclip-s0",
74743
+ "mobileclip-s1",
74744
+ "mobileclip-s2"
74745
+ ],
74553
74746
  "package-detection": ["yolov8n-package"],
74554
74747
  "bird-classifier": ["bird-classifier-aiy-edgetpu", "bird-nabirds-404"],
74555
74748
  "animal-classifier": ["animals-10"],
@@ -75972,6 +76165,25 @@ var DETAIL_CROP_PADDING_FIELD = {
75972
76165
  default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
75973
76166
  };
75974
76167
  /**
76168
+ * The cadence of the automatic whole-archive face rescore (D866).
76169
+ *
76170
+ * One cluster-wide knob in `pipeline-orchestrator`'s GLOBAL settings, read by
76171
+ * `pipeline-analytics` over the hub-routed `addon-settings` cap on every tick.
76172
+ * It lives here for the same reason the detail-crop convention does: the
76173
+ * writer (orchestrator) and the reader (analytics) are different addons, they
76174
+ * never import each other, and a key copied by hand is how a setting silently
76175
+ * stops arriving.
76176
+ */
76177
+ var FACE_RESCORE_SECTION_ID = "face-rescore";
76178
+ var FACE_RESCORE_INTERVAL_KEY = "faceRescoreIntervalMinutes";
76179
+ /** Slider bounds. `0` is "disabled"; anything in 1..9 is raised to `min`. */
76180
+ var FACE_RESCORE_INTERVAL_FIELD = {
76181
+ min: 0,
76182
+ max: 1440,
76183
+ step: 5,
76184
+ default: 60
76185
+ };
76186
+ /**
75975
76187
  * THE native-frame **lease** knobs — hold depth, RAM budget, demand window and
75976
76188
  * subject-tile budget for the decode worker's native-resolution retention.
75977
76189
  *
@@ -76427,6 +76639,22 @@ var DurableLedger = class DurableLedger {
76427
76639
  }
76428
76640
  }
76429
76641
  /**
76642
+ * Drop every MIRRORED row about `deviceId` (per the spec's `deviceIdOf`),
76643
+ * leaving the durable rows to the caller's `deleteWhere` (D871). Returns how
76644
+ * many mirror entries went; a spec without `deviceIdOf` evicts nothing.
76645
+ */
76646
+ evictDevice(deviceId) {
76647
+ const of = this.spec.deviceIdOf;
76648
+ if (of === void 0) return 0;
76649
+ let evicted = 0;
76650
+ for (const [key, row] of [...this.mirror.entries()]) {
76651
+ if (of(row) !== deviceId) continue;
76652
+ this.mirror.delete(key);
76653
+ evicted += 1;
76654
+ }
76655
+ return evicted;
76656
+ }
76657
+ /**
76430
76658
  * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
76431
76659
  *
76432
76660
  * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
@@ -83735,67 +83963,20 @@ function buildRunnerConfig(base, overrides) {
83735
83963
  preRollInferenceFrames: config.preRollInferenceFrames
83736
83964
  };
83737
83965
  }
83738
- var DEVICE_FEATURES_SPEC = {
83739
- collection: "pipeline-orchestrator:device-features",
83740
- columns: [
83741
- {
83742
- name: "deviceId",
83743
- type: "TEXT",
83744
- primaryKey: true,
83745
- notNull: true
83746
- },
83747
- (
83748
- /** The feature name list, verbatim. */
83749
- {
83750
- name: "features",
83751
- type: "JSON",
83752
- notNull: true
83753
- }),
83754
- {
83755
- name: "updatedAt",
83756
- type: "INTEGER",
83757
- notNull: true
83758
- }
83759
- ],
83760
- writeMode: "write-behind",
83761
- keyOf: (row) => String(row.deviceId),
83762
- toValue: (row) => ({
83763
- features: [...row.features],
83764
- updatedAt: row.updatedAt
83765
- }),
83766
- fromRecord: (key, data) => {
83767
- const deviceId = Number(key);
83768
- const raw = data["features"];
83769
- if (!Number.isFinite(deviceId) || !Array.isArray(raw)) return null;
83770
- const features = raw.filter((f) => typeof f === "string");
83771
- if (features.length === 0) return null;
83772
- const updatedAt = Number(data["updatedAt"]);
83773
- return {
83774
- deviceId,
83775
- features,
83776
- updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
83777
- restored: true
83778
- };
83779
- },
83780
- deviceIdOf: (row) => row.deviceId
83781
- };
83966
+ //#endregion
83967
+ //#region src/device-features-mirror.ts
83968
+ /** The per-device settings key a device's mirrored features live under. */
83969
+ var DEVICE_FEATURES_KEY = "deviceFeatures";
83782
83970
  var DeviceFeaturesMirror = class {
83783
83971
  /** Process-local fallback, used only when no store was supplied. */
83784
83972
  local = /* @__PURE__ */ new Map();
83785
- durable;
83973
+ settings;
83786
83974
  logger;
83787
83975
  now;
83788
83976
  constructor(deps) {
83789
83977
  this.logger = deps.logger;
83790
83978
  this.now = deps.now ?? (() => Date.now());
83791
- this.durable = deps.store === void 0 ? null : new DurableLedger({
83792
- spec: DEVICE_FEATURES_SPEC,
83793
- store: deps.store,
83794
- logger: deps.logger
83795
- });
83796
- }
83797
- static declare(store) {
83798
- return DurableLedger.declare(store, DEVICE_FEATURES_SPEC);
83979
+ this.settings = deps.settings ?? null;
83799
83980
  }
83800
83981
  /**
83801
83982
  * Seed the mirror from the last session. Call once at boot, after `declare`
@@ -83803,13 +83984,29 @@ var DeviceFeaturesMirror = class {
83803
83984
  * already there when the first read fails.
83804
83985
  */
83805
83986
  async hydrate() {
83806
- if (this.durable === null) return 0;
83807
- const rows = await this.durable.load();
83808
- this.logger.info("device-features mirror restored", { meta: { devices: rows.length } });
83809
- return rows.length;
83987
+ const settings = this.settings;
83988
+ if (settings === null) return 0;
83989
+ const list = settings.listDeviceStoreIds;
83990
+ if (list !== void 0) {
83991
+ const ids = await list.call(settings);
83992
+ const batch = settings.readDeviceStoreBatch;
83993
+ const blobs = batch === void 0 ? new Map(await Promise.all(ids.map(async (id) => [id, await settings.readDeviceStore(id)]))) : await batch.call(settings, ids);
83994
+ for (const [deviceId, blob] of blobs) {
83995
+ const row = rowOf(deviceId, blob[DEVICE_FEATURES_KEY]);
83996
+ if (row !== null) this.local.set(deviceId, row);
83997
+ }
83998
+ }
83999
+ this.logger.info("device-features mirror restored", { meta: { devices: this.local.size } });
84000
+ return this.local.size;
83810
84001
  }
83811
84002
  held(deviceId) {
83812
- return this.durable === null ? this.local.get(deviceId) : this.durable.get(String(deviceId));
84003
+ return this.local.get(deviceId);
84004
+ }
84005
+ async persist(deviceId, features, updatedAt) {
84006
+ await this.settings?.writeDeviceStore(deviceId, { [DEVICE_FEATURES_KEY]: {
84007
+ features: [...features],
84008
+ updatedAt
84009
+ } });
83813
84010
  }
83814
84011
  remember(deviceId, features) {
83815
84012
  const row = {
@@ -83818,20 +84015,19 @@ var DeviceFeaturesMirror = class {
83818
84015
  updatedAt: this.now(),
83819
84016
  restored: false
83820
84017
  };
83821
- if (this.durable === null) {
83822
- this.local.set(deviceId, row);
83823
- return;
83824
- }
83825
- const held = this.durable.get(String(deviceId));
84018
+ const held = this.local.get(deviceId);
84019
+ this.local.set(deviceId, row);
83826
84020
  if (held !== void 0 && !held.restored && sameFeatures(held.features, features)) return;
83827
- this.durable.put(row);
84021
+ this.persist(deviceId, features, row.updatedAt).catch((err) => {
84022
+ this.logger.warn("device-features mirror not persisted", {
84023
+ tags: { deviceId },
84024
+ meta: { error: err instanceof Error ? err.message : String(err) }
84025
+ });
84026
+ });
83828
84027
  }
83829
84028
  drop(deviceId) {
83830
- if (this.durable === null) {
83831
- this.local.delete(deviceId);
83832
- return;
83833
- }
83834
- this.durable.forget(String(deviceId));
84029
+ this.local.delete(deviceId);
84030
+ this.settings?.writeDeviceStore(deviceId, { [DEVICE_FEATURES_KEY]: void 0 });
83835
84031
  }
83836
84032
  /**
83837
84033
  * Resolve a device's features, preferring a fresh read but never letting a
@@ -83880,7 +84076,7 @@ var DeviceFeaturesMirror = class {
83880
84076
  this.drop(deviceId);
83881
84077
  return [];
83882
84078
  }
83883
- /** Drop a device's mirror — call when the device is removed. */
84079
+ /** Drop a device's mirror. (A removed device's settings are cleared centrally, D865.) */
83884
84080
  forget(deviceId) {
83885
84081
  this.drop(deviceId);
83886
84082
  }
@@ -83891,6 +84087,20 @@ function sameFeatures(a, b) {
83891
84087
  const held = new Set(a);
83892
84088
  return b.every((f) => held.has(f));
83893
84089
  }
84090
+ function rowOf(deviceId, raw) {
84091
+ if (typeof raw !== "object" || raw === null) return null;
84092
+ const features = Reflect.get(raw, "features");
84093
+ if (!Array.isArray(features)) return null;
84094
+ const kept = features.filter((f) => typeof f === "string");
84095
+ if (kept.length === 0) return null;
84096
+ const updatedAt = Number(Reflect.get(raw, "updatedAt"));
84097
+ return {
84098
+ deviceId,
84099
+ features: kept,
84100
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
84101
+ restored: true
84102
+ };
84103
+ }
83894
84104
  //#endregion
83895
84105
  //#region src/onboard-forwarding-reconcile.ts
83896
84106
  /** The `detectionSources` member that means "this camera's own firmware". */
@@ -84049,9 +84259,10 @@ var DetectionWiringController = class {
84049
84259
  featuresMirror;
84050
84260
  constructor(deps) {
84051
84261
  this.deps = deps;
84262
+ const settings = deps.persistFeatures === true ? deps.ctx().settings : void 0;
84052
84263
  this.featuresMirror = new DeviceFeaturesMirror({
84053
84264
  logger: deps.logger,
84054
- ...deps.featuresStore !== void 0 ? { store: deps.featuresStore } : {}
84265
+ ...settings !== void 0 ? { settings } : {}
84055
84266
  });
84056
84267
  }
84057
84268
  /**
@@ -84060,8 +84271,7 @@ var DetectionWiringController = class {
84060
84271
  * that would have needed it.
84061
84272
  */
84062
84273
  async hydrateFeaturesMirror() {
84063
- if (this.deps.featuresStore === void 0) return;
84064
- await DeviceFeaturesMirror.declare(this.deps.featuresStore);
84274
+ if (this.deps.persistFeatures !== true) return;
84065
84275
  await this.featuresMirror.hydrate();
84066
84276
  }
84067
84277
  /** `activeDetections.get(deviceId)`. */
@@ -87442,25 +87652,33 @@ var NodeStressController = class NodeStressController {
87442
87652
  //#region src/durable/lts-aggregator.ts
87443
87653
  /** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
87444
87654
  var LTS_BUCKET_MS = 5 * 6e4;
87655
+ /** Buckets written per transaction — bounds one statement, not the flush. */
87656
+ var FLUSH_BATCH = 200;
87445
87657
  /** How often the row cap is enforced. Rarely: it is a bound, not a deadline. */
87446
87658
  var CAP_SWEEP_INTERVAL_MS = 6 * 36e5;
87659
+ /**
87660
+ * The row's identity IS its four key columns (D871): a composite
87661
+ * `primaryKey` + `WITHOUT ROWID`, so the table is its own index and there is no
87662
+ * TEXT `id` repeating what the row already says. The old layout kept
87663
+ * `<subject>|<series>|<scope>|<bucketStart>` as a TEXT primary key beside the
87664
+ * four columns — 85 MB of rows plus a 44 MB autoindex for 1.07 M rows. A
87665
+ * composite-key collection is written with `upsertMany` (a bucket flushed twice
87666
+ * replaces itself) and read with `query` / `count` / `deleteWhere`; the calls
87667
+ * that address one row by id are refused by the engine.
87668
+ */
87669
+ var LTS_KEY_COLUMNS = [
87670
+ "subject",
87671
+ "series",
87672
+ "scope",
87673
+ "bucketStart"
87674
+ ];
87447
87675
  var LTS_COLUMNS = [
87448
- (
87449
- /** `<subject>|<series>|<scope>|<bucketStart>` — deterministic, so a bucket
87450
- * flushed twice replaces itself rather than doubling. */
87451
- {
87452
- name: "id",
87453
- type: "TEXT",
87454
- primaryKey: true,
87455
- notNull: true
87456
- }),
87457
87676
  (
87458
87677
  /** The camera id, or the node id. One column, because every query is
87459
87678
  * "this thing over time" and the thing is one or the other. */
87460
87679
  {
87461
87680
  name: "subject",
87462
- type: "TEXT",
87463
- notNull: true
87681
+ type: "TEXT"
87464
87682
  }),
87465
87683
  (
87466
87684
  /** Numeric mirror of `subject` for camera rows, so a per-camera question is
@@ -87472,22 +87690,19 @@ var LTS_COLUMNS = [
87472
87690
  }),
87473
87691
  {
87474
87692
  name: "series",
87475
- type: "TEXT",
87476
- notNull: true
87693
+ type: "TEXT"
87477
87694
  },
87478
87695
  (
87479
87696
  /** Sub-scope within a series: a zoneId for occupancy, `''` otherwise. */
87480
87697
  {
87481
87698
  name: "scope",
87482
- type: "TEXT",
87483
- notNull: true
87699
+ type: "TEXT"
87484
87700
  }),
87485
87701
  (
87486
87702
  /** Bucket start, wall-clock aligned to {@link LTS_BUCKET_MS}. */
87487
87703
  {
87488
87704
  name: "bucketStart",
87489
- type: "INTEGER",
87490
- notNull: true
87705
+ type: "INTEGER"
87491
87706
  }),
87492
87707
  {
87493
87708
  name: "samples",
@@ -87510,12 +87725,15 @@ var LTS_COLUMNS = [
87510
87725
  notNull: true
87511
87726
  }
87512
87727
  ];
87728
+ /**
87729
+ * The primary key serves every per-subject read (`subject, series, scope` then a
87730
+ * `bucketStart` range, already in bucket order); this index serves the read that
87731
+ * names a series without a subject. The old `deviceId` index went with the TEXT
87732
+ * id: nothing queries a series by the numeric mirror.
87733
+ */
87513
87734
  var LTS_INDEXES = [{
87514
87735
  name: "idx_lts_series_bucket",
87515
87736
  columns: ["series", "bucketStart"]
87516
- }, {
87517
- name: "idx_lts_device",
87518
- columns: ["deviceId"]
87519
87737
  }];
87520
87738
  function ltsBucketStart(at, bucketMs = LTS_BUCKET_MS) {
87521
87739
  return Math.floor(at / bucketMs) * bucketMs;
@@ -87544,6 +87762,8 @@ var LtsAggregator = class {
87544
87762
  return store.declareCollection.mutate({
87545
87763
  collection,
87546
87764
  columns: [...LTS_COLUMNS],
87765
+ primaryKey: [...LTS_KEY_COLUMNS],
87766
+ withoutRowid: true,
87547
87767
  indexes: [...LTS_INDEXES]
87548
87768
  });
87549
87769
  }
@@ -87603,43 +87823,60 @@ var LtsAggregator = class {
87603
87823
  return 0;
87604
87824
  }
87605
87825
  let written = 0;
87606
- for (const bucket of due) {
87607
- const key = rowId(bucket.subject, bucket.series, bucket.scope, bucket.bucketStart);
87608
- try {
87609
- await this.store.set.mutate({
87610
- collection: this.collection,
87611
- key,
87612
- value: {
87613
- subject: bucket.subject,
87614
- ...bucket.deviceId !== void 0 ? { deviceId: bucket.deviceId } : {},
87615
- series: bucket.series,
87616
- scope: bucket.scope,
87617
- bucketStart: bucket.bucketStart,
87618
- samples: bucket.samples,
87619
- sum: bucket.sum,
87620
- min: bucket.min,
87621
- max: bucket.max
87622
- }
87623
- });
87624
- written += 1;
87625
- this.groups.add(groupKey(bucket.subject, bucket.series, bucket.scope));
87626
- } catch (err) {
87627
- this.logger.warn("lts bucket write failed — this interval will be missing", {
87628
- ...bucket.deviceId !== void 0 ? { tags: { deviceId: bucket.deviceId } } : {},
87629
- meta: {
87630
- collection: this.collection,
87631
- series: bucket.series,
87632
- subject: bucket.subject,
87633
- bucketStart: bucket.bucketStart,
87634
- error: String(err)
87635
- }
87636
- });
87637
- }
87638
- this.open.delete(key);
87826
+ for (const batch of chunk(due, FLUSH_BATCH)) {
87827
+ written += await this.writeBatch(batch);
87828
+ for (const bucket of batch) this.open.delete(rowId(bucket.subject, bucket.series, bucket.scope, bucket.bucketStart));
87639
87829
  }
87640
87830
  await this.maybeSweepCap(now);
87641
87831
  return written;
87642
87832
  }
87833
+ /**
87834
+ * Upsert one batch in a single transaction. If the batch is refused as a
87835
+ * whole (all-or-nothing), each bucket is retried alone so one bad row costs
87836
+ * one interval, not forty. Returns the number of rows written.
87837
+ */
87838
+ async writeBatch(batch) {
87839
+ try {
87840
+ await this.upsert(batch);
87841
+ for (const bucket of batch) this.groups.add(groupKey(bucket.subject, bucket.series, bucket.scope));
87842
+ return batch.length;
87843
+ } catch (err) {
87844
+ if (batch.length > 1) {
87845
+ let written = 0;
87846
+ for (const bucket of batch) written += await this.writeBatch([bucket]);
87847
+ return written;
87848
+ }
87849
+ const bucket = batch[0];
87850
+ this.logger.warn("lts bucket write failed — this interval will be missing", {
87851
+ ...bucket?.deviceId !== void 0 ? { tags: { deviceId: bucket.deviceId } } : {},
87852
+ meta: {
87853
+ collection: this.collection,
87854
+ series: bucket?.series,
87855
+ subject: bucket?.subject,
87856
+ bucketStart: bucket?.bucketStart,
87857
+ error: String(err)
87858
+ }
87859
+ });
87860
+ return 0;
87861
+ }
87862
+ }
87863
+ async upsert(batch) {
87864
+ await this.store.upsertMany.mutate({
87865
+ collection: this.collection,
87866
+ conflict: [...LTS_KEY_COLUMNS],
87867
+ records: batch.map((bucket) => ({ data: {
87868
+ subject: bucket.subject,
87869
+ deviceId: bucket.deviceId ?? null,
87870
+ series: bucket.series,
87871
+ scope: bucket.scope,
87872
+ bucketStart: bucket.bucketStart,
87873
+ samples: bucket.samples,
87874
+ sum: bucket.sum,
87875
+ min: bucket.min,
87876
+ max: bucket.max
87877
+ } }))
87878
+ });
87879
+ }
87643
87880
  /** Read closed buckets back. The read surface every chart will use. */
87644
87881
  async read(query = {}) {
87645
87882
  const where = {};
@@ -87724,6 +87961,11 @@ var LtsAggregator = class {
87724
87961
  }
87725
87962
  }
87726
87963
  };
87964
+ function chunk(items, size) {
87965
+ const out = [];
87966
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
87967
+ return out;
87968
+ }
87727
87969
  function groupKey(subject, series, scope) {
87728
87970
  return `${subject}${series}${scope}`;
87729
87971
  }
@@ -87782,7 +88024,7 @@ function recordToRow(data) {
87782
88024
  * write="one row per (node, series, 5-min bucket), written ONCE when the bucket closes; a node reporting no samples writes nothing"
87783
88025
  * 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."
87784
88026
  */
87785
- var NODE_STRESS_LTS_COLLECTION = "pipeline-orchestrator:stats-5m";
88027
+ var NODE_STRESS_LTS_COLLECTION = "pipeline-orchestrator:stats-5m-v2";
87786
88028
  var NodeStressLts = class {
87787
88029
  lts;
87788
88030
  constructor(deps) {
@@ -94359,7 +94601,7 @@ async function buildOrchestratorControllers(deps) {
94359
94601
  reconcile.scheduleReconcile();
94360
94602
  const detectionWiring = new DetectionWiringController({
94361
94603
  ctx: () => deps.ctx(),
94362
- featuresStore: deps.ctx().api.settingsStore,
94604
+ persistFeatures: true,
94363
94605
  ledger,
94364
94606
  placement,
94365
94607
  audio,
@@ -94579,6 +94821,24 @@ function buildGlobalSettingsSections(options) {
94579
94821
  default: DEFAULT_DETAIL_CROP_CONVENTION.square
94580
94822
  }]
94581
94823
  },
94824
+ {
94825
+ id: FACE_RESCORE_SECTION_ID,
94826
+ title: "Face suggestions",
94827
+ tab: "pipeline",
94828
+ 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.",
94829
+ fields: [{
94830
+ key: FACE_RESCORE_INTERVAL_KEY,
94831
+ type: "slider",
94832
+ label: "Suggestion refresh interval",
94833
+ 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.",
94834
+ min: FACE_RESCORE_INTERVAL_FIELD.min,
94835
+ max: FACE_RESCORE_INTERVAL_FIELD.max,
94836
+ step: FACE_RESCORE_INTERVAL_FIELD.step,
94837
+ default: FACE_RESCORE_INTERVAL_FIELD.default,
94838
+ showValue: true,
94839
+ unit: "min"
94840
+ }]
94841
+ },
94582
94842
  {
94583
94843
  id: NATIVE_LEASE_SECTION_ID,
94584
94844
  title: "Native frame lease",