@camstack/addon-export-hap 1.2.16 → 1.2.17
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 +1073 -878
- package/dist/export-hap.addon.mjs +1073 -878
- package/package.json +1 -1
package/dist/export-hap.addon.js
CHANGED
|
@@ -7080,7 +7080,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7080
7080
|
input: unknown()
|
|
7081
7081
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7082
7082
|
//#endregion
|
|
7083
|
-
//#region ../types/dist/
|
|
7083
|
+
//#region ../types/dist/canonical-hash-rO1sRmEK.mjs
|
|
7084
7084
|
var AUDIO_ENCODER_BY_CODEC = {
|
|
7085
7085
|
opus: "libopus",
|
|
7086
7086
|
aac: "aac",
|
|
@@ -7374,37 +7374,6 @@ function buildFfmpegArgs(inv) {
|
|
|
7374
7374
|
...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
|
|
7375
7375
|
];
|
|
7376
7376
|
}
|
|
7377
|
-
/**
|
|
7378
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7379
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
7380
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
7381
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7382
|
-
*
|
|
7383
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7384
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7385
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
7386
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7387
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7388
|
-
* this guard would propagate redundant pushes.
|
|
7389
|
-
*
|
|
7390
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7391
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7392
|
-
* barrier so exports react only when the full cap set has landed —
|
|
7393
|
-
* tracked separately for post-HA-integration work.
|
|
7394
|
-
*/
|
|
7395
|
-
function canonicalHash(value) {
|
|
7396
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7397
|
-
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
7398
|
-
}
|
|
7399
|
-
function replaceWithSortedKeys(_key, value) {
|
|
7400
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7401
|
-
const obj = value;
|
|
7402
|
-
const out = {};
|
|
7403
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7404
|
-
return out;
|
|
7405
|
-
}
|
|
7406
|
-
return value;
|
|
7407
|
-
}
|
|
7408
7377
|
var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
|
|
7409
7378
|
/** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
|
|
7410
7379
|
var BOX_HEADER_BYTES = 8;
|
|
@@ -7577,6 +7546,37 @@ var Fmp4BoxSplitter = class {
|
|
|
7577
7546
|
return [];
|
|
7578
7547
|
}
|
|
7579
7548
|
};
|
|
7549
|
+
/**
|
|
7550
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7551
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
7552
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
7553
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7554
|
+
*
|
|
7555
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7556
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7557
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
7558
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7559
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7560
|
+
* this guard would propagate redundant pushes.
|
|
7561
|
+
*
|
|
7562
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7563
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7564
|
+
* barrier so exports react only when the full cap set has landed —
|
|
7565
|
+
* tracked separately for post-HA-integration work.
|
|
7566
|
+
*/
|
|
7567
|
+
function canonicalHash(value) {
|
|
7568
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7569
|
+
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
7570
|
+
}
|
|
7571
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7572
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7573
|
+
const obj = value;
|
|
7574
|
+
const out = {};
|
|
7575
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7576
|
+
return out;
|
|
7577
|
+
}
|
|
7578
|
+
return value;
|
|
7579
|
+
}
|
|
7580
7580
|
//#endregion
|
|
7581
7581
|
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7582
7582
|
/**
|
|
@@ -9656,6 +9656,69 @@ var StreamFormatSchema = _enum([
|
|
|
9656
9656
|
"mjpeg",
|
|
9657
9657
|
"rtsp"
|
|
9658
9658
|
]);
|
|
9659
|
+
/** A container `produceEventMedia` can emit. */
|
|
9660
|
+
var EventMediaKindSchema = _enum(["mp4", "gif"]);
|
|
9661
|
+
/**
|
|
9662
|
+
* One produced artifact, referenced by HANDLE.
|
|
9663
|
+
*
|
|
9664
|
+
* Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
|
|
9665
|
+
* method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
|
|
9666
|
+
* [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
|
|
9667
|
+
* on demand, compressed, by handle). `bytes` is here so a caller can decide
|
|
9668
|
+
* whether it wants the fetch at all.
|
|
9669
|
+
*/
|
|
9670
|
+
var EventMediaArtifactSchema = object({
|
|
9671
|
+
kind: EventMediaKindSchema,
|
|
9672
|
+
/** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
|
|
9673
|
+
handle: string(),
|
|
9674
|
+
/**
|
|
9675
|
+
* The node holding the bytes — the ROUTING key for `fetchEventMedia`.
|
|
9676
|
+
*
|
|
9677
|
+
* `stream-broker` is a singleton cap and an unpinned call never leaves the
|
|
9678
|
+
* hub, so a handle produced on an agent's broker would be redeemed against
|
|
9679
|
+
* the hub's store and come back `null`. Same contract, same field name and
|
|
9680
|
+
* the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
|
|
9681
|
+
* lives and the consumer pins to it.
|
|
9682
|
+
*/
|
|
9683
|
+
nodeId: string(),
|
|
9684
|
+
mime: string(),
|
|
9685
|
+
bytes: number().int(),
|
|
9686
|
+
width: number().int(),
|
|
9687
|
+
height: number().int()
|
|
9688
|
+
});
|
|
9689
|
+
/**
|
|
9690
|
+
* What a production actually covered — the answer to the only question an
|
|
9691
|
+
* operator asks about a notification clip.
|
|
9692
|
+
*
|
|
9693
|
+
* `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
|
|
9694
|
+
* so a caller can state "this clip starts 4.1 s before the event" instead of
|
|
9695
|
+
* inferring it from a duration. A production whose `fromTs` is later than the
|
|
9696
|
+
* event is a production with no pre-roll, and that is exactly the defect this
|
|
9697
|
+
* method exists to make visible rather than plausible.
|
|
9698
|
+
*/
|
|
9699
|
+
var EventMediaCoverageSchema = object({
|
|
9700
|
+
fromTs: number(),
|
|
9701
|
+
toTs: number(),
|
|
9702
|
+
/** Encoded packets in the muxed window. */
|
|
9703
|
+
packets: number().int()
|
|
9704
|
+
});
|
|
9705
|
+
/**
|
|
9706
|
+
* The result of ONE cut, in every container the caller asked for.
|
|
9707
|
+
*
|
|
9708
|
+
* Every artifact in `media` came out of the SAME window of the SAME rendition —
|
|
9709
|
+
* that is the whole reason this is one method rather than one call per format.
|
|
9710
|
+
* A consumer attaching a gif and a video can no longer show two different
|
|
9711
|
+
* moments, because it never chose two sources.
|
|
9712
|
+
*/
|
|
9713
|
+
var EventMediaProductionSchema = object({
|
|
9714
|
+
media: array(EventMediaArtifactSchema).readonly(),
|
|
9715
|
+
coverage: EventMediaCoverageSchema,
|
|
9716
|
+
/** The rendition actually cut from — what the default or the fallback chose. */
|
|
9717
|
+
profile: CamProfileSchema,
|
|
9718
|
+
/** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
|
|
9719
|
+
* source, a downscale, or a playback rate other than 1). */
|
|
9720
|
+
video: _enum(["copy", "encode"])
|
|
9721
|
+
});
|
|
9659
9722
|
var RtspRestreamEntrySchema = object({
|
|
9660
9723
|
brokerId: string(),
|
|
9661
9724
|
url: string(),
|
|
@@ -10055,6 +10118,56 @@ method(object({
|
|
|
10055
10118
|
}), {
|
|
10056
10119
|
kind: "mutation",
|
|
10057
10120
|
auth: "admin"
|
|
10121
|
+
}), method(object({
|
|
10122
|
+
deviceId: number(),
|
|
10123
|
+
/** Absent = the largest H.264 rendition at or below 1080p, which is
|
|
10124
|
+
* also the one that can be copied. Falls back to whatever the ring
|
|
10125
|
+
* actually retained, and the answer says which. */
|
|
10126
|
+
profile: CamProfileSchema.optional(),
|
|
10127
|
+
aroundMs: number(),
|
|
10128
|
+
preSeconds: number().min(0).max(20).default(4),
|
|
10129
|
+
postSeconds: number().min(0).max(20).default(6),
|
|
10130
|
+
kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
|
|
10131
|
+
/** GIF geometry. The video keeps the source's own. */
|
|
10132
|
+
gifMaxWidth: number().int().min(120).max(1280).default(640),
|
|
10133
|
+
/**
|
|
10134
|
+
* The gif's own PLAYBACK rate in frames per second — what the finished
|
|
10135
|
+
* gif runs at, not how many source frames feed it. The decimation that
|
|
10136
|
+
* feeds it samples `gifFps / gifSpeed` source frames per second, so at
|
|
10137
|
+
* the defaults a 12 fps gif is built out of 3 source frames a second.
|
|
10138
|
+
*/
|
|
10139
|
+
gifFps: number().int().min(1).max(15).default(12),
|
|
10140
|
+
/**
|
|
10141
|
+
* How fast the GIF plays against real time, independent of `speed`.
|
|
10142
|
+
*
|
|
10143
|
+
* 4× by default, by operator request: a notification gif is glanced at
|
|
10144
|
+
* on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
|
|
10145
|
+
* a separate knob from `speed` even though both now default to 4 —
|
|
10146
|
+
* a caller wanting a real-time video and a fast gif must not have to
|
|
10147
|
+
* choose.
|
|
10148
|
+
*/
|
|
10149
|
+
gifSpeed: number().min(1).max(8).default(4),
|
|
10150
|
+
/**
|
|
10151
|
+
* Playback rate of the VIDEO. Also 4× by default, by operator decision.
|
|
10152
|
+
*
|
|
10153
|
+
* `1` is real time and is the ONLY value that allows the copy branch —
|
|
10154
|
+
* anything else forces `libx264` over the window. That was priced
|
|
10155
|
+
* before it was chosen: a per-event burst measured at 0.23 s and 254 KB
|
|
10156
|
+
* on a real 615 720p cut, against 922 KB for the copy it replaces. A
|
|
10157
|
+
* re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
|
|
10158
|
+
* once the decode is forced the width stops being free.
|
|
10159
|
+
*/
|
|
10160
|
+
speed: number().min(1).max(8).default(4)
|
|
10161
|
+
}), EventMediaProductionSchema, {
|
|
10162
|
+
kind: "mutation",
|
|
10163
|
+
auth: "admin"
|
|
10164
|
+
}), method(object({ handle: string() }), object({
|
|
10165
|
+
base64: string(),
|
|
10166
|
+
mime: string(),
|
|
10167
|
+
bytes: number().int()
|
|
10168
|
+
}).nullable(), {
|
|
10169
|
+
kind: "mutation",
|
|
10170
|
+
auth: "admin"
|
|
10058
10171
|
}), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
|
|
10059
10172
|
probed: boolean(),
|
|
10060
10173
|
summary: string()
|
|
@@ -10247,25 +10360,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
|
|
|
10247
10360
|
}),
|
|
10248
10361
|
lastChangedAt: number()
|
|
10249
10362
|
});
|
|
10250
|
-
/**
|
|
10251
|
-
* core-blocks — user-authored TypeScript, stored in the kernel and executed in
|
|
10252
|
-
* its own process.
|
|
10253
|
-
*
|
|
10254
|
-
* Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
|
|
10255
|
-
*
|
|
10256
|
-
* The first use is **owning devices without being a device provider**: a block
|
|
10257
|
-
* declares devices under a system or custom integration and drives their state,
|
|
10258
|
-
* with the same `ctx` an addon gets. Automations come later; nothing here
|
|
10259
|
-
* models a trigger.
|
|
10260
|
-
*
|
|
10261
|
-
* **Stated plainly, because it does not change by being true:** a block has an
|
|
10262
|
-
* addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
|
|
10263
|
-
* with no review step. What makes that survivable is not a sandbox, it is
|
|
10264
|
-
* PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
|
|
10265
|
-
* so a block that throws or never returns is marked `failed` and visible
|
|
10266
|
-
* instead of taking the hub with it (D6). Every method here is admin-only, and
|
|
10267
|
-
* must stay so.
|
|
10268
|
-
*/
|
|
10269
10363
|
/** Where a block runs. The operator chooses — a block driving a device on an
|
|
10270
10364
|
* agent is the reason placement is not fixed to the hub. */
|
|
10271
10365
|
var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
|
|
@@ -10337,6 +10431,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
10337
10431
|
}), object({ block: CoreBlockSchema }), {
|
|
10338
10432
|
kind: "mutation",
|
|
10339
10433
|
auth: "admin"
|
|
10434
|
+
}), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
|
|
10435
|
+
kind: "mutation",
|
|
10436
|
+
auth: "admin"
|
|
10340
10437
|
}), method(object({ code: string() }), CoreBlockCompileResultSchema, {
|
|
10341
10438
|
kind: "mutation",
|
|
10342
10439
|
auth: "admin"
|
|
@@ -11096,601 +11193,6 @@ var deviceExportCapability = {
|
|
|
11096
11193
|
unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
|
|
11097
11194
|
}
|
|
11098
11195
|
};
|
|
11099
|
-
/**
|
|
11100
|
-
* Resource-bound constants for the safe expression engine.
|
|
11101
|
-
*
|
|
11102
|
-
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
11103
|
-
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
11104
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
11105
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
11106
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
11107
|
-
*/
|
|
11108
|
-
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
11109
|
-
* rejected without allocation. */
|
|
11110
|
-
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
11111
|
-
/** A legal binding / identifier name. */
|
|
11112
|
-
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11113
|
-
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
11114
|
-
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
11115
|
-
var RESERVED_BINDING_NAMES = new Set([
|
|
11116
|
-
"now",
|
|
11117
|
-
"true",
|
|
11118
|
-
"false",
|
|
11119
|
-
"null"
|
|
11120
|
-
]);
|
|
11121
|
-
/**
|
|
11122
|
-
* Error types for the safe expression engine. Two distinct classes so callers
|
|
11123
|
-
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
11124
|
-
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
11125
|
-
*/
|
|
11126
|
-
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
11127
|
-
* the failure is anchored to a character (author-facing inline feedback). */
|
|
11128
|
-
var ExpressionParseError = class extends Error {
|
|
11129
|
-
position;
|
|
11130
|
-
constructor(message, position) {
|
|
11131
|
-
super(message);
|
|
11132
|
-
this.name = "ExpressionParseError";
|
|
11133
|
-
this.position = position;
|
|
11134
|
-
}
|
|
11135
|
-
};
|
|
11136
|
-
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
11137
|
-
* result, unknown builtin, step-budget exceeded). */
|
|
11138
|
-
var ExpressionEvalError = class extends Error {
|
|
11139
|
-
constructor(message) {
|
|
11140
|
-
super(message);
|
|
11141
|
-
this.name = "ExpressionEvalError";
|
|
11142
|
-
}
|
|
11143
|
-
};
|
|
11144
|
-
/**
|
|
11145
|
-
* Frozen, null-prototype builtin function table for the expression engine
|
|
11146
|
-
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
11147
|
-
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
11148
|
-
* own-property check against it.
|
|
11149
|
-
*
|
|
11150
|
-
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
11151
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
11152
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
11153
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
11154
|
-
* callable — they are simply "unknown function" at parse time.
|
|
11155
|
-
*
|
|
11156
|
-
* Every numeric argument is validated as a finite number and every numeric
|
|
11157
|
-
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
11158
|
-
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
11159
|
-
* closed rather than emitting a garbage value.
|
|
11160
|
-
*/
|
|
11161
|
-
function asFiniteNumber(value, name, index) {
|
|
11162
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
11163
|
-
return value;
|
|
11164
|
-
}
|
|
11165
|
-
function asString$1(value, name, index) {
|
|
11166
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
11167
|
-
return value;
|
|
11168
|
-
}
|
|
11169
|
-
function finiteResult(value, name) {
|
|
11170
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
11171
|
-
return value;
|
|
11172
|
-
}
|
|
11173
|
-
function allFiniteNumbers(args, name) {
|
|
11174
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
11175
|
-
}
|
|
11176
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
11177
|
-
var table = {
|
|
11178
|
-
min: {
|
|
11179
|
-
minArgs: 1,
|
|
11180
|
-
maxArgs: INF,
|
|
11181
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
11182
|
-
},
|
|
11183
|
-
max: {
|
|
11184
|
-
minArgs: 1,
|
|
11185
|
-
maxArgs: INF,
|
|
11186
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
11187
|
-
},
|
|
11188
|
-
abs: {
|
|
11189
|
-
minArgs: 1,
|
|
11190
|
-
maxArgs: 1,
|
|
11191
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
11192
|
-
},
|
|
11193
|
-
floor: {
|
|
11194
|
-
minArgs: 1,
|
|
11195
|
-
maxArgs: 1,
|
|
11196
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
11197
|
-
},
|
|
11198
|
-
ceil: {
|
|
11199
|
-
minArgs: 1,
|
|
11200
|
-
maxArgs: 1,
|
|
11201
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
11202
|
-
},
|
|
11203
|
-
sqrt: {
|
|
11204
|
-
minArgs: 1,
|
|
11205
|
-
maxArgs: 1,
|
|
11206
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
11207
|
-
},
|
|
11208
|
-
round: {
|
|
11209
|
-
minArgs: 1,
|
|
11210
|
-
maxArgs: 2,
|
|
11211
|
-
apply: (args) => {
|
|
11212
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
11213
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
11214
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
11215
|
-
const factor = 10 ** digits;
|
|
11216
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
11217
|
-
}
|
|
11218
|
-
},
|
|
11219
|
-
pow: {
|
|
11220
|
-
minArgs: 2,
|
|
11221
|
-
maxArgs: 2,
|
|
11222
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
11223
|
-
},
|
|
11224
|
-
clamp: {
|
|
11225
|
-
minArgs: 3,
|
|
11226
|
-
maxArgs: 3,
|
|
11227
|
-
apply: (args) => {
|
|
11228
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
11229
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
11230
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
11231
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
11232
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
11233
|
-
}
|
|
11234
|
-
},
|
|
11235
|
-
avg: {
|
|
11236
|
-
minArgs: 1,
|
|
11237
|
-
maxArgs: INF,
|
|
11238
|
-
apply: (args) => {
|
|
11239
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
11240
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
11241
|
-
}
|
|
11242
|
-
},
|
|
11243
|
-
sum: {
|
|
11244
|
-
minArgs: 1,
|
|
11245
|
-
maxArgs: INF,
|
|
11246
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
11247
|
-
},
|
|
11248
|
-
coalesce: {
|
|
11249
|
-
minArgs: 1,
|
|
11250
|
-
maxArgs: INF,
|
|
11251
|
-
apply: (args) => {
|
|
11252
|
-
for (const a of args) if (a !== null) return a;
|
|
11253
|
-
return null;
|
|
11254
|
-
}
|
|
11255
|
-
},
|
|
11256
|
-
age: {
|
|
11257
|
-
minArgs: 2,
|
|
11258
|
-
maxArgs: 2,
|
|
11259
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
11260
|
-
},
|
|
11261
|
-
convert: {
|
|
11262
|
-
minArgs: 3,
|
|
11263
|
-
maxArgs: 3,
|
|
11264
|
-
apply: (args, hooks) => {
|
|
11265
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
11266
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
11267
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
11268
|
-
if (hooks.convert) {
|
|
11269
|
-
const out = hooks.convert(x, from, to);
|
|
11270
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
11271
|
-
return finiteResult(out, "convert");
|
|
11272
|
-
}
|
|
11273
|
-
if (from === to) return x;
|
|
11274
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
11275
|
-
}
|
|
11276
|
-
}
|
|
11277
|
-
};
|
|
11278
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
11279
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
11280
|
-
* callees at parse time (immediate author feedback). */
|
|
11281
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
11282
|
-
/**
|
|
11283
|
-
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
11284
|
-
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
11285
|
-
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
11286
|
-
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
11287
|
-
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
11288
|
-
* is a parse error with a source position, so member access / assignment /
|
|
11289
|
-
* template literals are lexically impossible.
|
|
11290
|
-
*/
|
|
11291
|
-
var KEYWORDS = new Set([
|
|
11292
|
-
"true",
|
|
11293
|
-
"false",
|
|
11294
|
-
"null"
|
|
11295
|
-
]);
|
|
11296
|
-
function isDigit(ch) {
|
|
11297
|
-
return ch >= "0" && ch <= "9";
|
|
11298
|
-
}
|
|
11299
|
-
function isIdentStart(ch) {
|
|
11300
|
-
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
11301
|
-
}
|
|
11302
|
-
function isIdentPart(ch) {
|
|
11303
|
-
return isIdentStart(ch) || isDigit(ch);
|
|
11304
|
-
}
|
|
11305
|
-
function isWhitespace(ch) {
|
|
11306
|
-
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
11307
|
-
}
|
|
11308
|
-
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
11309
|
-
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
11310
|
-
* string. */
|
|
11311
|
-
function tokenize(source) {
|
|
11312
|
-
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
11313
|
-
const tokens = [];
|
|
11314
|
-
let i = 0;
|
|
11315
|
-
const n = source.length;
|
|
11316
|
-
while (i < n) {
|
|
11317
|
-
const ch = source[i];
|
|
11318
|
-
if (isWhitespace(ch)) {
|
|
11319
|
-
i += 1;
|
|
11320
|
-
continue;
|
|
11321
|
-
}
|
|
11322
|
-
if (isDigit(ch)) {
|
|
11323
|
-
const start = i;
|
|
11324
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
11325
|
-
if (i < n && source[i] === ".") {
|
|
11326
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
11327
|
-
i += 1;
|
|
11328
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
11329
|
-
}
|
|
11330
|
-
const text = source.slice(start, i);
|
|
11331
|
-
const value = Number(text);
|
|
11332
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
11333
|
-
tokens.push({
|
|
11334
|
-
type: "number",
|
|
11335
|
-
value,
|
|
11336
|
-
pos: start
|
|
11337
|
-
});
|
|
11338
|
-
continue;
|
|
11339
|
-
}
|
|
11340
|
-
if (ch === "'" || ch === "\"") {
|
|
11341
|
-
const quote = ch;
|
|
11342
|
-
const start = i;
|
|
11343
|
-
i += 1;
|
|
11344
|
-
let out = "";
|
|
11345
|
-
let closed = false;
|
|
11346
|
-
while (i < n) {
|
|
11347
|
-
const c = source[i];
|
|
11348
|
-
if (c === "\\") {
|
|
11349
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
11350
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
11351
|
-
out += next;
|
|
11352
|
-
i += 2;
|
|
11353
|
-
continue;
|
|
11354
|
-
}
|
|
11355
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
11356
|
-
}
|
|
11357
|
-
if (c === quote) {
|
|
11358
|
-
closed = true;
|
|
11359
|
-
i += 1;
|
|
11360
|
-
break;
|
|
11361
|
-
}
|
|
11362
|
-
out += c;
|
|
11363
|
-
i += 1;
|
|
11364
|
-
}
|
|
11365
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
11366
|
-
tokens.push({
|
|
11367
|
-
type: "string",
|
|
11368
|
-
value: out,
|
|
11369
|
-
pos: start
|
|
11370
|
-
});
|
|
11371
|
-
continue;
|
|
11372
|
-
}
|
|
11373
|
-
if (isIdentStart(ch)) {
|
|
11374
|
-
const start = i;
|
|
11375
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
11376
|
-
const text = source.slice(start, i);
|
|
11377
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
11378
|
-
type: "keyword",
|
|
11379
|
-
keyword: keywordOf(text),
|
|
11380
|
-
pos: start
|
|
11381
|
-
});
|
|
11382
|
-
else tokens.push({
|
|
11383
|
-
type: "identifier",
|
|
11384
|
-
name: text,
|
|
11385
|
-
pos: start
|
|
11386
|
-
});
|
|
11387
|
-
continue;
|
|
11388
|
-
}
|
|
11389
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
11390
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
11391
|
-
tokens.push({
|
|
11392
|
-
type: "punct",
|
|
11393
|
-
punct: two,
|
|
11394
|
-
pos: i
|
|
11395
|
-
});
|
|
11396
|
-
i += 2;
|
|
11397
|
-
continue;
|
|
11398
|
-
}
|
|
11399
|
-
if (isSinglePunct(ch)) {
|
|
11400
|
-
tokens.push({
|
|
11401
|
-
type: "punct",
|
|
11402
|
-
punct: ch,
|
|
11403
|
-
pos: i
|
|
11404
|
-
});
|
|
11405
|
-
i += 1;
|
|
11406
|
-
continue;
|
|
11407
|
-
}
|
|
11408
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
11409
|
-
}
|
|
11410
|
-
tokens.push({
|
|
11411
|
-
type: "eof",
|
|
11412
|
-
pos: n
|
|
11413
|
-
});
|
|
11414
|
-
return tokens;
|
|
11415
|
-
}
|
|
11416
|
-
function keywordOf(text) {
|
|
11417
|
-
if (text === "true") return "true";
|
|
11418
|
-
if (text === "false") return "false";
|
|
11419
|
-
return "null";
|
|
11420
|
-
}
|
|
11421
|
-
function isSinglePunct(ch) {
|
|
11422
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
11423
|
-
}
|
|
11424
|
-
/**
|
|
11425
|
-
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
11426
|
-
*
|
|
11427
|
-
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
11428
|
-
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
11429
|
-
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
11430
|
-
* string validated against the builtin table at parse time, so an unknown
|
|
11431
|
-
* function is rejected immediately (author feedback) and a persisted expression
|
|
11432
|
-
* that references a since-removed builtin degrades at read.
|
|
11433
|
-
*
|
|
11434
|
-
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
11435
|
-
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
11436
|
-
*/
|
|
11437
|
-
/** Binary/logical operator precedence (higher binds tighter). */
|
|
11438
|
-
var BINARY_PRECEDENCE = {
|
|
11439
|
-
"||": 1,
|
|
11440
|
-
"&&": 2,
|
|
11441
|
-
"==": 3,
|
|
11442
|
-
"!=": 3,
|
|
11443
|
-
"<": 4,
|
|
11444
|
-
"<=": 4,
|
|
11445
|
-
">": 4,
|
|
11446
|
-
">=": 4,
|
|
11447
|
-
"+": 5,
|
|
11448
|
-
"-": 5,
|
|
11449
|
-
"*": 6,
|
|
11450
|
-
"/": 6,
|
|
11451
|
-
"%": 6
|
|
11452
|
-
};
|
|
11453
|
-
function isLogicalOp(op) {
|
|
11454
|
-
return op === "&&" || op === "||";
|
|
11455
|
-
}
|
|
11456
|
-
function isBinaryOp(op) {
|
|
11457
|
-
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
11458
|
-
}
|
|
11459
|
-
var Parser = class {
|
|
11460
|
-
tokens;
|
|
11461
|
-
pos = 0;
|
|
11462
|
-
nodeCount = 0;
|
|
11463
|
-
identifiers = /* @__PURE__ */ new Set();
|
|
11464
|
-
callees = /* @__PURE__ */ new Set();
|
|
11465
|
-
constructor(tokens) {
|
|
11466
|
-
this.tokens = tokens;
|
|
11467
|
-
}
|
|
11468
|
-
parse() {
|
|
11469
|
-
const ast = this.parseTernary();
|
|
11470
|
-
const tok = this.peek();
|
|
11471
|
-
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
11472
|
-
return {
|
|
11473
|
-
ast,
|
|
11474
|
-
identifiers: this.identifiers,
|
|
11475
|
-
callees: this.callees,
|
|
11476
|
-
nodeCount: this.nodeCount
|
|
11477
|
-
};
|
|
11478
|
-
}
|
|
11479
|
-
peek() {
|
|
11480
|
-
return this.tokens[this.pos];
|
|
11481
|
-
}
|
|
11482
|
-
next() {
|
|
11483
|
-
return this.tokens[this.pos++];
|
|
11484
|
-
}
|
|
11485
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
11486
|
-
expectPunct(punct) {
|
|
11487
|
-
const tok = this.peek();
|
|
11488
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
11489
|
-
this.pos += 1;
|
|
11490
|
-
}
|
|
11491
|
-
matchPunct(punct) {
|
|
11492
|
-
const tok = this.peek();
|
|
11493
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
11494
|
-
this.pos += 1;
|
|
11495
|
-
return true;
|
|
11496
|
-
}
|
|
11497
|
-
return false;
|
|
11498
|
-
}
|
|
11499
|
-
countNode() {
|
|
11500
|
-
this.nodeCount += 1;
|
|
11501
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
11502
|
-
}
|
|
11503
|
-
parseTernary() {
|
|
11504
|
-
const test = this.parseBinary(1);
|
|
11505
|
-
if (this.matchPunct("?")) {
|
|
11506
|
-
const consequent = this.parseTernary();
|
|
11507
|
-
this.expectPunct(":");
|
|
11508
|
-
const alternate = this.parseTernary();
|
|
11509
|
-
this.countNode();
|
|
11510
|
-
return {
|
|
11511
|
-
kind: "conditional",
|
|
11512
|
-
test,
|
|
11513
|
-
consequent,
|
|
11514
|
-
alternate
|
|
11515
|
-
};
|
|
11516
|
-
}
|
|
11517
|
-
return test;
|
|
11518
|
-
}
|
|
11519
|
-
parseBinary(minPrec) {
|
|
11520
|
-
let left = this.parseUnary();
|
|
11521
|
-
for (;;) {
|
|
11522
|
-
const tok = this.peek();
|
|
11523
|
-
if (tok.type !== "punct") break;
|
|
11524
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
11525
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
11526
|
-
const op = tok.punct;
|
|
11527
|
-
this.pos += 1;
|
|
11528
|
-
const right = this.parseBinary(prec + 1);
|
|
11529
|
-
this.countNode();
|
|
11530
|
-
if (isLogicalOp(op)) left = {
|
|
11531
|
-
kind: "logical",
|
|
11532
|
-
op,
|
|
11533
|
-
left,
|
|
11534
|
-
right
|
|
11535
|
-
};
|
|
11536
|
-
else if (isBinaryOp(op)) left = {
|
|
11537
|
-
kind: "binary",
|
|
11538
|
-
op,
|
|
11539
|
-
left,
|
|
11540
|
-
right
|
|
11541
|
-
};
|
|
11542
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
11543
|
-
}
|
|
11544
|
-
return left;
|
|
11545
|
-
}
|
|
11546
|
-
parseUnary() {
|
|
11547
|
-
const tok = this.peek();
|
|
11548
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
11549
|
-
const op = tok.punct;
|
|
11550
|
-
this.pos += 1;
|
|
11551
|
-
const operand = this.parseUnary();
|
|
11552
|
-
this.countNode();
|
|
11553
|
-
return {
|
|
11554
|
-
kind: "unary",
|
|
11555
|
-
op,
|
|
11556
|
-
operand
|
|
11557
|
-
};
|
|
11558
|
-
}
|
|
11559
|
-
return this.parsePrimary();
|
|
11560
|
-
}
|
|
11561
|
-
parsePrimary() {
|
|
11562
|
-
const tok = this.next();
|
|
11563
|
-
switch (tok.type) {
|
|
11564
|
-
case "number":
|
|
11565
|
-
this.countNode();
|
|
11566
|
-
return {
|
|
11567
|
-
kind: "literal",
|
|
11568
|
-
value: tok.value
|
|
11569
|
-
};
|
|
11570
|
-
case "string":
|
|
11571
|
-
this.countNode();
|
|
11572
|
-
return {
|
|
11573
|
-
kind: "literal",
|
|
11574
|
-
value: tok.value
|
|
11575
|
-
};
|
|
11576
|
-
case "keyword":
|
|
11577
|
-
this.countNode();
|
|
11578
|
-
return {
|
|
11579
|
-
kind: "literal",
|
|
11580
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
11581
|
-
};
|
|
11582
|
-
case "identifier": {
|
|
11583
|
-
const nextTok = this.peek();
|
|
11584
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
11585
|
-
this.identifiers.add(tok.name);
|
|
11586
|
-
this.countNode();
|
|
11587
|
-
return {
|
|
11588
|
-
kind: "identifier",
|
|
11589
|
-
name: tok.name
|
|
11590
|
-
};
|
|
11591
|
-
}
|
|
11592
|
-
case "punct":
|
|
11593
|
-
if (tok.punct === "(") {
|
|
11594
|
-
const inner = this.parseTernary();
|
|
11595
|
-
this.expectPunct(")");
|
|
11596
|
-
return inner;
|
|
11597
|
-
}
|
|
11598
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
11599
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
11600
|
-
}
|
|
11601
|
-
}
|
|
11602
|
-
parseCall(callee, pos) {
|
|
11603
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
11604
|
-
this.expectPunct("(");
|
|
11605
|
-
const args = [];
|
|
11606
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
11607
|
-
args.push(this.parseTernary());
|
|
11608
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
11609
|
-
if (this.matchPunct(",")) continue;
|
|
11610
|
-
this.expectPunct(")");
|
|
11611
|
-
break;
|
|
11612
|
-
}
|
|
11613
|
-
this.callees.add(callee);
|
|
11614
|
-
this.countNode();
|
|
11615
|
-
return {
|
|
11616
|
-
kind: "call",
|
|
11617
|
-
callee,
|
|
11618
|
-
args
|
|
11619
|
-
};
|
|
11620
|
-
}
|
|
11621
|
-
};
|
|
11622
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
11623
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
11624
|
-
function parseExpression(source) {
|
|
11625
|
-
return new Parser(tokenize(source)).parse();
|
|
11626
|
-
}
|
|
11627
|
-
/**
|
|
11628
|
-
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
11629
|
-
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
11630
|
-
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
11631
|
-
* one per read on a hot resolve path.
|
|
11632
|
-
*
|
|
11633
|
-
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
11634
|
-
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
11635
|
-
* callers is safe and maximises hit rate.
|
|
11636
|
-
*/
|
|
11637
|
-
var cache = /* @__PURE__ */ new Map();
|
|
11638
|
-
function getCached(source) {
|
|
11639
|
-
const hit = cache.get(source);
|
|
11640
|
-
if (hit !== void 0) {
|
|
11641
|
-
cache.delete(source);
|
|
11642
|
-
cache.set(source, hit);
|
|
11643
|
-
return hit;
|
|
11644
|
-
}
|
|
11645
|
-
let result;
|
|
11646
|
-
try {
|
|
11647
|
-
result = {
|
|
11648
|
-
ok: true,
|
|
11649
|
-
parsed: parseExpression(source)
|
|
11650
|
-
};
|
|
11651
|
-
} catch (err) {
|
|
11652
|
-
result = {
|
|
11653
|
-
ok: false,
|
|
11654
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
11655
|
-
};
|
|
11656
|
-
}
|
|
11657
|
-
cache.set(source, result);
|
|
11658
|
-
if (cache.size > 256) {
|
|
11659
|
-
const oldest = cache.keys().next().value;
|
|
11660
|
-
if (oldest !== void 0) cache.delete(oldest);
|
|
11661
|
-
}
|
|
11662
|
-
return result;
|
|
11663
|
-
}
|
|
11664
|
-
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
11665
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
11666
|
-
function compileExpressionSafe(source) {
|
|
11667
|
-
return getCached(source);
|
|
11668
|
-
}
|
|
11669
|
-
Object.freeze({});
|
|
11670
|
-
/**
|
|
11671
|
-
* Author-time validation. Returns `null` when the source is valid, else a
|
|
11672
|
-
* human-readable error message. Checks: the expression compiles; binding count
|
|
11673
|
-
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
11674
|
-
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
11675
|
-
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
11676
|
-
*/
|
|
11677
|
-
function validateExpressionSource(src) {
|
|
11678
|
-
const names = Object.keys(src.bindings);
|
|
11679
|
-
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
11680
|
-
for (const name of names) {
|
|
11681
|
-
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
11682
|
-
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
11683
|
-
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
11684
|
-
}
|
|
11685
|
-
const compiled = compileExpressionSafe(src.expr);
|
|
11686
|
-
if (!compiled.ok) return compiled.error;
|
|
11687
|
-
const bound = new Set(names);
|
|
11688
|
-
for (const id of compiled.parsed.identifiers) {
|
|
11689
|
-
if (id === "now") continue;
|
|
11690
|
-
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
11691
|
-
}
|
|
11692
|
-
return null;
|
|
11693
|
-
}
|
|
11694
11196
|
var ProviderStatusSchema = object({
|
|
11695
11197
|
connected: boolean(),
|
|
11696
11198
|
deviceCount: number(),
|
|
@@ -11815,92 +11317,6 @@ var ChildLayoutEntrySchema = object({
|
|
|
11815
11317
|
order: number().optional(),
|
|
11816
11318
|
collapsed: boolean().optional()
|
|
11817
11319
|
});
|
|
11818
|
-
/** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
|
|
11819
|
-
* `device-management.ts`. Source is a union: a FIELD source copies a sibling
|
|
11820
|
-
* accessory's status field (`kind` optional/absent for wire compat); a
|
|
11821
|
-
* LITERAL source carries a per-device constant (no sibling is read); a
|
|
11822
|
-
* GLOBAL source (P2e) copies ANY device's status field, addressed by the
|
|
11823
|
-
* source device's full re-sync-stable `stableId`. */
|
|
11824
|
-
var DeviceLinkFieldSourceSchema = object({
|
|
11825
|
-
kind: literal("field").optional(),
|
|
11826
|
-
sourceKey: string(),
|
|
11827
|
-
cap: string(),
|
|
11828
|
-
fieldPath: string()
|
|
11829
|
-
});
|
|
11830
|
-
var DeviceLinkLiteralSourceSchema = object({
|
|
11831
|
-
kind: literal("literal"),
|
|
11832
|
-
value: union([
|
|
11833
|
-
string(),
|
|
11834
|
-
number(),
|
|
11835
|
-
boolean(),
|
|
11836
|
-
_null()
|
|
11837
|
-
])
|
|
11838
|
-
});
|
|
11839
|
-
var DeviceLinkGlobalSourceSchema = object({
|
|
11840
|
-
kind: literal("global"),
|
|
11841
|
-
sourceStableId: string(),
|
|
11842
|
-
cap: string(),
|
|
11843
|
-
fieldPath: string()
|
|
11844
|
-
});
|
|
11845
|
-
/** Expression source (Stage X): compute the target field from N named bindings
|
|
11846
|
-
* via the safe expression engine. Bindings are field | literal | global — never
|
|
11847
|
-
* another expression (no nesting). The `superRefine` runs the SAME author-time
|
|
11848
|
-
* validation as `validateExpressionSource` (compiles the expr, checks binding
|
|
11849
|
-
* names + identifier coverage) so every wire boundary that parses a DeviceLink
|
|
11850
|
-
* (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
|
|
11851
|
-
* Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
|
|
11852
|
-
var DeviceLinkExpressionSourceSchema = object({
|
|
11853
|
-
kind: literal("expression"),
|
|
11854
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
11855
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
|
|
11856
|
-
DeviceLinkFieldSourceSchema,
|
|
11857
|
-
DeviceLinkLiteralSourceSchema,
|
|
11858
|
-
DeviceLinkGlobalSourceSchema
|
|
11859
|
-
]))
|
|
11860
|
-
}).superRefine((src, ctx) => {
|
|
11861
|
-
const err = validateExpressionSource(src);
|
|
11862
|
-
if (err !== null) ctx.addIssue({
|
|
11863
|
-
code: "custom",
|
|
11864
|
-
message: err,
|
|
11865
|
-
path: ["expr"]
|
|
11866
|
-
});
|
|
11867
|
-
});
|
|
11868
|
-
var DeviceLinkSchema = object({
|
|
11869
|
-
id: string(),
|
|
11870
|
-
source: union([
|
|
11871
|
-
DeviceLinkFieldSourceSchema,
|
|
11872
|
-
DeviceLinkLiteralSourceSchema,
|
|
11873
|
-
DeviceLinkGlobalSourceSchema,
|
|
11874
|
-
DeviceLinkExpressionSourceSchema
|
|
11875
|
-
]),
|
|
11876
|
-
target: object({
|
|
11877
|
-
cap: string(),
|
|
11878
|
-
fieldPath: string(),
|
|
11879
|
-
itemKey: string().optional()
|
|
11880
|
-
}),
|
|
11881
|
-
transform: discriminatedUnion("kind", [
|
|
11882
|
-
object({ kind: literal("identity") }),
|
|
11883
|
-
object({
|
|
11884
|
-
kind: literal("enum-map"),
|
|
11885
|
-
mapping: record(string(), union([
|
|
11886
|
-
string(),
|
|
11887
|
-
number(),
|
|
11888
|
-
boolean()
|
|
11889
|
-
])),
|
|
11890
|
-
fallback: union([
|
|
11891
|
-
string(),
|
|
11892
|
-
number(),
|
|
11893
|
-
boolean()
|
|
11894
|
-
]).optional()
|
|
11895
|
-
}),
|
|
11896
|
-
object({
|
|
11897
|
-
kind: literal("linear"),
|
|
11898
|
-
scale: number(),
|
|
11899
|
-
offset: number(),
|
|
11900
|
-
clamp: tuple([number(), number()]).readonly().optional()
|
|
11901
|
-
})
|
|
11902
|
-
]).optional()
|
|
11903
|
-
});
|
|
11904
11320
|
/** Cap-wire shape of a per-cap display refinement — mirrors
|
|
11905
11321
|
* `DeviceCapDisplayOverride` in `device-management.ts`. */
|
|
11906
11322
|
var DeviceCapDisplayOverrideSchema = object({
|
|
@@ -11980,8 +11396,6 @@ var DeviceInfoSchema = object({
|
|
|
11980
11396
|
* named accordion sections (with optional intra-section order). See
|
|
11981
11397
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
11982
11398
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
11983
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
11984
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
11985
11399
|
/** Operator-authored per-device display override. See `DeviceMeta.display`. */
|
|
11986
11400
|
display: DeviceDisplayOverrideSchema.optional()
|
|
11987
11401
|
});
|
|
@@ -11990,7 +11404,7 @@ var ConfigEntrySchema = object({
|
|
|
11990
11404
|
value: unknown(),
|
|
11991
11405
|
description: string().optional()
|
|
11992
11406
|
});
|
|
11993
|
-
var
|
|
11407
|
+
var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
|
|
11994
11408
|
/** One resolved linked device — the compact projection consumers need. */
|
|
11995
11409
|
var LinkedDeviceSchema = object({
|
|
11996
11410
|
deviceId: number(),
|
|
@@ -12053,8 +11467,6 @@ var DeviceMetaSchema = object({
|
|
|
12053
11467
|
* accordion sections (with optional intra-section order). See
|
|
12054
11468
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
12055
11469
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
12056
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
12057
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
12058
11470
|
/** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
|
|
12059
11471
|
* Optional: only present for accessory children that carry a known role. */
|
|
12060
11472
|
role: string().nullable().optional(),
|
|
@@ -12147,12 +11559,6 @@ method(object({
|
|
|
12147
11559
|
}), _void(), {
|
|
12148
11560
|
kind: "mutation",
|
|
12149
11561
|
auth: "admin"
|
|
12150
|
-
}), method(object({
|
|
12151
|
-
deviceId: number(),
|
|
12152
|
-
deviceLinks: array(DeviceLinkSchema).readonly()
|
|
12153
|
-
}), _void(), {
|
|
12154
|
-
kind: "mutation",
|
|
12155
|
-
auth: "admin"
|
|
12156
11562
|
}), method(object({
|
|
12157
11563
|
deviceId: number(),
|
|
12158
11564
|
display: DeviceDisplayOverrideSchema.nullable()
|
|
@@ -12234,7 +11640,7 @@ method(object({
|
|
|
12234
11640
|
* shipping 293 rows to find 12. */
|
|
12235
11641
|
isCamera: boolean().optional()
|
|
12236
11642
|
}), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
|
|
12237
|
-
mode:
|
|
11643
|
+
mode: LinkedDevicesModeSchema,
|
|
12238
11644
|
devices: array(LinkedDeviceSchema)
|
|
12239
11645
|
})), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
|
|
12240
11646
|
deviceId: number(),
|
|
@@ -12267,11 +11673,7 @@ method(object({
|
|
|
12267
11673
|
deviceId: number(),
|
|
12268
11674
|
entries: array(object({
|
|
12269
11675
|
capName: string(),
|
|
12270
|
-
kind: _enum([
|
|
12271
|
-
"native",
|
|
12272
|
-
"wrapped",
|
|
12273
|
-
"linked"
|
|
12274
|
-
]),
|
|
11676
|
+
kind: _enum(["native", "wrapped"]),
|
|
12275
11677
|
providerAddonId: string(),
|
|
12276
11678
|
providerNodeId: string(),
|
|
12277
11679
|
nativeAddonId: string()
|
|
@@ -12280,11 +11682,7 @@ method(object({
|
|
|
12280
11682
|
deviceId: number(),
|
|
12281
11683
|
entries: array(object({
|
|
12282
11684
|
capName: string(),
|
|
12283
|
-
kind: _enum([
|
|
12284
|
-
"native",
|
|
12285
|
-
"wrapped",
|
|
12286
|
-
"linked"
|
|
12287
|
-
]),
|
|
11685
|
+
kind: _enum(["native", "wrapped"]),
|
|
12288
11686
|
providerAddonId: string(),
|
|
12289
11687
|
providerNodeId: string(),
|
|
12290
11688
|
nativeAddonId: string()
|
|
@@ -13087,7 +12485,7 @@ var MotionAnalysisResultSchema = object({
|
|
|
13087
12485
|
frameHeight: number(),
|
|
13088
12486
|
analysisMs: number()
|
|
13089
12487
|
});
|
|
13090
|
-
method(object({
|
|
12488
|
+
DeviceType.Camera, method(object({
|
|
13091
12489
|
deviceId: number(),
|
|
13092
12490
|
frame: FrameInputSchema.optional(),
|
|
13093
12491
|
frameHandle: FrameHandleSchema.optional()
|
|
@@ -15276,6 +14674,18 @@ var OauthIntegrationDescriptorSchema = object({
|
|
|
15276
14674
|
* redirect_uri that does not start with one of these. Required —
|
|
15277
14675
|
* an empty list means the integration can never complete linking. */
|
|
15278
14676
|
allowedRedirectPrefixes: array(string()).min(1),
|
|
14677
|
+
/** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
|
|
14678
|
+
* RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
|
|
14679
|
+
* `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
|
|
14680
|
+
* whose address the hub cannot know in advance (a Home Assistant at
|
|
14681
|
+
* `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
|
|
14682
|
+
* exactly; a public host never satisfies this branch, so it is not a
|
|
14683
|
+
* wildcard prefix by another name. */
|
|
14684
|
+
allowedPrivateHostPaths: array(string()).optional(),
|
|
14685
|
+
/** When true this is a PUBLIC client (source is published, no secret can be
|
|
14686
|
+
* protected) and PKCE is mandatory: `/authorize` refuses without an S256
|
|
14687
|
+
* `code_challenge`, `/token` refuses without the matching `code_verifier`. */
|
|
14688
|
+
requiresPkce: boolean().optional(),
|
|
15279
14689
|
/** Optional public origin (no trailing slash) that this integration's
|
|
15280
14690
|
* issued codes/tokens should carry as the `hubUrl` claim — typically the
|
|
15281
14691
|
* operator-selected external-access endpoint resolved by the addon. When
|
|
@@ -15426,7 +14836,7 @@ var TrackEnvelopeSchema = object({
|
|
|
15426
14836
|
* `snapshots[]` references — megabytes across a page of tracks. `slim`
|
|
15427
14837
|
* keeps every scalar the list surfaces actually render (ids, class(es),
|
|
15428
14838
|
* label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
|
|
15429
|
-
* zonesVisited, bestEventId, envelope) and returns `positions` /
|
|
14839
|
+
* zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
|
|
15430
14840
|
* `snapshots` as EMPTY arrays — detail views re-fetch the full row via
|
|
15431
14841
|
* `getTrack`. Mirrors the event-store `projection` convention
|
|
15432
14842
|
* (`getObjectEvents` et al.).
|
|
@@ -15663,6 +15073,24 @@ var TrackSchema = object({
|
|
|
15663
15073
|
* Populated from the persisted envelope columns on historical reads;
|
|
15664
15074
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
15665
15075
|
envelope: TrackEnvelopeSchema.optional(),
|
|
15076
|
+
/**
|
|
15077
|
+
* A face DETECTOR found a face on this track — nothing more. It says the
|
|
15078
|
+
* detail plane produced a `face` detail; it does NOT say the face was
|
|
15079
|
+
* embedded, matched, above `minFacePx`, or that the recognizer was even
|
|
15080
|
+
* enabled. Set once and never cleared.
|
|
15081
|
+
*
|
|
15082
|
+
* **This exists so "face present but not recognised" is expressible.** A
|
|
15083
|
+
* recognised identity lands in `subLabel` (attributed to the face chain via
|
|
15084
|
+
* `subLabelMeta.stepId`), so before this field a track with an unmatched face
|
|
15085
|
+
* and a track with no face at all were byte-identical on the wire and no
|
|
15086
|
+
* surface could tell them apart. The read is `hasFace === true && subLabel
|
|
15087
|
+
* === undefined`.
|
|
15088
|
+
*
|
|
15089
|
+
* **Absent ≠ false.** Every row written before the column existed omits it,
|
|
15090
|
+
* and so does every server that predates the field — a consumer must test
|
|
15091
|
+
* `=== true` and render nothing otherwise, never infer "no face".
|
|
15092
|
+
*/
|
|
15093
|
+
hasFace: boolean().optional(),
|
|
15666
15094
|
...TrackFlagFields,
|
|
15667
15095
|
...TrackRetrainFields
|
|
15668
15096
|
});
|
|
@@ -18277,6 +17705,10 @@ var SsoBridgeClaimsSchema = object({
|
|
|
18277
17705
|
integrationId: string().optional(),
|
|
18278
17706
|
/** JWT ID — unique per issued code; consumed-set enforces single-use. */
|
|
18279
17707
|
jti: string().optional(),
|
|
17708
|
+
/** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
|
|
17709
|
+
* client. Its PRESENCE is what makes the verifier mandatory at exchange,
|
|
17710
|
+
* so the requirement travels with the code and not with mutable config. */
|
|
17711
|
+
codeChallenge: string().optional(),
|
|
18280
17712
|
/** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
|
|
18281
17713
|
* tokens so the verify path can check the session is not revoked. */
|
|
18282
17714
|
sessionId: string().optional()
|
|
@@ -18829,7 +18261,7 @@ var ClipPlaybackSchema = object({
|
|
|
18829
18261
|
playbackEndpoints: array(string()).optional(),
|
|
18830
18262
|
token: string().optional()
|
|
18831
18263
|
});
|
|
18832
|
-
method(object({
|
|
18264
|
+
DeviceType.Camera, method(object({
|
|
18833
18265
|
deviceId: number(),
|
|
18834
18266
|
since: number(),
|
|
18835
18267
|
until: number(),
|
|
@@ -23905,13 +23337,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
23905
23337
|
username: string(),
|
|
23906
23338
|
scopes: array(TokenScopeSchema),
|
|
23907
23339
|
redirectUri: string(),
|
|
23908
|
-
hubUrl: string()
|
|
23340
|
+
hubUrl: string(),
|
|
23341
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
23342
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
23343
|
+
codeChallenge: string().optional()
|
|
23909
23344
|
}), object({ code: string() }), {
|
|
23910
23345
|
kind: "mutation",
|
|
23911
23346
|
access: "create"
|
|
23912
23347
|
}), method(object({
|
|
23913
23348
|
code: string(),
|
|
23914
|
-
redirectUri: string()
|
|
23349
|
+
redirectUri: string(),
|
|
23350
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
23351
|
+
codeVerifier: string().optional()
|
|
23915
23352
|
}), object({
|
|
23916
23353
|
accessToken: string(),
|
|
23917
23354
|
refreshToken: string(),
|
|
@@ -24251,76 +23688,705 @@ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapsho
|
|
|
24251
23688
|
className: string().optional()
|
|
24252
23689
|
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
|
|
24253
23690
|
/**
|
|
24254
|
-
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
24255
|
-
* cap so a single CRUD surface backs every consumer; each stage has
|
|
24256
|
-
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
24257
|
-
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
23691
|
+
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
23692
|
+
* cap so a single CRUD surface backs every consumer; each stage has
|
|
23693
|
+
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
23694
|
+
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
23695
|
+
*
|
|
23696
|
+
* Extend the enum here when a new gating consumer comes online (audio
|
|
23697
|
+
* gating, alert filtering, …) — no other surface needs to change.
|
|
23698
|
+
*/
|
|
23699
|
+
var ZoneRuleStageEnum = _enum([
|
|
23700
|
+
"motion",
|
|
23701
|
+
"detection",
|
|
23702
|
+
"package"
|
|
23703
|
+
]);
|
|
23704
|
+
DeviceType.Camera, method(object({
|
|
23705
|
+
deviceId: number(),
|
|
23706
|
+
stage: ZoneRuleStageEnum
|
|
23707
|
+
}), array(ZoneRuleSchema).readonly()), method(object({
|
|
23708
|
+
deviceId: number(),
|
|
23709
|
+
stage: ZoneRuleStageEnum,
|
|
23710
|
+
rules: array(ZoneRuleSchema).readonly()
|
|
23711
|
+
}), _void(), {
|
|
23712
|
+
kind: "mutation",
|
|
23713
|
+
auth: "admin"
|
|
23714
|
+
}), object({
|
|
23715
|
+
motion: array(ZoneRuleSchema).readonly(),
|
|
23716
|
+
detection: array(ZoneRuleSchema).readonly(),
|
|
23717
|
+
package: array(ZoneRuleSchema).readonly()
|
|
23718
|
+
});
|
|
23719
|
+
/**
|
|
23720
|
+
* Accessory device helpers — shared across drivers.
|
|
23721
|
+
*
|
|
23722
|
+
* Many vendor-specific drivers register accessory child devices on
|
|
23723
|
+
* top of a parent (Reolink: siren / floodlight / PIR / autotrack /
|
|
23724
|
+
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
23725
|
+
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
23726
|
+
* spawning, builds a name derived from the parent, and produces a
|
|
23727
|
+
* stableId tied to the parent so boot-restore can reconstruct the
|
|
23728
|
+
* relationship.
|
|
23729
|
+
*
|
|
23730
|
+
* Centralised `(kind → DeviceType)` mapping was dropped on purpose:
|
|
23731
|
+
* drivers may reasonably disagree on the right type for an accessory
|
|
23732
|
+
* (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
|
|
23733
|
+
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
23734
|
+
* one canonical mapping was over-prescriptive and added a layer of
|
|
23735
|
+
* indirection without saving meaningful code at call sites — the
|
|
23736
|
+
* driver knows its own hardware best.
|
|
23737
|
+
*/
|
|
23738
|
+
/**
|
|
23739
|
+
* Subset of `DeviceRole` values that drivers register as child
|
|
23740
|
+
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
23741
|
+
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
23742
|
+
* children, so the call site reads as
|
|
23743
|
+
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
23744
|
+
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
23745
|
+
* any role works, including non-accessory ones like Doorbell).
|
|
23746
|
+
*/
|
|
23747
|
+
var AccessoryKind = {
|
|
23748
|
+
Siren: DeviceRole.Siren,
|
|
23749
|
+
Floodlight: DeviceRole.Floodlight,
|
|
23750
|
+
Spotlight: DeviceRole.Spotlight,
|
|
23751
|
+
PirSensor: DeviceRole.PirSensor,
|
|
23752
|
+
Chime: DeviceRole.Chime,
|
|
23753
|
+
Autotrack: DeviceRole.Autotrack,
|
|
23754
|
+
Nightvision: DeviceRole.Nightvision,
|
|
23755
|
+
PrivacyMask: DeviceRole.PrivacyMask
|
|
23756
|
+
};
|
|
23757
|
+
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
23758
|
+
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
|
|
23759
|
+
new Set(Object.values(DeviceType));
|
|
23760
|
+
DeviceFeature.BatteryOperated;
|
|
23761
|
+
/**
|
|
23762
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
23763
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
23764
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
23765
|
+
*/
|
|
23766
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
23767
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
23768
|
+
var ExpressionParseError = class extends Error {
|
|
23769
|
+
position;
|
|
23770
|
+
constructor(message, position) {
|
|
23771
|
+
super(message);
|
|
23772
|
+
this.name = "ExpressionParseError";
|
|
23773
|
+
this.position = position;
|
|
23774
|
+
}
|
|
23775
|
+
};
|
|
23776
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
23777
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
23778
|
+
var ExpressionEvalError = class extends Error {
|
|
23779
|
+
constructor(message) {
|
|
23780
|
+
super(message);
|
|
23781
|
+
this.name = "ExpressionEvalError";
|
|
23782
|
+
}
|
|
23783
|
+
};
|
|
23784
|
+
/**
|
|
23785
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
23786
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
23787
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
23788
|
+
* own-property check against it.
|
|
23789
|
+
*
|
|
23790
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
23791
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
23792
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
23793
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
23794
|
+
* callable — they are simply "unknown function" at parse time.
|
|
23795
|
+
*
|
|
23796
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
23797
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
23798
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
23799
|
+
* closed rather than emitting a garbage value.
|
|
23800
|
+
*/
|
|
23801
|
+
function asFiniteNumber(value, name, index) {
|
|
23802
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
23803
|
+
return value;
|
|
23804
|
+
}
|
|
23805
|
+
function asString$1(value, name, index) {
|
|
23806
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
23807
|
+
return value;
|
|
23808
|
+
}
|
|
23809
|
+
function finiteResult(value, name) {
|
|
23810
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
23811
|
+
return value;
|
|
23812
|
+
}
|
|
23813
|
+
function allFiniteNumbers(args, name) {
|
|
23814
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
23815
|
+
}
|
|
23816
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
23817
|
+
var table = {
|
|
23818
|
+
min: {
|
|
23819
|
+
minArgs: 1,
|
|
23820
|
+
maxArgs: INF,
|
|
23821
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
23822
|
+
},
|
|
23823
|
+
max: {
|
|
23824
|
+
minArgs: 1,
|
|
23825
|
+
maxArgs: INF,
|
|
23826
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
23827
|
+
},
|
|
23828
|
+
abs: {
|
|
23829
|
+
minArgs: 1,
|
|
23830
|
+
maxArgs: 1,
|
|
23831
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
23832
|
+
},
|
|
23833
|
+
floor: {
|
|
23834
|
+
minArgs: 1,
|
|
23835
|
+
maxArgs: 1,
|
|
23836
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
23837
|
+
},
|
|
23838
|
+
ceil: {
|
|
23839
|
+
minArgs: 1,
|
|
23840
|
+
maxArgs: 1,
|
|
23841
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
23842
|
+
},
|
|
23843
|
+
sqrt: {
|
|
23844
|
+
minArgs: 1,
|
|
23845
|
+
maxArgs: 1,
|
|
23846
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
23847
|
+
},
|
|
23848
|
+
round: {
|
|
23849
|
+
minArgs: 1,
|
|
23850
|
+
maxArgs: 2,
|
|
23851
|
+
apply: (args) => {
|
|
23852
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
23853
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
23854
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
23855
|
+
const factor = 10 ** digits;
|
|
23856
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
23857
|
+
}
|
|
23858
|
+
},
|
|
23859
|
+
pow: {
|
|
23860
|
+
minArgs: 2,
|
|
23861
|
+
maxArgs: 2,
|
|
23862
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
23863
|
+
},
|
|
23864
|
+
clamp: {
|
|
23865
|
+
minArgs: 3,
|
|
23866
|
+
maxArgs: 3,
|
|
23867
|
+
apply: (args) => {
|
|
23868
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
23869
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
23870
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
23871
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
23872
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
23873
|
+
}
|
|
23874
|
+
},
|
|
23875
|
+
avg: {
|
|
23876
|
+
minArgs: 1,
|
|
23877
|
+
maxArgs: INF,
|
|
23878
|
+
apply: (args) => {
|
|
23879
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
23880
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
23881
|
+
}
|
|
23882
|
+
},
|
|
23883
|
+
sum: {
|
|
23884
|
+
minArgs: 1,
|
|
23885
|
+
maxArgs: INF,
|
|
23886
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
23887
|
+
},
|
|
23888
|
+
coalesce: {
|
|
23889
|
+
minArgs: 1,
|
|
23890
|
+
maxArgs: INF,
|
|
23891
|
+
apply: (args) => {
|
|
23892
|
+
for (const a of args) if (a !== null) return a;
|
|
23893
|
+
return null;
|
|
23894
|
+
}
|
|
23895
|
+
},
|
|
23896
|
+
age: {
|
|
23897
|
+
minArgs: 2,
|
|
23898
|
+
maxArgs: 2,
|
|
23899
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
23900
|
+
},
|
|
23901
|
+
convert: {
|
|
23902
|
+
minArgs: 3,
|
|
23903
|
+
maxArgs: 3,
|
|
23904
|
+
apply: (args, hooks) => {
|
|
23905
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
23906
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
23907
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
23908
|
+
if (hooks.convert) {
|
|
23909
|
+
const out = hooks.convert(x, from, to);
|
|
23910
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
23911
|
+
return finiteResult(out, "convert");
|
|
23912
|
+
}
|
|
23913
|
+
if (from === to) return x;
|
|
23914
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
23915
|
+
}
|
|
23916
|
+
}
|
|
23917
|
+
};
|
|
23918
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
23919
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
23920
|
+
* callees at parse time (immediate author feedback). */
|
|
23921
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
23922
|
+
/**
|
|
23923
|
+
* Resource-bound constants for the safe expression engine.
|
|
23924
|
+
*
|
|
23925
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
23926
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
23927
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
23928
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
23929
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
23930
|
+
*/
|
|
23931
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
23932
|
+
* rejected without allocation. */
|
|
23933
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
23934
|
+
/** A legal binding / identifier name. */
|
|
23935
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
23936
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
23937
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
23938
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
23939
|
+
"now",
|
|
23940
|
+
"true",
|
|
23941
|
+
"false",
|
|
23942
|
+
"null"
|
|
23943
|
+
]);
|
|
23944
|
+
/**
|
|
23945
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
23946
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
23947
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
23948
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
23949
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
23950
|
+
* is a parse error with a source position, so member access / assignment /
|
|
23951
|
+
* template literals are lexically impossible.
|
|
23952
|
+
*/
|
|
23953
|
+
var KEYWORDS = new Set([
|
|
23954
|
+
"true",
|
|
23955
|
+
"false",
|
|
23956
|
+
"null"
|
|
23957
|
+
]);
|
|
23958
|
+
function isDigit(ch) {
|
|
23959
|
+
return ch >= "0" && ch <= "9";
|
|
23960
|
+
}
|
|
23961
|
+
function isIdentStart(ch) {
|
|
23962
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
23963
|
+
}
|
|
23964
|
+
function isIdentPart(ch) {
|
|
23965
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
23966
|
+
}
|
|
23967
|
+
function isWhitespace(ch) {
|
|
23968
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
23969
|
+
}
|
|
23970
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
23971
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
23972
|
+
* string. */
|
|
23973
|
+
function tokenize(source) {
|
|
23974
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
23975
|
+
const tokens = [];
|
|
23976
|
+
let i = 0;
|
|
23977
|
+
const n = source.length;
|
|
23978
|
+
while (i < n) {
|
|
23979
|
+
const ch = source[i];
|
|
23980
|
+
if (isWhitespace(ch)) {
|
|
23981
|
+
i += 1;
|
|
23982
|
+
continue;
|
|
23983
|
+
}
|
|
23984
|
+
if (isDigit(ch)) {
|
|
23985
|
+
const start = i;
|
|
23986
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
23987
|
+
if (i < n && source[i] === ".") {
|
|
23988
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
23989
|
+
i += 1;
|
|
23990
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
23991
|
+
}
|
|
23992
|
+
const text = source.slice(start, i);
|
|
23993
|
+
const value = Number(text);
|
|
23994
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
23995
|
+
tokens.push({
|
|
23996
|
+
type: "number",
|
|
23997
|
+
value,
|
|
23998
|
+
pos: start
|
|
23999
|
+
});
|
|
24000
|
+
continue;
|
|
24001
|
+
}
|
|
24002
|
+
if (ch === "'" || ch === "\"") {
|
|
24003
|
+
const quote = ch;
|
|
24004
|
+
const start = i;
|
|
24005
|
+
i += 1;
|
|
24006
|
+
let out = "";
|
|
24007
|
+
let closed = false;
|
|
24008
|
+
while (i < n) {
|
|
24009
|
+
const c = source[i];
|
|
24010
|
+
if (c === "\\") {
|
|
24011
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
24012
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
24013
|
+
out += next;
|
|
24014
|
+
i += 2;
|
|
24015
|
+
continue;
|
|
24016
|
+
}
|
|
24017
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
24018
|
+
}
|
|
24019
|
+
if (c === quote) {
|
|
24020
|
+
closed = true;
|
|
24021
|
+
i += 1;
|
|
24022
|
+
break;
|
|
24023
|
+
}
|
|
24024
|
+
out += c;
|
|
24025
|
+
i += 1;
|
|
24026
|
+
}
|
|
24027
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
24028
|
+
tokens.push({
|
|
24029
|
+
type: "string",
|
|
24030
|
+
value: out,
|
|
24031
|
+
pos: start
|
|
24032
|
+
});
|
|
24033
|
+
continue;
|
|
24034
|
+
}
|
|
24035
|
+
if (isIdentStart(ch)) {
|
|
24036
|
+
const start = i;
|
|
24037
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
24038
|
+
const text = source.slice(start, i);
|
|
24039
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
24040
|
+
type: "keyword",
|
|
24041
|
+
keyword: keywordOf(text),
|
|
24042
|
+
pos: start
|
|
24043
|
+
});
|
|
24044
|
+
else tokens.push({
|
|
24045
|
+
type: "identifier",
|
|
24046
|
+
name: text,
|
|
24047
|
+
pos: start
|
|
24048
|
+
});
|
|
24049
|
+
continue;
|
|
24050
|
+
}
|
|
24051
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
24052
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
24053
|
+
tokens.push({
|
|
24054
|
+
type: "punct",
|
|
24055
|
+
punct: two,
|
|
24056
|
+
pos: i
|
|
24057
|
+
});
|
|
24058
|
+
i += 2;
|
|
24059
|
+
continue;
|
|
24060
|
+
}
|
|
24061
|
+
if (isSinglePunct(ch)) {
|
|
24062
|
+
tokens.push({
|
|
24063
|
+
type: "punct",
|
|
24064
|
+
punct: ch,
|
|
24065
|
+
pos: i
|
|
24066
|
+
});
|
|
24067
|
+
i += 1;
|
|
24068
|
+
continue;
|
|
24069
|
+
}
|
|
24070
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
24071
|
+
}
|
|
24072
|
+
tokens.push({
|
|
24073
|
+
type: "eof",
|
|
24074
|
+
pos: n
|
|
24075
|
+
});
|
|
24076
|
+
return tokens;
|
|
24077
|
+
}
|
|
24078
|
+
function keywordOf(text) {
|
|
24079
|
+
if (text === "true") return "true";
|
|
24080
|
+
if (text === "false") return "false";
|
|
24081
|
+
return "null";
|
|
24082
|
+
}
|
|
24083
|
+
function isSinglePunct(ch) {
|
|
24084
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
24085
|
+
}
|
|
24086
|
+
/**
|
|
24087
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
24258
24088
|
*
|
|
24259
|
-
*
|
|
24260
|
-
*
|
|
24089
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
24090
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
24091
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
24092
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
24093
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
24094
|
+
* that references a since-removed builtin degrades at read.
|
|
24095
|
+
*
|
|
24096
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
24097
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
24261
24098
|
*/
|
|
24262
|
-
|
|
24263
|
-
|
|
24264
|
-
"
|
|
24265
|
-
"
|
|
24266
|
-
|
|
24267
|
-
|
|
24268
|
-
|
|
24269
|
-
|
|
24270
|
-
|
|
24271
|
-
|
|
24272
|
-
|
|
24273
|
-
|
|
24274
|
-
|
|
24275
|
-
|
|
24276
|
-
|
|
24277
|
-
}
|
|
24278
|
-
|
|
24279
|
-
|
|
24280
|
-
|
|
24281
|
-
|
|
24099
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
24100
|
+
var BINARY_PRECEDENCE = {
|
|
24101
|
+
"||": 1,
|
|
24102
|
+
"&&": 2,
|
|
24103
|
+
"==": 3,
|
|
24104
|
+
"!=": 3,
|
|
24105
|
+
"<": 4,
|
|
24106
|
+
"<=": 4,
|
|
24107
|
+
">": 4,
|
|
24108
|
+
">=": 4,
|
|
24109
|
+
"+": 5,
|
|
24110
|
+
"-": 5,
|
|
24111
|
+
"*": 6,
|
|
24112
|
+
"/": 6,
|
|
24113
|
+
"%": 6
|
|
24114
|
+
};
|
|
24115
|
+
function isLogicalOp(op) {
|
|
24116
|
+
return op === "&&" || op === "||";
|
|
24117
|
+
}
|
|
24118
|
+
function isBinaryOp(op) {
|
|
24119
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
24120
|
+
}
|
|
24121
|
+
var Parser = class {
|
|
24122
|
+
tokens;
|
|
24123
|
+
pos = 0;
|
|
24124
|
+
nodeCount = 0;
|
|
24125
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
24126
|
+
callees = /* @__PURE__ */ new Set();
|
|
24127
|
+
constructor(tokens) {
|
|
24128
|
+
this.tokens = tokens;
|
|
24129
|
+
}
|
|
24130
|
+
parse() {
|
|
24131
|
+
const ast = this.parseTernary();
|
|
24132
|
+
const tok = this.peek();
|
|
24133
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
24134
|
+
return {
|
|
24135
|
+
ast,
|
|
24136
|
+
identifiers: this.identifiers,
|
|
24137
|
+
callees: this.callees,
|
|
24138
|
+
nodeCount: this.nodeCount
|
|
24139
|
+
};
|
|
24140
|
+
}
|
|
24141
|
+
peek() {
|
|
24142
|
+
return this.tokens[this.pos];
|
|
24143
|
+
}
|
|
24144
|
+
next() {
|
|
24145
|
+
return this.tokens[this.pos++];
|
|
24146
|
+
}
|
|
24147
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
24148
|
+
expectPunct(punct) {
|
|
24149
|
+
const tok = this.peek();
|
|
24150
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
24151
|
+
this.pos += 1;
|
|
24152
|
+
}
|
|
24153
|
+
matchPunct(punct) {
|
|
24154
|
+
const tok = this.peek();
|
|
24155
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
24156
|
+
this.pos += 1;
|
|
24157
|
+
return true;
|
|
24158
|
+
}
|
|
24159
|
+
return false;
|
|
24160
|
+
}
|
|
24161
|
+
countNode() {
|
|
24162
|
+
this.nodeCount += 1;
|
|
24163
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
24164
|
+
}
|
|
24165
|
+
parseTernary() {
|
|
24166
|
+
const test = this.parseBinary(1);
|
|
24167
|
+
if (this.matchPunct("?")) {
|
|
24168
|
+
const consequent = this.parseTernary();
|
|
24169
|
+
this.expectPunct(":");
|
|
24170
|
+
const alternate = this.parseTernary();
|
|
24171
|
+
this.countNode();
|
|
24172
|
+
return {
|
|
24173
|
+
kind: "conditional",
|
|
24174
|
+
test,
|
|
24175
|
+
consequent,
|
|
24176
|
+
alternate
|
|
24177
|
+
};
|
|
24178
|
+
}
|
|
24179
|
+
return test;
|
|
24180
|
+
}
|
|
24181
|
+
parseBinary(minPrec) {
|
|
24182
|
+
let left = this.parseUnary();
|
|
24183
|
+
for (;;) {
|
|
24184
|
+
const tok = this.peek();
|
|
24185
|
+
if (tok.type !== "punct") break;
|
|
24186
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
24187
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
24188
|
+
const op = tok.punct;
|
|
24189
|
+
this.pos += 1;
|
|
24190
|
+
const right = this.parseBinary(prec + 1);
|
|
24191
|
+
this.countNode();
|
|
24192
|
+
if (isLogicalOp(op)) left = {
|
|
24193
|
+
kind: "logical",
|
|
24194
|
+
op,
|
|
24195
|
+
left,
|
|
24196
|
+
right
|
|
24197
|
+
};
|
|
24198
|
+
else if (isBinaryOp(op)) left = {
|
|
24199
|
+
kind: "binary",
|
|
24200
|
+
op,
|
|
24201
|
+
left,
|
|
24202
|
+
right
|
|
24203
|
+
};
|
|
24204
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
24205
|
+
}
|
|
24206
|
+
return left;
|
|
24207
|
+
}
|
|
24208
|
+
parseUnary() {
|
|
24209
|
+
const tok = this.peek();
|
|
24210
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
24211
|
+
const op = tok.punct;
|
|
24212
|
+
this.pos += 1;
|
|
24213
|
+
const operand = this.parseUnary();
|
|
24214
|
+
this.countNode();
|
|
24215
|
+
return {
|
|
24216
|
+
kind: "unary",
|
|
24217
|
+
op,
|
|
24218
|
+
operand
|
|
24219
|
+
};
|
|
24220
|
+
}
|
|
24221
|
+
return this.parsePrimary();
|
|
24222
|
+
}
|
|
24223
|
+
parsePrimary() {
|
|
24224
|
+
const tok = this.next();
|
|
24225
|
+
switch (tok.type) {
|
|
24226
|
+
case "number":
|
|
24227
|
+
this.countNode();
|
|
24228
|
+
return {
|
|
24229
|
+
kind: "literal",
|
|
24230
|
+
value: tok.value
|
|
24231
|
+
};
|
|
24232
|
+
case "string":
|
|
24233
|
+
this.countNode();
|
|
24234
|
+
return {
|
|
24235
|
+
kind: "literal",
|
|
24236
|
+
value: tok.value
|
|
24237
|
+
};
|
|
24238
|
+
case "keyword":
|
|
24239
|
+
this.countNode();
|
|
24240
|
+
return {
|
|
24241
|
+
kind: "literal",
|
|
24242
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
24243
|
+
};
|
|
24244
|
+
case "identifier": {
|
|
24245
|
+
const nextTok = this.peek();
|
|
24246
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
24247
|
+
this.identifiers.add(tok.name);
|
|
24248
|
+
this.countNode();
|
|
24249
|
+
return {
|
|
24250
|
+
kind: "identifier",
|
|
24251
|
+
name: tok.name
|
|
24252
|
+
};
|
|
24253
|
+
}
|
|
24254
|
+
case "punct":
|
|
24255
|
+
if (tok.punct === "(") {
|
|
24256
|
+
const inner = this.parseTernary();
|
|
24257
|
+
this.expectPunct(")");
|
|
24258
|
+
return inner;
|
|
24259
|
+
}
|
|
24260
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
24261
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
24262
|
+
}
|
|
24263
|
+
}
|
|
24264
|
+
parseCall(callee, pos) {
|
|
24265
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
24266
|
+
this.expectPunct("(");
|
|
24267
|
+
const args = [];
|
|
24268
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
24269
|
+
args.push(this.parseTernary());
|
|
24270
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
24271
|
+
if (this.matchPunct(",")) continue;
|
|
24272
|
+
this.expectPunct(")");
|
|
24273
|
+
break;
|
|
24274
|
+
}
|
|
24275
|
+
this.callees.add(callee);
|
|
24276
|
+
this.countNode();
|
|
24277
|
+
return {
|
|
24278
|
+
kind: "call",
|
|
24279
|
+
callee,
|
|
24280
|
+
args
|
|
24281
|
+
};
|
|
24282
|
+
}
|
|
24283
|
+
};
|
|
24284
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
24285
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
24286
|
+
function parseExpression(source) {
|
|
24287
|
+
return new Parser(tokenize(source)).parse();
|
|
24288
|
+
}
|
|
24282
24289
|
/**
|
|
24283
|
-
*
|
|
24284
|
-
*
|
|
24285
|
-
*
|
|
24286
|
-
*
|
|
24287
|
-
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
24288
|
-
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
24289
|
-
* spawning, builds a name derived from the parent, and produces a
|
|
24290
|
-
* stableId tied to the parent so boot-restore can reconstruct the
|
|
24291
|
-
* relationship.
|
|
24290
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
24291
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
24292
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
24293
|
+
* one per read on a hot resolve path.
|
|
24292
24294
|
*
|
|
24293
|
-
*
|
|
24294
|
-
*
|
|
24295
|
-
*
|
|
24296
|
-
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
24297
|
-
* one canonical mapping was over-prescriptive and added a layer of
|
|
24298
|
-
* indirection without saving meaningful code at call sites — the
|
|
24299
|
-
* driver knows its own hardware best.
|
|
24295
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
24296
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
24297
|
+
* callers is safe and maximises hit rate.
|
|
24300
24298
|
*/
|
|
24299
|
+
var cache = /* @__PURE__ */ new Map();
|
|
24300
|
+
function getCached(source) {
|
|
24301
|
+
const hit = cache.get(source);
|
|
24302
|
+
if (hit !== void 0) {
|
|
24303
|
+
cache.delete(source);
|
|
24304
|
+
cache.set(source, hit);
|
|
24305
|
+
return hit;
|
|
24306
|
+
}
|
|
24307
|
+
let result;
|
|
24308
|
+
try {
|
|
24309
|
+
result = {
|
|
24310
|
+
ok: true,
|
|
24311
|
+
parsed: parseExpression(source)
|
|
24312
|
+
};
|
|
24313
|
+
} catch (err) {
|
|
24314
|
+
result = {
|
|
24315
|
+
ok: false,
|
|
24316
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
24317
|
+
};
|
|
24318
|
+
}
|
|
24319
|
+
cache.set(source, result);
|
|
24320
|
+
if (cache.size > 256) {
|
|
24321
|
+
const oldest = cache.keys().next().value;
|
|
24322
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
24323
|
+
}
|
|
24324
|
+
return result;
|
|
24325
|
+
}
|
|
24326
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
24327
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
24328
|
+
function compileExpressionSafe(source) {
|
|
24329
|
+
return getCached(source);
|
|
24330
|
+
}
|
|
24331
|
+
Object.freeze({});
|
|
24301
24332
|
/**
|
|
24302
|
-
*
|
|
24303
|
-
*
|
|
24304
|
-
*
|
|
24305
|
-
*
|
|
24306
|
-
*
|
|
24307
|
-
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
24308
|
-
* any role works, including non-accessory ones like Doorbell).
|
|
24333
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
24334
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
24335
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
24336
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
24337
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
24309
24338
|
*/
|
|
24310
|
-
|
|
24311
|
-
|
|
24312
|
-
|
|
24313
|
-
|
|
24314
|
-
|
|
24315
|
-
|
|
24316
|
-
|
|
24317
|
-
|
|
24318
|
-
|
|
24319
|
-
|
|
24320
|
-
|
|
24321
|
-
|
|
24322
|
-
|
|
24323
|
-
|
|
24339
|
+
function validateExpressionSource(src) {
|
|
24340
|
+
const names = Object.keys(src.bindings);
|
|
24341
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
24342
|
+
for (const name of names) {
|
|
24343
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
24344
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
24345
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
24346
|
+
}
|
|
24347
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
24348
|
+
if (!compiled.ok) return compiled.error;
|
|
24349
|
+
const bound = new Set(names);
|
|
24350
|
+
for (const id of compiled.parsed.identifiers) {
|
|
24351
|
+
if (id === "now") continue;
|
|
24352
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
24353
|
+
}
|
|
24354
|
+
return null;
|
|
24355
|
+
}
|
|
24356
|
+
var ExpressionBindingSourceSchema = union([
|
|
24357
|
+
object({
|
|
24358
|
+
kind: literal("field").optional(),
|
|
24359
|
+
sourceKey: string(),
|
|
24360
|
+
cap: string(),
|
|
24361
|
+
fieldPath: string()
|
|
24362
|
+
}),
|
|
24363
|
+
object({
|
|
24364
|
+
kind: literal("literal"),
|
|
24365
|
+
value: union([
|
|
24366
|
+
string(),
|
|
24367
|
+
number(),
|
|
24368
|
+
boolean(),
|
|
24369
|
+
_null()
|
|
24370
|
+
])
|
|
24371
|
+
}),
|
|
24372
|
+
object({
|
|
24373
|
+
kind: literal("global"),
|
|
24374
|
+
sourceStableId: string(),
|
|
24375
|
+
cap: string(),
|
|
24376
|
+
fieldPath: string()
|
|
24377
|
+
})
|
|
24378
|
+
]);
|
|
24379
|
+
object({
|
|
24380
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
24381
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
24382
|
+
}).superRefine((src, ctx) => {
|
|
24383
|
+
const err = validateExpressionSource(src);
|
|
24384
|
+
if (err !== null) ctx.addIssue({
|
|
24385
|
+
code: "custom",
|
|
24386
|
+
message: err,
|
|
24387
|
+
path: ["expr"]
|
|
24388
|
+
});
|
|
24389
|
+
});
|
|
24324
24390
|
Object.freeze({
|
|
24325
24391
|
"accessories.setChildHidden": {
|
|
24326
24392
|
capName: "accessories",
|
|
@@ -25144,6 +25210,12 @@ Object.freeze({
|
|
|
25144
25210
|
addonId: null,
|
|
25145
25211
|
access: "view"
|
|
25146
25212
|
},
|
|
25213
|
+
"coreBlocks.restart": {
|
|
25214
|
+
capName: "core-blocks",
|
|
25215
|
+
capScope: "system",
|
|
25216
|
+
addonId: null,
|
|
25217
|
+
access: "create"
|
|
25218
|
+
},
|
|
25147
25219
|
"coreBlocks.setEnabled": {
|
|
25148
25220
|
capName: "core-blocks",
|
|
25149
25221
|
capScope: "system",
|
|
@@ -25780,12 +25852,6 @@ Object.freeze({
|
|
|
25780
25852
|
addonId: null,
|
|
25781
25853
|
access: "create"
|
|
25782
25854
|
},
|
|
25783
|
-
"deviceManager.setDeviceLinks": {
|
|
25784
|
-
capName: "device-manager",
|
|
25785
|
-
capScope: "system",
|
|
25786
|
-
addonId: null,
|
|
25787
|
-
access: "create"
|
|
25788
|
-
},
|
|
25789
25855
|
"deviceManager.setDisabled": {
|
|
25790
25856
|
capName: "device-manager",
|
|
25791
25857
|
capScope: "system",
|
|
@@ -28930,6 +28996,12 @@ Object.freeze({
|
|
|
28930
28996
|
addonId: null,
|
|
28931
28997
|
access: "create"
|
|
28932
28998
|
},
|
|
28999
|
+
"streamBroker.fetchEventMedia": {
|
|
29000
|
+
capName: "stream-broker",
|
|
29001
|
+
capScope: "system",
|
|
29002
|
+
addonId: null,
|
|
29003
|
+
access: "create"
|
|
29004
|
+
},
|
|
28933
29005
|
"streamBroker.getAllRtspEntries": {
|
|
28934
29006
|
capName: "stream-broker",
|
|
28935
29007
|
capScope: "system",
|
|
@@ -29014,6 +29086,12 @@ Object.freeze({
|
|
|
29014
29086
|
addonId: null,
|
|
29015
29087
|
access: "create"
|
|
29016
29088
|
},
|
|
29089
|
+
"streamBroker.produceEventMedia": {
|
|
29090
|
+
capName: "stream-broker",
|
|
29091
|
+
capScope: "system",
|
|
29092
|
+
addonId: null,
|
|
29093
|
+
access: "create"
|
|
29094
|
+
},
|
|
29017
29095
|
"streamBroker.publishCameraStream": {
|
|
29018
29096
|
capName: "stream-broker",
|
|
29019
29097
|
capScope: "system",
|
|
@@ -43983,37 +44061,37 @@ function errMsg$8(err) {
|
|
|
43983
44061
|
}
|
|
43984
44062
|
//#endregion
|
|
43985
44063
|
//#region src/mappers/builders/doorbell-delivery.ts
|
|
43986
|
-
function isRecord(value) {
|
|
44064
|
+
function isRecord$1(value) {
|
|
43987
44065
|
return typeof value === "object" && value !== null;
|
|
43988
44066
|
}
|
|
43989
44067
|
function numberOrNull(value) {
|
|
43990
44068
|
return typeof value === "number" ? value : null;
|
|
43991
44069
|
}
|
|
43992
44070
|
function isConnectionLike(value) {
|
|
43993
|
-
return isRecord(value) && typeof value["hasEventNotifications"] === "function";
|
|
44071
|
+
return isRecord$1(value) && typeof value["hasEventNotifications"] === "function";
|
|
43994
44072
|
}
|
|
43995
|
-
function isIterable(value) {
|
|
43996
|
-
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
44073
|
+
function isIterable$1(value) {
|
|
44074
|
+
return isRecord$1(value) && typeof value[Symbol.iterator] === "function";
|
|
43997
44075
|
}
|
|
43998
44076
|
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
43999
|
-
function readConnections(accessory) {
|
|
44000
|
-
if (!isRecord(accessory)) return null;
|
|
44077
|
+
function readConnections$1(accessory) {
|
|
44078
|
+
if (!isRecord$1(accessory)) return null;
|
|
44001
44079
|
const server = accessory["_server"];
|
|
44002
|
-
if (!isRecord(server)) return null;
|
|
44080
|
+
if (!isRecord$1(server)) return null;
|
|
44003
44081
|
const httpServer = server["httpServer"];
|
|
44004
|
-
if (!isRecord(httpServer)) return null;
|
|
44082
|
+
if (!isRecord$1(httpServer)) return null;
|
|
44005
44083
|
const connections = httpServer["connections"];
|
|
44006
|
-
return isIterable(connections) ? connections : null;
|
|
44084
|
+
return isIterable$1(connections) ? connections : null;
|
|
44007
44085
|
}
|
|
44008
44086
|
/**
|
|
44009
44087
|
* Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
|
|
44010
44088
|
* Pure with respect to HAP state — it only reads. Never throws.
|
|
44011
44089
|
*/
|
|
44012
44090
|
function describeDoorbellDelivery(accessory, characteristic) {
|
|
44013
|
-
const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
44014
|
-
const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
44015
|
-
const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
|
|
44016
|
-
const connections = readConnections(accessory);
|
|
44091
|
+
const aid = isRecord$1(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
44092
|
+
const iid = isRecord$1(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
44093
|
+
const serverPublished = isRecord$1(accessory) && isRecord$1(accessory["_server"]);
|
|
44094
|
+
const connections = readConnections$1(accessory);
|
|
44017
44095
|
if (connections === null) return {
|
|
44018
44096
|
aid,
|
|
44019
44097
|
iid,
|
|
@@ -44125,10 +44203,16 @@ async function buildMotionSensor(bctx, existing = null) {
|
|
|
44125
44203
|
resetTimer = null;
|
|
44126
44204
|
}, RESET_DEBOUNCE_MS);
|
|
44127
44205
|
};
|
|
44206
|
+
const motionLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
44207
|
+
const hksvTrigger = existing !== null;
|
|
44128
44208
|
const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
|
|
44129
44209
|
if (event.data.deviceId !== numericDeviceId) return;
|
|
44130
44210
|
const detected = event.data.detected === true;
|
|
44131
44211
|
motionService.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, detected);
|
|
44212
|
+
motionLog.debug("export-hap: motion pushed to HomeKit", { meta: {
|
|
44213
|
+
detected,
|
|
44214
|
+
hksvTrigger
|
|
44215
|
+
} });
|
|
44132
44216
|
if (detected) armReset();
|
|
44133
44217
|
else if (resetTimer) {
|
|
44134
44218
|
clearTimeout(resetTimer);
|
|
@@ -44505,36 +44589,84 @@ async function probe(call, label, log) {
|
|
|
44505
44589
|
}
|
|
44506
44590
|
}
|
|
44507
44591
|
//#endregion
|
|
44508
|
-
//#region src/hksv/
|
|
44592
|
+
//#region src/hksv/controller-census.ts
|
|
44593
|
+
var EMPTY_CENSUS = {
|
|
44594
|
+
serverPublished: false,
|
|
44595
|
+
connections: 0,
|
|
44596
|
+
adminConnections: 0,
|
|
44597
|
+
nonAdminConnections: 0,
|
|
44598
|
+
unverifiedConnections: 0,
|
|
44599
|
+
pairedControllers: 0,
|
|
44600
|
+
pairedAdmins: 0
|
|
44601
|
+
};
|
|
44602
|
+
function isRecord(value) {
|
|
44603
|
+
return typeof value === "object" && value !== null;
|
|
44604
|
+
}
|
|
44605
|
+
function isIterable(value) {
|
|
44606
|
+
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
44607
|
+
}
|
|
44608
|
+
function isAccessoryInfoLike(value) {
|
|
44609
|
+
return isRecord(value) && isRecord(value["pairedClients"]) && typeof value["hasAdminPermissions"] === "function";
|
|
44610
|
+
}
|
|
44611
|
+
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
44612
|
+
function readConnections(accessory) {
|
|
44613
|
+
const server = accessory["_server"];
|
|
44614
|
+
if (!isRecord(server)) return null;
|
|
44615
|
+
const httpServer = server["httpServer"];
|
|
44616
|
+
if (!isRecord(httpServer)) return null;
|
|
44617
|
+
const connections = httpServer["connections"];
|
|
44618
|
+
return isIterable(connections) ? connections : null;
|
|
44619
|
+
}
|
|
44509
44620
|
/**
|
|
44510
|
-
*
|
|
44511
|
-
*
|
|
44512
|
-
|
|
44513
|
-
|
|
44514
|
-
|
|
44515
|
-
|
|
44516
|
-
|
|
44517
|
-
|
|
44518
|
-
|
|
44519
|
-
|
|
44520
|
-
|
|
44521
|
-
|
|
44522
|
-
|
|
44523
|
-
|
|
44524
|
-
|
|
44525
|
-
|
|
44526
|
-
|
|
44527
|
-
|
|
44528
|
-
|
|
44529
|
-
|
|
44530
|
-
|
|
44531
|
-
|
|
44532
|
-
|
|
44533
|
-
|
|
44534
|
-
|
|
44535
|
-
|
|
44536
|
-
|
|
44621
|
+
* Census the accessory's HAP connections. Pure with respect to HAP state — it
|
|
44622
|
+
* only reads — and never throws.
|
|
44623
|
+
*/
|
|
44624
|
+
function describeHapControllers(accessory) {
|
|
44625
|
+
if (!isRecord(accessory)) return EMPTY_CENSUS;
|
|
44626
|
+
const info = accessory["_accessoryInfo"];
|
|
44627
|
+
const paired = isAccessoryInfoLike(info) ? Object.keys(info.pairedClients) : [];
|
|
44628
|
+
const pairedAdmins = isAccessoryInfoLike(info) ? paired.filter((username) => info.hasAdminPermissions(username)).length : 0;
|
|
44629
|
+
const connections = readConnections(accessory);
|
|
44630
|
+
if (connections === null) return {
|
|
44631
|
+
...EMPTY_CENSUS,
|
|
44632
|
+
pairedControllers: paired.length,
|
|
44633
|
+
pairedAdmins
|
|
44634
|
+
};
|
|
44635
|
+
let open = 0;
|
|
44636
|
+
let admins = 0;
|
|
44637
|
+
let nonAdmins = 0;
|
|
44638
|
+
let unverified = 0;
|
|
44639
|
+
for (const connection of connections) {
|
|
44640
|
+
open += 1;
|
|
44641
|
+
const username = isRecord(connection) ? connection["username"] : void 0;
|
|
44642
|
+
if (typeof username !== "string" || !isAccessoryInfoLike(info)) {
|
|
44643
|
+
unverified += 1;
|
|
44644
|
+
continue;
|
|
44645
|
+
}
|
|
44646
|
+
if (info.hasAdminPermissions(username)) admins += 1;
|
|
44647
|
+
else nonAdmins += 1;
|
|
44648
|
+
}
|
|
44649
|
+
return {
|
|
44650
|
+
serverPublished: true,
|
|
44651
|
+
connections: open,
|
|
44652
|
+
adminConnections: admins,
|
|
44653
|
+
nonAdminConnections: nonAdmins,
|
|
44654
|
+
unverifiedConnections: unverified,
|
|
44655
|
+
pairedControllers: paired.length,
|
|
44656
|
+
pairedAdmins
|
|
44657
|
+
};
|
|
44658
|
+
}
|
|
44659
|
+
/**
|
|
44660
|
+
* True when NO connected controller may write HKSV state. Every
|
|
44661
|
+
* `SelectedCameraRecordingConfiguration` write such a controller sends is
|
|
44662
|
+
* refused before it reaches us, so the recording configuration can never
|
|
44663
|
+
* arrive and the caller must say so out loud.
|
|
44537
44664
|
*/
|
|
44665
|
+
function noAdminControllerConnected(census) {
|
|
44666
|
+
return census.serverPublished && census.connections > 0 && census.adminConnections === 0;
|
|
44667
|
+
}
|
|
44668
|
+
//#endregion
|
|
44669
|
+
//#region src/hksv/recording-options.ts
|
|
44538
44670
|
/**
|
|
44539
44671
|
* The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
|
|
44540
44672
|
* range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
|
|
@@ -44557,6 +44689,16 @@ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
|
|
|
44557
44689
|
*/
|
|
44558
44690
|
var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
|
|
44559
44691
|
/**
|
|
44692
|
+
* The frame rates the advertised {@link Resolution} may carry, and the only
|
|
44693
|
+
* ones. See {@link normaliseAdvertisedFps} for why the measured rate does not
|
|
44694
|
+
* go in raw.
|
|
44695
|
+
*/
|
|
44696
|
+
var HKSV_ADVERTISED_FRAME_RATES = [
|
|
44697
|
+
15,
|
|
44698
|
+
24,
|
|
44699
|
+
30
|
|
44700
|
+
];
|
|
44701
|
+
/**
|
|
44560
44702
|
* The advertised fragment length for a camera whose key-frame cadence is
|
|
44561
44703
|
* `sourceGopMs`, or `null` when no offered length covers it.
|
|
44562
44704
|
*
|
|
@@ -44572,6 +44714,46 @@ function deriveFragmentLengthMs(sourceGopMs) {
|
|
|
44572
44714
|
return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
|
|
44573
44715
|
}
|
|
44574
44716
|
/**
|
|
44717
|
+
* The frame rate to ADVERTISE for a slot that was measured at `measuredFps` —
|
|
44718
|
+
* the nearest member of {@link HKSV_ADVERTISED_FRAME_RATES}, ties going to the
|
|
44719
|
+
* lower rate.
|
|
44720
|
+
*
|
|
44721
|
+
* The measured rate does not go into the advertisement raw, for two reasons,
|
|
44722
|
+
* and the second one is the serious one.
|
|
44723
|
+
*
|
|
44724
|
+
* **It is the list the controller chooses from.** `[1280, 720, 10]` — 615's
|
|
44725
|
+
* measured 720p slot — is a frame rate no shipping HKSV camera offers, and the
|
|
44726
|
+
* controller has to find an acceptable configuration in what we advertise
|
|
44727
|
+
* before it will write one back.
|
|
44728
|
+
*
|
|
44729
|
+
* **A measurement makes the advertisement UNSTABLE, and hap-nodejs punishes
|
|
44730
|
+
* that by discarding the controller's selection.** `RecordingManagement`
|
|
44731
|
+
* hashes the supported-configuration TLVs and, on restore, keeps the persisted
|
|
44732
|
+
* `selectedConfiguration` only while the hash still matches — otherwise
|
|
44733
|
+
* `deserialize: discarding saved selectedConfiguration`, after which the
|
|
44734
|
+
* accessory answers every HDS `DATA_SEND OPEN` with `INVALID_CONFIGURATION`
|
|
44735
|
+
* and records nothing until the controller happens to re-select. The
|
|
44736
|
+
* advertised resolution is the one hashed input that came from a probe:
|
|
44737
|
+
* camera 590 measured 9 fps on one restart and 10 on the next, on
|
|
44738
|
+
* 2026-08-07/08, so this was a self-inflicted outage waiting on a reboot.
|
|
44739
|
+
* Quantising gives the probe a wide band to move inside without the
|
|
44740
|
+
* advertisement changing at all.
|
|
44741
|
+
*/
|
|
44742
|
+
function normaliseAdvertisedFps(measuredFps) {
|
|
44743
|
+
const fallback = HKSV_ADVERTISED_FRAME_RATES[0] ?? 15;
|
|
44744
|
+
if (!Number.isFinite(measuredFps) || measuredFps <= 0) return fallback;
|
|
44745
|
+
let best = fallback;
|
|
44746
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
44747
|
+
for (const candidate of HKSV_ADVERTISED_FRAME_RATES) {
|
|
44748
|
+
const distance = Math.abs(candidate - measuredFps);
|
|
44749
|
+
if (distance < bestDistance) {
|
|
44750
|
+
best = candidate;
|
|
44751
|
+
bestDistance = distance;
|
|
44752
|
+
}
|
|
44753
|
+
}
|
|
44754
|
+
return best;
|
|
44755
|
+
}
|
|
44756
|
+
/**
|
|
44575
44757
|
* Build the advertisement.
|
|
44576
44758
|
*
|
|
44577
44759
|
* ONE resolution is advertised — the one slot the recording child pulls. HAP's
|
|
@@ -44584,7 +44766,7 @@ function buildRecordingOptions(input) {
|
|
|
44584
44766
|
const resolution = [
|
|
44585
44767
|
input.width,
|
|
44586
44768
|
input.height,
|
|
44587
|
-
|
|
44769
|
+
normaliseAdvertisedFps(input.fps)
|
|
44588
44770
|
];
|
|
44589
44771
|
return {
|
|
44590
44772
|
prebufferLength: HKSV_PREBUFFER_MS,
|
|
@@ -44907,13 +45089,19 @@ var HksvRecordingDelegate = class {
|
|
|
44907
45089
|
updateRecordingActive(active) {
|
|
44908
45090
|
if (active === this.active) return;
|
|
44909
45091
|
this.active = active;
|
|
45092
|
+
const census = this.input.describeControllers();
|
|
44910
45093
|
this.log.info("hksv: recording active changed", {
|
|
44911
45094
|
tags: { deviceId: this.input.deviceId },
|
|
44912
45095
|
meta: {
|
|
44913
45096
|
active,
|
|
44914
|
-
hasConfiguration: this.configuration !== void 0
|
|
45097
|
+
hasConfiguration: this.configuration !== void 0,
|
|
45098
|
+
...census
|
|
44915
45099
|
}
|
|
44916
45100
|
});
|
|
45101
|
+
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", {
|
|
45102
|
+
tags: { deviceId: this.input.deviceId },
|
|
45103
|
+
meta: { ...census }
|
|
45104
|
+
});
|
|
44917
45105
|
this.reconcile("recording-active");
|
|
44918
45106
|
}
|
|
44919
45107
|
updateRecordingConfiguration(configuration) {
|
|
@@ -44968,13 +45156,14 @@ var HksvRecordingDelegate = class {
|
|
|
44968
45156
|
streamId,
|
|
44969
45157
|
subscription
|
|
44970
45158
|
};
|
|
44971
|
-
const startedAt =
|
|
45159
|
+
const startedAt = this.input.now();
|
|
44972
45160
|
const prebufferSpanMs = source.prebufferSpanMs();
|
|
44973
45161
|
let packets = 0;
|
|
44974
45162
|
let bytes = 0;
|
|
44975
45163
|
let markedLast = false;
|
|
44976
45164
|
let longestFragmentGapMs = 0;
|
|
44977
|
-
let
|
|
45165
|
+
let firstFragmentAt = null;
|
|
45166
|
+
let lastFragmentAt = null;
|
|
44978
45167
|
try {
|
|
44979
45168
|
for await (const packet of subscription.packets()) {
|
|
44980
45169
|
if (signal?.aborted === true) {
|
|
@@ -44990,9 +45179,10 @@ var HksvRecordingDelegate = class {
|
|
|
44990
45179
|
packets += 1;
|
|
44991
45180
|
bytes += packet.data.length;
|
|
44992
45181
|
if (packet.kind === "fragment") {
|
|
44993
|
-
const now =
|
|
44994
|
-
|
|
44995
|
-
|
|
45182
|
+
const now = this.input.now();
|
|
45183
|
+
if (lastFragmentAt === null) firstFragmentAt = now;
|
|
45184
|
+
else longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastFragmentAt);
|
|
45185
|
+
lastFragmentAt = now;
|
|
44996
45186
|
}
|
|
44997
45187
|
markedLast = markedLast || packet.isLast;
|
|
44998
45188
|
yield {
|
|
@@ -45027,9 +45217,10 @@ var HksvRecordingDelegate = class {
|
|
|
45027
45217
|
streamId,
|
|
45028
45218
|
packets,
|
|
45029
45219
|
bytes,
|
|
45030
|
-
durationMs:
|
|
45220
|
+
durationMs: this.input.now() - startedAt,
|
|
45031
45221
|
prebufferSpanMs,
|
|
45032
45222
|
longestFragmentGapMs,
|
|
45223
|
+
msToFirstFragmentMs: firstFragmentAt === null ? null : firstFragmentAt - startedAt,
|
|
45033
45224
|
closedReason: subscription.closedReason,
|
|
45034
45225
|
markedLast
|
|
45035
45226
|
}
|
|
@@ -45246,6 +45437,8 @@ async function buildHksvRecording(input) {
|
|
|
45246
45437
|
deviceId: numericDeviceId,
|
|
45247
45438
|
isAudioActive: input.isAudioActive,
|
|
45248
45439
|
advertisedFragmentMs: fragmentLengthMs,
|
|
45440
|
+
now: () => Date.now(),
|
|
45441
|
+
describeControllers: () => describeHapControllers(bctx.accessory),
|
|
45249
45442
|
createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
|
|
45250
45443
|
logger: log,
|
|
45251
45444
|
deviceId: numericDeviceId,
|
|
@@ -45256,11 +45449,13 @@ async function buildHksvRecording(input) {
|
|
|
45256
45449
|
audioActive
|
|
45257
45450
|
})
|
|
45258
45451
|
});
|
|
45452
|
+
const advertisedResolution = options.video.resolutions[0];
|
|
45259
45453
|
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
45260
45454
|
brokerId: source.brokerId,
|
|
45261
45455
|
profile: source.profile,
|
|
45262
45456
|
resolution: `${source.width}x${source.height}`,
|
|
45263
|
-
fps,
|
|
45457
|
+
measuredFps: fps,
|
|
45458
|
+
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
45264
45459
|
fragmentLengthMs,
|
|
45265
45460
|
sourceGopMs: gopMs ?? "unknown"
|
|
45266
45461
|
} });
|