@camstack/addon-provider-homematic 1.2.47 → 1.2.49

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.
Files changed (3) hide show
  1. package/dist/addon.js +331 -174
  2. package/dist/addon.mjs +331 -174
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -13259,6 +13259,114 @@ method(object({
13259
13259
  height: number()
13260
13260
  }), EmbeddingResultSchema, { auth: "admin" }), method(object({ text: string() }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" });
13261
13261
  /**
13262
+ * `failure-contribution` — the capability an addon reports its OWN losses
13263
+ * through, per camera, with the denominator attached. It stores nothing.
13264
+ *
13265
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13266
+ *
13267
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13268
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13269
+ * copied: the contributor reports what it already knows, hub-main adds only
13270
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13271
+ * somebody to forget to edit.
13272
+ *
13273
+ * They are not merged, because their invariants are opposites:
13274
+ *
13275
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13276
+ * claim a camera cost nothing, which is a measurement nobody made;
13277
+ * - a `failure-contribution` zero is the **most valuable value on the
13278
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13279
+ * and it is exactly what an absent entry cannot say.
13280
+ *
13281
+ * Putting a loss counter on a cost entry would also break the reconciliation
13282
+ * that gives `load-contribution` its point: contributions are subtracted from
13283
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13284
+ * has no process.
13285
+ *
13286
+ * ## Why not a log line, since the counters already exist
13287
+ *
13288
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13289
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13290
+ * ends in a log line, and a log line is the thing the operator asked to stop
13291
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13292
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13293
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13294
+ * media blackout were both diagnosed. The counters stay; this is where they can
13295
+ * be READ.
13296
+ *
13297
+ * ## The rate is served with its denominator or not at all
13298
+ *
13299
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13300
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13301
+ * than yesterday" and was **flat across twelve hours** once divided by the
13302
+ * successes on the same path. A surface that publishes only the numerator
13303
+ * reproduces that mistake on every read.
13304
+ *
13305
+ * ## Shape
13306
+ *
13307
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13308
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13309
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13310
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13311
+ * a forked runner's entries reach hub-main over transport that already exists.
13312
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13313
+ * result through `system.getFailureContributions`.
13314
+ */
13315
+ var FailureReasonCountSchema = object({
13316
+ /**
13317
+ * Why the attempt did not land, in the contributor's own vocabulary —
13318
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13319
+ * strings that already appear in this repo's logs and, where one exists, the
13320
+ * same string the per-track `previewMissReason` records (D276): a second
13321
+ * vocabulary for the same loss would make the row and the counter
13322
+ * un-joinable.
13323
+ */
13324
+ reason: string(),
13325
+ count: number().int().nonnegative()
13326
+ });
13327
+ var FailureContributionSchema = object({
13328
+ /**
13329
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13330
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13331
+ * `unit` free: the families are owned by different addons and a shared enum
13332
+ * is a central list that rots invisibly.
13333
+ */
13334
+ family: string(),
13335
+ /**
13336
+ * The NUMERIC device id — the same value every log line carries as
13337
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13338
+ * cannot name the camera must not emit the entry, because a fleet total
13339
+ * cannot answer the only question anybody asks of this surface.
13340
+ */
13341
+ deviceId: number().int().positive(),
13342
+ /**
13343
+ * A second dimension inside the family: the model / step id for an inference
13344
+ * timeout, so "which camera AND which model" is one read. Absent when the
13345
+ * family has a single variant.
13346
+ */
13347
+ variant: string().optional(),
13348
+ /**
13349
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13350
+ * differencing two reads must drop the interval when it changes, because the
13351
+ * counter restarted from zero in a respawned runner. Same discipline as
13352
+ * `LoadContribution.startedAtMs`.
13353
+ */
13354
+ sinceMs: number(),
13355
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13356
+ atMs: number(),
13357
+ /**
13358
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13359
+ * window. A failure count published without it is the mistake this schema
13360
+ * exists to make impossible.
13361
+ */
13362
+ attempts: number().int().nonnegative(),
13363
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13364
+ succeeded: number().int().nonnegative(),
13365
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13366
+ reasons: array(FailureReasonCountSchema).readonly()
13367
+ });
13368
+ method(_void(), array(FailureContributionSchema).readonly());
13369
+ /**
13262
13370
  * filesystem-browse — per-node capability for browsing the node's local
13263
13371
  * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
13264
13372
  * are sandboxed to operator-configured allowed roots (D115). Used by the
@@ -13780,6 +13888,68 @@ method(LlmGenerateBaseInputSchema, LlmGenerateResultSchema, { kind: "mutation" }
13780
13888
  kind: "mutation",
13781
13889
  auth: "admin"
13782
13890
  });
13891
+ var LoadContributionSchema = object({
13892
+ role: _enum([
13893
+ "decode",
13894
+ "transcode",
13895
+ "recording",
13896
+ "streaming",
13897
+ "detection"
13898
+ ]),
13899
+ /**
13900
+ * The NUMERIC device id — the same value every log line carries as
13901
+ * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13902
+ * camera (a shared pool), NOT that the contributor forgot to look it up: a
13903
+ * contributor that cannot name its camera must not emit the entry at all,
13904
+ * because an unnamed per-camera entry is indistinguishable from a shared one
13905
+ * and would quietly turn one camera's cost into everybody's.
13906
+ */
13907
+ deviceId: number().int().positive().nullable(),
13908
+ attribution: _enum([
13909
+ "measured",
13910
+ "accounted",
13911
+ "unattributable"
13912
+ ]),
13913
+ /**
13914
+ * What ONE entry is, in the contributor's own words — `615/high`,
13915
+ * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13916
+ * family and inventing a common one would lose the only information that
13917
+ * makes two entries for the same camera distinguishable.
13918
+ */
13919
+ unit: string(),
13920
+ /**
13921
+ * The OS process this cost lives in, when there is one. Present so a
13922
+ * consumer can (a) tell two generations of the same unit apart across a
13923
+ * restart, and (b) subtract claimed processes from the node's process
13924
+ * snapshot to see what NOBODY claimed. Absent for an entry that owns no
13925
+ * process of its own.
13926
+ */
13927
+ pid: number().int().positive().optional(),
13928
+ /**
13929
+ * When this generation started. The pid's incarnation marker: a consumer
13930
+ * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
13931
+ * window when this changes, because the counter restarted from zero in a new
13932
+ * process.
13933
+ */
13934
+ startedAtMs: number().optional(),
13935
+ /**
13936
+ * CUMULATIVE CPU seconds this unit has consumed since it started — user +
13937
+ * system, read from the child's own `/proc/<pid>/stat` at the moment the
13938
+ * contribution is asked for.
13939
+ *
13940
+ * Cumulative and not a rate on purpose: a rate needs a window, a window
13941
+ * needs a sampler, and a new per-node sampler is the defect half of
13942
+ * `docs/architecture/load-ledger.md` documents. A counter can be differenced
13943
+ * by whoever already keeps a history; a rate cannot be un-averaged.
13944
+ *
13945
+ * Absent — never zero — on a node with no `/proc`, on a read failure, and on
13946
+ * an entry with no process.
13947
+ */
13948
+ cpuSeconds: number().optional(),
13949
+ /** Resident bytes of this unit's process, same source and same rules. */
13950
+ rssBytes: number().optional()
13951
+ });
13952
+ method(_void(), array(LoadContributionSchema).readonly());
13783
13953
  /**
13784
13954
  * `log-channels` — the capability an addon DECLARES its diagnostic channels
13785
13955
  * through. It stores nothing.
@@ -13856,176 +14026,6 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13856
14026
  tags: record(string(), string()).optional()
13857
14027
  }), array(LogEntrySchema).readonly());
13858
14028
  /**
13859
- * `failure-contribution` — the capability an addon reports its OWN losses
13860
- * through, per camera, with the denominator attached. It stores nothing.
13861
- *
13862
- * ## The twin of `load-contribution`, and why it is a twin and not a field
13863
- *
13864
- * `load-contribution` answers *what did this camera COST*. This answers *what
13865
- * did this camera LOSE*. The reporting discipline is identical and deliberately
13866
- * copied: the contributor reports what it already knows, hub-main adds only
13867
- * `addonId`, nothing needs global knowledge, and there is no central list for
13868
- * somebody to forget to edit.
13869
- *
13870
- * They are not merged, because their invariants are opposites:
13871
- *
13872
- * - a `load-contribution` measurement is **absent, never zero** — a zero would
13873
- * claim a camera cost nothing, which is a measurement nobody made;
13874
- * - a `failure-contribution` zero is the **most valuable value on the
13875
- * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13876
- * and it is exactly what an absent entry cannot say.
13877
- *
13878
- * Putting a loss counter on a cost entry would also break the reconciliation
13879
- * that gives `load-contribution` its point: contributions are subtracted from
13880
- * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13881
- * has no process.
13882
- *
13883
- * ## Why not a log line, since the counters already exist
13884
- *
13885
- * Several of these paths already counted themselves — `CaptureScheduler`'s
13886
- * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13887
- * ends in a log line, and a log line is the thing the operator asked to stop
13888
- * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13889
- * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13890
- * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13891
- * media blackout were both diagnosed. The counters stay; this is where they can
13892
- * be READ.
13893
- *
13894
- * ## The rate is served with its denominator or not at all
13895
- *
13896
- * Every entry carries `attempts` and `succeeded`. A miss count alone is
13897
- * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13898
- * than yesterday" and was **flat across twelve hours** once divided by the
13899
- * successes on the same path. A surface that publishes only the numerator
13900
- * reproduces that mistake on every read.
13901
- *
13902
- * ## Shape
13903
- *
13904
- * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13905
- * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13906
- * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13907
- * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13908
- * a forked runner's entries reach hub-main over transport that already exists.
13909
- * No new UDS message, no second registry (D3). The operator reads the assembled
13910
- * result through `system.getFailureContributions`.
13911
- */
13912
- var FailureReasonCountSchema = object({
13913
- /**
13914
- * Why the attempt did not land, in the contributor's own vocabulary —
13915
- * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13916
- * strings that already appear in this repo's logs and, where one exists, the
13917
- * same string the per-track `previewMissReason` records (D276): a second
13918
- * vocabulary for the same loss would make the row and the counter
13919
- * un-joinable.
13920
- */
13921
- reason: string(),
13922
- count: number().int().nonnegative()
13923
- });
13924
- var FailureContributionSchema = object({
13925
- /**
13926
- * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13927
- * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13928
- * `unit` free: the families are owned by different addons and a shared enum
13929
- * is a central list that rots invisibly.
13930
- */
13931
- family: string(),
13932
- /**
13933
- * The NUMERIC device id — the same value every log line carries as
13934
- * `tags.deviceId`. Never nullable and never absent: a contributor that
13935
- * cannot name the camera must not emit the entry, because a fleet total
13936
- * cannot answer the only question anybody asks of this surface.
13937
- */
13938
- deviceId: number().int().positive(),
13939
- /**
13940
- * A second dimension inside the family: the model / step id for an inference
13941
- * timeout, so "which camera AND which model" is one read. Absent when the
13942
- * family has a single variant.
13943
- */
13944
- variant: string().optional(),
13945
- /**
13946
- * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13947
- * differencing two reads must drop the interval when it changes, because the
13948
- * counter restarted from zero in a respawned runner. Same discipline as
13949
- * `LoadContribution.startedAtMs`.
13950
- */
13951
- sinceMs: number(),
13952
- /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13953
- atMs: number(),
13954
- /**
13955
- * THE DENOMINATOR — every attempt on this path for this camera in the
13956
- * window. A failure count published without it is the mistake this schema
13957
- * exists to make impossible.
13958
- */
13959
- attempts: number().int().nonnegative(),
13960
- /** Attempts that landed. `attempts - succeeded` is the loss. */
13961
- succeeded: number().int().nonnegative(),
13962
- /** The loss, partitioned. Sums to `attempts - succeeded`. */
13963
- reasons: array(FailureReasonCountSchema).readonly()
13964
- });
13965
- method(_void(), array(FailureContributionSchema).readonly());
13966
- var LoadContributionSchema = object({
13967
- role: _enum([
13968
- "decode",
13969
- "transcode",
13970
- "recording",
13971
- "streaming",
13972
- "detection"
13973
- ]),
13974
- /**
13975
- * The NUMERIC device id — the same value every log line carries as
13976
- * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13977
- * camera (a shared pool), NOT that the contributor forgot to look it up: a
13978
- * contributor that cannot name its camera must not emit the entry at all,
13979
- * because an unnamed per-camera entry is indistinguishable from a shared one
13980
- * and would quietly turn one camera's cost into everybody's.
13981
- */
13982
- deviceId: number().int().positive().nullable(),
13983
- attribution: _enum([
13984
- "measured",
13985
- "accounted",
13986
- "unattributable"
13987
- ]),
13988
- /**
13989
- * What ONE entry is, in the contributor's own words — `615/high`,
13990
- * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13991
- * family and inventing a common one would lose the only information that
13992
- * makes two entries for the same camera distinguishable.
13993
- */
13994
- unit: string(),
13995
- /**
13996
- * The OS process this cost lives in, when there is one. Present so a
13997
- * consumer can (a) tell two generations of the same unit apart across a
13998
- * restart, and (b) subtract claimed processes from the node's process
13999
- * snapshot to see what NOBODY claimed. Absent for an entry that owns no
14000
- * process of its own.
14001
- */
14002
- pid: number().int().positive().optional(),
14003
- /**
14004
- * When this generation started. The pid's incarnation marker: a consumer
14005
- * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
14006
- * window when this changes, because the counter restarted from zero in a new
14007
- * process.
14008
- */
14009
- startedAtMs: number().optional(),
14010
- /**
14011
- * CUMULATIVE CPU seconds this unit has consumed since it started — user +
14012
- * system, read from the child's own `/proc/<pid>/stat` at the moment the
14013
- * contribution is asked for.
14014
- *
14015
- * Cumulative and not a rate on purpose: a rate needs a window, a window
14016
- * needs a sampler, and a new per-node sampler is the defect half of
14017
- * `docs/architecture/load-ledger.md` documents. A counter can be differenced
14018
- * by whoever already keeps a history; a rate cannot be un-averaged.
14019
- *
14020
- * Absent — never zero — on a node with no `/proc`, on a read failure, and on
14021
- * an entry with no process.
14022
- */
14023
- cpuSeconds: number().optional(),
14024
- /** Resident bytes of this unit's process, same source and same rules. */
14025
- rssBytes: number().optional()
14026
- });
14027
- method(_void(), array(LoadContributionSchema).readonly());
14028
- /**
14029
14029
  * `login-method` — collection cap through which auth addons contribute
14030
14030
  * their pre-auth login surfaces to the login page. This is the SINGLE,
14031
14031
  * generic mechanism that supersedes the dead `auth.listProviders` reader:
@@ -18763,12 +18763,53 @@ var MediaFileKindEnum = _enum([
18763
18763
  "keyFrameSmall",
18764
18764
  "thumbnailSmall"
18765
18765
  ]);
18766
+ /**
18767
+ * One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
18768
+ * ARE — never the bytes themselves.
18769
+ *
18770
+ * ## Why `url` and not `base64`
18771
+ *
18772
+ * Measured on the live hub 2026-08-30: `getTrackMedia {trackId, deviceId}`
18773
+ * with no `kinds` returned 6 rows / **3 597 219 B**, of which `keyFrame` alone
18774
+ * was **2 824 077 B** — one full-resolution frame, base64, so +33 % on the
18775
+ * wire. Forty events is ~144 MB. Every byte of it was read off disk,
18776
+ * base64-encoded, held whole in a unary tRPC envelope, and materialised in
18777
+ * hub-main's heap on the way past — for an `<img>` that would have cached it.
18778
+ *
18779
+ * `url` points at the `event-media` data plane
18780
+ * (`/addon/<addonId>/event-media/<storedKey>`), which serves the same blob
18781
+ * with an ETag and `Cache-Control: immutable`, honours conditional GETs, can
18782
+ * render a `?variant=thumb`, and streams. The hub gate in front of it requires
18783
+ * a bearer or the session cookie (`access: 'authenticated'`), so the bytes are
18784
+ * no less protected than they were inside a `view`-level cap response — see
18785
+ * `data-plane-access.ts` for the rule and the one gap it does not close
18786
+ * (per-device scoping).
18787
+ *
18788
+ * The URL is built from the row's **stored** key, which is not always its
18789
+ * published `kind`: a track's face/plate crop is stored as `crop` under
18790
+ * `('face'|'plate', '<prefix>-<trackId>')` and published as
18791
+ * `faceCrop`/`plateCrop`. `MediaStore.getByKey` knows only the stored key.
18792
+ *
18793
+ * ## `base64` is TRANSITIONAL and is going away
18794
+ *
18795
+ * It is still populated for one reason: the deployed viewer's track-detail
18796
+ * HERO tile reads it (`use-track-media-entry.ts` → `parseMediaFiles`, which
18797
+ * REQUIRES the field), and a row without it parses as a FAILED read — the red
18798
+ * triangle — not as absence. Removing the field before that viewer ships is an
18799
+ * outage, not a cleanup. Once the viewer takes its hero bytes from `url`,
18800
+ * delete this line and the `withBytes` pass-through in
18801
+ * `analytics-query-facade.ts`; nothing else reads it.
18802
+ */
18766
18803
  var MediaFileSchema = object({
18767
18804
  key: string(),
18768
18805
  kind: MediaFileKindEnum,
18769
- base64: string(),
18770
18806
  sizeBytes: number(),
18771
18807
  timestamp: number()
18808
+ }).extend({
18809
+ /** `/addon/<addonId>/event-media/<encoded stored key>`. Always present. */
18810
+ url: string(),
18811
+ /** @deprecated Transitional — see the schema docblock. Use {@link url}. */
18812
+ base64: string()
18772
18813
  });
18773
18814
  /**
18774
18815
  * One media row WITHOUT its bytes.
@@ -18780,7 +18821,9 @@ var MediaFileSchema = object({
18780
18821
  * blocks the whole view.
18781
18822
  *
18782
18823
  * `sizeBytes` is carried because it is what lets a client decide between the
18783
- * stored blob and a `?variant=thumb` rendering without fetching either.
18824
+ * stored blob and a `?variant=thumb` rendering without fetching either, and
18825
+ * `url` because a client that had to build the plane path itself is a second
18826
+ * copy of a route — the embed, the viewer and the admin UI each grew one.
18784
18827
  */
18785
18828
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
18786
18829
  /**
@@ -19127,6 +19170,50 @@ var EventStoreFootprintSchema = object({
19127
19170
  totalBytes: number().int(),
19128
19171
  devices: array(EventStoreDeviceFootprintSchema).readonly()
19129
19172
  });
19173
+ /** Event-media footprint for one {@link MediaFileKind}. */
19174
+ var EventMediaKindFootprintSchema = object({
19175
+ kind: MediaFileKindEnum,
19176
+ /** Media rows of this kind. */
19177
+ rows: number().int(),
19178
+ /** Bytes on disk held by those rows. */
19179
+ bytes: number().int()
19180
+ });
19181
+ /**
19182
+ * The media footprint broken down by KIND — the axis a deletion decision
19183
+ * actually turns on.
19184
+ *
19185
+ * A byte total says how much there is; it cannot say what is safe to remove.
19186
+ * The deletable set (the periodic `snapshot` filmstrip, the surplus per-edge
19187
+ * motion stills) and the keep set (`firstFrame`, rolling `lastFrame`,
19188
+ * `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
19189
+ * buffers, gallery media, the CLIP `crop`) are distinguished by `kind` and by
19190
+ * nothing else, so sizing a deletion means summing per kind.
19191
+ *
19192
+ * ## Why `unaccounted*` exists
19193
+ *
19194
+ * `kinds` is enumerated from {@link MediaFileKindEnum} — the closed set the
19195
+ * writers use — and summed one kind at a time. `totalRows` / `totalBytes` come
19196
+ * from a SEPARATE unfiltered aggregate over the same rows, never from adding
19197
+ * `kinds` up. A row whose stored `kind` is not in the enum (written by a
19198
+ * retired code path, or by a version that knew a kind this one does not) would
19199
+ * otherwise vanish from the total silently, and an operator would delete
19200
+ * against a denominator smaller than the disk.
19201
+ *
19202
+ * `unaccountedRows` / `unaccountedBytes` are the difference. They are normally
19203
+ * zero; a non-zero value is a real finding and must be shown, not rounded away.
19204
+ */
19205
+ var EventMediaKindBreakdownSchema = object({
19206
+ /** Every media row in scope, from one unfiltered aggregate. */
19207
+ totalRows: number().int(),
19208
+ /** Every media byte in scope, from that same aggregate. */
19209
+ totalBytes: number().int(),
19210
+ /** Per-kind footprint, ordered by bytes descending. */
19211
+ kinds: array(EventMediaKindFootprintSchema).readonly(),
19212
+ /** `totalRows` minus the summed `kinds` rows — see the schema note. */
19213
+ unaccountedRows: number().int(),
19214
+ /** `totalBytes` minus the summed `kinds` bytes — see the schema note. */
19215
+ unaccountedBytes: number().int()
19216
+ });
19130
19217
  /** Per-kind counts returned by the event-prune / device-delete mutations. */
19131
19218
  var EventPruneCountsSchema = object({
19132
19219
  motion: number().int(),
@@ -19330,6 +19417,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19330
19417
  }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
19331
19418
  kind: "query",
19332
19419
  auth: "admin"
19420
+ }), method(object({ deviceId: number().int().optional() }), EventMediaKindBreakdownSchema, {
19421
+ kind: "query",
19422
+ auth: "admin"
19333
19423
  }), method(object({
19334
19424
  olderThanMs: number(),
19335
19425
  reason: OpsLogReasonSchema.optional()
@@ -19469,6 +19559,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19469
19559
  }), array(MediaFileSchema).readonly()), method(object({
19470
19560
  trackId: string(),
19471
19561
  deviceId: number()
19562
+ }), array(MediaFileInfoSchema).readonly()), method(object({
19563
+ eventId: string(),
19564
+ deviceId: number()
19472
19565
  }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
19473
19566
  kind: "mutation",
19474
19567
  auth: "admin"
@@ -21278,6 +21371,20 @@ method(object({
21278
21371
  error: string().optional()
21279
21372
  }), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
21280
21373
  providerId: string(),
21374
+ /**
21375
+ * The location this config is an UNSAVED edit of, when there is one.
21376
+ *
21377
+ * `listLocations` replaces every declared secret with the redaction
21378
+ * sentinel, so the edit modal's form state holds the sentinel for any
21379
+ * credential the operator did not retype — and posting that here
21380
+ * without a way to resolve it makes the provider try to authenticate
21381
+ * as `__camstack_redacted__` and report the operator's own working
21382
+ * password as wrong. Given this id, the orchestrator restores each
21383
+ * sentinel from the stored config (same rule as `upsertLocation`)
21384
+ * before dispatching. Omitted by the "Add location" wizard, where
21385
+ * every value was typed just now and nothing is stored yet.
21386
+ */
21387
+ locationId: string().optional(),
21281
21388
  config: record(string(), unknown())
21282
21389
  }), object({
21283
21390
  ok: boolean(),
@@ -24459,10 +24566,24 @@ var FaceClusterSchema = object({
24459
24566
  size: number().int(),
24460
24567
  cohesion: number()
24461
24568
  });
24569
+ /**
24570
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
24571
+ * are — never the bytes.
24572
+ *
24573
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
24574
+ * track/event contract) is still populated because a deployed viewer requires
24575
+ * the field to parse a row at all; this method has no such reader. Its ONE
24576
+ * caller is the admin UI's detail modal, which was building
24577
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
24578
+ * dialog already rendering its key FRAME from the `event-media` plane.
24579
+ *
24580
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
24581
+ * media key directly, so this needed no new plane and no new access decision.
24582
+ */
24462
24583
  var MediaFileLiteSchema$1 = object({
24463
24584
  key: string(),
24464
24585
  kind: string(),
24465
- base64: string(),
24586
+ url: string(),
24466
24587
  sizeBytes: number(),
24467
24588
  timestamp: number()
24468
24589
  });
@@ -27484,10 +27605,24 @@ var PlateInfoSchema = object({
27484
27605
  */
27485
27606
  cropUrl: string().optional()
27486
27607
  });
27608
+ /**
27609
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
27610
+ * are — never the bytes.
27611
+ *
27612
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
27613
+ * track/event contract) is still populated because a deployed viewer requires
27614
+ * the field to parse a row at all; this method has no such reader. Its ONE
27615
+ * caller is the admin UI's detail modal, which was building
27616
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
27617
+ * dialog already rendering its key FRAME from the `event-media` plane.
27618
+ *
27619
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
27620
+ * media key directly, so this needed no new plane and no new access decision.
27621
+ */
27487
27622
  var MediaFileLiteSchema = object({
27488
27623
  key: string(),
27489
27624
  kind: string(),
27490
- base64: string(),
27625
+ url: string(),
27491
27626
  sizeBytes: number(),
27492
27627
  timestamp: number()
27493
27628
  });
@@ -35288,6 +35423,12 @@ Object.freeze({
35288
35423
  addonId: null,
35289
35424
  access: "view"
35290
35425
  },
35426
+ "pipelineAnalytics.getEventMediaFootprintByKind": {
35427
+ capName: "pipeline-analytics",
35428
+ capScope: "device",
35429
+ addonId: null,
35430
+ access: "view"
35431
+ },
35291
35432
  "pipelineAnalytics.getEventStoreFootprint": {
35292
35433
  capName: "pipeline-analytics",
35293
35434
  capScope: "device",
@@ -35384,6 +35525,12 @@ Object.freeze({
35384
35525
  addonId: null,
35385
35526
  access: "view"
35386
35527
  },
35528
+ "pipelineAnalytics.listEventMedia": {
35529
+ capName: "pipeline-analytics",
35530
+ capScope: "device",
35531
+ addonId: null,
35532
+ access: "view"
35533
+ },
35387
35534
  "pipelineAnalytics.listGroups": {
35388
35535
  capName: "pipeline-analytics",
35389
35536
  capScope: "device",
@@ -38947,6 +39094,11 @@ Object.freeze({
38947
39094
  form: "single",
38948
39095
  optional: false
38949
39096
  }],
39097
+ "pipelineAnalytics.getEventMediaFootprintByKind": [{
39098
+ name: "deviceId",
39099
+ form: "single",
39100
+ optional: true
39101
+ }],
38950
39102
  "pipelineAnalytics.getGroup": [{
38951
39103
  name: "deviceId",
38952
39104
  form: "single",
@@ -39007,6 +39159,11 @@ Object.freeze({
39007
39159
  form: "array",
39008
39160
  optional: false
39009
39161
  }],
39162
+ "pipelineAnalytics.listEventMedia": [{
39163
+ name: "deviceId",
39164
+ form: "single",
39165
+ optional: false
39166
+ }],
39010
39167
  "pipelineAnalytics.listGroups": [{
39011
39168
  name: "deviceIds",
39012
39169
  form: "array",
package/dist/addon.mjs CHANGED
@@ -13260,6 +13260,114 @@ method(object({
13260
13260
  height: number()
13261
13261
  }), EmbeddingResultSchema, { auth: "admin" }), method(object({ text: string() }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" });
13262
13262
  /**
13263
+ * `failure-contribution` — the capability an addon reports its OWN losses
13264
+ * through, per camera, with the denominator attached. It stores nothing.
13265
+ *
13266
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13267
+ *
13268
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13269
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13270
+ * copied: the contributor reports what it already knows, hub-main adds only
13271
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13272
+ * somebody to forget to edit.
13273
+ *
13274
+ * They are not merged, because their invariants are opposites:
13275
+ *
13276
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13277
+ * claim a camera cost nothing, which is a measurement nobody made;
13278
+ * - a `failure-contribution` zero is the **most valuable value on the
13279
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13280
+ * and it is exactly what an absent entry cannot say.
13281
+ *
13282
+ * Putting a loss counter on a cost entry would also break the reconciliation
13283
+ * that gives `load-contribution` its point: contributions are subtracted from
13284
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13285
+ * has no process.
13286
+ *
13287
+ * ## Why not a log line, since the counters already exist
13288
+ *
13289
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13290
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13291
+ * ends in a log line, and a log line is the thing the operator asked to stop
13292
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13293
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13294
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13295
+ * media blackout were both diagnosed. The counters stay; this is where they can
13296
+ * be READ.
13297
+ *
13298
+ * ## The rate is served with its denominator or not at all
13299
+ *
13300
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13301
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13302
+ * than yesterday" and was **flat across twelve hours** once divided by the
13303
+ * successes on the same path. A surface that publishes only the numerator
13304
+ * reproduces that mistake on every read.
13305
+ *
13306
+ * ## Shape
13307
+ *
13308
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13309
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13310
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13311
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13312
+ * a forked runner's entries reach hub-main over transport that already exists.
13313
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13314
+ * result through `system.getFailureContributions`.
13315
+ */
13316
+ var FailureReasonCountSchema = object({
13317
+ /**
13318
+ * Why the attempt did not land, in the contributor's own vocabulary —
13319
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13320
+ * strings that already appear in this repo's logs and, where one exists, the
13321
+ * same string the per-track `previewMissReason` records (D276): a second
13322
+ * vocabulary for the same loss would make the row and the counter
13323
+ * un-joinable.
13324
+ */
13325
+ reason: string(),
13326
+ count: number().int().nonnegative()
13327
+ });
13328
+ var FailureContributionSchema = object({
13329
+ /**
13330
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13331
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13332
+ * `unit` free: the families are owned by different addons and a shared enum
13333
+ * is a central list that rots invisibly.
13334
+ */
13335
+ family: string(),
13336
+ /**
13337
+ * The NUMERIC device id — the same value every log line carries as
13338
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13339
+ * cannot name the camera must not emit the entry, because a fleet total
13340
+ * cannot answer the only question anybody asks of this surface.
13341
+ */
13342
+ deviceId: number().int().positive(),
13343
+ /**
13344
+ * A second dimension inside the family: the model / step id for an inference
13345
+ * timeout, so "which camera AND which model" is one read. Absent when the
13346
+ * family has a single variant.
13347
+ */
13348
+ variant: string().optional(),
13349
+ /**
13350
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13351
+ * differencing two reads must drop the interval when it changes, because the
13352
+ * counter restarted from zero in a respawned runner. Same discipline as
13353
+ * `LoadContribution.startedAtMs`.
13354
+ */
13355
+ sinceMs: number(),
13356
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13357
+ atMs: number(),
13358
+ /**
13359
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13360
+ * window. A failure count published without it is the mistake this schema
13361
+ * exists to make impossible.
13362
+ */
13363
+ attempts: number().int().nonnegative(),
13364
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13365
+ succeeded: number().int().nonnegative(),
13366
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13367
+ reasons: array(FailureReasonCountSchema).readonly()
13368
+ });
13369
+ method(_void(), array(FailureContributionSchema).readonly());
13370
+ /**
13263
13371
  * filesystem-browse — per-node capability for browsing the node's local
13264
13372
  * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
13265
13373
  * are sandboxed to operator-configured allowed roots (D115). Used by the
@@ -13781,6 +13889,68 @@ method(LlmGenerateBaseInputSchema, LlmGenerateResultSchema, { kind: "mutation" }
13781
13889
  kind: "mutation",
13782
13890
  auth: "admin"
13783
13891
  });
13892
+ var LoadContributionSchema = object({
13893
+ role: _enum([
13894
+ "decode",
13895
+ "transcode",
13896
+ "recording",
13897
+ "streaming",
13898
+ "detection"
13899
+ ]),
13900
+ /**
13901
+ * The NUMERIC device id — the same value every log line carries as
13902
+ * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13903
+ * camera (a shared pool), NOT that the contributor forgot to look it up: a
13904
+ * contributor that cannot name its camera must not emit the entry at all,
13905
+ * because an unnamed per-camera entry is indistinguishable from a shared one
13906
+ * and would quietly turn one camera's cost into everybody's.
13907
+ */
13908
+ deviceId: number().int().positive().nullable(),
13909
+ attribution: _enum([
13910
+ "measured",
13911
+ "accounted",
13912
+ "unattributable"
13913
+ ]),
13914
+ /**
13915
+ * What ONE entry is, in the contributor's own words — `615/high`,
13916
+ * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13917
+ * family and inventing a common one would lose the only information that
13918
+ * makes two entries for the same camera distinguishable.
13919
+ */
13920
+ unit: string(),
13921
+ /**
13922
+ * The OS process this cost lives in, when there is one. Present so a
13923
+ * consumer can (a) tell two generations of the same unit apart across a
13924
+ * restart, and (b) subtract claimed processes from the node's process
13925
+ * snapshot to see what NOBODY claimed. Absent for an entry that owns no
13926
+ * process of its own.
13927
+ */
13928
+ pid: number().int().positive().optional(),
13929
+ /**
13930
+ * When this generation started. The pid's incarnation marker: a consumer
13931
+ * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
13932
+ * window when this changes, because the counter restarted from zero in a new
13933
+ * process.
13934
+ */
13935
+ startedAtMs: number().optional(),
13936
+ /**
13937
+ * CUMULATIVE CPU seconds this unit has consumed since it started — user +
13938
+ * system, read from the child's own `/proc/<pid>/stat` at the moment the
13939
+ * contribution is asked for.
13940
+ *
13941
+ * Cumulative and not a rate on purpose: a rate needs a window, a window
13942
+ * needs a sampler, and a new per-node sampler is the defect half of
13943
+ * `docs/architecture/load-ledger.md` documents. A counter can be differenced
13944
+ * by whoever already keeps a history; a rate cannot be un-averaged.
13945
+ *
13946
+ * Absent — never zero — on a node with no `/proc`, on a read failure, and on
13947
+ * an entry with no process.
13948
+ */
13949
+ cpuSeconds: number().optional(),
13950
+ /** Resident bytes of this unit's process, same source and same rules. */
13951
+ rssBytes: number().optional()
13952
+ });
13953
+ method(_void(), array(LoadContributionSchema).readonly());
13784
13954
  /**
13785
13955
  * `log-channels` — the capability an addon DECLARES its diagnostic channels
13786
13956
  * through. It stores nothing.
@@ -13857,176 +14027,6 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13857
14027
  tags: record(string(), string()).optional()
13858
14028
  }), array(LogEntrySchema).readonly());
13859
14029
  /**
13860
- * `failure-contribution` — the capability an addon reports its OWN losses
13861
- * through, per camera, with the denominator attached. It stores nothing.
13862
- *
13863
- * ## The twin of `load-contribution`, and why it is a twin and not a field
13864
- *
13865
- * `load-contribution` answers *what did this camera COST*. This answers *what
13866
- * did this camera LOSE*. The reporting discipline is identical and deliberately
13867
- * copied: the contributor reports what it already knows, hub-main adds only
13868
- * `addonId`, nothing needs global knowledge, and there is no central list for
13869
- * somebody to forget to edit.
13870
- *
13871
- * They are not merged, because their invariants are opposites:
13872
- *
13873
- * - a `load-contribution` measurement is **absent, never zero** — a zero would
13874
- * claim a camera cost nothing, which is a measurement nobody made;
13875
- * - a `failure-contribution` zero is the **most valuable value on the
13876
- * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13877
- * and it is exactly what an absent entry cannot say.
13878
- *
13879
- * Putting a loss counter on a cost entry would also break the reconciliation
13880
- * that gives `load-contribution` its point: contributions are subtracted from
13881
- * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13882
- * has no process.
13883
- *
13884
- * ## Why not a log line, since the counters already exist
13885
- *
13886
- * Several of these paths already counted themselves — `CaptureScheduler`'s
13887
- * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13888
- * ends in a log line, and a log line is the thing the operator asked to stop
13889
- * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13890
- * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13891
- * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13892
- * media blackout were both diagnosed. The counters stay; this is where they can
13893
- * be READ.
13894
- *
13895
- * ## The rate is served with its denominator or not at all
13896
- *
13897
- * Every entry carries `attempts` and `succeeded`. A miss count alone is
13898
- * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13899
- * than yesterday" and was **flat across twelve hours** once divided by the
13900
- * successes on the same path. A surface that publishes only the numerator
13901
- * reproduces that mistake on every read.
13902
- *
13903
- * ## Shape
13904
- *
13905
- * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13906
- * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13907
- * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13908
- * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13909
- * a forked runner's entries reach hub-main over transport that already exists.
13910
- * No new UDS message, no second registry (D3). The operator reads the assembled
13911
- * result through `system.getFailureContributions`.
13912
- */
13913
- var FailureReasonCountSchema = object({
13914
- /**
13915
- * Why the attempt did not land, in the contributor's own vocabulary —
13916
- * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13917
- * strings that already appear in this repo's logs and, where one exists, the
13918
- * same string the per-track `previewMissReason` records (D276): a second
13919
- * vocabulary for the same loss would make the row and the counter
13920
- * un-joinable.
13921
- */
13922
- reason: string(),
13923
- count: number().int().nonnegative()
13924
- });
13925
- var FailureContributionSchema = object({
13926
- /**
13927
- * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13928
- * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13929
- * `unit` free: the families are owned by different addons and a shared enum
13930
- * is a central list that rots invisibly.
13931
- */
13932
- family: string(),
13933
- /**
13934
- * The NUMERIC device id — the same value every log line carries as
13935
- * `tags.deviceId`. Never nullable and never absent: a contributor that
13936
- * cannot name the camera must not emit the entry, because a fleet total
13937
- * cannot answer the only question anybody asks of this surface.
13938
- */
13939
- deviceId: number().int().positive(),
13940
- /**
13941
- * A second dimension inside the family: the model / step id for an inference
13942
- * timeout, so "which camera AND which model" is one read. Absent when the
13943
- * family has a single variant.
13944
- */
13945
- variant: string().optional(),
13946
- /**
13947
- * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13948
- * differencing two reads must drop the interval when it changes, because the
13949
- * counter restarted from zero in a respawned runner. Same discipline as
13950
- * `LoadContribution.startedAtMs`.
13951
- */
13952
- sinceMs: number(),
13953
- /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13954
- atMs: number(),
13955
- /**
13956
- * THE DENOMINATOR — every attempt on this path for this camera in the
13957
- * window. A failure count published without it is the mistake this schema
13958
- * exists to make impossible.
13959
- */
13960
- attempts: number().int().nonnegative(),
13961
- /** Attempts that landed. `attempts - succeeded` is the loss. */
13962
- succeeded: number().int().nonnegative(),
13963
- /** The loss, partitioned. Sums to `attempts - succeeded`. */
13964
- reasons: array(FailureReasonCountSchema).readonly()
13965
- });
13966
- method(_void(), array(FailureContributionSchema).readonly());
13967
- var LoadContributionSchema = object({
13968
- role: _enum([
13969
- "decode",
13970
- "transcode",
13971
- "recording",
13972
- "streaming",
13973
- "detection"
13974
- ]),
13975
- /**
13976
- * The NUMERIC device id — the same value every log line carries as
13977
- * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13978
- * camera (a shared pool), NOT that the contributor forgot to look it up: a
13979
- * contributor that cannot name its camera must not emit the entry at all,
13980
- * because an unnamed per-camera entry is indistinguishable from a shared one
13981
- * and would quietly turn one camera's cost into everybody's.
13982
- */
13983
- deviceId: number().int().positive().nullable(),
13984
- attribution: _enum([
13985
- "measured",
13986
- "accounted",
13987
- "unattributable"
13988
- ]),
13989
- /**
13990
- * What ONE entry is, in the contributor's own words — `615/high`,
13991
- * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13992
- * family and inventing a common one would lose the only information that
13993
- * makes two entries for the same camera distinguishable.
13994
- */
13995
- unit: string(),
13996
- /**
13997
- * The OS process this cost lives in, when there is one. Present so a
13998
- * consumer can (a) tell two generations of the same unit apart across a
13999
- * restart, and (b) subtract claimed processes from the node's process
14000
- * snapshot to see what NOBODY claimed. Absent for an entry that owns no
14001
- * process of its own.
14002
- */
14003
- pid: number().int().positive().optional(),
14004
- /**
14005
- * When this generation started. The pid's incarnation marker: a consumer
14006
- * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
14007
- * window when this changes, because the counter restarted from zero in a new
14008
- * process.
14009
- */
14010
- startedAtMs: number().optional(),
14011
- /**
14012
- * CUMULATIVE CPU seconds this unit has consumed since it started — user +
14013
- * system, read from the child's own `/proc/<pid>/stat` at the moment the
14014
- * contribution is asked for.
14015
- *
14016
- * Cumulative and not a rate on purpose: a rate needs a window, a window
14017
- * needs a sampler, and a new per-node sampler is the defect half of
14018
- * `docs/architecture/load-ledger.md` documents. A counter can be differenced
14019
- * by whoever already keeps a history; a rate cannot be un-averaged.
14020
- *
14021
- * Absent — never zero — on a node with no `/proc`, on a read failure, and on
14022
- * an entry with no process.
14023
- */
14024
- cpuSeconds: number().optional(),
14025
- /** Resident bytes of this unit's process, same source and same rules. */
14026
- rssBytes: number().optional()
14027
- });
14028
- method(_void(), array(LoadContributionSchema).readonly());
14029
- /**
14030
14030
  * `login-method` — collection cap through which auth addons contribute
14031
14031
  * their pre-auth login surfaces to the login page. This is the SINGLE,
14032
14032
  * generic mechanism that supersedes the dead `auth.listProviders` reader:
@@ -18764,12 +18764,53 @@ var MediaFileKindEnum = _enum([
18764
18764
  "keyFrameSmall",
18765
18765
  "thumbnailSmall"
18766
18766
  ]);
18767
+ /**
18768
+ * One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
18769
+ * ARE — never the bytes themselves.
18770
+ *
18771
+ * ## Why `url` and not `base64`
18772
+ *
18773
+ * Measured on the live hub 2026-08-30: `getTrackMedia {trackId, deviceId}`
18774
+ * with no `kinds` returned 6 rows / **3 597 219 B**, of which `keyFrame` alone
18775
+ * was **2 824 077 B** — one full-resolution frame, base64, so +33 % on the
18776
+ * wire. Forty events is ~144 MB. Every byte of it was read off disk,
18777
+ * base64-encoded, held whole in a unary tRPC envelope, and materialised in
18778
+ * hub-main's heap on the way past — for an `<img>` that would have cached it.
18779
+ *
18780
+ * `url` points at the `event-media` data plane
18781
+ * (`/addon/<addonId>/event-media/<storedKey>`), which serves the same blob
18782
+ * with an ETag and `Cache-Control: immutable`, honours conditional GETs, can
18783
+ * render a `?variant=thumb`, and streams. The hub gate in front of it requires
18784
+ * a bearer or the session cookie (`access: 'authenticated'`), so the bytes are
18785
+ * no less protected than they were inside a `view`-level cap response — see
18786
+ * `data-plane-access.ts` for the rule and the one gap it does not close
18787
+ * (per-device scoping).
18788
+ *
18789
+ * The URL is built from the row's **stored** key, which is not always its
18790
+ * published `kind`: a track's face/plate crop is stored as `crop` under
18791
+ * `('face'|'plate', '<prefix>-<trackId>')` and published as
18792
+ * `faceCrop`/`plateCrop`. `MediaStore.getByKey` knows only the stored key.
18793
+ *
18794
+ * ## `base64` is TRANSITIONAL and is going away
18795
+ *
18796
+ * It is still populated for one reason: the deployed viewer's track-detail
18797
+ * HERO tile reads it (`use-track-media-entry.ts` → `parseMediaFiles`, which
18798
+ * REQUIRES the field), and a row without it parses as a FAILED read — the red
18799
+ * triangle — not as absence. Removing the field before that viewer ships is an
18800
+ * outage, not a cleanup. Once the viewer takes its hero bytes from `url`,
18801
+ * delete this line and the `withBytes` pass-through in
18802
+ * `analytics-query-facade.ts`; nothing else reads it.
18803
+ */
18767
18804
  var MediaFileSchema = object({
18768
18805
  key: string(),
18769
18806
  kind: MediaFileKindEnum,
18770
- base64: string(),
18771
18807
  sizeBytes: number(),
18772
18808
  timestamp: number()
18809
+ }).extend({
18810
+ /** `/addon/<addonId>/event-media/<encoded stored key>`. Always present. */
18811
+ url: string(),
18812
+ /** @deprecated Transitional — see the schema docblock. Use {@link url}. */
18813
+ base64: string()
18773
18814
  });
18774
18815
  /**
18775
18816
  * One media row WITHOUT its bytes.
@@ -18781,7 +18822,9 @@ var MediaFileSchema = object({
18781
18822
  * blocks the whole view.
18782
18823
  *
18783
18824
  * `sizeBytes` is carried because it is what lets a client decide between the
18784
- * stored blob and a `?variant=thumb` rendering without fetching either.
18825
+ * stored blob and a `?variant=thumb` rendering without fetching either, and
18826
+ * `url` because a client that had to build the plane path itself is a second
18827
+ * copy of a route — the embed, the viewer and the admin UI each grew one.
18785
18828
  */
18786
18829
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
18787
18830
  /**
@@ -19128,6 +19171,50 @@ var EventStoreFootprintSchema = object({
19128
19171
  totalBytes: number().int(),
19129
19172
  devices: array(EventStoreDeviceFootprintSchema).readonly()
19130
19173
  });
19174
+ /** Event-media footprint for one {@link MediaFileKind}. */
19175
+ var EventMediaKindFootprintSchema = object({
19176
+ kind: MediaFileKindEnum,
19177
+ /** Media rows of this kind. */
19178
+ rows: number().int(),
19179
+ /** Bytes on disk held by those rows. */
19180
+ bytes: number().int()
19181
+ });
19182
+ /**
19183
+ * The media footprint broken down by KIND — the axis a deletion decision
19184
+ * actually turns on.
19185
+ *
19186
+ * A byte total says how much there is; it cannot say what is safe to remove.
19187
+ * The deletable set (the periodic `snapshot` filmstrip, the surplus per-edge
19188
+ * motion stills) and the keep set (`firstFrame`, rolling `lastFrame`,
19189
+ * `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
19190
+ * buffers, gallery media, the CLIP `crop`) are distinguished by `kind` and by
19191
+ * nothing else, so sizing a deletion means summing per kind.
19192
+ *
19193
+ * ## Why `unaccounted*` exists
19194
+ *
19195
+ * `kinds` is enumerated from {@link MediaFileKindEnum} — the closed set the
19196
+ * writers use — and summed one kind at a time. `totalRows` / `totalBytes` come
19197
+ * from a SEPARATE unfiltered aggregate over the same rows, never from adding
19198
+ * `kinds` up. A row whose stored `kind` is not in the enum (written by a
19199
+ * retired code path, or by a version that knew a kind this one does not) would
19200
+ * otherwise vanish from the total silently, and an operator would delete
19201
+ * against a denominator smaller than the disk.
19202
+ *
19203
+ * `unaccountedRows` / `unaccountedBytes` are the difference. They are normally
19204
+ * zero; a non-zero value is a real finding and must be shown, not rounded away.
19205
+ */
19206
+ var EventMediaKindBreakdownSchema = object({
19207
+ /** Every media row in scope, from one unfiltered aggregate. */
19208
+ totalRows: number().int(),
19209
+ /** Every media byte in scope, from that same aggregate. */
19210
+ totalBytes: number().int(),
19211
+ /** Per-kind footprint, ordered by bytes descending. */
19212
+ kinds: array(EventMediaKindFootprintSchema).readonly(),
19213
+ /** `totalRows` minus the summed `kinds` rows — see the schema note. */
19214
+ unaccountedRows: number().int(),
19215
+ /** `totalBytes` minus the summed `kinds` bytes — see the schema note. */
19216
+ unaccountedBytes: number().int()
19217
+ });
19131
19218
  /** Per-kind counts returned by the event-prune / device-delete mutations. */
19132
19219
  var EventPruneCountsSchema = object({
19133
19220
  motion: number().int(),
@@ -19331,6 +19418,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19331
19418
  }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
19332
19419
  kind: "query",
19333
19420
  auth: "admin"
19421
+ }), method(object({ deviceId: number().int().optional() }), EventMediaKindBreakdownSchema, {
19422
+ kind: "query",
19423
+ auth: "admin"
19334
19424
  }), method(object({
19335
19425
  olderThanMs: number(),
19336
19426
  reason: OpsLogReasonSchema.optional()
@@ -19470,6 +19560,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19470
19560
  }), array(MediaFileSchema).readonly()), method(object({
19471
19561
  trackId: string(),
19472
19562
  deviceId: number()
19563
+ }), array(MediaFileInfoSchema).readonly()), method(object({
19564
+ eventId: string(),
19565
+ deviceId: number()
19473
19566
  }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
19474
19567
  kind: "mutation",
19475
19568
  auth: "admin"
@@ -21279,6 +21372,20 @@ method(object({
21279
21372
  error: string().optional()
21280
21373
  }), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
21281
21374
  providerId: string(),
21375
+ /**
21376
+ * The location this config is an UNSAVED edit of, when there is one.
21377
+ *
21378
+ * `listLocations` replaces every declared secret with the redaction
21379
+ * sentinel, so the edit modal's form state holds the sentinel for any
21380
+ * credential the operator did not retype — and posting that here
21381
+ * without a way to resolve it makes the provider try to authenticate
21382
+ * as `__camstack_redacted__` and report the operator's own working
21383
+ * password as wrong. Given this id, the orchestrator restores each
21384
+ * sentinel from the stored config (same rule as `upsertLocation`)
21385
+ * before dispatching. Omitted by the "Add location" wizard, where
21386
+ * every value was typed just now and nothing is stored yet.
21387
+ */
21388
+ locationId: string().optional(),
21282
21389
  config: record(string(), unknown())
21283
21390
  }), object({
21284
21391
  ok: boolean(),
@@ -24460,10 +24567,24 @@ var FaceClusterSchema = object({
24460
24567
  size: number().int(),
24461
24568
  cohesion: number()
24462
24569
  });
24570
+ /**
24571
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
24572
+ * are — never the bytes.
24573
+ *
24574
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
24575
+ * track/event contract) is still populated because a deployed viewer requires
24576
+ * the field to parse a row at all; this method has no such reader. Its ONE
24577
+ * caller is the admin UI's detail modal, which was building
24578
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
24579
+ * dialog already rendering its key FRAME from the `event-media` plane.
24580
+ *
24581
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
24582
+ * media key directly, so this needed no new plane and no new access decision.
24583
+ */
24463
24584
  var MediaFileLiteSchema$1 = object({
24464
24585
  key: string(),
24465
24586
  kind: string(),
24466
- base64: string(),
24587
+ url: string(),
24467
24588
  sizeBytes: number(),
24468
24589
  timestamp: number()
24469
24590
  });
@@ -27485,10 +27606,24 @@ var PlateInfoSchema = object({
27485
27606
  */
27486
27607
  cropUrl: string().optional()
27487
27608
  });
27609
+ /**
27610
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
27611
+ * are — never the bytes.
27612
+ *
27613
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
27614
+ * track/event contract) is still populated because a deployed viewer requires
27615
+ * the field to parse a row at all; this method has no such reader. Its ONE
27616
+ * caller is the admin UI's detail modal, which was building
27617
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
27618
+ * dialog already rendering its key FRAME from the `event-media` plane.
27619
+ *
27620
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
27621
+ * media key directly, so this needed no new plane and no new access decision.
27622
+ */
27488
27623
  var MediaFileLiteSchema = object({
27489
27624
  key: string(),
27490
27625
  kind: string(),
27491
- base64: string(),
27626
+ url: string(),
27492
27627
  sizeBytes: number(),
27493
27628
  timestamp: number()
27494
27629
  });
@@ -35289,6 +35424,12 @@ Object.freeze({
35289
35424
  addonId: null,
35290
35425
  access: "view"
35291
35426
  },
35427
+ "pipelineAnalytics.getEventMediaFootprintByKind": {
35428
+ capName: "pipeline-analytics",
35429
+ capScope: "device",
35430
+ addonId: null,
35431
+ access: "view"
35432
+ },
35292
35433
  "pipelineAnalytics.getEventStoreFootprint": {
35293
35434
  capName: "pipeline-analytics",
35294
35435
  capScope: "device",
@@ -35385,6 +35526,12 @@ Object.freeze({
35385
35526
  addonId: null,
35386
35527
  access: "view"
35387
35528
  },
35529
+ "pipelineAnalytics.listEventMedia": {
35530
+ capName: "pipeline-analytics",
35531
+ capScope: "device",
35532
+ addonId: null,
35533
+ access: "view"
35534
+ },
35388
35535
  "pipelineAnalytics.listGroups": {
35389
35536
  capName: "pipeline-analytics",
35390
35537
  capScope: "device",
@@ -38948,6 +39095,11 @@ Object.freeze({
38948
39095
  form: "single",
38949
39096
  optional: false
38950
39097
  }],
39098
+ "pipelineAnalytics.getEventMediaFootprintByKind": [{
39099
+ name: "deviceId",
39100
+ form: "single",
39101
+ optional: true
39102
+ }],
38951
39103
  "pipelineAnalytics.getGroup": [{
38952
39104
  name: "deviceId",
38953
39105
  form: "single",
@@ -39008,6 +39160,11 @@ Object.freeze({
39008
39160
  form: "array",
39009
39161
  optional: false
39010
39162
  }],
39163
+ "pipelineAnalytics.listEventMedia": [{
39164
+ name: "deviceId",
39165
+ form: "single",
39166
+ optional: false
39167
+ }],
39011
39168
  "pipelineAnalytics.listGroups": [{
39012
39169
  name: "deviceIds",
39013
39170
  form: "array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homematic",
3
- "version": "1.2.47",
3
+ "version": "1.2.49",
4
4
  "description": "Homematic / HomematicIP (CCU3 / RaspberryMatic) device-provider addon for CamStack — wraps the nodehomematic library",
5
5
  "keywords": [
6
6
  "camstack",