@camstack/addon-export-hap 1.2.16 → 1.2.18
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-hap.addon.js +2225 -1383
- package/dist/export-hap.addon.mjs +2225 -1383
- package/package.json +1 -1
|
@@ -48,7 +48,7 @@ function carryForward(base, existing, keys) {
|
|
|
48
48
|
return out;
|
|
49
49
|
}
|
|
50
50
|
//#endregion
|
|
51
|
-
//#region ../types/dist/event-category-
|
|
51
|
+
//#region ../types/dist/event-category-Cv9dO26A.mjs
|
|
52
52
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
53
53
|
EventCategory["SystemBoot"] = "system.boot";
|
|
54
54
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -64,6 +64,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
64
64
|
*/
|
|
65
65
|
EventCategory["SystemRestartCompleted"] = "system.restart-completed";
|
|
66
66
|
/**
|
|
67
|
+
* A newer addon or server-root package version was found by the
|
|
68
|
+
* authoritative registry check. Emitted once per
|
|
69
|
+
* `(target, packageName, currentVersion, latestVersion)` transition; repeated
|
|
70
|
+
* polling of the same result is deduplicated by the checker.
|
|
71
|
+
*/
|
|
72
|
+
EventCategory["UpdateAvailable"] = "update.available";
|
|
73
|
+
/**
|
|
67
74
|
* Readiness transition for a capability provider. Every producer emits
|
|
68
75
|
* this event on `onInitialize` completion, `onDestroy`, and
|
|
69
76
|
* `$node.reconnect`; every consumer that needs to gate on a cross-process
|
|
@@ -7054,7 +7061,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7054
7061
|
input: unknown()
|
|
7055
7062
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7056
7063
|
//#endregion
|
|
7057
|
-
//#region ../types/dist/
|
|
7064
|
+
//#region ../types/dist/canonical-hash-rO1sRmEK.mjs
|
|
7058
7065
|
var AUDIO_ENCODER_BY_CODEC = {
|
|
7059
7066
|
opus: "libopus",
|
|
7060
7067
|
aac: "aac",
|
|
@@ -7348,37 +7355,6 @@ function buildFfmpegArgs(inv) {
|
|
|
7348
7355
|
...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
|
|
7349
7356
|
];
|
|
7350
7357
|
}
|
|
7351
|
-
/**
|
|
7352
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7353
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
7354
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
7355
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7356
|
-
*
|
|
7357
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7358
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7359
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
7360
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7361
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7362
|
-
* this guard would propagate redundant pushes.
|
|
7363
|
-
*
|
|
7364
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7365
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7366
|
-
* barrier so exports react only when the full cap set has landed —
|
|
7367
|
-
* tracked separately for post-HA-integration work.
|
|
7368
|
-
*/
|
|
7369
|
-
function canonicalHash(value) {
|
|
7370
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7371
|
-
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
7372
|
-
}
|
|
7373
|
-
function replaceWithSortedKeys(_key, value) {
|
|
7374
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7375
|
-
const obj = value;
|
|
7376
|
-
const out = {};
|
|
7377
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7378
|
-
return out;
|
|
7379
|
-
}
|
|
7380
|
-
return value;
|
|
7381
|
-
}
|
|
7382
7358
|
var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
|
|
7383
7359
|
/** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
|
|
7384
7360
|
var BOX_HEADER_BYTES = 8;
|
|
@@ -7551,6 +7527,37 @@ var Fmp4BoxSplitter = class {
|
|
|
7551
7527
|
return [];
|
|
7552
7528
|
}
|
|
7553
7529
|
};
|
|
7530
|
+
/**
|
|
7531
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7532
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
7533
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
7534
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7535
|
+
*
|
|
7536
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7537
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7538
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
7539
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7540
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7541
|
+
* this guard would propagate redundant pushes.
|
|
7542
|
+
*
|
|
7543
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7544
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7545
|
+
* barrier so exports react only when the full cap set has landed —
|
|
7546
|
+
* tracked separately for post-HA-integration work.
|
|
7547
|
+
*/
|
|
7548
|
+
function canonicalHash(value) {
|
|
7549
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7550
|
+
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
7551
|
+
}
|
|
7552
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7553
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7554
|
+
const obj = value;
|
|
7555
|
+
const out = {};
|
|
7556
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7557
|
+
return out;
|
|
7558
|
+
}
|
|
7559
|
+
return value;
|
|
7560
|
+
}
|
|
7554
7561
|
//#endregion
|
|
7555
7562
|
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7556
7563
|
/**
|
|
@@ -7730,6 +7737,104 @@ object({
|
|
|
7730
7737
|
})
|
|
7731
7738
|
});
|
|
7732
7739
|
/**
|
|
7740
|
+
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7741
|
+
*
|
|
7742
|
+
* ## Why this exists
|
|
7743
|
+
*
|
|
7744
|
+
* `adopt({childNativeIds: [...]})` materialises one CamStack device per
|
|
7745
|
+
* candidate PLUS every accessory child, and the whole array shares ONE UDS
|
|
7746
|
+
* request deadline (60s). Measured on the live hub against Home Assistant:
|
|
7747
|
+
* each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
|
|
7748
|
+
* meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
|
|
7749
|
+
* `setRole`, … `persistConfig`) before the class is constructed — and an
|
|
7750
|
+
* accessory child costs the same as its parent. So the real unit of work is
|
|
7751
|
+
* the CHILD, not the candidate:
|
|
7752
|
+
*
|
|
7753
|
+
* - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
|
|
7754
|
+
* - ONE candidate with 217 children → ~217 devices → **>60s, times out**
|
|
7755
|
+
*
|
|
7756
|
+
* That second line is why this is a job and not a smaller batch. No chunking,
|
|
7757
|
+
* no bounded concurrency over candidates and no per-call tuning can fix a
|
|
7758
|
+
* shape where **N=1 already exceeds the deadline** — the count that blows the
|
|
7759
|
+
* budget is the source system's accessory fan-out, which the operator does not
|
|
7760
|
+
* choose and cannot see. A design that only works below some N is the same bug
|
|
7761
|
+
* deferred.
|
|
7762
|
+
*
|
|
7763
|
+
* ## What the timeout did NOT do
|
|
7764
|
+
*
|
|
7765
|
+
* It did not stop the work. The UDS deadline ends the CALLER's wait; the
|
|
7766
|
+
* provider's loop runs to completion. Measured: a 25-candidate adopt that
|
|
7767
|
+
* "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
|
|
7768
|
+
* an error and had no way to learn that. Every field below exists so that
|
|
7769
|
+
* question has an answer.
|
|
7770
|
+
*
|
|
7771
|
+
* ## Idempotency
|
|
7772
|
+
*
|
|
7773
|
+
* Jobs are in-RAM; a restart forgets them. That is safe here because adoption
|
|
7774
|
+
* is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
|
|
7775
|
+
* re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
|
|
7776
|
+
* by the engine before any provider call and lands in `alreadyAdopted`. It is
|
|
7777
|
+
* never a duplicate device, and never an error the operator has to interpret.
|
|
7778
|
+
*/
|
|
7779
|
+
var AdoptionJobStateSchema = _enum([
|
|
7780
|
+
"running",
|
|
7781
|
+
"done",
|
|
7782
|
+
"failed",
|
|
7783
|
+
"cancelled"
|
|
7784
|
+
]);
|
|
7785
|
+
/**
|
|
7786
|
+
* Per-candidate result. Every candidate the job was asked to adopt ends in
|
|
7787
|
+
* exactly one of these buckets — there is no silent drop, and the operator can
|
|
7788
|
+
* always answer "which of my 25 landed?".
|
|
7789
|
+
*
|
|
7790
|
+
* - `adopted` — created now by this job.
|
|
7791
|
+
* - `already-adopted` — a device for this candidate existed before the job
|
|
7792
|
+
* reached it (a re-run, or a retry after a timeout). Not an error.
|
|
7793
|
+
* - `failed` — the provider threw; `error` carries the message.
|
|
7794
|
+
* - `cancelled` — the operator cancelled before this candidate was reached.
|
|
7795
|
+
*/
|
|
7796
|
+
var AdoptionOutcomeSchema = _enum([
|
|
7797
|
+
"adopted",
|
|
7798
|
+
"already-adopted",
|
|
7799
|
+
"failed",
|
|
7800
|
+
"cancelled"
|
|
7801
|
+
]);
|
|
7802
|
+
var AdoptionCandidateResultSchema = object({
|
|
7803
|
+
childNativeId: string(),
|
|
7804
|
+
outcome: AdoptionOutcomeSchema,
|
|
7805
|
+
/** The materialised parent device id — null for `failed` / `cancelled`. */
|
|
7806
|
+
parentDeviceId: number().int().nonnegative().nullable(),
|
|
7807
|
+
/** Accessory children created for this candidate. */
|
|
7808
|
+
accessoryCount: number().int().nonnegative(),
|
|
7809
|
+
/** Failure message; null unless `outcome === 'failed'`. */
|
|
7810
|
+
error: string().nullable()
|
|
7811
|
+
});
|
|
7812
|
+
var AdoptionJobSchema = object({
|
|
7813
|
+
jobId: string(),
|
|
7814
|
+
/** The integration provider this job adopts through (the `addonId` pin). */
|
|
7815
|
+
addonId: string(),
|
|
7816
|
+
integrationId: string(),
|
|
7817
|
+
state: AdoptionJobStateSchema,
|
|
7818
|
+
/** Candidates the job was asked to adopt. Known up front, so never null. */
|
|
7819
|
+
total: number().int().nonnegative(),
|
|
7820
|
+
/** Candidates that have reached a terminal bucket. */
|
|
7821
|
+
processed: number().int().nonnegative(),
|
|
7822
|
+
adopted: number().int().nonnegative(),
|
|
7823
|
+
alreadyAdopted: number().int().nonnegative(),
|
|
7824
|
+
failed: number().int().nonnegative(),
|
|
7825
|
+
/** Accessory child devices created across every candidate — the real unit
|
|
7826
|
+
* of work, surfaced so a slow job is legible rather than mysterious. */
|
|
7827
|
+
accessoriesCreated: number().int().nonnegative(),
|
|
7828
|
+
/** The candidate currently being adopted; null when idle or finished. */
|
|
7829
|
+
currentChildNativeId: string().nullable(),
|
|
7830
|
+
/** One entry per candidate, in the order they were processed. */
|
|
7831
|
+
results: array(AdoptionCandidateResultSchema).readonly(),
|
|
7832
|
+
startedAt: number(),
|
|
7833
|
+
finishedAt: number().nullable(),
|
|
7834
|
+
/** Set only when the job itself broke (not a per-candidate failure). */
|
|
7835
|
+
error: string().nullable()
|
|
7836
|
+
});
|
|
7837
|
+
/**
|
|
7733
7838
|
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7734
7839
|
* pipeline functions an operator thinks in terms of.
|
|
7735
7840
|
*
|
|
@@ -8603,358 +8708,1132 @@ var ConvertResultSchema = object({
|
|
|
8603
8708
|
})).readonly()
|
|
8604
8709
|
});
|
|
8605
8710
|
/**
|
|
8606
|
-
*
|
|
8607
|
-
*
|
|
8608
|
-
*
|
|
8609
|
-
* The provider iterates every `addon-pages-source` (collection) provider
|
|
8610
|
-
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
8611
|
-
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
8612
|
-
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
8613
|
-
* rebuilds without manual reload.
|
|
8614
|
-
*
|
|
8615
|
-
* The hub-local builtin `addon-pages-aggregator` (see
|
|
8616
|
-
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
8617
|
-
* provider. Splitting the public aggregator from the raw collection
|
|
8618
|
-
* keeps both ends in codegen — there's no hand-written
|
|
8619
|
-
* `addon-pages.router.ts` wrapper anymore.
|
|
8711
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
8712
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
8713
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
8620
8714
|
*/
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
}
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8715
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
8716
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
8717
|
+
var ExpressionParseError = class extends Error {
|
|
8718
|
+
position;
|
|
8719
|
+
constructor(message, position) {
|
|
8720
|
+
super(message);
|
|
8721
|
+
this.name = "ExpressionParseError";
|
|
8722
|
+
this.position = position;
|
|
8723
|
+
}
|
|
8724
|
+
};
|
|
8725
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
8726
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
8727
|
+
var ExpressionEvalError = class extends Error {
|
|
8728
|
+
constructor(message) {
|
|
8729
|
+
super(message);
|
|
8730
|
+
this.name = "ExpressionEvalError";
|
|
8731
|
+
}
|
|
8732
|
+
};
|
|
8637
8733
|
/**
|
|
8638
|
-
*
|
|
8639
|
-
*
|
|
8640
|
-
*
|
|
8641
|
-
*
|
|
8642
|
-
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
8643
|
-
* that admin-ui consumes.
|
|
8734
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
8735
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
8736
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
8737
|
+
* own-property check against it.
|
|
8644
8738
|
*
|
|
8645
|
-
*
|
|
8646
|
-
*
|
|
8647
|
-
*
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
* Module Federation remote name — must match the `name` field on the
|
|
8656
|
-
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
8657
|
-
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
8658
|
-
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
8659
|
-
* contain hyphens).
|
|
8660
|
-
*/
|
|
8661
|
-
remoteName: string(),
|
|
8662
|
-
/**
|
|
8663
|
-
* Bundle filename inside the addon's `dist/` dir served at
|
|
8664
|
-
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
8665
|
-
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
8666
|
-
* the static-file route can compute an mtime-based cache-buster URL
|
|
8667
|
-
* without a separate filesystem stat.
|
|
8668
|
-
*/
|
|
8669
|
-
bundle: string(),
|
|
8670
|
-
/**
|
|
8671
|
-
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
8672
|
-
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
8673
|
-
* Any OTHER string creates (or joins) a custom section rendered after
|
|
8674
|
-
* the built-in groups; its label comes from `sectionLabel` (first
|
|
8675
|
-
* declaration wins), falling back to the id. Absent → the legacy
|
|
8676
|
-
* "Addon Pages" group.
|
|
8677
|
-
*/
|
|
8678
|
-
section: string().optional(),
|
|
8679
|
-
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
8680
|
-
sectionLabel: string().optional()
|
|
8681
|
-
});
|
|
8682
|
-
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
8683
|
-
var AddonHttpRouteSchema = object({
|
|
8684
|
-
method: _enum([
|
|
8685
|
-
"GET",
|
|
8686
|
-
"POST",
|
|
8687
|
-
"PUT",
|
|
8688
|
-
"DELETE",
|
|
8689
|
-
"PATCH"
|
|
8690
|
-
]),
|
|
8691
|
-
path: string(),
|
|
8692
|
-
access: _enum([
|
|
8693
|
-
"public",
|
|
8694
|
-
"authenticated",
|
|
8695
|
-
"admin"
|
|
8696
|
-
]).optional(),
|
|
8697
|
-
description: string().optional()
|
|
8698
|
-
});
|
|
8699
|
-
/**
|
|
8700
|
-
* Cross-process route invocation envelope. The hub captures the
|
|
8701
|
-
* request as plain data, ships it to the worker via Moleculer, and
|
|
8702
|
-
* the worker runs the local handler against a capturing reply. The
|
|
8703
|
-
* envelope returned describes what the handler intended (status,
|
|
8704
|
-
* headers, body, or a redirect) so the hub can translate it back to
|
|
8705
|
-
* the Fastify reply that's actually wired to the socket.
|
|
8739
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
8740
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
8741
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
8742
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
8743
|
+
* callable — they are simply "unknown function" at parse time.
|
|
8744
|
+
*
|
|
8745
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
8746
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
8747
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
8748
|
+
* closed rather than emitting a garbage value.
|
|
8706
8749
|
*/
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8750
|
+
function asFiniteNumber(value, name, index) {
|
|
8751
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
8752
|
+
return value;
|
|
8753
|
+
}
|
|
8754
|
+
function asString$1(value, name, index) {
|
|
8755
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
8756
|
+
return value;
|
|
8757
|
+
}
|
|
8758
|
+
function finiteResult(value, name) {
|
|
8759
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
8760
|
+
return value;
|
|
8761
|
+
}
|
|
8762
|
+
function allFiniteNumbers(args, name) {
|
|
8763
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
8764
|
+
}
|
|
8765
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
8766
|
+
var table = {
|
|
8767
|
+
min: {
|
|
8768
|
+
minArgs: 1,
|
|
8769
|
+
maxArgs: INF,
|
|
8770
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
8771
|
+
},
|
|
8772
|
+
max: {
|
|
8773
|
+
minArgs: 1,
|
|
8774
|
+
maxArgs: INF,
|
|
8775
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
8776
|
+
},
|
|
8777
|
+
abs: {
|
|
8778
|
+
minArgs: 1,
|
|
8779
|
+
maxArgs: 1,
|
|
8780
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
8781
|
+
},
|
|
8782
|
+
floor: {
|
|
8783
|
+
minArgs: 1,
|
|
8784
|
+
maxArgs: 1,
|
|
8785
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
8786
|
+
},
|
|
8787
|
+
ceil: {
|
|
8788
|
+
minArgs: 1,
|
|
8789
|
+
maxArgs: 1,
|
|
8790
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
8791
|
+
},
|
|
8792
|
+
sqrt: {
|
|
8793
|
+
minArgs: 1,
|
|
8794
|
+
maxArgs: 1,
|
|
8795
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
8796
|
+
},
|
|
8797
|
+
round: {
|
|
8798
|
+
minArgs: 1,
|
|
8799
|
+
maxArgs: 2,
|
|
8800
|
+
apply: (args) => {
|
|
8801
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
8802
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
8803
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
8804
|
+
const factor = 10 ** digits;
|
|
8805
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
8806
|
+
}
|
|
8807
|
+
},
|
|
8808
|
+
pow: {
|
|
8809
|
+
minArgs: 2,
|
|
8810
|
+
maxArgs: 2,
|
|
8811
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
8812
|
+
},
|
|
8813
|
+
clamp: {
|
|
8814
|
+
minArgs: 3,
|
|
8815
|
+
maxArgs: 3,
|
|
8816
|
+
apply: (args) => {
|
|
8817
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
8818
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
8819
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
8820
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
8821
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
8822
|
+
}
|
|
8823
|
+
},
|
|
8824
|
+
avg: {
|
|
8825
|
+
minArgs: 1,
|
|
8826
|
+
maxArgs: INF,
|
|
8827
|
+
apply: (args) => {
|
|
8828
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
8829
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
8830
|
+
}
|
|
8831
|
+
},
|
|
8832
|
+
sum: {
|
|
8833
|
+
minArgs: 1,
|
|
8834
|
+
maxArgs: INF,
|
|
8835
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
8836
|
+
},
|
|
8837
|
+
coalesce: {
|
|
8838
|
+
minArgs: 1,
|
|
8839
|
+
maxArgs: INF,
|
|
8840
|
+
apply: (args) => {
|
|
8841
|
+
for (const a of args) if (a !== null) return a;
|
|
8842
|
+
return null;
|
|
8843
|
+
}
|
|
8844
|
+
},
|
|
8845
|
+
age: {
|
|
8846
|
+
minArgs: 2,
|
|
8847
|
+
maxArgs: 2,
|
|
8848
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
8849
|
+
},
|
|
8850
|
+
convert: {
|
|
8851
|
+
minArgs: 3,
|
|
8852
|
+
maxArgs: 3,
|
|
8853
|
+
apply: (args, hooks) => {
|
|
8854
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
8855
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
8856
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
8857
|
+
if (hooks.convert) {
|
|
8858
|
+
const out = hooks.convert(x, from, to);
|
|
8859
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
8860
|
+
return finiteResult(out, "convert");
|
|
8861
|
+
}
|
|
8862
|
+
if (from === to) return x;
|
|
8863
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
8864
|
+
}
|
|
8865
|
+
}
|
|
8866
|
+
};
|
|
8867
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
8868
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
8869
|
+
* callees at parse time (immediate author feedback). */
|
|
8870
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
8790
8871
|
/**
|
|
8791
|
-
*
|
|
8792
|
-
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
8793
|
-
* widgets registers a provider on this collection cap; the hub-local
|
|
8794
|
-
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
8795
|
-
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
8796
|
-
* exposes the public listing surface that admin-ui consumes.
|
|
8797
|
-
*
|
|
8798
|
-
* The split exists because the public listing has a different output
|
|
8799
|
-
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
8800
|
-
* per-provider raw declarations. Both ends flow through codegen.
|
|
8872
|
+
* Resource-bound constants for the safe expression engine.
|
|
8801
8873
|
*
|
|
8802
|
-
*
|
|
8803
|
-
*
|
|
8804
|
-
*
|
|
8805
|
-
*
|
|
8806
|
-
*
|
|
8807
|
-
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
8808
|
-
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
8874
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
8875
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
8876
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
8877
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
8878
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
8809
8879
|
*/
|
|
8810
|
-
/**
|
|
8811
|
-
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
"
|
|
8819
|
-
"
|
|
8820
|
-
"
|
|
8821
|
-
"
|
|
8880
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
8881
|
+
* rejected without allocation. */
|
|
8882
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
8883
|
+
/** A legal binding / identifier name. */
|
|
8884
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8885
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
8886
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
8887
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
8888
|
+
"now",
|
|
8889
|
+
"true",
|
|
8890
|
+
"false",
|
|
8891
|
+
"null"
|
|
8822
8892
|
]);
|
|
8823
8893
|
/**
|
|
8824
|
-
*
|
|
8825
|
-
*
|
|
8826
|
-
*
|
|
8827
|
-
*
|
|
8828
|
-
* `
|
|
8894
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
8895
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
8896
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
8897
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
8898
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
8899
|
+
* is a parse error with a source position, so member access / assignment /
|
|
8900
|
+
* template literals are lexically impossible.
|
|
8829
8901
|
*/
|
|
8830
|
-
var
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
*
|
|
8849
|
-
*
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
-
|
|
8901
|
-
|
|
8902
|
-
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
|
|
8908
|
-
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8902
|
+
var KEYWORDS = new Set([
|
|
8903
|
+
"true",
|
|
8904
|
+
"false",
|
|
8905
|
+
"null"
|
|
8906
|
+
]);
|
|
8907
|
+
function isDigit(ch) {
|
|
8908
|
+
return ch >= "0" && ch <= "9";
|
|
8909
|
+
}
|
|
8910
|
+
function isIdentStart(ch) {
|
|
8911
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
8912
|
+
}
|
|
8913
|
+
function isIdentPart(ch) {
|
|
8914
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
8915
|
+
}
|
|
8916
|
+
function isWhitespace(ch) {
|
|
8917
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
8918
|
+
}
|
|
8919
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
8920
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
8921
|
+
* string. */
|
|
8922
|
+
function tokenize(source) {
|
|
8923
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
8924
|
+
const tokens = [];
|
|
8925
|
+
let i = 0;
|
|
8926
|
+
const n = source.length;
|
|
8927
|
+
while (i < n) {
|
|
8928
|
+
const ch = source[i];
|
|
8929
|
+
if (isWhitespace(ch)) {
|
|
8930
|
+
i += 1;
|
|
8931
|
+
continue;
|
|
8932
|
+
}
|
|
8933
|
+
if (isDigit(ch)) {
|
|
8934
|
+
const start = i;
|
|
8935
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
8936
|
+
if (i < n && source[i] === ".") {
|
|
8937
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
8938
|
+
i += 1;
|
|
8939
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
8940
|
+
}
|
|
8941
|
+
const text = source.slice(start, i);
|
|
8942
|
+
const value = Number(text);
|
|
8943
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
8944
|
+
tokens.push({
|
|
8945
|
+
type: "number",
|
|
8946
|
+
value,
|
|
8947
|
+
pos: start
|
|
8948
|
+
});
|
|
8949
|
+
continue;
|
|
8950
|
+
}
|
|
8951
|
+
if (ch === "'" || ch === "\"") {
|
|
8952
|
+
const quote = ch;
|
|
8953
|
+
const start = i;
|
|
8954
|
+
i += 1;
|
|
8955
|
+
let out = "";
|
|
8956
|
+
let closed = false;
|
|
8957
|
+
while (i < n) {
|
|
8958
|
+
const c = source[i];
|
|
8959
|
+
if (c === "\\") {
|
|
8960
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
8961
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
8962
|
+
out += next;
|
|
8963
|
+
i += 2;
|
|
8964
|
+
continue;
|
|
8965
|
+
}
|
|
8966
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
8967
|
+
}
|
|
8968
|
+
if (c === quote) {
|
|
8969
|
+
closed = true;
|
|
8970
|
+
i += 1;
|
|
8971
|
+
break;
|
|
8972
|
+
}
|
|
8973
|
+
out += c;
|
|
8974
|
+
i += 1;
|
|
8975
|
+
}
|
|
8976
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
8977
|
+
tokens.push({
|
|
8978
|
+
type: "string",
|
|
8979
|
+
value: out,
|
|
8980
|
+
pos: start
|
|
8981
|
+
});
|
|
8982
|
+
continue;
|
|
8983
|
+
}
|
|
8984
|
+
if (isIdentStart(ch)) {
|
|
8985
|
+
const start = i;
|
|
8986
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
8987
|
+
const text = source.slice(start, i);
|
|
8988
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
8989
|
+
type: "keyword",
|
|
8990
|
+
keyword: keywordOf(text),
|
|
8991
|
+
pos: start
|
|
8992
|
+
});
|
|
8993
|
+
else tokens.push({
|
|
8994
|
+
type: "identifier",
|
|
8995
|
+
name: text,
|
|
8996
|
+
pos: start
|
|
8997
|
+
});
|
|
8998
|
+
continue;
|
|
8999
|
+
}
|
|
9000
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
9001
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
9002
|
+
tokens.push({
|
|
9003
|
+
type: "punct",
|
|
9004
|
+
punct: two,
|
|
9005
|
+
pos: i
|
|
9006
|
+
});
|
|
9007
|
+
i += 2;
|
|
9008
|
+
continue;
|
|
9009
|
+
}
|
|
9010
|
+
if (isSinglePunct(ch)) {
|
|
9011
|
+
tokens.push({
|
|
9012
|
+
type: "punct",
|
|
9013
|
+
punct: ch,
|
|
9014
|
+
pos: i
|
|
9015
|
+
});
|
|
9016
|
+
i += 1;
|
|
9017
|
+
continue;
|
|
9018
|
+
}
|
|
9019
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
9020
|
+
}
|
|
9021
|
+
tokens.push({
|
|
9022
|
+
type: "eof",
|
|
9023
|
+
pos: n
|
|
9024
|
+
});
|
|
9025
|
+
return tokens;
|
|
9026
|
+
}
|
|
9027
|
+
function keywordOf(text) {
|
|
9028
|
+
if (text === "true") return "true";
|
|
9029
|
+
if (text === "false") return "false";
|
|
9030
|
+
return "null";
|
|
9031
|
+
}
|
|
9032
|
+
function isSinglePunct(ch) {
|
|
9033
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
9034
|
+
}
|
|
8918
9035
|
/**
|
|
8919
|
-
*
|
|
8920
|
-
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9036
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
8921
9037
|
*
|
|
8922
|
-
*
|
|
8923
|
-
*
|
|
8924
|
-
* `
|
|
8925
|
-
*
|
|
8926
|
-
*
|
|
8927
|
-
*
|
|
9038
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
9039
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
9040
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
9041
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
9042
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
9043
|
+
* that references a since-removed builtin degrades at read.
|
|
8928
9044
|
*
|
|
8929
|
-
*
|
|
8930
|
-
*
|
|
8931
|
-
* provider. Splitting the public aggregator from the raw collection
|
|
8932
|
-
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
9045
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
9046
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
8933
9047
|
*/
|
|
8934
|
-
|
|
8935
|
-
|
|
8936
|
-
|
|
8937
|
-
|
|
8938
|
-
|
|
9048
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
9049
|
+
var BINARY_PRECEDENCE = {
|
|
9050
|
+
"||": 1,
|
|
9051
|
+
"&&": 2,
|
|
9052
|
+
"==": 3,
|
|
9053
|
+
"!=": 3,
|
|
9054
|
+
"<": 4,
|
|
9055
|
+
"<=": 4,
|
|
9056
|
+
">": 4,
|
|
9057
|
+
">=": 4,
|
|
9058
|
+
"+": 5,
|
|
9059
|
+
"-": 5,
|
|
9060
|
+
"*": 6,
|
|
9061
|
+
"/": 6,
|
|
9062
|
+
"%": 6
|
|
9063
|
+
};
|
|
9064
|
+
function isLogicalOp(op) {
|
|
9065
|
+
return op === "&&" || op === "||";
|
|
9066
|
+
}
|
|
9067
|
+
function isBinaryOp(op) {
|
|
9068
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
9069
|
+
}
|
|
9070
|
+
var Parser = class {
|
|
9071
|
+
tokens;
|
|
9072
|
+
pos = 0;
|
|
9073
|
+
nodeCount = 0;
|
|
9074
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
9075
|
+
callees = /* @__PURE__ */ new Set();
|
|
9076
|
+
constructor(tokens) {
|
|
9077
|
+
this.tokens = tokens;
|
|
9078
|
+
}
|
|
9079
|
+
parse() {
|
|
9080
|
+
const ast = this.parseTernary();
|
|
9081
|
+
const tok = this.peek();
|
|
9082
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
9083
|
+
return {
|
|
9084
|
+
ast,
|
|
9085
|
+
identifiers: this.identifiers,
|
|
9086
|
+
callees: this.callees,
|
|
9087
|
+
nodeCount: this.nodeCount
|
|
9088
|
+
};
|
|
9089
|
+
}
|
|
9090
|
+
peek() {
|
|
9091
|
+
return this.tokens[this.pos];
|
|
9092
|
+
}
|
|
9093
|
+
next() {
|
|
9094
|
+
return this.tokens[this.pos++];
|
|
9095
|
+
}
|
|
9096
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
9097
|
+
expectPunct(punct) {
|
|
9098
|
+
const tok = this.peek();
|
|
9099
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
9100
|
+
this.pos += 1;
|
|
9101
|
+
}
|
|
9102
|
+
matchPunct(punct) {
|
|
9103
|
+
const tok = this.peek();
|
|
9104
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
9105
|
+
this.pos += 1;
|
|
9106
|
+
return true;
|
|
9107
|
+
}
|
|
9108
|
+
return false;
|
|
9109
|
+
}
|
|
9110
|
+
countNode() {
|
|
9111
|
+
this.nodeCount += 1;
|
|
9112
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
9113
|
+
}
|
|
9114
|
+
parseTernary() {
|
|
9115
|
+
const test = this.parseBinary(1);
|
|
9116
|
+
if (this.matchPunct("?")) {
|
|
9117
|
+
const consequent = this.parseTernary();
|
|
9118
|
+
this.expectPunct(":");
|
|
9119
|
+
const alternate = this.parseTernary();
|
|
9120
|
+
this.countNode();
|
|
9121
|
+
return {
|
|
9122
|
+
kind: "conditional",
|
|
9123
|
+
test,
|
|
9124
|
+
consequent,
|
|
9125
|
+
alternate
|
|
9126
|
+
};
|
|
9127
|
+
}
|
|
9128
|
+
return test;
|
|
9129
|
+
}
|
|
9130
|
+
parseBinary(minPrec) {
|
|
9131
|
+
let left = this.parseUnary();
|
|
9132
|
+
for (;;) {
|
|
9133
|
+
const tok = this.peek();
|
|
9134
|
+
if (tok.type !== "punct") break;
|
|
9135
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
9136
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
9137
|
+
const op = tok.punct;
|
|
9138
|
+
this.pos += 1;
|
|
9139
|
+
const right = this.parseBinary(prec + 1);
|
|
9140
|
+
this.countNode();
|
|
9141
|
+
if (isLogicalOp(op)) left = {
|
|
9142
|
+
kind: "logical",
|
|
9143
|
+
op,
|
|
9144
|
+
left,
|
|
9145
|
+
right
|
|
9146
|
+
};
|
|
9147
|
+
else if (isBinaryOp(op)) left = {
|
|
9148
|
+
kind: "binary",
|
|
9149
|
+
op,
|
|
9150
|
+
left,
|
|
9151
|
+
right
|
|
9152
|
+
};
|
|
9153
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
9154
|
+
}
|
|
9155
|
+
return left;
|
|
9156
|
+
}
|
|
9157
|
+
parseUnary() {
|
|
9158
|
+
const tok = this.peek();
|
|
9159
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
9160
|
+
const op = tok.punct;
|
|
9161
|
+
this.pos += 1;
|
|
9162
|
+
const operand = this.parseUnary();
|
|
9163
|
+
this.countNode();
|
|
9164
|
+
return {
|
|
9165
|
+
kind: "unary",
|
|
9166
|
+
op,
|
|
9167
|
+
operand
|
|
9168
|
+
};
|
|
9169
|
+
}
|
|
9170
|
+
return this.parsePrimary();
|
|
9171
|
+
}
|
|
9172
|
+
parsePrimary() {
|
|
9173
|
+
const tok = this.next();
|
|
9174
|
+
switch (tok.type) {
|
|
9175
|
+
case "number":
|
|
9176
|
+
this.countNode();
|
|
9177
|
+
return {
|
|
9178
|
+
kind: "literal",
|
|
9179
|
+
value: tok.value
|
|
9180
|
+
};
|
|
9181
|
+
case "string":
|
|
9182
|
+
this.countNode();
|
|
9183
|
+
return {
|
|
9184
|
+
kind: "literal",
|
|
9185
|
+
value: tok.value
|
|
9186
|
+
};
|
|
9187
|
+
case "keyword":
|
|
9188
|
+
this.countNode();
|
|
9189
|
+
return {
|
|
9190
|
+
kind: "literal",
|
|
9191
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
9192
|
+
};
|
|
9193
|
+
case "identifier": {
|
|
9194
|
+
const nextTok = this.peek();
|
|
9195
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
9196
|
+
this.identifiers.add(tok.name);
|
|
9197
|
+
this.countNode();
|
|
9198
|
+
return {
|
|
9199
|
+
kind: "identifier",
|
|
9200
|
+
name: tok.name
|
|
9201
|
+
};
|
|
9202
|
+
}
|
|
9203
|
+
case "punct":
|
|
9204
|
+
if (tok.punct === "(") {
|
|
9205
|
+
const inner = this.parseTernary();
|
|
9206
|
+
this.expectPunct(")");
|
|
9207
|
+
return inner;
|
|
9208
|
+
}
|
|
9209
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
9210
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
9211
|
+
}
|
|
9212
|
+
}
|
|
9213
|
+
parseCall(callee, pos) {
|
|
9214
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
9215
|
+
this.expectPunct("(");
|
|
9216
|
+
const args = [];
|
|
9217
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
9218
|
+
args.push(this.parseTernary());
|
|
9219
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
9220
|
+
if (this.matchPunct(",")) continue;
|
|
9221
|
+
this.expectPunct(")");
|
|
9222
|
+
break;
|
|
9223
|
+
}
|
|
9224
|
+
this.callees.add(callee);
|
|
9225
|
+
this.countNode();
|
|
9226
|
+
return {
|
|
9227
|
+
kind: "call",
|
|
9228
|
+
callee,
|
|
9229
|
+
args
|
|
9230
|
+
};
|
|
9231
|
+
}
|
|
9232
|
+
};
|
|
9233
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
9234
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
9235
|
+
function parseExpression(source) {
|
|
9236
|
+
return new Parser(tokenize(source)).parse();
|
|
9237
|
+
}
|
|
8939
9238
|
/**
|
|
8940
|
-
*
|
|
9239
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
9240
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
9241
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
9242
|
+
* one per read on a hot resolve path.
|
|
8941
9243
|
*
|
|
8942
|
-
*
|
|
8943
|
-
*
|
|
8944
|
-
*
|
|
9244
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
9245
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
9246
|
+
* callers is safe and maximises hit rate.
|
|
8945
9247
|
*/
|
|
8946
|
-
var
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
9248
|
+
var cache = /* @__PURE__ */ new Map();
|
|
9249
|
+
function getCached(source) {
|
|
9250
|
+
const hit = cache.get(source);
|
|
9251
|
+
if (hit !== void 0) {
|
|
9252
|
+
cache.delete(source);
|
|
9253
|
+
cache.set(source, hit);
|
|
9254
|
+
return hit;
|
|
9255
|
+
}
|
|
9256
|
+
let result;
|
|
9257
|
+
try {
|
|
9258
|
+
result = {
|
|
9259
|
+
ok: true,
|
|
9260
|
+
parsed: parseExpression(source)
|
|
9261
|
+
};
|
|
9262
|
+
} catch (err) {
|
|
9263
|
+
result = {
|
|
9264
|
+
ok: false,
|
|
9265
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
9266
|
+
};
|
|
9267
|
+
}
|
|
9268
|
+
cache.set(source, result);
|
|
9269
|
+
if (cache.size > 256) {
|
|
9270
|
+
const oldest = cache.keys().next().value;
|
|
9271
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
9272
|
+
}
|
|
9273
|
+
return result;
|
|
9274
|
+
}
|
|
9275
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
9276
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
9277
|
+
function compileExpressionSafe(source) {
|
|
9278
|
+
return getCached(source);
|
|
9279
|
+
}
|
|
9280
|
+
Object.freeze({});
|
|
9281
|
+
/**
|
|
9282
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
9283
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
9284
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
9285
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
9286
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
9287
|
+
*/
|
|
9288
|
+
function validateExpressionSource(src) {
|
|
9289
|
+
const names = Object.keys(src.bindings);
|
|
9290
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
9291
|
+
for (const name of names) {
|
|
9292
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
9293
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
9294
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
9295
|
+
}
|
|
9296
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
9297
|
+
if (!compiled.ok) return compiled.error;
|
|
9298
|
+
const bound = new Set(names);
|
|
9299
|
+
for (const id of compiled.parsed.identifiers) {
|
|
9300
|
+
if (id === "now") continue;
|
|
9301
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
9302
|
+
}
|
|
9303
|
+
return null;
|
|
9304
|
+
}
|
|
9305
|
+
var ExpressionBindingSourceSchema = union([
|
|
9306
|
+
object({
|
|
9307
|
+
kind: literal("field").optional(),
|
|
9308
|
+
sourceKey: string(),
|
|
9309
|
+
cap: string(),
|
|
9310
|
+
fieldPath: string()
|
|
9311
|
+
}),
|
|
9312
|
+
object({
|
|
9313
|
+
kind: literal("literal"),
|
|
9314
|
+
value: union([
|
|
9315
|
+
string(),
|
|
9316
|
+
number(),
|
|
9317
|
+
boolean(),
|
|
9318
|
+
_null()
|
|
9319
|
+
])
|
|
9320
|
+
}),
|
|
9321
|
+
object({
|
|
9322
|
+
kind: literal("global"),
|
|
9323
|
+
sourceStableId: string(),
|
|
9324
|
+
cap: string(),
|
|
9325
|
+
fieldPath: string()
|
|
9326
|
+
})
|
|
8951
9327
|
]);
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
9328
|
+
object({
|
|
9329
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
9330
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
9331
|
+
}).superRefine((src, ctx) => {
|
|
9332
|
+
const err = validateExpressionSource(src);
|
|
9333
|
+
if (err !== null) ctx.addIssue({
|
|
9334
|
+
code: "custom",
|
|
9335
|
+
message: err,
|
|
9336
|
+
path: ["expr"]
|
|
9337
|
+
});
|
|
9338
|
+
});
|
|
9339
|
+
/** How a leaf compares a device field to a value. Derived from the field's
|
|
9340
|
+
* `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
|
|
9341
|
+
var AutomationConditionOperatorSchema = _enum([
|
|
9342
|
+
"eq",
|
|
9343
|
+
"ne",
|
|
9344
|
+
"gt",
|
|
9345
|
+
"gte",
|
|
9346
|
+
"lt",
|
|
9347
|
+
"lte",
|
|
9348
|
+
"contains",
|
|
9349
|
+
"in"
|
|
9350
|
+
]);
|
|
9351
|
+
var AutomationConditionLeafSchema = object({
|
|
9352
|
+
kind: literal("condition"),
|
|
9353
|
+
deviceId: number().int().nonnegative(),
|
|
9354
|
+
cap: string().min(1),
|
|
9355
|
+
fieldPath: string().min(1),
|
|
9356
|
+
operator: AutomationConditionOperatorSchema,
|
|
9357
|
+
value: union([
|
|
9358
|
+
string(),
|
|
9359
|
+
number(),
|
|
9360
|
+
boolean(),
|
|
9361
|
+
array(union([string(), number()]))
|
|
9362
|
+
])
|
|
9363
|
+
});
|
|
9364
|
+
/**
|
|
9365
|
+
* The expression leaf, declared as a plain object rather than an intersection
|
|
9366
|
+
* with {@link ExpressionSourceSchema}: a discriminated union has to be able to
|
|
9367
|
+
* read `kind` off each option, and an intersection hides it. The author-time
|
|
9368
|
+
* validation is the SAME function `ExpressionSourceSchema` runs, so the two
|
|
9369
|
+
* cannot drift — an expression that one accepts, the other accepts.
|
|
9370
|
+
*/
|
|
9371
|
+
var AutomationConditionExpressionSchema = object({
|
|
9372
|
+
kind: literal("expression"),
|
|
9373
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
9374
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
9375
|
+
}).superRefine((src, ctx) => {
|
|
9376
|
+
const err = validateExpressionSource(src);
|
|
9377
|
+
if (err !== null) ctx.addIssue({
|
|
9378
|
+
code: "custom",
|
|
9379
|
+
message: err,
|
|
9380
|
+
path: ["expr"]
|
|
9381
|
+
});
|
|
9382
|
+
});
|
|
9383
|
+
var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
|
|
9384
|
+
object({
|
|
9385
|
+
kind: literal("all"),
|
|
9386
|
+
children: array(AutomationConditionSchema)
|
|
9387
|
+
}),
|
|
9388
|
+
object({
|
|
9389
|
+
kind: literal("any"),
|
|
9390
|
+
children: array(AutomationConditionSchema)
|
|
9391
|
+
}),
|
|
9392
|
+
object({
|
|
9393
|
+
kind: literal("not"),
|
|
9394
|
+
child: AutomationConditionSchema
|
|
9395
|
+
}),
|
|
9396
|
+
AutomationConditionLeafSchema,
|
|
9397
|
+
AutomationConditionExpressionSchema
|
|
9398
|
+
]));
|
|
9399
|
+
/**
|
|
9400
|
+
* What starts a run.
|
|
9401
|
+
*
|
|
9402
|
+
* D8 compliance, and it is the reason `device-state` is not merely an event
|
|
9403
|
+
* subscription: the trigger evaluates against the **state mirror**, which is
|
|
9404
|
+
* reconciled, and an event only WAKES the evaluation. A dropped event therefore
|
|
9405
|
+
* DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
|
|
9406
|
+
* already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
|
|
9407
|
+
* not.
|
|
9408
|
+
*/
|
|
9409
|
+
var AutomationTriggerSchema = discriminatedUnion("kind", [
|
|
9410
|
+
object({
|
|
9411
|
+
kind: literal("device-state"),
|
|
9412
|
+
deviceId: number().int().nonnegative(),
|
|
9413
|
+
cap: string().min(1),
|
|
9414
|
+
fieldPath: string().min(1),
|
|
9415
|
+
/** Fire when the field takes this value. Omit to fire on any change. */
|
|
9416
|
+
becomes: union([
|
|
9417
|
+
string(),
|
|
9418
|
+
number(),
|
|
9419
|
+
boolean()
|
|
9420
|
+
]).optional(),
|
|
9421
|
+
/** Only on a CHANGE of value, not on every re-report. */
|
|
9422
|
+
edge: boolean().optional(),
|
|
9423
|
+
/** The condition must hold this long before the run starts. */
|
|
9424
|
+
forMs: number().int().min(0).max(864e5).optional(),
|
|
9425
|
+
/** Collapse a burst into one run. */
|
|
9426
|
+
debounceMs: number().int().min(0).max(6e5).optional()
|
|
9427
|
+
}),
|
|
9428
|
+
object({
|
|
9429
|
+
kind: literal("device-event"),
|
|
9430
|
+
/** An `EventCategory` value. */
|
|
9431
|
+
category: string().min(1),
|
|
9432
|
+
deviceId: number().int().nonnegative().optional()
|
|
9433
|
+
}),
|
|
9434
|
+
object({
|
|
9435
|
+
kind: literal("schedule"),
|
|
9436
|
+
cron: string().min(1).max(120)
|
|
9437
|
+
}),
|
|
9438
|
+
object({ kind: literal("manual") })
|
|
9439
|
+
]);
|
|
9440
|
+
/**
|
|
9441
|
+
* One action step.
|
|
9442
|
+
*
|
|
9443
|
+
* `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
|
|
9444
|
+
* identical so `NcRuleActionRunner` runs them unchanged — its device-scope
|
|
9445
|
+
* check, stop-at-first-failure and per-sequence throttle are the whole reason
|
|
9446
|
+
* to reuse it, and none of them are re-implemented here.
|
|
9447
|
+
*
|
|
9448
|
+
* **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
|
|
9449
|
+
* a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
|
|
9450
|
+
* documents its subject device as *"for the log tag, never for routing"*. So an
|
|
9451
|
+
* NC action can never target the device that triggered it — which is fine for
|
|
9452
|
+
* the NC (its rules already scope to a device) and fatal for an automation
|
|
9453
|
+
* ("sound the siren of the camera that saw the person"). `deviceId` therefore
|
|
9454
|
+
* also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
|
|
9455
|
+
* is called. The runner still receives a number and is untouched; the
|
|
9456
|
+
* resolution is the recipe's job, not the runner's.
|
|
9457
|
+
*/
|
|
9458
|
+
var AutomationActionSchema = discriminatedUnion("kind", [
|
|
9459
|
+
object({
|
|
9460
|
+
kind: literal("wait"),
|
|
9461
|
+
seconds: number().min(0).max(300)
|
|
9462
|
+
}),
|
|
9463
|
+
object({
|
|
9464
|
+
kind: literal("cap"),
|
|
9465
|
+
deviceId: union([number().int(), object({ $var: string().min(1) })]),
|
|
9466
|
+
cap: string().min(1),
|
|
9467
|
+
method: string().min(1),
|
|
9468
|
+
/** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
|
|
9469
|
+
* evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
|
|
9470
|
+
args: record(string(), unknown()).optional()
|
|
9471
|
+
}),
|
|
9472
|
+
object({
|
|
9473
|
+
kind: literal("code"),
|
|
9474
|
+
/** Compiled into the automation's OWN block by esbuild — not a third
|
|
9475
|
+
* runtime, not a `vm`, and not dynamically evaluated. */
|
|
9476
|
+
code: string().min(1).max(2e4)
|
|
9477
|
+
})
|
|
9478
|
+
]);
|
|
9479
|
+
object({
|
|
9480
|
+
triggers: array(AutomationTriggerSchema),
|
|
9481
|
+
conditions: AutomationConditionSchema.optional(),
|
|
9482
|
+
actions: array(AutomationActionSchema)
|
|
9483
|
+
});
|
|
9484
|
+
/**
|
|
9485
|
+
* `addon-pages` — system-scoped singleton aggregator cap. Public-facing
|
|
9486
|
+
* surface that admin-ui consumes through `useAddonPagesListPages()`.
|
|
9487
|
+
*
|
|
9488
|
+
* The provider iterates every `addon-pages-source` (collection) provider
|
|
9489
|
+
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
9490
|
+
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
9491
|
+
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
9492
|
+
* rebuilds without manual reload.
|
|
9493
|
+
*
|
|
9494
|
+
* The hub-local builtin `addon-pages-aggregator` (see
|
|
9495
|
+
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
9496
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9497
|
+
* keeps both ends in codegen — there's no hand-written
|
|
9498
|
+
* `addon-pages.router.ts` wrapper anymore.
|
|
9499
|
+
*/
|
|
9500
|
+
var AddonPageDeclarationSchema$1 = object({
|
|
9501
|
+
id: string(),
|
|
9502
|
+
label: string(),
|
|
9503
|
+
icon: string(),
|
|
9504
|
+
path: string(),
|
|
9505
|
+
remoteName: string(),
|
|
9506
|
+
bundle: string(),
|
|
9507
|
+
section: string().optional(),
|
|
9508
|
+
sectionLabel: string().optional()
|
|
9509
|
+
});
|
|
9510
|
+
var AddonPageInfoSchema = object({
|
|
9511
|
+
addonId: string(),
|
|
9512
|
+
page: AddonPageDeclarationSchema$1,
|
|
9513
|
+
bundleUrl: string()
|
|
9514
|
+
});
|
|
9515
|
+
method(_void(), array(AddonPageInfoSchema).readonly());
|
|
9516
|
+
/**
|
|
9517
|
+
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
9518
|
+
* declarations. Every addon that contributes a UI page registers a
|
|
9519
|
+
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
9520
|
+
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
9521
|
+
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
9522
|
+
* that admin-ui consumes.
|
|
9523
|
+
*
|
|
9524
|
+
* The split exists because the public listing has a different output
|
|
9525
|
+
* shape than the per-provider raw declarations, and we want both ends
|
|
9526
|
+
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
9527
|
+
*/
|
|
9528
|
+
var AddonPageDeclarationSchema = object({
|
|
9529
|
+
id: string(),
|
|
9530
|
+
label: string(),
|
|
9531
|
+
icon: string(),
|
|
9532
|
+
path: string(),
|
|
9533
|
+
/**
|
|
9534
|
+
* Module Federation remote name — must match the `name` field on the
|
|
9535
|
+
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
9536
|
+
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
9537
|
+
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
9538
|
+
* contain hyphens).
|
|
9539
|
+
*/
|
|
9540
|
+
remoteName: string(),
|
|
9541
|
+
/**
|
|
9542
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9543
|
+
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
9544
|
+
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
9545
|
+
* the static-file route can compute an mtime-based cache-buster URL
|
|
9546
|
+
* without a separate filesystem stat.
|
|
9547
|
+
*/
|
|
9548
|
+
bundle: string(),
|
|
9549
|
+
/**
|
|
9550
|
+
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
9551
|
+
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
9552
|
+
* Any OTHER string creates (or joins) a custom section rendered after
|
|
9553
|
+
* the built-in groups; its label comes from `sectionLabel` (first
|
|
9554
|
+
* declaration wins), falling back to the id. Absent → the legacy
|
|
9555
|
+
* "Addon Pages" group.
|
|
9556
|
+
*/
|
|
9557
|
+
section: string().optional(),
|
|
9558
|
+
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
9559
|
+
sectionLabel: string().optional()
|
|
9560
|
+
});
|
|
9561
|
+
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
9562
|
+
var AddonHttpRouteSchema = object({
|
|
9563
|
+
method: _enum([
|
|
9564
|
+
"GET",
|
|
9565
|
+
"POST",
|
|
9566
|
+
"PUT",
|
|
9567
|
+
"DELETE",
|
|
9568
|
+
"PATCH"
|
|
9569
|
+
]),
|
|
9570
|
+
path: string(),
|
|
9571
|
+
access: _enum([
|
|
9572
|
+
"public",
|
|
9573
|
+
"authenticated",
|
|
9574
|
+
"admin"
|
|
9575
|
+
]).optional(),
|
|
9576
|
+
description: string().optional()
|
|
9577
|
+
});
|
|
9578
|
+
/**
|
|
9579
|
+
* Cross-process route invocation envelope. The hub captures the
|
|
9580
|
+
* request as plain data, ships it to the worker via Moleculer, and
|
|
9581
|
+
* the worker runs the local handler against a capturing reply. The
|
|
9582
|
+
* envelope returned describes what the handler intended (status,
|
|
9583
|
+
* headers, body, or a redirect) so the hub can translate it back to
|
|
9584
|
+
* the Fastify reply that's actually wired to the socket.
|
|
9585
|
+
*/
|
|
9586
|
+
var InvokeRequestSchema = object({
|
|
9587
|
+
method: string(),
|
|
9588
|
+
path: string(),
|
|
9589
|
+
params: record(string(), string()),
|
|
9590
|
+
query: record(string(), string()),
|
|
9591
|
+
body: unknown(),
|
|
9592
|
+
headers: record(string(), string()),
|
|
9593
|
+
user: object({
|
|
9594
|
+
id: string(),
|
|
9595
|
+
username: string(),
|
|
9596
|
+
isAdmin: boolean()
|
|
9597
|
+
}).optional(),
|
|
9598
|
+
scopedToken: unknown().optional()
|
|
9599
|
+
});
|
|
9600
|
+
var InvokeReplyEnvelopeSchema = object({
|
|
9601
|
+
status: number().int(),
|
|
9602
|
+
headers: record(string(), string()),
|
|
9603
|
+
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
9604
|
+
* sending `body`. Status defaults to 302 when this is set unless
|
|
9605
|
+
* the handler called `reply.code(...)` explicitly. */
|
|
9606
|
+
redirectUrl: string().nullable(),
|
|
9607
|
+
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
9608
|
+
body: unknown().optional(),
|
|
9609
|
+
/** Set when the handler called `reply.type(mime)`. */
|
|
9610
|
+
contentType: string().optional()
|
|
9611
|
+
});
|
|
9612
|
+
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
9613
|
+
var ConfigTabDeclarationSchema = object({
|
|
9614
|
+
id: string(),
|
|
9615
|
+
label: string(),
|
|
9616
|
+
icon: string(),
|
|
9617
|
+
order: number().optional()
|
|
9618
|
+
});
|
|
9619
|
+
var ConfigSectionWithValuesSchema = object({
|
|
9620
|
+
id: string(),
|
|
9621
|
+
title: string(),
|
|
9622
|
+
description: string().optional(),
|
|
9623
|
+
style: _enum(["card", "accordion"]).optional(),
|
|
9624
|
+
defaultCollapsed: boolean().optional(),
|
|
9625
|
+
columns: union([
|
|
9626
|
+
literal(1),
|
|
9627
|
+
literal(2),
|
|
9628
|
+
literal(3),
|
|
9629
|
+
literal(4)
|
|
9630
|
+
]).optional(),
|
|
9631
|
+
tab: string().optional(),
|
|
9632
|
+
location: _enum(["settings", "top-tab"]).optional(),
|
|
9633
|
+
order: number().optional(),
|
|
9634
|
+
fields: array(any())
|
|
9635
|
+
});
|
|
9636
|
+
var SettingsSchemaWithValuesSchema = object({
|
|
9637
|
+
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
9638
|
+
sections: array(ConfigSectionWithValuesSchema)
|
|
9639
|
+
});
|
|
9640
|
+
/** Patch object — keys are field names, values are the new field values. */
|
|
9641
|
+
var SettingsPatchSchema = record(string(), unknown());
|
|
9642
|
+
/** Standard success response for update operations. */
|
|
9643
|
+
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
9644
|
+
method(object({
|
|
9645
|
+
addonId: string(),
|
|
9646
|
+
nodeId: string().optional(),
|
|
9647
|
+
overlay: record(string(), unknown()).optional(),
|
|
9648
|
+
cap: string().optional()
|
|
9649
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9650
|
+
addonId: string(),
|
|
9651
|
+
nodeId: string().optional(),
|
|
9652
|
+
patch: SettingsPatchSchema
|
|
9653
|
+
}), SettingsUpdateResultSchema, {
|
|
9654
|
+
kind: "mutation",
|
|
9655
|
+
auth: "admin"
|
|
9656
|
+
}), method(object({
|
|
9657
|
+
addonId: string(),
|
|
9658
|
+
deviceId: number(),
|
|
9659
|
+
nodeId: string().optional()
|
|
9660
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9661
|
+
addonId: string(),
|
|
9662
|
+
deviceId: number(),
|
|
9663
|
+
nodeId: string().optional(),
|
|
9664
|
+
patch: SettingsPatchSchema
|
|
9665
|
+
}), SettingsUpdateResultSchema, {
|
|
9666
|
+
kind: "mutation",
|
|
9667
|
+
auth: "admin"
|
|
9668
|
+
});
|
|
9669
|
+
/**
|
|
9670
|
+
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
9671
|
+
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
9672
|
+
* widgets registers a provider on this collection cap; the hub-local
|
|
9673
|
+
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
9674
|
+
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
9675
|
+
* exposes the public listing surface that admin-ui consumes.
|
|
9676
|
+
*
|
|
9677
|
+
* The split exists because the public listing has a different output
|
|
9678
|
+
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
9679
|
+
* per-provider raw declarations. Both ends flow through codegen.
|
|
9680
|
+
*
|
|
9681
|
+
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
9682
|
+
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
9683
|
+
* same `ContributionRenderer` / Module-Federation path as every other
|
|
9684
|
+
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
9685
|
+
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
9686
|
+
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
9687
|
+
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
9688
|
+
*/
|
|
9689
|
+
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
9690
|
+
var WidgetHostEnum = _enum([
|
|
9691
|
+
"device-tab",
|
|
9692
|
+
"dashboard",
|
|
9693
|
+
"integration-detail"
|
|
9694
|
+
]);
|
|
9695
|
+
var WidgetSizeEnum = _enum([
|
|
9696
|
+
"xs",
|
|
9697
|
+
"sm",
|
|
9698
|
+
"md",
|
|
9699
|
+
"lg",
|
|
9700
|
+
"xl"
|
|
9701
|
+
]);
|
|
9702
|
+
/**
|
|
9703
|
+
* MF remote descriptor — mirrors `UiContributionRemote` from
|
|
9704
|
+
* `capability-definition.ts`. Widget remotes expose a single
|
|
9705
|
+
* `'./widgets'` module whose default export is a
|
|
9706
|
+
* `Record<componentKey, Component>` map; `componentKey` (the widget
|
|
9707
|
+
* `stableId`) picks the entry the host mounts.
|
|
9708
|
+
*/
|
|
9709
|
+
var WidgetRemoteSchema = object({
|
|
9710
|
+
remoteName: string(),
|
|
9711
|
+
exposedModule: string(),
|
|
9712
|
+
componentKey: string().optional()
|
|
9713
|
+
});
|
|
9714
|
+
/**
|
|
9715
|
+
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
9716
|
+
* widget-only metadata. The `UiContribution` core fields:
|
|
9717
|
+
*
|
|
9718
|
+
* - `tab` — where the widget hosts. A widget that runs on the
|
|
9719
|
+
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
9720
|
+
* widget declares the target device-detail tab id.
|
|
9721
|
+
* - `subTab` — optional sub-tab within `tab`.
|
|
9722
|
+
* - `label` — operator-facing label.
|
|
9723
|
+
* - `order` — ordering within `(tab, subTab)`.
|
|
9724
|
+
* - `kind` — always `'remote'` for widgets.
|
|
9725
|
+
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
9726
|
+
*
|
|
9727
|
+
* Widget-only fields retained alongside the contribution core:
|
|
9728
|
+
*
|
|
9729
|
+
* - `stableId` — stable identity within the addon (the MF
|
|
9730
|
+
* `componentKey`; kept top-level so consumers have
|
|
9731
|
+
* a stable key without reaching into `remote`).
|
|
9732
|
+
* - `description` / `icon` — picker metadata.
|
|
9733
|
+
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
9734
|
+
* aggregator stamps a versioned `bundleUrl` from it.
|
|
9735
|
+
* - `hosts` — every host the widget supports (a widget can run
|
|
9736
|
+
* both on the dashboard and a device tab). `tab`
|
|
9737
|
+
* is the PRIMARY host; `hosts` is the full set the
|
|
9738
|
+
* picker filters on.
|
|
9739
|
+
* - `requires` — host-context requirements validated at mount.
|
|
9740
|
+
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
9741
|
+
* — dashboard placement hints.
|
|
9742
|
+
*/
|
|
9743
|
+
var WidgetMetadataSchema = object({
|
|
9744
|
+
/** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
|
|
9745
|
+
tab: string(),
|
|
9746
|
+
/** Optional sub-tab within `tab`. */
|
|
9747
|
+
subTab: string().optional(),
|
|
9748
|
+
/** Operator-facing label. */
|
|
9749
|
+
label: string(),
|
|
9750
|
+
/** Ordering within `(tab, subTab)`, ascending. */
|
|
9751
|
+
order: number().optional(),
|
|
9752
|
+
/** Always `'remote'` — a widget is a Module Federation remote. */
|
|
9753
|
+
kind: literal("remote"),
|
|
9754
|
+
/** MF remote descriptor. */
|
|
9755
|
+
remote: WidgetRemoteSchema,
|
|
9756
|
+
/** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
|
|
9757
|
+
stableId: string(),
|
|
9758
|
+
description: string().optional(),
|
|
9759
|
+
icon: string().optional(),
|
|
9760
|
+
/**
|
|
9761
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9762
|
+
* `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
|
|
9763
|
+
* this is always `'remoteEntry.js'` — the value is kept on the
|
|
9764
|
+
* metadata so the static-file route can compute an mtime-based
|
|
9765
|
+
* cache-buster URL without a separate filesystem stat.
|
|
9766
|
+
*/
|
|
9767
|
+
bundle: string(),
|
|
9768
|
+
/** Every host the widget supports. The picker filters on this set. */
|
|
9769
|
+
hosts: array(WidgetHostEnum).readonly(),
|
|
9770
|
+
/** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
|
|
9771
|
+
requires: object({
|
|
9772
|
+
deviceContext: boolean().default(false),
|
|
9773
|
+
integrationContext: boolean().default(false)
|
|
9774
|
+
}),
|
|
9775
|
+
/**
|
|
9776
|
+
* Loadable BEFORE authentication. The normal widget registry listing
|
|
9777
|
+
* (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
|
|
9778
|
+
* (the login page) cannot discover a widget through it. A widget that
|
|
9779
|
+
* declares `preAuth: true` marks itself as safe to mount on a pre-auth
|
|
9780
|
+
* screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
|
|
9781
|
+
* login-method contribution channel (see `login-method.cap.ts`) rather
|
|
9782
|
+
* than the authenticated registry, and its bundle is served by the
|
|
9783
|
+
* public `/api/addon-widgets/:addonId/*` static route. Defaults false.
|
|
9784
|
+
*/
|
|
9785
|
+
preAuth: boolean().optional().default(false),
|
|
9786
|
+
/** Dashboard placement HINTS (operator can override per instance). */
|
|
9787
|
+
defaultSize: WidgetSizeEnum.default("md"),
|
|
9788
|
+
allowedSizes: array(WidgetSizeEnum).readonly().default([
|
|
9789
|
+
"sm",
|
|
9790
|
+
"md",
|
|
9791
|
+
"lg"
|
|
9792
|
+
]),
|
|
9793
|
+
defaultColumns: number().int().min(1).max(12).default(6),
|
|
9794
|
+
defaultRows: number().int().min(1).max(12).default(1)
|
|
9795
|
+
});
|
|
9796
|
+
method(_void(), array(WidgetMetadataSchema).readonly());
|
|
9797
|
+
/**
|
|
9798
|
+
* `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
|
|
9799
|
+
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9800
|
+
*
|
|
9801
|
+
* The provider iterates every `addon-widgets-source` (collection)
|
|
9802
|
+
* provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
|
|
9803
|
+
* `bundleUrl` strings pointing at
|
|
9804
|
+
* `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
|
|
9805
|
+
* `mtime` cache-buster lets the browser pick up addon rebuilds without
|
|
9806
|
+
* manual reload — same scheme used by `addon-pages`.
|
|
9807
|
+
*
|
|
9808
|
+
* The hub-local builtin `addon-widgets-aggregator` (see
|
|
9809
|
+
* `@camstack/system/builtins/addon-widgets-aggregator`) registers the
|
|
9810
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9811
|
+
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
9812
|
+
*/
|
|
9813
|
+
var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
|
|
9814
|
+
addonId: string(),
|
|
9815
|
+
bundleUrl: string()
|
|
9816
|
+
});
|
|
9817
|
+
method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
|
|
9818
|
+
/**
|
|
9819
|
+
* Alerts capability — collection-based internal alert system.
|
|
9820
|
+
*
|
|
9821
|
+
* Multiple providers can register. Each provider filters by EventBus category
|
|
9822
|
+
* and creates/updates alerts. The built-in Alert Center addon persists alerts
|
|
9823
|
+
* in the DB and serves them to the admin UI.
|
|
9824
|
+
*/
|
|
9825
|
+
var AlertSeveritySchema = _enum([
|
|
9826
|
+
"info",
|
|
9827
|
+
"success",
|
|
9828
|
+
"warning",
|
|
9829
|
+
"error"
|
|
9830
|
+
]);
|
|
9831
|
+
var AlertStatusSchema = _enum([
|
|
9832
|
+
"active",
|
|
9833
|
+
"in-progress",
|
|
9834
|
+
"completed",
|
|
9835
|
+
"failed",
|
|
9836
|
+
"dismissed"
|
|
8958
9837
|
]);
|
|
8959
9838
|
var AlertSourceSchema = object({
|
|
8960
9839
|
type: string(),
|
|
@@ -9630,6 +10509,69 @@ var StreamFormatSchema = _enum([
|
|
|
9630
10509
|
"mjpeg",
|
|
9631
10510
|
"rtsp"
|
|
9632
10511
|
]);
|
|
10512
|
+
/** A container `produceEventMedia` can emit. */
|
|
10513
|
+
var EventMediaKindSchema = _enum(["mp4", "gif"]);
|
|
10514
|
+
/**
|
|
10515
|
+
* One produced artifact, referenced by HANDLE.
|
|
10516
|
+
*
|
|
10517
|
+
* Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
|
|
10518
|
+
* method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
|
|
10519
|
+
* [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
|
|
10520
|
+
* on demand, compressed, by handle). `bytes` is here so a caller can decide
|
|
10521
|
+
* whether it wants the fetch at all.
|
|
10522
|
+
*/
|
|
10523
|
+
var EventMediaArtifactSchema = object({
|
|
10524
|
+
kind: EventMediaKindSchema,
|
|
10525
|
+
/** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
|
|
10526
|
+
handle: string(),
|
|
10527
|
+
/**
|
|
10528
|
+
* The node holding the bytes — the ROUTING key for `fetchEventMedia`.
|
|
10529
|
+
*
|
|
10530
|
+
* `stream-broker` is a singleton cap and an unpinned call never leaves the
|
|
10531
|
+
* hub, so a handle produced on an agent's broker would be redeemed against
|
|
10532
|
+
* the hub's store and come back `null`. Same contract, same field name and
|
|
10533
|
+
* the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
|
|
10534
|
+
* lives and the consumer pins to it.
|
|
10535
|
+
*/
|
|
10536
|
+
nodeId: string(),
|
|
10537
|
+
mime: string(),
|
|
10538
|
+
bytes: number().int(),
|
|
10539
|
+
width: number().int(),
|
|
10540
|
+
height: number().int()
|
|
10541
|
+
});
|
|
10542
|
+
/**
|
|
10543
|
+
* What a production actually covered — the answer to the only question an
|
|
10544
|
+
* operator asks about a notification clip.
|
|
10545
|
+
*
|
|
10546
|
+
* `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
|
|
10547
|
+
* so a caller can state "this clip starts 4.1 s before the event" instead of
|
|
10548
|
+
* inferring it from a duration. A production whose `fromTs` is later than the
|
|
10549
|
+
* event is a production with no pre-roll, and that is exactly the defect this
|
|
10550
|
+
* method exists to make visible rather than plausible.
|
|
10551
|
+
*/
|
|
10552
|
+
var EventMediaCoverageSchema = object({
|
|
10553
|
+
fromTs: number(),
|
|
10554
|
+
toTs: number(),
|
|
10555
|
+
/** Encoded packets in the muxed window. */
|
|
10556
|
+
packets: number().int()
|
|
10557
|
+
});
|
|
10558
|
+
/**
|
|
10559
|
+
* The result of ONE cut, in every container the caller asked for.
|
|
10560
|
+
*
|
|
10561
|
+
* Every artifact in `media` came out of the SAME window of the SAME rendition —
|
|
10562
|
+
* that is the whole reason this is one method rather than one call per format.
|
|
10563
|
+
* A consumer attaching a gif and a video can no longer show two different
|
|
10564
|
+
* moments, because it never chose two sources.
|
|
10565
|
+
*/
|
|
10566
|
+
var EventMediaProductionSchema = object({
|
|
10567
|
+
media: array(EventMediaArtifactSchema).readonly(),
|
|
10568
|
+
coverage: EventMediaCoverageSchema,
|
|
10569
|
+
/** The rendition actually cut from — what the default or the fallback chose. */
|
|
10570
|
+
profile: CamProfileSchema,
|
|
10571
|
+
/** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
|
|
10572
|
+
* source, a downscale, or a playback rate other than 1). */
|
|
10573
|
+
video: _enum(["copy", "encode"])
|
|
10574
|
+
});
|
|
9633
10575
|
var RtspRestreamEntrySchema = object({
|
|
9634
10576
|
brokerId: string(),
|
|
9635
10577
|
url: string(),
|
|
@@ -10029,6 +10971,56 @@ method(object({
|
|
|
10029
10971
|
}), {
|
|
10030
10972
|
kind: "mutation",
|
|
10031
10973
|
auth: "admin"
|
|
10974
|
+
}), method(object({
|
|
10975
|
+
deviceId: number(),
|
|
10976
|
+
/** Absent = the largest H.264 rendition at or below 1080p, which is
|
|
10977
|
+
* also the one that can be copied. Falls back to whatever the ring
|
|
10978
|
+
* actually retained, and the answer says which. */
|
|
10979
|
+
profile: CamProfileSchema.optional(),
|
|
10980
|
+
aroundMs: number(),
|
|
10981
|
+
preSeconds: number().min(0).max(20).default(4),
|
|
10982
|
+
postSeconds: number().min(0).max(20).default(6),
|
|
10983
|
+
kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
|
|
10984
|
+
/** GIF geometry. The video keeps the source's own. */
|
|
10985
|
+
gifMaxWidth: number().int().min(120).max(1280).default(640),
|
|
10986
|
+
/**
|
|
10987
|
+
* The gif's own PLAYBACK rate in frames per second — what the finished
|
|
10988
|
+
* gif runs at, not how many source frames feed it. The decimation that
|
|
10989
|
+
* feeds it samples `gifFps / gifSpeed` source frames per second, so at
|
|
10990
|
+
* the defaults a 12 fps gif is built out of 3 source frames a second.
|
|
10991
|
+
*/
|
|
10992
|
+
gifFps: number().int().min(1).max(15).default(12),
|
|
10993
|
+
/**
|
|
10994
|
+
* How fast the GIF plays against real time, independent of `speed`.
|
|
10995
|
+
*
|
|
10996
|
+
* 4× by default, by operator request: a notification gif is glanced at
|
|
10997
|
+
* on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
|
|
10998
|
+
* a separate knob from `speed` even though both now default to 4 —
|
|
10999
|
+
* a caller wanting a real-time video and a fast gif must not have to
|
|
11000
|
+
* choose.
|
|
11001
|
+
*/
|
|
11002
|
+
gifSpeed: number().min(1).max(8).default(4),
|
|
11003
|
+
/**
|
|
11004
|
+
* Playback rate of the VIDEO. Also 4× by default, by operator decision.
|
|
11005
|
+
*
|
|
11006
|
+
* `1` is real time and is the ONLY value that allows the copy branch —
|
|
11007
|
+
* anything else forces `libx264` over the window. That was priced
|
|
11008
|
+
* before it was chosen: a per-event burst measured at 0.23 s and 254 KB
|
|
11009
|
+
* on a real 615 720p cut, against 922 KB for the copy it replaces. A
|
|
11010
|
+
* re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
|
|
11011
|
+
* once the decode is forced the width stops being free.
|
|
11012
|
+
*/
|
|
11013
|
+
speed: number().min(1).max(8).default(4)
|
|
11014
|
+
}), EventMediaProductionSchema, {
|
|
11015
|
+
kind: "mutation",
|
|
11016
|
+
auth: "admin"
|
|
11017
|
+
}), method(object({ handle: string() }), object({
|
|
11018
|
+
base64: string(),
|
|
11019
|
+
mime: string(),
|
|
11020
|
+
bytes: number().int()
|
|
11021
|
+
}).nullable(), {
|
|
11022
|
+
kind: "mutation",
|
|
11023
|
+
auth: "admin"
|
|
10032
11024
|
}), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
|
|
10033
11025
|
probed: boolean(),
|
|
10034
11026
|
summary: string()
|
|
@@ -10221,25 +11213,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
10221
11213
|
}),
|
|
10222
11214
|
lastChangedAt: number()
|
|
10223
11215
|
});
|
|
10224
|
-
/**
|
|
10225
|
-
* core-blocks — user-authored TypeScript, stored in the kernel and executed in
|
|
10226
|
-
* its own process.
|
|
10227
|
-
*
|
|
10228
|
-
* Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
|
|
10229
|
-
*
|
|
10230
|
-
* The first use is **owning devices without being a device provider**: a block
|
|
10231
|
-
* declares devices under a system or custom integration and drives their state,
|
|
10232
|
-
* with the same `ctx` an addon gets. Automations come later; nothing here
|
|
10233
|
-
* models a trigger.
|
|
10234
|
-
*
|
|
10235
|
-
* **Stated plainly, because it does not change by being true:** a block has an
|
|
10236
|
-
* addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
|
|
10237
|
-
* with no review step. What makes that survivable is not a sandbox, it is
|
|
10238
|
-
* PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
|
|
10239
|
-
* so a block that throws or never returns is marked `failed` and visible
|
|
10240
|
-
* instead of taking the hub with it (D6). Every method here is admin-only, and
|
|
10241
|
-
* must stay so.
|
|
10242
|
-
*/
|
|
10243
11216
|
/** Where a block runs. The operator chooses — a block driving a device on an
|
|
10244
11217
|
* agent is the reason placement is not fixed to the hub. */
|
|
10245
11218
|
var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
|
|
@@ -10311,6 +11284,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
10311
11284
|
}), object({ block: CoreBlockSchema }), {
|
|
10312
11285
|
kind: "mutation",
|
|
10313
11286
|
auth: "admin"
|
|
11287
|
+
}), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
|
|
11288
|
+
kind: "mutation",
|
|
11289
|
+
auth: "admin"
|
|
10314
11290
|
}), method(object({ code: string() }), CoreBlockCompileResultSchema, {
|
|
10315
11291
|
kind: "mutation",
|
|
10316
11292
|
auth: "admin"
|
|
@@ -10845,826 +11821,231 @@ var AdoptionFilterSchema = object({
|
|
|
10845
11821
|
id: string(),
|
|
10846
11822
|
label: string(),
|
|
10847
11823
|
isDefault: boolean().optional()
|
|
10848
|
-
});
|
|
10849
|
-
/**
|
|
10850
|
-
* Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
|
|
10851
|
-
* de-HA-ified — `area` / `manufacturer` stay free-form strings so any
|
|
10852
|
-
* integration can populate them from its own metadata. Distinct from the
|
|
10853
|
-
* granularity `AdoptionFilter` above: this narrows the candidate set within a
|
|
10854
|
-
* chosen granularity, whereas the granularity `filter` selects WHAT a
|
|
10855
|
-
* candidate is (a device vs an entity).
|
|
10856
|
-
*/
|
|
10857
|
-
var CandidateQueryFilterSchema = object({
|
|
10858
|
-
/** Substring filter on name + manufacturer + model. */
|
|
10859
|
-
search: string().optional(),
|
|
10860
|
-
/** Area-name exact match. */
|
|
10861
|
-
area: string().optional(),
|
|
10862
|
-
/** Manufacturer exact match. */
|
|
10863
|
-
manufacturer: string().optional(),
|
|
10864
|
-
/** When true, only return candidates the operator already adopted. */
|
|
10865
|
-
adoptedOnly: boolean().optional(),
|
|
10866
|
-
/** When true, only return candidates the operator hasn't adopted yet. */
|
|
10867
|
-
unadoptedOnly: boolean().optional()
|
|
10868
|
-
});
|
|
10869
|
-
var ListCandidatesInputSchema = object({
|
|
10870
|
-
integrationId: string(),
|
|
10871
|
-
page: number().int().positive().default(1),
|
|
10872
|
-
pageSize: number().int().positive().max(2e4).default(50),
|
|
10873
|
-
/**
|
|
10874
|
-
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
10875
|
-
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
10876
|
-
* exactly the pre-existing behavior (fully back-compatible).
|
|
10877
|
-
*/
|
|
10878
|
-
filter: string().optional(),
|
|
10879
|
-
/** Optional candidate-list text/query narrowing within the granularity. */
|
|
10880
|
-
filterText: CandidateQueryFilterSchema.optional()
|
|
10881
|
-
});
|
|
10882
|
-
var ListCandidatesOutputSchema = object({
|
|
10883
|
-
candidates: array(DiscoveredChildDeviceSchema).readonly(),
|
|
10884
|
-
totalCount: number().int().nonnegative(),
|
|
10885
|
-
page: number().int().positive(),
|
|
10886
|
-
pageSize: number().int().positive()
|
|
10887
|
-
});
|
|
10888
|
-
var GetCandidateInputSchema = object({
|
|
10889
|
-
integrationId: string(),
|
|
10890
|
-
childNativeId: string()
|
|
10891
|
-
});
|
|
10892
|
-
var AdoptionStatusSchema = object({
|
|
10893
|
-
/** Last refresh timestamp (ms epoch) — null when never refreshed. */
|
|
10894
|
-
lastDiscoveryAt: number().int().nonnegative().nullable(),
|
|
10895
|
-
/** Count of candidates in the discovery cache. */
|
|
10896
|
-
candidateCount: number().int().nonnegative(),
|
|
10897
|
-
/** Count of candidates the operator has already adopted. */
|
|
10898
|
-
adoptedCount: number().int().nonnegative(),
|
|
10899
|
-
/** Last error message from a refresh attempt. */
|
|
10900
|
-
lastError: string().nullable()
|
|
10901
|
-
});
|
|
10902
|
-
var PerCandidateSchema = object({
|
|
10903
|
-
/** Override the default display name for this candidate's parent. */
|
|
10904
|
-
name: string().min(1).optional(),
|
|
10905
|
-
/** Pre-hide a subset of entity-children — created (state still flows)
|
|
10906
|
-
* but listed in the parent's `accessories.hiddenChildIds`. */
|
|
10907
|
-
hiddenChildIds: array(string()).optional()
|
|
10908
|
-
});
|
|
10909
|
-
var AdoptInputSchema = object({
|
|
10910
|
-
integrationId: string(),
|
|
10911
|
-
/**
|
|
10912
|
-
* Candidate native ids to adopt. Their MEANING is filter-relative: under the
|
|
10913
|
-
* default `'devices'` granularity these are device-native ids; under a
|
|
10914
|
-
* provider-declared granularity (e.g. HA `'entities'`) they are that
|
|
10915
|
-
* granularity's native ids (e.g. entity ids). The field name is kept stable
|
|
10916
|
-
* to avoid a breaking rename.
|
|
10917
|
-
*/
|
|
10918
|
-
childNativeIds: array(string()).min(1),
|
|
10919
|
-
/**
|
|
10920
|
-
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
10921
|
-
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
10922
|
-
* exactly the pre-existing behavior (fully back-compatible).
|
|
10923
|
-
*/
|
|
10924
|
-
filter: string().optional(),
|
|
10925
|
-
/** When true, import each adopted device's source-system location (e.g. HA
|
|
10926
|
-
* area) into CamStack — fuzzy-match an existing location or create it, then
|
|
10927
|
-
* assign. Omitted/false = no location work (back-compat). */
|
|
10928
|
-
importLocations: boolean().optional(),
|
|
10929
|
-
perCandidate: record(string(), PerCandidateSchema).optional()
|
|
10930
|
-
});
|
|
10931
|
-
var AdoptResultSchema = object({ adopted: array(object({
|
|
10932
|
-
childNativeId: string(),
|
|
10933
|
-
parentDeviceId: number().int().nonnegative(),
|
|
10934
|
-
accessoryDeviceIds: array(number().int().nonnegative()).readonly()
|
|
10935
|
-
})).readonly() });
|
|
10936
|
-
var ReleaseInputSchema = object({
|
|
10937
|
-
integrationId: string(),
|
|
10938
|
-
/** Parent CamStack device id (NOT an accessory child id). Removing
|
|
10939
|
-
* the parent cascades into every accessory. */
|
|
10940
|
-
camDeviceId: number().int().nonnegative()
|
|
10941
|
-
});
|
|
10942
|
-
var ResyncInputSchema = object({
|
|
10943
|
-
/** Parent CamStack device id of an adopted device. The provider resolves its
|
|
10944
|
-
* source (integration/broker + native id) and re-aligns the device's
|
|
10945
|
-
* structural spec (type/role/capabilities/units) with the live mapping,
|
|
10946
|
-
* rebuilding any child whose class changed while preserving operator edits. */
|
|
10947
|
-
camDeviceId: number().int().nonnegative(),
|
|
10948
|
-
/** "Resync from zero" (#19). When true, the kernel PURGES every accessory
|
|
10949
|
-
* child of `camDeviceId` BEFORE the provider re-derives the device, so the
|
|
10950
|
-
* children are rebuilt fresh from source — correct names, coords, and units —
|
|
10951
|
-
* instead of being preserved by the incremental reconcile. Use to recover from
|
|
10952
|
-
* legacy generic/placeholder names that the normal name-precedence keeps frozen
|
|
10953
|
-
* (the operator's explicit reset). Push-driven integrations (no-op resync)
|
|
10954
|
-
* rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
|
|
10955
|
-
* Operator edits on the PARENT (its name, layout, primary-child pick) survive —
|
|
10956
|
-
* only the children are torn down. Omitted/false ⇒ the normal incremental
|
|
10957
|
-
* re-sync that preserves children. */
|
|
10958
|
-
resetToSource: boolean().optional()
|
|
10959
|
-
});
|
|
10960
|
-
var ResyncResultSchema = object({
|
|
10961
|
-
/** True when the persisted spec actually changed (children may have been rebuilt). */
|
|
10962
|
-
changed: boolean(),
|
|
10963
|
-
/** Number of child devices rebuilt into a new class by this re-sync. */
|
|
10964
|
-
rebuiltChildren: number().int().nonnegative(),
|
|
10965
|
-
/** Number of accessory children torn down by a `resetToSource` purge before the
|
|
10966
|
-
* provider re-derived the device. 0/absent for a normal incremental re-sync. */
|
|
10967
|
-
removedChildren: number().int().nonnegative().optional()
|
|
10968
|
-
});
|
|
10969
|
-
method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
|
|
10970
|
-
kind: "mutation",
|
|
10971
|
-
auth: "admin"
|
|
10972
|
-
}), method(AdoptInputSchema, AdoptResultSchema, {
|
|
10973
|
-
kind: "mutation",
|
|
10974
|
-
auth: "admin"
|
|
10975
|
-
}), method(ReleaseInputSchema, _void(), {
|
|
10976
|
-
kind: "mutation",
|
|
10977
|
-
auth: "admin"
|
|
10978
|
-
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
10979
|
-
kind: "mutation",
|
|
10980
|
-
auth: "admin"
|
|
10981
|
-
});
|
|
10982
|
-
/**
|
|
10983
|
-
* `device-export` — collection cap for addons that export camstack
|
|
10984
|
-
* devices to external ecosystems (HomeAssistant via MQTT discovery,
|
|
10985
|
-
* HomeKit/HAP, Alexa Smart Home, …).
|
|
10986
|
-
*
|
|
10987
|
-
* No `ecosystem` enum — the addon id identifies the export target.
|
|
10988
|
-
* Each addon owns its mapping logic in its own settings UI. The cap
|
|
10989
|
-
* exposes only the COMMON surface: link state, supported device
|
|
10990
|
-
* kinds (so the UI can filter the device picker), and per-device
|
|
10991
|
-
* expose/unexpose.
|
|
10992
|
-
*/
|
|
10993
|
-
var LinkStateSchema = _enum([
|
|
10994
|
-
"unlinked",
|
|
10995
|
-
"linked",
|
|
10996
|
-
"error"
|
|
10997
|
-
]);
|
|
10998
|
-
/**
|
|
10999
|
-
* A single label/value row surfaced in the export panel's "Setup"
|
|
11000
|
-
* section. Rendered with a copy-to-clipboard button. `secret: true`
|
|
11001
|
-
* rows are masked by default with a reveal toggle (client ids,
|
|
11002
|
-
* secrets, …).
|
|
11003
|
-
*/
|
|
11004
|
-
var ExportSetupFieldSchema = object({
|
|
11005
|
-
label: string(),
|
|
11006
|
-
value: string(),
|
|
11007
|
-
/** Mask the value by default + render a reveal toggle (client id, secrets). */
|
|
11008
|
-
secret: boolean().optional()
|
|
11009
|
-
});
|
|
11010
|
-
/**
|
|
11011
|
-
* Generic, addon-agnostic pairing/account block. Any export addon can
|
|
11012
|
-
* surface a scannable QR, a set of copyable label/value rows, and a
|
|
11013
|
-
* free-form operator note — the `DeviceExportPanel` renders whatever
|
|
11014
|
-
* the provider supplies and skips the section entirely when `setup`
|
|
11015
|
-
* is unset.
|
|
11016
|
-
*/
|
|
11017
|
-
var ExportSetupSchema = object({
|
|
11018
|
-
/** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
|
|
11019
|
-
qr: string().optional(),
|
|
11020
|
-
/** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
|
|
11021
|
-
fields: array(ExportSetupFieldSchema).readonly().optional(),
|
|
11022
|
-
/** Free-form operator instructions rendered above the fields. */
|
|
11023
|
-
note: string().optional()
|
|
11024
|
-
});
|
|
11025
|
-
var DeviceExportStatusSchema = object({
|
|
11026
|
-
linkState: LinkStateSchema,
|
|
11027
|
-
exposedDeviceCount: number(),
|
|
11028
|
-
error: string().optional(),
|
|
11029
|
-
/**
|
|
11030
|
-
* Optional pairing/account info the panel renders in a generic
|
|
11031
|
-
* "Setup" section. Addon-agnostic — the addon id identifies the
|
|
11032
|
-
* export target, never an `ecosystem` key here.
|
|
11033
|
-
*/
|
|
11034
|
-
setup: ExportSetupSchema.optional()
|
|
11035
|
-
});
|
|
11036
|
-
var DeviceKindSchema = string();
|
|
11037
|
-
var ExposedDeviceSchema = object({
|
|
11038
|
-
deviceId: string(),
|
|
11039
|
-
exposedAs: string().optional(),
|
|
11040
|
-
capabilities: array(string()).optional()
|
|
11041
|
-
});
|
|
11042
|
-
var ExposeInputSchema = object({
|
|
11043
|
-
deviceId: string(),
|
|
11044
|
-
capabilities: array(string()).optional()
|
|
11045
|
-
});
|
|
11046
|
-
var UnexposeInputSchema = object({ deviceId: string() });
|
|
11047
|
-
var deviceExportCapability = {
|
|
11048
|
-
name: "device-export",
|
|
11049
|
-
scope: "system",
|
|
11050
|
-
mode: "collection",
|
|
11051
|
-
providerKind: "device-export",
|
|
11052
|
-
status: {
|
|
11053
|
-
schema: DeviceExportStatusSchema,
|
|
11054
|
-
kind: "poll"
|
|
11055
|
-
},
|
|
11056
|
-
/**
|
|
11057
|
-
* Each export provider contributes its own per-device "Export" panel
|
|
11058
|
-
* (enabled toggle, preferred stream, addon-specific knobs like the
|
|
11059
|
-
* HA-MQTT discovery prefix). The three framework methods
|
|
11060
|
-
* `getDeviceSettingsContribution` / `getDeviceLiveContribution` /
|
|
11061
|
-
* `applyDeviceSettingsPatch` are auto-injected on the provider
|
|
11062
|
-
* interface and routed to the device-details aggregator.
|
|
11063
|
-
*/
|
|
11064
|
-
exposesDeviceSettings: true,
|
|
11065
|
-
methods: {
|
|
11066
|
-
getStatus: method(_void(), DeviceExportStatusSchema),
|
|
11067
|
-
listSupportedDeviceKinds: method(_void(), array(DeviceKindSchema)),
|
|
11068
|
-
listExposedDevices: method(_void(), array(ExposedDeviceSchema)),
|
|
11069
|
-
exposeDevice: method(ExposeInputSchema, _void(), { kind: "mutation" }),
|
|
11070
|
-
unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
|
|
11071
|
-
}
|
|
11072
|
-
};
|
|
11073
|
-
/**
|
|
11074
|
-
* Resource-bound constants for the safe expression engine.
|
|
11075
|
-
*
|
|
11076
|
-
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
11077
|
-
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
11078
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
11079
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
11080
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
11081
|
-
*/
|
|
11082
|
-
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
11083
|
-
* rejected without allocation. */
|
|
11084
|
-
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
11085
|
-
/** A legal binding / identifier name. */
|
|
11086
|
-
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11087
|
-
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
11088
|
-
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
11089
|
-
var RESERVED_BINDING_NAMES = new Set([
|
|
11090
|
-
"now",
|
|
11091
|
-
"true",
|
|
11092
|
-
"false",
|
|
11093
|
-
"null"
|
|
11094
|
-
]);
|
|
11095
|
-
/**
|
|
11096
|
-
* Error types for the safe expression engine. Two distinct classes so callers
|
|
11097
|
-
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
11098
|
-
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
11099
|
-
*/
|
|
11100
|
-
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
11101
|
-
* the failure is anchored to a character (author-facing inline feedback). */
|
|
11102
|
-
var ExpressionParseError = class extends Error {
|
|
11103
|
-
position;
|
|
11104
|
-
constructor(message, position) {
|
|
11105
|
-
super(message);
|
|
11106
|
-
this.name = "ExpressionParseError";
|
|
11107
|
-
this.position = position;
|
|
11108
|
-
}
|
|
11109
|
-
};
|
|
11110
|
-
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
11111
|
-
* result, unknown builtin, step-budget exceeded). */
|
|
11112
|
-
var ExpressionEvalError = class extends Error {
|
|
11113
|
-
constructor(message) {
|
|
11114
|
-
super(message);
|
|
11115
|
-
this.name = "ExpressionEvalError";
|
|
11116
|
-
}
|
|
11117
|
-
};
|
|
11118
|
-
/**
|
|
11119
|
-
* Frozen, null-prototype builtin function table for the expression engine
|
|
11120
|
-
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
11121
|
-
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
11122
|
-
* own-property check against it.
|
|
11123
|
-
*
|
|
11124
|
-
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
11125
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
11126
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
11127
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
11128
|
-
* callable — they are simply "unknown function" at parse time.
|
|
11129
|
-
*
|
|
11130
|
-
* Every numeric argument is validated as a finite number and every numeric
|
|
11131
|
-
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
11132
|
-
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
11133
|
-
* closed rather than emitting a garbage value.
|
|
11134
|
-
*/
|
|
11135
|
-
function asFiniteNumber(value, name, index) {
|
|
11136
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
11137
|
-
return value;
|
|
11138
|
-
}
|
|
11139
|
-
function asString$1(value, name, index) {
|
|
11140
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
11141
|
-
return value;
|
|
11142
|
-
}
|
|
11143
|
-
function finiteResult(value, name) {
|
|
11144
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
11145
|
-
return value;
|
|
11146
|
-
}
|
|
11147
|
-
function allFiniteNumbers(args, name) {
|
|
11148
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
11149
|
-
}
|
|
11150
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
11151
|
-
var table = {
|
|
11152
|
-
min: {
|
|
11153
|
-
minArgs: 1,
|
|
11154
|
-
maxArgs: INF,
|
|
11155
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
11156
|
-
},
|
|
11157
|
-
max: {
|
|
11158
|
-
minArgs: 1,
|
|
11159
|
-
maxArgs: INF,
|
|
11160
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
11161
|
-
},
|
|
11162
|
-
abs: {
|
|
11163
|
-
minArgs: 1,
|
|
11164
|
-
maxArgs: 1,
|
|
11165
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
11166
|
-
},
|
|
11167
|
-
floor: {
|
|
11168
|
-
minArgs: 1,
|
|
11169
|
-
maxArgs: 1,
|
|
11170
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
11171
|
-
},
|
|
11172
|
-
ceil: {
|
|
11173
|
-
minArgs: 1,
|
|
11174
|
-
maxArgs: 1,
|
|
11175
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
11176
|
-
},
|
|
11177
|
-
sqrt: {
|
|
11178
|
-
minArgs: 1,
|
|
11179
|
-
maxArgs: 1,
|
|
11180
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
11181
|
-
},
|
|
11182
|
-
round: {
|
|
11183
|
-
minArgs: 1,
|
|
11184
|
-
maxArgs: 2,
|
|
11185
|
-
apply: (args) => {
|
|
11186
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
11187
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
11188
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
11189
|
-
const factor = 10 ** digits;
|
|
11190
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
11191
|
-
}
|
|
11192
|
-
},
|
|
11193
|
-
pow: {
|
|
11194
|
-
minArgs: 2,
|
|
11195
|
-
maxArgs: 2,
|
|
11196
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
11197
|
-
},
|
|
11198
|
-
clamp: {
|
|
11199
|
-
minArgs: 3,
|
|
11200
|
-
maxArgs: 3,
|
|
11201
|
-
apply: (args) => {
|
|
11202
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
11203
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
11204
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
11205
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
11206
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
11207
|
-
}
|
|
11208
|
-
},
|
|
11209
|
-
avg: {
|
|
11210
|
-
minArgs: 1,
|
|
11211
|
-
maxArgs: INF,
|
|
11212
|
-
apply: (args) => {
|
|
11213
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
11214
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
11215
|
-
}
|
|
11216
|
-
},
|
|
11217
|
-
sum: {
|
|
11218
|
-
minArgs: 1,
|
|
11219
|
-
maxArgs: INF,
|
|
11220
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
11221
|
-
},
|
|
11222
|
-
coalesce: {
|
|
11223
|
-
minArgs: 1,
|
|
11224
|
-
maxArgs: INF,
|
|
11225
|
-
apply: (args) => {
|
|
11226
|
-
for (const a of args) if (a !== null) return a;
|
|
11227
|
-
return null;
|
|
11228
|
-
}
|
|
11229
|
-
},
|
|
11230
|
-
age: {
|
|
11231
|
-
minArgs: 2,
|
|
11232
|
-
maxArgs: 2,
|
|
11233
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
11234
|
-
},
|
|
11235
|
-
convert: {
|
|
11236
|
-
minArgs: 3,
|
|
11237
|
-
maxArgs: 3,
|
|
11238
|
-
apply: (args, hooks) => {
|
|
11239
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
11240
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
11241
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
11242
|
-
if (hooks.convert) {
|
|
11243
|
-
const out = hooks.convert(x, from, to);
|
|
11244
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
11245
|
-
return finiteResult(out, "convert");
|
|
11246
|
-
}
|
|
11247
|
-
if (from === to) return x;
|
|
11248
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
11249
|
-
}
|
|
11250
|
-
}
|
|
11251
|
-
};
|
|
11252
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
11253
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
11254
|
-
* callees at parse time (immediate author feedback). */
|
|
11255
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
11256
|
-
/**
|
|
11257
|
-
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
11258
|
-
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
11259
|
-
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
11260
|
-
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
11261
|
-
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
11262
|
-
* is a parse error with a source position, so member access / assignment /
|
|
11263
|
-
* template literals are lexically impossible.
|
|
11264
|
-
*/
|
|
11265
|
-
var KEYWORDS = new Set([
|
|
11266
|
-
"true",
|
|
11267
|
-
"false",
|
|
11268
|
-
"null"
|
|
11269
|
-
]);
|
|
11270
|
-
function isDigit(ch) {
|
|
11271
|
-
return ch >= "0" && ch <= "9";
|
|
11272
|
-
}
|
|
11273
|
-
function isIdentStart(ch) {
|
|
11274
|
-
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
11275
|
-
}
|
|
11276
|
-
function isIdentPart(ch) {
|
|
11277
|
-
return isIdentStart(ch) || isDigit(ch);
|
|
11278
|
-
}
|
|
11279
|
-
function isWhitespace(ch) {
|
|
11280
|
-
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
11281
|
-
}
|
|
11282
|
-
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
11283
|
-
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
11284
|
-
* string. */
|
|
11285
|
-
function tokenize(source) {
|
|
11286
|
-
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
11287
|
-
const tokens = [];
|
|
11288
|
-
let i = 0;
|
|
11289
|
-
const n = source.length;
|
|
11290
|
-
while (i < n) {
|
|
11291
|
-
const ch = source[i];
|
|
11292
|
-
if (isWhitespace(ch)) {
|
|
11293
|
-
i += 1;
|
|
11294
|
-
continue;
|
|
11295
|
-
}
|
|
11296
|
-
if (isDigit(ch)) {
|
|
11297
|
-
const start = i;
|
|
11298
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
11299
|
-
if (i < n && source[i] === ".") {
|
|
11300
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
11301
|
-
i += 1;
|
|
11302
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
11303
|
-
}
|
|
11304
|
-
const text = source.slice(start, i);
|
|
11305
|
-
const value = Number(text);
|
|
11306
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
11307
|
-
tokens.push({
|
|
11308
|
-
type: "number",
|
|
11309
|
-
value,
|
|
11310
|
-
pos: start
|
|
11311
|
-
});
|
|
11312
|
-
continue;
|
|
11313
|
-
}
|
|
11314
|
-
if (ch === "'" || ch === "\"") {
|
|
11315
|
-
const quote = ch;
|
|
11316
|
-
const start = i;
|
|
11317
|
-
i += 1;
|
|
11318
|
-
let out = "";
|
|
11319
|
-
let closed = false;
|
|
11320
|
-
while (i < n) {
|
|
11321
|
-
const c = source[i];
|
|
11322
|
-
if (c === "\\") {
|
|
11323
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
11324
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
11325
|
-
out += next;
|
|
11326
|
-
i += 2;
|
|
11327
|
-
continue;
|
|
11328
|
-
}
|
|
11329
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
11330
|
-
}
|
|
11331
|
-
if (c === quote) {
|
|
11332
|
-
closed = true;
|
|
11333
|
-
i += 1;
|
|
11334
|
-
break;
|
|
11335
|
-
}
|
|
11336
|
-
out += c;
|
|
11337
|
-
i += 1;
|
|
11338
|
-
}
|
|
11339
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
11340
|
-
tokens.push({
|
|
11341
|
-
type: "string",
|
|
11342
|
-
value: out,
|
|
11343
|
-
pos: start
|
|
11344
|
-
});
|
|
11345
|
-
continue;
|
|
11346
|
-
}
|
|
11347
|
-
if (isIdentStart(ch)) {
|
|
11348
|
-
const start = i;
|
|
11349
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
11350
|
-
const text = source.slice(start, i);
|
|
11351
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
11352
|
-
type: "keyword",
|
|
11353
|
-
keyword: keywordOf(text),
|
|
11354
|
-
pos: start
|
|
11355
|
-
});
|
|
11356
|
-
else tokens.push({
|
|
11357
|
-
type: "identifier",
|
|
11358
|
-
name: text,
|
|
11359
|
-
pos: start
|
|
11360
|
-
});
|
|
11361
|
-
continue;
|
|
11362
|
-
}
|
|
11363
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
11364
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
11365
|
-
tokens.push({
|
|
11366
|
-
type: "punct",
|
|
11367
|
-
punct: two,
|
|
11368
|
-
pos: i
|
|
11369
|
-
});
|
|
11370
|
-
i += 2;
|
|
11371
|
-
continue;
|
|
11372
|
-
}
|
|
11373
|
-
if (isSinglePunct(ch)) {
|
|
11374
|
-
tokens.push({
|
|
11375
|
-
type: "punct",
|
|
11376
|
-
punct: ch,
|
|
11377
|
-
pos: i
|
|
11378
|
-
});
|
|
11379
|
-
i += 1;
|
|
11380
|
-
continue;
|
|
11381
|
-
}
|
|
11382
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
11383
|
-
}
|
|
11384
|
-
tokens.push({
|
|
11385
|
-
type: "eof",
|
|
11386
|
-
pos: n
|
|
11387
|
-
});
|
|
11388
|
-
return tokens;
|
|
11389
|
-
}
|
|
11390
|
-
function keywordOf(text) {
|
|
11391
|
-
if (text === "true") return "true";
|
|
11392
|
-
if (text === "false") return "false";
|
|
11393
|
-
return "null";
|
|
11394
|
-
}
|
|
11395
|
-
function isSinglePunct(ch) {
|
|
11396
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
11397
|
-
}
|
|
11398
|
-
/**
|
|
11399
|
-
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
11400
|
-
*
|
|
11401
|
-
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
11402
|
-
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
11403
|
-
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
11404
|
-
* string validated against the builtin table at parse time, so an unknown
|
|
11405
|
-
* function is rejected immediately (author feedback) and a persisted expression
|
|
11406
|
-
* that references a since-removed builtin degrades at read.
|
|
11407
|
-
*
|
|
11408
|
-
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
11409
|
-
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
11410
|
-
*/
|
|
11411
|
-
/** Binary/logical operator precedence (higher binds tighter). */
|
|
11412
|
-
var BINARY_PRECEDENCE = {
|
|
11413
|
-
"||": 1,
|
|
11414
|
-
"&&": 2,
|
|
11415
|
-
"==": 3,
|
|
11416
|
-
"!=": 3,
|
|
11417
|
-
"<": 4,
|
|
11418
|
-
"<=": 4,
|
|
11419
|
-
">": 4,
|
|
11420
|
-
">=": 4,
|
|
11421
|
-
"+": 5,
|
|
11422
|
-
"-": 5,
|
|
11423
|
-
"*": 6,
|
|
11424
|
-
"/": 6,
|
|
11425
|
-
"%": 6
|
|
11426
|
-
};
|
|
11427
|
-
function isLogicalOp(op) {
|
|
11428
|
-
return op === "&&" || op === "||";
|
|
11429
|
-
}
|
|
11430
|
-
function isBinaryOp(op) {
|
|
11431
|
-
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
11432
|
-
}
|
|
11433
|
-
var Parser = class {
|
|
11434
|
-
tokens;
|
|
11435
|
-
pos = 0;
|
|
11436
|
-
nodeCount = 0;
|
|
11437
|
-
identifiers = /* @__PURE__ */ new Set();
|
|
11438
|
-
callees = /* @__PURE__ */ new Set();
|
|
11439
|
-
constructor(tokens) {
|
|
11440
|
-
this.tokens = tokens;
|
|
11441
|
-
}
|
|
11442
|
-
parse() {
|
|
11443
|
-
const ast = this.parseTernary();
|
|
11444
|
-
const tok = this.peek();
|
|
11445
|
-
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
11446
|
-
return {
|
|
11447
|
-
ast,
|
|
11448
|
-
identifiers: this.identifiers,
|
|
11449
|
-
callees: this.callees,
|
|
11450
|
-
nodeCount: this.nodeCount
|
|
11451
|
-
};
|
|
11452
|
-
}
|
|
11453
|
-
peek() {
|
|
11454
|
-
return this.tokens[this.pos];
|
|
11455
|
-
}
|
|
11456
|
-
next() {
|
|
11457
|
-
return this.tokens[this.pos++];
|
|
11458
|
-
}
|
|
11459
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
11460
|
-
expectPunct(punct) {
|
|
11461
|
-
const tok = this.peek();
|
|
11462
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
11463
|
-
this.pos += 1;
|
|
11464
|
-
}
|
|
11465
|
-
matchPunct(punct) {
|
|
11466
|
-
const tok = this.peek();
|
|
11467
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
11468
|
-
this.pos += 1;
|
|
11469
|
-
return true;
|
|
11470
|
-
}
|
|
11471
|
-
return false;
|
|
11472
|
-
}
|
|
11473
|
-
countNode() {
|
|
11474
|
-
this.nodeCount += 1;
|
|
11475
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
11476
|
-
}
|
|
11477
|
-
parseTernary() {
|
|
11478
|
-
const test = this.parseBinary(1);
|
|
11479
|
-
if (this.matchPunct("?")) {
|
|
11480
|
-
const consequent = this.parseTernary();
|
|
11481
|
-
this.expectPunct(":");
|
|
11482
|
-
const alternate = this.parseTernary();
|
|
11483
|
-
this.countNode();
|
|
11484
|
-
return {
|
|
11485
|
-
kind: "conditional",
|
|
11486
|
-
test,
|
|
11487
|
-
consequent,
|
|
11488
|
-
alternate
|
|
11489
|
-
};
|
|
11490
|
-
}
|
|
11491
|
-
return test;
|
|
11492
|
-
}
|
|
11493
|
-
parseBinary(minPrec) {
|
|
11494
|
-
let left = this.parseUnary();
|
|
11495
|
-
for (;;) {
|
|
11496
|
-
const tok = this.peek();
|
|
11497
|
-
if (tok.type !== "punct") break;
|
|
11498
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
11499
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
11500
|
-
const op = tok.punct;
|
|
11501
|
-
this.pos += 1;
|
|
11502
|
-
const right = this.parseBinary(prec + 1);
|
|
11503
|
-
this.countNode();
|
|
11504
|
-
if (isLogicalOp(op)) left = {
|
|
11505
|
-
kind: "logical",
|
|
11506
|
-
op,
|
|
11507
|
-
left,
|
|
11508
|
-
right
|
|
11509
|
-
};
|
|
11510
|
-
else if (isBinaryOp(op)) left = {
|
|
11511
|
-
kind: "binary",
|
|
11512
|
-
op,
|
|
11513
|
-
left,
|
|
11514
|
-
right
|
|
11515
|
-
};
|
|
11516
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
11517
|
-
}
|
|
11518
|
-
return left;
|
|
11519
|
-
}
|
|
11520
|
-
parseUnary() {
|
|
11521
|
-
const tok = this.peek();
|
|
11522
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
11523
|
-
const op = tok.punct;
|
|
11524
|
-
this.pos += 1;
|
|
11525
|
-
const operand = this.parseUnary();
|
|
11526
|
-
this.countNode();
|
|
11527
|
-
return {
|
|
11528
|
-
kind: "unary",
|
|
11529
|
-
op,
|
|
11530
|
-
operand
|
|
11531
|
-
};
|
|
11532
|
-
}
|
|
11533
|
-
return this.parsePrimary();
|
|
11534
|
-
}
|
|
11535
|
-
parsePrimary() {
|
|
11536
|
-
const tok = this.next();
|
|
11537
|
-
switch (tok.type) {
|
|
11538
|
-
case "number":
|
|
11539
|
-
this.countNode();
|
|
11540
|
-
return {
|
|
11541
|
-
kind: "literal",
|
|
11542
|
-
value: tok.value
|
|
11543
|
-
};
|
|
11544
|
-
case "string":
|
|
11545
|
-
this.countNode();
|
|
11546
|
-
return {
|
|
11547
|
-
kind: "literal",
|
|
11548
|
-
value: tok.value
|
|
11549
|
-
};
|
|
11550
|
-
case "keyword":
|
|
11551
|
-
this.countNode();
|
|
11552
|
-
return {
|
|
11553
|
-
kind: "literal",
|
|
11554
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
11555
|
-
};
|
|
11556
|
-
case "identifier": {
|
|
11557
|
-
const nextTok = this.peek();
|
|
11558
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
11559
|
-
this.identifiers.add(tok.name);
|
|
11560
|
-
this.countNode();
|
|
11561
|
-
return {
|
|
11562
|
-
kind: "identifier",
|
|
11563
|
-
name: tok.name
|
|
11564
|
-
};
|
|
11565
|
-
}
|
|
11566
|
-
case "punct":
|
|
11567
|
-
if (tok.punct === "(") {
|
|
11568
|
-
const inner = this.parseTernary();
|
|
11569
|
-
this.expectPunct(")");
|
|
11570
|
-
return inner;
|
|
11571
|
-
}
|
|
11572
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
11573
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
11574
|
-
}
|
|
11575
|
-
}
|
|
11576
|
-
parseCall(callee, pos) {
|
|
11577
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
11578
|
-
this.expectPunct("(");
|
|
11579
|
-
const args = [];
|
|
11580
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
11581
|
-
args.push(this.parseTernary());
|
|
11582
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
11583
|
-
if (this.matchPunct(",")) continue;
|
|
11584
|
-
this.expectPunct(")");
|
|
11585
|
-
break;
|
|
11586
|
-
}
|
|
11587
|
-
this.callees.add(callee);
|
|
11588
|
-
this.countNode();
|
|
11589
|
-
return {
|
|
11590
|
-
kind: "call",
|
|
11591
|
-
callee,
|
|
11592
|
-
args
|
|
11593
|
-
};
|
|
11594
|
-
}
|
|
11595
|
-
};
|
|
11596
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
11597
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
11598
|
-
function parseExpression(source) {
|
|
11599
|
-
return new Parser(tokenize(source)).parse();
|
|
11600
|
-
}
|
|
11824
|
+
});
|
|
11601
11825
|
/**
|
|
11602
|
-
*
|
|
11603
|
-
*
|
|
11604
|
-
*
|
|
11605
|
-
*
|
|
11826
|
+
* Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
|
|
11827
|
+
* de-HA-ified — `area` / `manufacturer` stay free-form strings so any
|
|
11828
|
+
* integration can populate them from its own metadata. Distinct from the
|
|
11829
|
+
* granularity `AdoptionFilter` above: this narrows the candidate set within a
|
|
11830
|
+
* chosen granularity, whereas the granularity `filter` selects WHAT a
|
|
11831
|
+
* candidate is (a device vs an entity).
|
|
11832
|
+
*/
|
|
11833
|
+
var CandidateQueryFilterSchema = object({
|
|
11834
|
+
/** Substring filter on name + manufacturer + model. */
|
|
11835
|
+
search: string().optional(),
|
|
11836
|
+
/** Area-name exact match. */
|
|
11837
|
+
area: string().optional(),
|
|
11838
|
+
/** Manufacturer exact match. */
|
|
11839
|
+
manufacturer: string().optional(),
|
|
11840
|
+
/** When true, only return candidates the operator already adopted. */
|
|
11841
|
+
adoptedOnly: boolean().optional(),
|
|
11842
|
+
/** When true, only return candidates the operator hasn't adopted yet. */
|
|
11843
|
+
unadoptedOnly: boolean().optional()
|
|
11844
|
+
});
|
|
11845
|
+
var ListCandidatesInputSchema = object({
|
|
11846
|
+
integrationId: string(),
|
|
11847
|
+
page: number().int().positive().default(1),
|
|
11848
|
+
pageSize: number().int().positive().max(2e4).default(50),
|
|
11849
|
+
/**
|
|
11850
|
+
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
11851
|
+
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
11852
|
+
* exactly the pre-existing behavior (fully back-compatible).
|
|
11853
|
+
*/
|
|
11854
|
+
filter: string().optional(),
|
|
11855
|
+
/** Optional candidate-list text/query narrowing within the granularity. */
|
|
11856
|
+
filterText: CandidateQueryFilterSchema.optional()
|
|
11857
|
+
});
|
|
11858
|
+
var ListCandidatesOutputSchema = object({
|
|
11859
|
+
candidates: array(DiscoveredChildDeviceSchema).readonly(),
|
|
11860
|
+
totalCount: number().int().nonnegative(),
|
|
11861
|
+
page: number().int().positive(),
|
|
11862
|
+
pageSize: number().int().positive()
|
|
11863
|
+
});
|
|
11864
|
+
var GetCandidateInputSchema = object({
|
|
11865
|
+
integrationId: string(),
|
|
11866
|
+
childNativeId: string()
|
|
11867
|
+
});
|
|
11868
|
+
var AdoptionStatusSchema = object({
|
|
11869
|
+
/** Last refresh timestamp (ms epoch) — null when never refreshed. */
|
|
11870
|
+
lastDiscoveryAt: number().int().nonnegative().nullable(),
|
|
11871
|
+
/** Count of candidates in the discovery cache. */
|
|
11872
|
+
candidateCount: number().int().nonnegative(),
|
|
11873
|
+
/** Count of candidates the operator has already adopted. */
|
|
11874
|
+
adoptedCount: number().int().nonnegative(),
|
|
11875
|
+
/** Last error message from a refresh attempt. */
|
|
11876
|
+
lastError: string().nullable()
|
|
11877
|
+
});
|
|
11878
|
+
var PerCandidateSchema = object({
|
|
11879
|
+
/** Override the default display name for this candidate's parent. */
|
|
11880
|
+
name: string().min(1).optional(),
|
|
11881
|
+
/** Pre-hide a subset of entity-children — created (state still flows)
|
|
11882
|
+
* but listed in the parent's `accessories.hiddenChildIds`. */
|
|
11883
|
+
hiddenChildIds: array(string()).optional()
|
|
11884
|
+
});
|
|
11885
|
+
var AdoptInputSchema = object({
|
|
11886
|
+
integrationId: string(),
|
|
11887
|
+
/**
|
|
11888
|
+
* Candidate native ids to adopt. Their MEANING is filter-relative: under the
|
|
11889
|
+
* default `'devices'` granularity these are device-native ids; under a
|
|
11890
|
+
* provider-declared granularity (e.g. HA `'entities'`) they are that
|
|
11891
|
+
* granularity's native ids (e.g. entity ids). The field name is kept stable
|
|
11892
|
+
* to avoid a breaking rename.
|
|
11893
|
+
*/
|
|
11894
|
+
childNativeIds: array(string()).min(1),
|
|
11895
|
+
/**
|
|
11896
|
+
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
11897
|
+
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
11898
|
+
* exactly the pre-existing behavior (fully back-compatible).
|
|
11899
|
+
*/
|
|
11900
|
+
filter: string().optional(),
|
|
11901
|
+
/** When true, import each adopted device's source-system location (e.g. HA
|
|
11902
|
+
* area) into CamStack — fuzzy-match an existing location or create it, then
|
|
11903
|
+
* assign. Omitted/false = no location work (back-compat). */
|
|
11904
|
+
importLocations: boolean().optional(),
|
|
11905
|
+
perCandidate: record(string(), PerCandidateSchema).optional()
|
|
11906
|
+
});
|
|
11907
|
+
var AdoptResultSchema = object({ adopted: array(object({
|
|
11908
|
+
childNativeId: string(),
|
|
11909
|
+
parentDeviceId: number().int().nonnegative(),
|
|
11910
|
+
accessoryDeviceIds: array(number().int().nonnegative()).readonly()
|
|
11911
|
+
})).readonly() });
|
|
11912
|
+
var ReleaseInputSchema = object({
|
|
11913
|
+
integrationId: string(),
|
|
11914
|
+
/** Parent CamStack device id (NOT an accessory child id). Removing
|
|
11915
|
+
* the parent cascades into every accessory. */
|
|
11916
|
+
camDeviceId: number().int().nonnegative()
|
|
11917
|
+
});
|
|
11918
|
+
var ResyncInputSchema = object({
|
|
11919
|
+
/** Parent CamStack device id of an adopted device. The provider resolves its
|
|
11920
|
+
* source (integration/broker + native id) and re-aligns the device's
|
|
11921
|
+
* structural spec (type/role/capabilities/units) with the live mapping,
|
|
11922
|
+
* rebuilding any child whose class changed while preserving operator edits. */
|
|
11923
|
+
camDeviceId: number().int().nonnegative(),
|
|
11924
|
+
/** "Resync from zero" (#19). When true, the kernel PURGES every accessory
|
|
11925
|
+
* child of `camDeviceId` BEFORE the provider re-derives the device, so the
|
|
11926
|
+
* children are rebuilt fresh from source — correct names, coords, and units —
|
|
11927
|
+
* instead of being preserved by the incremental reconcile. Use to recover from
|
|
11928
|
+
* legacy generic/placeholder names that the normal name-precedence keeps frozen
|
|
11929
|
+
* (the operator's explicit reset). Push-driven integrations (no-op resync)
|
|
11930
|
+
* rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
|
|
11931
|
+
* Operator edits on the PARENT (its name, layout, primary-child pick) survive —
|
|
11932
|
+
* only the children are torn down. Omitted/false ⇒ the normal incremental
|
|
11933
|
+
* re-sync that preserves children. */
|
|
11934
|
+
resetToSource: boolean().optional()
|
|
11935
|
+
});
|
|
11936
|
+
var ResyncResultSchema = object({
|
|
11937
|
+
/** True when the persisted spec actually changed (children may have been rebuilt). */
|
|
11938
|
+
changed: boolean(),
|
|
11939
|
+
/** Number of child devices rebuilt into a new class by this re-sync. */
|
|
11940
|
+
rebuiltChildren: number().int().nonnegative(),
|
|
11941
|
+
/** Number of accessory children torn down by a `resetToSource` purge before the
|
|
11942
|
+
* provider re-derived the device. 0/absent for a normal incremental re-sync. */
|
|
11943
|
+
removedChildren: number().int().nonnegative().optional()
|
|
11944
|
+
});
|
|
11945
|
+
method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
|
|
11946
|
+
kind: "mutation",
|
|
11947
|
+
auth: "admin"
|
|
11948
|
+
}), method(AdoptInputSchema, AdoptResultSchema, {
|
|
11949
|
+
kind: "mutation",
|
|
11950
|
+
auth: "admin"
|
|
11951
|
+
}), method(ReleaseInputSchema, _void(), {
|
|
11952
|
+
kind: "mutation",
|
|
11953
|
+
auth: "admin"
|
|
11954
|
+
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
11955
|
+
kind: "mutation",
|
|
11956
|
+
auth: "admin"
|
|
11957
|
+
});
|
|
11958
|
+
/**
|
|
11959
|
+
* `device-export` — collection cap for addons that export camstack
|
|
11960
|
+
* devices to external ecosystems (HomeAssistant via MQTT discovery,
|
|
11961
|
+
* HomeKit/HAP, Alexa Smart Home, …).
|
|
11606
11962
|
*
|
|
11607
|
-
*
|
|
11608
|
-
*
|
|
11609
|
-
*
|
|
11963
|
+
* No `ecosystem` enum — the addon id identifies the export target.
|
|
11964
|
+
* Each addon owns its mapping logic in its own settings UI. The cap
|
|
11965
|
+
* exposes only the COMMON surface: link state, supported device
|
|
11966
|
+
* kinds (so the UI can filter the device picker), and per-device
|
|
11967
|
+
* expose/unexpose.
|
|
11610
11968
|
*/
|
|
11611
|
-
var
|
|
11612
|
-
|
|
11613
|
-
|
|
11614
|
-
|
|
11615
|
-
|
|
11616
|
-
cache.set(source, hit);
|
|
11617
|
-
return hit;
|
|
11618
|
-
}
|
|
11619
|
-
let result;
|
|
11620
|
-
try {
|
|
11621
|
-
result = {
|
|
11622
|
-
ok: true,
|
|
11623
|
-
parsed: parseExpression(source)
|
|
11624
|
-
};
|
|
11625
|
-
} catch (err) {
|
|
11626
|
-
result = {
|
|
11627
|
-
ok: false,
|
|
11628
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
11629
|
-
};
|
|
11630
|
-
}
|
|
11631
|
-
cache.set(source, result);
|
|
11632
|
-
if (cache.size > 256) {
|
|
11633
|
-
const oldest = cache.keys().next().value;
|
|
11634
|
-
if (oldest !== void 0) cache.delete(oldest);
|
|
11635
|
-
}
|
|
11636
|
-
return result;
|
|
11637
|
-
}
|
|
11638
|
-
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
11639
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
11640
|
-
function compileExpressionSafe(source) {
|
|
11641
|
-
return getCached(source);
|
|
11642
|
-
}
|
|
11643
|
-
Object.freeze({});
|
|
11969
|
+
var LinkStateSchema = _enum([
|
|
11970
|
+
"unlinked",
|
|
11971
|
+
"linked",
|
|
11972
|
+
"error"
|
|
11973
|
+
]);
|
|
11644
11974
|
/**
|
|
11645
|
-
*
|
|
11646
|
-
*
|
|
11647
|
-
*
|
|
11648
|
-
*
|
|
11649
|
-
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
11975
|
+
* A single label/value row surfaced in the export panel's "Setup"
|
|
11976
|
+
* section. Rendered with a copy-to-clipboard button. `secret: true`
|
|
11977
|
+
* rows are masked by default with a reveal toggle (client ids,
|
|
11978
|
+
* secrets, …).
|
|
11650
11979
|
*/
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
11980
|
+
var ExportSetupFieldSchema = object({
|
|
11981
|
+
label: string(),
|
|
11982
|
+
value: string(),
|
|
11983
|
+
/** Mask the value by default + render a reveal toggle (client id, secrets). */
|
|
11984
|
+
secret: boolean().optional()
|
|
11985
|
+
});
|
|
11986
|
+
/**
|
|
11987
|
+
* Generic, addon-agnostic pairing/account block. Any export addon can
|
|
11988
|
+
* surface a scannable QR, a set of copyable label/value rows, and a
|
|
11989
|
+
* free-form operator note — the `DeviceExportPanel` renders whatever
|
|
11990
|
+
* the provider supplies and skips the section entirely when `setup`
|
|
11991
|
+
* is unset.
|
|
11992
|
+
*/
|
|
11993
|
+
var ExportSetupSchema = object({
|
|
11994
|
+
/** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
|
|
11995
|
+
qr: string().optional(),
|
|
11996
|
+
/** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
|
|
11997
|
+
fields: array(ExportSetupFieldSchema).readonly().optional(),
|
|
11998
|
+
/** Free-form operator instructions rendered above the fields. */
|
|
11999
|
+
note: string().optional()
|
|
12000
|
+
});
|
|
12001
|
+
var DeviceExportStatusSchema = object({
|
|
12002
|
+
linkState: LinkStateSchema,
|
|
12003
|
+
exposedDeviceCount: number(),
|
|
12004
|
+
error: string().optional(),
|
|
12005
|
+
/**
|
|
12006
|
+
* Optional pairing/account info the panel renders in a generic
|
|
12007
|
+
* "Setup" section. Addon-agnostic — the addon id identifies the
|
|
12008
|
+
* export target, never an `ecosystem` key here.
|
|
12009
|
+
*/
|
|
12010
|
+
setup: ExportSetupSchema.optional()
|
|
12011
|
+
});
|
|
12012
|
+
var DeviceKindSchema = string();
|
|
12013
|
+
var ExposedDeviceSchema = object({
|
|
12014
|
+
deviceId: string(),
|
|
12015
|
+
exposedAs: string().optional(),
|
|
12016
|
+
capabilities: array(string()).optional()
|
|
12017
|
+
});
|
|
12018
|
+
var ExposeInputSchema = object({
|
|
12019
|
+
deviceId: string(),
|
|
12020
|
+
capabilities: array(string()).optional()
|
|
12021
|
+
});
|
|
12022
|
+
var UnexposeInputSchema = object({ deviceId: string() });
|
|
12023
|
+
var deviceExportCapability = {
|
|
12024
|
+
name: "device-export",
|
|
12025
|
+
scope: "system",
|
|
12026
|
+
mode: "collection",
|
|
12027
|
+
providerKind: "device-export",
|
|
12028
|
+
status: {
|
|
12029
|
+
schema: DeviceExportStatusSchema,
|
|
12030
|
+
kind: "poll"
|
|
12031
|
+
},
|
|
12032
|
+
/**
|
|
12033
|
+
* Each export provider contributes its own per-device "Export" panel
|
|
12034
|
+
* (enabled toggle, preferred stream, addon-specific knobs like the
|
|
12035
|
+
* HA-MQTT discovery prefix). The three framework methods
|
|
12036
|
+
* `getDeviceSettingsContribution` / `getDeviceLiveContribution` /
|
|
12037
|
+
* `applyDeviceSettingsPatch` are auto-injected on the provider
|
|
12038
|
+
* interface and routed to the device-details aggregator.
|
|
12039
|
+
*/
|
|
12040
|
+
exposesDeviceSettings: true,
|
|
12041
|
+
methods: {
|
|
12042
|
+
getStatus: method(_void(), DeviceExportStatusSchema),
|
|
12043
|
+
listSupportedDeviceKinds: method(_void(), array(DeviceKindSchema)),
|
|
12044
|
+
listExposedDevices: method(_void(), array(ExposedDeviceSchema)),
|
|
12045
|
+
exposeDevice: method(ExposeInputSchema, _void(), { kind: "mutation" }),
|
|
12046
|
+
unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
|
|
11665
12047
|
}
|
|
11666
|
-
|
|
11667
|
-
}
|
|
12048
|
+
};
|
|
11668
12049
|
var ProviderStatusSchema = object({
|
|
11669
12050
|
connected: boolean(),
|
|
11670
12051
|
deviceCount: number(),
|
|
@@ -11789,92 +12170,6 @@ var ChildLayoutEntrySchema = object({
|
|
|
11789
12170
|
order: number().optional(),
|
|
11790
12171
|
collapsed: boolean().optional()
|
|
11791
12172
|
});
|
|
11792
|
-
/** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
|
|
11793
|
-
* `device-management.ts`. Source is a union: a FIELD source copies a sibling
|
|
11794
|
-
* accessory's status field (`kind` optional/absent for wire compat); a
|
|
11795
|
-
* LITERAL source carries a per-device constant (no sibling is read); a
|
|
11796
|
-
* GLOBAL source (P2e) copies ANY device's status field, addressed by the
|
|
11797
|
-
* source device's full re-sync-stable `stableId`. */
|
|
11798
|
-
var DeviceLinkFieldSourceSchema = object({
|
|
11799
|
-
kind: literal("field").optional(),
|
|
11800
|
-
sourceKey: string(),
|
|
11801
|
-
cap: string(),
|
|
11802
|
-
fieldPath: string()
|
|
11803
|
-
});
|
|
11804
|
-
var DeviceLinkLiteralSourceSchema = object({
|
|
11805
|
-
kind: literal("literal"),
|
|
11806
|
-
value: union([
|
|
11807
|
-
string(),
|
|
11808
|
-
number(),
|
|
11809
|
-
boolean(),
|
|
11810
|
-
_null()
|
|
11811
|
-
])
|
|
11812
|
-
});
|
|
11813
|
-
var DeviceLinkGlobalSourceSchema = object({
|
|
11814
|
-
kind: literal("global"),
|
|
11815
|
-
sourceStableId: string(),
|
|
11816
|
-
cap: string(),
|
|
11817
|
-
fieldPath: string()
|
|
11818
|
-
});
|
|
11819
|
-
/** Expression source (Stage X): compute the target field from N named bindings
|
|
11820
|
-
* via the safe expression engine. Bindings are field | literal | global — never
|
|
11821
|
-
* another expression (no nesting). The `superRefine` runs the SAME author-time
|
|
11822
|
-
* validation as `validateExpressionSource` (compiles the expr, checks binding
|
|
11823
|
-
* names + identifier coverage) so every wire boundary that parses a DeviceLink
|
|
11824
|
-
* (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
|
|
11825
|
-
* Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
|
|
11826
|
-
var DeviceLinkExpressionSourceSchema = object({
|
|
11827
|
-
kind: literal("expression"),
|
|
11828
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
11829
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
|
|
11830
|
-
DeviceLinkFieldSourceSchema,
|
|
11831
|
-
DeviceLinkLiteralSourceSchema,
|
|
11832
|
-
DeviceLinkGlobalSourceSchema
|
|
11833
|
-
]))
|
|
11834
|
-
}).superRefine((src, ctx) => {
|
|
11835
|
-
const err = validateExpressionSource(src);
|
|
11836
|
-
if (err !== null) ctx.addIssue({
|
|
11837
|
-
code: "custom",
|
|
11838
|
-
message: err,
|
|
11839
|
-
path: ["expr"]
|
|
11840
|
-
});
|
|
11841
|
-
});
|
|
11842
|
-
var DeviceLinkSchema = object({
|
|
11843
|
-
id: string(),
|
|
11844
|
-
source: union([
|
|
11845
|
-
DeviceLinkFieldSourceSchema,
|
|
11846
|
-
DeviceLinkLiteralSourceSchema,
|
|
11847
|
-
DeviceLinkGlobalSourceSchema,
|
|
11848
|
-
DeviceLinkExpressionSourceSchema
|
|
11849
|
-
]),
|
|
11850
|
-
target: object({
|
|
11851
|
-
cap: string(),
|
|
11852
|
-
fieldPath: string(),
|
|
11853
|
-
itemKey: string().optional()
|
|
11854
|
-
}),
|
|
11855
|
-
transform: discriminatedUnion("kind", [
|
|
11856
|
-
object({ kind: literal("identity") }),
|
|
11857
|
-
object({
|
|
11858
|
-
kind: literal("enum-map"),
|
|
11859
|
-
mapping: record(string(), union([
|
|
11860
|
-
string(),
|
|
11861
|
-
number(),
|
|
11862
|
-
boolean()
|
|
11863
|
-
])),
|
|
11864
|
-
fallback: union([
|
|
11865
|
-
string(),
|
|
11866
|
-
number(),
|
|
11867
|
-
boolean()
|
|
11868
|
-
]).optional()
|
|
11869
|
-
}),
|
|
11870
|
-
object({
|
|
11871
|
-
kind: literal("linear"),
|
|
11872
|
-
scale: number(),
|
|
11873
|
-
offset: number(),
|
|
11874
|
-
clamp: tuple([number(), number()]).readonly().optional()
|
|
11875
|
-
})
|
|
11876
|
-
]).optional()
|
|
11877
|
-
});
|
|
11878
12173
|
/** Cap-wire shape of a per-cap display refinement — mirrors
|
|
11879
12174
|
* `DeviceCapDisplayOverride` in `device-management.ts`. */
|
|
11880
12175
|
var DeviceCapDisplayOverrideSchema = object({
|
|
@@ -11954,8 +12249,6 @@ var DeviceInfoSchema = object({
|
|
|
11954
12249
|
* named accordion sections (with optional intra-section order). See
|
|
11955
12250
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
11956
12251
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
11957
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
11958
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
11959
12252
|
/** Operator-authored per-device display override. See `DeviceMeta.display`. */
|
|
11960
12253
|
display: DeviceDisplayOverrideSchema.optional()
|
|
11961
12254
|
});
|
|
@@ -11964,7 +12257,7 @@ var ConfigEntrySchema = object({
|
|
|
11964
12257
|
value: unknown(),
|
|
11965
12258
|
description: string().optional()
|
|
11966
12259
|
});
|
|
11967
|
-
var
|
|
12260
|
+
var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
|
|
11968
12261
|
/** One resolved linked device — the compact projection consumers need. */
|
|
11969
12262
|
var LinkedDeviceSchema = object({
|
|
11970
12263
|
deviceId: number(),
|
|
@@ -12027,8 +12320,6 @@ var DeviceMetaSchema = object({
|
|
|
12027
12320
|
* accordion sections (with optional intra-section order). See
|
|
12028
12321
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
12029
12322
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
12030
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
12031
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
12032
12323
|
/** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
|
|
12033
12324
|
* Optional: only present for accessory children that carry a known role. */
|
|
12034
12325
|
role: string().nullable().optional(),
|
|
@@ -12121,12 +12412,6 @@ method(object({
|
|
|
12121
12412
|
}), _void(), {
|
|
12122
12413
|
kind: "mutation",
|
|
12123
12414
|
auth: "admin"
|
|
12124
|
-
}), method(object({
|
|
12125
|
-
deviceId: number(),
|
|
12126
|
-
deviceLinks: array(DeviceLinkSchema).readonly()
|
|
12127
|
-
}), _void(), {
|
|
12128
|
-
kind: "mutation",
|
|
12129
|
-
auth: "admin"
|
|
12130
12415
|
}), method(object({
|
|
12131
12416
|
deviceId: number(),
|
|
12132
12417
|
display: DeviceDisplayOverrideSchema.nullable()
|
|
@@ -12208,7 +12493,7 @@ method(object({
|
|
|
12208
12493
|
* shipping 293 rows to find 12. */
|
|
12209
12494
|
isCamera: boolean().optional()
|
|
12210
12495
|
}), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
|
|
12211
|
-
mode:
|
|
12496
|
+
mode: LinkedDevicesModeSchema,
|
|
12212
12497
|
devices: array(LinkedDeviceSchema)
|
|
12213
12498
|
})), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
|
|
12214
12499
|
deviceId: number(),
|
|
@@ -12241,11 +12526,7 @@ method(object({
|
|
|
12241
12526
|
deviceId: number(),
|
|
12242
12527
|
entries: array(object({
|
|
12243
12528
|
capName: string(),
|
|
12244
|
-
kind: _enum([
|
|
12245
|
-
"native",
|
|
12246
|
-
"wrapped",
|
|
12247
|
-
"linked"
|
|
12248
|
-
]),
|
|
12529
|
+
kind: _enum(["native", "wrapped"]),
|
|
12249
12530
|
providerAddonId: string(),
|
|
12250
12531
|
providerNodeId: string(),
|
|
12251
12532
|
nativeAddonId: string()
|
|
@@ -12254,11 +12535,7 @@ method(object({
|
|
|
12254
12535
|
deviceId: number(),
|
|
12255
12536
|
entries: array(object({
|
|
12256
12537
|
capName: string(),
|
|
12257
|
-
kind: _enum([
|
|
12258
|
-
"native",
|
|
12259
|
-
"wrapped",
|
|
12260
|
-
"linked"
|
|
12261
|
-
]),
|
|
12538
|
+
kind: _enum(["native", "wrapped"]),
|
|
12262
12539
|
providerAddonId: string(),
|
|
12263
12540
|
providerNodeId: string(),
|
|
12264
12541
|
nativeAddonId: string()
|
|
@@ -12359,6 +12636,15 @@ method(object({
|
|
|
12359
12636
|
}), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
|
|
12360
12637
|
kind: "mutation",
|
|
12361
12638
|
auth: "admin"
|
|
12639
|
+
}), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
|
|
12640
|
+
kind: "mutation",
|
|
12641
|
+
auth: "admin"
|
|
12642
|
+
}), method(object({
|
|
12643
|
+
addonId: string(),
|
|
12644
|
+
integrationId: string().optional()
|
|
12645
|
+
}), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
12646
|
+
kind: "mutation",
|
|
12647
|
+
auth: "admin"
|
|
12362
12648
|
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
12363
12649
|
kind: "mutation",
|
|
12364
12650
|
auth: "admin"
|
|
@@ -13061,7 +13347,7 @@ var MotionAnalysisResultSchema = object({
|
|
|
13061
13347
|
frameHeight: number(),
|
|
13062
13348
|
analysisMs: number()
|
|
13063
13349
|
});
|
|
13064
|
-
method(object({
|
|
13350
|
+
DeviceType.Camera, method(object({
|
|
13065
13351
|
deviceId: number(),
|
|
13066
13352
|
frame: FrameInputSchema.optional(),
|
|
13067
13353
|
frameHandle: FrameHandleSchema.optional()
|
|
@@ -13081,7 +13367,7 @@ method(object({
|
|
|
13081
13367
|
* Why: pub/sub routing over the system event-bus loses fidelity
|
|
13082
13368
|
* (callback shape, QoS guarantees, will/retain semantics) and adds
|
|
13083
13369
|
* refcount bookkeeping that addons would rather own themselves. The
|
|
13084
|
-
* canonical consumer
|
|
13370
|
+
* canonical consumer needs raw `mqtt.js`
|
|
13085
13371
|
* features anyway — give it the connection config, get out of the way.
|
|
13086
13372
|
*
|
|
13087
13373
|
* Consumer flow:
|
|
@@ -14246,8 +14532,35 @@ var NcDeliverySchema = _enum([
|
|
|
14246
14532
|
"immediate",
|
|
14247
14533
|
"track-end",
|
|
14248
14534
|
"device-event",
|
|
14249
|
-
"package-event"
|
|
14535
|
+
"package-event",
|
|
14536
|
+
"system-event"
|
|
14250
14537
|
]);
|
|
14538
|
+
/**
|
|
14539
|
+
* Stable Notification Center vocabulary over infrastructure/liveness events.
|
|
14540
|
+
* Bus categories are normalized into these intent-level kinds so rules do not
|
|
14541
|
+
* depend on a provider's raw event name or payload shape.
|
|
14542
|
+
*/
|
|
14543
|
+
var NcSystemEventKindSchema = _enum([
|
|
14544
|
+
"camera-online",
|
|
14545
|
+
"camera-offline",
|
|
14546
|
+
"stream-online",
|
|
14547
|
+
"stream-offline",
|
|
14548
|
+
"node-online",
|
|
14549
|
+
"node-offline",
|
|
14550
|
+
"addon-update-available",
|
|
14551
|
+
"server-update-available"
|
|
14552
|
+
]);
|
|
14553
|
+
/**
|
|
14554
|
+
* One coherent system-event condition. `kinds` is the required opt-in safety
|
|
14555
|
+
* gate; the remaining lists are optional narrowing filters relevant to the
|
|
14556
|
+
* selected kinds.
|
|
14557
|
+
*/
|
|
14558
|
+
var NcSystemEventConditionSchema = object({
|
|
14559
|
+
kinds: array(NcSystemEventKindSchema).min(1),
|
|
14560
|
+
deviceIds: array(number().int()).min(1).optional(),
|
|
14561
|
+
nodeIds: array(string().min(1)).min(1).optional(),
|
|
14562
|
+
packageNames: array(string().min(1)).min(1).optional()
|
|
14563
|
+
});
|
|
14251
14564
|
/** Weekly schedule — OR of windows; absence on the rule = always active. */
|
|
14252
14565
|
var NcScheduleSchema = object({
|
|
14253
14566
|
windows: array(object({
|
|
@@ -14552,6 +14865,8 @@ var NcConditionsSchema = object({
|
|
|
14552
14865
|
"picked-up",
|
|
14553
14866
|
"both"
|
|
14554
14867
|
]).optional(),
|
|
14868
|
+
/** Infrastructure/liveness/update event matcher (`system-event` delivery). */
|
|
14869
|
+
systemEvent: NcSystemEventConditionSchema.optional(),
|
|
14555
14870
|
/**
|
|
14556
14871
|
* PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
|
|
14557
14872
|
* (MaskShape vocabulary). A record passes when its bbox overlaps ANY
|
|
@@ -14771,7 +15086,8 @@ var NcTestResultSchema = object({
|
|
|
14771
15086
|
"object-event",
|
|
14772
15087
|
"track",
|
|
14773
15088
|
"device-event",
|
|
14774
|
-
"package-event"
|
|
15089
|
+
"package-event",
|
|
15090
|
+
"system-event"
|
|
14775
15091
|
]),
|
|
14776
15092
|
deviceId: number(),
|
|
14777
15093
|
timestamp: number(),
|
|
@@ -14793,7 +15109,8 @@ var NcConditionDescriptorSchema = object({
|
|
|
14793
15109
|
"schedule",
|
|
14794
15110
|
"device",
|
|
14795
15111
|
"package",
|
|
14796
|
-
"occupancy"
|
|
15112
|
+
"occupancy",
|
|
15113
|
+
"system"
|
|
14797
15114
|
]),
|
|
14798
15115
|
label: string(),
|
|
14799
15116
|
/** Editor widget the UI renders — never hardcode per-condition forms. */
|
|
@@ -14811,7 +15128,8 @@ var NcConditionDescriptorSchema = object({
|
|
|
14811
15128
|
"crossingSelect",
|
|
14812
15129
|
"polygonDraw",
|
|
14813
15130
|
"occupancy",
|
|
14814
|
-
"deviceState"
|
|
15131
|
+
"deviceState",
|
|
15132
|
+
"systemEvent"
|
|
14815
15133
|
]),
|
|
14816
15134
|
operator: _enum([
|
|
14817
15135
|
"in",
|
|
@@ -14870,7 +15188,8 @@ var NcHistoryRecordKindSchema = _enum([
|
|
|
14870
15188
|
"object-event",
|
|
14871
15189
|
"track-end",
|
|
14872
15190
|
"device-event",
|
|
14873
|
-
"package-event"
|
|
15191
|
+
"package-event",
|
|
15192
|
+
"system-event"
|
|
14874
15193
|
]);
|
|
14875
15194
|
/** Subject summary frozen on the row at fire time (survives rule/record edits). */
|
|
14876
15195
|
var NcHistorySubjectSchema = object({
|
|
@@ -14878,7 +15197,14 @@ var NcHistorySubjectSchema = object({
|
|
|
14878
15197
|
label: string().optional(),
|
|
14879
15198
|
confidence: number().optional(),
|
|
14880
15199
|
zones: array(string()),
|
|
14881
|
-
timestamp: number()
|
|
15200
|
+
timestamp: number(),
|
|
15201
|
+
systemEvent: object({
|
|
15202
|
+
kind: NcSystemEventKindSchema,
|
|
15203
|
+
subject: string(),
|
|
15204
|
+
title: string(),
|
|
15205
|
+
body: string(),
|
|
15206
|
+
data: record(string(), unknown())
|
|
15207
|
+
}).optional()
|
|
14882
15208
|
});
|
|
14883
15209
|
/**
|
|
14884
15210
|
* One delivery-history row. This is a read-only VIEW over the durable
|
|
@@ -15238,6 +15564,76 @@ object({
|
|
|
15238
15564
|
* Each provider returns a static descriptor; the core enumerates them
|
|
15239
15565
|
* to validate the `integration=` query param and resolve the consent
|
|
15240
15566
|
* label + the scopes baked into the issued token.
|
|
15567
|
+
*
|
|
15568
|
+
* ## Declaring one
|
|
15569
|
+
*
|
|
15570
|
+
* An OAuth client is integration-specific knowledge — who the client is, what
|
|
15571
|
+
* it may ask for, where it may be sent — so it is declared by the ADDON that
|
|
15572
|
+
* owns the integration, never by the kernel and never as a branch inside
|
|
15573
|
+
* `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
|
|
15574
|
+
* steps, no others:
|
|
15575
|
+
*
|
|
15576
|
+
* 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
|
|
15577
|
+
* manifest entry. This is also what tells the hub, at addon-LOAD time, that
|
|
15578
|
+
* a descriptor is owed — see "the boot window" below.
|
|
15579
|
+
* 2. Return a provider from `onInitialize()`:
|
|
15580
|
+
*
|
|
15581
|
+
* ```ts
|
|
15582
|
+
* const provider: IOauthIntegrationProvider = {
|
|
15583
|
+
* getDescriptor: async () => ({
|
|
15584
|
+
* integrationId: 'my-thing', // the `integration=` query param
|
|
15585
|
+
* displayName: 'My Thing',
|
|
15586
|
+
* requestedScopes: [ … ], // see below
|
|
15587
|
+
* allowedRedirectPrefixes: ['https://callback.example/'],
|
|
15588
|
+
* }),
|
|
15589
|
+
* }
|
|
15590
|
+
* return [{ capability: oauthIntegrationCapability, provider }]
|
|
15591
|
+
* ```
|
|
15592
|
+
*
|
|
15593
|
+
* The descriptor must be **static** — it is read on the authorize path, so
|
|
15594
|
+
* never put an await on network or disk behind it, and never register it
|
|
15595
|
+
* behind one either (a provider is registered only once `onInitialize`
|
|
15596
|
+
* RETURNS, so anything awaited before the return delays linking).
|
|
15597
|
+
* 3. Nothing else. There is no allow-list to join, no id to register with the
|
|
15598
|
+
* core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
|
|
15599
|
+
* `/api/oauth2/integrations` are built from this collection alone.
|
|
15600
|
+
*
|
|
15601
|
+
* **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
|
|
15602
|
+
* NEEDS to function.** Not a blast radius, not a conservative
|
|
15603
|
+
* under-declaration, not a description of some other path the addon happens to
|
|
15604
|
+
* have. Derive it from what the client actually calls **with this token** —
|
|
15605
|
+
* every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
|
|
15606
|
+
* addon HTTP route it posts to — and write the call that justifies each entry
|
|
15607
|
+
* next to it. Two integrations once used this field to mean two different
|
|
15608
|
+
* things; the operator ruled there is one meaning, and any third integration
|
|
15609
|
+
* inherits it (2026-08-09).
|
|
15610
|
+
*
|
|
15611
|
+
* This is not documentation, it is the ENFORCEMENT INPUT. Since
|
|
15612
|
+
* [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
|
|
15613
|
+
* checks an integration token's grant before letting it reach an
|
|
15614
|
+
* `access: 'authenticated'` route, so an **under-declaration is an integration
|
|
15615
|
+
* that stops working** — a missing `addon:` entry means `403 Token scope
|
|
15616
|
+
* mismatch` on every control the client tries to actuate. Widen the descriptor
|
|
15617
|
+
* honestly rather than weakening a check to make a route pass.
|
|
15618
|
+
*
|
|
15619
|
+
* Prefer a narrow `capability:` scope to a `category:` one unless the client
|
|
15620
|
+
* genuinely needs a whole family; a category scope grants every future member
|
|
15621
|
+
* of that category too. `category:system [create]` has been rejected once and
|
|
15622
|
+
* should stay rejected: it hands `addons.installPackage` to an integration.
|
|
15623
|
+
*
|
|
15624
|
+
* Calls the ADDON itself makes over `ctx.api` run as the addon and are not
|
|
15625
|
+
* scope-checked, so they are not what this field describes — but reaching the
|
|
15626
|
+
* addon's route in the first place IS, and that is the entry to declare.
|
|
15627
|
+
*
|
|
15628
|
+
* **The boot window.** An addon registers its provider after its runner forks
|
|
15629
|
+
* and initialises, so between hub start and that moment this collection is
|
|
15630
|
+
* incomplete and an `integrationId` can be legitimately absent. The core does
|
|
15631
|
+
* not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
|
|
15632
|
+
* it compares the manifest declarers against the registered providers and
|
|
15633
|
+
* answers `503 temporarily_unavailable` (with `Retry-After` and the pending
|
|
15634
|
+
* addon ids) instead of `400 unknown integration`, and reports
|
|
15635
|
+
* `complete: false` on `GET /api/oauth2/integrations`. A client should retry
|
|
15636
|
+
* while the list is incomplete rather than conclude the hub cannot do OAuth.
|
|
15241
15637
|
*/
|
|
15242
15638
|
var OauthIntegrationDescriptorSchema = object({
|
|
15243
15639
|
/** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
|
|
@@ -15250,13 +15646,48 @@ var OauthIntegrationDescriptorSchema = object({
|
|
|
15250
15646
|
* redirect_uri that does not start with one of these. Required —
|
|
15251
15647
|
* an empty list means the integration can never complete linking. */
|
|
15252
15648
|
allowedRedirectPrefixes: array(string()).min(1),
|
|
15649
|
+
/** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
|
|
15650
|
+
* RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
|
|
15651
|
+
* `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
|
|
15652
|
+
* whose address the hub cannot know in advance (a Home Assistant at
|
|
15653
|
+
* `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
|
|
15654
|
+
* exactly; a public host never satisfies this branch, so it is not a
|
|
15655
|
+
* wildcard prefix by another name. */
|
|
15656
|
+
allowedPrivateHostPaths: array(string()).optional(),
|
|
15657
|
+
/** When true this is a PUBLIC client (source is published, no secret can be
|
|
15658
|
+
* protected) and PKCE is mandatory: `/authorize` refuses without an S256
|
|
15659
|
+
* `code_challenge`, `/token` refuses without the matching `code_verifier`. */
|
|
15660
|
+
requiresPkce: boolean().optional(),
|
|
15253
15661
|
/** Optional public origin (no trailing slash) that this integration's
|
|
15254
15662
|
* issued codes/tokens should carry as the `hubUrl` claim — typically the
|
|
15255
15663
|
* operator-selected external-access endpoint resolved by the addon. When
|
|
15256
15664
|
* present, /api/oauth2/authorize bakes THIS into the code instead of the
|
|
15257
15665
|
* hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
|
|
15258
15666
|
* the hub's env) drives the claim that its cloud Lambda routes back on. */
|
|
15259
|
-
hubUrl: string().optional()
|
|
15667
|
+
hubUrl: string().optional(),
|
|
15668
|
+
/**
|
|
15669
|
+
* How long a REFRESH token issued for this integration lives — seconds, or
|
|
15670
|
+
* `'never'` for a token minted with no `exp` claim at all. Omit to keep the
|
|
15671
|
+
* 30-day default, which is what every link used before this field existed.
|
|
15672
|
+
*
|
|
15673
|
+
* Declared here for the same reason `requestedScopes` is: the integration
|
|
15674
|
+
* knows what it needs. Amazon's account linking and a Home Assistant config
|
|
15675
|
+
* entry are both meant to survive indefinitely, and re-linking is a manual
|
|
15676
|
+
* user action, so a 30-day expiry silently unlinks a working integration.
|
|
15677
|
+
*
|
|
15678
|
+
* **The security posture, stated so it is owned deliberately.** A refresh
|
|
15679
|
+
* token that never expires is permanent access if it leaks. What bounds it is
|
|
15680
|
+
* revocation, not time: `oauthRefresh` re-reads the session on every use and
|
|
15681
|
+
* returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
|
|
15682
|
+
* The one gap is the ACCESS token — it is a plain signed JWT that nothing
|
|
15683
|
+
* re-checks against the session on the `/trpc` and `/addon/*` paths, so
|
|
15684
|
+
* revoking a link takes effect there only after its remaining hour. That hour
|
|
15685
|
+
* is why the access TTL is not configurable.
|
|
15686
|
+
*
|
|
15687
|
+
* The value is baked into the authorization code at `/authorize` and travels
|
|
15688
|
+
* on the tokens, so editing this field changes FUTURE links only.
|
|
15689
|
+
*/
|
|
15690
|
+
refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
|
|
15260
15691
|
});
|
|
15261
15692
|
method(_void(), OauthIntegrationDescriptorSchema);
|
|
15262
15693
|
/**
|
|
@@ -15400,7 +15831,7 @@ var TrackEnvelopeSchema = object({
|
|
|
15400
15831
|
* `snapshots[]` references — megabytes across a page of tracks. `slim`
|
|
15401
15832
|
* keeps every scalar the list surfaces actually render (ids, class(es),
|
|
15402
15833
|
* label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
|
|
15403
|
-
* zonesVisited, bestEventId, envelope) and returns `positions` /
|
|
15834
|
+
* zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
|
|
15404
15835
|
* `snapshots` as EMPTY arrays — detail views re-fetch the full row via
|
|
15405
15836
|
* `getTrack`. Mirrors the event-store `projection` convention
|
|
15406
15837
|
* (`getObjectEvents` et al.).
|
|
@@ -15609,8 +16040,26 @@ var TrackSchema = object({
|
|
|
15609
16040
|
/** Periodic snapshots at snapshotIntervalMs cadence (subject to
|
|
15610
16041
|
* saveThumbnails policy). */
|
|
15611
16042
|
snapshots: array(TrackSnapshotSchema).readonly(),
|
|
15612
|
-
/** Deduplicated zones the track has entered at least once. */
|
|
16043
|
+
/** Deduplicated zones the track has entered at least once. Zone IDS. */
|
|
15613
16044
|
zonesVisited: array(string()).readonly(),
|
|
16045
|
+
/**
|
|
16046
|
+
* Human NAMES for {@link zonesVisited}, resolved at READ time against the
|
|
16047
|
+
* `zones` capability.
|
|
16048
|
+
*
|
|
16049
|
+
* `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
|
|
16050
|
+
* and no card can render — so every free-text search surface was structurally
|
|
16051
|
+
* unable to answer "show me the tracks in Uscio", and did not fail loudly, it
|
|
16052
|
+
* just returned nothing. Resolving here rather than in each client keeps ONE
|
|
16053
|
+
* derivation and costs the clients no extra call (the `zones` cap is
|
|
16054
|
+
* per-device, so a client-side resolve would be a per-camera fan-out on a
|
|
16055
|
+
* surface built to avoid exactly that).
|
|
16056
|
+
*
|
|
16057
|
+
* Resolved, never invented: a zone deleted since the track was written has no
|
|
16058
|
+
* name and is DROPPED, so this array can be shorter than `zonesVisited` — the
|
|
16059
|
+
* two are not positionally aligned. Absent when the track visited no zone, or
|
|
16060
|
+
* when the zone catalogue could not be read.
|
|
16061
|
+
*/
|
|
16062
|
+
zoneNames: array(string()).readonly().optional(),
|
|
15614
16063
|
/** Deduplicated set of detector classes observed for this track over its
|
|
15615
16064
|
* life (a track may be reclassified, e.g. person→vehicle). Absent on
|
|
15616
16065
|
* legacy rows written before class accumulation shipped. */
|
|
@@ -15637,6 +16086,24 @@ var TrackSchema = object({
|
|
|
15637
16086
|
* Populated from the persisted envelope columns on historical reads;
|
|
15638
16087
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
15639
16088
|
envelope: TrackEnvelopeSchema.optional(),
|
|
16089
|
+
/**
|
|
16090
|
+
* A face DETECTOR found a face on this track — nothing more. It says the
|
|
16091
|
+
* detail plane produced a `face` detail; it does NOT say the face was
|
|
16092
|
+
* embedded, matched, above `minFacePx`, or that the recognizer was even
|
|
16093
|
+
* enabled. Set once and never cleared.
|
|
16094
|
+
*
|
|
16095
|
+
* **This exists so "face present but not recognised" is expressible.** A
|
|
16096
|
+
* recognised identity lands in `subLabel` (attributed to the face chain via
|
|
16097
|
+
* `subLabelMeta.stepId`), so before this field a track with an unmatched face
|
|
16098
|
+
* and a track with no face at all were byte-identical on the wire and no
|
|
16099
|
+
* surface could tell them apart. The read is `hasFace === true && subLabel
|
|
16100
|
+
* === undefined`.
|
|
16101
|
+
*
|
|
16102
|
+
* **Absent ≠ false.** Every row written before the column existed omits it,
|
|
16103
|
+
* and so does every server that predates the field — a consumer must test
|
|
16104
|
+
* `=== true` and render nothing otherwise, never infer "no face".
|
|
16105
|
+
*/
|
|
16106
|
+
hasFace: boolean().optional(),
|
|
15640
16107
|
...TrackFlagFields,
|
|
15641
16108
|
...TrackRetrainFields
|
|
15642
16109
|
});
|
|
@@ -17662,6 +18129,23 @@ var CameraRecordingStatusSchema = object({
|
|
|
17662
18129
|
active: boolean(),
|
|
17663
18130
|
storageBytes: number()
|
|
17664
18131
|
});
|
|
18132
|
+
/** One stage of the fan-out that could NOT be read, and how long it cost. */
|
|
18133
|
+
var CameraStatusDegradationSchema = object({
|
|
18134
|
+
stage: _enum([
|
|
18135
|
+
"source",
|
|
18136
|
+
"broker",
|
|
18137
|
+
"detection",
|
|
18138
|
+
"recording",
|
|
18139
|
+
"switches"
|
|
18140
|
+
]),
|
|
18141
|
+
reason: _enum([
|
|
18142
|
+
"timeout",
|
|
18143
|
+
"error",
|
|
18144
|
+
"partial"
|
|
18145
|
+
]),
|
|
18146
|
+
/** Wall-clock ms spent on the stage before it was abandoned. */
|
|
18147
|
+
elapsedMs: number()
|
|
18148
|
+
});
|
|
17665
18149
|
/**
|
|
17666
18150
|
* Aggregated per-camera pipeline status — server-composed, single call.
|
|
17667
18151
|
*
|
|
@@ -17692,9 +18176,28 @@ var CameraStatusSchema = object({
|
|
|
17692
18176
|
* differently — a quiet camera that looks identical to a dead one is the
|
|
17693
18177
|
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
17694
18178
|
*
|
|
17695
|
-
* Empty when nothing is off
|
|
18179
|
+
* Empty when nothing is off, and never contains a switch no provider offers
|
|
18180
|
+
* — but an empty list is only a POSITIVE claim when `degraded` does not name
|
|
18181
|
+
* `'switches'`. When it does, the switch set could not be read and nothing
|
|
18182
|
+
* here may be rendered as "the operator turned nothing off": that is the
|
|
18183
|
+
* D62 failure (a camera we could not read painted as broken) in the very
|
|
18184
|
+
* field that exists to prevent it.
|
|
17696
18185
|
*/
|
|
17697
18186
|
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
18187
|
+
/**
|
|
18188
|
+
* Stages of the bounded fan-out that were CUT SHORT — a timeout or a
|
|
18189
|
+
* rejection — and whose block is therefore `null` because we could not
|
|
18190
|
+
* READ it, not because there is nothing there.
|
|
18191
|
+
*
|
|
18192
|
+
* Without this, three different facts arrive as the same `null`: "the stage
|
|
18193
|
+
* timed out", "the stage failed", and "this camera legitimately has no
|
|
18194
|
+
* decoder / no recording". Every surface that draws a conclusion from a null
|
|
18195
|
+
* block (or from an empty `switchedOff`) must consult this first; a stage
|
|
18196
|
+
* named here supports no conclusion at all, only "unknown".
|
|
18197
|
+
*
|
|
18198
|
+
* Empty on a clean read — the overwhelmingly common case.
|
|
18199
|
+
*/
|
|
18200
|
+
degraded: array(CameraStatusDegradationSchema).readonly(),
|
|
17698
18201
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
17699
18202
|
fetchedAt: number()
|
|
17700
18203
|
});
|
|
@@ -18212,6 +18715,37 @@ DeviceType.Camera, method(object({
|
|
|
18212
18715
|
lastCapturedAt: number().nullable(),
|
|
18213
18716
|
cacheAgeMs: number().nullable(),
|
|
18214
18717
|
etag: string().nullable()
|
|
18718
|
+
}))), systemMethod(object({
|
|
18719
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18720
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18721
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18722
|
+
targets: array(object({
|
|
18723
|
+
deviceId: number(),
|
|
18724
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18725
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18726
|
+
width: number().int().positive().optional()
|
|
18727
|
+
})).min(1).max(200) }), array(object({
|
|
18728
|
+
deviceId: number(),
|
|
18729
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18730
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18731
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18732
|
+
url: string().nullable(),
|
|
18733
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18734
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18735
|
+
capturedAt: number().nullable(),
|
|
18736
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18737
|
+
ageMs: number().nullable(),
|
|
18738
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18739
|
+
expiresAt: number().nullable(),
|
|
18740
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18741
|
+
width: number().nullable(),
|
|
18742
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18743
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18744
|
+
neverCaptured: boolean(),
|
|
18745
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18746
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18747
|
+
* present it as current. */
|
|
18748
|
+
sleeping: boolean()
|
|
18215
18749
|
})));
|
|
18216
18750
|
/**
|
|
18217
18751
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -18251,13 +18785,35 @@ var SsoBridgeClaimsSchema = object({
|
|
|
18251
18785
|
integrationId: string().optional(),
|
|
18252
18786
|
/** JWT ID — unique per issued code; consumed-set enforces single-use. */
|
|
18253
18787
|
jti: string().optional(),
|
|
18788
|
+
/** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
|
|
18789
|
+
* client. Its PRESENCE is what makes the verifier mandatory at exchange,
|
|
18790
|
+
* so the requirement travels with the code and not with mutable config. */
|
|
18791
|
+
codeChallenge: string().optional(),
|
|
18254
18792
|
/** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
|
|
18255
18793
|
* tokens so the verify path can check the session is not revoked. */
|
|
18256
|
-
sessionId: string().optional()
|
|
18794
|
+
sessionId: string().optional(),
|
|
18795
|
+
/**
|
|
18796
|
+
* The refresh lifetime this LINK was created with, in seconds, or `'never'`.
|
|
18797
|
+
* Baked into the code at `/authorize` from the integration's descriptor and
|
|
18798
|
+
* carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
|
|
18799
|
+
* on the token rather than being re-read from the descriptor on purpose:
|
|
18800
|
+
* editing a descriptor must not retroactively extend or shorten a link the
|
|
18801
|
+
* operator already consented to.
|
|
18802
|
+
*/
|
|
18803
|
+
refreshTtl: union([number().int().positive(), literal("never")]).optional()
|
|
18257
18804
|
});
|
|
18258
18805
|
method(object({
|
|
18259
18806
|
claims: SsoBridgeClaimsSchema,
|
|
18260
|
-
|
|
18807
|
+
/**
|
|
18808
|
+
* Seconds, or `'never'` for a token minted with NO `exp` claim.
|
|
18809
|
+
*
|
|
18810
|
+
* `'never'` is a literal rather than `undefined`/`0` because omitting
|
|
18811
|
+
* this field already means "the 5-minute SSO hand-off default", and
|
|
18812
|
+
* `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
|
|
18813
|
+
* went through the numeric path would fail at mint time and break
|
|
18814
|
+
* linking rather than produce an eternal token.
|
|
18815
|
+
*/
|
|
18816
|
+
ttlSec: union([number().int().positive(), literal("never")]).optional()
|
|
18261
18817
|
}), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
|
|
18262
18818
|
var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
18263
18819
|
providerId: string().min(1),
|
|
@@ -18803,7 +19359,7 @@ var ClipPlaybackSchema = object({
|
|
|
18803
19359
|
playbackEndpoints: array(string()).optional(),
|
|
18804
19360
|
token: string().optional()
|
|
18805
19361
|
});
|
|
18806
|
-
method(object({
|
|
19362
|
+
DeviceType.Camera, method(object({
|
|
18807
19363
|
deviceId: number(),
|
|
18808
19364
|
since: number(),
|
|
18809
19365
|
until: number(),
|
|
@@ -19958,6 +20514,37 @@ DeviceType.Light, method(object({
|
|
|
19958
20514
|
mireds: number().int().optional(),
|
|
19959
20515
|
lastChangedAt: number()
|
|
19960
20516
|
});
|
|
20517
|
+
var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
|
|
20518
|
+
object({
|
|
20519
|
+
outcome: literal("validated"),
|
|
20520
|
+
/** Round-trip of the sign-in, when the provider measured it. */
|
|
20521
|
+
latencyMs: number().nonnegative().optional(),
|
|
20522
|
+
/** Optional human detail worth showing next to the tick
|
|
20523
|
+
* ("3 devices visible on this account"). */
|
|
20524
|
+
detail: string().optional()
|
|
20525
|
+
}).strict(),
|
|
20526
|
+
object({
|
|
20527
|
+
outcome: literal("rejected"),
|
|
20528
|
+
error: string()
|
|
20529
|
+
}).strict(),
|
|
20530
|
+
object({
|
|
20531
|
+
outcome: literal("inconclusive"),
|
|
20532
|
+
error: string()
|
|
20533
|
+
}).strict()
|
|
20534
|
+
]);
|
|
20535
|
+
var ConnectionTestInputSchema = object({
|
|
20536
|
+
/** Candidate integration settings, exactly as the create form collected them. */
|
|
20537
|
+
settings: record(string(), unknown()) });
|
|
20538
|
+
/**
|
|
20539
|
+
* What the provider's test actually DOES, so the UI can say it in words before
|
|
20540
|
+
* the operator presses the button ("Signs in to the Dreo cloud"). Purely
|
|
20541
|
+
* descriptive — it never changes routing.
|
|
20542
|
+
*/
|
|
20543
|
+
var ConnectionTestDescriptorSchema = object({ label: string() });
|
|
20544
|
+
method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
|
|
20545
|
+
kind: "mutation",
|
|
20546
|
+
auth: "admin"
|
|
20547
|
+
}), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
|
|
19961
20548
|
object({
|
|
19962
20549
|
/** True when the upstream system considers the entity connected. */
|
|
19963
20550
|
connected: boolean(),
|
|
@@ -20315,7 +20902,29 @@ var FaceInfoSchema = object({
|
|
|
20315
20902
|
recognizedIdentityId: string().optional(),
|
|
20316
20903
|
identityName: string().optional(),
|
|
20317
20904
|
assigned: boolean(),
|
|
20905
|
+
/**
|
|
20906
|
+
* The crop, inline, base64.
|
|
20907
|
+
*
|
|
20908
|
+
* **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
|
|
20909
|
+
* field alone is ~2.87 MiB, re-sent in full on every operator assign and
|
|
20910
|
+
* every 30 s poll, base64-inflated over the msgpack socket and held in the
|
|
20911
|
+
* query heap. It stays for callers that have not migrated; `includeCrops:
|
|
20912
|
+
* false` turns it off once they have.
|
|
20913
|
+
*/
|
|
20318
20914
|
base64: string().optional(),
|
|
20915
|
+
/**
|
|
20916
|
+
* Same crop, as a data-plane URL for `<img src>` — the move the admin
|
|
20917
|
+
* snapshot surfaces made on 2026-08-08.
|
|
20918
|
+
*
|
|
20919
|
+
* Served by the `event-media` plane, which resolves a raw MediaStore key and
|
|
20920
|
+
* is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
|
|
20921
|
+
* cookie, so no header plumbing is needed. The bytes then ride the browser's
|
|
20922
|
+
* HTTP cache with an ETag and `immutable`, instead of the WebSocket.
|
|
20923
|
+
*
|
|
20924
|
+
* Absent when the face has no stored crop, or when the addon has no data
|
|
20925
|
+
* plane — callers fall back to {@link base64}.
|
|
20926
|
+
*/
|
|
20927
|
+
cropUrl: string().optional(),
|
|
20319
20928
|
/** Design B: the face bbox (pixel space) on the key frame — lets a detail
|
|
20320
20929
|
* view draw the box over the native `keyFrameMediaKey` frame. Absent on
|
|
20321
20930
|
* legacy rows written before design B. */
|
|
@@ -20380,7 +20989,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
|
|
|
20380
20989
|
auth: "admin"
|
|
20381
20990
|
}), method(object({
|
|
20382
20991
|
limit: number().int().positive().optional(),
|
|
20383
|
-
filter: FaceFilterEnum.optional()
|
|
20992
|
+
filter: FaceFilterEnum.optional(),
|
|
20993
|
+
/**
|
|
20994
|
+
* Inline the base64 crop on every row. Default `true` — the existing
|
|
20995
|
+
* behaviour, kept so no caller breaks.
|
|
20996
|
+
*
|
|
20997
|
+
* Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
|
|
20998
|
+
* drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
|
|
20999
|
+
* the browser cache the images.
|
|
21000
|
+
*
|
|
21001
|
+
* **This is an INPUT field, so it does not reach the addon until the
|
|
21002
|
+
* next train.** The hub router validates cap inputs against its own
|
|
21003
|
+
* compiled Zod, which strips a key it does not know — verified today
|
|
21004
|
+
* on the OUTPUT side, where an additive field DOES arrive immediately
|
|
21005
|
+
* (`Track.hasFace`). Until the train ships, sending `false` is
|
|
21006
|
+
* harmless and simply keeps the crops inline.
|
|
21007
|
+
*/
|
|
21008
|
+
includeCrops: boolean().optional()
|
|
20384
21009
|
}).optional(), array(FaceInfoSchema).readonly()), method(object({
|
|
20385
21010
|
deviceId: number().int(),
|
|
20386
21011
|
trackId: string()
|
|
@@ -20821,15 +21446,57 @@ var AvailableIntegrationTypeSchema = object({
|
|
|
20821
21446
|
* flow can import (e.g. HA areas). Drives the adopt modal's "import
|
|
20822
21447
|
* locations" checkbox. Provider-declared in the addon manifest. */
|
|
20823
21448
|
supportsLocationImport: boolean(),
|
|
21449
|
+
/**
|
|
21450
|
+
* True when this integration DECLARES a pre-creation test (the
|
|
21451
|
+
* `connection-test` cap, or a broker whose settings it stores). Drives the
|
|
21452
|
+
* Test button: an integration that cannot be tested must say so up front
|
|
21453
|
+
* rather than offering a button that always answers the same nonsense.
|
|
21454
|
+
*/
|
|
21455
|
+
canTest: boolean(),
|
|
20824
21456
|
existingInstances: array(object({
|
|
20825
21457
|
id: string(),
|
|
20826
21458
|
name: string()
|
|
20827
21459
|
})),
|
|
20828
21460
|
canAdd: boolean()
|
|
20829
21461
|
});
|
|
21462
|
+
/**
|
|
21463
|
+
* Why a test could not be answered as a plain boolean.
|
|
21464
|
+
*
|
|
21465
|
+
* `success` alone collapsed four different situations into one red box, and the
|
|
21466
|
+
* one that mattered most — "nobody ever asked the remote anything" — looked
|
|
21467
|
+
* exactly like "the remote said no". The status is the discriminator:
|
|
21468
|
+
*
|
|
21469
|
+
* - `validated` — a provider-declared test ran and the remote ACCEPTED.
|
|
21470
|
+
* - `rejected` — a provider-declared test ran and the remote REFUSED.
|
|
21471
|
+
* The only status that blocks `integrations.create`.
|
|
21472
|
+
* - `inconclusive` — a test IS declared but could not complete (timeout,
|
|
21473
|
+
* DNS, 5xx). Nothing was observed; not a failure.
|
|
21474
|
+
* - `unsupported` — this integration declares NO test. Nothing was
|
|
21475
|
+
* observed either; not a failure, and not a pass.
|
|
21476
|
+
*
|
|
21477
|
+
* `unsupported` and `inconclusive` both carry `success: false` so an older
|
|
21478
|
+
* client can never read them as a green tick, and both carry an `error` string
|
|
21479
|
+
* that SAYS the test did not run rather than inventing a failure.
|
|
21480
|
+
*/
|
|
21481
|
+
var TestConnectionStatusEnum = _enum([
|
|
21482
|
+
"validated",
|
|
21483
|
+
"rejected",
|
|
21484
|
+
"inconclusive",
|
|
21485
|
+
"unsupported"
|
|
21486
|
+
]);
|
|
20830
21487
|
var TestConnectionResultSchema$1 = object({
|
|
21488
|
+
/** True ONLY for `validated`. Never true for a test that did not run. */
|
|
20831
21489
|
success: boolean(),
|
|
20832
|
-
error: string().optional()
|
|
21490
|
+
error: string().optional(),
|
|
21491
|
+
/** Optional for wire back-compat with clients built before the tri-state;
|
|
21492
|
+
* the server always sets it. */
|
|
21493
|
+
status: TestConnectionStatusEnum.optional(),
|
|
21494
|
+
/** Addon id whose declared test answered — `null` when none did. Lets the UI
|
|
21495
|
+
* attribute a result instead of blaming "the integration". */
|
|
21496
|
+
testedBy: string().nullable().optional(),
|
|
21497
|
+
latencyMs: number().nonnegative().optional(),
|
|
21498
|
+
/** Human detail from a `validated` result ("3 devices on this account"). */
|
|
21499
|
+
detail: string().optional()
|
|
20833
21500
|
});
|
|
20834
21501
|
var CreateIntegrationInputSchema = object({
|
|
20835
21502
|
addonId: string(),
|
|
@@ -23879,13 +24546,23 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
23879
24546
|
username: string(),
|
|
23880
24547
|
scopes: array(TokenScopeSchema),
|
|
23881
24548
|
redirectUri: string(),
|
|
23882
|
-
hubUrl: string()
|
|
24549
|
+
hubUrl: string(),
|
|
24550
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
24551
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
24552
|
+
codeChallenge: string().optional(),
|
|
24553
|
+
/** The integration's declared refresh lifetime — seconds, or `'never'`.
|
|
24554
|
+
* From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
|
|
24555
|
+
* code so the link carries its own lifetime; omit for the 30-day
|
|
24556
|
+
* default. */
|
|
24557
|
+
refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
|
|
23883
24558
|
}), object({ code: string() }), {
|
|
23884
24559
|
kind: "mutation",
|
|
23885
24560
|
access: "create"
|
|
23886
24561
|
}), method(object({
|
|
23887
24562
|
code: string(),
|
|
23888
|
-
redirectUri: string()
|
|
24563
|
+
redirectUri: string(),
|
|
24564
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
24565
|
+
codeVerifier: string().optional()
|
|
23889
24566
|
}), object({
|
|
23890
24567
|
accessToken: string(),
|
|
23891
24568
|
refreshToken: string(),
|
|
@@ -25070,6 +25747,18 @@ Object.freeze({
|
|
|
25070
25747
|
addonId: null,
|
|
25071
25748
|
access: "create"
|
|
25072
25749
|
},
|
|
25750
|
+
"connectionTest.describeTest": {
|
|
25751
|
+
capName: "connection-test",
|
|
25752
|
+
capScope: "system",
|
|
25753
|
+
addonId: null,
|
|
25754
|
+
access: "view"
|
|
25755
|
+
},
|
|
25756
|
+
"connectionTest.testSettings": {
|
|
25757
|
+
capName: "connection-test",
|
|
25758
|
+
capScope: "system",
|
|
25759
|
+
addonId: null,
|
|
25760
|
+
access: "create"
|
|
25761
|
+
},
|
|
25073
25762
|
"consumables.reset": {
|
|
25074
25763
|
capName: "consumables",
|
|
25075
25764
|
capScope: "device",
|
|
@@ -25118,6 +25807,12 @@ Object.freeze({
|
|
|
25118
25807
|
addonId: null,
|
|
25119
25808
|
access: "view"
|
|
25120
25809
|
},
|
|
25810
|
+
"coreBlocks.restart": {
|
|
25811
|
+
capName: "core-blocks",
|
|
25812
|
+
capScope: "system",
|
|
25813
|
+
addonId: null,
|
|
25814
|
+
access: "create"
|
|
25815
|
+
},
|
|
25121
25816
|
"coreBlocks.setEnabled": {
|
|
25122
25817
|
capName: "core-blocks",
|
|
25123
25818
|
capScope: "system",
|
|
@@ -25454,6 +26149,12 @@ Object.freeze({
|
|
|
25454
26149
|
addonId: null,
|
|
25455
26150
|
access: "create"
|
|
25456
26151
|
},
|
|
26152
|
+
"deviceManager.adoptionCancelJob": {
|
|
26153
|
+
capName: "device-manager",
|
|
26154
|
+
capScope: "system",
|
|
26155
|
+
addonId: null,
|
|
26156
|
+
access: "create"
|
|
26157
|
+
},
|
|
25457
26158
|
"deviceManager.adoptionListCandidateFilters": {
|
|
25458
26159
|
capName: "device-manager",
|
|
25459
26160
|
capScope: "system",
|
|
@@ -25466,6 +26167,12 @@ Object.freeze({
|
|
|
25466
26167
|
addonId: null,
|
|
25467
26168
|
access: "view"
|
|
25468
26169
|
},
|
|
26170
|
+
"deviceManager.adoptionListJobs": {
|
|
26171
|
+
capName: "device-manager",
|
|
26172
|
+
capScope: "system",
|
|
26173
|
+
addonId: null,
|
|
26174
|
+
access: "view"
|
|
26175
|
+
},
|
|
25469
26176
|
"deviceManager.adoptionRefresh": {
|
|
25470
26177
|
capName: "device-manager",
|
|
25471
26178
|
capScope: "system",
|
|
@@ -25484,6 +26191,12 @@ Object.freeze({
|
|
|
25484
26191
|
addonId: null,
|
|
25485
26192
|
access: "create"
|
|
25486
26193
|
},
|
|
26194
|
+
"deviceManager.adoptionStartJob": {
|
|
26195
|
+
capName: "device-manager",
|
|
26196
|
+
capScope: "system",
|
|
26197
|
+
addonId: null,
|
|
26198
|
+
access: "create"
|
|
26199
|
+
},
|
|
25487
26200
|
"deviceManager.allocateDeviceId": {
|
|
25488
26201
|
capName: "device-manager",
|
|
25489
26202
|
capScope: "system",
|
|
@@ -25754,12 +26467,6 @@ Object.freeze({
|
|
|
25754
26467
|
addonId: null,
|
|
25755
26468
|
access: "create"
|
|
25756
26469
|
},
|
|
25757
|
-
"deviceManager.setDeviceLinks": {
|
|
25758
|
-
capName: "device-manager",
|
|
25759
|
-
capScope: "system",
|
|
25760
|
-
addonId: null,
|
|
25761
|
-
access: "create"
|
|
25762
|
-
},
|
|
25763
26470
|
"deviceManager.setDisabled": {
|
|
25764
26471
|
capName: "device-manager",
|
|
25765
26472
|
capScope: "system",
|
|
@@ -28628,6 +29335,12 @@ Object.freeze({
|
|
|
28628
29335
|
addonId: null,
|
|
28629
29336
|
access: "view"
|
|
28630
29337
|
},
|
|
29338
|
+
"snapshot.getSnapshotLinks": {
|
|
29339
|
+
capName: "snapshot",
|
|
29340
|
+
capScope: "device",
|
|
29341
|
+
addonId: null,
|
|
29342
|
+
access: "view"
|
|
29343
|
+
},
|
|
28631
29344
|
"snapshot.getSnapshotOverview": {
|
|
28632
29345
|
capName: "snapshot",
|
|
28633
29346
|
capScope: "device",
|
|
@@ -28904,6 +29617,12 @@ Object.freeze({
|
|
|
28904
29617
|
addonId: null,
|
|
28905
29618
|
access: "create"
|
|
28906
29619
|
},
|
|
29620
|
+
"streamBroker.fetchEventMedia": {
|
|
29621
|
+
capName: "stream-broker",
|
|
29622
|
+
capScope: "system",
|
|
29623
|
+
addonId: null,
|
|
29624
|
+
access: "create"
|
|
29625
|
+
},
|
|
28907
29626
|
"streamBroker.getAllRtspEntries": {
|
|
28908
29627
|
capName: "stream-broker",
|
|
28909
29628
|
capScope: "system",
|
|
@@ -28988,6 +29707,12 @@ Object.freeze({
|
|
|
28988
29707
|
addonId: null,
|
|
28989
29708
|
access: "create"
|
|
28990
29709
|
},
|
|
29710
|
+
"streamBroker.produceEventMedia": {
|
|
29711
|
+
capName: "stream-broker",
|
|
29712
|
+
capScope: "system",
|
|
29713
|
+
addonId: null,
|
|
29714
|
+
access: "create"
|
|
29715
|
+
},
|
|
28991
29716
|
"streamBroker.publishCameraStream": {
|
|
28992
29717
|
capName: "stream-broker",
|
|
28993
29718
|
capScope: "system",
|
|
@@ -43957,37 +44682,37 @@ function errMsg$8(err) {
|
|
|
43957
44682
|
}
|
|
43958
44683
|
//#endregion
|
|
43959
44684
|
//#region src/mappers/builders/doorbell-delivery.ts
|
|
43960
|
-
function isRecord(value) {
|
|
44685
|
+
function isRecord$1(value) {
|
|
43961
44686
|
return typeof value === "object" && value !== null;
|
|
43962
44687
|
}
|
|
43963
44688
|
function numberOrNull(value) {
|
|
43964
44689
|
return typeof value === "number" ? value : null;
|
|
43965
44690
|
}
|
|
43966
44691
|
function isConnectionLike(value) {
|
|
43967
|
-
return isRecord(value) && typeof value["hasEventNotifications"] === "function";
|
|
44692
|
+
return isRecord$1(value) && typeof value["hasEventNotifications"] === "function";
|
|
43968
44693
|
}
|
|
43969
|
-
function isIterable(value) {
|
|
43970
|
-
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
44694
|
+
function isIterable$1(value) {
|
|
44695
|
+
return isRecord$1(value) && typeof value[Symbol.iterator] === "function";
|
|
43971
44696
|
}
|
|
43972
44697
|
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
43973
|
-
function readConnections(accessory) {
|
|
43974
|
-
if (!isRecord(accessory)) return null;
|
|
44698
|
+
function readConnections$1(accessory) {
|
|
44699
|
+
if (!isRecord$1(accessory)) return null;
|
|
43975
44700
|
const server = accessory["_server"];
|
|
43976
|
-
if (!isRecord(server)) return null;
|
|
44701
|
+
if (!isRecord$1(server)) return null;
|
|
43977
44702
|
const httpServer = server["httpServer"];
|
|
43978
|
-
if (!isRecord(httpServer)) return null;
|
|
44703
|
+
if (!isRecord$1(httpServer)) return null;
|
|
43979
44704
|
const connections = httpServer["connections"];
|
|
43980
|
-
return isIterable(connections) ? connections : null;
|
|
44705
|
+
return isIterable$1(connections) ? connections : null;
|
|
43981
44706
|
}
|
|
43982
44707
|
/**
|
|
43983
44708
|
* Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
|
|
43984
44709
|
* Pure with respect to HAP state — it only reads. Never throws.
|
|
43985
44710
|
*/
|
|
43986
44711
|
function describeDoorbellDelivery(accessory, characteristic) {
|
|
43987
|
-
const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
43988
|
-
const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
43989
|
-
const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
|
|
43990
|
-
const connections = readConnections(accessory);
|
|
44712
|
+
const aid = isRecord$1(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
44713
|
+
const iid = isRecord$1(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
44714
|
+
const serverPublished = isRecord$1(accessory) && isRecord$1(accessory["_server"]);
|
|
44715
|
+
const connections = readConnections$1(accessory);
|
|
43991
44716
|
if (connections === null) return {
|
|
43992
44717
|
aid,
|
|
43993
44718
|
iid,
|
|
@@ -44099,10 +44824,16 @@ async function buildMotionSensor(bctx, existing = null) {
|
|
|
44099
44824
|
resetTimer = null;
|
|
44100
44825
|
}, RESET_DEBOUNCE_MS);
|
|
44101
44826
|
};
|
|
44827
|
+
const motionLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
44828
|
+
const hksvTrigger = existing !== null;
|
|
44102
44829
|
const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
|
|
44103
44830
|
if (event.data.deviceId !== numericDeviceId) return;
|
|
44104
44831
|
const detected = event.data.detected === true;
|
|
44105
44832
|
motionService.updateCharacteristic(Characteristic.MotionDetected, detected);
|
|
44833
|
+
motionLog.debug("export-hap: motion pushed to HomeKit", { meta: {
|
|
44834
|
+
detected,
|
|
44835
|
+
hksvTrigger
|
|
44836
|
+
} });
|
|
44106
44837
|
if (detected) armReset();
|
|
44107
44838
|
else if (resetTimer) {
|
|
44108
44839
|
clearTimeout(resetTimer);
|
|
@@ -44479,36 +45210,84 @@ async function probe(call, label, log) {
|
|
|
44479
45210
|
}
|
|
44480
45211
|
}
|
|
44481
45212
|
//#endregion
|
|
44482
|
-
//#region src/hksv/
|
|
45213
|
+
//#region src/hksv/controller-census.ts
|
|
45214
|
+
var EMPTY_CENSUS = {
|
|
45215
|
+
serverPublished: false,
|
|
45216
|
+
connections: 0,
|
|
45217
|
+
adminConnections: 0,
|
|
45218
|
+
nonAdminConnections: 0,
|
|
45219
|
+
unverifiedConnections: 0,
|
|
45220
|
+
pairedControllers: 0,
|
|
45221
|
+
pairedAdmins: 0
|
|
45222
|
+
};
|
|
45223
|
+
function isRecord(value) {
|
|
45224
|
+
return typeof value === "object" && value !== null;
|
|
45225
|
+
}
|
|
45226
|
+
function isIterable(value) {
|
|
45227
|
+
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
45228
|
+
}
|
|
45229
|
+
function isAccessoryInfoLike(value) {
|
|
45230
|
+
return isRecord(value) && isRecord(value["pairedClients"]) && typeof value["hasAdminPermissions"] === "function";
|
|
45231
|
+
}
|
|
45232
|
+
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
45233
|
+
function readConnections(accessory) {
|
|
45234
|
+
const server = accessory["_server"];
|
|
45235
|
+
if (!isRecord(server)) return null;
|
|
45236
|
+
const httpServer = server["httpServer"];
|
|
45237
|
+
if (!isRecord(httpServer)) return null;
|
|
45238
|
+
const connections = httpServer["connections"];
|
|
45239
|
+
return isIterable(connections) ? connections : null;
|
|
45240
|
+
}
|
|
44483
45241
|
/**
|
|
44484
|
-
*
|
|
44485
|
-
*
|
|
44486
|
-
|
|
44487
|
-
|
|
44488
|
-
|
|
44489
|
-
|
|
44490
|
-
|
|
44491
|
-
|
|
44492
|
-
|
|
44493
|
-
|
|
44494
|
-
|
|
44495
|
-
|
|
44496
|
-
|
|
44497
|
-
|
|
44498
|
-
|
|
44499
|
-
|
|
44500
|
-
|
|
44501
|
-
|
|
44502
|
-
|
|
44503
|
-
|
|
44504
|
-
|
|
44505
|
-
|
|
44506
|
-
|
|
44507
|
-
|
|
44508
|
-
|
|
44509
|
-
|
|
44510
|
-
|
|
45242
|
+
* Census the accessory's HAP connections. Pure with respect to HAP state — it
|
|
45243
|
+
* only reads — and never throws.
|
|
45244
|
+
*/
|
|
45245
|
+
function describeHapControllers(accessory) {
|
|
45246
|
+
if (!isRecord(accessory)) return EMPTY_CENSUS;
|
|
45247
|
+
const info = accessory["_accessoryInfo"];
|
|
45248
|
+
const paired = isAccessoryInfoLike(info) ? Object.keys(info.pairedClients) : [];
|
|
45249
|
+
const pairedAdmins = isAccessoryInfoLike(info) ? paired.filter((username) => info.hasAdminPermissions(username)).length : 0;
|
|
45250
|
+
const connections = readConnections(accessory);
|
|
45251
|
+
if (connections === null) return {
|
|
45252
|
+
...EMPTY_CENSUS,
|
|
45253
|
+
pairedControllers: paired.length,
|
|
45254
|
+
pairedAdmins
|
|
45255
|
+
};
|
|
45256
|
+
let open = 0;
|
|
45257
|
+
let admins = 0;
|
|
45258
|
+
let nonAdmins = 0;
|
|
45259
|
+
let unverified = 0;
|
|
45260
|
+
for (const connection of connections) {
|
|
45261
|
+
open += 1;
|
|
45262
|
+
const username = isRecord(connection) ? connection["username"] : void 0;
|
|
45263
|
+
if (typeof username !== "string" || !isAccessoryInfoLike(info)) {
|
|
45264
|
+
unverified += 1;
|
|
45265
|
+
continue;
|
|
45266
|
+
}
|
|
45267
|
+
if (info.hasAdminPermissions(username)) admins += 1;
|
|
45268
|
+
else nonAdmins += 1;
|
|
45269
|
+
}
|
|
45270
|
+
return {
|
|
45271
|
+
serverPublished: true,
|
|
45272
|
+
connections: open,
|
|
45273
|
+
adminConnections: admins,
|
|
45274
|
+
nonAdminConnections: nonAdmins,
|
|
45275
|
+
unverifiedConnections: unverified,
|
|
45276
|
+
pairedControllers: paired.length,
|
|
45277
|
+
pairedAdmins
|
|
45278
|
+
};
|
|
45279
|
+
}
|
|
45280
|
+
/**
|
|
45281
|
+
* True when NO connected controller may write HKSV state. Every
|
|
45282
|
+
* `SelectedCameraRecordingConfiguration` write such a controller sends is
|
|
45283
|
+
* refused before it reaches us, so the recording configuration can never
|
|
45284
|
+
* arrive and the caller must say so out loud.
|
|
44511
45285
|
*/
|
|
45286
|
+
function noAdminControllerConnected(census) {
|
|
45287
|
+
return census.serverPublished && census.connections > 0 && census.adminConnections === 0;
|
|
45288
|
+
}
|
|
45289
|
+
//#endregion
|
|
45290
|
+
//#region src/hksv/recording-options.ts
|
|
44512
45291
|
/**
|
|
44513
45292
|
* The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
|
|
44514
45293
|
* range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
|
|
@@ -44531,6 +45310,16 @@ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
|
|
|
44531
45310
|
*/
|
|
44532
45311
|
var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
|
|
44533
45312
|
/**
|
|
45313
|
+
* The frame rates the advertised {@link Resolution} may carry, and the only
|
|
45314
|
+
* ones. See {@link normaliseAdvertisedFps} for why the measured rate does not
|
|
45315
|
+
* go in raw.
|
|
45316
|
+
*/
|
|
45317
|
+
var HKSV_ADVERTISED_FRAME_RATES = [
|
|
45318
|
+
15,
|
|
45319
|
+
24,
|
|
45320
|
+
30
|
|
45321
|
+
];
|
|
45322
|
+
/**
|
|
44534
45323
|
* The advertised fragment length for a camera whose key-frame cadence is
|
|
44535
45324
|
* `sourceGopMs`, or `null` when no offered length covers it.
|
|
44536
45325
|
*
|
|
@@ -44546,6 +45335,46 @@ function deriveFragmentLengthMs(sourceGopMs) {
|
|
|
44546
45335
|
return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
|
|
44547
45336
|
}
|
|
44548
45337
|
/**
|
|
45338
|
+
* The frame rate to ADVERTISE for a slot that was measured at `measuredFps` —
|
|
45339
|
+
* the nearest member of {@link HKSV_ADVERTISED_FRAME_RATES}, ties going to the
|
|
45340
|
+
* lower rate.
|
|
45341
|
+
*
|
|
45342
|
+
* The measured rate does not go into the advertisement raw, for two reasons,
|
|
45343
|
+
* and the second one is the serious one.
|
|
45344
|
+
*
|
|
45345
|
+
* **It is the list the controller chooses from.** `[1280, 720, 10]` — 615's
|
|
45346
|
+
* measured 720p slot — is a frame rate no shipping HKSV camera offers, and the
|
|
45347
|
+
* controller has to find an acceptable configuration in what we advertise
|
|
45348
|
+
* before it will write one back.
|
|
45349
|
+
*
|
|
45350
|
+
* **A measurement makes the advertisement UNSTABLE, and hap-nodejs punishes
|
|
45351
|
+
* that by discarding the controller's selection.** `RecordingManagement`
|
|
45352
|
+
* hashes the supported-configuration TLVs and, on restore, keeps the persisted
|
|
45353
|
+
* `selectedConfiguration` only while the hash still matches — otherwise
|
|
45354
|
+
* `deserialize: discarding saved selectedConfiguration`, after which the
|
|
45355
|
+
* accessory answers every HDS `DATA_SEND OPEN` with `INVALID_CONFIGURATION`
|
|
45356
|
+
* and records nothing until the controller happens to re-select. The
|
|
45357
|
+
* advertised resolution is the one hashed input that came from a probe:
|
|
45358
|
+
* camera 590 measured 9 fps on one restart and 10 on the next, on
|
|
45359
|
+
* 2026-08-07/08, so this was a self-inflicted outage waiting on a reboot.
|
|
45360
|
+
* Quantising gives the probe a wide band to move inside without the
|
|
45361
|
+
* advertisement changing at all.
|
|
45362
|
+
*/
|
|
45363
|
+
function normaliseAdvertisedFps(measuredFps) {
|
|
45364
|
+
const fallback = HKSV_ADVERTISED_FRAME_RATES[0] ?? 15;
|
|
45365
|
+
if (!Number.isFinite(measuredFps) || measuredFps <= 0) return fallback;
|
|
45366
|
+
let best = fallback;
|
|
45367
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
45368
|
+
for (const candidate of HKSV_ADVERTISED_FRAME_RATES) {
|
|
45369
|
+
const distance = Math.abs(candidate - measuredFps);
|
|
45370
|
+
if (distance < bestDistance) {
|
|
45371
|
+
best = candidate;
|
|
45372
|
+
bestDistance = distance;
|
|
45373
|
+
}
|
|
45374
|
+
}
|
|
45375
|
+
return best;
|
|
45376
|
+
}
|
|
45377
|
+
/**
|
|
44549
45378
|
* Build the advertisement.
|
|
44550
45379
|
*
|
|
44551
45380
|
* ONE resolution is advertised — the one slot the recording child pulls. HAP's
|
|
@@ -44558,7 +45387,7 @@ function buildRecordingOptions(input) {
|
|
|
44558
45387
|
const resolution = [
|
|
44559
45388
|
input.width,
|
|
44560
45389
|
input.height,
|
|
44561
|
-
|
|
45390
|
+
normaliseAdvertisedFps(input.fps)
|
|
44562
45391
|
];
|
|
44563
45392
|
return {
|
|
44564
45393
|
prebufferLength: HKSV_PREBUFFER_MS,
|
|
@@ -44881,13 +45710,19 @@ var HksvRecordingDelegate = class {
|
|
|
44881
45710
|
updateRecordingActive(active) {
|
|
44882
45711
|
if (active === this.active) return;
|
|
44883
45712
|
this.active = active;
|
|
45713
|
+
const census = this.input.describeControllers();
|
|
44884
45714
|
this.log.info("hksv: recording active changed", {
|
|
44885
45715
|
tags: { deviceId: this.input.deviceId },
|
|
44886
45716
|
meta: {
|
|
44887
45717
|
active,
|
|
44888
|
-
hasConfiguration: this.configuration !== void 0
|
|
45718
|
+
hasConfiguration: this.configuration !== void 0,
|
|
45719
|
+
...census
|
|
44889
45720
|
}
|
|
44890
45721
|
});
|
|
45722
|
+
if (active && this.configuration === void 0 && noAdminControllerConnected(census)) this.log.warn("hksv: recording is ON but NO connected controller holds admin — the recording configuration can never arrive", {
|
|
45723
|
+
tags: { deviceId: this.input.deviceId },
|
|
45724
|
+
meta: { ...census }
|
|
45725
|
+
});
|
|
44891
45726
|
this.reconcile("recording-active");
|
|
44892
45727
|
}
|
|
44893
45728
|
updateRecordingConfiguration(configuration) {
|
|
@@ -44942,13 +45777,14 @@ var HksvRecordingDelegate = class {
|
|
|
44942
45777
|
streamId,
|
|
44943
45778
|
subscription
|
|
44944
45779
|
};
|
|
44945
|
-
const startedAt =
|
|
45780
|
+
const startedAt = this.input.now();
|
|
44946
45781
|
const prebufferSpanMs = source.prebufferSpanMs();
|
|
44947
45782
|
let packets = 0;
|
|
44948
45783
|
let bytes = 0;
|
|
44949
45784
|
let markedLast = false;
|
|
44950
45785
|
let longestFragmentGapMs = 0;
|
|
44951
|
-
let
|
|
45786
|
+
let firstFragmentAt = null;
|
|
45787
|
+
let lastFragmentAt = null;
|
|
44952
45788
|
try {
|
|
44953
45789
|
for await (const packet of subscription.packets()) {
|
|
44954
45790
|
if (signal?.aborted === true) {
|
|
@@ -44964,9 +45800,10 @@ var HksvRecordingDelegate = class {
|
|
|
44964
45800
|
packets += 1;
|
|
44965
45801
|
bytes += packet.data.length;
|
|
44966
45802
|
if (packet.kind === "fragment") {
|
|
44967
|
-
const now =
|
|
44968
|
-
|
|
44969
|
-
|
|
45803
|
+
const now = this.input.now();
|
|
45804
|
+
if (lastFragmentAt === null) firstFragmentAt = now;
|
|
45805
|
+
else longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastFragmentAt);
|
|
45806
|
+
lastFragmentAt = now;
|
|
44970
45807
|
}
|
|
44971
45808
|
markedLast = markedLast || packet.isLast;
|
|
44972
45809
|
yield {
|
|
@@ -45001,9 +45838,10 @@ var HksvRecordingDelegate = class {
|
|
|
45001
45838
|
streamId,
|
|
45002
45839
|
packets,
|
|
45003
45840
|
bytes,
|
|
45004
|
-
durationMs:
|
|
45841
|
+
durationMs: this.input.now() - startedAt,
|
|
45005
45842
|
prebufferSpanMs,
|
|
45006
45843
|
longestFragmentGapMs,
|
|
45844
|
+
msToFirstFragmentMs: firstFragmentAt === null ? null : firstFragmentAt - startedAt,
|
|
45007
45845
|
closedReason: subscription.closedReason,
|
|
45008
45846
|
markedLast
|
|
45009
45847
|
}
|
|
@@ -45220,6 +46058,8 @@ async function buildHksvRecording(input) {
|
|
|
45220
46058
|
deviceId: numericDeviceId,
|
|
45221
46059
|
isAudioActive: input.isAudioActive,
|
|
45222
46060
|
advertisedFragmentMs: fragmentLengthMs,
|
|
46061
|
+
now: () => Date.now(),
|
|
46062
|
+
describeControllers: () => describeHapControllers(bctx.accessory),
|
|
45223
46063
|
createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
|
|
45224
46064
|
logger: log,
|
|
45225
46065
|
deviceId: numericDeviceId,
|
|
@@ -45230,11 +46070,13 @@ async function buildHksvRecording(input) {
|
|
|
45230
46070
|
audioActive
|
|
45231
46071
|
})
|
|
45232
46072
|
});
|
|
46073
|
+
const advertisedResolution = options.video.resolutions[0];
|
|
45233
46074
|
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
45234
46075
|
brokerId: source.brokerId,
|
|
45235
46076
|
profile: source.profile,
|
|
45236
46077
|
resolution: `${source.width}x${source.height}`,
|
|
45237
|
-
fps,
|
|
46078
|
+
measuredFps: fps,
|
|
46079
|
+
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
45238
46080
|
fragmentLengthMs,
|
|
45239
46081
|
sourceGopMs: gopMs ?? "unknown"
|
|
45240
46082
|
} });
|