@camstack/addon-export-alexa 1.2.138 → 1.2.139
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/export-alexa.addon.js +2101 -162
- package/dist/export-alexa.addon.mjs +2101 -162
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as crypto$1 from "node:crypto";
|
|
2
2
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import sharp from "sharp";
|
|
4
|
-
//#region ../types/dist/event-category-
|
|
4
|
+
//#region ../types/dist/event-category-DaIZi00R.mjs
|
|
5
5
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
6
6
|
EventCategory["SystemBoot"] = "system.boot";
|
|
7
7
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -80,6 +80,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
80
80
|
*/
|
|
81
81
|
EventCategory["AddonLoadFailed"] = "addon.load-failed";
|
|
82
82
|
/**
|
|
83
|
+
* A keychain value was stored or removed (D787). Carries the ref, its
|
|
84
|
+
* owner and the new version — NEVER the value. A process holding the ref
|
|
85
|
+
* drops its cached copy and the owning `BaseAddon` re-resolves its config.
|
|
86
|
+
* Telemetry (D8): a process that misses it still revalidates by version.
|
|
87
|
+
*/
|
|
88
|
+
EventCategory["KeychainChanged"] = "keychain.changed";
|
|
89
|
+
/**
|
|
83
90
|
* Addon recovered from a previous failure. Emitted when an addon
|
|
84
91
|
* transitions from `failed` back to `healthy` (typically via the
|
|
85
92
|
* monitor's auto-retry loop, or after manual `addons.retryLoad`).
|
|
@@ -5372,7 +5379,7 @@ var ZodIssueCode = {
|
|
|
5372
5379
|
var ZodFirstPartyTypeKind;
|
|
5373
5380
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5374
5381
|
//#endregion
|
|
5375
|
-
//#region ../types/dist/sleep-
|
|
5382
|
+
//#region ../types/dist/sleep-Cr5YOk3m.mjs
|
|
5376
5383
|
/**
|
|
5377
5384
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5378
5385
|
* window to float samples (D455).
|
|
@@ -5451,6 +5458,97 @@ function buildAlawTable() {
|
|
|
5451
5458
|
}
|
|
5452
5459
|
buildUlawTable();
|
|
5453
5460
|
buildAlawTable();
|
|
5461
|
+
/** Is this field's stored value a credential? */
|
|
5462
|
+
function isSecretConfigField(field) {
|
|
5463
|
+
if (field.type === "password") return true;
|
|
5464
|
+
return "secret" in field && field.secret === true;
|
|
5465
|
+
}
|
|
5466
|
+
/**
|
|
5467
|
+
* Is this field's value stored in the KEYCHAIN (D787)? Every secret field is,
|
|
5468
|
+
* by its TYPE, unless it opts out with `storage: 'inline'` — never by its name.
|
|
5469
|
+
*/
|
|
5470
|
+
function isKeychainStoredField(field) {
|
|
5471
|
+
if (!isSecretConfigField(field)) return false;
|
|
5472
|
+
if (field.type === "textarea" && field.isJson === true) return false;
|
|
5473
|
+
return !("storage" in field && field.storage === "inline");
|
|
5474
|
+
}
|
|
5475
|
+
/**
|
|
5476
|
+
* What a settings blob holds where a secret used to be.
|
|
5477
|
+
*
|
|
5478
|
+
* A keychain-stored field never holds its value. It holds a self-describing
|
|
5479
|
+
* MARKER, `{"$secret":"secret://…","v":1}` — an object, never a string, so no
|
|
5480
|
+
* code can mistake it for a credential and no form can send it to a camera as
|
|
5481
|
+
* a password. The empty state is `null` / absent, never `''`.
|
|
5482
|
+
*
|
|
5483
|
+
* The same shapes travel to and from the admin UI:
|
|
5484
|
+
*
|
|
5485
|
+
* - read: `hydrateSchema` hands a secret field a {@link SecretFieldView}
|
|
5486
|
+
* (`{ $secret, v, set: true }`) or `null` — the browser learns whether a
|
|
5487
|
+
* value is set, never the value.
|
|
5488
|
+
* - write: an untouched field submits the view back unchanged (KEEP); a typed
|
|
5489
|
+
* value is a plain non-empty string (CHANGE); the Clear button submits
|
|
5490
|
+
* {@link SECRET_CLEAR} (CLEAR). `''`, `null` and the legacy redaction tokens
|
|
5491
|
+
* are KEEP — an empty string is what a defaults-fallback pushes back, and
|
|
5492
|
+
* treating it as "clear" is how a save destroyed a stored credential before.
|
|
5493
|
+
*
|
|
5494
|
+
* Pure and browser-safe.
|
|
5495
|
+
*/
|
|
5496
|
+
var SECRET_CLEAR = { $secretClear: true };
|
|
5497
|
+
/** The `'***'` token D634 redaction writes for non-admins. */
|
|
5498
|
+
var LEGACY_DEVICE_REDACTION = "***";
|
|
5499
|
+
function isRecord$1(value) {
|
|
5500
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5501
|
+
}
|
|
5502
|
+
function makeSecretMarker(ref) {
|
|
5503
|
+
return {
|
|
5504
|
+
$secret: ref,
|
|
5505
|
+
v: 1
|
|
5506
|
+
};
|
|
5507
|
+
}
|
|
5508
|
+
/** A stored marker: an object whose `$secret` is a non-empty string. */
|
|
5509
|
+
function isSecretMarker(value) {
|
|
5510
|
+
if (!isRecord$1(value)) return false;
|
|
5511
|
+
const ref = value["$secret"];
|
|
5512
|
+
return typeof ref === "string" && ref.length > 0;
|
|
5513
|
+
}
|
|
5514
|
+
function isSecretClear(value) {
|
|
5515
|
+
return isRecord$1(value) && value["$secretClear"] === true;
|
|
5516
|
+
}
|
|
5517
|
+
/**
|
|
5518
|
+
* The UI view of a stored secret field value: a marker becomes "set", inline
|
|
5519
|
+
* plaintext becomes "set (inline)", nothing becomes `null`. A value never
|
|
5520
|
+
* survives this function.
|
|
5521
|
+
*/
|
|
5522
|
+
function toSecretFieldView(stored) {
|
|
5523
|
+
if (isSecretMarker(stored)) return {
|
|
5524
|
+
$secret: stored.$secret,
|
|
5525
|
+
v: 1,
|
|
5526
|
+
set: true
|
|
5527
|
+
};
|
|
5528
|
+
if (typeof stored === "string" && stored.length > 0) return {
|
|
5529
|
+
$secret: null,
|
|
5530
|
+
v: 1,
|
|
5531
|
+
set: true,
|
|
5532
|
+
inline: true
|
|
5533
|
+
};
|
|
5534
|
+
return null;
|
|
5535
|
+
}
|
|
5536
|
+
/**
|
|
5537
|
+
* Classify an incoming value for a secret path. Only a non-empty string that is
|
|
5538
|
+
* not a redaction token sets a value; only {@link SECRET_CLEAR} clears.
|
|
5539
|
+
* Everything else — a marker (ours or anyone's), a UI view, `''`, `null`,
|
|
5540
|
+
* `undefined`, a redaction token, a non-string — keeps what is stored.
|
|
5541
|
+
*/
|
|
5542
|
+
function classifySecretWrite(incoming) {
|
|
5543
|
+
if (isSecretClear(incoming)) return { kind: "clear" };
|
|
5544
|
+
if (typeof incoming !== "string") return { kind: "keep" };
|
|
5545
|
+
if (incoming.length === 0) return { kind: "keep" };
|
|
5546
|
+
if (incoming === "__camstack_redacted__" || incoming === LEGACY_DEVICE_REDACTION) return { kind: "keep" };
|
|
5547
|
+
return {
|
|
5548
|
+
kind: "set",
|
|
5549
|
+
value: incoming
|
|
5550
|
+
};
|
|
5551
|
+
}
|
|
5454
5552
|
Object.fromEntries([
|
|
5455
5553
|
{
|
|
5456
5554
|
id: "overview",
|
|
@@ -5622,33 +5720,19 @@ Object.fromEntries([
|
|
|
5622
5720
|
function isValuelessField(field) {
|
|
5623
5721
|
return field.type === "separator" || field.type === "info" || field.type === "qr-code" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button" || field.type === "device-action-button";
|
|
5624
5722
|
}
|
|
5625
|
-
|
|
5626
|
-
* Merge a `ConfigUISchema` with a raw `values` record into a
|
|
5627
|
-
* `ConfigUISchemaWithValues`. Used by every `get*Settings` backend endpoint
|
|
5628
|
-
* before returning to the admin UI. Groups are walked recursively.
|
|
5629
|
-
*
|
|
5630
|
-
* For each leaf field:
|
|
5631
|
-
* - structural fields (`separator`, `info`, `button`) pass through unchanged
|
|
5632
|
-
* - other fields get `value = values[key] ?? field.default ?? null`
|
|
5633
|
-
*
|
|
5634
|
-
* Unknown keys in `values` (keys not declared in the schema) are silently
|
|
5635
|
-
* ignored — they don't contribute to the output. Missing keys fall back to
|
|
5636
|
-
* the schema default or `null`. This mirrors the old backend behaviour where
|
|
5637
|
-
* `FormBuilder` rendered `values[key] ?? field.default`.
|
|
5638
|
-
*/
|
|
5639
|
-
function hydrateSchema(schema, values) {
|
|
5723
|
+
function hydrateSchema(schema, values, options = {}) {
|
|
5640
5724
|
return {
|
|
5641
5725
|
...schema.tabs ? { tabs: [...schema.tabs] } : {},
|
|
5642
5726
|
sections: schema.sections.map((section) => ({
|
|
5643
5727
|
...section,
|
|
5644
|
-
fields: section.fields.map((field) => hydrateField(field, values))
|
|
5728
|
+
fields: section.fields.map((field) => hydrateField(field, values, options))
|
|
5645
5729
|
}))
|
|
5646
5730
|
};
|
|
5647
5731
|
}
|
|
5648
|
-
function hydrateField(field, values) {
|
|
5732
|
+
function hydrateField(field, values, options) {
|
|
5649
5733
|
if (isValuelessField(field)) return field;
|
|
5650
5734
|
if (field.type === "group") {
|
|
5651
|
-
const hydratedChildren = field.fields.map((child) => hydrateField(child, values));
|
|
5735
|
+
const hydratedChildren = field.fields.map((child) => hydrateField(child, values, options));
|
|
5652
5736
|
return {
|
|
5653
5737
|
...field,
|
|
5654
5738
|
fields: hydratedChildren
|
|
@@ -5657,7 +5741,7 @@ function hydrateField(field, values) {
|
|
|
5657
5741
|
if (field.type === "sub-tabs") {
|
|
5658
5742
|
const hydratedTabs = field.tabs.map((tab) => ({
|
|
5659
5743
|
...tab,
|
|
5660
|
-
fields: tab.fields.map((child) => hydrateField(child, values))
|
|
5744
|
+
fields: tab.fields.map((child) => hydrateField(child, values, options))
|
|
5661
5745
|
}));
|
|
5662
5746
|
return {
|
|
5663
5747
|
...field,
|
|
@@ -5667,6 +5751,33 @@ function hydrateField(field, values) {
|
|
|
5667
5751
|
const key = field.key;
|
|
5668
5752
|
const storedValue = Object.prototype.hasOwnProperty.call(values, key) ? values[key] : void 0;
|
|
5669
5753
|
const defaultValue = field.default;
|
|
5754
|
+
const asView = (v) => isSecretMarker(v) || options.inlineSecretsAsViews === true && v !== void 0;
|
|
5755
|
+
if (isKeychainStoredField(field) && asView(storedValue)) return {
|
|
5756
|
+
...field,
|
|
5757
|
+
value: toSecretFieldView(storedValue)
|
|
5758
|
+
};
|
|
5759
|
+
if (isKeychainStoredField(field) && options.inlineSecretsAsViews === true) return {
|
|
5760
|
+
...field,
|
|
5761
|
+
value: null
|
|
5762
|
+
};
|
|
5763
|
+
if (field.type === "editable-array" && Array.isArray(storedValue)) {
|
|
5764
|
+
const secretItemKeys = field.itemFields.filter((item) => "key" in item && isKeychainStoredField(item)).map((item) => "key" in item ? item.key : "");
|
|
5765
|
+
if (secretItemKeys.length > 0) {
|
|
5766
|
+
const rows = storedValue.map((row) => {
|
|
5767
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) return row;
|
|
5768
|
+
const out = { ...row };
|
|
5769
|
+
for (const k of secretItemKeys) {
|
|
5770
|
+
const v = Reflect.get(row, k);
|
|
5771
|
+
if (asView(v)) out[k] = toSecretFieldView(v);
|
|
5772
|
+
}
|
|
5773
|
+
return out;
|
|
5774
|
+
});
|
|
5775
|
+
return {
|
|
5776
|
+
...field,
|
|
5777
|
+
value: rows
|
|
5778
|
+
};
|
|
5779
|
+
}
|
|
5780
|
+
}
|
|
5670
5781
|
if (field.multiple) {
|
|
5671
5782
|
const stored = Array.isArray(storedValue) ? storedValue : storedValue !== void 0 && storedValue !== null ? [storedValue] : [];
|
|
5672
5783
|
const itemFallback = field.multiple.itemDefault !== void 0 ? field.multiple.itemDefault : defaultValue !== void 0 ? defaultValue : typeOf(field) === "string" ? "" : null;
|
|
@@ -5909,6 +6020,431 @@ function projectStore(store, perNodeKeys, nodeId) {
|
|
|
5909
6020
|
}
|
|
5910
6021
|
return out;
|
|
5911
6022
|
}
|
|
6023
|
+
var GLOBAL_SCOPE = { kind: "global" };
|
|
6024
|
+
/** The scope every addon-wide (not per-node, not per-device) setting lives in. */
|
|
6025
|
+
function globalSecretScope() {
|
|
6026
|
+
return GLOBAL_SCOPE;
|
|
6027
|
+
}
|
|
6028
|
+
function encodeSegment(value) {
|
|
6029
|
+
return encodeURIComponent(value);
|
|
6030
|
+
}
|
|
6031
|
+
/**
|
|
6032
|
+
* The field path of a value inside an array item, addressed by the item's id:
|
|
6033
|
+
* `arrayItemFieldPath('providers', 'authentik', 'clientSecret')` →
|
|
6034
|
+
* `providers[authentik].clientSecret`.
|
|
6035
|
+
*/
|
|
6036
|
+
function arrayItemFieldPath(arrayKey, itemId, fieldKey) {
|
|
6037
|
+
return `${arrayKey}[${encodeSegment(itemId)}].${fieldKey}`;
|
|
6038
|
+
}
|
|
6039
|
+
/** A write to a secret path could not be stored in the keychain, so it was refused. */
|
|
6040
|
+
var KeychainWriteRefusedError = class extends Error {
|
|
6041
|
+
fieldPath;
|
|
6042
|
+
constructor(fieldPath, cause) {
|
|
6043
|
+
super(`secret "${fieldPath}" was not saved: the keychain could not store it (${cause instanceof Error ? cause.message : String(cause)}). Nothing was written.`);
|
|
6044
|
+
this.fieldPath = fieldPath;
|
|
6045
|
+
this.name = "KeychainWriteRefusedError";
|
|
6046
|
+
}
|
|
6047
|
+
};
|
|
6048
|
+
var KEEP = { kind: "keep" };
|
|
6049
|
+
function isRecord(value) {
|
|
6050
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6051
|
+
}
|
|
6052
|
+
function defaultMintId() {
|
|
6053
|
+
return globalThis.crypto.randomUUID();
|
|
6054
|
+
}
|
|
6055
|
+
/** Every marker ref anywhere under `value`. */
|
|
6056
|
+
function collectSecretMarkerRefs(value, out = []) {
|
|
6057
|
+
if (isSecretMarker(value)) {
|
|
6058
|
+
out.push(value.$secret);
|
|
6059
|
+
return out;
|
|
6060
|
+
}
|
|
6061
|
+
if (Array.isArray(value)) for (const item of value) collectSecretMarkerRefs(item, out);
|
|
6062
|
+
else if (isRecord(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
|
|
6063
|
+
return out;
|
|
6064
|
+
}
|
|
6065
|
+
async function storedValueEquals(secrets, marker, candidate) {
|
|
6066
|
+
const resolved = (await secrets.resolve([marker.$secret]))[marker.$secret];
|
|
6067
|
+
return resolved?.kind === "value" && resolved.value === candidate;
|
|
6068
|
+
}
|
|
6069
|
+
async function writeLeaf(ctx, incoming, stored, fieldPath) {
|
|
6070
|
+
const intent = classifySecretWrite(incoming);
|
|
6071
|
+
const ref = ctx.secrets.ref(ctx.scope, fieldPath);
|
|
6072
|
+
const storedIsOurs = isSecretMarker(stored) && stored.$secret === ref;
|
|
6073
|
+
if (intent.kind === "keep") return KEEP;
|
|
6074
|
+
if (intent.kind === "set" && !ctx.operatorWrite && typeof stored === "string" && stored === intent.value) return KEEP;
|
|
6075
|
+
if (intent.kind === "clear") {
|
|
6076
|
+
if (storedIsOurs) ctx.deletes.push(ref);
|
|
6077
|
+
return {
|
|
6078
|
+
kind: "value",
|
|
6079
|
+
value: null
|
|
6080
|
+
};
|
|
6081
|
+
}
|
|
6082
|
+
if (storedIsOurs && await storedValueEquals(ctx.secrets, stored, intent.value)) return {
|
|
6083
|
+
kind: "value",
|
|
6084
|
+
value: stored
|
|
6085
|
+
};
|
|
6086
|
+
try {
|
|
6087
|
+
await ctx.secrets.put(ref, intent.value);
|
|
6088
|
+
} catch (err) {
|
|
6089
|
+
throw new KeychainWriteRefusedError(fieldPath, err);
|
|
6090
|
+
}
|
|
6091
|
+
return {
|
|
6092
|
+
kind: "value",
|
|
6093
|
+
value: makeSecretMarker(ref)
|
|
6094
|
+
};
|
|
6095
|
+
}
|
|
6096
|
+
/** Apply a member result onto `out`: KEEP restores the stored member, or removes it. */
|
|
6097
|
+
function applyMember(out, key, result, storedParent) {
|
|
6098
|
+
if (result.kind === "value") {
|
|
6099
|
+
out[key] = result.value;
|
|
6100
|
+
return;
|
|
6101
|
+
}
|
|
6102
|
+
if (Object.prototype.hasOwnProperty.call(storedParent, key)) out[key] = storedParent[key];
|
|
6103
|
+
else delete out[key];
|
|
6104
|
+
}
|
|
6105
|
+
async function writeMembers(ctx, children, incoming, stored, pathOf) {
|
|
6106
|
+
const out = { ...incoming };
|
|
6107
|
+
for (const [member, node] of children) applyMember(out, member, await writeNode(ctx, node, incoming[member], stored[member], pathOf(member)), stored);
|
|
6108
|
+
return out;
|
|
6109
|
+
}
|
|
6110
|
+
function rowId(row, idKey) {
|
|
6111
|
+
const raw = row[idKey];
|
|
6112
|
+
if (typeof raw === "string" && raw.length > 0) return raw;
|
|
6113
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return String(raw);
|
|
6114
|
+
return null;
|
|
6115
|
+
}
|
|
6116
|
+
async function writeArray(ctx, node, incoming, stored, fieldPath) {
|
|
6117
|
+
const storedRows = Array.isArray(stored) ? stored.filter(isRecord) : [];
|
|
6118
|
+
if (!Array.isArray(incoming)) {
|
|
6119
|
+
ctx.deletes.push(...collectSecretMarkerRefs(storedRows));
|
|
6120
|
+
return {
|
|
6121
|
+
kind: "value",
|
|
6122
|
+
value: incoming
|
|
6123
|
+
};
|
|
6124
|
+
}
|
|
6125
|
+
const storedById = /* @__PURE__ */ new Map();
|
|
6126
|
+
for (const row of storedRows) {
|
|
6127
|
+
const id = rowId(row, node.idKey);
|
|
6128
|
+
if (id !== null) storedById.set(id, row);
|
|
6129
|
+
}
|
|
6130
|
+
const kept = /* @__PURE__ */ new Set();
|
|
6131
|
+
const rows = [];
|
|
6132
|
+
for (const row of incoming) {
|
|
6133
|
+
if (!isRecord(row)) {
|
|
6134
|
+
rows.push(row);
|
|
6135
|
+
continue;
|
|
6136
|
+
}
|
|
6137
|
+
const id = rowId(row, node.idKey) ?? ctx.mintId();
|
|
6138
|
+
kept.add(id);
|
|
6139
|
+
const withId = {
|
|
6140
|
+
...row,
|
|
6141
|
+
[node.idKey]: row[node.idKey] ?? id
|
|
6142
|
+
};
|
|
6143
|
+
if (rowId(withId, node.idKey) === null) withId[node.idKey] = id;
|
|
6144
|
+
const storedRow = storedById.get(id) ?? {};
|
|
6145
|
+
rows.push(await writeMembers(ctx, node.item, withId, storedRow, (member) => arrayItemFieldPath(fieldPath, id, member)));
|
|
6146
|
+
}
|
|
6147
|
+
for (const [id, row] of storedById) if (!kept.has(id)) ctx.deletes.push(...collectSecretMarkerRefs(row));
|
|
6148
|
+
return {
|
|
6149
|
+
kind: "value",
|
|
6150
|
+
value: rows
|
|
6151
|
+
};
|
|
6152
|
+
}
|
|
6153
|
+
async function writeNode(ctx, node, incoming, stored, fieldPath) {
|
|
6154
|
+
switch (node.kind) {
|
|
6155
|
+
case "leaf": return writeLeaf(ctx, incoming, stored, fieldPath);
|
|
6156
|
+
case "array": return writeArray(ctx, node, incoming, stored, fieldPath);
|
|
6157
|
+
case "object": {
|
|
6158
|
+
if (!isRecord(incoming)) {
|
|
6159
|
+
if (incoming === void 0) return KEEP;
|
|
6160
|
+
ctx.deletes.push(...collectSecretMarkerRefs(stored));
|
|
6161
|
+
return {
|
|
6162
|
+
kind: "value",
|
|
6163
|
+
value: incoming
|
|
6164
|
+
};
|
|
6165
|
+
}
|
|
6166
|
+
const storedObj = isRecord(stored) ? stored : {};
|
|
6167
|
+
return {
|
|
6168
|
+
kind: "value",
|
|
6169
|
+
value: await writeMembers(ctx, node.children, incoming, storedObj, (m) => `${fieldPath}.${m}`)
|
|
6170
|
+
};
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
/** Split a perNode-scoped key (`key@nodeId`). */
|
|
6175
|
+
function splitNodeKey(key) {
|
|
6176
|
+
const at = key.indexOf("@");
|
|
6177
|
+
return at <= 0 ? {
|
|
6178
|
+
base: key,
|
|
6179
|
+
nodeId: null
|
|
6180
|
+
} : {
|
|
6181
|
+
base: key.slice(0, at),
|
|
6182
|
+
nodeId: key.slice(at + 1)
|
|
6183
|
+
};
|
|
6184
|
+
}
|
|
6185
|
+
/** Rewrite a settings patch so no secret value reaches the blob. See the file doc. */
|
|
6186
|
+
async function transformSecretWrite(input) {
|
|
6187
|
+
const deletes = [];
|
|
6188
|
+
const base = {
|
|
6189
|
+
secrets: input.secrets,
|
|
6190
|
+
mintId: input.mintId ?? defaultMintId,
|
|
6191
|
+
deletes,
|
|
6192
|
+
operatorWrite: input.operatorWrite === true
|
|
6193
|
+
};
|
|
6194
|
+
const out = {};
|
|
6195
|
+
for (const [key, incoming] of Object.entries(input.patch)) {
|
|
6196
|
+
const { base: baseKey, nodeId } = splitNodeKey(key);
|
|
6197
|
+
const node = input.tree.get(baseKey);
|
|
6198
|
+
if (node === void 0) {
|
|
6199
|
+
out[key] = incoming;
|
|
6200
|
+
continue;
|
|
6201
|
+
}
|
|
6202
|
+
const scope = nodeId !== null && nodeId.length > 0 ? {
|
|
6203
|
+
kind: "node",
|
|
6204
|
+
nodeId
|
|
6205
|
+
} : input.scope ?? globalSecretScope();
|
|
6206
|
+
const result = await writeNode({
|
|
6207
|
+
...base,
|
|
6208
|
+
scope
|
|
6209
|
+
}, node, incoming, input.stored[key], baseKey);
|
|
6210
|
+
if (result.kind === "value") out[key] = result.value;
|
|
6211
|
+
}
|
|
6212
|
+
return {
|
|
6213
|
+
patch: out,
|
|
6214
|
+
afterWrite: async () => {
|
|
6215
|
+
if (deletes.length === 0) return { orphaned: [] };
|
|
6216
|
+
try {
|
|
6217
|
+
await input.secrets.delete([...new Set(deletes)]);
|
|
6218
|
+
return { orphaned: [] };
|
|
6219
|
+
} catch {
|
|
6220
|
+
return { orphaned: [...new Set(deletes)] };
|
|
6221
|
+
}
|
|
6222
|
+
}
|
|
6223
|
+
};
|
|
6224
|
+
}
|
|
6225
|
+
/**
|
|
6226
|
+
* Replace every marker in `value` with its keychain value. Returns the SAME
|
|
6227
|
+
* reference when there is no marker (no allocation, no keychain call).
|
|
6228
|
+
*/
|
|
6229
|
+
async function resolveSecretMarkers(value, secrets) {
|
|
6230
|
+
const refs = collectSecretMarkerRefs(value);
|
|
6231
|
+
if (refs.length === 0) return {
|
|
6232
|
+
value,
|
|
6233
|
+
unresolved: []
|
|
6234
|
+
};
|
|
6235
|
+
const answers = await secrets.resolve(refs);
|
|
6236
|
+
const unresolved = [];
|
|
6237
|
+
for (const ref of new Set(refs)) {
|
|
6238
|
+
const answer = answers[ref];
|
|
6239
|
+
if (answer === void 0) unresolved.push({
|
|
6240
|
+
ref,
|
|
6241
|
+
reason: "not-initialized"
|
|
6242
|
+
});
|
|
6243
|
+
else if (answer.kind === "unavailable") unresolved.push({
|
|
6244
|
+
ref,
|
|
6245
|
+
reason: answer.reason
|
|
6246
|
+
});
|
|
6247
|
+
else if (answer.kind === "missing" || answer.kind === "forbidden") unresolved.push({
|
|
6248
|
+
ref,
|
|
6249
|
+
reason: answer.kind
|
|
6250
|
+
});
|
|
6251
|
+
}
|
|
6252
|
+
const substitute = (v) => {
|
|
6253
|
+
if (isSecretMarker(v)) {
|
|
6254
|
+
const answer = answers[v.$secret];
|
|
6255
|
+
return answer?.kind === "value" ? {
|
|
6256
|
+
keep: true,
|
|
6257
|
+
value: answer.value
|
|
6258
|
+
} : {
|
|
6259
|
+
keep: false,
|
|
6260
|
+
value: void 0
|
|
6261
|
+
};
|
|
6262
|
+
}
|
|
6263
|
+
if (Array.isArray(v)) {
|
|
6264
|
+
const items = [];
|
|
6265
|
+
for (const item of v) {
|
|
6266
|
+
const s = substitute(item);
|
|
6267
|
+
if (s.keep) items.push(s.value);
|
|
6268
|
+
}
|
|
6269
|
+
return {
|
|
6270
|
+
keep: true,
|
|
6271
|
+
value: items
|
|
6272
|
+
};
|
|
6273
|
+
}
|
|
6274
|
+
if (isRecord(v)) {
|
|
6275
|
+
const out = {};
|
|
6276
|
+
for (const [k, child] of Object.entries(v)) {
|
|
6277
|
+
const s = substitute(child);
|
|
6278
|
+
if (s.keep) out[k] = s.value;
|
|
6279
|
+
}
|
|
6280
|
+
return {
|
|
6281
|
+
keep: true,
|
|
6282
|
+
value: out
|
|
6283
|
+
};
|
|
6284
|
+
}
|
|
6285
|
+
return {
|
|
6286
|
+
keep: true,
|
|
6287
|
+
value: v
|
|
6288
|
+
};
|
|
6289
|
+
};
|
|
6290
|
+
return {
|
|
6291
|
+
value: substitute(value).value,
|
|
6292
|
+
unresolved
|
|
6293
|
+
};
|
|
6294
|
+
}
|
|
6295
|
+
function touchesTree(tree, patch) {
|
|
6296
|
+
if (tree.size === 0) return false;
|
|
6297
|
+
for (const key of Object.keys(patch)) {
|
|
6298
|
+
const at = key.indexOf("@");
|
|
6299
|
+
if (tree.has(at > 0 ? key.slice(0, at) : key)) return true;
|
|
6300
|
+
}
|
|
6301
|
+
return false;
|
|
6302
|
+
}
|
|
6303
|
+
/** The blob as stored now; a failed read REFUSES the write instead of guessing. */
|
|
6304
|
+
async function readStoredAddonBlob(raw) {
|
|
6305
|
+
const threeWay = raw.readAddonStoreResult;
|
|
6306
|
+
if (threeWay === void 0) return raw.readAddonStore();
|
|
6307
|
+
const result = await threeWay.call(raw);
|
|
6308
|
+
if (result.kind === "failed") throw new Error(`settings write refused: the stored blob could not be read (${result.error})`);
|
|
6309
|
+
return result.kind === "missing" ? {} : result.value;
|
|
6310
|
+
}
|
|
6311
|
+
function createSecretAwareSettings(raw, opts) {
|
|
6312
|
+
const chains = /* @__PURE__ */ new Map();
|
|
6313
|
+
const serialize = (key, fn) => {
|
|
6314
|
+
const next = (chains.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
6315
|
+
const tail = next.catch(() => void 0);
|
|
6316
|
+
chains.set(key, tail);
|
|
6317
|
+
tail.then(() => {
|
|
6318
|
+
if (chains.get(key) === tail) chains.delete(key);
|
|
6319
|
+
});
|
|
6320
|
+
return next;
|
|
6321
|
+
};
|
|
6322
|
+
const write = async (chainKey, tree, scope, patch, readStored, rawWrite) => {
|
|
6323
|
+
if (!touchesTree(tree, patch)) return rawWrite(patch);
|
|
6324
|
+
return serialize(chainKey, async () => {
|
|
6325
|
+
const stored = await readStored();
|
|
6326
|
+
const result = await transformSecretWrite({
|
|
6327
|
+
tree,
|
|
6328
|
+
secrets: opts.secrets,
|
|
6329
|
+
patch,
|
|
6330
|
+
stored,
|
|
6331
|
+
...scope !== void 0 ? { scope } : {},
|
|
6332
|
+
operatorWrite: opts.isOperatorWrite?.() === true
|
|
6333
|
+
});
|
|
6334
|
+
if (Object.keys(result.patch).length > 0) await rawWrite(result.patch);
|
|
6335
|
+
const { orphaned } = await result.afterWrite();
|
|
6336
|
+
if (orphaned.length > 0) opts.onOrphaned(orphaned);
|
|
6337
|
+
});
|
|
6338
|
+
};
|
|
6339
|
+
const wrapped = {
|
|
6340
|
+
writeAddonStore: (patch) => write("addon", opts.addonTree(), void 0, patch, () => readStoredAddonBlob(raw), (p) => raw.writeAddonStore(p)),
|
|
6341
|
+
writeDeviceStore: (deviceId, patch) => write(`device:${deviceId}`, opts.deviceTree(), {
|
|
6342
|
+
kind: "device",
|
|
6343
|
+
deviceId
|
|
6344
|
+
}, patch, () => raw.readDeviceStore(deviceId), (p) => raw.writeDeviceStore(deviceId, p))
|
|
6345
|
+
};
|
|
6346
|
+
return new Proxy(raw, { get(target, prop, receiver) {
|
|
6347
|
+
if (prop === "writeAddonStore") return wrapped.writeAddonStore;
|
|
6348
|
+
if (prop === "writeDeviceStore") return wrapped.writeDeviceStore;
|
|
6349
|
+
const value = Reflect.get(target, prop, receiver);
|
|
6350
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
6351
|
+
} });
|
|
6352
|
+
}
|
|
6353
|
+
/** The context the addon sees: identical, except that its settings writes are secret-aware. */
|
|
6354
|
+
function withSecretAwareSettings(context, opts) {
|
|
6355
|
+
const raw = context.settings;
|
|
6356
|
+
if (raw === void 0) return context;
|
|
6357
|
+
const settings = createSecretAwareSettings(raw, opts);
|
|
6358
|
+
return new Proxy(context, { get(target, prop, receiver) {
|
|
6359
|
+
if (prop === "settings") return settings;
|
|
6360
|
+
return Reflect.get(target, prop, receiver);
|
|
6361
|
+
} });
|
|
6362
|
+
}
|
|
6363
|
+
var LEAF = { kind: "leaf" };
|
|
6364
|
+
var DEFAULT_ITEM_ID_KEY = "id";
|
|
6365
|
+
function collectFields(fields, out) {
|
|
6366
|
+
for (const field of fields) {
|
|
6367
|
+
if (field.type === "group") {
|
|
6368
|
+
collectFields(field.fields, out);
|
|
6369
|
+
continue;
|
|
6370
|
+
}
|
|
6371
|
+
if (field.type === "sub-tabs") {
|
|
6372
|
+
for (const tab of field.tabs) collectFields(tab.fields, out);
|
|
6373
|
+
continue;
|
|
6374
|
+
}
|
|
6375
|
+
if (field.type === "editable-array") {
|
|
6376
|
+
const item = /* @__PURE__ */ new Map();
|
|
6377
|
+
for (const itemField of field.itemFields) if ("key" in itemField && isKeychainStoredField(itemField)) item.set(itemField.key, LEAF);
|
|
6378
|
+
if (item.size > 0) out.set(field.key, {
|
|
6379
|
+
kind: "array",
|
|
6380
|
+
idKey: field.itemIdKey ?? DEFAULT_ITEM_ID_KEY,
|
|
6381
|
+
item
|
|
6382
|
+
});
|
|
6383
|
+
continue;
|
|
6384
|
+
}
|
|
6385
|
+
if (!("key" in field) || typeof field.key !== "string") continue;
|
|
6386
|
+
if (isKeychainStoredField(field)) out.set(field.key, LEAF);
|
|
6387
|
+
}
|
|
6388
|
+
}
|
|
6389
|
+
/** The secret tree a settings schema declares. */
|
|
6390
|
+
function secretTreeFromSchema(schema) {
|
|
6391
|
+
const out = /* @__PURE__ */ new Map();
|
|
6392
|
+
if (schema === null) return out;
|
|
6393
|
+
for (const section of schema.sections) collectFields(section.fields, out);
|
|
6394
|
+
return out;
|
|
6395
|
+
}
|
|
6396
|
+
/** Parse `a.b[id].c` into segments; throws on a malformed path (a declaration bug). */
|
|
6397
|
+
function parseSecretStatePath(path) {
|
|
6398
|
+
const segments = [];
|
|
6399
|
+
for (const raw of path.split(".")) {
|
|
6400
|
+
const m = /^([^[\]]+)(?:\[([^[\]]+)\])?$/.exec(raw);
|
|
6401
|
+
if (m === null || m[1] === void 0) throw new Error(`secretStatePaths: malformed path "${path}"`);
|
|
6402
|
+
segments.push(m[2] !== void 0 ? {
|
|
6403
|
+
key: m[1],
|
|
6404
|
+
idKey: m[2]
|
|
6405
|
+
} : { key: m[1] });
|
|
6406
|
+
}
|
|
6407
|
+
if (segments.length === 0) throw new Error(`secretStatePaths: empty path`);
|
|
6408
|
+
if (segments[segments.length - 1]?.idKey !== void 0) throw new Error(`secretStatePaths: "${path}" ends on an array — name the member that is secret`);
|
|
6409
|
+
return segments;
|
|
6410
|
+
}
|
|
6411
|
+
function insert(into, segments) {
|
|
6412
|
+
const [head, ...rest] = segments;
|
|
6413
|
+
if (head === void 0) return;
|
|
6414
|
+
if (rest.length === 0) {
|
|
6415
|
+
into.set(head.key, LEAF);
|
|
6416
|
+
return;
|
|
6417
|
+
}
|
|
6418
|
+
const existing = into.get(head.key);
|
|
6419
|
+
if (head.idKey !== void 0) {
|
|
6420
|
+
const item = new Map(existing?.kind === "array" ? existing.item : []);
|
|
6421
|
+
insert(item, rest);
|
|
6422
|
+
into.set(head.key, {
|
|
6423
|
+
kind: "array",
|
|
6424
|
+
idKey: head.idKey,
|
|
6425
|
+
item
|
|
6426
|
+
});
|
|
6427
|
+
return;
|
|
6428
|
+
}
|
|
6429
|
+
const children = new Map(existing?.kind === "object" ? existing.children : []);
|
|
6430
|
+
insert(children, rest);
|
|
6431
|
+
into.set(head.key, {
|
|
6432
|
+
kind: "object",
|
|
6433
|
+
children
|
|
6434
|
+
});
|
|
6435
|
+
}
|
|
6436
|
+
/** Add hidden-state paths (`secretStatePaths`) to a schema-derived tree. */
|
|
6437
|
+
function mergeSecretStatePaths(tree, paths) {
|
|
6438
|
+
if (paths.length === 0) return tree;
|
|
6439
|
+
const out = new Map(tree);
|
|
6440
|
+
for (const path of paths) insert(out, parseSecretStatePath(path));
|
|
6441
|
+
return out;
|
|
6442
|
+
}
|
|
6443
|
+
/** First retry of a secret the keychain could not serve; doubles to {@link SECRET_RETRY_MAX_MS}. */
|
|
6444
|
+
var SECRET_RETRY_FIRST_MS = 5e3;
|
|
6445
|
+
var SECRET_RETRY_MAX_MS = 5 * 6e4;
|
|
6446
|
+
/** Coalesces a burst of `keychain.changed` events into one re-resolve. */
|
|
6447
|
+
var SECRET_CHANGE_DEBOUNCE_MS = 250;
|
|
5912
6448
|
/**
|
|
5913
6449
|
* Base class for CamStack addons. Eliminates settings boilerplate:
|
|
5914
6450
|
*
|
|
@@ -5978,6 +6514,25 @@ var BaseAddon = class {
|
|
|
5978
6514
|
settingsStoreReady = false;
|
|
5979
6515
|
/** Default config values. Provided via constructor. */
|
|
5980
6516
|
defaults;
|
|
6517
|
+
/**
|
|
6518
|
+
* Hidden-state paths that hold secrets (D787) — values the addon writes
|
|
6519
|
+
* itself (`updateGlobalSettings`, `state()`) that no form field declares.
|
|
6520
|
+
* Stored in the keychain exactly like a `password` field. Syntax:
|
|
6521
|
+
* `'snapshotSigningKey'`, `'identity.pincode'`, `'tokens[userId].accessToken'`
|
|
6522
|
+
* (`key[<id field>]` addresses array items by id, never by index).
|
|
6523
|
+
*/
|
|
6524
|
+
secretStatePaths = [];
|
|
6525
|
+
_addonSecretTree = null;
|
|
6526
|
+
_deviceSecretTree = null;
|
|
6527
|
+
/** Refs the last resolved config pointed at — a change to one re-resolves. */
|
|
6528
|
+
_configSecretRefs = /* @__PURE__ */ new Set();
|
|
6529
|
+
_unresolvedSecrets = [];
|
|
6530
|
+
_secretRetryTimer = null;
|
|
6531
|
+
_secretRetryDelayMs = SECRET_RETRY_FIRST_MS;
|
|
6532
|
+
_secretChangeTimer = null;
|
|
6533
|
+
_secretChangeUnsub = null;
|
|
6534
|
+
/** >0 while an OPERATOR's settings write is being applied. */
|
|
6535
|
+
_operatorWriteDepth = 0;
|
|
5981
6536
|
constructor(defaults) {
|
|
5982
6537
|
this.defaults = defaults;
|
|
5983
6538
|
this._config = { ...defaults };
|
|
@@ -6013,8 +6568,18 @@ var BaseAddon = class {
|
|
|
6013
6568
|
return this._config;
|
|
6014
6569
|
}
|
|
6015
6570
|
async initialize(context) {
|
|
6016
|
-
|
|
6571
|
+
const secrets = context.secrets;
|
|
6572
|
+
this._ctx = secrets !== void 0 ? withSecretAwareSettings(context, {
|
|
6573
|
+
secrets,
|
|
6574
|
+
addonTree: () => this.addonSecretTree(),
|
|
6575
|
+
deviceTree: () => this.deviceSecretTree(),
|
|
6576
|
+
onOrphaned: (refs) => context.logger.warn("keychain refs dropped by a settings write could not be deleted", { meta: { refs: [...refs] } }),
|
|
6577
|
+
isOperatorWrite: () => this._operatorWriteDepth > 0
|
|
6578
|
+
}) : context;
|
|
6017
6579
|
await this.resolveConfig();
|
|
6580
|
+
if (secrets !== void 0) this._secretChangeUnsub = secrets.onChange((ref) => {
|
|
6581
|
+
if (this._configSecretRefs.has(ref)) this.scheduleSecretReresolve(SECRET_CHANGE_DEBOUNCE_MS);
|
|
6582
|
+
});
|
|
6018
6583
|
const result = await this.onInitialize();
|
|
6019
6584
|
this.emitLifecycle(EventCategory.AddonStarted);
|
|
6020
6585
|
const normalized = normalizeAddonInitResult(result);
|
|
@@ -6047,6 +6612,12 @@ var BaseAddon = class {
|
|
|
6047
6612
|
*/
|
|
6048
6613
|
async onHubReachable() {}
|
|
6049
6614
|
async shutdown() {
|
|
6615
|
+
this._secretChangeUnsub?.();
|
|
6616
|
+
this._secretChangeUnsub = null;
|
|
6617
|
+
if (this._secretRetryTimer !== null) clearTimeout(this._secretRetryTimer);
|
|
6618
|
+
if (this._secretChangeTimer !== null) clearTimeout(this._secretChangeTimer);
|
|
6619
|
+
this._secretRetryTimer = null;
|
|
6620
|
+
this._secretChangeTimer = null;
|
|
6050
6621
|
this.emitLifecycle(EventCategory.AddonStopped);
|
|
6051
6622
|
if (this.autoEmitReadiness) this.emitReadinessForProviders("down");
|
|
6052
6623
|
await this.onShutdown();
|
|
@@ -6125,7 +6696,7 @@ var BaseAddon = class {
|
|
|
6125
6696
|
return hydrateSchema(schema, overlay ? {
|
|
6126
6697
|
...projected,
|
|
6127
6698
|
...overlay
|
|
6128
|
-
} : projected);
|
|
6699
|
+
} : projected, { inlineSecretsAsViews: true });
|
|
6129
6700
|
}
|
|
6130
6701
|
/**
|
|
6131
6702
|
* The integration-level view of this addon's settings: exactly the sections
|
|
@@ -6174,7 +6745,7 @@ var BaseAddon = class {
|
|
|
6174
6745
|
return hydrateSchema({
|
|
6175
6746
|
...schema,
|
|
6176
6747
|
sections
|
|
6177
|
-
}, projected);
|
|
6748
|
+
}, projected, { inlineSecretsAsViews: true });
|
|
6178
6749
|
}
|
|
6179
6750
|
/**
|
|
6180
6751
|
* The raw addon store PROJECTED onto the target node's bare per-node keys:
|
|
@@ -6228,18 +6799,50 @@ var BaseAddon = class {
|
|
|
6228
6799
|
value: result.value[key]
|
|
6229
6800
|
};
|
|
6230
6801
|
}
|
|
6802
|
+
/**
|
|
6803
|
+
* The OPERATOR's write — a settings form or an admin API call, dispatched by
|
|
6804
|
+
* the kernel. Identical to {@link updateGlobalSettings}, except that a secret
|
|
6805
|
+
* the operator sends is stored as sent even when it equals a pre-keychain
|
|
6806
|
+
* inline value: the operator re-entering a password is a change, an addon
|
|
6807
|
+
* writing back what it read is not (D787).
|
|
6808
|
+
*/
|
|
6809
|
+
async updateGlobalSettingsAsOperator(patch, nodeId) {
|
|
6810
|
+
this._operatorWriteDepth++;
|
|
6811
|
+
try {
|
|
6812
|
+
await this.applyGlobalPatch(patch, nodeId);
|
|
6813
|
+
} finally {
|
|
6814
|
+
this._operatorWriteDepth--;
|
|
6815
|
+
}
|
|
6816
|
+
}
|
|
6231
6817
|
async updateGlobalSettings(patch, nodeId) {
|
|
6818
|
+
await this.applyGlobalPatch(patch, nodeId);
|
|
6819
|
+
}
|
|
6820
|
+
/**
|
|
6821
|
+
* REMOVE stored secrets — their keychain values and their markers (D787).
|
|
6822
|
+
*
|
|
6823
|
+
* Writing `''` to a secret field KEEPS what is stored (an empty string is
|
|
6824
|
+
* what a defaults fallback writes back, and it must never destroy a
|
|
6825
|
+
* credential), so code that means "forget this token" says so here. Without
|
|
6826
|
+
* a keychain (an older kernel) it writes `''`, as such code always did.
|
|
6827
|
+
*/
|
|
6828
|
+
async clearSecrets(keys, nodeId) {
|
|
6829
|
+
const tree = this.addonSecretTree();
|
|
6830
|
+
const keychain = this._ctx?.secrets !== void 0;
|
|
6831
|
+
const patch = {};
|
|
6832
|
+
for (const key of keys) patch[key] = keychain && tree.has(key) ? SECRET_CLEAR : "";
|
|
6833
|
+
await this.applyGlobalPatch(patch, nodeId);
|
|
6834
|
+
}
|
|
6835
|
+
async applyGlobalPatch(barePatch, nodeId) {
|
|
6232
6836
|
const keys = this.perNodeKeys();
|
|
6233
6837
|
const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
|
|
6234
6838
|
const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
|
|
6235
|
-
const barePatch = patch;
|
|
6236
6839
|
const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
|
|
6237
6840
|
await this._ctx?.settings?.writeAddonStore(scoped);
|
|
6238
6841
|
if (target !== localNode) return;
|
|
6239
6842
|
await this.resolveConfig();
|
|
6240
6843
|
await this.onConfigChanged();
|
|
6241
6844
|
this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
|
|
6242
|
-
this.maybeAutoRestart(
|
|
6845
|
+
this.maybeAutoRestart(Object.keys(barePatch), this.globalSettingsSchema());
|
|
6243
6846
|
}
|
|
6244
6847
|
/**
|
|
6245
6848
|
* The set of field keys the global settings schema declares `perNode: true`
|
|
@@ -6264,7 +6867,7 @@ var BaseAddon = class {
|
|
|
6264
6867
|
* time to return its response before the addon is torn down and
|
|
6265
6868
|
* re-initialised by `AddonRegistryService.restartAddon`.
|
|
6266
6869
|
*/
|
|
6267
|
-
maybeAutoRestart(
|
|
6870
|
+
maybeAutoRestart(patchKeys, schema) {
|
|
6268
6871
|
if (!schema) return;
|
|
6269
6872
|
const restartKeys = /* @__PURE__ */ new Set();
|
|
6270
6873
|
for (const section of schema.sections) for (const field of section.fields) {
|
|
@@ -6272,13 +6875,13 @@ var BaseAddon = class {
|
|
|
6272
6875
|
if (field.requiresRestart) restartKeys.add(field.key);
|
|
6273
6876
|
}
|
|
6274
6877
|
if (restartKeys.size === 0) return;
|
|
6275
|
-
if (!
|
|
6878
|
+
if (!patchKeys.some((k) => restartKeys.has(k))) return;
|
|
6276
6879
|
const ctx = this._ctx;
|
|
6277
6880
|
if (!ctx) return;
|
|
6278
6881
|
const addonId = ctx.id;
|
|
6279
6882
|
setImmediate(() => {
|
|
6280
6883
|
ctx.api.addons?.restartAddon?.mutate({ addonId }).then(() => {
|
|
6281
|
-
ctx.logger.info("addon auto-restart triggered by restart-required setting change", { meta: { changedFields:
|
|
6884
|
+
ctx.logger.info("addon auto-restart triggered by restart-required setting change", { meta: { changedFields: patchKeys.filter((k) => restartKeys.has(k)) } });
|
|
6282
6885
|
}).catch((err) => {
|
|
6283
6886
|
ctx.logger.error("addon auto-restart failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
6284
6887
|
});
|
|
@@ -6287,7 +6890,7 @@ var BaseAddon = class {
|
|
|
6287
6890
|
async getDeviceSettings(deviceId) {
|
|
6288
6891
|
const schema = this.deviceSettingsSchema();
|
|
6289
6892
|
if (!schema) return { sections: [] };
|
|
6290
|
-
return hydrateSchema(schema, await this._ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
6893
|
+
return hydrateSchema(schema, await this._ctx?.settings?.readDeviceStore(deviceId) ?? {}, { inlineSecretsAsViews: true });
|
|
6291
6894
|
}
|
|
6292
6895
|
async updateDeviceSettings(deviceId, patch) {
|
|
6293
6896
|
await this._ctx?.settings?.writeDeviceStore(deviceId, patch);
|
|
@@ -6417,7 +7020,7 @@ var BaseAddon = class {
|
|
|
6417
7020
|
* selection at boot (absent scoped key ⇒ the constructor default wins).
|
|
6418
7021
|
*/
|
|
6419
7022
|
async resolveConfig() {
|
|
6420
|
-
const stored = await this.readAddonStoreWithRetry();
|
|
7023
|
+
const stored = await this.resolveStoredSecrets(await this.readAddonStoreWithRetry());
|
|
6421
7024
|
const perNode = this.perNodeKeys();
|
|
6422
7025
|
const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
|
|
6423
7026
|
const resolved = { ...this.defaults };
|
|
@@ -6442,7 +7045,7 @@ var BaseAddon = class {
|
|
|
6442
7045
|
key,
|
|
6443
7046
|
schema,
|
|
6444
7047
|
fallback,
|
|
6445
|
-
read: () => this.readAddonStoreWithRetry(),
|
|
7048
|
+
read: async () => this.resolveStoredSecrets(await this.readAddonStoreWithRetry()),
|
|
6446
7049
|
write: async (patch) => {
|
|
6447
7050
|
await this._ctx?.settings?.writeAddonStore(patch);
|
|
6448
7051
|
},
|
|
@@ -6459,7 +7062,7 @@ var BaseAddon = class {
|
|
|
6459
7062
|
key,
|
|
6460
7063
|
schema,
|
|
6461
7064
|
fallback,
|
|
6462
|
-
read: async () => await this._ctx?.settings?.readDeviceStore(deviceId) ?? {},
|
|
7065
|
+
read: async () => this.resolveStoredSecrets(await this._ctx?.settings?.readDeviceStore(deviceId) ?? {}, false),
|
|
6463
7066
|
write: async (patch) => {
|
|
6464
7067
|
await this._ctx?.settings?.writeDeviceStore(deviceId, patch);
|
|
6465
7068
|
},
|
|
@@ -6471,6 +7074,70 @@ var BaseAddon = class {
|
|
|
6471
7074
|
} })
|
|
6472
7075
|
});
|
|
6473
7076
|
}
|
|
7077
|
+
/** The addon store's secret tree: schema fields by TYPE, plus `secretStatePaths`. */
|
|
7078
|
+
addonSecretTree() {
|
|
7079
|
+
this._addonSecretTree ??= mergeSecretStatePaths(secretTreeFromSchema(this.globalSettingsSchema()), this.secretStatePaths);
|
|
7080
|
+
return this._addonSecretTree;
|
|
7081
|
+
}
|
|
7082
|
+
deviceSecretTree() {
|
|
7083
|
+
this._deviceSecretTree ??= secretTreeFromSchema(this.deviceSettingsSchema());
|
|
7084
|
+
return this._deviceSecretTree;
|
|
7085
|
+
}
|
|
7086
|
+
/**
|
|
7087
|
+
* Secrets of this addon's stored config that could not be resolved on the
|
|
7088
|
+
* last read, with why. Non-empty means the addon is running WITHOUT them —
|
|
7089
|
+
* an addon whose function needs one should report itself degraded rather
|
|
7090
|
+
* than start on an empty credential.
|
|
7091
|
+
*/
|
|
7092
|
+
get unresolvedSecrets() {
|
|
7093
|
+
return this._unresolvedSecrets;
|
|
7094
|
+
}
|
|
7095
|
+
/**
|
|
7096
|
+
* Substitute every keychain marker in a stored blob with its value. With no
|
|
7097
|
+
* keychain (no `ctx.secrets`) or no marker, the blob comes back unchanged.
|
|
7098
|
+
* An unresolvable value is LEFT OUT (never `''`), logged by ref — never by
|
|
7099
|
+
* value — and, when the cause can pass, retried with backoff. Nothing is
|
|
7100
|
+
* ever written back from here.
|
|
7101
|
+
*/
|
|
7102
|
+
async resolveStoredSecrets(blob, trackForConfig = true) {
|
|
7103
|
+
const secrets = this._ctx?.secrets;
|
|
7104
|
+
if (secrets === void 0) return blob;
|
|
7105
|
+
const refs = collectSecretMarkerRefs(blob);
|
|
7106
|
+
if (trackForConfig) this._configSecretRefs = new Set(refs);
|
|
7107
|
+
if (refs.length === 0) {
|
|
7108
|
+
if (trackForConfig) this._unresolvedSecrets = [];
|
|
7109
|
+
return blob;
|
|
7110
|
+
}
|
|
7111
|
+
const { value, unresolved } = await resolveSecretMarkers(blob, secrets);
|
|
7112
|
+
if (trackForConfig) {
|
|
7113
|
+
this._unresolvedSecrets = unresolved;
|
|
7114
|
+
if (unresolved.length > 0) {
|
|
7115
|
+
this._ctx?.logger?.error?.("secret unavailable — running without it, will retry", { meta: { secrets: unresolved.map((u) => ({
|
|
7116
|
+
ref: u.ref,
|
|
7117
|
+
reason: u.reason
|
|
7118
|
+
})) } });
|
|
7119
|
+
if (unresolved.some((u) => u.reason !== "missing" && u.reason !== "forbidden")) {
|
|
7120
|
+
this.scheduleSecretReresolve(this._secretRetryDelayMs);
|
|
7121
|
+
this._secretRetryDelayMs = Math.min(this._secretRetryDelayMs * 2, SECRET_RETRY_MAX_MS);
|
|
7122
|
+
}
|
|
7123
|
+
} else this._secretRetryDelayMs = SECRET_RETRY_FIRST_MS;
|
|
7124
|
+
}
|
|
7125
|
+
return value;
|
|
7126
|
+
}
|
|
7127
|
+
/** Re-resolve the config (a keychain-only change, or a retry) and apply it. */
|
|
7128
|
+
scheduleSecretReresolve(delayMs) {
|
|
7129
|
+
const timerOf = delayMs === SECRET_CHANGE_DEBOUNCE_MS ? "_secretChangeTimer" : "_secretRetryTimer";
|
|
7130
|
+
if (this[timerOf] !== null) return;
|
|
7131
|
+
const timer = setTimeout(() => {
|
|
7132
|
+
this[timerOf] = null;
|
|
7133
|
+
if (this._ctx === null) return;
|
|
7134
|
+
this.resolveConfig().then(() => this.onConfigChanged()).catch((err) => {
|
|
7135
|
+
this._ctx?.logger?.error?.("re-resolving secrets failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
7136
|
+
});
|
|
7137
|
+
}, delayMs);
|
|
7138
|
+
timer.unref?.();
|
|
7139
|
+
this[timerOf] = timer;
|
|
7140
|
+
}
|
|
6474
7141
|
/**
|
|
6475
7142
|
* Wrap `ctx.settings.readAddonStore()` with a short retry budget so a
|
|
6476
7143
|
* transient settings-store outage (mid-restart of sqlite-settings, tsx-watch
|
|
@@ -14275,6 +14942,196 @@ id: union([string(), number()]) }), {
|
|
|
14275
14942
|
kind: "mutation",
|
|
14276
14943
|
auth: "admin"
|
|
14277
14944
|
});
|
|
14945
|
+
/**
|
|
14946
|
+
* `keychain` — the hub's encrypted store of secret values (D787).
|
|
14947
|
+
*
|
|
14948
|
+
* Settings blobs never hold a secret: they hold a marker
|
|
14949
|
+
* `{"$secret":"secret://<owner>/<scope>/<fieldPath>","v":1}` and the value
|
|
14950
|
+
* lives here, AES-256-GCM under a data key that a master key wraps. This cap
|
|
14951
|
+
* is the OWNER's door to its own values: every method is owner-checked
|
|
14952
|
+
* against the CALLER, never against anything the caller claims.
|
|
14953
|
+
*
|
|
14954
|
+
* Who the caller is: the transport stamps `callerAddonId` into the args of a
|
|
14955
|
+
* call that crossed a process (the parent `LocalChildRegistry` from the UDS
|
|
14956
|
+
* connection, the agent from its child's connection carried in
|
|
14957
|
+
* `$hub-cap-fwd`'s envelope). A stamp always overwrites; a wire call with no
|
|
14958
|
+
* stamp has its claim stripped. Only a hub in-process caller may state its own
|
|
14959
|
+
* identity — `ctx.secrets` does that with the addon id closed over. This is
|
|
14960
|
+
* identity against BUGS and misrouting, not against a malicious addon (every
|
|
14961
|
+
* runner is the same OS user): see the threat model in
|
|
14962
|
+
* `docs/design/2026-10-08-secrets-keychain.md`.
|
|
14963
|
+
*
|
|
14964
|
+
* `callerAddonId` is deliberately NOT in any input schema: the tRPC router
|
|
14965
|
+
* parses the input, so a session can never supply it, and the internal mount
|
|
14966
|
+
* floors every method to admin anyway (D240).
|
|
14967
|
+
*
|
|
14968
|
+
* Internal: addons reach it through `ctx.secrets`, never directly.
|
|
14969
|
+
*/
|
|
14970
|
+
/** Why a value cannot be served right now. Never folded into "missing". */
|
|
14971
|
+
var KeychainUnavailableReasonSchema = _enum([
|
|
14972
|
+
"key-missing",
|
|
14973
|
+
"wrong-key",
|
|
14974
|
+
"key-file-unreadable",
|
|
14975
|
+
"store-unavailable",
|
|
14976
|
+
"not-initialized",
|
|
14977
|
+
"decrypt-failed"
|
|
14978
|
+
]);
|
|
14979
|
+
var KeychainStateSchema = discriminatedUnion("state", [object({ state: literal("ready") }), object({
|
|
14980
|
+
state: literal("locked"),
|
|
14981
|
+
reason: KeychainUnavailableReasonSchema
|
|
14982
|
+
})]);
|
|
14983
|
+
var KeychainResolveEntrySchema = discriminatedUnion("kind", [
|
|
14984
|
+
object({
|
|
14985
|
+
kind: literal("value"),
|
|
14986
|
+
value: string(),
|
|
14987
|
+
version: number().int()
|
|
14988
|
+
}),
|
|
14989
|
+
object({ kind: literal("missing") }),
|
|
14990
|
+
object({
|
|
14991
|
+
kind: literal("unavailable"),
|
|
14992
|
+
reason: KeychainUnavailableReasonSchema
|
|
14993
|
+
}),
|
|
14994
|
+
object({ kind: literal("forbidden") })
|
|
14995
|
+
]);
|
|
14996
|
+
var KeychainResolveResultSchema = record(string(), KeychainResolveEntrySchema);
|
|
14997
|
+
/** Metadata of one stored value — never the value. */
|
|
14998
|
+
var KeychainSecretMetaSchema = object({
|
|
14999
|
+
ref: string(),
|
|
15000
|
+
owner: string(),
|
|
15001
|
+
/** Formatted scope (`global`, `node/<id>`, `device/<id>`, …). */
|
|
15002
|
+
scope: string(),
|
|
15003
|
+
fieldPath: string(),
|
|
15004
|
+
version: number().int(),
|
|
15005
|
+
createdAt: number(),
|
|
15006
|
+
updatedAt: number(),
|
|
15007
|
+
/** `addon:<id>` | `user:<id>` | `system`. */
|
|
15008
|
+
updatedBy: string(),
|
|
15009
|
+
/** Coarse — written at most once an hour per ref. `null` = never read. */
|
|
15010
|
+
lastReadAt: number().nullable()
|
|
15011
|
+
});
|
|
15012
|
+
var RefListSchema = array(string().min(1)).max(512);
|
|
15013
|
+
method(object({
|
|
15014
|
+
ref: string().min(1),
|
|
15015
|
+
value: string().min(1)
|
|
15016
|
+
}), object({
|
|
15017
|
+
ref: string(),
|
|
15018
|
+
version: number().int()
|
|
15019
|
+
}), {
|
|
15020
|
+
kind: "mutation",
|
|
15021
|
+
auth: "admin"
|
|
15022
|
+
}), method(object({ refs: RefListSchema }), KeychainResolveResultSchema, { auth: "admin" }), method(object({ refs: RefListSchema }), object({ deleted: number().int() }), {
|
|
15023
|
+
kind: "mutation",
|
|
15024
|
+
auth: "admin"
|
|
15025
|
+
}), method(object({}), array(KeychainSecretMetaSchema).readonly(), { auth: "admin" }), method(object({}), KeychainStateSchema, { auth: "admin" });
|
|
15026
|
+
var KeychainKeySourceSchema = object({
|
|
15027
|
+
kind: _enum([
|
|
15028
|
+
"config-file",
|
|
15029
|
+
"previous-config-file",
|
|
15030
|
+
"platform",
|
|
15031
|
+
"default-file"
|
|
15032
|
+
]),
|
|
15033
|
+
/** File sources only — a path is not a secret. */
|
|
15034
|
+
path: string().optional(),
|
|
15035
|
+
/**
|
|
15036
|
+
* True when a whole-volume backup of the data directory carries this key
|
|
15037
|
+
* next to the database it protects (the default file). The page warns.
|
|
15038
|
+
*/
|
|
15039
|
+
sharesBackupWithDatabase: boolean()
|
|
15040
|
+
});
|
|
15041
|
+
var KeychainStatusSchema = object({
|
|
15042
|
+
keychain: KeychainStateSchema,
|
|
15043
|
+
/** The source of the master key in use; `null` while locked. */
|
|
15044
|
+
keySource: KeychainKeySourceSchema.nullable(),
|
|
15045
|
+
/** The source configured to be primary — differs from `keySource` while a rotation is pending. */
|
|
15046
|
+
configuredSource: KeychainKeySourceSchema,
|
|
15047
|
+
/** Short fingerprint of the master key in use (`HMAC(KEK, label)`, 16 hex). */
|
|
15048
|
+
fingerprint: string().nullable(),
|
|
15049
|
+
activeKeyId: string().nullable(),
|
|
15050
|
+
/**
|
|
15051
|
+
* The keychain unlocked with a NON-primary source: the configured primary
|
|
15052
|
+
* key is not the one that wraps the data key yet. `rotateMasterKey` moves it.
|
|
15053
|
+
*/
|
|
15054
|
+
rotationPending: boolean(),
|
|
15055
|
+
/** Wrapped copies of the data key under a previous master key, kept until purged. */
|
|
15056
|
+
retiredWraps: number().int(),
|
|
15057
|
+
recoveryKeyDownloadedAt: number().nullable(),
|
|
15058
|
+
secretCount: number().int()
|
|
15059
|
+
});
|
|
15060
|
+
/**
|
|
15061
|
+
* Whether a stored value is still pointed at by its owner's settings.
|
|
15062
|
+
* `unknown` is a first-class answer: a scope whose blob could not be read is
|
|
15063
|
+
* never reported as an orphan (and never deleted as one).
|
|
15064
|
+
*/
|
|
15065
|
+
var KeychainSecretUsageSchema = _enum([
|
|
15066
|
+
"in-use",
|
|
15067
|
+
"orphan",
|
|
15068
|
+
"unknown"
|
|
15069
|
+
]);
|
|
15070
|
+
var KeychainAdminSecretSchema = KeychainSecretMetaSchema.extend({ usage: KeychainSecretUsageSchema });
|
|
15071
|
+
var KeychainAuditActionSchema = _enum([
|
|
15072
|
+
"put",
|
|
15073
|
+
"delete",
|
|
15074
|
+
"reveal",
|
|
15075
|
+
"rotate-master-key",
|
|
15076
|
+
"purge-retired-keys",
|
|
15077
|
+
"download-recovery-key",
|
|
15078
|
+
"delete-orphans",
|
|
15079
|
+
"reset",
|
|
15080
|
+
"initialize"
|
|
15081
|
+
]);
|
|
15082
|
+
var KeychainAuditEntrySchema = object({
|
|
15083
|
+
id: string(),
|
|
15084
|
+
at: number(),
|
|
15085
|
+
action: KeychainAuditActionSchema,
|
|
15086
|
+
/** `addon:<id>` | `user:<id>` | `system`. */
|
|
15087
|
+
actor: string(),
|
|
15088
|
+
ref: string().nullable(),
|
|
15089
|
+
detail: string().nullable()
|
|
15090
|
+
});
|
|
15091
|
+
method(object({}), KeychainStatusSchema, { auth: "admin" }), method(object({}), array(KeychainAdminSecretSchema).readonly(), { auth: "admin" }), method(object({ limit: number().int().min(1).max(500).optional() }), array(KeychainAuditEntrySchema).readonly(), { auth: "admin" }), method(object({ ref: string().min(1) }), object({
|
|
15092
|
+
value: string(),
|
|
15093
|
+
version: number().int()
|
|
15094
|
+
}), {
|
|
15095
|
+
kind: "mutation",
|
|
15096
|
+
auth: "admin",
|
|
15097
|
+
caller: "required"
|
|
15098
|
+
}), method(object({ refs: array(string().min(1)).max(1e3) }), object({
|
|
15099
|
+
deleted: array(string()).readonly(),
|
|
15100
|
+
kept: array(object({
|
|
15101
|
+
ref: string(),
|
|
15102
|
+
usage: KeychainSecretUsageSchema
|
|
15103
|
+
})).readonly()
|
|
15104
|
+
}), {
|
|
15105
|
+
kind: "mutation",
|
|
15106
|
+
auth: "admin",
|
|
15107
|
+
caller: "required"
|
|
15108
|
+
}), method(object({}), object({
|
|
15109
|
+
fingerprint: string(),
|
|
15110
|
+
keySource: KeychainKeySourceSchema
|
|
15111
|
+
}), {
|
|
15112
|
+
kind: "mutation",
|
|
15113
|
+
auth: "admin",
|
|
15114
|
+
caller: "required"
|
|
15115
|
+
}), method(object({}), object({ purged: number().int() }), {
|
|
15116
|
+
kind: "mutation",
|
|
15117
|
+
auth: "admin",
|
|
15118
|
+
caller: "required"
|
|
15119
|
+
}), method(object({}), object({
|
|
15120
|
+
key: string(),
|
|
15121
|
+
fingerprint: string(),
|
|
15122
|
+
keySource: KeychainKeySourceSchema
|
|
15123
|
+
}), {
|
|
15124
|
+
kind: "mutation",
|
|
15125
|
+
auth: "admin",
|
|
15126
|
+
caller: "required"
|
|
15127
|
+
}), method(object({ confirm: literal("RESET KEYCHAIN") }), object({
|
|
15128
|
+
archivedCollection: string().nullable(),
|
|
15129
|
+
archived: number().int()
|
|
15130
|
+
}), {
|
|
15131
|
+
kind: "mutation",
|
|
15132
|
+
auth: "admin",
|
|
15133
|
+
caller: "required"
|
|
15134
|
+
});
|
|
14278
15135
|
method(_void(), object({
|
|
14279
15136
|
id: string(),
|
|
14280
15137
|
name: string(),
|
|
@@ -15337,7 +16194,11 @@ var DeviceRegisterPayloadSchema = object({
|
|
|
15337
16194
|
var DeviceRemovePayloadSchema = object({ deviceId: number() });
|
|
15338
16195
|
var DevicePersistConfigPayloadSchema = object({
|
|
15339
16196
|
deviceId: number(),
|
|
15340
|
-
data: record(string(), unknown())
|
|
16197
|
+
data: record(string(), unknown()),
|
|
16198
|
+
/** The paths the device's schema declares secret (D783), when the caller
|
|
16199
|
+
* knows them — those values are kept in the keychain, not the blob (D787).
|
|
16200
|
+
* Absent: the paths the device last reported at `registerDevice` apply. */
|
|
16201
|
+
secretConfigPaths: array(string()).optional()
|
|
15341
16202
|
});
|
|
15342
16203
|
/** What a migration actually did, per switch. `unreachable` is a first-class
|
|
15343
16204
|
* answer: a camera that could not be asked is not a camera that was silenced. */
|
|
@@ -22332,8 +23193,23 @@ var RetrainMacroClassSchema = _enum([
|
|
|
22332
23193
|
]);
|
|
22333
23194
|
/** A subject to learn, or a phantom to unlearn (taught by OMISSION). */
|
|
22334
23195
|
var RetrainAnnotationKindSchema = _enum(["subject", "model_error"]);
|
|
22335
|
-
/**
|
|
22336
|
-
|
|
23196
|
+
/**
|
|
23197
|
+
* Where a box came from. A machine-sourced box is a DRAFT on the page until
|
|
23198
|
+
* the operator confirms it; once saved it keeps its source as provenance.
|
|
23199
|
+
*
|
|
23200
|
+
* - `operator` — drawn by hand.
|
|
23201
|
+
* - `assist` — proposed by a detector run on the copy (the camera's own
|
|
23202
|
+
* model, or a larger teacher model).
|
|
23203
|
+
* - `pipeline` — the box the live pipeline actually drew on that frame, read
|
|
23204
|
+
* back from the track store (never a re-run).
|
|
23205
|
+
* - `llm` — proposed by a vision-capable LLM profile.
|
|
23206
|
+
*/
|
|
23207
|
+
var RetrainAnnotationSourceSchema = _enum([
|
|
23208
|
+
"operator",
|
|
23209
|
+
"assist",
|
|
23210
|
+
"pipeline",
|
|
23211
|
+
"llm"
|
|
23212
|
+
]);
|
|
22337
23213
|
/** Normalised `[0,1]` rectangle against the FULL frame — the canonical form. */
|
|
22338
23214
|
var RetrainBboxSchema = object({
|
|
22339
23215
|
x: number(),
|
|
@@ -22351,7 +23227,8 @@ var RetrainBboxSchema = object({
|
|
|
22351
23227
|
*/
|
|
22352
23228
|
var RetrainAnnotationSchema = object({
|
|
22353
23229
|
id: string(),
|
|
22354
|
-
|
|
23230
|
+
/** The frame's track. Absent on a recording still, which has none. */
|
|
23231
|
+
trackId: string().optional(),
|
|
22355
23232
|
deviceId: number(),
|
|
22356
23233
|
/** The COPY in retrain storage — never the source track's media key. */
|
|
22357
23234
|
mediaKey: string(),
|
|
@@ -22400,13 +23277,47 @@ var RetrainFrameCandidateSchema = object({
|
|
|
22400
23277
|
* fail, whatever became of the original. */
|
|
22401
23278
|
copied: boolean()
|
|
22402
23279
|
});
|
|
23280
|
+
/**
|
|
23281
|
+
* One box the live pipeline drew on a copied frame — what a correction starts
|
|
23282
|
+
* from. Derived when the frame is listed, from the track store: the subject
|
|
23283
|
+
* box recorded on the source media row when there is one, else the track
|
|
23284
|
+
* position nearest the frame's capture time. Never stored as an annotation
|
|
23285
|
+
* until the operator confirms it.
|
|
23286
|
+
*/
|
|
23287
|
+
var RetrainPipelineBoxSchema = object({
|
|
23288
|
+
/** Normalised `[0,1]` against the full frame. */
|
|
23289
|
+
bbox: RetrainBboxSchema,
|
|
23290
|
+
macroClass: RetrainMacroClassSchema,
|
|
23291
|
+
label: string().optional(),
|
|
23292
|
+
subLabel: string().optional(),
|
|
23293
|
+
/** The detection score of the position used, when it was recorded. */
|
|
23294
|
+
score: number().optional(),
|
|
23295
|
+
/** The root model that produced the track, when it was recorded. */
|
|
23296
|
+
modelId: string().optional(),
|
|
23297
|
+
trackId: string(),
|
|
23298
|
+
/** `subject-box`: recorded on this very frame. `position`: the track's
|
|
23299
|
+
* position nearest the capture time, `deltaMs` away. */
|
|
23300
|
+
basis: _enum(["subject-box", "position"]),
|
|
23301
|
+
deltaMs: number()
|
|
23302
|
+
});
|
|
23303
|
+
/**
|
|
23304
|
+
* Where a retrain frame came from: a TRACK's stored media (copy-on-select),
|
|
23305
|
+
* or the RECORDING at an instant — the frame a model saw nothing in has no
|
|
23306
|
+
* track at all.
|
|
23307
|
+
*/
|
|
23308
|
+
var RetrainFrameOriginSchema = _enum(["track", "recording"]);
|
|
22403
23309
|
/** A frame the dataset OWNS: bytes copied at selection time. */
|
|
22404
23310
|
var RetrainFrameSchema = object({
|
|
22405
23311
|
frameId: string(),
|
|
22406
23312
|
deviceId: number(),
|
|
22407
|
-
|
|
22408
|
-
/**
|
|
22409
|
-
|
|
23313
|
+
origin: RetrainFrameOriginSchema,
|
|
23314
|
+
/** The track it was copied from. Absent on a recording still. */
|
|
23315
|
+
trackId: string().optional(),
|
|
23316
|
+
/** Provenance only. It may already point at nothing — that is expected.
|
|
23317
|
+
* Absent on a recording still. */
|
|
23318
|
+
sourceMediaId: number().int().optional(),
|
|
23319
|
+
/** A recording still: the playhead instant it was taken at. */
|
|
23320
|
+
epochMs: number().optional(),
|
|
22410
23321
|
sourceKind: MediaFileKindEnum,
|
|
22411
23322
|
sizeBytes: number().int(),
|
|
22412
23323
|
width: number().int(),
|
|
@@ -22417,7 +23328,12 @@ var RetrainFrameSchema = object({
|
|
|
22417
23328
|
/** The operator's "every subject in this frame is boxed". Detector
|
|
22418
23329
|
* evaluation keeps only complete frames: an unboxed subject would count as
|
|
22419
23330
|
* a false positive. */
|
|
22420
|
-
complete: boolean()
|
|
23331
|
+
complete: boolean(),
|
|
23332
|
+
/** The boxes the pipeline drew on this frame. Present on list answers;
|
|
23333
|
+
* empty when nothing the track store holds can be placed on it. */
|
|
23334
|
+
pipelineBoxes: array(RetrainPipelineBoxSchema).readonly().optional(),
|
|
23335
|
+
/** The named collections this frame belongs to. Present on list answers. */
|
|
23336
|
+
collectionIds: array(string()).readonly().optional()
|
|
22421
23337
|
});
|
|
22422
23338
|
/** Why a copy-on-select could not be honoured — named, never a silent skip. */
|
|
22423
23339
|
var RetrainCopyRefusalSchema = _enum([
|
|
@@ -22448,6 +23364,8 @@ var RetrainFrameListSchema = object({
|
|
|
22448
23364
|
var RetrainExportFilterSchema = object({
|
|
22449
23365
|
deviceIds: array(number()).optional(),
|
|
22450
23366
|
frameIds: array(string()).max(2e4).optional(),
|
|
23367
|
+
/** Only frames in at least one of these named collections. */
|
|
23368
|
+
collectionIds: array(string()).max(200).optional(),
|
|
22451
23369
|
batchId: string().optional(),
|
|
22452
23370
|
/** Only frames the operator marked complete (all subjects boxed). */
|
|
22453
23371
|
completeOnly: boolean().optional()
|
|
@@ -22512,6 +23430,109 @@ var RetrainAssistResultSchema = discriminatedUnion("kind", [object({
|
|
|
22512
23430
|
reason: string(),
|
|
22513
23431
|
detail: string().optional()
|
|
22514
23432
|
})]);
|
|
23433
|
+
/** A named set over the retrain corpus — membership only, it owns no pixels. */
|
|
23434
|
+
var RetrainCollectionSchema = object({
|
|
23435
|
+
id: string(),
|
|
23436
|
+
name: string(),
|
|
23437
|
+
createdAt: number(),
|
|
23438
|
+
frameCount: number().int()
|
|
23439
|
+
});
|
|
23440
|
+
var RetrainCollectionCreateResultSchema = discriminatedUnion("kind", [object({
|
|
23441
|
+
kind: literal("created"),
|
|
23442
|
+
collection: RetrainCollectionSchema
|
|
23443
|
+
}), object({
|
|
23444
|
+
kind: literal("refused"),
|
|
23445
|
+
reason: _enum(["empty-name", "duplicate-name"])
|
|
23446
|
+
})]);
|
|
23447
|
+
var RetrainCollectionRenameResultSchema = discriminatedUnion("kind", [object({
|
|
23448
|
+
kind: literal("renamed"),
|
|
23449
|
+
collection: RetrainCollectionSchema
|
|
23450
|
+
}), object({
|
|
23451
|
+
kind: literal("refused"),
|
|
23452
|
+
reason: _enum([
|
|
23453
|
+
"empty-name",
|
|
23454
|
+
"duplicate-name",
|
|
23455
|
+
"unknown-collection"
|
|
23456
|
+
])
|
|
23457
|
+
})]);
|
|
23458
|
+
/**
|
|
23459
|
+
* The outcome of adding the frame at a recording instant. Every refusal is
|
|
23460
|
+
* named: `still-unavailable` (not recording, a gap, or an undecodable frame —
|
|
23461
|
+
* `detail` says which), `still-busy` (the recorder is at its decode bound; try
|
|
23462
|
+
* again), `unreadable-image`, `write-failed`, `unknown-collection`.
|
|
23463
|
+
*/
|
|
23464
|
+
var AddRetrainFrameFromRecordingResultSchema = discriminatedUnion("kind", [object({
|
|
23465
|
+
kind: literal("added"),
|
|
23466
|
+
collectionId: string(),
|
|
23467
|
+
frame: RetrainFrameSchema,
|
|
23468
|
+
/** The moment was already in the dataset; nothing was decoded. */
|
|
23469
|
+
reused: boolean()
|
|
23470
|
+
}), object({
|
|
23471
|
+
kind: literal("refused"),
|
|
23472
|
+
reason: _enum([
|
|
23473
|
+
"unknown-collection",
|
|
23474
|
+
"still-unavailable",
|
|
23475
|
+
"still-busy",
|
|
23476
|
+
"unreadable-image",
|
|
23477
|
+
"write-failed"
|
|
23478
|
+
]),
|
|
23479
|
+
detail: string().optional()
|
|
23480
|
+
})]);
|
|
23481
|
+
/** A vision LLM profile the AI assist may use, and where it sends the frame. */
|
|
23482
|
+
var RetrainAssistProfileSchema = object({
|
|
23483
|
+
id: string(),
|
|
23484
|
+
name: string(),
|
|
23485
|
+
model: string(),
|
|
23486
|
+
kind: string(),
|
|
23487
|
+
/** `true` when the frame would leave the house (a cloud vendor, or an
|
|
23488
|
+
* endpoint that is not on the local network). The page confirms each such
|
|
23489
|
+
* run, and the provider refuses one without `allowCloud`. */
|
|
23490
|
+
leavesHouse: boolean()
|
|
23491
|
+
});
|
|
23492
|
+
/** A box the AI assist proposed: a draft, plus the model's own reason. */
|
|
23493
|
+
var RetrainLlmProposalSchema = RetrainAnnotationDraftSchema.extend({ reason: string() });
|
|
23494
|
+
/** What the AI assist said about a box that was already on the canvas. */
|
|
23495
|
+
var RetrainLlmReviewSchema = object({
|
|
23496
|
+
/** The `ref` the page sent the box under. */
|
|
23497
|
+
ref: string(),
|
|
23498
|
+
verdict: _enum([
|
|
23499
|
+
"confirmed",
|
|
23500
|
+
"wrong-class",
|
|
23501
|
+
"not-an-object",
|
|
23502
|
+
"uncertain"
|
|
23503
|
+
]),
|
|
23504
|
+
suggestedClass: RetrainMacroClassSchema.optional(),
|
|
23505
|
+
reason: string()
|
|
23506
|
+
});
|
|
23507
|
+
/**
|
|
23508
|
+
* The AI assist's answer. `proposed` carries drafts (`source: 'llm'`, the
|
|
23509
|
+
* model and its confidence) and reviews; items the model got wrong (a box off
|
|
23510
|
+
* the frame, an unasked class, an unknown ref) are dropped and counted in
|
|
23511
|
+
* `rejected`. A whole answer that is not the schema is `malformed-answer`.
|
|
23512
|
+
*/
|
|
23513
|
+
var RetrainLlmAssistResultSchema = discriminatedUnion("kind", [object({
|
|
23514
|
+
kind: literal("proposed"),
|
|
23515
|
+
profileId: string(),
|
|
23516
|
+
model: string(),
|
|
23517
|
+
/** The frame was sent outside the house for this answer. */
|
|
23518
|
+
leftHouse: boolean(),
|
|
23519
|
+
proposals: array(RetrainLlmProposalSchema).readonly(),
|
|
23520
|
+
reviews: array(RetrainLlmReviewSchema).readonly(),
|
|
23521
|
+
rejected: number().int()
|
|
23522
|
+
}), object({
|
|
23523
|
+
kind: literal("refused"),
|
|
23524
|
+
reason: _enum([
|
|
23525
|
+
"unknown-frame",
|
|
23526
|
+
"unreadable-frame",
|
|
23527
|
+
"unknown-profile",
|
|
23528
|
+
"profile-disabled",
|
|
23529
|
+
"no-vision",
|
|
23530
|
+
"cloud-not-confirmed",
|
|
23531
|
+
"llm-error",
|
|
23532
|
+
"malformed-answer"
|
|
23533
|
+
]),
|
|
23534
|
+
detail: string().optional()
|
|
23535
|
+
})]);
|
|
22515
23536
|
/** The outcome of a lifecycle move owned by the retrain page. */
|
|
22516
23537
|
var RetrainTransitionResultSchema = object({
|
|
22517
23538
|
trackId: string(),
|
|
@@ -23229,7 +24250,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).r
|
|
|
23229
24250
|
auth: "admin"
|
|
23230
24251
|
}), method(object({
|
|
23231
24252
|
deviceId: number(),
|
|
23232
|
-
trackId: string(),
|
|
24253
|
+
trackId: string().optional(),
|
|
23233
24254
|
frameId: string()
|
|
23234
24255
|
}), object({
|
|
23235
24256
|
removed: boolean(),
|
|
@@ -23246,7 +24267,8 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).r
|
|
|
23246
24267
|
auth: "admin"
|
|
23247
24268
|
}), method(object({
|
|
23248
24269
|
deviceId: number(),
|
|
23249
|
-
|
|
24270
|
+
/** The frame's track, when it has one — checked against the frame. */
|
|
24271
|
+
trackId: string().optional(),
|
|
23250
24272
|
frameId: string(),
|
|
23251
24273
|
subject: RetrainAssistSubjectSchema,
|
|
23252
24274
|
/** Which node runs it. Absent ⇒ wherever an unowned call lands. */
|
|
@@ -23254,12 +24276,16 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).r
|
|
|
23254
24276
|
}), RetrainAssistResultSchema, {
|
|
23255
24277
|
kind: "mutation",
|
|
23256
24278
|
auth: "admin"
|
|
23257
|
-
}), method(object({
|
|
24279
|
+
}), method(object({
|
|
24280
|
+
trackId: string().optional(),
|
|
24281
|
+
frameIds: array(string()).max(2e3).optional()
|
|
24282
|
+
}), array(RetrainAnnotationSchema).readonly(), {
|
|
23258
24283
|
kind: "query",
|
|
23259
24284
|
auth: "admin"
|
|
23260
24285
|
}), method(object({
|
|
23261
24286
|
deviceId: number(),
|
|
23262
|
-
|
|
24287
|
+
/** The frame's track, when it has one — checked against the frame. */
|
|
24288
|
+
trackId: string().optional(),
|
|
23263
24289
|
frameId: string(),
|
|
23264
24290
|
annotations: array(RetrainAnnotationDraftSchema)
|
|
23265
24291
|
}), array(RetrainAnnotationSchema).readonly(), {
|
|
@@ -23282,7 +24308,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).r
|
|
|
23282
24308
|
auth: "admin"
|
|
23283
24309
|
}), method(object({
|
|
23284
24310
|
deviceId: number(),
|
|
23285
|
-
trackId: string(),
|
|
24311
|
+
trackId: string().optional(),
|
|
23286
24312
|
frameId: string(),
|
|
23287
24313
|
complete: boolean()
|
|
23288
24314
|
}), RetrainFrameSchema, {
|
|
@@ -23296,6 +24322,74 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema$1).r
|
|
|
23296
24322
|
kind: "mutation",
|
|
23297
24323
|
auth: "admin",
|
|
23298
24324
|
timeoutMs: 60 * 6e4
|
|
24325
|
+
}), method(object({}), array(RetrainAssistProfileSchema).readonly(), {
|
|
24326
|
+
kind: "query",
|
|
24327
|
+
auth: "admin"
|
|
24328
|
+
}), method(object({
|
|
24329
|
+
deviceId: number(),
|
|
24330
|
+
trackId: string().optional(),
|
|
24331
|
+
frameId: string(),
|
|
24332
|
+
profileId: string(),
|
|
24333
|
+
classes: array(RetrainMacroClassSchema).min(1),
|
|
24334
|
+
boxes: array(object({
|
|
24335
|
+
ref: string(),
|
|
24336
|
+
bbox: RetrainBboxSchema,
|
|
24337
|
+
macroClass: RetrainMacroClassSchema,
|
|
24338
|
+
label: string().optional()
|
|
24339
|
+
})).max(50),
|
|
24340
|
+
allowCloud: boolean()
|
|
24341
|
+
}), RetrainLlmAssistResultSchema, {
|
|
24342
|
+
kind: "mutation",
|
|
24343
|
+
auth: "admin",
|
|
24344
|
+
timeoutMs: 18e4
|
|
24345
|
+
}), method(object({ name: string().max(120) }), RetrainCollectionCreateResultSchema, {
|
|
24346
|
+
kind: "mutation",
|
|
24347
|
+
auth: "admin"
|
|
24348
|
+
}), method(object({}), array(RetrainCollectionSchema).readonly(), {
|
|
24349
|
+
kind: "query",
|
|
24350
|
+
auth: "admin"
|
|
24351
|
+
}), method(object({
|
|
24352
|
+
id: string(),
|
|
24353
|
+
name: string().max(120)
|
|
24354
|
+
}), RetrainCollectionRenameResultSchema, {
|
|
24355
|
+
kind: "mutation",
|
|
24356
|
+
auth: "admin"
|
|
24357
|
+
}), method(object({ id: string() }), object({
|
|
24358
|
+
deleted: boolean(),
|
|
24359
|
+
removedFrames: number().int()
|
|
24360
|
+
}), {
|
|
24361
|
+
kind: "mutation",
|
|
24362
|
+
auth: "admin"
|
|
24363
|
+
}), method(object({
|
|
24364
|
+
collectionId: string(),
|
|
24365
|
+
deviceId: number(),
|
|
24366
|
+
epochMs: number()
|
|
24367
|
+
}), AddRetrainFrameFromRecordingResultSchema, {
|
|
24368
|
+
kind: "mutation",
|
|
24369
|
+
auth: "admin",
|
|
24370
|
+
timeoutMs: 6e4
|
|
24371
|
+
}), method(object({
|
|
24372
|
+
collectionId: string(),
|
|
24373
|
+
frameIds: array(string()).min(1).max(500)
|
|
24374
|
+
}), object({
|
|
24375
|
+
added: number().int(),
|
|
24376
|
+
alreadyMember: number().int(),
|
|
24377
|
+
unknownFrameIds: array(string()).readonly()
|
|
24378
|
+
}), {
|
|
24379
|
+
kind: "mutation",
|
|
24380
|
+
auth: "admin"
|
|
24381
|
+
}), method(object({
|
|
24382
|
+
collectionId: string(),
|
|
24383
|
+
frameId: string()
|
|
24384
|
+
}), object({
|
|
24385
|
+
removed: boolean(),
|
|
24386
|
+
deletedFrame: boolean()
|
|
24387
|
+
}), {
|
|
24388
|
+
kind: "mutation",
|
|
24389
|
+
auth: "admin"
|
|
24390
|
+
}), method(object({ collectionId: string() }), array(RetrainFrameSchema).readonly(), {
|
|
24391
|
+
kind: "query",
|
|
24392
|
+
auth: "admin"
|
|
23299
24393
|
}), method(object({
|
|
23300
24394
|
/** Which of the three event tables owns the row. */
|
|
23301
24395
|
ownerType: EventOwnerTypeSchema,
|
|
@@ -23919,13 +25013,31 @@ var RecommendationReasonSchema = discriminatedUnion("kind", [
|
|
|
23919
25013
|
object({
|
|
23920
25014
|
kind: literal("no-family-build"),
|
|
23921
25015
|
format: ModelFormatSchema$1
|
|
25016
|
+
}),
|
|
25017
|
+
object({
|
|
25018
|
+
kind: literal("kept-custom-pin"),
|
|
25019
|
+
modelId: string(),
|
|
25020
|
+
deviceKey: string()
|
|
23922
25021
|
})
|
|
23923
25022
|
]);
|
|
25023
|
+
/** Why a device is left exactly as it is, whatever the plan would pick (D786). */
|
|
25024
|
+
var KeptReasonSchema = _enum(["custom-pin"]);
|
|
25025
|
+
/**
|
|
25026
|
+
* Where a choice's latency came from (D791): this node's own benchmark
|
|
25027
|
+
* result, or the hardware-class table. Measured on the node beats the table.
|
|
25028
|
+
*/
|
|
25029
|
+
var LatencySourceSchema = discriminatedUnion("kind", [object({ kind: literal("table") }), object({
|
|
25030
|
+
kind: literal("node-benchmark"),
|
|
25031
|
+
resultId: string(),
|
|
25032
|
+
measuredAtMs: number()
|
|
25033
|
+
})]);
|
|
23924
25034
|
var DeviceModelChoiceSchema = object({
|
|
23925
25035
|
modelId: string(),
|
|
23926
25036
|
p50Ms: number(),
|
|
23927
25037
|
fps: number(),
|
|
23928
25038
|
measured: boolean(),
|
|
25039
|
+
/** Absent on a plan from before D791 — read as the table. */
|
|
25040
|
+
source: LatencySourceSchema.optional(),
|
|
23929
25041
|
/** Parallel sessions at the camera rate with the headroom kept free: ⌊fps × (1 − headroom) / camera fps⌋. */
|
|
23930
25042
|
capacitySessions: number(),
|
|
23931
25043
|
personRecall: number()
|
|
@@ -23936,6 +25048,8 @@ var DeviceRecommendationSchema = object({
|
|
|
23936
25048
|
enabled: boolean(),
|
|
23937
25049
|
available: boolean(),
|
|
23938
25050
|
chosen: DeviceModelChoiceSchema.nullable(),
|
|
25051
|
+
/** Present when the device is kept as is: Apply writes nothing on it. */
|
|
25052
|
+
kept: KeptReasonSchema.optional(),
|
|
23939
25053
|
reasons: array(RecommendationReasonSchema).readonly()
|
|
23940
25054
|
});
|
|
23941
25055
|
var NodeRecommendationSchema = object({
|
|
@@ -23989,6 +25103,17 @@ var AutoTuneWarningSchema = discriminatedUnion("kind", [
|
|
|
23989
25103
|
kind: literal("coral-enabled"),
|
|
23990
25104
|
nodeId: string(),
|
|
23991
25105
|
deviceKey: string()
|
|
25106
|
+
}),
|
|
25107
|
+
object({
|
|
25108
|
+
kind: literal("custom-pin-kept"),
|
|
25109
|
+
nodeId: string(),
|
|
25110
|
+
deviceKey: string().nullable(),
|
|
25111
|
+
deviceId: number().nullable(),
|
|
25112
|
+
modelId: string()
|
|
25113
|
+
}),
|
|
25114
|
+
object({
|
|
25115
|
+
kind: literal("custom-registry-unreadable"),
|
|
25116
|
+
error: string()
|
|
23992
25117
|
})
|
|
23993
25118
|
]);
|
|
23994
25119
|
var AutoTuneOptionsSchema = object({
|
|
@@ -24167,13 +25292,14 @@ var SuggestSharesResultSchema = object({
|
|
|
24167
25292
|
nodes: array(SuggestedNodeShareSchema).readonly()
|
|
24168
25293
|
});
|
|
24169
25294
|
/**
|
|
24170
|
-
* Where the node and device shares come from (D750, D755).
|
|
24171
|
-
* - `
|
|
24172
|
-
*
|
|
24173
|
-
*
|
|
24174
|
-
*
|
|
24175
|
-
*
|
|
24176
|
-
*
|
|
25295
|
+
* Where the node and device shares come from (D750, D755, D791).
|
|
25296
|
+
* - `benchmark` (default since D791) — each node's capacity as its own node
|
|
25297
|
+
* benchmark measured it; a node without fresh results is sized from
|
|
25298
|
+
* `hardware` and says so. Measured on the node beats the table.
|
|
25299
|
+
* - `hardware` — computed from each node's static capacity only (its
|
|
25300
|
+
* accelerators at their Auto tune models, from the latency table).
|
|
25301
|
+
* Both are recomputed whenever a node or device joins, leaves, is switched
|
|
25302
|
+
* on or off, changes model, or a benchmark result is written. No load input.
|
|
24177
25303
|
* - `manual` — the operator's stored sliders.
|
|
24178
25304
|
* Either way a node or device that cannot take sessions gives its share to
|
|
24179
25305
|
* the others in proportion; the stored values are never rewritten for that.
|
|
@@ -24222,6 +25348,362 @@ var EffectiveSharesSchema = object({
|
|
|
24222
25348
|
nodeWeights: record(string(), number()),
|
|
24223
25349
|
deviceWeights: record(string(), record(string(), number()))
|
|
24224
25350
|
});
|
|
25351
|
+
/**
|
|
25352
|
+
* Wire shapes of the node benchmark (D791): the operator's plan, the
|
|
25353
|
+
* preflight the confirmation shows, the run the orchestrator owns, what the
|
|
25354
|
+
* measuring runner reports, and the result rows Auto tune and the automatic
|
|
25355
|
+
* shares read.
|
|
25356
|
+
*
|
|
25357
|
+
* ONE node at a time, owned by the pipeline orchestrator. While a run is
|
|
25358
|
+
* active the cluster is in BENCHMARK MODE for that node: it is not a
|
|
25359
|
+
* detection candidate, camera pins naming it are ignored so its sessions move
|
|
25360
|
+
* elsewhere, and both come back when the mode ends — after a crash or a hub
|
|
25361
|
+
* restart too (a persisted manifest, reconciled against the registry).
|
|
25362
|
+
*
|
|
25363
|
+
* Two measurements, both on the node, driven by its own runner (frames never
|
|
25364
|
+
* cross a process at frame rate, D9):
|
|
25365
|
+
* - `device` — one accelerator × one candidate detector, root only, open
|
|
25366
|
+
* loop: how many streams at the camera rate it carries inside the frame
|
|
25367
|
+
* interval;
|
|
25368
|
+
* - `pipeline` — the chosen detector plus the operator's detail steps
|
|
25369
|
+
* (faces, plates, classifiers, CLIP…), on varied frames from the bench
|
|
25370
|
+
* clip: how many parallel sessions the node carries before latency, drops,
|
|
25371
|
+
* CPU, RAM or the event loop give out.
|
|
25372
|
+
*/
|
|
25373
|
+
/** Defaults and bounds, one place. */
|
|
25374
|
+
var NODE_BENCHMARK_DEFAULTS = {
|
|
25375
|
+
fpsPerStream: 10,
|
|
25376
|
+
budgetMinutes: 45,
|
|
25377
|
+
minBudgetMinutes: 10,
|
|
25378
|
+
maxBudgetMinutes: 120,
|
|
25379
|
+
/** A result older than this no longer beats the table. */
|
|
25380
|
+
staleAfterDays: 30,
|
|
25381
|
+
/** Warm-in per ramp step (not accounted). */
|
|
25382
|
+
settleSec: 5,
|
|
25383
|
+
/** Accounted window per ramp step. */
|
|
25384
|
+
measureSec: 20,
|
|
25385
|
+
/** Longest the drain may take before the run is refused. */
|
|
25386
|
+
drainTimeoutSec: 120,
|
|
25387
|
+
/** Candidate models per device in one run. */
|
|
25388
|
+
maxModelsPerDevice: 6
|
|
25389
|
+
};
|
|
25390
|
+
/** The two measurements a run can make. */
|
|
25391
|
+
var NodeBenchmarkPhaseSchema = _enum(["device", "pipeline"]);
|
|
25392
|
+
var NodeBenchmarkDevicePlanSchema = object({
|
|
25393
|
+
/** `<backend>:<device>` — `openvino:npu`, `coreml:all`, `cpu`. */
|
|
25394
|
+
deviceKey: string().min(1),
|
|
25395
|
+
/**
|
|
25396
|
+
* Root detector candidates for the `device` measurement, in the order
|
|
25397
|
+
* measured. The FIRST is the one the `pipeline` measurement runs.
|
|
25398
|
+
*/
|
|
25399
|
+
modelIds: array(string().min(1)).min(1).max(NODE_BENCHMARK_DEFAULTS.maxModelsPerDevice)
|
|
25400
|
+
});
|
|
25401
|
+
/** What the operator confirmed. */
|
|
25402
|
+
var NodeBenchmarkPlanSchema = object({
|
|
25403
|
+
nodeId: string().min(1),
|
|
25404
|
+
devices: array(NodeBenchmarkDevicePlanSchema).min(1),
|
|
25405
|
+
/** Which measurements run. At least one. */
|
|
25406
|
+
phases: array(NodeBenchmarkPhaseSchema).min(1).default(["device", "pipeline"]),
|
|
25407
|
+
/**
|
|
25408
|
+
* Detail steps (catalog step ids under the root: faces, plates,
|
|
25409
|
+
* classifiers, CLIP…) the `pipeline` measurement enables. The root
|
|
25410
|
+
* detector always runs.
|
|
25411
|
+
*/
|
|
25412
|
+
steps: array(string().min(1)).default([]),
|
|
25413
|
+
/** Offered rate of one stream / session. */
|
|
25414
|
+
fpsPerStream: number().int().min(1).max(30).default(NODE_BENCHMARK_DEFAULTS.fpsPerStream),
|
|
25415
|
+
/** Hard deadline: the mode ends and the cluster is restored when it passes. */
|
|
25416
|
+
budgetMinutes: number().int().min(NODE_BENCHMARK_DEFAULTS.minBudgetMinutes).max(NODE_BENCHMARK_DEFAULTS.maxBudgetMinutes).default(NODE_BENCHMARK_DEFAULTS.budgetMinutes),
|
|
25417
|
+
/** Keep the models the run downloaded (default: delete what was not installed before). */
|
|
25418
|
+
keepDownloadedModels: boolean().default(false),
|
|
25419
|
+
/** Start although the other nodes' capacity does not cover the sessions running now. */
|
|
25420
|
+
acceptInsufficientHeadroom: boolean().default(false)
|
|
25421
|
+
});
|
|
25422
|
+
var NodeBenchmarkRefusalSchema = object({
|
|
25423
|
+
kind: _enum([
|
|
25424
|
+
"benchmark-active",
|
|
25425
|
+
"node-offline",
|
|
25426
|
+
"unknown-device",
|
|
25427
|
+
"no-phase",
|
|
25428
|
+
"insufficient-headroom"
|
|
25429
|
+
]),
|
|
25430
|
+
detail: string(),
|
|
25431
|
+
overridable: boolean()
|
|
25432
|
+
});
|
|
25433
|
+
/** A camera whose detection leaves the node for the run. */
|
|
25434
|
+
var NodeBenchmarkMovingCameraSchema = object({
|
|
25435
|
+
deviceId: number(),
|
|
25436
|
+
/** Placed on the node now (a standing attach or a running session). */
|
|
25437
|
+
placedOnNode: boolean(),
|
|
25438
|
+
/** A node pin naming this node, ignored for the run. */
|
|
25439
|
+
pinnedToNode: boolean()
|
|
25440
|
+
});
|
|
25441
|
+
/** A camera that cannot leave: no other node can obtain its frames. */
|
|
25442
|
+
var NodeBenchmarkStayingCameraSchema = object({
|
|
25443
|
+
deviceId: number(),
|
|
25444
|
+
reason: literal("only-frame-source")
|
|
25445
|
+
});
|
|
25446
|
+
var NodeBenchmarkPreflightSchema = object({
|
|
25447
|
+
nodeId: string(),
|
|
25448
|
+
online: boolean(),
|
|
25449
|
+
moving: array(NodeBenchmarkMovingCameraSchema).readonly(),
|
|
25450
|
+
/** Keep running on the node during the run; the result is flagged contaminated. */
|
|
25451
|
+
staying: array(NodeBenchmarkStayingCameraSchema).readonly(),
|
|
25452
|
+
estimatedDurationMs: number(),
|
|
25453
|
+
/** The run ends and the cluster is restored at the latest after this long. */
|
|
25454
|
+
budgetMs: number(),
|
|
25455
|
+
phases: array(NodeBenchmarkPhaseSchema).readonly(),
|
|
25456
|
+
steps: array(string()).readonly(),
|
|
25457
|
+
devices: array(NodeBenchmarkDevicePlanSchema).readonly(),
|
|
25458
|
+
/** Parallel sessions the cluster runs now, and what the other nodes can carry. */
|
|
25459
|
+
headroom: object({
|
|
25460
|
+
sessionsNow: number(),
|
|
25461
|
+
otherNodesCapacitySessions: number().nullable()
|
|
25462
|
+
}),
|
|
25463
|
+
refusals: array(NodeBenchmarkRefusalSchema).readonly(),
|
|
25464
|
+
canStart: boolean()
|
|
25465
|
+
});
|
|
25466
|
+
/** Where the measuring runner is. */
|
|
25467
|
+
var NodeBenchProgressSchema = object({
|
|
25468
|
+
phase: _enum([
|
|
25469
|
+
"preparing",
|
|
25470
|
+
"device",
|
|
25471
|
+
"pipeline",
|
|
25472
|
+
"cleanup"
|
|
25473
|
+
]),
|
|
25474
|
+
deviceKey: string().nullable(),
|
|
25475
|
+
modelId: string().nullable(),
|
|
25476
|
+
/** Streams (device) or sessions (pipeline) of the step running now. */
|
|
25477
|
+
load: number().nullable(),
|
|
25478
|
+
cellsDone: number(),
|
|
25479
|
+
cellsTotal: number(),
|
|
25480
|
+
message: string()
|
|
25481
|
+
});
|
|
25482
|
+
/** What limited a ramp. `none` = the ramp ran out of steps still passing. */
|
|
25483
|
+
var NodeBenchLimitSchema = _enum([
|
|
25484
|
+
"p95",
|
|
25485
|
+
"drops",
|
|
25486
|
+
"errors",
|
|
25487
|
+
"stall",
|
|
25488
|
+
"cpu",
|
|
25489
|
+
"ram",
|
|
25490
|
+
"none"
|
|
25491
|
+
]);
|
|
25492
|
+
var NodeBenchRampStepSchema = object({
|
|
25493
|
+
load: number(),
|
|
25494
|
+
pass: boolean(),
|
|
25495
|
+
p95Ms: number(),
|
|
25496
|
+
dropRatio: number(),
|
|
25497
|
+
/** Completed frames per second over the accounted window. */
|
|
25498
|
+
fps: number(),
|
|
25499
|
+
cpuPct: number().nullable(),
|
|
25500
|
+
ramFreePct: number().nullable(),
|
|
25501
|
+
failReasons: array(string()).readonly()
|
|
25502
|
+
});
|
|
25503
|
+
var NodeBenchCellSchema = discriminatedUnion("phase", [object({
|
|
25504
|
+
phase: literal("device"),
|
|
25505
|
+
deviceKey: string(),
|
|
25506
|
+
modelId: string(),
|
|
25507
|
+
/** Single-stream latency (the K = 1 step). */
|
|
25508
|
+
p50Ms: number(),
|
|
25509
|
+
p95Ms: number(),
|
|
25510
|
+
p99Ms: number(),
|
|
25511
|
+
/** Frames per second sustained at the last passing step. */
|
|
25512
|
+
fps: number(),
|
|
25513
|
+
/** Streams at the camera rate the device carried inside the frame interval; 0 = not even one. */
|
|
25514
|
+
kMax: number(),
|
|
25515
|
+
limitedBy: NodeBenchLimitSchema,
|
|
25516
|
+
/** First call (model load + warm-up). */
|
|
25517
|
+
warmupMs: number(),
|
|
25518
|
+
ramp: array(NodeBenchRampStepSchema).readonly()
|
|
25519
|
+
}), object({
|
|
25520
|
+
phase: literal("pipeline"),
|
|
25521
|
+
deviceKey: string(),
|
|
25522
|
+
modelId: string(),
|
|
25523
|
+
steps: array(string()).readonly(),
|
|
25524
|
+
/** Parallel sessions at the camera rate the node carried; 0 = not even one. */
|
|
25525
|
+
maxSessions: number(),
|
|
25526
|
+
limitedBy: NodeBenchLimitSchema,
|
|
25527
|
+
/** Detail steps that produced at least one result on the clip — a step that never fired measured nothing. */
|
|
25528
|
+
stepsFired: array(string()).readonly(),
|
|
25529
|
+
warmupMs: number(),
|
|
25530
|
+
ramp: array(NodeBenchRampStepSchema).readonly()
|
|
25531
|
+
})]);
|
|
25532
|
+
/**
|
|
25533
|
+
* What a result was measured ON. A result whose fingerprint no longer matches
|
|
25534
|
+
* the node no longer beats the table.
|
|
25535
|
+
*/
|
|
25536
|
+
var NodeBenchFingerprintSchema = object({
|
|
25537
|
+
pipelineVersion: string().nullable(),
|
|
25538
|
+
cpuModel: string().nullable(),
|
|
25539
|
+
ramGb: number().nullable(),
|
|
25540
|
+
os: string().nullable(),
|
|
25541
|
+
/** Per device: the pool the cells ran on (runtime + tuning). */
|
|
25542
|
+
pools: record(string(), object({
|
|
25543
|
+
runtime: string(),
|
|
25544
|
+
numWorkers: number(),
|
|
25545
|
+
concurrency: number(),
|
|
25546
|
+
batchMode: string()
|
|
25547
|
+
})).default({})
|
|
25548
|
+
});
|
|
25549
|
+
/** The frames the run used. */
|
|
25550
|
+
var NodeBenchClipSchema = object({
|
|
25551
|
+
/** `camstack-bench-v1` or `reference-images` (the fallback when the clip is not reachable). */
|
|
25552
|
+
id: string(),
|
|
25553
|
+
revision: string().nullable(),
|
|
25554
|
+
frames: number(),
|
|
25555
|
+
width: number(),
|
|
25556
|
+
height: number()
|
|
25557
|
+
});
|
|
25558
|
+
var NodeBenchRunnerInputSchema = object({
|
|
25559
|
+
runId: string().min(1),
|
|
25560
|
+
devices: array(NodeBenchmarkDevicePlanSchema).min(1),
|
|
25561
|
+
phases: array(NodeBenchmarkPhaseSchema).min(1),
|
|
25562
|
+
/** The full detail step tree root for the `pipeline` measurement is built on the node from these ids. */
|
|
25563
|
+
steps: array(string()).default([]),
|
|
25564
|
+
fpsPerStream: number().int().min(1).max(30),
|
|
25565
|
+
/** The runner stops on its own past this instant, whatever the hub does. */
|
|
25566
|
+
deadlineAtMs: number(),
|
|
25567
|
+
settleSec: number().min(0).max(60).default(NODE_BENCHMARK_DEFAULTS.settleSec),
|
|
25568
|
+
measureSec: number().min(2).max(300).default(NODE_BENCHMARK_DEFAULTS.measureSec),
|
|
25569
|
+
keepDownloadedModels: boolean().default(false)
|
|
25570
|
+
});
|
|
25571
|
+
var NodeBenchRunnerStateSchema = _enum([
|
|
25572
|
+
"running",
|
|
25573
|
+
"done",
|
|
25574
|
+
"failed",
|
|
25575
|
+
"aborted"
|
|
25576
|
+
]);
|
|
25577
|
+
var NodeBenchRunnerStatusSchema = object({
|
|
25578
|
+
runId: string(),
|
|
25579
|
+
nodeId: string(),
|
|
25580
|
+
state: NodeBenchRunnerStateSchema,
|
|
25581
|
+
progress: NodeBenchProgressSchema.nullable(),
|
|
25582
|
+
/** Every finished cell so far — a run stopped half way keeps what it measured. */
|
|
25583
|
+
cells: array(NodeBenchCellSchema).readonly(),
|
|
25584
|
+
fingerprint: NodeBenchFingerprintSchema.nullable(),
|
|
25585
|
+
clip: NodeBenchClipSchema.nullable(),
|
|
25586
|
+
/** Cameras seen running on the node during the run (they share its pools). */
|
|
25587
|
+
activeCameraIds: array(number()).readonly(),
|
|
25588
|
+
/** Models the run downloaded and then deleted (not installed before). */
|
|
25589
|
+
deletedModels: array(string()).readonly(),
|
|
25590
|
+
error: string().nullable(),
|
|
25591
|
+
startedAtMs: number(),
|
|
25592
|
+
finishedAtMs: number().nullable()
|
|
25593
|
+
});
|
|
25594
|
+
var NodeBenchmarkStateSchema = _enum([
|
|
25595
|
+
"draining",
|
|
25596
|
+
"measuring",
|
|
25597
|
+
"restoring",
|
|
25598
|
+
"done",
|
|
25599
|
+
"failed",
|
|
25600
|
+
"aborted"
|
|
25601
|
+
]);
|
|
25602
|
+
/** Why a run ended. */
|
|
25603
|
+
var NodeBenchmarkEndReasonSchema = _enum([
|
|
25604
|
+
"completed",
|
|
25605
|
+
"operator-abort",
|
|
25606
|
+
"deadline",
|
|
25607
|
+
"node-lost",
|
|
25608
|
+
"runner-lost",
|
|
25609
|
+
"hub-restart",
|
|
25610
|
+
"drain-timeout",
|
|
25611
|
+
"failed"
|
|
25612
|
+
]);
|
|
25613
|
+
var NodeBenchmarkRunSchema = object({
|
|
25614
|
+
runId: string(),
|
|
25615
|
+
nodeId: string(),
|
|
25616
|
+
state: NodeBenchmarkStateSchema,
|
|
25617
|
+
plan: NodeBenchmarkPlanSchema,
|
|
25618
|
+
startedAtMs: number(),
|
|
25619
|
+
deadlineAtMs: number(),
|
|
25620
|
+
finishedAtMs: number().nullable(),
|
|
25621
|
+
endReason: NodeBenchmarkEndReasonSchema.nullable(),
|
|
25622
|
+
error: string().nullable(),
|
|
25623
|
+
/** Cameras moved off the node for the run. */
|
|
25624
|
+
moved: array(number()).readonly(),
|
|
25625
|
+
/** Cameras that stayed (their only frame source is the node). */
|
|
25626
|
+
staying: array(number()).readonly(),
|
|
25627
|
+
/** Something else ran on the node's pools during the run. */
|
|
25628
|
+
contaminated: boolean(),
|
|
25629
|
+
progress: NodeBenchProgressSchema.nullable(),
|
|
25630
|
+
resultIds: array(string()).readonly()
|
|
25631
|
+
});
|
|
25632
|
+
/** `getNodeBenchmarkStatus`: the active run (or the last one), and whether benchmark mode is on. */
|
|
25633
|
+
var NodeBenchmarkStatusSchema = object({
|
|
25634
|
+
/** The node the cluster is in benchmark mode for; null = none. */
|
|
25635
|
+
modeNodeId: string().nullable(),
|
|
25636
|
+
run: NodeBenchmarkRunSchema.nullable()
|
|
25637
|
+
});
|
|
25638
|
+
/**
|
|
25639
|
+
* One stored measurement — the only authority Auto tune and the automatic
|
|
25640
|
+
* shares read measured capacity from. Written by the orchestrator when a run
|
|
25641
|
+
* ends, never by anyone else.
|
|
25642
|
+
*/
|
|
25643
|
+
var NodeBenchmarkResultSchema = object({
|
|
25644
|
+
id: string(),
|
|
25645
|
+
runId: string(),
|
|
25646
|
+
nodeId: string(),
|
|
25647
|
+
cell: NodeBenchCellSchema,
|
|
25648
|
+
fpsPerStream: number(),
|
|
25649
|
+
fingerprint: NodeBenchFingerprintSchema.nullable(),
|
|
25650
|
+
clip: NodeBenchClipSchema.nullable(),
|
|
25651
|
+
/** Other work shared the node's pools (a camera that could not move, or the hub's own duties). */
|
|
25652
|
+
contaminated: boolean(),
|
|
25653
|
+
stayingCameras: array(number()).readonly(),
|
|
25654
|
+
/** The run ended early; the cell itself completed. */
|
|
25655
|
+
partialRun: boolean(),
|
|
25656
|
+
measuredAtMs: number()
|
|
25657
|
+
});
|
|
25658
|
+
var ListNodeBenchmarkResultsInputSchema = object({
|
|
25659
|
+
nodeId: string().optional(),
|
|
25660
|
+
deviceKey: string().optional()
|
|
25661
|
+
});
|
|
25662
|
+
/** A result with its standing today: does it still beat the table? */
|
|
25663
|
+
var NodeBenchmarkResultViewSchema = NodeBenchmarkResultSchema.extend({
|
|
25664
|
+
/** `fresh` beats the table; `stale` falls back to it; `superseded` = a newer row covers the same cell. */
|
|
25665
|
+
standing: _enum([
|
|
25666
|
+
"fresh",
|
|
25667
|
+
"stale",
|
|
25668
|
+
"superseded"
|
|
25669
|
+
]),
|
|
25670
|
+
staleWhy: _enum([
|
|
25671
|
+
"age",
|
|
25672
|
+
"runtime",
|
|
25673
|
+
"pool-tuning"
|
|
25674
|
+
]).nullable()
|
|
25675
|
+
});
|
|
25676
|
+
object({
|
|
25677
|
+
runId: string(),
|
|
25678
|
+
nodeId: string(),
|
|
25679
|
+
startedAtMs: number(),
|
|
25680
|
+
deadlineAtMs: number(),
|
|
25681
|
+
plan: NodeBenchmarkPlanSchema,
|
|
25682
|
+
moved: array(number()).readonly(),
|
|
25683
|
+
staying: array(number()).readonly()
|
|
25684
|
+
});
|
|
25685
|
+
/** Candidate detectors per device, for the plan editor. */
|
|
25686
|
+
var NodeBenchmarkCandidatesSchema = object({
|
|
25687
|
+
nodeId: string(),
|
|
25688
|
+
reachable: boolean(),
|
|
25689
|
+
devices: array(object({
|
|
25690
|
+
deviceKey: string(),
|
|
25691
|
+
enabled: boolean(),
|
|
25692
|
+
available: boolean(),
|
|
25693
|
+
/** Ordered: what Auto tune would pick first, then the rest of the family by accuracy. */
|
|
25694
|
+
suggested: array(string()).readonly(),
|
|
25695
|
+
/** Every root detector the device has a build for. */
|
|
25696
|
+
all: array(string()).readonly(),
|
|
25697
|
+
/** The model the device runs now, when pinned. */
|
|
25698
|
+
currentModelId: string().nullable()
|
|
25699
|
+
})).readonly(),
|
|
25700
|
+
/** Detail steps the node's catalog offers under the root (for the `pipeline` measurement). */
|
|
25701
|
+
steps: array(object({
|
|
25702
|
+
id: string(),
|
|
25703
|
+
name: string(),
|
|
25704
|
+
slot: string()
|
|
25705
|
+
})).readonly()
|
|
25706
|
+
});
|
|
24225
25707
|
object({
|
|
24226
25708
|
activeCameras: number(),
|
|
24227
25709
|
throttledCameras: number(),
|
|
@@ -25310,7 +26792,16 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
|
|
|
25310
26792
|
}), ParkedTrackFrameSchema.nullable()), method(object({
|
|
25311
26793
|
deviceId: number(),
|
|
25312
26794
|
trackId: string()
|
|
25313
|
-
}), object({ released: number().int().nonnegative() }), { kind: "mutation" })
|
|
26795
|
+
}), object({ released: number().int().nonnegative() }), { kind: "mutation" }), method(NodeBenchRunnerInputSchema, object({
|
|
26796
|
+
accepted: literal(true),
|
|
26797
|
+
runId: string()
|
|
26798
|
+
}), {
|
|
26799
|
+
kind: "mutation",
|
|
26800
|
+
auth: "admin"
|
|
26801
|
+
}), method(object({ runId: string() }), NodeBenchRunnerStatusSchema.nullable(), { auth: "admin" }), method(object({ runId: string() }), object({ aborted: boolean() }), {
|
|
26802
|
+
kind: "mutation",
|
|
26803
|
+
auth: "admin"
|
|
26804
|
+
});
|
|
25314
26805
|
var CameraPipelineConfigSchema = object({
|
|
25315
26806
|
engine: PipelineEngineChoiceSchema.optional(),
|
|
25316
26807
|
steps: array(PipelineStepInputSchema).readonly(),
|
|
@@ -25949,6 +27440,21 @@ method(object({
|
|
|
25949
27440
|
}), method(PlacementStatsInputSchema, PlacementStatsSchema, { auth: "admin" }), method(SuggestSharesInputSchema, SuggestSharesResultSchema, { auth: "admin" }), method(_void(), EffectiveSharesSchema, { auth: "admin" }), method(SetShareSourceInputSchema, object({ success: literal(true) }), {
|
|
25950
27441
|
kind: "mutation",
|
|
25951
27442
|
auth: "admin"
|
|
27443
|
+
}), method(NodeBenchmarkPlanSchema, NodeBenchmarkPreflightSchema, { auth: "admin" }), method(NodeBenchmarkPlanSchema, discriminatedUnion("kind", [object({
|
|
27444
|
+
kind: literal("started"),
|
|
27445
|
+
runId: string()
|
|
27446
|
+
}), object({
|
|
27447
|
+
kind: literal("refused"),
|
|
27448
|
+
preflight: NodeBenchmarkPreflightSchema
|
|
27449
|
+
})]), {
|
|
27450
|
+
kind: "mutation",
|
|
27451
|
+
auth: "admin"
|
|
27452
|
+
}), method(_void(), NodeBenchmarkStatusSchema, { auth: "admin" }), method(object({ runId: string() }), object({ aborted: boolean() }), {
|
|
27453
|
+
kind: "mutation",
|
|
27454
|
+
auth: "admin"
|
|
27455
|
+
}), method(object({ nodeId: string() }), NodeBenchmarkCandidatesSchema, { auth: "admin" }), method(ListNodeBenchmarkResultsInputSchema, array(NodeBenchmarkResultViewSchema).readonly(), { auth: "admin" }), method(object({ id: string() }), object({ deleted: boolean() }), {
|
|
27456
|
+
kind: "mutation",
|
|
27457
|
+
auth: "admin"
|
|
25952
27458
|
}), method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()), method(object({
|
|
25953
27459
|
deviceId: number(),
|
|
25954
27460
|
addonId: string(),
|
|
@@ -31062,6 +32568,9 @@ var CreateIntegrationInputSchema = object({
|
|
|
31062
32568
|
enabled: boolean().optional(),
|
|
31063
32569
|
info: record(string(), unknown()).optional(),
|
|
31064
32570
|
settings: record(string(), unknown()).optional(),
|
|
32571
|
+
/** Keys of `settings` the form's schema declares secret (by type) — kept in
|
|
32572
|
+
* the keychain, not the table (D787). */
|
|
32573
|
+
secretKeys: array(string()).optional(),
|
|
31065
32574
|
skipRestart: boolean().optional()
|
|
31066
32575
|
});
|
|
31067
32576
|
var UpdateIntegrationInputSchema = object({
|
|
@@ -31085,7 +32594,9 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
|
|
|
31085
32594
|
auth: "admin"
|
|
31086
32595
|
}), method(object({ id: string() }), record(string(), unknown()), { auth: "admin" }), method(object({
|
|
31087
32596
|
id: string(),
|
|
31088
|
-
settings: record(string(), unknown())
|
|
32597
|
+
settings: record(string(), unknown()),
|
|
32598
|
+
/** Keys the form's schema declares secret (by type) — keychain-stored (D787). */
|
|
32599
|
+
secretKeys: array(string()).optional()
|
|
31089
32600
|
}), object({ success: boolean() }), {
|
|
31090
32601
|
kind: "mutation",
|
|
31091
32602
|
auth: "admin"
|
|
@@ -39813,6 +41324,90 @@ Object.freeze({
|
|
|
39813
41324
|
addonId: null,
|
|
39814
41325
|
access: "create"
|
|
39815
41326
|
},
|
|
41327
|
+
"keychain.delete": {
|
|
41328
|
+
capName: "keychain",
|
|
41329
|
+
capScope: "system",
|
|
41330
|
+
addonId: null,
|
|
41331
|
+
access: "delete"
|
|
41332
|
+
},
|
|
41333
|
+
"keychain.getState": {
|
|
41334
|
+
capName: "keychain",
|
|
41335
|
+
capScope: "system",
|
|
41336
|
+
addonId: null,
|
|
41337
|
+
access: "view"
|
|
41338
|
+
},
|
|
41339
|
+
"keychain.listMeta": {
|
|
41340
|
+
capName: "keychain",
|
|
41341
|
+
capScope: "system",
|
|
41342
|
+
addonId: null,
|
|
41343
|
+
access: "view"
|
|
41344
|
+
},
|
|
41345
|
+
"keychain.put": {
|
|
41346
|
+
capName: "keychain",
|
|
41347
|
+
capScope: "system",
|
|
41348
|
+
addonId: null,
|
|
41349
|
+
access: "create"
|
|
41350
|
+
},
|
|
41351
|
+
"keychain.resolve": {
|
|
41352
|
+
capName: "keychain",
|
|
41353
|
+
capScope: "system",
|
|
41354
|
+
addonId: null,
|
|
41355
|
+
access: "view"
|
|
41356
|
+
},
|
|
41357
|
+
"keychainAdmin.deleteOrphans": {
|
|
41358
|
+
capName: "keychain-admin",
|
|
41359
|
+
capScope: "system",
|
|
41360
|
+
addonId: null,
|
|
41361
|
+
access: "delete"
|
|
41362
|
+
},
|
|
41363
|
+
"keychainAdmin.downloadRecoveryKey": {
|
|
41364
|
+
capName: "keychain-admin",
|
|
41365
|
+
capScope: "system",
|
|
41366
|
+
addonId: null,
|
|
41367
|
+
access: "create"
|
|
41368
|
+
},
|
|
41369
|
+
"keychainAdmin.listAudit": {
|
|
41370
|
+
capName: "keychain-admin",
|
|
41371
|
+
capScope: "system",
|
|
41372
|
+
addonId: null,
|
|
41373
|
+
access: "view"
|
|
41374
|
+
},
|
|
41375
|
+
"keychainAdmin.listSecrets": {
|
|
41376
|
+
capName: "keychain-admin",
|
|
41377
|
+
capScope: "system",
|
|
41378
|
+
addonId: null,
|
|
41379
|
+
access: "view"
|
|
41380
|
+
},
|
|
41381
|
+
"keychainAdmin.purgeRetiredKeys": {
|
|
41382
|
+
capName: "keychain-admin",
|
|
41383
|
+
capScope: "system",
|
|
41384
|
+
addonId: null,
|
|
41385
|
+
access: "delete"
|
|
41386
|
+
},
|
|
41387
|
+
"keychainAdmin.resetKeychain": {
|
|
41388
|
+
capName: "keychain-admin",
|
|
41389
|
+
capScope: "system",
|
|
41390
|
+
addonId: null,
|
|
41391
|
+
access: "delete"
|
|
41392
|
+
},
|
|
41393
|
+
"keychainAdmin.reveal": {
|
|
41394
|
+
capName: "keychain-admin",
|
|
41395
|
+
capScope: "system",
|
|
41396
|
+
addonId: null,
|
|
41397
|
+
access: "create"
|
|
41398
|
+
},
|
|
41399
|
+
"keychainAdmin.rotateMasterKey": {
|
|
41400
|
+
capName: "keychain-admin",
|
|
41401
|
+
capScope: "system",
|
|
41402
|
+
addonId: null,
|
|
41403
|
+
access: "create"
|
|
41404
|
+
},
|
|
41405
|
+
"keychainAdmin.status": {
|
|
41406
|
+
capName: "keychain-admin",
|
|
41407
|
+
capScope: "system",
|
|
41408
|
+
addonId: null,
|
|
41409
|
+
access: "view"
|
|
41410
|
+
},
|
|
39816
41411
|
"lawnMowerControl.dock": {
|
|
39817
41412
|
capName: "lawn-mower-control",
|
|
39818
41413
|
capScope: "device",
|
|
@@ -41187,6 +42782,18 @@ Object.freeze({
|
|
|
41187
42782
|
addonId: null,
|
|
41188
42783
|
access: "create"
|
|
41189
42784
|
},
|
|
42785
|
+
"pipelineAnalytics.addRetrainFrameFromRecording": {
|
|
42786
|
+
capName: "pipeline-analytics",
|
|
42787
|
+
capScope: "device",
|
|
42788
|
+
addonId: null,
|
|
42789
|
+
access: "create"
|
|
42790
|
+
},
|
|
42791
|
+
"pipelineAnalytics.addRetrainFramesToCollection": {
|
|
42792
|
+
capName: "pipeline-analytics",
|
|
42793
|
+
capScope: "device",
|
|
42794
|
+
addonId: null,
|
|
42795
|
+
access: "create"
|
|
42796
|
+
},
|
|
41190
42797
|
"pipelineAnalytics.applyDeviceSettingsPatch": {
|
|
41191
42798
|
capName: "pipeline-analytics",
|
|
41192
42799
|
capScope: "device",
|
|
@@ -41235,12 +42842,24 @@ Object.freeze({
|
|
|
41235
42842
|
addonId: null,
|
|
41236
42843
|
access: "view"
|
|
41237
42844
|
},
|
|
42845
|
+
"pipelineAnalytics.createRetrainCollection": {
|
|
42846
|
+
capName: "pipeline-analytics",
|
|
42847
|
+
capScope: "device",
|
|
42848
|
+
addonId: null,
|
|
42849
|
+
access: "create"
|
|
42850
|
+
},
|
|
41238
42851
|
"pipelineAnalytics.deleteDeviceEvents": {
|
|
41239
42852
|
capName: "pipeline-analytics",
|
|
41240
42853
|
capScope: "device",
|
|
41241
42854
|
addonId: null,
|
|
41242
42855
|
access: "delete"
|
|
41243
42856
|
},
|
|
42857
|
+
"pipelineAnalytics.deleteRetrainCollection": {
|
|
42858
|
+
capName: "pipeline-analytics",
|
|
42859
|
+
capScope: "device",
|
|
42860
|
+
addonId: null,
|
|
42861
|
+
access: "delete"
|
|
42862
|
+
},
|
|
41244
42863
|
"pipelineAnalytics.deleteTracks": {
|
|
41245
42864
|
capName: "pipeline-analytics",
|
|
41246
42865
|
capScope: "device",
|
|
@@ -41445,6 +43064,24 @@ Object.freeze({
|
|
|
41445
43064
|
addonId: null,
|
|
41446
43065
|
access: "view"
|
|
41447
43066
|
},
|
|
43067
|
+
"pipelineAnalytics.listRetrainAssistProfiles": {
|
|
43068
|
+
capName: "pipeline-analytics",
|
|
43069
|
+
capScope: "device",
|
|
43070
|
+
addonId: null,
|
|
43071
|
+
access: "view"
|
|
43072
|
+
},
|
|
43073
|
+
"pipelineAnalytics.listRetrainCollectionFrames": {
|
|
43074
|
+
capName: "pipeline-analytics",
|
|
43075
|
+
capScope: "device",
|
|
43076
|
+
addonId: null,
|
|
43077
|
+
access: "view"
|
|
43078
|
+
},
|
|
43079
|
+
"pipelineAnalytics.listRetrainCollections": {
|
|
43080
|
+
capName: "pipeline-analytics",
|
|
43081
|
+
capScope: "device",
|
|
43082
|
+
addonId: null,
|
|
43083
|
+
access: "view"
|
|
43084
|
+
},
|
|
41448
43085
|
"pipelineAnalytics.listRetrainFrames": {
|
|
41449
43086
|
capName: "pipeline-analytics",
|
|
41450
43087
|
capScope: "device",
|
|
@@ -41493,6 +43130,12 @@ Object.freeze({
|
|
|
41493
43130
|
addonId: null,
|
|
41494
43131
|
access: "create"
|
|
41495
43132
|
},
|
|
43133
|
+
"pipelineAnalytics.proposeRetrainAnnotationsWithLlm": {
|
|
43134
|
+
capName: "pipeline-analytics",
|
|
43135
|
+
capScope: "device",
|
|
43136
|
+
addonId: null,
|
|
43137
|
+
access: "create"
|
|
43138
|
+
},
|
|
41496
43139
|
"pipelineAnalytics.pruneEvents": {
|
|
41497
43140
|
capName: "pipeline-analytics",
|
|
41498
43141
|
capScope: "device",
|
|
@@ -41547,6 +43190,18 @@ Object.freeze({
|
|
|
41547
43190
|
addonId: null,
|
|
41548
43191
|
access: "create"
|
|
41549
43192
|
},
|
|
43193
|
+
"pipelineAnalytics.removeRetrainFrameFromCollection": {
|
|
43194
|
+
capName: "pipeline-analytics",
|
|
43195
|
+
capScope: "device",
|
|
43196
|
+
addonId: null,
|
|
43197
|
+
access: "delete"
|
|
43198
|
+
},
|
|
43199
|
+
"pipelineAnalytics.renameRetrainCollection": {
|
|
43200
|
+
capName: "pipeline-analytics",
|
|
43201
|
+
capScope: "device",
|
|
43202
|
+
addonId: null,
|
|
43203
|
+
access: "create"
|
|
43204
|
+
},
|
|
41550
43205
|
"pipelineAnalytics.restageRetrainTrack": {
|
|
41551
43206
|
capName: "pipeline-analytics",
|
|
41552
43207
|
capScope: "device",
|
|
@@ -41835,6 +43490,12 @@ Object.freeze({
|
|
|
41835
43490
|
addonId: null,
|
|
41836
43491
|
access: "view"
|
|
41837
43492
|
},
|
|
43493
|
+
"pipelineOrchestrator.abortNodeBenchmark": {
|
|
43494
|
+
capName: "pipeline-orchestrator",
|
|
43495
|
+
capScope: "system",
|
|
43496
|
+
addonId: null,
|
|
43497
|
+
access: "create"
|
|
43498
|
+
},
|
|
41838
43499
|
"pipelineOrchestrator.applyAutoTune": {
|
|
41839
43500
|
capName: "pipeline-orchestrator",
|
|
41840
43501
|
capScope: "system",
|
|
@@ -41859,6 +43520,12 @@ Object.freeze({
|
|
|
41859
43520
|
addonId: null,
|
|
41860
43521
|
access: "create"
|
|
41861
43522
|
},
|
|
43523
|
+
"pipelineOrchestrator.deleteNodeBenchmarkResult": {
|
|
43524
|
+
capName: "pipeline-orchestrator",
|
|
43525
|
+
capScope: "system",
|
|
43526
|
+
addonId: null,
|
|
43527
|
+
access: "delete"
|
|
43528
|
+
},
|
|
41862
43529
|
"pipelineOrchestrator.getAgentLoad": {
|
|
41863
43530
|
capName: "pipeline-orchestrator",
|
|
41864
43531
|
capScope: "system",
|
|
@@ -41967,6 +43634,24 @@ Object.freeze({
|
|
|
41967
43634
|
addonId: null,
|
|
41968
43635
|
access: "view"
|
|
41969
43636
|
},
|
|
43637
|
+
"pipelineOrchestrator.getNodeBenchmarkCandidates": {
|
|
43638
|
+
capName: "pipeline-orchestrator",
|
|
43639
|
+
capScope: "system",
|
|
43640
|
+
addonId: null,
|
|
43641
|
+
access: "view"
|
|
43642
|
+
},
|
|
43643
|
+
"pipelineOrchestrator.getNodeBenchmarkPreflight": {
|
|
43644
|
+
capName: "pipeline-orchestrator",
|
|
43645
|
+
capScope: "system",
|
|
43646
|
+
addonId: null,
|
|
43647
|
+
access: "view"
|
|
43648
|
+
},
|
|
43649
|
+
"pipelineOrchestrator.getNodeBenchmarkStatus": {
|
|
43650
|
+
capName: "pipeline-orchestrator",
|
|
43651
|
+
capScope: "system",
|
|
43652
|
+
addonId: null,
|
|
43653
|
+
access: "view"
|
|
43654
|
+
},
|
|
41970
43655
|
"pipelineOrchestrator.getNodeInferenceDevices": {
|
|
41971
43656
|
capName: "pipeline-orchestrator",
|
|
41972
43657
|
capScope: "system",
|
|
@@ -42009,6 +43694,12 @@ Object.freeze({
|
|
|
42009
43694
|
addonId: null,
|
|
42010
43695
|
access: "view"
|
|
42011
43696
|
},
|
|
43697
|
+
"pipelineOrchestrator.listNodeBenchmarkResults": {
|
|
43698
|
+
capName: "pipeline-orchestrator",
|
|
43699
|
+
capScope: "system",
|
|
43700
|
+
addonId: null,
|
|
43701
|
+
access: "view"
|
|
43702
|
+
},
|
|
42012
43703
|
"pipelineOrchestrator.pauseForStorageMigration": {
|
|
42013
43704
|
capName: "pipeline-orchestrator",
|
|
42014
43705
|
capScope: "system",
|
|
@@ -42153,6 +43844,12 @@ Object.freeze({
|
|
|
42153
43844
|
addonId: null,
|
|
42154
43845
|
access: "create"
|
|
42155
43846
|
},
|
|
43847
|
+
"pipelineOrchestrator.startNodeBenchmark": {
|
|
43848
|
+
capName: "pipeline-orchestrator",
|
|
43849
|
+
capScope: "system",
|
|
43850
|
+
addonId: null,
|
|
43851
|
+
access: "create"
|
|
43852
|
+
},
|
|
42156
43853
|
"pipelineOrchestrator.suggestShares": {
|
|
42157
43854
|
capName: "pipeline-orchestrator",
|
|
42158
43855
|
capScope: "system",
|
|
@@ -42171,6 +43868,12 @@ Object.freeze({
|
|
|
42171
43868
|
addonId: null,
|
|
42172
43869
|
access: "create"
|
|
42173
43870
|
},
|
|
43871
|
+
"pipelineRunner.abortNodeBench": {
|
|
43872
|
+
capName: "pipeline-runner",
|
|
43873
|
+
capScope: "system",
|
|
43874
|
+
addonId: null,
|
|
43875
|
+
access: "create"
|
|
43876
|
+
},
|
|
42174
43877
|
"pipelineRunner.attachCamera": {
|
|
42175
43878
|
capName: "pipeline-runner",
|
|
42176
43879
|
capScope: "system",
|
|
@@ -42219,6 +43922,12 @@ Object.freeze({
|
|
|
42219
43922
|
addonId: null,
|
|
42220
43923
|
access: "view"
|
|
42221
43924
|
},
|
|
43925
|
+
"pipelineRunner.getNodeBenchStatus": {
|
|
43926
|
+
capName: "pipeline-runner",
|
|
43927
|
+
capScope: "system",
|
|
43928
|
+
addonId: null,
|
|
43929
|
+
access: "view"
|
|
43930
|
+
},
|
|
42222
43931
|
"pipelineRunner.getParkedTrackFrame": {
|
|
42223
43932
|
capName: "pipeline-runner",
|
|
42224
43933
|
capScope: "system",
|
|
@@ -42261,6 +43970,12 @@ Object.freeze({
|
|
|
42261
43970
|
addonId: null,
|
|
42262
43971
|
access: "create"
|
|
42263
43972
|
},
|
|
43973
|
+
"pipelineRunner.startNodeBench": {
|
|
43974
|
+
capName: "pipeline-runner",
|
|
43975
|
+
capScope: "system",
|
|
43976
|
+
addonId: null,
|
|
43977
|
+
access: "create"
|
|
43978
|
+
},
|
|
42264
43979
|
"plateGallery.assignPlate": {
|
|
42265
43980
|
capName: "plate-gallery",
|
|
42266
43981
|
capScope: "system",
|
|
@@ -47190,6 +48905,11 @@ Object.freeze({
|
|
|
47190
48905
|
form: "single",
|
|
47191
48906
|
optional: false
|
|
47192
48907
|
}],
|
|
48908
|
+
"pipelineAnalytics.addRetrainFrameFromRecording": [{
|
|
48909
|
+
name: "deviceId",
|
|
48910
|
+
form: "single",
|
|
48911
|
+
optional: false
|
|
48912
|
+
}],
|
|
47193
48913
|
"pipelineAnalytics.applyDeviceSettingsPatch": [{
|
|
47194
48914
|
name: "deviceId",
|
|
47195
48915
|
form: "single",
|
|
@@ -47380,6 +49100,11 @@ Object.freeze({
|
|
|
47380
49100
|
form: "single",
|
|
47381
49101
|
optional: false
|
|
47382
49102
|
}],
|
|
49103
|
+
"pipelineAnalytics.proposeRetrainAnnotationsWithLlm": [{
|
|
49104
|
+
name: "deviceId",
|
|
49105
|
+
form: "single",
|
|
49106
|
+
optional: false
|
|
49107
|
+
}],
|
|
47383
49108
|
"pipelineAnalytics.pruneEventsBefore": [{
|
|
47384
49109
|
name: "deviceId",
|
|
47385
49110
|
form: "single",
|
|
@@ -50791,10 +52516,12 @@ var BRIGHTNESS_CONTROLLER = {
|
|
|
50791
52516
|
};
|
|
50792
52517
|
/**
|
|
50793
52518
|
* PTZ directional toggle — one `Alexa.ToggleController` instance per
|
|
50794
|
-
* pan/tilt direction. Each is MOMENTARY: turning the toggle ON
|
|
50795
|
-
*
|
|
50796
|
-
*
|
|
50797
|
-
*
|
|
52519
|
+
* pan/tilt direction. Each is MOMENTARY: turning the toggle ON moves the
|
|
52520
|
+
* head ~1 s through the driver's own self-terminating `ptz.move` pulses
|
|
52521
|
+
* (`round(TARGET_TRAVEL_MS / getOptions().moveImpulseMs)` of them, one
|
|
52522
|
+
* when the impulse is unknown — see `handlers/ptz-travel.ts`; never
|
|
52523
|
+
* `continuousMove` / `stop`), and the toggle auto-resets to OFF (handler
|
|
52524
|
+
* returns the OFF state in `context.properties`). Alexa renders one row per instance in the
|
|
50798
52525
|
* device detail; users can also say "Alexa, turn on Salone Pan Left".
|
|
50799
52526
|
*
|
|
50800
52527
|
* Instance ids match the directive routing in `ptz-direction.ts`.
|
|
@@ -52163,8 +53890,10 @@ var DetectionTracker = class {
|
|
|
52163
53890
|
* (R4, D779).
|
|
52164
53891
|
*
|
|
52165
53892
|
* - `objectIdentifier`: "a UUID identifying a specific physical object" — one
|
|
52166
|
-
* per pipeline track.
|
|
52167
|
-
*
|
|
53893
|
+
* per pipeline track. Amazon types it "Version 4 UUID String", so it is
|
|
53894
|
+
* v4-shaped like the event id: a hash of `object|<deviceId>:<trackId>` under
|
|
53895
|
+
* this hub's namespace, stable for the track's life and across restarts.
|
|
53896
|
+
* (The first build sent a UUIDv5 here — D779 amendment.)
|
|
52168
53897
|
* - `eventIdentifier`: Amazon requires a version-4 UUID per event in the
|
|
52169
53898
|
* history. This skill posts ONE event per track, so the event id is derived
|
|
52170
53899
|
* from the track too — a v4-shaped UUID whose 122 "random" bits are a
|
|
@@ -52191,10 +53920,6 @@ function stamp$1(bytes, version) {
|
|
|
52191
53920
|
out[8] = (out[8] ?? 0) & 63 | 128;
|
|
52192
53921
|
return out;
|
|
52193
53922
|
}
|
|
52194
|
-
/** RFC 4122 UUIDv5 (SHA-1, name-based). */
|
|
52195
|
-
function uuidV5(namespace, name) {
|
|
52196
|
-
return formatUuid(stamp$1(createHash("sha1").update(uuidBytes(namespace)).update(Buffer.from(name, "utf8")).digest(), 5));
|
|
52197
|
-
}
|
|
52198
53923
|
/** A version-4-formatted UUID whose bits are a hash — see the file header. */
|
|
52199
53924
|
function hashedUuidV4(namespace, name) {
|
|
52200
53925
|
return formatUuid(stamp$1(createHash("sha256").update(uuidBytes(namespace)).update(name, "utf8").digest(), 4));
|
|
@@ -52203,7 +53928,7 @@ function trackName(deviceId, trackId) {
|
|
|
52203
53928
|
return `${deviceId}:${trackId}`;
|
|
52204
53929
|
}
|
|
52205
53930
|
function objectIdentifierFor(namespace, deviceId, trackId) {
|
|
52206
|
-
return
|
|
53931
|
+
return hashedUuidV4(namespace, `object|${trackName(deviceId, trackId)}`);
|
|
52207
53932
|
}
|
|
52208
53933
|
/** One event per track AND class: a track re-classified person → package is a
|
|
52209
53934
|
* second event about the same object. */
|
|
@@ -52702,18 +54427,80 @@ async function handlePower(header, endpointRaw, deps, _callerIdentity) {
|
|
|
52702
54427
|
};
|
|
52703
54428
|
}
|
|
52704
54429
|
//#endregion
|
|
52705
|
-
//#region src/handlers/ptz-
|
|
54430
|
+
//#region src/handlers/ptz-travel.ts
|
|
54431
|
+
/**
|
|
54432
|
+
* One Alexa PTZ voice command = ~1 s of head travel, built ONLY from the PTZ
|
|
54433
|
+
* cap's `ptz.move`.
|
|
54434
|
+
*
|
|
54435
|
+
* `move` is a self-terminating burst: the driver runs it for the pulse it
|
|
54436
|
+
* calibrated and stops the head itself, and reports that pulse as
|
|
54437
|
+
* `getOptions().moveImpulseMs` (ptz.cap.ts) — 200 reolink, 350 amcrest,
|
|
54438
|
+
* 500 hikvision, 1000 onvif. One pulse is too short a nudge for a voice
|
|
54439
|
+
* command, so the travel issues `round(TARGET_TRAVEL_MS / moveImpulseMs)` of
|
|
54440
|
+
* them back to back. There is no `continuousMove` / timer / `stop` here:
|
|
54441
|
+
* ONVIF does not implement `continuousMove`, and `move` needs no stop.
|
|
54442
|
+
*
|
|
54443
|
+
* Drivers differ in WHEN `move` resolves: hikvision awaits its own pulse,
|
|
54444
|
+
* reolink / amcrest / onvif return as soon as the burst has started. Each pulse
|
|
54445
|
+
* is awaited, then the remainder of its impulse is waited out, so both kinds
|
|
54446
|
+
* yield consecutive pulses rather than overlapping ones (which a driver would
|
|
54447
|
+
* collapse into one). An unknown impulse is a single `move`.
|
|
54448
|
+
*/
|
|
54449
|
+
/** How far a voice command moves the head, in driver-pulse milliseconds. */
|
|
54450
|
+
var TARGET_TRAVEL_MS = 1e3;
|
|
54451
|
+
var realClock = {
|
|
54452
|
+
now: () => Date.now(),
|
|
54453
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
54454
|
+
};
|
|
54455
|
+
/** `max(1, round(TARGET_TRAVEL_MS / moveImpulseMs))`; unknown impulse → 1. */
|
|
54456
|
+
function ptzTravelPulses(moveImpulseMs) {
|
|
54457
|
+
if (moveImpulseMs === null || !(moveImpulseMs > 0)) return 1;
|
|
54458
|
+
return Math.max(1, Math.round(TARGET_TRAVEL_MS / moveImpulseMs));
|
|
54459
|
+
}
|
|
54460
|
+
/** `getOptions().moveImpulseMs`, or `null` when unreported or unreadable. */
|
|
54461
|
+
async function readImpulse(ptz, deviceId) {
|
|
54462
|
+
try {
|
|
54463
|
+
const ms = (await ptz.getOptions.query({ deviceId })).moveImpulseMs;
|
|
54464
|
+
return typeof ms === "number" && ms > 0 ? ms : null;
|
|
54465
|
+
} catch {
|
|
54466
|
+
return null;
|
|
54467
|
+
}
|
|
54468
|
+
}
|
|
52706
54469
|
/**
|
|
52707
|
-
*
|
|
52708
|
-
*
|
|
52709
|
-
*
|
|
52710
|
-
* `TurnOn` pulses a `continuousMove({pan|tilt: ±1})` and schedules a
|
|
52711
|
-
* `stop` after `ptzPulseMs`. The handler returns immediately with
|
|
52712
|
-
* `toggleState=OFF` so Alexa reflects the momentary nature in its UI.
|
|
52713
|
-
*
|
|
52714
|
-
* `TurnOff` is a no-op (success response) — Alexa never delivers it in
|
|
52715
|
-
* normal momentary usage, but accept it for resilience.
|
|
54470
|
+
* Runs the travel. REJECTS when the first `move` fails (nothing moved, the
|
|
54471
|
+
* caller answers with an error); a later failure stops the travel and is
|
|
54472
|
+
* returned in `error`, since the head has already moved.
|
|
52716
54473
|
*/
|
|
54474
|
+
async function runPtzTravel(ptz, deviceId, direction, clock = realClock) {
|
|
54475
|
+
const impulse = await readImpulse(ptz, deviceId);
|
|
54476
|
+
const pulses = ptzTravelPulses(impulse);
|
|
54477
|
+
for (let i = 0; i < pulses; i++) {
|
|
54478
|
+
const startedAt = clock.now();
|
|
54479
|
+
try {
|
|
54480
|
+
await ptz.move.mutate({
|
|
54481
|
+
deviceId,
|
|
54482
|
+
...direction
|
|
54483
|
+
});
|
|
54484
|
+
} catch (err) {
|
|
54485
|
+
if (i === 0) throw err;
|
|
54486
|
+
return {
|
|
54487
|
+
pulses,
|
|
54488
|
+
issued: i,
|
|
54489
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
54490
|
+
};
|
|
54491
|
+
}
|
|
54492
|
+
if (!(i === pulses - 1) && impulse !== null) {
|
|
54493
|
+
const remaining = impulse - (clock.now() - startedAt);
|
|
54494
|
+
if (remaining > 0) await clock.sleep(remaining);
|
|
54495
|
+
}
|
|
54496
|
+
}
|
|
54497
|
+
return {
|
|
54498
|
+
pulses,
|
|
54499
|
+
issued: pulses
|
|
54500
|
+
};
|
|
54501
|
+
}
|
|
54502
|
+
//#endregion
|
|
54503
|
+
//#region src/handlers/ptz-direction.ts
|
|
52717
54504
|
var ToggleDirectiveSchema = object({
|
|
52718
54505
|
header: object({
|
|
52719
54506
|
namespace: literal("Alexa.ToggleController"),
|
|
@@ -52747,27 +54534,24 @@ async function handlePtzDirection(header, endpointRaw, deps, _callerIdentity) {
|
|
|
52747
54534
|
const move = INSTANCE_TO_MOVE[instance];
|
|
52748
54535
|
if (!move) return errorEnvelope(header, "INVALID_DIRECTIVE", `ToggleController instance not supported: ${instance}`);
|
|
52749
54536
|
const api = deps.ctx.api;
|
|
54537
|
+
const deviceId = parsedId.deviceId;
|
|
52750
54538
|
try {
|
|
52751
|
-
await api.ptz
|
|
52752
|
-
|
|
52753
|
-
|
|
52754
|
-
|
|
52755
|
-
|
|
52756
|
-
|
|
52757
|
-
|
|
52758
|
-
|
|
52759
|
-
instance,
|
|
52760
|
-
error: err instanceof Error ? err.message : String(err)
|
|
52761
|
-
} });
|
|
52762
|
-
});
|
|
52763
|
-
}, deps.ptzPulseMs);
|
|
54539
|
+
const travel = await runPtzTravel(api.ptz, deviceId, move);
|
|
54540
|
+
if (travel.error) deps.logger.warn("alexa ptz-direction: ptz.move failed mid-travel", { meta: {
|
|
54541
|
+
deviceId,
|
|
54542
|
+
instance,
|
|
54543
|
+
issued: travel.issued,
|
|
54544
|
+
pulses: travel.pulses,
|
|
54545
|
+
error: travel.error.message
|
|
54546
|
+
} });
|
|
52764
54547
|
} catch (err) {
|
|
52765
|
-
|
|
52766
|
-
|
|
54548
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54549
|
+
deps.logger.warn("alexa ptz-direction: ptz.move failed", { meta: {
|
|
54550
|
+
deviceId,
|
|
52767
54551
|
instance,
|
|
52768
|
-
error:
|
|
54552
|
+
error: message
|
|
52769
54553
|
} });
|
|
52770
|
-
return errorEnvelope(header, "INTERNAL_ERROR", `ptz.
|
|
54554
|
+
return errorEnvelope(header, "INTERNAL_ERROR", `ptz.move failed: ${message}`);
|
|
52771
54555
|
}
|
|
52772
54556
|
return successResponse(header, endpointId, "OFF", instance);
|
|
52773
54557
|
}
|
|
@@ -52796,18 +54580,6 @@ function successResponse(header, endpointId, toggleState, instance) {
|
|
|
52796
54580
|
}
|
|
52797
54581
|
//#endregion
|
|
52798
54582
|
//#region src/handlers/ptz-preset.ts
|
|
52799
|
-
/**
|
|
52800
|
-
* `Alexa.ModeController/SetMode` handler — dispatches three PTZ instances:
|
|
52801
|
-
*
|
|
52802
|
-
* `ptz.preset` — preset-recall. Decodes the preset name from the mode
|
|
52803
|
-
* value and calls `ptz.goToPreset({deviceId, presetId})`.
|
|
52804
|
-
*
|
|
52805
|
-
* `ptz.pan` — momentary pan. `ptz.pan.left|right|center` issues a
|
|
52806
|
-
* continuousMove pulse stopped on a timer; the dropdown reverts to
|
|
52807
|
-
* `center` server-side so subsequent picks re-trigger the move.
|
|
52808
|
-
*
|
|
52809
|
-
* `ptz.tilt` — momentary tilt. Mirrors ptz.pan with up|down|center.
|
|
52810
|
-
*/
|
|
52811
54583
|
var SetModeDirectiveSchema = object({
|
|
52812
54584
|
header: object({
|
|
52813
54585
|
namespace: literal("Alexa.ModeController"),
|
|
@@ -52818,7 +54590,6 @@ var SetModeDirectiveSchema = object({
|
|
|
52818
54590
|
payload: SetModePayloadSchema
|
|
52819
54591
|
});
|
|
52820
54592
|
var PTZ_PRESET_MODE_PREFIX = "ptz.preset.";
|
|
52821
|
-
var PTZ_DIRECTION_PULSE_MS = 1e3;
|
|
52822
54593
|
var PTZ_PAN_MOVES = {
|
|
52823
54594
|
"ptz.pan.left": { pan: -1 },
|
|
52824
54595
|
"ptz.pan.right": { pan: 1 },
|
|
@@ -52850,29 +54621,28 @@ async function handleSetMode(header, endpointRaw, payloadRaw, deps, _callerIdent
|
|
|
52850
54621
|
if (instance === "ptz.pan" || instance === "ptz.tilt") {
|
|
52851
54622
|
const move = (instance === "ptz.pan" ? PTZ_PAN_MOVES : PTZ_TILT_MOVES)[mode];
|
|
52852
54623
|
if (!move) return errorEnvelope(header, "INVALID_DIRECTIVE", `mode '${mode}' is not a valid ${instance} mode`);
|
|
52853
|
-
if (Object.keys(move).length
|
|
52854
|
-
|
|
52855
|
-
|
|
52856
|
-
|
|
52857
|
-
error:
|
|
52858
|
-
|
|
52859
|
-
|
|
52860
|
-
|
|
52861
|
-
|
|
52862
|
-
|
|
52863
|
-
|
|
52864
|
-
|
|
52865
|
-
|
|
52866
|
-
|
|
52867
|
-
|
|
52868
|
-
|
|
52869
|
-
|
|
52870
|
-
|
|
52871
|
-
|
|
52872
|
-
});
|
|
52873
|
-
|
|
52874
|
-
|
|
52875
|
-
return errorEnvelope(header, "INTERNAL_ERROR", `ptz.continuousMove failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
54624
|
+
if (Object.keys(move).length > 0) {
|
|
54625
|
+
const deviceId = parsedId.deviceId;
|
|
54626
|
+
try {
|
|
54627
|
+
const travel = await runPtzTravel(api.ptz, deviceId, move);
|
|
54628
|
+
if (travel.error) deps.logger.warn("alexa ptz-mode: ptz.move failed mid-travel", { meta: {
|
|
54629
|
+
deviceId,
|
|
54630
|
+
instance,
|
|
54631
|
+
mode,
|
|
54632
|
+
issued: travel.issued,
|
|
54633
|
+
pulses: travel.pulses,
|
|
54634
|
+
error: travel.error.message
|
|
54635
|
+
} });
|
|
54636
|
+
} catch (err) {
|
|
54637
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54638
|
+
deps.logger.warn("alexa ptz-mode: ptz.move failed", { meta: {
|
|
54639
|
+
deviceId,
|
|
54640
|
+
instance,
|
|
54641
|
+
mode,
|
|
54642
|
+
error: message
|
|
54643
|
+
} });
|
|
54644
|
+
return errorEnvelope(header, "INTERNAL_ERROR", `ptz.move failed: ${message}`);
|
|
54645
|
+
}
|
|
52876
54646
|
}
|
|
52877
54647
|
return modeSuccessResponse(header, endpointId, instance, instance === "ptz.pan" ? "ptz.pan.center" : "ptz.tilt.center");
|
|
52878
54648
|
}
|
|
@@ -53174,6 +54944,7 @@ async function handleReportState(header, endpointRaw, deps, _callerIdentity) {
|
|
|
53174
54944
|
});
|
|
53175
54945
|
}
|
|
53176
54946
|
} catch (err) {
|
|
54947
|
+
if (err instanceof ChildUnreachableError) return errorEnvelope(header, "ENDPOINT_UNREACHABLE", err.message);
|
|
53177
54948
|
deps.logger.warn("alexa report-state: collection failed", { meta: {
|
|
53178
54949
|
endpointId,
|
|
53179
54950
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -53321,16 +55092,35 @@ async function collectAutotrackProperties(api, parentDeviceId, properties, timeO
|
|
|
53321
55092
|
uncertaintyInMilliseconds: 0
|
|
53322
55093
|
});
|
|
53323
55094
|
}
|
|
55095
|
+
/** The child's `switch` cannot be read: answered `ENDPOINT_UNREACHABLE`. */
|
|
55096
|
+
var ChildUnreachableError = class extends Error {};
|
|
55097
|
+
/** One warning per child per window (a sleeping battery camera's accessories
|
|
55098
|
+
* are polled by Alexa for as long as it sleeps). */
|
|
55099
|
+
var CHILD_WARN_EVERY_MS = 10 * 6e4;
|
|
55100
|
+
var childWarnedAt = /* @__PURE__ */ new Map();
|
|
55101
|
+
function warnChildUnreadable(logger, childDeviceId, error) {
|
|
55102
|
+
const now = Date.now();
|
|
55103
|
+
const last = childWarnedAt.get(childDeviceId);
|
|
55104
|
+
if (last !== void 0 && now - last < CHILD_WARN_EVERY_MS) return;
|
|
55105
|
+
childWarnedAt.set(childDeviceId, now);
|
|
55106
|
+
logger.warn("alexa report-state: child accessory switch unreadable — answered ENDPOINT_UNREACHABLE", { meta: {
|
|
55107
|
+
childDeviceId,
|
|
55108
|
+
error,
|
|
55109
|
+
repeatsSuppressedForMs: CHILD_WARN_EVERY_MS
|
|
55110
|
+
} });
|
|
55111
|
+
}
|
|
53324
55112
|
async function collectChildProperties(api, childDeviceId, properties, timeOfSample, logger) {
|
|
55113
|
+
let failure = null;
|
|
53325
55114
|
const switchState = await api.switch.getStatus.query({ deviceId: childDeviceId }).catch((err) => {
|
|
53326
|
-
|
|
53327
|
-
childDeviceId,
|
|
53328
|
-
error: err instanceof Error ? err.message : String(err)
|
|
53329
|
-
} });
|
|
55115
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
53330
55116
|
return null;
|
|
53331
55117
|
});
|
|
53332
|
-
if (switchState === null || switchState === void 0)
|
|
53333
|
-
|
|
55118
|
+
if (switchState === null || switchState === void 0) {
|
|
55119
|
+
warnChildUnreadable(logger, childDeviceId, failure ?? "no payload");
|
|
55120
|
+
throw new ChildUnreachableError(`accessory ${childDeviceId} cannot be read right now`);
|
|
55121
|
+
}
|
|
55122
|
+
childWarnedAt.delete(childDeviceId);
|
|
55123
|
+
logger.debug("alexa report-state: switch.getStatus payload", { meta: {
|
|
53334
55124
|
childDeviceId,
|
|
53335
55125
|
payload: switchState
|
|
53336
55126
|
} });
|
|
@@ -54151,44 +55941,84 @@ function refuse(reply, status, error) {
|
|
|
54151
55941
|
reply.code(status).type("application/json").header("cache-control", "no-store").header("content-encoding", "identity");
|
|
54152
55942
|
reply.send({ error });
|
|
54153
55943
|
}
|
|
55944
|
+
function headerOf(headers, name) {
|
|
55945
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return value;
|
|
55946
|
+
}
|
|
55947
|
+
/**
|
|
55948
|
+
* Exactly one line per fetch, whatever the answer (D779 amendment): "did Amazon ever
|
|
55949
|
+
* fetch the image, and what did it get?" is otherwise unanswerable — a served
|
|
55950
|
+
* image used to log nothing. Never the signature, never the token.
|
|
55951
|
+
*/
|
|
55952
|
+
function logFetch(deps, req, started, outcome) {
|
|
55953
|
+
const level = outcome.status === 401 || outcome.status === 403 ? "warn" : "info";
|
|
55954
|
+
deps.logger[level]("alexa snapshot: fetch answered", {
|
|
55955
|
+
...outcome.deviceId !== void 0 ? { tags: { deviceId: outcome.deviceId } } : {},
|
|
55956
|
+
meta: {
|
|
55957
|
+
status: outcome.status,
|
|
55958
|
+
...outcome.imageId !== void 0 ? { imageId: outcome.imageId } : {},
|
|
55959
|
+
...outcome.reason !== void 0 ? { reason: outcome.reason } : {},
|
|
55960
|
+
...outcome.lazy !== void 0 ? { lazy: outcome.lazy } : {},
|
|
55961
|
+
...outcome.bytes !== void 0 ? { bytes: outcome.bytes } : {},
|
|
55962
|
+
tookMs: Date.now() - started,
|
|
55963
|
+
userAgent: headerOf(req.headers, "user-agent") ?? null,
|
|
55964
|
+
...outcome.extra
|
|
55965
|
+
}
|
|
55966
|
+
});
|
|
55967
|
+
}
|
|
54154
55968
|
async function handleSnapshotFetch(req, reply, deps) {
|
|
55969
|
+
const started = Date.now();
|
|
54155
55970
|
if (!deps.enabled()) {
|
|
55971
|
+
logFetch(deps, req, started, {
|
|
55972
|
+
status: 410,
|
|
55973
|
+
reason: "switched-off"
|
|
55974
|
+
});
|
|
54156
55975
|
refuse(reply, 410, "snapshots are switched off");
|
|
54157
55976
|
return;
|
|
54158
55977
|
}
|
|
54159
55978
|
const imageId = IMAGE_FILE.exec(req.params["file"] ?? "")?.[1];
|
|
54160
55979
|
if (imageId === void 0) {
|
|
55980
|
+
logFetch(deps, req, started, {
|
|
55981
|
+
status: 410,
|
|
55982
|
+
reason: "not-a-snapshot-address"
|
|
55983
|
+
});
|
|
54161
55984
|
refuse(reply, 410, "not a snapshot address");
|
|
54162
55985
|
return;
|
|
54163
55986
|
}
|
|
54164
55987
|
const found = deps.store.lookup(imageId, req.query["exp"], req.query["sig"]);
|
|
54165
55988
|
if (found.kind === "gone") {
|
|
54166
|
-
deps
|
|
55989
|
+
logFetch(deps, req, started, {
|
|
55990
|
+
status: 410,
|
|
54167
55991
|
imageId,
|
|
54168
55992
|
reason: found.reason
|
|
54169
|
-
}
|
|
55993
|
+
});
|
|
54170
55994
|
refuse(reply, 410, found.reason);
|
|
54171
55995
|
return;
|
|
54172
55996
|
}
|
|
54173
55997
|
const image = found.image;
|
|
55998
|
+
const lazy = image.load !== void 0 || image.bytes.byteLength === 0;
|
|
55999
|
+
const base = {
|
|
56000
|
+
imageId,
|
|
56001
|
+
deviceId: image.deviceId,
|
|
56002
|
+
lazy
|
|
56003
|
+
};
|
|
54174
56004
|
const token = bearerOf(req.headers);
|
|
54175
56005
|
const verified = token === null ? null : await deps.verifyToken(token).catch(() => null);
|
|
54176
56006
|
if (verified === null) {
|
|
54177
|
-
deps
|
|
54178
|
-
|
|
54179
|
-
|
|
54180
|
-
|
|
54181
|
-
|
|
54182
|
-
}
|
|
56007
|
+
logFetch(deps, req, started, {
|
|
56008
|
+
...base,
|
|
56009
|
+
status: 401,
|
|
56010
|
+
reason: "no valid account-link token",
|
|
56011
|
+
extra: { hadToken: token !== null }
|
|
54183
56012
|
});
|
|
54184
56013
|
refuse(reply, 401, "a valid Alexa account-link token is required");
|
|
54185
56014
|
return;
|
|
54186
56015
|
}
|
|
54187
56016
|
if (verified.userId !== image.userId || !await deps.mayView(verified.userId, image.deviceId)) {
|
|
54188
|
-
deps
|
|
54189
|
-
|
|
54190
|
-
|
|
54191
|
-
|
|
56017
|
+
logFetch(deps, req, started, {
|
|
56018
|
+
...base,
|
|
56019
|
+
status: 403,
|
|
56020
|
+
reason: "token of another user, or the user lost the camera",
|
|
56021
|
+
extra: {
|
|
54192
56022
|
tokenUser: verified.userId,
|
|
54193
56023
|
imageUser: image.userId
|
|
54194
56024
|
}
|
|
@@ -54198,13 +56028,19 @@ async function handleSnapshotFetch(req, reply, deps) {
|
|
|
54198
56028
|
}
|
|
54199
56029
|
const bytes = await deps.store.bytesOf(image);
|
|
54200
56030
|
if (bytes === null || bytes.byteLength === 0) {
|
|
54201
|
-
deps
|
|
54202
|
-
|
|
54203
|
-
|
|
56031
|
+
logFetch(deps, req, started, {
|
|
56032
|
+
...base,
|
|
56033
|
+
status: 410,
|
|
56034
|
+
reason: "no image for this event"
|
|
54204
56035
|
});
|
|
54205
56036
|
refuse(reply, 410, "no image is available for this event");
|
|
54206
56037
|
return;
|
|
54207
56038
|
}
|
|
56039
|
+
logFetch(deps, req, started, {
|
|
56040
|
+
...base,
|
|
56041
|
+
status: 200,
|
|
56042
|
+
bytes: bytes.byteLength
|
|
56043
|
+
});
|
|
54208
56044
|
reply.code(200).type(image.contentType).header("cache-control", "private, no-store").header("content-encoding", "identity").header("content-length", String(bytes.byteLength));
|
|
54209
56045
|
reply.send(bytes);
|
|
54210
56046
|
}
|
|
@@ -54687,6 +56523,8 @@ var LwaEventGateway = class {
|
|
|
54687
56523
|
/** Per-endpoint count of `ObjectDetection` events dropped because the
|
|
54688
56524
|
* endpoint does not declare {@link SMART_VISION_INTERFACE}. */
|
|
54689
56525
|
smartVisionDropped = /* @__PURE__ */ new Map();
|
|
56526
|
+
/** Endpoints whose redacted ObjectDetection body was logged (D779 amendment). */
|
|
56527
|
+
smartVisionSampled = /* @__PURE__ */ new Set();
|
|
54690
56528
|
/** Latches the "no grant at all" warning so it's logged ONCE, not on every
|
|
54691
56529
|
* doorbell/motion event while no account has a push grant. */
|
|
54692
56530
|
warnedNoGrant = false;
|
|
@@ -54739,6 +56577,7 @@ var LwaEventGateway = class {
|
|
|
54739
56577
|
this.lastSentAt.clear();
|
|
54740
56578
|
this.motionHold.clear();
|
|
54741
56579
|
this.smartVisionDropped.clear();
|
|
56580
|
+
this.smartVisionSampled.clear();
|
|
54742
56581
|
this.warnedNoToken.clear();
|
|
54743
56582
|
this.warnedLegacyWaiting.clear();
|
|
54744
56583
|
this.migrationRetryAt.clear();
|
|
@@ -54943,6 +56782,24 @@ var LwaEventGateway = class {
|
|
|
54943
56782
|
for (const account of linked) {
|
|
54944
56783
|
if (!this.mayPush(account.userId, opts.deviceId, "object detection")) continue;
|
|
54945
56784
|
const images = opts.imagesFor?.(account.userId) ?? {};
|
|
56785
|
+
if (!this.smartVisionSampled.has(endpointId)) {
|
|
56786
|
+
this.smartVisionSampled.add(endpointId);
|
|
56787
|
+
this.deps.logger.info("alexa lwa-gateway: ObjectDetection body sample (redacted)", {
|
|
56788
|
+
tags: { deviceId: opts.deviceId },
|
|
56789
|
+
meta: { body: redactedObjectDetectionSample({
|
|
56790
|
+
userId: account.userId,
|
|
56791
|
+
endpointId,
|
|
56792
|
+
detections: [{
|
|
56793
|
+
eventIdentifier: d.eventIdentifier,
|
|
56794
|
+
imageNetClass: d.imageNetClass,
|
|
56795
|
+
objectIdentifier: d.objectIdentifier,
|
|
56796
|
+
...images
|
|
56797
|
+
}],
|
|
56798
|
+
timestamp: d.timestamp,
|
|
56799
|
+
accessToken: ""
|
|
56800
|
+
}) }
|
|
56801
|
+
});
|
|
56802
|
+
}
|
|
54946
56803
|
await this.postEventBody(account.userId, endpointId, (accessToken) => buildSmartVisionObjectDetectionEvent({
|
|
54947
56804
|
userId: account.userId,
|
|
54948
56805
|
endpointId,
|
|
@@ -55493,6 +57350,29 @@ function buildSmartVisionObjectDetectionEvent(opts) {
|
|
|
55493
57350
|
})) }
|
|
55494
57351
|
} };
|
|
55495
57352
|
}
|
|
57353
|
+
/** Every `sig=` query value in a URI, for a log line. */
|
|
57354
|
+
function redactSignature(uri) {
|
|
57355
|
+
return uri?.replace(/([?&]sig=)[^&]*/g, "$1<redacted>");
|
|
57356
|
+
}
|
|
57357
|
+
/**
|
|
57358
|
+
* The ObjectDetection body exactly as built for the wire, minus its secrets
|
|
57359
|
+
* (D779 amendment): the scope token and every image URI's signature. Logged once per
|
|
57360
|
+
* endpoint per process so the shape can be compared with Amazon's example.
|
|
57361
|
+
*/
|
|
57362
|
+
function redactedObjectDetectionSample(opts) {
|
|
57363
|
+
const body = buildSmartVisionObjectDetectionEvent({
|
|
57364
|
+
...opts,
|
|
57365
|
+
accessToken: "<redacted>"
|
|
57366
|
+
});
|
|
57367
|
+
return { event: {
|
|
57368
|
+
...body.event,
|
|
57369
|
+
payload: { events: body.event.payload.events.map((e) => ({
|
|
57370
|
+
...e,
|
|
57371
|
+
...e.frameImageUri !== void 0 ? { frameImageUri: redactSignature(e.frameImageUri) } : {},
|
|
57372
|
+
...e.croppedImageUri !== void 0 ? { croppedImageUri: redactSignature(e.croppedImageUri) } : {}
|
|
57373
|
+
})) }
|
|
57374
|
+
} };
|
|
57375
|
+
}
|
|
55496
57376
|
function buildAddOrUpdateReportPayload(_userId, endpoints, accessToken) {
|
|
55497
57377
|
return { event: {
|
|
55498
57378
|
header: {
|
|
@@ -55565,6 +57445,11 @@ async function removeEndpointsFromAlexa(deps) {
|
|
|
55565
57445
|
//#endregion
|
|
55566
57446
|
//#region src/settings-schema.ts
|
|
55567
57447
|
var ADDON_ID = "export-alexa";
|
|
57448
|
+
/** The rows that exist only for a custom skill. */
|
|
57449
|
+
var CUSTOM_SKILL_ONLY = {
|
|
57450
|
+
field: "skillMode",
|
|
57451
|
+
equals: "custom"
|
|
57452
|
+
};
|
|
55568
57453
|
var CONNECTION_DESCRIPTION = [
|
|
55569
57454
|
"Public skill (default): enable the \"CamStack\" skill in the Alexa app and link your account. No Amazon developer setup is needed.",
|
|
55570
57455
|
"Custom skill: enter your skill id and deploy the skill Lambda from this addon's lambda-template in your own AWS account.",
|
|
@@ -55614,13 +57499,14 @@ function connectionSection(input) {
|
|
|
55614
57499
|
],
|
|
55615
57500
|
default: defaults.region
|
|
55616
57501
|
},
|
|
55617
|
-
|
|
57502
|
+
{
|
|
55618
57503
|
type: "text",
|
|
55619
57504
|
key: "expectedAudience",
|
|
55620
57505
|
label: "Alexa skill id",
|
|
55621
57506
|
description: "Your skill's id, format amzn1.ask.skill.xxxx, from the Alexa developer console. Directives for any other skill are refused.",
|
|
55622
|
-
default: defaults.expectedAudience
|
|
55623
|
-
|
|
57507
|
+
default: defaults.expectedAudience,
|
|
57508
|
+
showWhen: CUSTOM_SKILL_ONLY
|
|
57509
|
+
},
|
|
55624
57510
|
{
|
|
55625
57511
|
type: "addon-action-select",
|
|
55626
57512
|
key: "publicHubUrl",
|
|
@@ -55763,6 +57649,7 @@ function customSkillSecuritySection(input) {
|
|
|
55763
57649
|
key: "__ingress-token-info",
|
|
55764
57650
|
label: "Ingress token",
|
|
55765
57651
|
variant: "info",
|
|
57652
|
+
showWhen: CUSTOM_SKILL_ONLY,
|
|
55766
57653
|
content: "An optional shared secret your skill Lambda sends on every call to this hub. Show it, put it in the Lambda's HUB_INGRESS_TOKEN environment variable, then turn on \"Require ingress token\". After a rotation, update the Lambda."
|
|
55767
57654
|
},
|
|
55768
57655
|
{
|
|
@@ -55774,7 +57661,8 @@ function customSkillSecuritySection(input) {
|
|
|
55774
57661
|
buttonLabel: "Show ingress token",
|
|
55775
57662
|
action: "revealIngressToken",
|
|
55776
57663
|
variant: "default",
|
|
55777
|
-
successMessage: "Ingress token: {ingressToken}"
|
|
57664
|
+
successMessage: "Ingress token: {ingressToken}",
|
|
57665
|
+
showWhen: CUSTOM_SKILL_ONLY
|
|
55778
57666
|
},
|
|
55779
57667
|
{
|
|
55780
57668
|
type: "addon-action-button",
|
|
@@ -55786,14 +57674,16 @@ function customSkillSecuritySection(input) {
|
|
|
55786
57674
|
action: "regenerateIngressToken",
|
|
55787
57675
|
variant: "default",
|
|
55788
57676
|
successMessage: "New ingress token: {ingressToken} — put it in HUB_INGRESS_TOKEN now.",
|
|
55789
|
-
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN."
|
|
57677
|
+
confirmMessage: "Rotate the ingress token? With \"Require ingress token\" on, the Lambda is refused until you update HUB_INGRESS_TOKEN.",
|
|
57678
|
+
showWhen: CUSTOM_SKILL_ONLY
|
|
55790
57679
|
},
|
|
55791
57680
|
{
|
|
55792
57681
|
type: "boolean",
|
|
55793
57682
|
key: "enforceIngressToken",
|
|
55794
57683
|
label: "Require ingress token",
|
|
55795
57684
|
description: "When on, this hub refuses skill calls without the matching token. Turn it on only after HUB_INGRESS_TOKEN is set in your Lambda.",
|
|
55796
|
-
default: input.defaults.enforceIngressToken
|
|
57685
|
+
default: input.defaults.enforceIngressToken,
|
|
57686
|
+
showWhen: CUSTOM_SKILL_ONLY
|
|
55797
57687
|
}
|
|
55798
57688
|
]
|
|
55799
57689
|
};
|
|
@@ -55804,7 +57694,7 @@ function buildAlexaSettingsSections(input) {
|
|
|
55804
57694
|
featuresSection(input),
|
|
55805
57695
|
accountLinkSection(input),
|
|
55806
57696
|
troubleshootingSection(),
|
|
55807
|
-
|
|
57697
|
+
customSkillSecuritySection(input)
|
|
55808
57698
|
];
|
|
55809
57699
|
}
|
|
55810
57700
|
//#endregion
|
|
@@ -56184,6 +58074,8 @@ function sensorSelectionField(input) {
|
|
|
56184
58074
|
immediate: true
|
|
56185
58075
|
};
|
|
56186
58076
|
}
|
|
58077
|
+
/** Minimum spacing of two events of one class on one camera (D779 amendment). */
|
|
58078
|
+
var CAMERA_EVENT_MIN_INTERVAL_MS = 3e4;
|
|
56187
58079
|
/** Bound on the per-track bookkeeping should `end` events be lost (D8). */
|
|
56188
58080
|
var MAX_TRACKED = 5e3;
|
|
56189
58081
|
var TrackLifecycleSchema = object({
|
|
@@ -56206,6 +58098,9 @@ var ObjectDetectionFeed = class {
|
|
|
56206
58098
|
handled = /* @__PURE__ */ new Map();
|
|
56207
58099
|
/** objectIdentifier → last event time. */
|
|
56208
58100
|
lastPosted = /* @__PURE__ */ new Map();
|
|
58101
|
+
/** `${deviceId}|${class}` → last event time, and tracks held back since. */
|
|
58102
|
+
lastCameraPost = /* @__PURE__ */ new Map();
|
|
58103
|
+
heldBack = /* @__PURE__ */ new Map();
|
|
56209
58104
|
off = null;
|
|
56210
58105
|
constructor(deps) {
|
|
56211
58106
|
this.deps = deps;
|
|
@@ -56230,6 +58125,8 @@ var ObjectDetectionFeed = class {
|
|
|
56230
58125
|
this.off = null;
|
|
56231
58126
|
this.handled.clear();
|
|
56232
58127
|
this.lastPosted.clear();
|
|
58128
|
+
this.lastCameraPost.clear();
|
|
58129
|
+
this.heldBack.clear();
|
|
56233
58130
|
}
|
|
56234
58131
|
/** Per-track entries currently held (tests, diagnostics). */
|
|
56235
58132
|
trackedCount() {
|
|
@@ -56250,6 +58147,12 @@ var ObjectDetectionFeed = class {
|
|
|
56250
58147
|
const objectIdentifier = objectIdentifierFor(namespace, event.deviceId, event.trackId);
|
|
56251
58148
|
const last = this.lastPosted.get(objectIdentifier);
|
|
56252
58149
|
if (last !== void 0 && now - last < 3e4) return;
|
|
58150
|
+
const cameraKey = `${event.deviceId}|${cls}`;
|
|
58151
|
+
const lastOnCamera = this.lastCameraPost.get(cameraKey);
|
|
58152
|
+
if (lastOnCamera !== void 0 && now - lastOnCamera < 3e4) {
|
|
58153
|
+
if (event.phase === "start") this.heldBack.set(cameraKey, (this.heldBack.get(cameraKey) ?? 0) + 1);
|
|
58154
|
+
return;
|
|
58155
|
+
}
|
|
56253
58156
|
this.mark(key, now);
|
|
56254
58157
|
if (!(await this.deps.reportableClasses(event.deviceId)).includes(cls)) return;
|
|
56255
58158
|
const detection = {
|
|
@@ -56260,7 +58163,20 @@ var ObjectDetectionFeed = class {
|
|
|
56260
58163
|
timestamp: now
|
|
56261
58164
|
};
|
|
56262
58165
|
this.lastPosted.set(objectIdentifier, now);
|
|
58166
|
+
this.lastCameraPost.set(cameraKey, now);
|
|
56263
58167
|
this.pruneLastPosted(now);
|
|
58168
|
+
const held = this.heldBack.get(cameraKey) ?? 0;
|
|
58169
|
+
if (held > 0) {
|
|
58170
|
+
this.heldBack.delete(cameraKey);
|
|
58171
|
+
this.deps.logger.info("alexa object detection: tracks held back by the per-camera window", {
|
|
58172
|
+
tags: { deviceId: event.deviceId },
|
|
58173
|
+
meta: {
|
|
58174
|
+
imageNetClass: cls,
|
|
58175
|
+
heldBack: held,
|
|
58176
|
+
windowMs: CAMERA_EVENT_MIN_INTERVAL_MS
|
|
58177
|
+
}
|
|
58178
|
+
});
|
|
58179
|
+
}
|
|
56264
58180
|
await this.deps.remember(event.deviceId, {
|
|
56265
58181
|
e: detection.eventIdentifier,
|
|
56266
58182
|
t: event.trackId,
|
|
@@ -56679,7 +58595,15 @@ function verifyExpiringUrl(input) {
|
|
|
56679
58595
|
*/
|
|
56680
58596
|
/** How long a minted image URL is valid — Amazon recommends ten minutes. */
|
|
56681
58597
|
var SNAPSHOT_URL_TTL_MS = 10 * 6e4;
|
|
58598
|
+
/** Bound on entries holding pixels (GetSnapshot images, loaded lazy ones). */
|
|
56682
58599
|
var MAX_IMAGES = 128;
|
|
58600
|
+
/**
|
|
58601
|
+
* Bound on lazy entries not yet fetched (D779 amendment). They hold no pixels — a closure
|
|
58602
|
+
* over (device, track) — so the bound only guards against a runaway. It must
|
|
58603
|
+
* cover ten minutes of events: at the measured ~15 events/min, two URIs each,
|
|
58604
|
+
* the shared 128 bound evicted a URI ~4 min after Alexa was handed it.
|
|
58605
|
+
*/
|
|
58606
|
+
var MAX_LAZY_IMAGES = 4096;
|
|
56683
58607
|
var MAX_TOTAL_BYTES = 24 * 1024 * 1024;
|
|
56684
58608
|
var SnapshotStore = class {
|
|
56685
58609
|
secret;
|
|
@@ -56809,9 +58733,18 @@ var SnapshotStore = class {
|
|
|
56809
58733
|
for (const [id, image] of this.images) if (image.expiresAt <= now) this.drop(id, image);
|
|
56810
58734
|
}
|
|
56811
58735
|
evictOverBounds() {
|
|
58736
|
+
let lazy = 0;
|
|
58737
|
+
for (const image of this.images.values()) if (image.load !== void 0) lazy += 1;
|
|
58738
|
+
let loaded = this.images.size - lazy;
|
|
56812
58739
|
for (const [id, image] of this.images) {
|
|
56813
|
-
|
|
58740
|
+
const overLoaded = loaded > MAX_IMAGES || this.totalBytes > MAX_TOTAL_BYTES;
|
|
58741
|
+
const overLazy = lazy > MAX_LAZY_IMAGES;
|
|
58742
|
+
if (!overLoaded && !overLazy) return;
|
|
58743
|
+
const isLazy = image.load !== void 0;
|
|
58744
|
+
if (isLazy ? !overLazy : !overLoaded) continue;
|
|
56814
58745
|
this.drop(id, image);
|
|
58746
|
+
if (isLazy) lazy -= 1;
|
|
58747
|
+
else loaded -= 1;
|
|
56815
58748
|
}
|
|
56816
58749
|
}
|
|
56817
58750
|
drop(id, image) {
|
|
@@ -57541,6 +59474,14 @@ var SUPPORTED_KINDS = [
|
|
|
57541
59474
|
DeviceType.Generic
|
|
57542
59475
|
];
|
|
57543
59476
|
var ExportAlexaAddon = class extends BaseAddon {
|
|
59477
|
+
/** Hidden state that is a credential — kept in the keychain, never the blob (D787). */
|
|
59478
|
+
secretStatePaths = [
|
|
59479
|
+
"ingressToken",
|
|
59480
|
+
"snapshotSigningKey",
|
|
59481
|
+
"tokens[userId].accessToken",
|
|
59482
|
+
"tokens[userId].refreshToken",
|
|
59483
|
+
"tokens[userId].brokerCredential"
|
|
59484
|
+
];
|
|
57544
59485
|
lwaGateway = null;
|
|
57545
59486
|
syncEngine = null;
|
|
57546
59487
|
/** S1 (D767): the linking user's live grants. Built in `onInitialize`, the
|
|
@@ -57568,7 +59509,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57568
59509
|
if (!this.config.snapshotSigningKey) await this.updateGlobalSettings({ snapshotSigningKey: generateIngressToken() });
|
|
57569
59510
|
if (!this.config.visionNamespace) await this.updateGlobalSettings({ visionNamespace: randomUUID() });
|
|
57570
59511
|
if (this.config.lwaClientSecret) {
|
|
57571
|
-
await this.
|
|
59512
|
+
await this.clearSecrets(["lwaClientSecret"]);
|
|
57572
59513
|
this.ctx.logger.warn("export-alexa: removed a stored LWA client secret — the hub no longer uses one; your skill Lambda holds it (D753)");
|
|
57573
59514
|
}
|
|
57574
59515
|
await this.backfillLinkedAccounts();
|
|
@@ -57643,8 +59584,7 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
57643
59584
|
},
|
|
57644
59585
|
ptzDirection: {
|
|
57645
59586
|
ctx: this.ctx,
|
|
57646
|
-
logger: this.ctx.logger
|
|
57647
|
-
ptzPulseMs: 1e3
|
|
59587
|
+
logger: this.ctx.logger
|
|
57648
59588
|
},
|
|
57649
59589
|
powerController: {
|
|
57650
59590
|
ctx: this.ctx,
|
|
@@ -58396,7 +60336,6 @@ var ExportAlexaAddon = class extends BaseAddon {
|
|
|
58396
60336
|
* linking rows live in the `device-export` setup block instead. */
|
|
58397
60337
|
globalSettingsSchema() {
|
|
58398
60338
|
return { sections: buildAlexaSettingsSections({
|
|
58399
|
-
skillMode: this.config.skillMode,
|
|
58400
60339
|
linkedAccounts: this.config.linkedAccounts,
|
|
58401
60340
|
tokens: this.config.tokens,
|
|
58402
60341
|
now: Date.now(),
|