@camstack/addon-provider-amcrest 0.2.38 → 0.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -5921,6 +5921,40 @@ var BaseAddon = class {
5921
5921
  deviceSettingsSchema() {
5922
5922
  return null;
5923
5923
  }
5924
+ /**
5925
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
5926
+ * ARE the configuration of its integration.
5927
+ *
5928
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
5929
+ * operator should find on the addon's integration page (System →
5930
+ * Integrations → <name>) rather than only in the cluster-wide list of every
5931
+ * addon. Empty (the default) means the addon has no integration-level
5932
+ * settings and no such surface is offered — this is opt-in, because whether
5933
+ * an addon's configuration IS its integration's configuration depends on the
5934
+ * nature of the integration.
5935
+ *
5936
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
5937
+ * the ONE global schema, in the ONE addon store, written by the ONE
5938
+ * `updateGlobalSettings` path. There is deliberately no
5939
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
5940
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
5941
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
5942
+ *
5943
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
5944
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
5945
+ * removed with the reason recorded at
5946
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
5947
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
5948
+ * marker sprinkled across sections also has to borrow a field that already
5949
+ * means something else; borrowing `section.tab` put the literal word
5950
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
5951
+ * GROUP this visually" and cannot also mean "where this lives" (D269
5952
+ * supersedes D268). One declaration, in one place, next to the schema whose
5953
+ * ids it names.
5954
+ */
5955
+ integrationSettingSections() {
5956
+ return [];
5957
+ }
5924
5958
  async getGlobalSettings(overlay, cap, nodeId) {
5925
5959
  const schema = this.globalSettingsSchema(cap);
5926
5960
  if (!schema) return { sections: [] };
@@ -5931,6 +5965,55 @@ var BaseAddon = class {
5931
5965
  } : projected);
5932
5966
  }
5933
5967
  /**
5968
+ * The integration-level view of this addon's settings: exactly the sections
5969
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
5970
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
5971
+ *
5972
+ * Returns `null` when the addon declared nothing — an addon that opts out has
5973
+ * no integration settings surface at all, rather than an empty one that reads
5974
+ * as a failed load.
5975
+ *
5976
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
5977
+ * and not in whichever UI happens to render this:
5978
+ *
5979
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
5980
+ * shown here is the same field, with the same bare key, that the addon's
5981
+ * own page shows. There is no integration-specific writer — callers save
5982
+ * through `updateGlobalSettings` — so a second store key is unreachable,
5983
+ * not merely discouraged.
5984
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
5985
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
5986
+ * such a field silently picked would be a wrong answer for the operator
5987
+ * who opened the page (D266).
5988
+ * 3. **No silent typo.** A declared id that names no section throws. The
5989
+ * alternative — skip it — turns a rename into a surface that quietly
5990
+ * empties, which looks exactly like an addon with nothing to configure.
5991
+ */
5992
+ async getIntegrationSettings(nodeId) {
5993
+ const declared = this.integrationSettingSections();
5994
+ if (declared.length === 0) return null;
5995
+ const schema = this.globalSettingsSchema();
5996
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
5997
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
5998
+ const sections = [];
5999
+ for (const id of declared) {
6000
+ const section = byId.get(id);
6001
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6002
+ const fields = dropPerNodeFields(section.fields);
6003
+ if (fields.length === 0) continue;
6004
+ sections.push({
6005
+ ...section,
6006
+ fields
6007
+ });
6008
+ }
6009
+ if (sections.length === 0) return null;
6010
+ const projected = await this.resolveGlobalStore(nodeId);
6011
+ return hydrateSchema({
6012
+ ...schema,
6013
+ sections
6014
+ }, projected);
6015
+ }
6016
+ /**
5934
6017
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
5935
6018
  * every `perNode: true` field carries THAT node's scoped value on its bare
5936
6019
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6234,6 +6317,41 @@ var BaseAddon = class {
6234
6317
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6235
6318
  * don't declare `perNode` and are excluded by the `in` narrowing.
6236
6319
  */
6320
+ /**
6321
+ * The same fields with every `perNode: true` one removed, recursing into layout
6322
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6323
+ * with no child is dropped rather than rendered empty.
6324
+ *
6325
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6326
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6327
+ */
6328
+ function dropPerNodeFields(fields) {
6329
+ const kept = [];
6330
+ for (const field of fields) {
6331
+ if (field.type === "group") {
6332
+ const inner = dropPerNodeFields(field.fields);
6333
+ if (inner.length > 0) kept.push({
6334
+ ...field,
6335
+ fields: inner
6336
+ });
6337
+ continue;
6338
+ }
6339
+ if (field.type === "sub-tabs") {
6340
+ const tabs = field.tabs.map((tab) => ({
6341
+ ...tab,
6342
+ fields: dropPerNodeFields(tab.fields)
6343
+ })).filter((tab) => tab.fields.length > 0);
6344
+ if (tabs.length > 0) kept.push({
6345
+ ...field,
6346
+ tabs
6347
+ });
6348
+ continue;
6349
+ }
6350
+ if ("perNode" in field && field.perNode === true) continue;
6351
+ kept.push(field);
6352
+ }
6353
+ return kept;
6354
+ }
6237
6355
  function collectPerNodeFieldKeys(fields) {
6238
6356
  const collected = [];
6239
6357
  for (const field of fields) {
@@ -9391,6 +9509,9 @@ method(object({
9391
9509
  kind: "mutation",
9392
9510
  auth: "admin"
9393
9511
  }), method(object({
9512
+ addonId: string(),
9513
+ nodeId: string().optional()
9514
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9394
9515
  addonId: string(),
9395
9516
  deviceId: number(),
9396
9517
  nodeId: string().optional()
@@ -13339,6 +13460,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13339
13460
  limit: number().optional(),
13340
13461
  tags: record(string(), string()).optional()
13341
13462
  }), array(LogEntrySchema).readonly());
13463
+ /**
13464
+ * `failure-contribution` — the capability an addon reports its OWN losses
13465
+ * through, per camera, with the denominator attached. It stores nothing.
13466
+ *
13467
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13468
+ *
13469
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13470
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13471
+ * copied: the contributor reports what it already knows, hub-main adds only
13472
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13473
+ * somebody to forget to edit.
13474
+ *
13475
+ * They are not merged, because their invariants are opposites:
13476
+ *
13477
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13478
+ * claim a camera cost nothing, which is a measurement nobody made;
13479
+ * - a `failure-contribution` zero is the **most valuable value on the
13480
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13481
+ * and it is exactly what an absent entry cannot say.
13482
+ *
13483
+ * Putting a loss counter on a cost entry would also break the reconciliation
13484
+ * that gives `load-contribution` its point: contributions are subtracted from
13485
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13486
+ * has no process.
13487
+ *
13488
+ * ## Why not a log line, since the counters already exist
13489
+ *
13490
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13491
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13492
+ * ends in a log line, and a log line is the thing the operator asked to stop
13493
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13494
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13495
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13496
+ * media blackout were both diagnosed. The counters stay; this is where they can
13497
+ * be READ.
13498
+ *
13499
+ * ## The rate is served with its denominator or not at all
13500
+ *
13501
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13502
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13503
+ * than yesterday" and was **flat across twelve hours** once divided by the
13504
+ * successes on the same path. A surface that publishes only the numerator
13505
+ * reproduces that mistake on every read.
13506
+ *
13507
+ * ## Shape
13508
+ *
13509
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13510
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13511
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13512
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13513
+ * a forked runner's entries reach hub-main over transport that already exists.
13514
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13515
+ * result through `system.getFailureContributions`.
13516
+ */
13517
+ var FailureReasonCountSchema = object({
13518
+ /**
13519
+ * Why the attempt did not land, in the contributor's own vocabulary —
13520
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13521
+ * strings that already appear in this repo's logs and, where one exists, the
13522
+ * same string the per-track `previewMissReason` records (D276): a second
13523
+ * vocabulary for the same loss would make the row and the counter
13524
+ * un-joinable.
13525
+ */
13526
+ reason: string(),
13527
+ count: number().int().nonnegative()
13528
+ });
13529
+ var FailureContributionSchema = object({
13530
+ /**
13531
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13532
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13533
+ * `unit` free: the families are owned by different addons and a shared enum
13534
+ * is a central list that rots invisibly.
13535
+ */
13536
+ family: string(),
13537
+ /**
13538
+ * The NUMERIC device id — the same value every log line carries as
13539
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13540
+ * cannot name the camera must not emit the entry, because a fleet total
13541
+ * cannot answer the only question anybody asks of this surface.
13542
+ */
13543
+ deviceId: number().int().positive(),
13544
+ /**
13545
+ * A second dimension inside the family: the model / step id for an inference
13546
+ * timeout, so "which camera AND which model" is one read. Absent when the
13547
+ * family has a single variant.
13548
+ */
13549
+ variant: string().optional(),
13550
+ /**
13551
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13552
+ * differencing two reads must drop the interval when it changes, because the
13553
+ * counter restarted from zero in a respawned runner. Same discipline as
13554
+ * `LoadContribution.startedAtMs`.
13555
+ */
13556
+ sinceMs: number(),
13557
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13558
+ atMs: number(),
13559
+ /**
13560
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13561
+ * window. A failure count published without it is the mistake this schema
13562
+ * exists to make impossible.
13563
+ */
13564
+ attempts: number().int().nonnegative(),
13565
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13566
+ succeeded: number().int().nonnegative(),
13567
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13568
+ reasons: array(FailureReasonCountSchema).readonly()
13569
+ });
13570
+ method(_void(), array(FailureContributionSchema).readonly());
13342
13571
  var LoadContributionSchema = object({
13343
13572
  role: _enum([
13344
13573
  "decode",
@@ -17929,6 +18158,20 @@ var TrackSchema = object({
17929
18158
  * `=== true` and render nothing otherwise — never infer "no rider".
17930
18159
  */
17931
18160
  hasRider: boolean().optional(),
18161
+ /**
18162
+ * WHY this track ended without a NATIVE best-shot tile
18163
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18164
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18165
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18166
+ * the late-keyFrame upgrade when a native tile lands after all. The
18167
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18168
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18169
+ *
18170
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18171
+ * that predates the field, and every track whose tile landed native all
18172
+ * omit it. Render nothing when absent.
18173
+ */
18174
+ previewMissReason: string().optional(),
17932
18175
  ...TrackFlagFields,
17933
18176
  ...TrackRetrainFields
17934
18177
  });
@@ -29494,6 +29737,13 @@ var LoggingSettingsPatchSchema = object({
29494
29737
  * anyone but its owner.
29495
29738
  */
29496
29739
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
29740
+ /**
29741
+ * One per-camera failure counter, plus WHO reported it.
29742
+ *
29743
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
29744
+ * the hub as it enumerates providers, never by the contributor.
29745
+ */
29746
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
29497
29747
  var GetLoggingSettingsInputSchema = object({
29498
29748
  scopeNodeId: string().optional(),
29499
29749
  /**
@@ -29552,7 +29802,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
29552
29802
  }), method(_void(), SiteLocationStatusSchema, {
29553
29803
  kind: "mutation",
29554
29804
  auth: "admin"
29555
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29805
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29556
29806
  kind: "mutation",
29557
29807
  auth: "admin"
29558
29808
  });
@@ -32080,6 +32330,12 @@ Object.freeze({
32080
32330
  addonId: null,
32081
32331
  access: "view"
32082
32332
  },
32333
+ "addonSettings.getIntegrationSettings": {
32334
+ capName: "addon-settings",
32335
+ capScope: "system",
32336
+ addonId: null,
32337
+ access: "view"
32338
+ },
32083
32339
  "addonSettings.updateDeviceSettings": {
32084
32340
  capName: "addon-settings",
32085
32341
  capScope: "system",
@@ -33742,6 +33998,12 @@ Object.freeze({
33742
33998
  addonId: null,
33743
33999
  access: "create"
33744
34000
  },
34001
+ "failureContribution.list": {
34002
+ capName: "failure-contribution",
34003
+ capScope: "system",
34004
+ addonId: null,
34005
+ access: "view"
34006
+ },
33745
34007
  "fanControl.setDirection": {
33746
34008
  capName: "fan-control",
33747
34009
  capScope: "device",
@@ -37048,6 +37310,12 @@ Object.freeze({
37048
37310
  addonId: null,
37049
37311
  access: "create"
37050
37312
  },
37313
+ "system.getFailureContributions": {
37314
+ capName: "system",
37315
+ capScope: "system",
37316
+ addonId: null,
37317
+ access: "view"
37318
+ },
37051
37319
  "system.getLoadContributions": {
37052
37320
  capName: "system",
37053
37321
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -5922,6 +5922,40 @@ var BaseAddon = class {
5922
5922
  deviceSettingsSchema() {
5923
5923
  return null;
5924
5924
  }
5925
+ /**
5926
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
5927
+ * ARE the configuration of its integration.
5928
+ *
5929
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
5930
+ * operator should find on the addon's integration page (System →
5931
+ * Integrations → <name>) rather than only in the cluster-wide list of every
5932
+ * addon. Empty (the default) means the addon has no integration-level
5933
+ * settings and no such surface is offered — this is opt-in, because whether
5934
+ * an addon's configuration IS its integration's configuration depends on the
5935
+ * nature of the integration.
5936
+ *
5937
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
5938
+ * the ONE global schema, in the ONE addon store, written by the ONE
5939
+ * `updateGlobalSettings` path. There is deliberately no
5940
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
5941
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
5942
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
5943
+ *
5944
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
5945
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
5946
+ * removed with the reason recorded at
5947
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
5948
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
5949
+ * marker sprinkled across sections also has to borrow a field that already
5950
+ * means something else; borrowing `section.tab` put the literal word
5951
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
5952
+ * GROUP this visually" and cannot also mean "where this lives" (D269
5953
+ * supersedes D268). One declaration, in one place, next to the schema whose
5954
+ * ids it names.
5955
+ */
5956
+ integrationSettingSections() {
5957
+ return [];
5958
+ }
5925
5959
  async getGlobalSettings(overlay, cap, nodeId) {
5926
5960
  const schema = this.globalSettingsSchema(cap);
5927
5961
  if (!schema) return { sections: [] };
@@ -5932,6 +5966,55 @@ var BaseAddon = class {
5932
5966
  } : projected);
5933
5967
  }
5934
5968
  /**
5969
+ * The integration-level view of this addon's settings: exactly the sections
5970
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
5971
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
5972
+ *
5973
+ * Returns `null` when the addon declared nothing — an addon that opts out has
5974
+ * no integration settings surface at all, rather than an empty one that reads
5975
+ * as a failed load.
5976
+ *
5977
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
5978
+ * and not in whichever UI happens to render this:
5979
+ *
5980
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
5981
+ * shown here is the same field, with the same bare key, that the addon's
5982
+ * own page shows. There is no integration-specific writer — callers save
5983
+ * through `updateGlobalSettings` — so a second store key is unreachable,
5984
+ * not merely discouraged.
5985
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
5986
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
5987
+ * such a field silently picked would be a wrong answer for the operator
5988
+ * who opened the page (D266).
5989
+ * 3. **No silent typo.** A declared id that names no section throws. The
5990
+ * alternative — skip it — turns a rename into a surface that quietly
5991
+ * empties, which looks exactly like an addon with nothing to configure.
5992
+ */
5993
+ async getIntegrationSettings(nodeId) {
5994
+ const declared = this.integrationSettingSections();
5995
+ if (declared.length === 0) return null;
5996
+ const schema = this.globalSettingsSchema();
5997
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
5998
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
5999
+ const sections = [];
6000
+ for (const id of declared) {
6001
+ const section = byId.get(id);
6002
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6003
+ const fields = dropPerNodeFields(section.fields);
6004
+ if (fields.length === 0) continue;
6005
+ sections.push({
6006
+ ...section,
6007
+ fields
6008
+ });
6009
+ }
6010
+ if (sections.length === 0) return null;
6011
+ const projected = await this.resolveGlobalStore(nodeId);
6012
+ return hydrateSchema({
6013
+ ...schema,
6014
+ sections
6015
+ }, projected);
6016
+ }
6017
+ /**
5935
6018
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
5936
6019
  * every `perNode: true` field carries THAT node's scoped value on its bare
5937
6020
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6235,6 +6318,41 @@ var BaseAddon = class {
6235
6318
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6236
6319
  * don't declare `perNode` and are excluded by the `in` narrowing.
6237
6320
  */
6321
+ /**
6322
+ * The same fields with every `perNode: true` one removed, recursing into layout
6323
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6324
+ * with no child is dropped rather than rendered empty.
6325
+ *
6326
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6327
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6328
+ */
6329
+ function dropPerNodeFields(fields) {
6330
+ const kept = [];
6331
+ for (const field of fields) {
6332
+ if (field.type === "group") {
6333
+ const inner = dropPerNodeFields(field.fields);
6334
+ if (inner.length > 0) kept.push({
6335
+ ...field,
6336
+ fields: inner
6337
+ });
6338
+ continue;
6339
+ }
6340
+ if (field.type === "sub-tabs") {
6341
+ const tabs = field.tabs.map((tab) => ({
6342
+ ...tab,
6343
+ fields: dropPerNodeFields(tab.fields)
6344
+ })).filter((tab) => tab.fields.length > 0);
6345
+ if (tabs.length > 0) kept.push({
6346
+ ...field,
6347
+ tabs
6348
+ });
6349
+ continue;
6350
+ }
6351
+ if ("perNode" in field && field.perNode === true) continue;
6352
+ kept.push(field);
6353
+ }
6354
+ return kept;
6355
+ }
6238
6356
  function collectPerNodeFieldKeys(fields) {
6239
6357
  const collected = [];
6240
6358
  for (const field of fields) {
@@ -9392,6 +9510,9 @@ method(object({
9392
9510
  kind: "mutation",
9393
9511
  auth: "admin"
9394
9512
  }), method(object({
9513
+ addonId: string(),
9514
+ nodeId: string().optional()
9515
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9395
9516
  addonId: string(),
9396
9517
  deviceId: number(),
9397
9518
  nodeId: string().optional()
@@ -13340,6 +13461,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13340
13461
  limit: number().optional(),
13341
13462
  tags: record(string(), string()).optional()
13342
13463
  }), array(LogEntrySchema).readonly());
13464
+ /**
13465
+ * `failure-contribution` — the capability an addon reports its OWN losses
13466
+ * through, per camera, with the denominator attached. It stores nothing.
13467
+ *
13468
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13469
+ *
13470
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13471
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13472
+ * copied: the contributor reports what it already knows, hub-main adds only
13473
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13474
+ * somebody to forget to edit.
13475
+ *
13476
+ * They are not merged, because their invariants are opposites:
13477
+ *
13478
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13479
+ * claim a camera cost nothing, which is a measurement nobody made;
13480
+ * - a `failure-contribution` zero is the **most valuable value on the
13481
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13482
+ * and it is exactly what an absent entry cannot say.
13483
+ *
13484
+ * Putting a loss counter on a cost entry would also break the reconciliation
13485
+ * that gives `load-contribution` its point: contributions are subtracted from
13486
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13487
+ * has no process.
13488
+ *
13489
+ * ## Why not a log line, since the counters already exist
13490
+ *
13491
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13492
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13493
+ * ends in a log line, and a log line is the thing the operator asked to stop
13494
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13495
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13496
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13497
+ * media blackout were both diagnosed. The counters stay; this is where they can
13498
+ * be READ.
13499
+ *
13500
+ * ## The rate is served with its denominator or not at all
13501
+ *
13502
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13503
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13504
+ * than yesterday" and was **flat across twelve hours** once divided by the
13505
+ * successes on the same path. A surface that publishes only the numerator
13506
+ * reproduces that mistake on every read.
13507
+ *
13508
+ * ## Shape
13509
+ *
13510
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13511
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13512
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13513
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13514
+ * a forked runner's entries reach hub-main over transport that already exists.
13515
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13516
+ * result through `system.getFailureContributions`.
13517
+ */
13518
+ var FailureReasonCountSchema = object({
13519
+ /**
13520
+ * Why the attempt did not land, in the contributor's own vocabulary —
13521
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13522
+ * strings that already appear in this repo's logs and, where one exists, the
13523
+ * same string the per-track `previewMissReason` records (D276): a second
13524
+ * vocabulary for the same loss would make the row and the counter
13525
+ * un-joinable.
13526
+ */
13527
+ reason: string(),
13528
+ count: number().int().nonnegative()
13529
+ });
13530
+ var FailureContributionSchema = object({
13531
+ /**
13532
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13533
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13534
+ * `unit` free: the families are owned by different addons and a shared enum
13535
+ * is a central list that rots invisibly.
13536
+ */
13537
+ family: string(),
13538
+ /**
13539
+ * The NUMERIC device id — the same value every log line carries as
13540
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13541
+ * cannot name the camera must not emit the entry, because a fleet total
13542
+ * cannot answer the only question anybody asks of this surface.
13543
+ */
13544
+ deviceId: number().int().positive(),
13545
+ /**
13546
+ * A second dimension inside the family: the model / step id for an inference
13547
+ * timeout, so "which camera AND which model" is one read. Absent when the
13548
+ * family has a single variant.
13549
+ */
13550
+ variant: string().optional(),
13551
+ /**
13552
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13553
+ * differencing two reads must drop the interval when it changes, because the
13554
+ * counter restarted from zero in a respawned runner. Same discipline as
13555
+ * `LoadContribution.startedAtMs`.
13556
+ */
13557
+ sinceMs: number(),
13558
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13559
+ atMs: number(),
13560
+ /**
13561
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13562
+ * window. A failure count published without it is the mistake this schema
13563
+ * exists to make impossible.
13564
+ */
13565
+ attempts: number().int().nonnegative(),
13566
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13567
+ succeeded: number().int().nonnegative(),
13568
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13569
+ reasons: array(FailureReasonCountSchema).readonly()
13570
+ });
13571
+ method(_void(), array(FailureContributionSchema).readonly());
13343
13572
  var LoadContributionSchema = object({
13344
13573
  role: _enum([
13345
13574
  "decode",
@@ -17930,6 +18159,20 @@ var TrackSchema = object({
17930
18159
  * `=== true` and render nothing otherwise — never infer "no rider".
17931
18160
  */
17932
18161
  hasRider: boolean().optional(),
18162
+ /**
18163
+ * WHY this track ended without a NATIVE best-shot tile
18164
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18165
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18166
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18167
+ * the late-keyFrame upgrade when a native tile lands after all. The
18168
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18169
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18170
+ *
18171
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18172
+ * that predates the field, and every track whose tile landed native all
18173
+ * omit it. Render nothing when absent.
18174
+ */
18175
+ previewMissReason: string().optional(),
17933
18176
  ...TrackFlagFields,
17934
18177
  ...TrackRetrainFields
17935
18178
  });
@@ -29495,6 +29738,13 @@ var LoggingSettingsPatchSchema = object({
29495
29738
  * anyone but its owner.
29496
29739
  */
29497
29740
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
29741
+ /**
29742
+ * One per-camera failure counter, plus WHO reported it.
29743
+ *
29744
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
29745
+ * the hub as it enumerates providers, never by the contributor.
29746
+ */
29747
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
29498
29748
  var GetLoggingSettingsInputSchema = object({
29499
29749
  scopeNodeId: string().optional(),
29500
29750
  /**
@@ -29553,7 +29803,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
29553
29803
  }), method(_void(), SiteLocationStatusSchema, {
29554
29804
  kind: "mutation",
29555
29805
  auth: "admin"
29556
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29806
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29557
29807
  kind: "mutation",
29558
29808
  auth: "admin"
29559
29809
  });
@@ -32081,6 +32331,12 @@ Object.freeze({
32081
32331
  addonId: null,
32082
32332
  access: "view"
32083
32333
  },
32334
+ "addonSettings.getIntegrationSettings": {
32335
+ capName: "addon-settings",
32336
+ capScope: "system",
32337
+ addonId: null,
32338
+ access: "view"
32339
+ },
32084
32340
  "addonSettings.updateDeviceSettings": {
32085
32341
  capName: "addon-settings",
32086
32342
  capScope: "system",
@@ -33743,6 +33999,12 @@ Object.freeze({
33743
33999
  addonId: null,
33744
34000
  access: "create"
33745
34001
  },
34002
+ "failureContribution.list": {
34003
+ capName: "failure-contribution",
34004
+ capScope: "system",
34005
+ addonId: null,
34006
+ access: "view"
34007
+ },
33746
34008
  "fanControl.setDirection": {
33747
34009
  capName: "fan-control",
33748
34010
  capScope: "device",
@@ -37049,6 +37311,12 @@ Object.freeze({
37049
37311
  addonId: null,
37050
37312
  access: "create"
37051
37313
  },
37314
+ "system.getFailureContributions": {
37315
+ capName: "system",
37316
+ capScope: "system",
37317
+ addonId: null,
37318
+ access: "view"
37319
+ },
37052
37320
  "system.getLoadContributions": {
37053
37321
  capName: "system",
37054
37322
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-amcrest",
3
- "version": "0.2.38",
3
+ "version": "0.2.40",
4
4
  "description": "Amcrest/Dahua camera device provider addon for CamStack — Dahua CGI over HTTP(S) with digest auth (snapshot, RTSP catalog, PTZ, image/day-night config)",
5
5
  "keywords": [
6
6
  "camstack",