@camstack/types 1.2.46 → 1.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +4 -3
- package/dist/addon.mjs +4 -3
- package/dist/{fmp4-box-splitter-B53u9-Nu.mjs → canonical-hash-rO1sRmEK.mjs} +34 -34
- package/dist/capabilities/core-blocks.cap.d.ts +52 -0
- package/dist/capabilities/device-manager.cap.d.ts +11 -427
- package/dist/capabilities/face-gallery.cap.d.ts +4 -0
- package/dist/capabilities/index.d.ts +2 -2
- package/dist/capabilities/motion-detection.cap.d.ts +6 -1
- package/dist/capabilities/notification-rules.cap.d.ts +24 -24
- package/dist/capabilities/oauth-integration.cap.d.ts +4 -0
- package/dist/capabilities/osd-manager.cap.d.ts +12 -12
- package/dist/capabilities/pipeline-analytics.cap.d.ts +7 -1
- package/dist/capabilities/sso-bridge.cap.d.ts +3 -0
- package/dist/capabilities/stream-broker.cap.d.ts +1 -0
- package/dist/capabilities/user-management.cap.d.ts +3 -1
- package/dist/capabilities/videoclips.cap.d.ts +5 -0
- package/dist/device/declared-device.d.ts +197 -0
- package/dist/device/device-binding.d.ts +15 -9
- package/dist/device/device-management.d.ts +1 -83
- package/dist/device/index.d.ts +20 -19
- package/dist/expression/binding-source.d.ts +85 -0
- package/dist/expression/{link-expression.d.ts → expression-source.d.ts} +22 -13
- package/dist/expression/index.d.ts +17 -14
- package/dist/expression/limits.d.ts +1 -1
- package/dist/generated/addon-api.d.ts +11 -11
- package/dist/generated/device-proxy.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +1 -1
- package/dist/index.d.ts +9 -8
- package/dist/index.js +2361 -2058
- package/dist/index.mjs +2336 -2044
- package/dist/node.js +7 -7
- package/dist/node.mjs +1 -1
- package/dist/{sleep-BbYwFLG6.mjs → sleep-7WqNZVcL.mjs} +0 -1
- package/dist/{sleep-CyN9nHr_.js → sleep-ocMLM2o5.js} +0 -1
- package/package.json +1 -1
- package/dist/device/device-link-transform.d.ts +0 -5
- package/dist/{fmp4-box-splitter-BkWH7O3L.js → canonical-hash-DNV8S5ET.js} +33 -33
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_event_category = require("./event-category-BE4PDZ_3.js");
|
|
3
|
-
const require_sleep = require("./sleep-
|
|
4
|
-
const
|
|
3
|
+
const require_sleep = require("./sleep-ocMLM2o5.js");
|
|
4
|
+
const require_canonical_hash = require("./canonical-hash-DNV8S5ET.js");
|
|
5
5
|
const require_enums = require("./enums.js");
|
|
6
6
|
const require_err_msg = require("./err-msg-COpsHMw2.js");
|
|
7
7
|
let zod = require("zod");
|
|
@@ -481,7 +481,7 @@ function canonicalEgressPlan(request, delivery = egressTransportFromRequest(requ
|
|
|
481
481
|
* every other consumer to 360p.
|
|
482
482
|
*/
|
|
483
483
|
function egressTranscodeSharingKey(request, delivery = egressTransportFromRequest(request)) {
|
|
484
|
-
return `egress:${
|
|
484
|
+
return `egress:${require_canonical_hash.canonicalHash(canonicalEgressPlan(request, delivery))}`;
|
|
485
485
|
}
|
|
486
486
|
//#endregion
|
|
487
487
|
//#region src/health/wiring-health.ts
|
|
@@ -4326,12 +4326,34 @@ var streamBrokerCapability = {
|
|
|
4326
4326
|
kinds: zod.z.array(EventMediaKindSchema).min(1).default(["mp4"]),
|
|
4327
4327
|
/** GIF geometry. The video keeps the source's own. */
|
|
4328
4328
|
gifMaxWidth: zod.z.number().int().min(120).max(1280).default(640),
|
|
4329
|
-
gifFps: zod.z.number().int().min(1).max(15).default(8),
|
|
4330
4329
|
/**
|
|
4331
|
-
*
|
|
4332
|
-
*
|
|
4330
|
+
* The gif's own PLAYBACK rate in frames per second — what the finished
|
|
4331
|
+
* gif runs at, not how many source frames feed it. The decimation that
|
|
4332
|
+
* feeds it samples `gifFps / gifSpeed` source frames per second, so at
|
|
4333
|
+
* the defaults a 12 fps gif is built out of 3 source frames a second.
|
|
4333
4334
|
*/
|
|
4334
|
-
|
|
4335
|
+
gifFps: zod.z.number().int().min(1).max(15).default(12),
|
|
4336
|
+
/**
|
|
4337
|
+
* How fast the GIF plays against real time, independent of `speed`.
|
|
4338
|
+
*
|
|
4339
|
+
* 4× by default, by operator request: a notification gif is glanced at
|
|
4340
|
+
* on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
|
|
4341
|
+
* a separate knob from `speed` even though both now default to 4 —
|
|
4342
|
+
* a caller wanting a real-time video and a fast gif must not have to
|
|
4343
|
+
* choose.
|
|
4344
|
+
*/
|
|
4345
|
+
gifSpeed: zod.z.number().min(1).max(8).default(4),
|
|
4346
|
+
/**
|
|
4347
|
+
* Playback rate of the VIDEO. Also 4× by default, by operator decision.
|
|
4348
|
+
*
|
|
4349
|
+
* `1` is real time and is the ONLY value that allows the copy branch —
|
|
4350
|
+
* anything else forces `libx264` over the window. That was priced
|
|
4351
|
+
* before it was chosen: a per-event burst measured at 0.23 s and 254 KB
|
|
4352
|
+
* on a real 615 720p cut, against 922 KB for the copy it replaces. A
|
|
4353
|
+
* re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
|
|
4354
|
+
* once the decode is forced the width stops being free.
|
|
4355
|
+
*/
|
|
4356
|
+
speed: zod.z.number().min(1).max(8).default(4)
|
|
4335
4357
|
}), EventMediaProductionSchema, {
|
|
4336
4358
|
kind: "mutation",
|
|
4337
4359
|
auth: "admin"
|
|
@@ -4928,6 +4950,28 @@ function kebabToCamel(s) {
|
|
|
4928
4950
|
* instead of taking the hub with it (D6). Every method here is admin-only, and
|
|
4929
4951
|
* must stay so.
|
|
4930
4952
|
*/
|
|
4953
|
+
/**
|
|
4954
|
+
* Prefix for a block's generated addon id AND its runner id — one addon, one
|
|
4955
|
+
* process, so the two are the same string (D2).
|
|
4956
|
+
*
|
|
4957
|
+
* It lives on the CONTRACT rather than beside the code that materialises the
|
|
4958
|
+
* package, because it is not an implementation detail: the operator surface
|
|
4959
|
+
* reads a block's logs by asking `logs.query` for `addonId = core-block-<id>`,
|
|
4960
|
+
* with no cap and no store of its own. Two copies of this string is one copy
|
|
4961
|
+
* too many — a drift would show as a logs pane that is simply always empty.
|
|
4962
|
+
*/
|
|
4963
|
+
var CORE_BLOCK_ADDON_PREFIX = "core-block-";
|
|
4964
|
+
/** The addon/runner id a block's process runs under. */
|
|
4965
|
+
function coreBlockAddonId(blockId) {
|
|
4966
|
+
return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
|
|
4967
|
+
}
|
|
4968
|
+
/** The block id behind a generated addon/runner id, or null when the id belongs
|
|
4969
|
+
* to something else. The inverse of {@link coreBlockAddonId}. */
|
|
4970
|
+
function coreBlockIdFromAddonId(addonId) {
|
|
4971
|
+
if (!addonId.startsWith("core-block-")) return null;
|
|
4972
|
+
const rest = addonId.slice(11);
|
|
4973
|
+
return rest.length > 0 ? rest : null;
|
|
4974
|
+
}
|
|
4931
4975
|
/** Where a block runs. The operator chooses — a block driving a device on an
|
|
4932
4976
|
* agent is the reason placement is not fixed to the hub. */
|
|
4933
4977
|
var CoreBlockPlacementSchema = zod.z.union([zod.z.literal("hub"), zod.z.string().min(1)]);
|
|
@@ -5016,6 +5060,23 @@ var coreBlocksCapability = {
|
|
|
5016
5060
|
auth: "admin"
|
|
5017
5061
|
}),
|
|
5018
5062
|
/**
|
|
5063
|
+
* Stop this block's runner and let the supervisor bring it back.
|
|
5064
|
+
*
|
|
5065
|
+
* **It stores nothing.** There is no "restart requested" flag and no second
|
|
5066
|
+
* lifecycle field: the supervisor already stops and respawns a runner when
|
|
5067
|
+
* the code hash changes, and this exposes that same path deliberately
|
|
5068
|
+
* (D62 — a switch writes the authority that already owned the function).
|
|
5069
|
+
* The next reconcile pass remains the authority, so a restart that lost a
|
|
5070
|
+
* race is corrected within one pass rather than leaving a stale flag behind.
|
|
5071
|
+
*
|
|
5072
|
+
* Refused for a DISABLED block: nothing would come back, and a button that
|
|
5073
|
+
* silently does nothing is worse than one that says why.
|
|
5074
|
+
*/
|
|
5075
|
+
restart: require_sleep.method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema }), {
|
|
5076
|
+
kind: "mutation",
|
|
5077
|
+
auth: "admin"
|
|
5078
|
+
}),
|
|
5079
|
+
/**
|
|
5019
5080
|
* Type-check without saving — what the editor calls as the author types, so
|
|
5020
5081
|
* the compiler's verdict is the same one the server will reach.
|
|
5021
5082
|
*/
|
|
@@ -6231,1114 +6292,247 @@ var deviceExportCapability = {
|
|
|
6231
6292
|
}
|
|
6232
6293
|
};
|
|
6233
6294
|
//#endregion
|
|
6234
|
-
//#region src/
|
|
6295
|
+
//#region src/capabilities/device-provider.cap.ts
|
|
6296
|
+
var ProviderStatusSchema = zod.z.object({
|
|
6297
|
+
connected: zod.z.boolean(),
|
|
6298
|
+
deviceCount: zod.z.number(),
|
|
6299
|
+
error: zod.z.string().optional()
|
|
6300
|
+
});
|
|
6301
|
+
var DiscoveredDeviceSchema = zod.z.object({
|
|
6302
|
+
externalId: zod.z.string(),
|
|
6303
|
+
name: zod.z.string(),
|
|
6304
|
+
type: zod.z.string(),
|
|
6305
|
+
metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
6306
|
+
});
|
|
6235
6307
|
/**
|
|
6236
|
-
*
|
|
6237
|
-
*
|
|
6238
|
-
*
|
|
6239
|
-
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
6240
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
6241
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
6242
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
6308
|
+
* Candidate handed back from discovery and accepted by
|
|
6309
|
+
* `adoptDiscoveredDevice`. Shape mirrors the in-process
|
|
6310
|
+
* `DiscoveredDevice` interface used by `DeviceDiscovery`.
|
|
6243
6311
|
*/
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
*
|
|
6252
|
-
*
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
var MAX_EXPRESSION_CALL_ARGS = 16;
|
|
6258
|
-
/** LRU compile-cache capacity (parsed ASTs keyed by raw source string). */
|
|
6259
|
-
var EXPRESSION_COMPILE_CACHE_CAPACITY = 256;
|
|
6260
|
-
/** A legal binding / identifier name. */
|
|
6261
|
-
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
6262
|
-
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
6263
|
-
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
6264
|
-
var RESERVED_BINDING_NAMES = new Set([
|
|
6265
|
-
"now",
|
|
6266
|
-
"true",
|
|
6267
|
-
"false",
|
|
6268
|
-
"null"
|
|
6269
|
-
]);
|
|
6270
|
-
//#endregion
|
|
6271
|
-
//#region src/expression/errors.ts
|
|
6312
|
+
var DiscoveryCandidateSchema = zod.z.object({
|
|
6313
|
+
stableId: zod.z.string(),
|
|
6314
|
+
type: zod.z.enum(require_sleep.DeviceType),
|
|
6315
|
+
suggestedName: zod.z.string(),
|
|
6316
|
+
prefilledConfig: zod.z.record(zod.z.string(), zod.z.unknown()),
|
|
6317
|
+
/**
|
|
6318
|
+
* Optional upstream-system identity (HA entity_id, vendor MAC, …).
|
|
6319
|
+
* Discovery pre-populates this for systems that know the upstream
|
|
6320
|
+
* identity ahead of adoption. Rendering metadata (unit, precision)
|
|
6321
|
+
* flows live through the cap STATUS SLICE after adoption.
|
|
6322
|
+
*/
|
|
6323
|
+
sourceInfo: SourceInfoSchema.optional()
|
|
6324
|
+
});
|
|
6272
6325
|
/**
|
|
6273
|
-
*
|
|
6274
|
-
*
|
|
6275
|
-
*
|
|
6326
|
+
* Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
|
|
6327
|
+
* Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
|
|
6328
|
+
* tRPC layer can pass it through without reshaping.
|
|
6276
6329
|
*/
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
/**
|
|
6288
|
-
*
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
}
|
|
6294
|
-
};
|
|
6295
|
-
//#endregion
|
|
6296
|
-
//#region src/expression/builtins.ts
|
|
6330
|
+
var DeviceSummarySchema = zod.z.object({
|
|
6331
|
+
id: zod.z.number(),
|
|
6332
|
+
stableId: zod.z.string(),
|
|
6333
|
+
addonId: zod.z.string(),
|
|
6334
|
+
type: zod.z.string(),
|
|
6335
|
+
name: zod.z.string(),
|
|
6336
|
+
parentDeviceId: zod.z.number().nullable(),
|
|
6337
|
+
online: zod.z.boolean(),
|
|
6338
|
+
features: zod.z.array(zod.z.string()),
|
|
6339
|
+
config: zod.z.record(zod.z.string(), zod.z.unknown()),
|
|
6340
|
+
/** Optional upstream-system identity (dispatch key + system tag).
|
|
6341
|
+
* See `SourceInfo`. Present when the device has a non-synthetic
|
|
6342
|
+
* source identifier (HA entities, vendor MAC, …); omitted when the
|
|
6343
|
+
* synthetic backfill is in effect. */
|
|
6344
|
+
sourceInfo: SourceInfoSchema.optional()
|
|
6345
|
+
});
|
|
6297
6346
|
/**
|
|
6298
|
-
*
|
|
6299
|
-
*
|
|
6300
|
-
*
|
|
6301
|
-
*
|
|
6302
|
-
*
|
|
6303
|
-
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
6304
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
6305
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
6306
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
6307
|
-
* callable — they are simply "unknown function" at parse time.
|
|
6308
|
-
*
|
|
6309
|
-
* Every numeric argument is validated as a finite number and every numeric
|
|
6310
|
-
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
6311
|
-
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
6312
|
-
* closed rather than emitting a garbage value.
|
|
6347
|
+
* Result of a live field test (e.g. probing an RTSP URL during device
|
|
6348
|
+
* creation). Matches the UI-side `FieldProbeResult` in
|
|
6349
|
+
* `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
|
|
6350
|
+
* returned `labels` as chips next to the input.
|
|
6313
6351
|
*/
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
6320
|
-
return value;
|
|
6321
|
-
}
|
|
6322
|
-
function finiteResult(value, name) {
|
|
6323
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
6324
|
-
return value;
|
|
6325
|
-
}
|
|
6326
|
-
function allFiniteNumbers(args, name) {
|
|
6327
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
6328
|
-
}
|
|
6329
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
6330
|
-
var table = {
|
|
6331
|
-
min: {
|
|
6332
|
-
minArgs: 1,
|
|
6333
|
-
maxArgs: INF,
|
|
6334
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
6335
|
-
},
|
|
6336
|
-
max: {
|
|
6337
|
-
minArgs: 1,
|
|
6338
|
-
maxArgs: INF,
|
|
6339
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
6340
|
-
},
|
|
6341
|
-
abs: {
|
|
6342
|
-
minArgs: 1,
|
|
6343
|
-
maxArgs: 1,
|
|
6344
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
6345
|
-
},
|
|
6346
|
-
floor: {
|
|
6347
|
-
minArgs: 1,
|
|
6348
|
-
maxArgs: 1,
|
|
6349
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
6350
|
-
},
|
|
6351
|
-
ceil: {
|
|
6352
|
-
minArgs: 1,
|
|
6353
|
-
maxArgs: 1,
|
|
6354
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
6355
|
-
},
|
|
6356
|
-
sqrt: {
|
|
6357
|
-
minArgs: 1,
|
|
6358
|
-
maxArgs: 1,
|
|
6359
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
6360
|
-
},
|
|
6361
|
-
round: {
|
|
6362
|
-
minArgs: 1,
|
|
6363
|
-
maxArgs: 2,
|
|
6364
|
-
apply: (args) => {
|
|
6365
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
6366
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
6367
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
6368
|
-
const factor = 10 ** digits;
|
|
6369
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
6370
|
-
}
|
|
6371
|
-
},
|
|
6372
|
-
pow: {
|
|
6373
|
-
minArgs: 2,
|
|
6374
|
-
maxArgs: 2,
|
|
6375
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
6376
|
-
},
|
|
6377
|
-
clamp: {
|
|
6378
|
-
minArgs: 3,
|
|
6379
|
-
maxArgs: 3,
|
|
6380
|
-
apply: (args) => {
|
|
6381
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
6382
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
6383
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
6384
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
6385
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
6386
|
-
}
|
|
6387
|
-
},
|
|
6388
|
-
avg: {
|
|
6389
|
-
minArgs: 1,
|
|
6390
|
-
maxArgs: INF,
|
|
6391
|
-
apply: (args) => {
|
|
6392
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
6393
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
6394
|
-
}
|
|
6395
|
-
},
|
|
6396
|
-
sum: {
|
|
6397
|
-
minArgs: 1,
|
|
6398
|
-
maxArgs: INF,
|
|
6399
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
6400
|
-
},
|
|
6401
|
-
coalesce: {
|
|
6402
|
-
minArgs: 1,
|
|
6403
|
-
maxArgs: INF,
|
|
6404
|
-
apply: (args) => {
|
|
6405
|
-
for (const a of args) if (a !== null) return a;
|
|
6406
|
-
return null;
|
|
6407
|
-
}
|
|
6408
|
-
},
|
|
6409
|
-
age: {
|
|
6410
|
-
minArgs: 2,
|
|
6411
|
-
maxArgs: 2,
|
|
6412
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
6413
|
-
},
|
|
6414
|
-
convert: {
|
|
6415
|
-
minArgs: 3,
|
|
6416
|
-
maxArgs: 3,
|
|
6417
|
-
apply: (args, hooks) => {
|
|
6418
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
6419
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
6420
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
6421
|
-
if (hooks.convert) {
|
|
6422
|
-
const out = hooks.convert(x, from, to);
|
|
6423
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
6424
|
-
return finiteResult(out, "convert");
|
|
6425
|
-
}
|
|
6426
|
-
if (from === to) return x;
|
|
6427
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
6428
|
-
}
|
|
6429
|
-
}
|
|
6430
|
-
};
|
|
6431
|
-
/** Frozen, null-prototype builtin table. */
|
|
6432
|
-
var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
|
|
6433
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
6434
|
-
* callees at parse time (immediate author feedback). */
|
|
6435
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
6436
|
-
//#endregion
|
|
6437
|
-
//#region src/expression/tokenizer.ts
|
|
6352
|
+
var FieldProbeResultSchema = zod.z.object({
|
|
6353
|
+
status: zod.z.enum(["ok", "error"]),
|
|
6354
|
+
labels: zod.z.array(zod.z.string()).optional(),
|
|
6355
|
+
error: zod.z.string().optional()
|
|
6356
|
+
});
|
|
6438
6357
|
/**
|
|
6439
|
-
*
|
|
6440
|
-
*
|
|
6441
|
-
*
|
|
6442
|
-
*
|
|
6443
|
-
*
|
|
6444
|
-
*
|
|
6445
|
-
* template literals are lexically impossible.
|
|
6358
|
+
* The output of `getChildCreationSchema` is a UI schema tree. We store
|
|
6359
|
+
* it as `unknown` at the capability layer — the router just passes it
|
|
6360
|
+
* through and the admin UI renders it via `FormBuilder`. The actual
|
|
6361
|
+
* type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
|
|
6362
|
+
* but we deliberately avoid a Zod mirror because the union is large and
|
|
6363
|
+
* not meant for runtime validation at this seam.
|
|
6446
6364
|
*/
|
|
6447
|
-
var
|
|
6448
|
-
|
|
6449
|
-
"
|
|
6450
|
-
"
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
}
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
}
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
*
|
|
6466
|
-
*
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
});
|
|
6527
|
-
continue;
|
|
6528
|
-
}
|
|
6529
|
-
if (isIdentStart(ch)) {
|
|
6530
|
-
const start = i;
|
|
6531
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
6532
|
-
const text = source.slice(start, i);
|
|
6533
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
6534
|
-
type: "keyword",
|
|
6535
|
-
keyword: keywordOf(text),
|
|
6536
|
-
pos: start
|
|
6537
|
-
});
|
|
6538
|
-
else tokens.push({
|
|
6539
|
-
type: "identifier",
|
|
6540
|
-
name: text,
|
|
6541
|
-
pos: start
|
|
6542
|
-
});
|
|
6543
|
-
continue;
|
|
6544
|
-
}
|
|
6545
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
6546
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
6547
|
-
tokens.push({
|
|
6548
|
-
type: "punct",
|
|
6549
|
-
punct: two,
|
|
6550
|
-
pos: i
|
|
6551
|
-
});
|
|
6552
|
-
i += 2;
|
|
6553
|
-
continue;
|
|
6554
|
-
}
|
|
6555
|
-
if (isSinglePunct(ch)) {
|
|
6556
|
-
tokens.push({
|
|
6557
|
-
type: "punct",
|
|
6558
|
-
punct: ch,
|
|
6559
|
-
pos: i
|
|
6560
|
-
});
|
|
6561
|
-
i += 1;
|
|
6562
|
-
continue;
|
|
6563
|
-
}
|
|
6564
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
6365
|
+
var CreationSchemaOutputSchema = zod.z.unknown();
|
|
6366
|
+
var deviceProviderCapability = {
|
|
6367
|
+
name: "device-provider",
|
|
6368
|
+
scope: "system",
|
|
6369
|
+
mode: "collection",
|
|
6370
|
+
methods: {
|
|
6371
|
+
start: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
|
|
6372
|
+
stop: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
|
|
6373
|
+
getStatus: require_sleep.method(zod.z.void(), ProviderStatusSchema),
|
|
6374
|
+
getDevices: require_sleep.method(zod.z.void(), zod.z.array(zod.z.object({
|
|
6375
|
+
id: zod.z.string(),
|
|
6376
|
+
name: zod.z.string(),
|
|
6377
|
+
type: zod.z.string()
|
|
6378
|
+
}))),
|
|
6379
|
+
supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
|
|
6380
|
+
/**
|
|
6381
|
+
* Run a network scan. `params` carries optional provider-specific scan
|
|
6382
|
+
* inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
|
|
6383
|
+
* shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
|
|
6384
|
+
* (provider uses its local-network default).
|
|
6385
|
+
*/
|
|
6386
|
+
discoverDevices: require_sleep.method(zod.z.object({ params: zod.z.record(zod.z.string(), zod.z.unknown()).optional() }), zod.z.array(DiscoveryCandidateSchema), {
|
|
6387
|
+
kind: "mutation",
|
|
6388
|
+
auth: "admin"
|
|
6389
|
+
}),
|
|
6390
|
+
/**
|
|
6391
|
+
* Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
|
|
6392
|
+
* provider accepts (e.g. Gree's broadcast address for a different subnet).
|
|
6393
|
+
* `null` when the provider takes no extra scan params — the generic
|
|
6394
|
+
* aggregated scan never renders this; the per-integration scan does.
|
|
6395
|
+
*/
|
|
6396
|
+
getDiscoveryParamsSchema: require_sleep.method(zod.z.object({}), CreationSchemaOutputSchema),
|
|
6397
|
+
/**
|
|
6398
|
+
* The DeviceType this provider creates via manual add (Camera for
|
|
6399
|
+
* Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
|
|
6400
|
+
* provider does not support manual creation. Lets the Add-Device dialog
|
|
6401
|
+
* pick the right type instead of assuming Camera.
|
|
6402
|
+
*/
|
|
6403
|
+
getManualCreationType: require_sleep.method(zod.z.object({}), zod.z.object({ deviceType: zod.z.enum(require_sleep.DeviceType).nullable() })),
|
|
6404
|
+
adoptDiscoveredDevice: require_sleep.method(zod.z.object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
|
|
6405
|
+
kind: "mutation",
|
|
6406
|
+
auth: "admin"
|
|
6407
|
+
}),
|
|
6408
|
+
supportsManualCreation: require_sleep.method(zod.z.object({}), zod.z.boolean()),
|
|
6409
|
+
/**
|
|
6410
|
+
* Fetch the creation form schema for a given DeviceType. Returns
|
|
6411
|
+
* `null` when the provider does not support manually creating
|
|
6412
|
+
* devices of that type. The output is a `ConfigUISchema` — the
|
|
6413
|
+
* router type-asserts it at the boundary.
|
|
6414
|
+
*/
|
|
6415
|
+
getChildCreationSchema: require_sleep.method(zod.z.object({ type: zod.z.enum(require_sleep.DeviceType) }), CreationSchemaOutputSchema),
|
|
6416
|
+
createDevice: require_sleep.method(zod.z.object({
|
|
6417
|
+
type: zod.z.enum(require_sleep.DeviceType),
|
|
6418
|
+
config: zod.z.record(zod.z.string(), zod.z.unknown())
|
|
6419
|
+
}), DeviceSummarySchema, {
|
|
6420
|
+
kind: "mutation",
|
|
6421
|
+
auth: "admin"
|
|
6422
|
+
}),
|
|
6423
|
+
/**
|
|
6424
|
+
* Test a single field in the creation form before the device has
|
|
6425
|
+
* been persisted. Typical use: probing an RTSP URL entered by the
|
|
6426
|
+
* user. Providers that don't support field probing return
|
|
6427
|
+
* `{ success: true, message: 'Field test not supported' }`.
|
|
6428
|
+
*
|
|
6429
|
+
* `formValues` is the live snapshot of every field in the form at
|
|
6430
|
+
* the moment the user clicked Test — useful for probes that depend
|
|
6431
|
+
* on multiple fields together (e.g. Reolink autodetect needs host
|
|
6432
|
+
* + credentials + UID + transport mode in a single call). Optional
|
|
6433
|
+
* for backwards compatibility; providers free to ignore it.
|
|
6434
|
+
*/
|
|
6435
|
+
testCreationField: require_sleep.method(zod.z.object({
|
|
6436
|
+
type: zod.z.enum(require_sleep.DeviceType),
|
|
6437
|
+
key: zod.z.string(),
|
|
6438
|
+
value: zod.z.unknown(),
|
|
6439
|
+
formValues: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
6440
|
+
}), FieldProbeResultSchema, {
|
|
6441
|
+
kind: "mutation",
|
|
6442
|
+
auth: "admin"
|
|
6443
|
+
})
|
|
6565
6444
|
}
|
|
6566
|
-
|
|
6567
|
-
type: "eof",
|
|
6568
|
-
pos: n
|
|
6569
|
-
});
|
|
6570
|
-
return tokens;
|
|
6571
|
-
}
|
|
6572
|
-
function keywordOf(text) {
|
|
6573
|
-
if (text === "true") return "true";
|
|
6574
|
-
if (text === "false") return "false";
|
|
6575
|
-
return "null";
|
|
6576
|
-
}
|
|
6577
|
-
function isSinglePunct(ch) {
|
|
6578
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
6579
|
-
}
|
|
6445
|
+
};
|
|
6580
6446
|
//#endregion
|
|
6581
|
-
//#region src/
|
|
6447
|
+
//#region src/capabilities/device-manager.cap.ts
|
|
6582
6448
|
/**
|
|
6583
|
-
*
|
|
6449
|
+
* Device Manager capability — hub-side singleton that unifies device persistence,
|
|
6450
|
+
* live registry access, and all management operations into a single tRPC surface.
|
|
6584
6451
|
*
|
|
6585
|
-
*
|
|
6586
|
-
*
|
|
6587
|
-
*
|
|
6588
|
-
*
|
|
6589
|
-
* function is rejected immediately (author feedback) and a persisted expression
|
|
6590
|
-
* that references a since-removed builtin degrades at read.
|
|
6452
|
+
* Replaces:
|
|
6453
|
+
* - `device-persistence` capability (persistence methods absorbed here)
|
|
6454
|
+
* - `device-management.router.ts` (deleted in Phase 2)
|
|
6455
|
+
* - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
|
|
6591
6456
|
*
|
|
6592
|
-
*
|
|
6593
|
-
*
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
"||": 1,
|
|
6598
|
-
"&&": 2,
|
|
6599
|
-
"==": 3,
|
|
6600
|
-
"!=": 3,
|
|
6601
|
-
"<": 4,
|
|
6602
|
-
"<=": 4,
|
|
6603
|
-
">": 4,
|
|
6604
|
-
">=": 4,
|
|
6605
|
-
"+": 5,
|
|
6606
|
-
"-": 5,
|
|
6607
|
-
"*": 6,
|
|
6608
|
-
"/": 6,
|
|
6609
|
-
"%": 6
|
|
6610
|
-
};
|
|
6611
|
-
function isLogicalOp(op) {
|
|
6612
|
-
return op === "&&" || op === "||";
|
|
6613
|
-
}
|
|
6614
|
-
function isBinaryOp(op) {
|
|
6615
|
-
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
6616
|
-
}
|
|
6617
|
-
var Parser = class {
|
|
6618
|
-
tokens;
|
|
6619
|
-
pos = 0;
|
|
6620
|
-
nodeCount = 0;
|
|
6621
|
-
identifiers = /* @__PURE__ */ new Set();
|
|
6622
|
-
callees = /* @__PURE__ */ new Set();
|
|
6623
|
-
constructor(tokens) {
|
|
6624
|
-
this.tokens = tokens;
|
|
6625
|
-
}
|
|
6626
|
-
parse() {
|
|
6627
|
-
const ast = this.parseTernary();
|
|
6628
|
-
const tok = this.peek();
|
|
6629
|
-
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
6630
|
-
return {
|
|
6631
|
-
ast,
|
|
6632
|
-
identifiers: this.identifiers,
|
|
6633
|
-
callees: this.callees,
|
|
6634
|
-
nodeCount: this.nodeCount
|
|
6635
|
-
};
|
|
6636
|
-
}
|
|
6637
|
-
peek() {
|
|
6638
|
-
return this.tokens[this.pos];
|
|
6639
|
-
}
|
|
6640
|
-
next() {
|
|
6641
|
-
return this.tokens[this.pos++];
|
|
6642
|
-
}
|
|
6643
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
6644
|
-
expectPunct(punct) {
|
|
6645
|
-
const tok = this.peek();
|
|
6646
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
6647
|
-
this.pos += 1;
|
|
6648
|
-
}
|
|
6649
|
-
matchPunct(punct) {
|
|
6650
|
-
const tok = this.peek();
|
|
6651
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
6652
|
-
this.pos += 1;
|
|
6653
|
-
return true;
|
|
6654
|
-
}
|
|
6655
|
-
return false;
|
|
6656
|
-
}
|
|
6657
|
-
countNode() {
|
|
6658
|
-
this.nodeCount += 1;
|
|
6659
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
6660
|
-
}
|
|
6661
|
-
parseTernary() {
|
|
6662
|
-
const test = this.parseBinary(1);
|
|
6663
|
-
if (this.matchPunct("?")) {
|
|
6664
|
-
const consequent = this.parseTernary();
|
|
6665
|
-
this.expectPunct(":");
|
|
6666
|
-
const alternate = this.parseTernary();
|
|
6667
|
-
this.countNode();
|
|
6668
|
-
return {
|
|
6669
|
-
kind: "conditional",
|
|
6670
|
-
test,
|
|
6671
|
-
consequent,
|
|
6672
|
-
alternate
|
|
6673
|
-
};
|
|
6674
|
-
}
|
|
6675
|
-
return test;
|
|
6676
|
-
}
|
|
6677
|
-
parseBinary(minPrec) {
|
|
6678
|
-
let left = this.parseUnary();
|
|
6679
|
-
for (;;) {
|
|
6680
|
-
const tok = this.peek();
|
|
6681
|
-
if (tok.type !== "punct") break;
|
|
6682
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
6683
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
6684
|
-
const op = tok.punct;
|
|
6685
|
-
this.pos += 1;
|
|
6686
|
-
const right = this.parseBinary(prec + 1);
|
|
6687
|
-
this.countNode();
|
|
6688
|
-
if (isLogicalOp(op)) left = {
|
|
6689
|
-
kind: "logical",
|
|
6690
|
-
op,
|
|
6691
|
-
left,
|
|
6692
|
-
right
|
|
6693
|
-
};
|
|
6694
|
-
else if (isBinaryOp(op)) left = {
|
|
6695
|
-
kind: "binary",
|
|
6696
|
-
op,
|
|
6697
|
-
left,
|
|
6698
|
-
right
|
|
6699
|
-
};
|
|
6700
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
6701
|
-
}
|
|
6702
|
-
return left;
|
|
6703
|
-
}
|
|
6704
|
-
parseUnary() {
|
|
6705
|
-
const tok = this.peek();
|
|
6706
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
6707
|
-
const op = tok.punct;
|
|
6708
|
-
this.pos += 1;
|
|
6709
|
-
const operand = this.parseUnary();
|
|
6710
|
-
this.countNode();
|
|
6711
|
-
return {
|
|
6712
|
-
kind: "unary",
|
|
6713
|
-
op,
|
|
6714
|
-
operand
|
|
6715
|
-
};
|
|
6716
|
-
}
|
|
6717
|
-
return this.parsePrimary();
|
|
6718
|
-
}
|
|
6719
|
-
parsePrimary() {
|
|
6720
|
-
const tok = this.next();
|
|
6721
|
-
switch (tok.type) {
|
|
6722
|
-
case "number":
|
|
6723
|
-
this.countNode();
|
|
6724
|
-
return {
|
|
6725
|
-
kind: "literal",
|
|
6726
|
-
value: tok.value
|
|
6727
|
-
};
|
|
6728
|
-
case "string":
|
|
6729
|
-
this.countNode();
|
|
6730
|
-
return {
|
|
6731
|
-
kind: "literal",
|
|
6732
|
-
value: tok.value
|
|
6733
|
-
};
|
|
6734
|
-
case "keyword":
|
|
6735
|
-
this.countNode();
|
|
6736
|
-
return {
|
|
6737
|
-
kind: "literal",
|
|
6738
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
6739
|
-
};
|
|
6740
|
-
case "identifier": {
|
|
6741
|
-
const nextTok = this.peek();
|
|
6742
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
6743
|
-
this.identifiers.add(tok.name);
|
|
6744
|
-
this.countNode();
|
|
6745
|
-
return {
|
|
6746
|
-
kind: "identifier",
|
|
6747
|
-
name: tok.name
|
|
6748
|
-
};
|
|
6749
|
-
}
|
|
6750
|
-
case "punct":
|
|
6751
|
-
if (tok.punct === "(") {
|
|
6752
|
-
const inner = this.parseTernary();
|
|
6753
|
-
this.expectPunct(")");
|
|
6754
|
-
return inner;
|
|
6755
|
-
}
|
|
6756
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
6757
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
6758
|
-
}
|
|
6759
|
-
}
|
|
6760
|
-
parseCall(callee, pos) {
|
|
6761
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
6762
|
-
this.expectPunct("(");
|
|
6763
|
-
const args = [];
|
|
6764
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
6765
|
-
args.push(this.parseTernary());
|
|
6766
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
6767
|
-
if (this.matchPunct(",")) continue;
|
|
6768
|
-
this.expectPunct(")");
|
|
6769
|
-
break;
|
|
6770
|
-
}
|
|
6771
|
-
this.callees.add(callee);
|
|
6772
|
-
this.countNode();
|
|
6773
|
-
return {
|
|
6774
|
-
kind: "call",
|
|
6775
|
-
callee,
|
|
6776
|
-
args
|
|
6777
|
-
};
|
|
6778
|
-
}
|
|
6779
|
-
};
|
|
6780
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
6781
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
6782
|
-
function parseExpression(source) {
|
|
6783
|
-
return new Parser(tokenize(source)).parse();
|
|
6784
|
-
}
|
|
6785
|
-
//#endregion
|
|
6786
|
-
//#region src/expression/compile.ts
|
|
6787
|
-
/**
|
|
6788
|
-
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
6789
|
-
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
6790
|
-
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
6791
|
-
* one per read on a hot resolve path.
|
|
6792
|
-
*
|
|
6793
|
-
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
6794
|
-
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
6795
|
-
* callers is safe and maximises hit rate.
|
|
6796
|
-
*/
|
|
6797
|
-
var cache = /* @__PURE__ */ new Map();
|
|
6798
|
-
function getCached(source) {
|
|
6799
|
-
const hit = cache.get(source);
|
|
6800
|
-
if (hit !== void 0) {
|
|
6801
|
-
cache.delete(source);
|
|
6802
|
-
cache.set(source, hit);
|
|
6803
|
-
return hit;
|
|
6804
|
-
}
|
|
6805
|
-
let result;
|
|
6806
|
-
try {
|
|
6807
|
-
result = {
|
|
6808
|
-
ok: true,
|
|
6809
|
-
parsed: parseExpression(source)
|
|
6810
|
-
};
|
|
6811
|
-
} catch (err) {
|
|
6812
|
-
result = {
|
|
6813
|
-
ok: false,
|
|
6814
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
6815
|
-
};
|
|
6816
|
-
}
|
|
6817
|
-
cache.set(source, result);
|
|
6818
|
-
if (cache.size > 256) {
|
|
6819
|
-
const oldest = cache.keys().next().value;
|
|
6820
|
-
if (oldest !== void 0) cache.delete(oldest);
|
|
6821
|
-
}
|
|
6822
|
-
return result;
|
|
6823
|
-
}
|
|
6824
|
-
/** Compile `source` to a `ParsedExpression`, throwing `ExpressionParseError`
|
|
6825
|
-
* on failure. LRU/negative-cached. */
|
|
6826
|
-
function compileExpression(source) {
|
|
6827
|
-
const result = getCached(source);
|
|
6828
|
-
if (result.ok) return result.parsed;
|
|
6829
|
-
throw new ExpressionParseError(result.error);
|
|
6830
|
-
}
|
|
6831
|
-
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
6832
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
6833
|
-
function compileExpressionSafe(source) {
|
|
6834
|
-
return getCached(source);
|
|
6835
|
-
}
|
|
6836
|
-
//#endregion
|
|
6837
|
-
//#region src/expression/evaluator.ts
|
|
6838
|
-
/**
|
|
6839
|
-
* Tree-walking evaluator for the safe expression mini-language.
|
|
6457
|
+
* All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
|
|
6458
|
+
* fork into separate processes but never run on remote cluster agents. Therefore:
|
|
6459
|
+
* - No nodeId routing needed — this is a pure hub singleton.
|
|
6460
|
+
* - The hub's DeviceRegistry is the single source of truth for all live devices.
|
|
6461
|
+
* - No shadow registry or cross-node aggregation required.
|
|
6840
6462
|
*
|
|
6841
|
-
*
|
|
6842
|
-
*
|
|
6843
|
-
* enumerable binding entries, so `name in scope` is a pure own-key check and
|
|
6844
|
-
* `constructor` / `__proto__` / `toString` are plain unknown identifiers.
|
|
6845
|
-
* - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
|
|
6846
|
-
* directly — the only external calls are into the frozen builtin table.
|
|
6847
|
-
* - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
|
|
6848
|
-
* construction; the step counter is defense-in-depth for a crafted max-size
|
|
6849
|
-
* AST. Nothing blocks: there are no timers, awaits or unbounded loops.
|
|
6850
|
-
*/
|
|
6851
|
-
var EMPTY_HOOKS = Object.freeze({});
|
|
6852
|
-
/** Build a null-prototype scope from own-enumerable binding entries. Inherited
|
|
6853
|
-
* keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
|
|
6854
|
-
* so nothing smuggles in via the prototype chain. */
|
|
6855
|
-
function createExpressionScope(bindings) {
|
|
6856
|
-
const scope = Object.create(null);
|
|
6857
|
-
for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
|
|
6858
|
-
return scope;
|
|
6859
|
-
}
|
|
6860
|
-
function isFiniteNumber(value) {
|
|
6861
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
6862
|
-
}
|
|
6863
|
-
/** JS truthiness of a primitive value. */
|
|
6864
|
-
function truthy(value) {
|
|
6865
|
-
return Boolean(value);
|
|
6866
|
-
}
|
|
6867
|
-
function requireFinite(value, context) {
|
|
6868
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
|
|
6869
|
-
return value;
|
|
6870
|
-
}
|
|
6871
|
-
function step(ctx) {
|
|
6872
|
-
ctx.steps += 1;
|
|
6873
|
-
if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
|
|
6874
|
-
}
|
|
6875
|
-
function evalNode(node, ctx) {
|
|
6876
|
-
step(ctx);
|
|
6877
|
-
switch (node.kind) {
|
|
6878
|
-
case "literal": return node.value;
|
|
6879
|
-
case "identifier":
|
|
6880
|
-
if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
|
|
6881
|
-
return ctx.scope[node.name];
|
|
6882
|
-
case "unary": return evalUnary(node.op, evalNode(node.operand, ctx));
|
|
6883
|
-
case "binary": return evalBinary(node.op, evalNode(node.left, ctx), evalNode(node.right, ctx));
|
|
6884
|
-
case "logical": {
|
|
6885
|
-
const left = evalNode(node.left, ctx);
|
|
6886
|
-
if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx) : left;
|
|
6887
|
-
return truthy(left) ? left : evalNode(node.right, ctx);
|
|
6888
|
-
}
|
|
6889
|
-
case "conditional": return truthy(evalNode(node.test, ctx)) ? evalNode(node.consequent, ctx) : evalNode(node.alternate, ctx);
|
|
6890
|
-
case "call": return evalCall(node.callee, node.args.map((a) => evalNode(a, ctx)), ctx.hooks);
|
|
6891
|
-
}
|
|
6892
|
-
}
|
|
6893
|
-
function evalUnary(op, operand) {
|
|
6894
|
-
if (op === "!") return !truthy(operand);
|
|
6895
|
-
if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
|
|
6896
|
-
return requireFinite(-operand, "unary \"-\"");
|
|
6897
|
-
}
|
|
6898
|
-
function evalBinary(op, left, right) {
|
|
6899
|
-
switch (op) {
|
|
6900
|
-
case "==": return left === right;
|
|
6901
|
-
case "!=": return left !== right;
|
|
6902
|
-
case "+":
|
|
6903
|
-
if (typeof left === "string" && typeof right === "string") return left + right;
|
|
6904
|
-
if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
|
|
6905
|
-
throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
|
|
6906
|
-
case "-":
|
|
6907
|
-
case "*":
|
|
6908
|
-
case "/":
|
|
6909
|
-
case "%":
|
|
6910
|
-
if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
|
|
6911
|
-
return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
|
|
6912
|
-
case "<":
|
|
6913
|
-
case "<=":
|
|
6914
|
-
case ">":
|
|
6915
|
-
case ">=":
|
|
6916
|
-
if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
|
|
6917
|
-
if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
|
|
6918
|
-
throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
|
|
6919
|
-
}
|
|
6920
|
-
}
|
|
6921
|
-
function evalCall(callee, args, hooks) {
|
|
6922
|
-
if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
|
|
6923
|
-
const builtin = EXPRESSION_BUILTINS[callee];
|
|
6924
|
-
if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
|
|
6925
|
-
return builtin.apply(args, hooks);
|
|
6926
|
-
}
|
|
6927
|
-
/** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
|
|
6928
|
-
* runtime failure (unknown identifier, type mismatch, non-finite result,
|
|
6929
|
-
* step-budget exhaustion). */
|
|
6930
|
-
function evaluateAst(node, scope, opts) {
|
|
6931
|
-
return evalNode(node, {
|
|
6932
|
-
scope,
|
|
6933
|
-
hooks: opts?.hooks ?? EMPTY_HOOKS,
|
|
6934
|
-
maxSteps: opts?.maxSteps ?? 4096,
|
|
6935
|
-
steps: 0
|
|
6936
|
-
});
|
|
6937
|
-
}
|
|
6938
|
-
//#endregion
|
|
6939
|
-
//#region src/expression/link-expression.ts
|
|
6940
|
-
/**
|
|
6941
|
-
* DeviceLink-facing helpers for the expression engine — the single seam both
|
|
6942
|
-
* resolver channels (async provider-read + sync mirror) and the wire-schema
|
|
6943
|
-
* `superRefine` share, so validation and evaluation semantics stay identical
|
|
6944
|
-
* everywhere.
|
|
6945
|
-
*/
|
|
6946
|
-
/** The `now` epoch-ms binding is auto-injected into every evaluation and is a
|
|
6947
|
-
* reserved binding name (authors may not rebind it). */
|
|
6948
|
-
var EXPRESSION_INJECTED_NOW = "now";
|
|
6949
|
-
/**
|
|
6950
|
-
* Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
|
|
6951
|
-
* Non-primitive values (objects, arrays, `undefined`, functions, bigint,
|
|
6952
|
-
* symbol) and non-finite numbers become `undefined` so the caller can apply
|
|
6953
|
-
* its binding-miss policy (→ `null`). `null` itself is a valid value.
|
|
6954
|
-
*/
|
|
6955
|
-
function toExpressionValue(raw) {
|
|
6956
|
-
if (raw === null) return null;
|
|
6957
|
-
if (typeof raw === "string") return raw;
|
|
6958
|
-
if (typeof raw === "boolean") return raw;
|
|
6959
|
-
if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
|
|
6960
|
-
}
|
|
6961
|
-
/**
|
|
6962
|
-
* Author-time validation. Returns `null` when the source is valid, else a
|
|
6963
|
-
* human-readable error message. Checks: the expression compiles; binding count
|
|
6964
|
-
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
6965
|
-
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
6966
|
-
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
6967
|
-
*/
|
|
6968
|
-
function validateExpressionSource(src) {
|
|
6969
|
-
const names = Object.keys(src.bindings);
|
|
6970
|
-
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
6971
|
-
for (const name of names) {
|
|
6972
|
-
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
6973
|
-
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
6974
|
-
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
6975
|
-
}
|
|
6976
|
-
const compiled = compileExpressionSafe(src.expr);
|
|
6977
|
-
if (!compiled.ok) return compiled.error;
|
|
6978
|
-
const bound = new Set(names);
|
|
6979
|
-
for (const id of compiled.parsed.identifiers) {
|
|
6980
|
-
if (id === "now") continue;
|
|
6981
|
-
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
6982
|
-
}
|
|
6983
|
-
return null;
|
|
6984
|
-
}
|
|
6985
|
-
/**
|
|
6986
|
-
* Shared read-path evaluation for BOTH resolver channels. Builds a null-proto
|
|
6987
|
-
* scope from `bindingValues` plus the injected `now` (supplied by the caller
|
|
6988
|
-
* for determinism/testability), compiles via the LRU, and evaluates. Any
|
|
6989
|
-
* failure (parse or eval) returns `{ ok: false }` — the caller treats that as
|
|
6990
|
-
* "skip this link".
|
|
6463
|
+
* Forked workers register devices back to the hub via `ctx.devices`
|
|
6464
|
+
* (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
|
|
6991
6465
|
*/
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
try {
|
|
7003
|
-
return {
|
|
7004
|
-
ok: true,
|
|
7005
|
-
value: evaluateAst(compiled.parsed.ast, scope, opts)
|
|
7006
|
-
};
|
|
7007
|
-
} catch (err) {
|
|
7008
|
-
return {
|
|
7009
|
-
ok: false,
|
|
7010
|
-
error: err instanceof ExpressionEvalError ? err.message : String(err)
|
|
7011
|
-
};
|
|
7012
|
-
}
|
|
7013
|
-
}
|
|
7014
|
-
//#endregion
|
|
7015
|
-
//#region src/capabilities/device-provider.cap.ts
|
|
7016
|
-
var ProviderStatusSchema = zod.z.object({
|
|
7017
|
-
connected: zod.z.boolean(),
|
|
7018
|
-
deviceCount: zod.z.number(),
|
|
7019
|
-
error: zod.z.string().optional()
|
|
6466
|
+
/** One child-placement directive on a container's `childLayout`. Structurally
|
|
6467
|
+
* identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
|
|
6468
|
+
* shape for the same field. The child is identified by its re-sync-stable
|
|
6469
|
+
* accessory `stableIdSuffix` (`childKey`); listed children are grouped into
|
|
6470
|
+
* named accordion sections (with optional intra-section order). */
|
|
6471
|
+
var ChildLayoutEntrySchema = zod.z.object({
|
|
6472
|
+
childKey: zod.z.string(),
|
|
6473
|
+
section: zod.z.string(),
|
|
6474
|
+
order: zod.z.number().optional(),
|
|
6475
|
+
collapsed: zod.z.boolean().optional()
|
|
7020
6476
|
});
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
6477
|
+
/** Cap-wire shape of a per-cap display refinement — mirrors
|
|
6478
|
+
* `DeviceCapDisplayOverride` in `device-management.ts`. */
|
|
6479
|
+
var DeviceCapDisplayOverrideSchema = zod.z.object({
|
|
6480
|
+
unit: zod.z.string().min(1).optional(),
|
|
6481
|
+
precision: zod.z.number().int().min(0).max(10).optional()
|
|
7026
6482
|
});
|
|
7027
|
-
/**
|
|
7028
|
-
*
|
|
7029
|
-
* `
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
6483
|
+
/** Cap-wire shape of an operator-authored per-device display override —
|
|
6484
|
+
* mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
|
|
6485
|
+
* bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
|
|
6486
|
+
var DeviceDisplayOverrideSchema = zod.z.object({
|
|
6487
|
+
icon: zod.z.string().min(1).optional(),
|
|
6488
|
+
label: zod.z.string().min(1).optional(),
|
|
6489
|
+
unit: zod.z.string().min(1).optional(),
|
|
6490
|
+
precision: zod.z.number().int().min(0).max(10).optional(),
|
|
6491
|
+
hidden: zod.z.boolean().optional(),
|
|
6492
|
+
perCap: zod.z.record(zod.z.string(), DeviceCapDisplayOverrideSchema).optional()
|
|
6493
|
+
});
|
|
6494
|
+
/** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
|
|
6495
|
+
* in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
|
|
6496
|
+
* the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
|
|
6497
|
+
var RoleDisplayDefaultSchema = zod.z.object({
|
|
6498
|
+
unit: zod.z.string().min(1).optional(),
|
|
6499
|
+
precision: zod.z.number().int().min(0).max(10).optional(),
|
|
6500
|
+
icon: zod.z.string().min(1).optional()
|
|
7044
6501
|
});
|
|
7045
6502
|
/**
|
|
7046
|
-
*
|
|
7047
|
-
*
|
|
7048
|
-
*
|
|
6503
|
+
* Serializable projection of a live IDevice.
|
|
6504
|
+
* Returned by listAll, getDevice, getChildren.
|
|
6505
|
+
* Live methods (getStreamSources, getConfigSchema) are separate calls.
|
|
7049
6506
|
*/
|
|
7050
|
-
var
|
|
6507
|
+
var DeviceInfoSchema = zod.z.object({
|
|
6508
|
+
/** Progressive, system-wide unique number. Allocated synchronously by
|
|
6509
|
+
* `device-manager.allocateDeviceId` BEFORE the owning `IDevice` is
|
|
6510
|
+
* constructed, so every live device exposes an `id` — no transient
|
|
6511
|
+
* null window. Distinct from `stableId`, which is unique per
|
|
6512
|
+
* integration. Ids are monotonic and never reissued on removal. */
|
|
7051
6513
|
id: zod.z.number(),
|
|
7052
6514
|
stableId: zod.z.string(),
|
|
7053
6515
|
addonId: zod.z.string(),
|
|
7054
|
-
type: zod.z.
|
|
6516
|
+
type: zod.z.enum(require_sleep.DeviceType),
|
|
7055
6517
|
name: zod.z.string(),
|
|
6518
|
+
/** Operator-organisational location label. `null` when unset. */
|
|
6519
|
+
location: zod.z.string().nullable(),
|
|
6520
|
+
/** Soft-disabled flag. */
|
|
6521
|
+
disabled: zod.z.boolean(),
|
|
7056
6522
|
parentDeviceId: zod.z.number().nullable(),
|
|
6523
|
+
/** Optional semantic role — `DeviceRole` string. null for top-level devices. */
|
|
6524
|
+
role: zod.z.string().nullable().optional(),
|
|
7057
6525
|
online: zod.z.boolean(),
|
|
6526
|
+
/** True when the device's initial feature-probe has completed (or it has
|
|
6527
|
+
* no probe) — exported shape is stable; exporters gate advertise on this.
|
|
6528
|
+
* Optional for deploy-order resilience: a record produced by an older
|
|
6529
|
+
* device-manager build omits it, and consumers treat absent as "not ready"
|
|
6530
|
+
* (export gate carries forward, never advertises a partial shape). */
|
|
6531
|
+
probed: zod.z.boolean().optional(),
|
|
7058
6532
|
features: zod.z.array(zod.z.string()),
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
* source identifier (HA entities, vendor MAC, …); omitted when the
|
|
7063
|
-
* synthetic backfill is in effect. */
|
|
7064
|
-
sourceInfo: SourceInfoSchema.optional()
|
|
7065
|
-
});
|
|
7066
|
-
/**
|
|
7067
|
-
* Result of a live field test (e.g. probing an RTSP URL during device
|
|
7068
|
-
* creation). Matches the UI-side `FieldProbeResult` in
|
|
7069
|
-
* `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
|
|
7070
|
-
* returned `labels` as chips next to the input.
|
|
7071
|
-
*/
|
|
7072
|
-
var FieldProbeResultSchema = zod.z.object({
|
|
7073
|
-
status: zod.z.enum(["ok", "error"]),
|
|
7074
|
-
labels: zod.z.array(zod.z.string()).optional(),
|
|
7075
|
-
error: zod.z.string().optional()
|
|
7076
|
-
});
|
|
7077
|
-
/**
|
|
7078
|
-
* The output of `getChildCreationSchema` is a UI schema tree. We store
|
|
7079
|
-
* it as `unknown` at the capability layer — the router just passes it
|
|
7080
|
-
* through and the admin UI renders it via `FormBuilder`. The actual
|
|
7081
|
-
* type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
|
|
7082
|
-
* but we deliberately avoid a Zod mirror because the union is large and
|
|
7083
|
-
* not meant for runtime validation at this seam.
|
|
7084
|
-
*/
|
|
7085
|
-
var CreationSchemaOutputSchema = zod.z.unknown();
|
|
7086
|
-
var deviceProviderCapability = {
|
|
7087
|
-
name: "device-provider",
|
|
7088
|
-
scope: "system",
|
|
7089
|
-
mode: "collection",
|
|
7090
|
-
methods: {
|
|
7091
|
-
start: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
|
|
7092
|
-
stop: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
|
|
7093
|
-
getStatus: require_sleep.method(zod.z.void(), ProviderStatusSchema),
|
|
7094
|
-
getDevices: require_sleep.method(zod.z.void(), zod.z.array(zod.z.object({
|
|
7095
|
-
id: zod.z.string(),
|
|
7096
|
-
name: zod.z.string(),
|
|
7097
|
-
type: zod.z.string()
|
|
7098
|
-
}))),
|
|
7099
|
-
supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
|
|
7100
|
-
/**
|
|
7101
|
-
* Run a network scan. `params` carries optional provider-specific scan
|
|
7102
|
-
* inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
|
|
7103
|
-
* shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
|
|
7104
|
-
* (provider uses its local-network default).
|
|
7105
|
-
*/
|
|
7106
|
-
discoverDevices: require_sleep.method(zod.z.object({ params: zod.z.record(zod.z.string(), zod.z.unknown()).optional() }), zod.z.array(DiscoveryCandidateSchema), {
|
|
7107
|
-
kind: "mutation",
|
|
7108
|
-
auth: "admin"
|
|
7109
|
-
}),
|
|
7110
|
-
/**
|
|
7111
|
-
* Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
|
|
7112
|
-
* provider accepts (e.g. Gree's broadcast address for a different subnet).
|
|
7113
|
-
* `null` when the provider takes no extra scan params — the generic
|
|
7114
|
-
* aggregated scan never renders this; the per-integration scan does.
|
|
7115
|
-
*/
|
|
7116
|
-
getDiscoveryParamsSchema: require_sleep.method(zod.z.object({}), CreationSchemaOutputSchema),
|
|
7117
|
-
/**
|
|
7118
|
-
* The DeviceType this provider creates via manual add (Camera for
|
|
7119
|
-
* Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
|
|
7120
|
-
* provider does not support manual creation. Lets the Add-Device dialog
|
|
7121
|
-
* pick the right type instead of assuming Camera.
|
|
7122
|
-
*/
|
|
7123
|
-
getManualCreationType: require_sleep.method(zod.z.object({}), zod.z.object({ deviceType: zod.z.enum(require_sleep.DeviceType).nullable() })),
|
|
7124
|
-
adoptDiscoveredDevice: require_sleep.method(zod.z.object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
|
|
7125
|
-
kind: "mutation",
|
|
7126
|
-
auth: "admin"
|
|
7127
|
-
}),
|
|
7128
|
-
supportsManualCreation: require_sleep.method(zod.z.object({}), zod.z.boolean()),
|
|
7129
|
-
/**
|
|
7130
|
-
* Fetch the creation form schema for a given DeviceType. Returns
|
|
7131
|
-
* `null` when the provider does not support manually creating
|
|
7132
|
-
* devices of that type. The output is a `ConfigUISchema` — the
|
|
7133
|
-
* router type-asserts it at the boundary.
|
|
7134
|
-
*/
|
|
7135
|
-
getChildCreationSchema: require_sleep.method(zod.z.object({ type: zod.z.enum(require_sleep.DeviceType) }), CreationSchemaOutputSchema),
|
|
7136
|
-
createDevice: require_sleep.method(zod.z.object({
|
|
7137
|
-
type: zod.z.enum(require_sleep.DeviceType),
|
|
7138
|
-
config: zod.z.record(zod.z.string(), zod.z.unknown())
|
|
7139
|
-
}), DeviceSummarySchema, {
|
|
7140
|
-
kind: "mutation",
|
|
7141
|
-
auth: "admin"
|
|
7142
|
-
}),
|
|
7143
|
-
/**
|
|
7144
|
-
* Test a single field in the creation form before the device has
|
|
7145
|
-
* been persisted. Typical use: probing an RTSP URL entered by the
|
|
7146
|
-
* user. Providers that don't support field probing return
|
|
7147
|
-
* `{ success: true, message: 'Field test not supported' }`.
|
|
7148
|
-
*
|
|
7149
|
-
* `formValues` is the live snapshot of every field in the form at
|
|
7150
|
-
* the moment the user clicked Test — useful for probes that depend
|
|
7151
|
-
* on multiple fields together (e.g. Reolink autodetect needs host
|
|
7152
|
-
* + credentials + UID + transport mode in a single call). Optional
|
|
7153
|
-
* for backwards compatibility; providers free to ignore it.
|
|
7154
|
-
*/
|
|
7155
|
-
testCreationField: require_sleep.method(zod.z.object({
|
|
7156
|
-
type: zod.z.enum(require_sleep.DeviceType),
|
|
7157
|
-
key: zod.z.string(),
|
|
7158
|
-
value: zod.z.unknown(),
|
|
7159
|
-
formValues: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
7160
|
-
}), FieldProbeResultSchema, {
|
|
7161
|
-
kind: "mutation",
|
|
7162
|
-
auth: "admin"
|
|
7163
|
-
})
|
|
7164
|
-
}
|
|
7165
|
-
};
|
|
7166
|
-
//#endregion
|
|
7167
|
-
//#region src/capabilities/device-manager.cap.ts
|
|
7168
|
-
/**
|
|
7169
|
-
* Device Manager capability — hub-side singleton that unifies device persistence,
|
|
7170
|
-
* live registry access, and all management operations into a single tRPC surface.
|
|
7171
|
-
*
|
|
7172
|
-
* Replaces:
|
|
7173
|
-
* - `device-persistence` capability (persistence methods absorbed here)
|
|
7174
|
-
* - `device-management.router.ts` (deleted in Phase 2)
|
|
7175
|
-
* - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
|
|
7176
|
-
*
|
|
7177
|
-
* All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
|
|
7178
|
-
* fork into separate processes but never run on remote cluster agents. Therefore:
|
|
7179
|
-
* - No nodeId routing needed — this is a pure hub singleton.
|
|
7180
|
-
* - The hub's DeviceRegistry is the single source of truth for all live devices.
|
|
7181
|
-
* - No shadow registry or cross-node aggregation required.
|
|
7182
|
-
*
|
|
7183
|
-
* Forked workers register devices back to the hub via `ctx.devices`
|
|
7184
|
-
* (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
|
|
7185
|
-
*/
|
|
7186
|
-
/** One child-placement directive on a container's `childLayout`. Structurally
|
|
7187
|
-
* identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
|
|
7188
|
-
* shape for the same field. The child is identified by its re-sync-stable
|
|
7189
|
-
* accessory `stableIdSuffix` (`childKey`); listed children are grouped into
|
|
7190
|
-
* named accordion sections (with optional intra-section order). */
|
|
7191
|
-
var ChildLayoutEntrySchema = zod.z.object({
|
|
7192
|
-
childKey: zod.z.string(),
|
|
7193
|
-
section: zod.z.string(),
|
|
7194
|
-
order: zod.z.number().optional(),
|
|
7195
|
-
collapsed: zod.z.boolean().optional()
|
|
7196
|
-
});
|
|
7197
|
-
/** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
|
|
7198
|
-
* `device-management.ts`. Source is a union: a FIELD source copies a sibling
|
|
7199
|
-
* accessory's status field (`kind` optional/absent for wire compat); a
|
|
7200
|
-
* LITERAL source carries a per-device constant (no sibling is read); a
|
|
7201
|
-
* GLOBAL source (P2e) copies ANY device's status field, addressed by the
|
|
7202
|
-
* source device's full re-sync-stable `stableId`. */
|
|
7203
|
-
var DeviceLinkFieldSourceSchema = zod.z.object({
|
|
7204
|
-
kind: zod.z.literal("field").optional(),
|
|
7205
|
-
sourceKey: zod.z.string(),
|
|
7206
|
-
cap: zod.z.string(),
|
|
7207
|
-
fieldPath: zod.z.string()
|
|
7208
|
-
});
|
|
7209
|
-
var DeviceLinkLiteralSourceSchema = zod.z.object({
|
|
7210
|
-
kind: zod.z.literal("literal"),
|
|
7211
|
-
value: zod.z.union([
|
|
7212
|
-
zod.z.string(),
|
|
7213
|
-
zod.z.number(),
|
|
7214
|
-
zod.z.boolean(),
|
|
7215
|
-
zod.z.null()
|
|
7216
|
-
])
|
|
7217
|
-
});
|
|
7218
|
-
var DeviceLinkGlobalSourceSchema = zod.z.object({
|
|
7219
|
-
kind: zod.z.literal("global"),
|
|
7220
|
-
sourceStableId: zod.z.string(),
|
|
7221
|
-
cap: zod.z.string(),
|
|
7222
|
-
fieldPath: zod.z.string()
|
|
7223
|
-
});
|
|
7224
|
-
/** Expression source (Stage X): compute the target field from N named bindings
|
|
7225
|
-
* via the safe expression engine. Bindings are field | literal | global — never
|
|
7226
|
-
* another expression (no nesting). The `superRefine` runs the SAME author-time
|
|
7227
|
-
* validation as `validateExpressionSource` (compiles the expr, checks binding
|
|
7228
|
-
* names + identifier coverage) so every wire boundary that parses a DeviceLink
|
|
7229
|
-
* (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
|
|
7230
|
-
* Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
|
|
7231
|
-
var DeviceLinkExpressionSourceSchema = zod.z.object({
|
|
7232
|
-
kind: zod.z.literal("expression"),
|
|
7233
|
-
expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
7234
|
-
bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), zod.z.union([
|
|
7235
|
-
DeviceLinkFieldSourceSchema,
|
|
7236
|
-
DeviceLinkLiteralSourceSchema,
|
|
7237
|
-
DeviceLinkGlobalSourceSchema
|
|
7238
|
-
]))
|
|
7239
|
-
}).superRefine((src, ctx) => {
|
|
7240
|
-
const err = validateExpressionSource(src);
|
|
7241
|
-
if (err !== null) ctx.addIssue({
|
|
7242
|
-
code: "custom",
|
|
7243
|
-
message: err,
|
|
7244
|
-
path: ["expr"]
|
|
7245
|
-
});
|
|
7246
|
-
});
|
|
7247
|
-
var DeviceLinkSchema = zod.z.object({
|
|
7248
|
-
id: zod.z.string(),
|
|
7249
|
-
source: zod.z.union([
|
|
7250
|
-
DeviceLinkFieldSourceSchema,
|
|
7251
|
-
DeviceLinkLiteralSourceSchema,
|
|
7252
|
-
DeviceLinkGlobalSourceSchema,
|
|
7253
|
-
DeviceLinkExpressionSourceSchema
|
|
7254
|
-
]),
|
|
7255
|
-
target: zod.z.object({
|
|
7256
|
-
cap: zod.z.string(),
|
|
7257
|
-
fieldPath: zod.z.string(),
|
|
7258
|
-
itemKey: zod.z.string().optional()
|
|
7259
|
-
}),
|
|
7260
|
-
transform: zod.z.discriminatedUnion("kind", [
|
|
7261
|
-
zod.z.object({ kind: zod.z.literal("identity") }),
|
|
7262
|
-
zod.z.object({
|
|
7263
|
-
kind: zod.z.literal("enum-map"),
|
|
7264
|
-
mapping: zod.z.record(zod.z.string(), zod.z.union([
|
|
7265
|
-
zod.z.string(),
|
|
7266
|
-
zod.z.number(),
|
|
7267
|
-
zod.z.boolean()
|
|
7268
|
-
])),
|
|
7269
|
-
fallback: zod.z.union([
|
|
7270
|
-
zod.z.string(),
|
|
7271
|
-
zod.z.number(),
|
|
7272
|
-
zod.z.boolean()
|
|
7273
|
-
]).optional()
|
|
7274
|
-
}),
|
|
7275
|
-
zod.z.object({
|
|
7276
|
-
kind: zod.z.literal("linear"),
|
|
7277
|
-
scale: zod.z.number(),
|
|
7278
|
-
offset: zod.z.number(),
|
|
7279
|
-
clamp: zod.z.tuple([zod.z.number(), zod.z.number()]).readonly().optional()
|
|
7280
|
-
})
|
|
7281
|
-
]).optional()
|
|
7282
|
-
});
|
|
7283
|
-
/** Cap-wire shape of a per-cap display refinement — mirrors
|
|
7284
|
-
* `DeviceCapDisplayOverride` in `device-management.ts`. */
|
|
7285
|
-
var DeviceCapDisplayOverrideSchema = zod.z.object({
|
|
7286
|
-
unit: zod.z.string().min(1).optional(),
|
|
7287
|
-
precision: zod.z.number().int().min(0).max(10).optional()
|
|
7288
|
-
});
|
|
7289
|
-
/** Cap-wire shape of an operator-authored per-device display override —
|
|
7290
|
-
* mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
|
|
7291
|
-
* bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
|
|
7292
|
-
var DeviceDisplayOverrideSchema = zod.z.object({
|
|
7293
|
-
icon: zod.z.string().min(1).optional(),
|
|
7294
|
-
label: zod.z.string().min(1).optional(),
|
|
7295
|
-
unit: zod.z.string().min(1).optional(),
|
|
7296
|
-
precision: zod.z.number().int().min(0).max(10).optional(),
|
|
7297
|
-
hidden: zod.z.boolean().optional(),
|
|
7298
|
-
perCap: zod.z.record(zod.z.string(), DeviceCapDisplayOverrideSchema).optional()
|
|
7299
|
-
});
|
|
7300
|
-
/** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
|
|
7301
|
-
* in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
|
|
7302
|
-
* the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
|
|
7303
|
-
var RoleDisplayDefaultSchema = zod.z.object({
|
|
7304
|
-
unit: zod.z.string().min(1).optional(),
|
|
7305
|
-
precision: zod.z.number().int().min(0).max(10).optional(),
|
|
7306
|
-
icon: zod.z.string().min(1).optional()
|
|
7307
|
-
});
|
|
7308
|
-
/**
|
|
7309
|
-
* Serializable projection of a live IDevice.
|
|
7310
|
-
* Returned by listAll, getDevice, getChildren.
|
|
7311
|
-
* Live methods (getStreamSources, getConfigSchema) are separate calls.
|
|
7312
|
-
*/
|
|
7313
|
-
var DeviceInfoSchema = zod.z.object({
|
|
7314
|
-
/** Progressive, system-wide unique number. Allocated synchronously by
|
|
7315
|
-
* `device-manager.allocateDeviceId` BEFORE the owning `IDevice` is
|
|
7316
|
-
* constructed, so every live device exposes an `id` — no transient
|
|
7317
|
-
* null window. Distinct from `stableId`, which is unique per
|
|
7318
|
-
* integration. Ids are monotonic and never reissued on removal. */
|
|
7319
|
-
id: zod.z.number(),
|
|
7320
|
-
stableId: zod.z.string(),
|
|
7321
|
-
addonId: zod.z.string(),
|
|
7322
|
-
type: zod.z.enum(require_sleep.DeviceType),
|
|
7323
|
-
name: zod.z.string(),
|
|
7324
|
-
/** Operator-organisational location label. `null` when unset. */
|
|
7325
|
-
location: zod.z.string().nullable(),
|
|
7326
|
-
/** Soft-disabled flag. */
|
|
7327
|
-
disabled: zod.z.boolean(),
|
|
7328
|
-
parentDeviceId: zod.z.number().nullable(),
|
|
7329
|
-
/** Optional semantic role — `DeviceRole` string. null for top-level devices. */
|
|
7330
|
-
role: zod.z.string().nullable().optional(),
|
|
7331
|
-
online: zod.z.boolean(),
|
|
7332
|
-
/** True when the device's initial feature-probe has completed (or it has
|
|
7333
|
-
* no probe) — exported shape is stable; exporters gate advertise on this.
|
|
7334
|
-
* Optional for deploy-order resilience: a record produced by an older
|
|
7335
|
-
* device-manager build omits it, and consumers treat absent as "not ready"
|
|
7336
|
-
* (export gate carries forward, never advertises a partial shape). */
|
|
7337
|
-
probed: zod.z.boolean().optional(),
|
|
7338
|
-
features: zod.z.array(zod.z.string()),
|
|
7339
|
-
/** true when the device has a getStreamSources() method (ICameraDevice) */
|
|
7340
|
-
isCamera: zod.z.boolean(),
|
|
7341
|
-
/** Current config values — serializable snapshot */
|
|
6533
|
+
/** true when the device has a getStreamSources() method (ICameraDevice) */
|
|
6534
|
+
isCamera: zod.z.boolean(),
|
|
6535
|
+
/** Current config values — serializable snapshot */
|
|
7342
6536
|
config: zod.z.record(zod.z.string(), zod.z.unknown()),
|
|
7343
6537
|
/** Hardware + identity blob (manufacturer / model / firmware / sn /
|
|
7344
6538
|
* uid / mac / …). Populated by drivers; editable via setMetadata. */
|
|
@@ -7359,8 +6553,6 @@ var DeviceInfoSchema = zod.z.object({
|
|
|
7359
6553
|
* named accordion sections (with optional intra-section order). See
|
|
7360
6554
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
7361
6555
|
childLayout: zod.z.array(ChildLayoutEntrySchema).readonly().optional(),
|
|
7362
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
7363
|
-
deviceLinks: zod.z.array(DeviceLinkSchema).readonly().optional(),
|
|
7364
6556
|
/** Operator-authored per-device display override. See `DeviceMeta.display`. */
|
|
7365
6557
|
display: DeviceDisplayOverrideSchema.optional()
|
|
7366
6558
|
});
|
|
@@ -7369,7 +6561,7 @@ var ConfigEntrySchema = zod.z.object({
|
|
|
7369
6561
|
value: zod.z.unknown(),
|
|
7370
6562
|
description: zod.z.string().optional()
|
|
7371
6563
|
});
|
|
7372
|
-
var
|
|
6564
|
+
var LinkedDevicesModeSchema = zod.z.enum(["auto", "manual"]);
|
|
7373
6565
|
/** One resolved linked device — the compact projection consumers need. */
|
|
7374
6566
|
var LinkedDeviceSchema = zod.z.object({
|
|
7375
6567
|
deviceId: zod.z.number(),
|
|
@@ -7432,8 +6624,6 @@ var DeviceMetaSchema = zod.z.object({
|
|
|
7432
6624
|
* accordion sections (with optional intra-section order). See
|
|
7433
6625
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
7434
6626
|
childLayout: zod.z.array(ChildLayoutEntrySchema).readonly().optional(),
|
|
7435
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
7436
|
-
deviceLinks: zod.z.array(DeviceLinkSchema).readonly().optional(),
|
|
7437
6627
|
/** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
|
|
7438
6628
|
* Optional: only present for accessory children that carry a known role. */
|
|
7439
6629
|
role: zod.z.string().nullable().optional(),
|
|
@@ -7585,16 +6775,6 @@ var deviceManagerCapability = {
|
|
|
7585
6775
|
kind: "mutation",
|
|
7586
6776
|
auth: "admin"
|
|
7587
6777
|
}),
|
|
7588
|
-
/** Set (or replace) the cross-device field wirings on a device's meta row.
|
|
7589
|
-
* Mirrors `setChildLayout`; persisted, projected, preserved across
|
|
7590
|
-
* re-register/restore. Idempotent. */
|
|
7591
|
-
setDeviceLinks: require_sleep.method(zod.z.object({
|
|
7592
|
-
deviceId: zod.z.number(),
|
|
7593
|
-
deviceLinks: zod.z.array(DeviceLinkSchema).readonly()
|
|
7594
|
-
}), zod.z.void(), {
|
|
7595
|
-
kind: "mutation",
|
|
7596
|
-
auth: "admin"
|
|
7597
|
-
}),
|
|
7598
6778
|
/** Set (or clear) the per-device display override on the meta row. Mirrors
|
|
7599
6779
|
* `setChildLayout` persistence; `null` clears the override entirely. The
|
|
7600
6780
|
* override unit(s) are normalized (`normalizeUnit`) at write so the render
|
|
@@ -7618,15 +6798,15 @@ var deviceManagerCapability = {
|
|
|
7618
6798
|
kind: "mutation",
|
|
7619
6799
|
auth: "admin"
|
|
7620
6800
|
}),
|
|
7621
|
-
/**
|
|
7622
|
-
*
|
|
7623
|
-
*
|
|
7624
|
-
*
|
|
7625
|
-
*
|
|
7626
|
-
*
|
|
7627
|
-
*
|
|
7628
|
-
*
|
|
7629
|
-
*
|
|
6801
|
+
/** The per-cap catalog of readable status-schema fields for one device.
|
|
6802
|
+
* This is the SOURCE PICKER — it outlived the Wiring tab that first
|
|
6803
|
+
* needed it (deleted 2026-08-08) and becomes the picker of a composition
|
|
6804
|
+
* recipe: every field the old tab could target, a composed device can
|
|
6805
|
+
* read. Caps without a status schema are omitted. Item-array caps
|
|
6806
|
+
* (`status.itemArray`, e.g. consumables) also emit their per-item fields
|
|
6807
|
+
* tagged `item: true` plus the cap-level `itemArray` descriptor.
|
|
6808
|
+
* `includeSynthesizable: true` unions in unbound device-scoped caps that
|
|
6809
|
+
* declare `status.empty` and match the device's type. */
|
|
7630
6810
|
getWireableFields: require_sleep.method(zod.z.object({
|
|
7631
6811
|
deviceId: zod.z.number(),
|
|
7632
6812
|
includeSynthesizable: zod.z.boolean().optional()
|
|
@@ -7768,7 +6948,7 @@ var deviceManagerCapability = {
|
|
|
7768
6948
|
* (see the module docblock) — there are no bespoke link mutations.
|
|
7769
6949
|
*/
|
|
7770
6950
|
getLinkedDevices: require_sleep.method(zod.z.object({ deviceId: zod.z.number() }), zod.z.object({
|
|
7771
|
-
mode:
|
|
6951
|
+
mode: LinkedDevicesModeSchema,
|
|
7772
6952
|
devices: zod.z.array(LinkedDeviceSchema)
|
|
7773
6953
|
})),
|
|
7774
6954
|
/** Get stream sources for a camera device. */
|
|
@@ -7832,11 +7012,7 @@ var deviceManagerCapability = {
|
|
|
7832
7012
|
deviceId: zod.z.number(),
|
|
7833
7013
|
entries: zod.z.array(zod.z.object({
|
|
7834
7014
|
capName: zod.z.string(),
|
|
7835
|
-
kind: zod.z.enum([
|
|
7836
|
-
"native",
|
|
7837
|
-
"wrapped",
|
|
7838
|
-
"linked"
|
|
7839
|
-
]),
|
|
7015
|
+
kind: zod.z.enum(["native", "wrapped"]),
|
|
7840
7016
|
providerAddonId: zod.z.string(),
|
|
7841
7017
|
providerNodeId: zod.z.string(),
|
|
7842
7018
|
nativeAddonId: zod.z.string()
|
|
@@ -7854,11 +7030,7 @@ var deviceManagerCapability = {
|
|
|
7854
7030
|
deviceId: zod.z.number(),
|
|
7855
7031
|
entries: zod.z.array(zod.z.object({
|
|
7856
7032
|
capName: zod.z.string(),
|
|
7857
|
-
kind: zod.z.enum([
|
|
7858
|
-
"native",
|
|
7859
|
-
"wrapped",
|
|
7860
|
-
"linked"
|
|
7861
|
-
]),
|
|
7033
|
+
kind: zod.z.enum(["native", "wrapped"]),
|
|
7862
7034
|
providerAddonId: zod.z.string(),
|
|
7863
7035
|
providerNodeId: zod.z.string(),
|
|
7864
7036
|
nativeAddonId: zod.z.string()
|
|
@@ -9104,6 +8276,10 @@ var motionDetectionCapability = {
|
|
|
9104
8276
|
mode: "singleton",
|
|
9105
8277
|
kind: "wrapper",
|
|
9106
8278
|
defaultActive: true,
|
|
8279
|
+
/** Frame-differencing motion analysis over a video stream — only a camera
|
|
8280
|
+
* produces the frames. Read by the `defaultActive` auto-bind in
|
|
8281
|
+
* `device-manager.getBindings` to decide which devices it may claim. */
|
|
8282
|
+
deviceTypes: [require_sleep.DeviceType.Camera],
|
|
9107
8283
|
exposesDeviceSettings: true,
|
|
9108
8284
|
methods: {
|
|
9109
8285
|
analyze: require_sleep.method(zod.z.object({
|
|
@@ -12225,6 +11401,18 @@ var OauthIntegrationDescriptorSchema = zod.z.object({
|
|
|
12225
11401
|
* redirect_uri that does not start with one of these. Required —
|
|
12226
11402
|
* an empty list means the integration can never complete linking. */
|
|
12227
11403
|
allowedRedirectPrefixes: zod.z.array(zod.z.string()).min(1),
|
|
11404
|
+
/** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
|
|
11405
|
+
* RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
|
|
11406
|
+
* `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
|
|
11407
|
+
* whose address the hub cannot know in advance (a Home Assistant at
|
|
11408
|
+
* `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
|
|
11409
|
+
* exactly; a public host never satisfies this branch, so it is not a
|
|
11410
|
+
* wildcard prefix by another name. */
|
|
11411
|
+
allowedPrivateHostPaths: zod.z.array(zod.z.string()).optional(),
|
|
11412
|
+
/** When true this is a PUBLIC client (source is published, no secret can be
|
|
11413
|
+
* protected) and PKCE is mandatory: `/authorize` refuses without an S256
|
|
11414
|
+
* `code_challenge`, `/token` refuses without the matching `code_verifier`. */
|
|
11415
|
+
requiresPkce: zod.z.boolean().optional(),
|
|
12228
11416
|
/** Optional public origin (no trailing slash) that this integration's
|
|
12229
11417
|
* issued codes/tokens should carry as the `hubUrl` claim — typically the
|
|
12230
11418
|
* operator-selected external-access endpoint resolved by the addon. When
|
|
@@ -12383,7 +11571,7 @@ var TrackEnvelopeSchema = zod.z.object({
|
|
|
12383
11571
|
* `snapshots[]` references — megabytes across a page of tracks. `slim`
|
|
12384
11572
|
* keeps every scalar the list surfaces actually render (ids, class(es),
|
|
12385
11573
|
* label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
|
|
12386
|
-
* zonesVisited, bestEventId, envelope) and returns `positions` /
|
|
11574
|
+
* zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
|
|
12387
11575
|
* `snapshots` as EMPTY arrays — detail views re-fetch the full row via
|
|
12388
11576
|
* `getTrack`. Mirrors the event-store `projection` convention
|
|
12389
11577
|
* (`getObjectEvents` et al.).
|
|
@@ -12636,6 +11824,24 @@ var TrackSchema = zod.z.object({
|
|
|
12636
11824
|
* Populated from the persisted envelope columns on historical reads;
|
|
12637
11825
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
12638
11826
|
envelope: TrackEnvelopeSchema.optional(),
|
|
11827
|
+
/**
|
|
11828
|
+
* A face DETECTOR found a face on this track — nothing more. It says the
|
|
11829
|
+
* detail plane produced a `face` detail; it does NOT say the face was
|
|
11830
|
+
* embedded, matched, above `minFacePx`, or that the recognizer was even
|
|
11831
|
+
* enabled. Set once and never cleared.
|
|
11832
|
+
*
|
|
11833
|
+
* **This exists so "face present but not recognised" is expressible.** A
|
|
11834
|
+
* recognised identity lands in `subLabel` (attributed to the face chain via
|
|
11835
|
+
* `subLabelMeta.stepId`), so before this field a track with an unmatched face
|
|
11836
|
+
* and a track with no face at all were byte-identical on the wire and no
|
|
11837
|
+
* surface could tell them apart. The read is `hasFace === true && subLabel
|
|
11838
|
+
* === undefined`.
|
|
11839
|
+
*
|
|
11840
|
+
* **Absent ≠ false.** Every row written before the column existed omits it,
|
|
11841
|
+
* and so does every server that predates the field — a consumer must test
|
|
11842
|
+
* `=== true` and render nothing otherwise, never infer "no face".
|
|
11843
|
+
*/
|
|
11844
|
+
hasFace: zod.z.boolean().optional(),
|
|
12639
11845
|
...TrackFlagFields,
|
|
12640
11846
|
...TrackRetrainFields
|
|
12641
11847
|
});
|
|
@@ -16577,6 +15783,10 @@ var SsoBridgeClaimsSchema = zod.z.object({
|
|
|
16577
15783
|
integrationId: zod.z.string().optional(),
|
|
16578
15784
|
/** JWT ID — unique per issued code; consumed-set enforces single-use. */
|
|
16579
15785
|
jti: zod.z.string().optional(),
|
|
15786
|
+
/** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
|
|
15787
|
+
* client. Its PRESENCE is what makes the verifier mandatory at exchange,
|
|
15788
|
+
* so the requirement travels with the code and not with mutable config. */
|
|
15789
|
+
codeChallenge: zod.z.string().optional(),
|
|
16580
15790
|
/** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
|
|
16581
15791
|
* tokens so the verify path can check the session is not revoked. */
|
|
16582
15792
|
sessionId: zod.z.string().optional()
|
|
@@ -17349,6 +16559,10 @@ var videoclipsCapability = {
|
|
|
17349
16559
|
mode: "singleton",
|
|
17350
16560
|
kind: "wrapper",
|
|
17351
16561
|
defaultActive: true,
|
|
16562
|
+
/** A clip is a window over a camera's footage — the cap is meaningless on a
|
|
16563
|
+
* sensor, a button or an event emitter, and the `defaultActive` auto-bind
|
|
16564
|
+
* reads this to decide which devices it may claim. */
|
|
16565
|
+
deviceTypes: [require_sleep.DeviceType.Camera],
|
|
17352
16566
|
methods: {
|
|
17353
16567
|
listClips: require_sleep.method(zod.z.object({
|
|
17354
16568
|
deviceId: zod.z.number(),
|
|
@@ -19900,7 +19114,29 @@ var FaceInfoSchema = zod.z.object({
|
|
|
19900
19114
|
recognizedIdentityId: zod.z.string().optional(),
|
|
19901
19115
|
identityName: zod.z.string().optional(),
|
|
19902
19116
|
assigned: zod.z.boolean(),
|
|
19117
|
+
/**
|
|
19118
|
+
* The crop, inline, base64.
|
|
19119
|
+
*
|
|
19120
|
+
* **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
|
|
19121
|
+
* field alone is ~2.87 MiB, re-sent in full on every operator assign and
|
|
19122
|
+
* every 30 s poll, base64-inflated over the msgpack socket and held in the
|
|
19123
|
+
* query heap. It stays for callers that have not migrated; `includeCrops:
|
|
19124
|
+
* false` turns it off once they have.
|
|
19125
|
+
*/
|
|
19903
19126
|
base64: zod.z.string().optional(),
|
|
19127
|
+
/**
|
|
19128
|
+
* Same crop, as a data-plane URL for `<img src>` — the move the admin
|
|
19129
|
+
* snapshot surfaces made on 2026-08-08.
|
|
19130
|
+
*
|
|
19131
|
+
* Served by the `event-media` plane, which resolves a raw MediaStore key and
|
|
19132
|
+
* is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
|
|
19133
|
+
* cookie, so no header plumbing is needed. The bytes then ride the browser's
|
|
19134
|
+
* HTTP cache with an ETag and `immutable`, instead of the WebSocket.
|
|
19135
|
+
*
|
|
19136
|
+
* Absent when the face has no stored crop, or when the addon has no data
|
|
19137
|
+
* plane — callers fall back to {@link base64}.
|
|
19138
|
+
*/
|
|
19139
|
+
cropUrl: zod.z.string().optional(),
|
|
19904
19140
|
/** Design B: the face bbox (pixel space) on the key frame — lets a detail
|
|
19905
19141
|
* view draw the box over the native `keyFrameMediaKey` frame. Absent on
|
|
19906
19142
|
* legacy rows written before design B. */
|
|
@@ -19976,7 +19212,23 @@ var faceGalleryCapability = {
|
|
|
19976
19212
|
}),
|
|
19977
19213
|
listRecentFaces: require_sleep.method(zod.z.object({
|
|
19978
19214
|
limit: zod.z.number().int().positive().optional(),
|
|
19979
|
-
filter: FaceFilterEnum.optional()
|
|
19215
|
+
filter: FaceFilterEnum.optional(),
|
|
19216
|
+
/**
|
|
19217
|
+
* Inline the base64 crop on every row. Default `true` — the existing
|
|
19218
|
+
* behaviour, kept so no caller breaks.
|
|
19219
|
+
*
|
|
19220
|
+
* Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
|
|
19221
|
+
* drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
|
|
19222
|
+
* the browser cache the images.
|
|
19223
|
+
*
|
|
19224
|
+
* **This is an INPUT field, so it does not reach the addon until the
|
|
19225
|
+
* next train.** The hub router validates cap inputs against its own
|
|
19226
|
+
* compiled Zod, which strips a key it does not know — verified today
|
|
19227
|
+
* on the OUTPUT side, where an additive field DOES arrive immediately
|
|
19228
|
+
* (`Track.hasFace`). Until the train ships, sending `false` is
|
|
19229
|
+
* harmless and simply keeps the crops inline.
|
|
19230
|
+
*/
|
|
19231
|
+
includeCrops: zod.z.boolean().optional()
|
|
19980
19232
|
}).optional(), zod.z.array(FaceInfoSchema).readonly()),
|
|
19981
19233
|
getFaceByTrack: require_sleep.method(zod.z.object({
|
|
19982
19234
|
deviceId: zod.z.number().int(),
|
|
@@ -25489,14 +24741,19 @@ var userManagementCapability = {
|
|
|
25489
24741
|
username: zod.z.string(),
|
|
25490
24742
|
scopes: zod.z.array(TokenScopeSchema),
|
|
25491
24743
|
redirectUri: zod.z.string(),
|
|
25492
|
-
hubUrl: zod.z.string()
|
|
24744
|
+
hubUrl: zod.z.string(),
|
|
24745
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
24746
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
24747
|
+
codeChallenge: zod.z.string().optional()
|
|
25493
24748
|
}), zod.z.object({ code: zod.z.string() }), {
|
|
25494
24749
|
kind: "mutation",
|
|
25495
24750
|
access: "create"
|
|
25496
24751
|
}),
|
|
25497
24752
|
oauthExchangeCode: require_sleep.method(zod.z.object({
|
|
25498
24753
|
code: zod.z.string(),
|
|
25499
|
-
redirectUri: zod.z.string()
|
|
24754
|
+
redirectUri: zod.z.string(),
|
|
24755
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
24756
|
+
codeVerifier: zod.z.string().optional()
|
|
25500
24757
|
}), zod.z.object({
|
|
25501
24758
|
accessToken: zod.z.string(),
|
|
25502
24759
|
refreshToken: zod.z.string(),
|
|
@@ -27387,6 +26644,218 @@ var BaseDeviceProvider = class extends require_sleep.BaseAddon {
|
|
|
27387
26644
|
}
|
|
27388
26645
|
};
|
|
27389
26646
|
//#endregion
|
|
26647
|
+
//#region src/device/declared-device.ts
|
|
26648
|
+
/** Marker written to a declared integration's `info`. */
|
|
26649
|
+
var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
|
|
26650
|
+
/**
|
|
26651
|
+
* Strip the `<node>/<addon>` suffix a forked child carries.
|
|
26652
|
+
*
|
|
26653
|
+
* Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
|
|
26654
|
+
* that was supposed to act — because on the hub it reads `hub/<addon>`.
|
|
26655
|
+
*/
|
|
26656
|
+
function declarationOwnerNodeId(localNodeId) {
|
|
26657
|
+
const raw = localNodeId ?? "hub";
|
|
26658
|
+
if (!raw.includes("/")) return raw;
|
|
26659
|
+
return raw.split("/")[0] ?? "hub";
|
|
26660
|
+
}
|
|
26661
|
+
/** Cap on how many rows one withdrawal pass may delete. */
|
|
26662
|
+
var DECLARED_DEVICE_SWEEP_LIMIT = 32;
|
|
26663
|
+
/**
|
|
26664
|
+
* The one way an addon owns a device it declares.
|
|
26665
|
+
*
|
|
26666
|
+
* Construct once with the addon's ports, then call {@link reconcile} on boot and
|
|
26667
|
+
* on every convergence tick. There is no second get-or-create helper — a guard
|
|
26668
|
+
* in `scripts/` enforces that.
|
|
26669
|
+
*/
|
|
26670
|
+
var DeclaredDevices = class {
|
|
26671
|
+
ports;
|
|
26672
|
+
constructor(ports) {
|
|
26673
|
+
this.ports = ports;
|
|
26674
|
+
}
|
|
26675
|
+
/**
|
|
26676
|
+
* Converge the declared set. Idempotent, and safe to call repeatedly.
|
|
26677
|
+
*
|
|
26678
|
+
* Throws only what the ports throw on the FIRST index read; every other
|
|
26679
|
+
* failure is per-device and logged, so one bad declaration never takes the
|
|
26680
|
+
* others down.
|
|
26681
|
+
*/
|
|
26682
|
+
async reconcile(spec) {
|
|
26683
|
+
if ((spec.placement ?? "hub") === "hub") {
|
|
26684
|
+
const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
|
|
26685
|
+
if (nodeId !== "hub") {
|
|
26686
|
+
this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
|
|
26687
|
+
nodeId,
|
|
26688
|
+
rawNodeId: this.ports.localNodeId ?? null
|
|
26689
|
+
} });
|
|
26690
|
+
return {
|
|
26691
|
+
integrationId: null,
|
|
26692
|
+
devices: [],
|
|
26693
|
+
removed: [],
|
|
26694
|
+
owned: false
|
|
26695
|
+
};
|
|
26696
|
+
}
|
|
26697
|
+
}
|
|
26698
|
+
const integrationId = await this.ensureIntegration(spec.integrationName);
|
|
26699
|
+
const index = await this.readIndex();
|
|
26700
|
+
const outcomes = [];
|
|
26701
|
+
for (const declaration of spec.devices) {
|
|
26702
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
26703
|
+
if (outcome !== null) outcomes.push(outcome);
|
|
26704
|
+
}
|
|
26705
|
+
return {
|
|
26706
|
+
integrationId,
|
|
26707
|
+
devices: outcomes,
|
|
26708
|
+
removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
|
|
26709
|
+
owned: true
|
|
26710
|
+
};
|
|
26711
|
+
}
|
|
26712
|
+
/**
|
|
26713
|
+
* Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
|
|
26714
|
+
*
|
|
26715
|
+
* The re-assertion is the fix for the defect the hand-rolled version shipped
|
|
26716
|
+
* with: writing `info.fixed` only on the create path left every pre-existing
|
|
26717
|
+
* install without it, and the kernel kept offering to delete an integration
|
|
26718
|
+
* the addon owns.
|
|
26719
|
+
*/
|
|
26720
|
+
async ensureIntegration(integrationName) {
|
|
26721
|
+
const existing = await this.ports.getIntegration(this.ports.addonId);
|
|
26722
|
+
if (existing === null) {
|
|
26723
|
+
const created = await this.ports.createIntegration({
|
|
26724
|
+
addonId: this.ports.addonId,
|
|
26725
|
+
name: integrationName,
|
|
26726
|
+
info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
|
|
26727
|
+
});
|
|
26728
|
+
this.ports.logger.info("declared a fixed integration", { meta: {
|
|
26729
|
+
integrationId: created.id,
|
|
26730
|
+
name: integrationName
|
|
26731
|
+
} });
|
|
26732
|
+
return created.id;
|
|
26733
|
+
}
|
|
26734
|
+
if (existing.info?.["fixed"] !== true) {
|
|
26735
|
+
await this.ports.updateIntegration({
|
|
26736
|
+
id: existing.id,
|
|
26737
|
+
info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
|
|
26738
|
+
});
|
|
26739
|
+
this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
|
|
26740
|
+
}
|
|
26741
|
+
return existing.id;
|
|
26742
|
+
}
|
|
26743
|
+
async readIndex() {
|
|
26744
|
+
const rows = await this.ports.listOwnDevices();
|
|
26745
|
+
return new Map(rows.map((row) => [row.stableId, row]));
|
|
26746
|
+
}
|
|
26747
|
+
/**
|
|
26748
|
+
* One declaration: adopt what exists, create what does not.
|
|
26749
|
+
*
|
|
26750
|
+
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
26751
|
+
* `initialMeta.name` lands as an unconditional `setName`. A transiently empty
|
|
26752
|
+
* index therefore looks exactly like a first boot and would silently re-stamp
|
|
26753
|
+
* the declared name over the operator's rename. D49: that branch needs a
|
|
26754
|
+
* second read to agree.
|
|
26755
|
+
*/
|
|
26756
|
+
async applyDeclaration(declaration, integrationId, index) {
|
|
26757
|
+
try {
|
|
26758
|
+
let existing = index.get(declaration.stableId);
|
|
26759
|
+
if (existing === void 0) {
|
|
26760
|
+
existing = (await this.readIndex()).get(declaration.stableId);
|
|
26761
|
+
if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
|
|
26762
|
+
tags: { deviceId: existing.id },
|
|
26763
|
+
meta: {
|
|
26764
|
+
stableId: declaration.stableId,
|
|
26765
|
+
addonId: this.ports.addonId
|
|
26766
|
+
}
|
|
26767
|
+
});
|
|
26768
|
+
}
|
|
26769
|
+
if (existing !== void 0) {
|
|
26770
|
+
const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
|
|
26771
|
+
this.ports.logger.info("declared device adopted", {
|
|
26772
|
+
tags: { deviceId: device.id },
|
|
26773
|
+
meta: {
|
|
26774
|
+
stableId: declaration.stableId,
|
|
26775
|
+
integrationId
|
|
26776
|
+
}
|
|
26777
|
+
});
|
|
26778
|
+
return {
|
|
26779
|
+
stableId: declaration.stableId,
|
|
26780
|
+
deviceId: device.id,
|
|
26781
|
+
device,
|
|
26782
|
+
created: false
|
|
26783
|
+
};
|
|
26784
|
+
}
|
|
26785
|
+
const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
|
|
26786
|
+
type: declaration.type,
|
|
26787
|
+
name: declaration.name,
|
|
26788
|
+
integrationId,
|
|
26789
|
+
...declaration.role === void 0 ? {} : { role: declaration.role }
|
|
26790
|
+
});
|
|
26791
|
+
this.ports.logger.info("declared device created", {
|
|
26792
|
+
tags: { deviceId: device.id },
|
|
26793
|
+
meta: {
|
|
26794
|
+
stableId: declaration.stableId,
|
|
26795
|
+
integrationId
|
|
26796
|
+
}
|
|
26797
|
+
});
|
|
26798
|
+
return {
|
|
26799
|
+
stableId: declaration.stableId,
|
|
26800
|
+
deviceId: device.id,
|
|
26801
|
+
device,
|
|
26802
|
+
created: true
|
|
26803
|
+
};
|
|
26804
|
+
} catch (err) {
|
|
26805
|
+
this.ports.logger.warn("a declared device could not be brought up", { meta: {
|
|
26806
|
+
stableId: declaration.stableId,
|
|
26807
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26808
|
+
} });
|
|
26809
|
+
return null;
|
|
26810
|
+
}
|
|
26811
|
+
}
|
|
26812
|
+
/**
|
|
26813
|
+
* Remove rows under the addon's FIXED integration whose declaration is gone.
|
|
26814
|
+
*
|
|
26815
|
+
* Bounded to that integration: a declared integration has no operator
|
|
26816
|
+
* add-flow, so every row under it got there by declaration. Devices this
|
|
26817
|
+
* addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
|
|
26818
|
+
*
|
|
26819
|
+
* Bounded in count, and every deletion is logged with its `deviceId` — a
|
|
26820
|
+
* withdrawal that removes an operator-visible row silently is the failure
|
|
26821
|
+
* mode, not the removal itself.
|
|
26822
|
+
*/
|
|
26823
|
+
async sweepWithdrawn(declarations, integrationId, index) {
|
|
26824
|
+
const declared = new Set(declarations.map((d) => d.stableId));
|
|
26825
|
+
const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
|
|
26826
|
+
if (candidates.length === 0) return [];
|
|
26827
|
+
if (candidates.length > 32) {
|
|
26828
|
+
this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
|
|
26829
|
+
integrationId,
|
|
26830
|
+
candidates: candidates.length,
|
|
26831
|
+
bound: 32
|
|
26832
|
+
} });
|
|
26833
|
+
return [];
|
|
26834
|
+
}
|
|
26835
|
+
const removed = [];
|
|
26836
|
+
for (const row of candidates) try {
|
|
26837
|
+
await this.ports.devices.remove(row.id);
|
|
26838
|
+
removed.push(row.id);
|
|
26839
|
+
this.ports.logger.info("declared device removed — its declaration was withdrawn", {
|
|
26840
|
+
tags: { deviceId: row.id },
|
|
26841
|
+
meta: {
|
|
26842
|
+
stableId: row.stableId,
|
|
26843
|
+
integrationId
|
|
26844
|
+
}
|
|
26845
|
+
});
|
|
26846
|
+
} catch (err) {
|
|
26847
|
+
this.ports.logger.warn("a withdrawn declared device could not be removed", {
|
|
26848
|
+
tags: { deviceId: row.id },
|
|
26849
|
+
meta: {
|
|
26850
|
+
stableId: row.stableId,
|
|
26851
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26852
|
+
}
|
|
26853
|
+
});
|
|
26854
|
+
}
|
|
26855
|
+
return removed;
|
|
26856
|
+
}
|
|
26857
|
+
};
|
|
26858
|
+
//#endregion
|
|
27390
26859
|
//#region src/device/device-control-resolution.ts
|
|
27391
26860
|
/**
|
|
27392
26861
|
* Device-control RESOLUTION LOGIC — the single, presentation-free source of
|
|
@@ -27563,30 +27032,6 @@ function resolveMutate(router, method) {
|
|
|
27563
27032
|
return typeof mutate === "function" ? mutate : null;
|
|
27564
27033
|
}
|
|
27565
27034
|
//#endregion
|
|
27566
|
-
//#region src/device/device-link-transform.ts
|
|
27567
|
-
/** Apply a link transform to a resolved source value. `identity`/undefined
|
|
27568
|
-
* pass through; `enum-map` remaps strings (fallback or null when unknown);
|
|
27569
|
-
* `linear` does scale*x+offset with optional clamp (null for non-numbers). */
|
|
27570
|
-
function applyTransform(value, transform) {
|
|
27571
|
-
if (!transform || transform.kind === "identity") return value;
|
|
27572
|
-
if (transform.kind === "enum-map") {
|
|
27573
|
-
const key = String(value);
|
|
27574
|
-
if (Object.prototype.hasOwnProperty.call(transform.mapping, key)) return transform.mapping[key];
|
|
27575
|
-
return transform.fallback ?? null;
|
|
27576
|
-
}
|
|
27577
|
-
if (transform.kind === "linear") {
|
|
27578
|
-
if (typeof value !== "number" || Number.isNaN(value)) return null;
|
|
27579
|
-
let out = value * transform.scale + transform.offset;
|
|
27580
|
-
if (transform.clamp) {
|
|
27581
|
-
const min = transform.clamp[0];
|
|
27582
|
-
const max = transform.clamp[1];
|
|
27583
|
-
out = Math.min(max, Math.max(min, out));
|
|
27584
|
-
}
|
|
27585
|
-
return out;
|
|
27586
|
-
}
|
|
27587
|
-
return value;
|
|
27588
|
-
}
|
|
27589
|
-
//#endregion
|
|
27590
27035
|
//#region src/device/device-profile.ts
|
|
27591
27036
|
/**
|
|
27592
27037
|
* Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
|
|
@@ -28115,501 +27560,1347 @@ var SystemMirror = class {
|
|
|
28115
27560
|
const proxy = this.getDeviceById(deviceId);
|
|
28116
27561
|
if (proxy) out.push(proxy);
|
|
28117
27562
|
}
|
|
28118
|
-
return out;
|
|
28119
|
-
}
|
|
28120
|
-
/** Map every device's slice for `capName` to a derived value. Devices
|
|
28121
|
-
* without the cap or without a slice are skipped. */
|
|
28122
|
-
mapState(capName, mapper) {
|
|
28123
|
-
const out = [];
|
|
28124
|
-
for (const [deviceId, perCap] of this.stateMirror) {
|
|
28125
|
-
const slice = perCap.get(capName);
|
|
28126
|
-
if (!slice) continue;
|
|
28127
|
-
out.push(mapper(slice, deviceId));
|
|
27563
|
+
return out;
|
|
27564
|
+
}
|
|
27565
|
+
/** Map every device's slice for `capName` to a derived value. Devices
|
|
27566
|
+
* without the cap or without a slice are skipped. */
|
|
27567
|
+
mapState(capName, mapper) {
|
|
27568
|
+
const out = [];
|
|
27569
|
+
for (const [deviceId, perCap] of this.stateMirror) {
|
|
27570
|
+
const slice = perCap.get(capName);
|
|
27571
|
+
if (!slice) continue;
|
|
27572
|
+
out.push(mapper(slice, deviceId));
|
|
27573
|
+
}
|
|
27574
|
+
return out;
|
|
27575
|
+
}
|
|
27576
|
+
/** First device whose slice matches `predicate`, or null. */
|
|
27577
|
+
findState(capName, predicate) {
|
|
27578
|
+
for (const [deviceId, perCap] of this.stateMirror) {
|
|
27579
|
+
const slice = perCap.get(capName);
|
|
27580
|
+
if (!slice) continue;
|
|
27581
|
+
if (!predicate(slice, deviceId)) continue;
|
|
27582
|
+
return this.getDeviceById(deviceId);
|
|
27583
|
+
}
|
|
27584
|
+
return null;
|
|
27585
|
+
}
|
|
27586
|
+
/** Count devices that bind a cap. Faster than `filterByCap(...).length`. */
|
|
27587
|
+
countByCap(capName) {
|
|
27588
|
+
let n = 0;
|
|
27589
|
+
for (const binding of this.bindings.values()) if (binding.entries.some((e) => e.capName === capName)) n++;
|
|
27590
|
+
return n;
|
|
27591
|
+
}
|
|
27592
|
+
/** Count devices whose slice for `capName` matches `predicate`. */
|
|
27593
|
+
countByState(capName, predicate) {
|
|
27594
|
+
let n = 0;
|
|
27595
|
+
for (const [deviceId, perCap] of this.stateMirror) {
|
|
27596
|
+
const slice = perCap.get(capName);
|
|
27597
|
+
if (!slice) continue;
|
|
27598
|
+
if (predicate(slice, deviceId)) n++;
|
|
27599
|
+
}
|
|
27600
|
+
return n;
|
|
27601
|
+
}
|
|
27602
|
+
/**
|
|
27603
|
+
* Global listener — fires for every `device.state-changed` event the
|
|
27604
|
+
* mirror absorbs.
|
|
27605
|
+
*/
|
|
27606
|
+
listen(cb) {
|
|
27607
|
+
this.globalStateListeners.add(cb);
|
|
27608
|
+
return () => {
|
|
27609
|
+
this.globalStateListeners.delete(cb);
|
|
27610
|
+
};
|
|
27611
|
+
}
|
|
27612
|
+
/**
|
|
27613
|
+
* Per-cap listener — fires only for state changes on `capName`,
|
|
27614
|
+
* across every device. The callback receives the deviceId so the
|
|
27615
|
+
* caller can route.
|
|
27616
|
+
*/
|
|
27617
|
+
listenCap(capName, cb) {
|
|
27618
|
+
let set = this.capListeners.get(capName);
|
|
27619
|
+
if (!set) {
|
|
27620
|
+
set = /* @__PURE__ */ new Set();
|
|
27621
|
+
this.capListeners.set(capName, set);
|
|
27622
|
+
}
|
|
27623
|
+
set.add(cb);
|
|
27624
|
+
return () => {
|
|
27625
|
+
set.delete(cb);
|
|
27626
|
+
if (set.size === 0) this.capListeners.delete(capName);
|
|
27627
|
+
};
|
|
27628
|
+
}
|
|
27629
|
+
/**
|
|
27630
|
+
* Per-device listener — fires for every cap change on `deviceId`.
|
|
27631
|
+
*/
|
|
27632
|
+
listenDevice(deviceId, cb) {
|
|
27633
|
+
let set = this.deviceListeners.get(deviceId);
|
|
27634
|
+
if (!set) {
|
|
27635
|
+
set = /* @__PURE__ */ new Set();
|
|
27636
|
+
this.deviceListeners.set(deviceId, set);
|
|
27637
|
+
}
|
|
27638
|
+
set.add(cb);
|
|
27639
|
+
return () => {
|
|
27640
|
+
set.delete(cb);
|
|
27641
|
+
if (set.size === 0) this.deviceListeners.delete(deviceId);
|
|
27642
|
+
};
|
|
27643
|
+
}
|
|
27644
|
+
/** Fires when `device.registered` lands. Receives the new metadata. */
|
|
27645
|
+
onDeviceAdded(cb) {
|
|
27646
|
+
this.addedListeners.add(cb);
|
|
27647
|
+
return () => {
|
|
27648
|
+
this.addedListeners.delete(cb);
|
|
27649
|
+
};
|
|
27650
|
+
}
|
|
27651
|
+
/** Fires when `device.unregistered` lands. `info` is the LAST-known
|
|
27652
|
+
* metadata (or null if the device was never seen). */
|
|
27653
|
+
onDeviceRemoved(cb) {
|
|
27654
|
+
this.removedListeners.add(cb);
|
|
27655
|
+
return () => {
|
|
27656
|
+
this.removedListeners.delete(cb);
|
|
27657
|
+
};
|
|
27658
|
+
}
|
|
27659
|
+
/**
|
|
27660
|
+
* Resolve when `predicate` over the runtime-state slice for
|
|
27661
|
+
* `(deviceId, capName)` becomes true. Resolves immediately if the
|
|
27662
|
+
* current slice already matches. Rejects with `Error('timeout')`
|
|
27663
|
+
* after `timeoutMs` (default 30s; pass `Infinity` to wait forever).
|
|
27664
|
+
*
|
|
27665
|
+
* Returns the matching slice — caller can read it directly without
|
|
27666
|
+
* a second mirror lookup.
|
|
27667
|
+
*/
|
|
27668
|
+
waitForState(deviceId, capName, predicate, timeoutMs = 3e4) {
|
|
27669
|
+
return new Promise((resolve, reject) => {
|
|
27670
|
+
const check = () => {
|
|
27671
|
+
const slice = this.stateMirror.get(deviceId)?.get(capName);
|
|
27672
|
+
if (slice && predicate(slice)) return slice;
|
|
27673
|
+
return null;
|
|
27674
|
+
};
|
|
27675
|
+
const initial = check();
|
|
27676
|
+
if (initial) {
|
|
27677
|
+
resolve(initial);
|
|
27678
|
+
return;
|
|
27679
|
+
}
|
|
27680
|
+
let timer = null;
|
|
27681
|
+
const off = this.listenDevice(deviceId, (_id, cap, slice) => {
|
|
27682
|
+
if (cap !== capName) return;
|
|
27683
|
+
if (!slice) return;
|
|
27684
|
+
if (predicate(slice)) {
|
|
27685
|
+
if (timer) clearTimeout(timer);
|
|
27686
|
+
off();
|
|
27687
|
+
resolve(slice);
|
|
27688
|
+
}
|
|
27689
|
+
});
|
|
27690
|
+
if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
|
|
27691
|
+
off();
|
|
27692
|
+
reject(/* @__PURE__ */ new Error(`waitForState timed out after ${timeoutMs}ms (deviceId=${deviceId}, capName=${capName})`));
|
|
27693
|
+
}, timeoutMs);
|
|
27694
|
+
});
|
|
27695
|
+
}
|
|
27696
|
+
/**
|
|
27697
|
+
* Resolve when a device with `deviceId` becomes available (a
|
|
27698
|
+
* binding exists). Resolves immediately if already known. Rejects
|
|
27699
|
+
* with timeout.
|
|
27700
|
+
*/
|
|
27701
|
+
waitForDevice(deviceId, timeoutMs = 3e4) {
|
|
27702
|
+
return new Promise((resolve, reject) => {
|
|
27703
|
+
const existing = this.getDeviceById(deviceId);
|
|
27704
|
+
if (existing) {
|
|
27705
|
+
resolve(existing);
|
|
27706
|
+
return;
|
|
27707
|
+
}
|
|
27708
|
+
let timer = null;
|
|
27709
|
+
const off = this.onDeviceAdded((id) => {
|
|
27710
|
+
if (id !== deviceId) return;
|
|
27711
|
+
const proxy = this.getDeviceById(id);
|
|
27712
|
+
if (!proxy) return;
|
|
27713
|
+
if (timer) clearTimeout(timer);
|
|
27714
|
+
off();
|
|
27715
|
+
resolve(proxy);
|
|
27716
|
+
});
|
|
27717
|
+
if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
|
|
27718
|
+
off();
|
|
27719
|
+
reject(/* @__PURE__ */ new Error(`waitForDevice timed out after ${timeoutMs}ms (deviceId=${deviceId})`));
|
|
27720
|
+
}, timeoutMs);
|
|
27721
|
+
});
|
|
27722
|
+
}
|
|
27723
|
+
/**
|
|
27724
|
+
* Iterate every device that binds `capName`. Awaits each callback
|
|
27725
|
+
* sequentially — for parallel use `invokeCap` with explicit
|
|
27726
|
+
* parallelism.
|
|
27727
|
+
*/
|
|
27728
|
+
async forEachCap(capName, cb) {
|
|
27729
|
+
for (const proxy of this.filterByCap(capName)) await cb(proxy);
|
|
27730
|
+
}
|
|
27731
|
+
/**
|
|
27732
|
+
* Invoke a cap method on every device that binds the cap. Returns
|
|
27733
|
+
* one result per device, success or failure isolated. Optional
|
|
27734
|
+
* parallelism cap — useful for "snapshot all cameras but only 4
|
|
27735
|
+
* at a time so battery cams don't all wake at once".
|
|
27736
|
+
*
|
|
27737
|
+
* Example:
|
|
27738
|
+
*
|
|
27739
|
+
* const results = await sm.invokeCap('snapshot', 'getSnapshot', {}, { parallelism: 4 })
|
|
27740
|
+
* const failed = results.filter(r => !r.ok)
|
|
27741
|
+
*/
|
|
27742
|
+
async invokeCap(capName, methodName, args, opts = {}) {
|
|
27743
|
+
const targets = this.filterByCap(capName);
|
|
27744
|
+
const parallelism = Math.max(1, opts.parallelism ?? targets.length);
|
|
27745
|
+
const out = [];
|
|
27746
|
+
for (let i = 0; i < targets.length; i += parallelism) {
|
|
27747
|
+
const chunk = targets.slice(i, i + parallelism);
|
|
27748
|
+
const settled = await Promise.allSettled(chunk.map(async (proxy) => {
|
|
27749
|
+
const cap = proxy[capName];
|
|
27750
|
+
if (!cap || typeof cap[methodName] !== "function") throw new Error(`device '${proxy.deviceId}' does not expose '${capName}.${methodName}'`);
|
|
27751
|
+
return await cap[methodName](args);
|
|
27752
|
+
}));
|
|
27753
|
+
for (let j = 0; j < settled.length; j++) {
|
|
27754
|
+
const proxy = chunk[j];
|
|
27755
|
+
const r = settled[j];
|
|
27756
|
+
if (r.status === "fulfilled") out.push({
|
|
27757
|
+
deviceId: proxy.deviceId,
|
|
27758
|
+
ok: true,
|
|
27759
|
+
result: r.value
|
|
27760
|
+
});
|
|
27761
|
+
else out.push({
|
|
27762
|
+
deviceId: proxy.deviceId,
|
|
27763
|
+
ok: false,
|
|
27764
|
+
error: r.reason
|
|
27765
|
+
});
|
|
27766
|
+
}
|
|
27767
|
+
}
|
|
27768
|
+
return out;
|
|
27769
|
+
}
|
|
27770
|
+
/**
|
|
27771
|
+
* One-shot summary — fleet size, breakdown by cap / addon / type.
|
|
27772
|
+
* Designed for REPL inspection (`sm.summary()`).
|
|
27773
|
+
*/
|
|
27774
|
+
summary() {
|
|
27775
|
+
const byCap = {};
|
|
27776
|
+
const byAddon = {};
|
|
27777
|
+
const byType = {};
|
|
27778
|
+
let online = 0;
|
|
27779
|
+
let offline = 0;
|
|
27780
|
+
for (const binding of this.bindings.values()) for (const entry of binding.entries) byCap[entry.capName] = (byCap[entry.capName] ?? 0) + 1;
|
|
27781
|
+
for (const info of this.devices.values()) {
|
|
27782
|
+
byAddon[info.addonId] = (byAddon[info.addonId] ?? 0) + 1;
|
|
27783
|
+
byType[info.type] = (byType[info.type] ?? 0) + 1;
|
|
27784
|
+
if (info.online) online++;
|
|
27785
|
+
else offline++;
|
|
27786
|
+
}
|
|
27787
|
+
return {
|
|
27788
|
+
totalDevices: this.bindings.size,
|
|
27789
|
+
online,
|
|
27790
|
+
offline,
|
|
27791
|
+
byCap,
|
|
27792
|
+
byAddon,
|
|
27793
|
+
byType,
|
|
27794
|
+
statedDevices: this.stateMirror.size
|
|
27795
|
+
};
|
|
27796
|
+
}
|
|
27797
|
+
/**
|
|
27798
|
+
* Debug-friendly dump — full state + binding + metadata for one
|
|
27799
|
+
* device or all devices. Cheap deep clone so caller mutations don't
|
|
27800
|
+
* leak into the mirror.
|
|
27801
|
+
*/
|
|
27802
|
+
dump(deviceId) {
|
|
27803
|
+
const dumpOne = (id) => {
|
|
27804
|
+
const info = this.devices.get(id) ?? null;
|
|
27805
|
+
const binding = this.bindings.get(id) ?? null;
|
|
27806
|
+
const state = {};
|
|
27807
|
+
const perCap = this.stateMirror.get(id);
|
|
27808
|
+
if (perCap) for (const [cap, slice] of perCap) state[cap] = { ...slice };
|
|
27809
|
+
return {
|
|
27810
|
+
deviceId: id,
|
|
27811
|
+
info,
|
|
27812
|
+
binding: binding ? {
|
|
27813
|
+
...binding,
|
|
27814
|
+
entries: binding.entries.map((e) => ({ ...e }))
|
|
27815
|
+
} : null,
|
|
27816
|
+
state
|
|
27817
|
+
};
|
|
27818
|
+
};
|
|
27819
|
+
if (deviceId !== void 0) return dumpOne(deviceId);
|
|
27820
|
+
const out = [];
|
|
27821
|
+
for (const id of this.bindings.keys()) out.push(dumpOne(id));
|
|
27822
|
+
return out;
|
|
27823
|
+
}
|
|
27824
|
+
/**
|
|
27825
|
+
* Direct read-only access to the underlying state mirror. Use
|
|
27826
|
+
* sparingly — `getSystemState` returns a deep copy that's safer for
|
|
27827
|
+
* exploratory work; this avoids the clone cost when iterating
|
|
27828
|
+
* thousands of slices.
|
|
27829
|
+
*/
|
|
27830
|
+
getRawMirror() {
|
|
27831
|
+
return this.stateMirror;
|
|
27832
|
+
}
|
|
27833
|
+
/**
|
|
27834
|
+
* Snapshot of the full state mirror — same shape as the warm-boot
|
|
27835
|
+
* payload. Deep-cloned; safe to mutate.
|
|
27836
|
+
*/
|
|
27837
|
+
getSystemState() {
|
|
27838
|
+
const copy = /* @__PURE__ */ new Map();
|
|
27839
|
+
for (const [id, perCap] of this.stateMirror) {
|
|
27840
|
+
const dup = /* @__PURE__ */ new Map();
|
|
27841
|
+
for (const [k, v] of perCap) dup.set(k, { ...v });
|
|
27842
|
+
copy.set(id, dup);
|
|
27843
|
+
}
|
|
27844
|
+
return copy;
|
|
27845
|
+
}
|
|
27846
|
+
subscribeBus() {
|
|
27847
|
+
if (!this.api.live?.onEvent) return;
|
|
27848
|
+
const sub = this.api.live.onEvent;
|
|
27849
|
+
this.bridges.push(sub.subscribe({ category: STATE_CHANGED_CATEGORY }, { onData: (evt) => {
|
|
27850
|
+
const data = evt.data;
|
|
27851
|
+
if (!data || typeof data.deviceId !== "number" || typeof data.capName !== "string") return;
|
|
27852
|
+
this.applyStateUpdate(data.deviceId, data.capName, data.slice);
|
|
27853
|
+
} }));
|
|
27854
|
+
this.bridges.push(sub.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
|
|
27855
|
+
const data = evt.data;
|
|
27856
|
+
const deviceId = typeof data?.deviceId === "number" ? data.deviceId : data?.source?.type === "device" && typeof data.source.id === "number" ? data.source.id : null;
|
|
27857
|
+
if (deviceId === null) return;
|
|
27858
|
+
this.refreshBinding(deviceId);
|
|
27859
|
+
} }));
|
|
27860
|
+
this.bridges.push(sub.subscribe({ category: DEVICE_REGISTERED_CATEGORY }, { onData: (evt) => {
|
|
27861
|
+
const data = evt.data;
|
|
27862
|
+
if (typeof data?.deviceId !== "number") return;
|
|
27863
|
+
this.refreshDeviceMetadata(data.deviceId, "added");
|
|
27864
|
+
} }));
|
|
27865
|
+
this.bridges.push(sub.subscribe({ category: DEVICE_UNREGISTERED_CATEGORY }, { onData: (evt) => {
|
|
27866
|
+
const data = evt.data;
|
|
27867
|
+
if (typeof data?.deviceId !== "number") return;
|
|
27868
|
+
this.applyDeviceRemoval(data.deviceId);
|
|
27869
|
+
} }));
|
|
27870
|
+
this.bridges.push(sub.subscribe({ category: DEVICE_UPDATED_CATEGORY }, { onData: (evt) => {
|
|
27871
|
+
const data = evt.data;
|
|
27872
|
+
if (typeof data?.deviceId !== "number") return;
|
|
27873
|
+
this.refreshDeviceMetadata(data.deviceId, "updated");
|
|
27874
|
+
} }));
|
|
27875
|
+
}
|
|
27876
|
+
applyStateUpdate(deviceId, capName, slice) {
|
|
27877
|
+
let perCap = this.stateMirror.get(deviceId);
|
|
27878
|
+
if (!perCap) {
|
|
27879
|
+
perCap = /* @__PURE__ */ new Map();
|
|
27880
|
+
this.stateMirror.set(deviceId, perCap);
|
|
27881
|
+
}
|
|
27882
|
+
if (slice === void 0) perCap.delete(capName);
|
|
27883
|
+
else perCap.set(capName, slice);
|
|
27884
|
+
const handleKey = `${deviceId}:${capName}`;
|
|
27885
|
+
const handleSet = this.handleListeners.get(handleKey);
|
|
27886
|
+
if (handleSet) for (const cb of handleSet) try {
|
|
27887
|
+
cb(slice);
|
|
27888
|
+
} catch {}
|
|
27889
|
+
for (const cb of this.globalStateListeners) try {
|
|
27890
|
+
cb(deviceId, capName, slice);
|
|
27891
|
+
} catch {}
|
|
27892
|
+
const capSet = this.capListeners.get(capName);
|
|
27893
|
+
if (capSet) for (const cb of capSet) try {
|
|
27894
|
+
cb(deviceId, slice);
|
|
27895
|
+
} catch {}
|
|
27896
|
+
const devSet = this.deviceListeners.get(deviceId);
|
|
27897
|
+
if (devSet) for (const cb of devSet) try {
|
|
27898
|
+
cb(deviceId, capName, slice);
|
|
27899
|
+
} catch {}
|
|
27900
|
+
}
|
|
27901
|
+
applyDeviceRemoval(deviceId) {
|
|
27902
|
+
const lastInfo = this.devices.get(deviceId) ?? null;
|
|
27903
|
+
this.bindings.delete(deviceId);
|
|
27904
|
+
this.devices.delete(deviceId);
|
|
27905
|
+
this.stateMirror.delete(deviceId);
|
|
27906
|
+
for (const cb of this.removedListeners) try {
|
|
27907
|
+
cb(deviceId, lastInfo);
|
|
27908
|
+
} catch {}
|
|
27909
|
+
}
|
|
27910
|
+
async refreshBinding(deviceId) {
|
|
27911
|
+
try {
|
|
27912
|
+
const fresh = (await this.api.deviceManager.getAllBindings.query({})).find((b) => b.deviceId === deviceId);
|
|
27913
|
+
if (fresh) this.bindings.set(deviceId, fresh);
|
|
27914
|
+
else this.applyDeviceRemoval(deviceId);
|
|
27915
|
+
} catch {}
|
|
27916
|
+
}
|
|
27917
|
+
async refreshDeviceMetadata(deviceId, kind) {
|
|
27918
|
+
try {
|
|
27919
|
+
const info = await this.api.deviceManager.getDevice.query({ deviceId });
|
|
27920
|
+
if (!info) return;
|
|
27921
|
+
const wasNew = !this.devices.has(deviceId);
|
|
27922
|
+
this.devices.set(deviceId, info);
|
|
27923
|
+
if (kind === "added" && wasNew) {
|
|
27924
|
+
await this.refreshBinding(deviceId);
|
|
27925
|
+
for (const cb of this.addedListeners) try {
|
|
27926
|
+
cb(deviceId, info);
|
|
27927
|
+
} catch {}
|
|
27928
|
+
}
|
|
27929
|
+
} catch {}
|
|
27930
|
+
}
|
|
27931
|
+
};
|
|
27932
|
+
function inSet(value, set) {
|
|
27933
|
+
if (Array.isArray(set)) return set.includes(value);
|
|
27934
|
+
return value === set;
|
|
27935
|
+
}
|
|
27936
|
+
function toArray(value) {
|
|
27937
|
+
return Array.isArray(value) ? value : [value];
|
|
27938
|
+
}
|
|
27939
|
+
function matchesString(haystack, match) {
|
|
27940
|
+
if (typeof match === "string") return haystack === match;
|
|
27941
|
+
if (match instanceof RegExp) return match.test(haystack);
|
|
27942
|
+
if ("exact" in match) return haystack === match.exact;
|
|
27943
|
+
if ("contains" in match) return haystack.toLowerCase().includes(match.contains.toLowerCase());
|
|
27944
|
+
return false;
|
|
27945
|
+
}
|
|
27946
|
+
//#endregion
|
|
27947
|
+
//#region src/device/zod-to-config-ui.ts
|
|
27948
|
+
/** Access Zod v4 internal .def — not in public typings but stable at runtime */
|
|
27949
|
+
function zodDef(schema) {
|
|
27950
|
+
return schema.def;
|
|
27951
|
+
}
|
|
27952
|
+
/** Access internal properties on a Zod schema instance */
|
|
27953
|
+
function zodInternals(schema) {
|
|
27954
|
+
return schema;
|
|
27955
|
+
}
|
|
27956
|
+
/**
|
|
27957
|
+
* Convert DeviceConfig.entries() output to ConfigUISchema for the admin UI FormBuilder.
|
|
27958
|
+
*
|
|
27959
|
+
* Each entry's Zod type is inspected to determine the correct ConfigField type:
|
|
27960
|
+
* - ZodString → 'text' (or 'password' when key contains "password"/"secret"/"token"/"apikey")
|
|
27961
|
+
* - ZodNumber → 'number' (extracts min/max/step from Zod v4 checks)
|
|
27962
|
+
* - ZodBoolean → 'boolean'
|
|
27963
|
+
* - ZodEnum → 'select' (options built from enum values)
|
|
27964
|
+
* - Anything else → 'text' fallback
|
|
27965
|
+
*
|
|
27966
|
+
* Wrapper types ZodDefault, ZodOptional, and ZodNullable are unwrapped transparently.
|
|
27967
|
+
* Default values are extracted from ZodDefault wrappers.
|
|
27968
|
+
*/
|
|
27969
|
+
function zodEntriesToConfigUI(entries, sectionTitle = "Configuration", sectionId = "main") {
|
|
27970
|
+
return { sections: [{
|
|
27971
|
+
id: sectionId,
|
|
27972
|
+
title: sectionTitle,
|
|
27973
|
+
fields: entries.map((entry) => zodToConfigField(entry.key, entry.schema, entry.description))
|
|
27974
|
+
}] };
|
|
27975
|
+
}
|
|
27976
|
+
function zodToConfigField(key, schema, description) {
|
|
27977
|
+
const inner = unwrapZod(schema);
|
|
27978
|
+
const defaultValue = getZodDefault(schema);
|
|
27979
|
+
const base = {
|
|
27980
|
+
key,
|
|
27981
|
+
label: description ?? humanizeKey(key),
|
|
27982
|
+
description,
|
|
27983
|
+
default: defaultValue
|
|
27984
|
+
};
|
|
27985
|
+
if (inner instanceof zod.z.ZodString) return buildStringField(key, base);
|
|
27986
|
+
if (inner instanceof zod.z.ZodNumber) return buildNumberField(inner, base);
|
|
27987
|
+
if (inner instanceof zod.z.ZodBoolean) return {
|
|
27988
|
+
...base,
|
|
27989
|
+
type: "boolean"
|
|
27990
|
+
};
|
|
27991
|
+
if (inner instanceof zod.z.ZodEnum) return buildEnumField(inner, base);
|
|
27992
|
+
if (inner instanceof zod.z.ZodArray || inner instanceof zod.z.ZodObject) return {
|
|
27993
|
+
...base,
|
|
27994
|
+
type: "textarea",
|
|
27995
|
+
rows: 6,
|
|
27996
|
+
isJson: true
|
|
27997
|
+
};
|
|
27998
|
+
return {
|
|
27999
|
+
...base,
|
|
28000
|
+
type: "text"
|
|
28001
|
+
};
|
|
28002
|
+
}
|
|
28003
|
+
function buildStringField(key, base) {
|
|
28004
|
+
const lowerKey = key.toLowerCase();
|
|
28005
|
+
if (lowerKey.includes("password") || lowerKey.includes("secret") || lowerKey.includes("token") || lowerKey.includes("apikey") || lowerKey.includes("api_key")) return {
|
|
28006
|
+
...base,
|
|
28007
|
+
type: "password",
|
|
28008
|
+
showToggle: true
|
|
28009
|
+
};
|
|
28010
|
+
return {
|
|
28011
|
+
...base,
|
|
28012
|
+
type: "text"
|
|
28013
|
+
};
|
|
28014
|
+
}
|
|
28015
|
+
function buildNumberField(inner, base) {
|
|
28016
|
+
const anyInner = zodInternals(inner);
|
|
28017
|
+
const rawMin = anyInner.minValue;
|
|
28018
|
+
const rawMax = anyInner.maxValue;
|
|
28019
|
+
const min = rawMin != null && isFinite(rawMin) ? rawMin : void 0;
|
|
28020
|
+
const max = rawMax != null && isFinite(rawMax) ? rawMax : void 0;
|
|
28021
|
+
const step = getMultipleOfStep(inner);
|
|
28022
|
+
return {
|
|
28023
|
+
...base,
|
|
28024
|
+
type: "number",
|
|
28025
|
+
...min !== void 0 ? { min } : {},
|
|
28026
|
+
...max !== void 0 ? { max } : {},
|
|
28027
|
+
...step !== void 0 ? { step } : {}
|
|
28028
|
+
};
|
|
28029
|
+
}
|
|
28030
|
+
function getMultipleOfStep(inner) {
|
|
28031
|
+
const checks = zodDef(inner).checks ?? [];
|
|
28032
|
+
for (const check of checks) if (check._zod?.def?.check === "multiple_of" && check._zod.def.value !== void 0) return check._zod.def.value;
|
|
28033
|
+
}
|
|
28034
|
+
function buildEnumField(inner, base) {
|
|
28035
|
+
const values = inner.options.map((v) => String(v));
|
|
28036
|
+
return {
|
|
28037
|
+
...base,
|
|
28038
|
+
type: "select",
|
|
28039
|
+
options: values.map((v) => ({
|
|
28040
|
+
label: humanizeKey(v),
|
|
28041
|
+
value: v
|
|
28042
|
+
}))
|
|
28043
|
+
};
|
|
28044
|
+
}
|
|
28045
|
+
function unwrapZod(schema) {
|
|
28046
|
+
if (schema instanceof zod.z.ZodDefault) return unwrapZod(zodDef(schema).innerType);
|
|
28047
|
+
if (schema instanceof zod.z.ZodOptional) return unwrapZod(zodDef(schema).innerType);
|
|
28048
|
+
if (schema instanceof zod.z.ZodNullable) return unwrapZod(zodDef(schema).innerType);
|
|
28049
|
+
return schema;
|
|
28050
|
+
}
|
|
28051
|
+
function getZodDefault(schema) {
|
|
28052
|
+
if (schema instanceof zod.z.ZodDefault) return zodDef(schema).defaultValue;
|
|
28053
|
+
}
|
|
28054
|
+
function humanizeKey(key) {
|
|
28055
|
+
return key.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase()).trim();
|
|
28056
|
+
}
|
|
28057
|
+
//#endregion
|
|
28058
|
+
//#region src/expression/errors.ts
|
|
28059
|
+
/**
|
|
28060
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
28061
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
28062
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
28063
|
+
*/
|
|
28064
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
28065
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
28066
|
+
var ExpressionParseError = class extends Error {
|
|
28067
|
+
position;
|
|
28068
|
+
constructor(message, position) {
|
|
28069
|
+
super(message);
|
|
28070
|
+
this.name = "ExpressionParseError";
|
|
28071
|
+
this.position = position;
|
|
28072
|
+
}
|
|
28073
|
+
};
|
|
28074
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
28075
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
28076
|
+
var ExpressionEvalError = class extends Error {
|
|
28077
|
+
constructor(message) {
|
|
28078
|
+
super(message);
|
|
28079
|
+
this.name = "ExpressionEvalError";
|
|
28080
|
+
}
|
|
28081
|
+
};
|
|
28082
|
+
//#endregion
|
|
28083
|
+
//#region src/expression/builtins.ts
|
|
28084
|
+
/**
|
|
28085
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
28086
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
28087
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
28088
|
+
* own-property check against it.
|
|
28089
|
+
*
|
|
28090
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
28091
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
28092
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
28093
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
28094
|
+
* callable — they are simply "unknown function" at parse time.
|
|
28095
|
+
*
|
|
28096
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
28097
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
28098
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
28099
|
+
* closed rather than emitting a garbage value.
|
|
28100
|
+
*/
|
|
28101
|
+
function asFiniteNumber(value, name, index) {
|
|
28102
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
28103
|
+
return value;
|
|
28104
|
+
}
|
|
28105
|
+
function asString$1(value, name, index) {
|
|
28106
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
28107
|
+
return value;
|
|
28108
|
+
}
|
|
28109
|
+
function finiteResult(value, name) {
|
|
28110
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
28111
|
+
return value;
|
|
28112
|
+
}
|
|
28113
|
+
function allFiniteNumbers(args, name) {
|
|
28114
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
28115
|
+
}
|
|
28116
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
28117
|
+
var table = {
|
|
28118
|
+
min: {
|
|
28119
|
+
minArgs: 1,
|
|
28120
|
+
maxArgs: INF,
|
|
28121
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
28122
|
+
},
|
|
28123
|
+
max: {
|
|
28124
|
+
minArgs: 1,
|
|
28125
|
+
maxArgs: INF,
|
|
28126
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
28127
|
+
},
|
|
28128
|
+
abs: {
|
|
28129
|
+
minArgs: 1,
|
|
28130
|
+
maxArgs: 1,
|
|
28131
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
28132
|
+
},
|
|
28133
|
+
floor: {
|
|
28134
|
+
minArgs: 1,
|
|
28135
|
+
maxArgs: 1,
|
|
28136
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
28137
|
+
},
|
|
28138
|
+
ceil: {
|
|
28139
|
+
minArgs: 1,
|
|
28140
|
+
maxArgs: 1,
|
|
28141
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
28142
|
+
},
|
|
28143
|
+
sqrt: {
|
|
28144
|
+
minArgs: 1,
|
|
28145
|
+
maxArgs: 1,
|
|
28146
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
28147
|
+
},
|
|
28148
|
+
round: {
|
|
28149
|
+
minArgs: 1,
|
|
28150
|
+
maxArgs: 2,
|
|
28151
|
+
apply: (args) => {
|
|
28152
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
28153
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
28154
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
28155
|
+
const factor = 10 ** digits;
|
|
28156
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
28157
|
+
}
|
|
28158
|
+
},
|
|
28159
|
+
pow: {
|
|
28160
|
+
minArgs: 2,
|
|
28161
|
+
maxArgs: 2,
|
|
28162
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
28163
|
+
},
|
|
28164
|
+
clamp: {
|
|
28165
|
+
minArgs: 3,
|
|
28166
|
+
maxArgs: 3,
|
|
28167
|
+
apply: (args) => {
|
|
28168
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
28169
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
28170
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
28171
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
28172
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
28173
|
+
}
|
|
28174
|
+
},
|
|
28175
|
+
avg: {
|
|
28176
|
+
minArgs: 1,
|
|
28177
|
+
maxArgs: INF,
|
|
28178
|
+
apply: (args) => {
|
|
28179
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
28180
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
28181
|
+
}
|
|
28182
|
+
},
|
|
28183
|
+
sum: {
|
|
28184
|
+
minArgs: 1,
|
|
28185
|
+
maxArgs: INF,
|
|
28186
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
28187
|
+
},
|
|
28188
|
+
coalesce: {
|
|
28189
|
+
minArgs: 1,
|
|
28190
|
+
maxArgs: INF,
|
|
28191
|
+
apply: (args) => {
|
|
28192
|
+
for (const a of args) if (a !== null) return a;
|
|
28193
|
+
return null;
|
|
28194
|
+
}
|
|
28195
|
+
},
|
|
28196
|
+
age: {
|
|
28197
|
+
minArgs: 2,
|
|
28198
|
+
maxArgs: 2,
|
|
28199
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
28200
|
+
},
|
|
28201
|
+
convert: {
|
|
28202
|
+
minArgs: 3,
|
|
28203
|
+
maxArgs: 3,
|
|
28204
|
+
apply: (args, hooks) => {
|
|
28205
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
28206
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
28207
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
28208
|
+
if (hooks.convert) {
|
|
28209
|
+
const out = hooks.convert(x, from, to);
|
|
28210
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
28211
|
+
return finiteResult(out, "convert");
|
|
28212
|
+
}
|
|
28213
|
+
if (from === to) return x;
|
|
28214
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
28215
|
+
}
|
|
28216
|
+
}
|
|
28217
|
+
};
|
|
28218
|
+
/** Frozen, null-prototype builtin table. */
|
|
28219
|
+
var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
|
|
28220
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
28221
|
+
* callees at parse time (immediate author feedback). */
|
|
28222
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
28223
|
+
//#endregion
|
|
28224
|
+
//#region src/expression/limits.ts
|
|
28225
|
+
/**
|
|
28226
|
+
* Resource-bound constants for the safe expression engine.
|
|
28227
|
+
*
|
|
28228
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
28229
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
28230
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
28231
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
28232
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
28233
|
+
*/
|
|
28234
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
28235
|
+
* rejected without allocation. */
|
|
28236
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
28237
|
+
/** Max AST nodes — checked during parse; a deeply nested grouping that exceeds
|
|
28238
|
+
* this is rejected as "expression too complex". */
|
|
28239
|
+
var MAX_EXPRESSION_AST_NODES = 256;
|
|
28240
|
+
/** Defense-in-depth walker step budget — one increment per node visit during
|
|
28241
|
+
* evaluation. The grammar guarantees O(nodeCount) walks, so this can only trip
|
|
28242
|
+
* on a crafted maximum-size AST. */
|
|
28243
|
+
var MAX_EXPRESSION_EVAL_STEPS = 4096;
|
|
28244
|
+
/** Max named bindings on one {@link ExpressionSource}. */
|
|
28245
|
+
var MAX_EXPRESSION_BINDINGS = 32;
|
|
28246
|
+
/** Max positional arguments to any builtin call. */
|
|
28247
|
+
var MAX_EXPRESSION_CALL_ARGS = 16;
|
|
28248
|
+
/** LRU compile-cache capacity (parsed ASTs keyed by raw source string). */
|
|
28249
|
+
var EXPRESSION_COMPILE_CACHE_CAPACITY = 256;
|
|
28250
|
+
/** A legal binding / identifier name. */
|
|
28251
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
28252
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
28253
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
28254
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
28255
|
+
"now",
|
|
28256
|
+
"true",
|
|
28257
|
+
"false",
|
|
28258
|
+
"null"
|
|
28259
|
+
]);
|
|
28260
|
+
//#endregion
|
|
28261
|
+
//#region src/expression/tokenizer.ts
|
|
28262
|
+
/**
|
|
28263
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
28264
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
28265
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
28266
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
28267
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
28268
|
+
* is a parse error with a source position, so member access / assignment /
|
|
28269
|
+
* template literals are lexically impossible.
|
|
28270
|
+
*/
|
|
28271
|
+
var KEYWORDS = new Set([
|
|
28272
|
+
"true",
|
|
28273
|
+
"false",
|
|
28274
|
+
"null"
|
|
28275
|
+
]);
|
|
28276
|
+
function isDigit(ch) {
|
|
28277
|
+
return ch >= "0" && ch <= "9";
|
|
28278
|
+
}
|
|
28279
|
+
function isIdentStart(ch) {
|
|
28280
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
28281
|
+
}
|
|
28282
|
+
function isIdentPart(ch) {
|
|
28283
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
28284
|
+
}
|
|
28285
|
+
function isWhitespace(ch) {
|
|
28286
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
28287
|
+
}
|
|
28288
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
28289
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
28290
|
+
* string. */
|
|
28291
|
+
function tokenize(source) {
|
|
28292
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
28293
|
+
const tokens = [];
|
|
28294
|
+
let i = 0;
|
|
28295
|
+
const n = source.length;
|
|
28296
|
+
while (i < n) {
|
|
28297
|
+
const ch = source[i];
|
|
28298
|
+
if (isWhitespace(ch)) {
|
|
28299
|
+
i += 1;
|
|
28300
|
+
continue;
|
|
28301
|
+
}
|
|
28302
|
+
if (isDigit(ch)) {
|
|
28303
|
+
const start = i;
|
|
28304
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
28305
|
+
if (i < n && source[i] === ".") {
|
|
28306
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
28307
|
+
i += 1;
|
|
28308
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
28309
|
+
}
|
|
28310
|
+
const text = source.slice(start, i);
|
|
28311
|
+
const value = Number(text);
|
|
28312
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
28313
|
+
tokens.push({
|
|
28314
|
+
type: "number",
|
|
28315
|
+
value,
|
|
28316
|
+
pos: start
|
|
28317
|
+
});
|
|
28318
|
+
continue;
|
|
28128
28319
|
}
|
|
28129
|
-
|
|
28130
|
-
|
|
28131
|
-
|
|
28132
|
-
|
|
28133
|
-
|
|
28134
|
-
|
|
28135
|
-
|
|
28136
|
-
|
|
28137
|
-
|
|
28320
|
+
if (ch === "'" || ch === "\"") {
|
|
28321
|
+
const quote = ch;
|
|
28322
|
+
const start = i;
|
|
28323
|
+
i += 1;
|
|
28324
|
+
let out = "";
|
|
28325
|
+
let closed = false;
|
|
28326
|
+
while (i < n) {
|
|
28327
|
+
const c = source[i];
|
|
28328
|
+
if (c === "\\") {
|
|
28329
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
28330
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
28331
|
+
out += next;
|
|
28332
|
+
i += 2;
|
|
28333
|
+
continue;
|
|
28334
|
+
}
|
|
28335
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
28336
|
+
}
|
|
28337
|
+
if (c === quote) {
|
|
28338
|
+
closed = true;
|
|
28339
|
+
i += 1;
|
|
28340
|
+
break;
|
|
28341
|
+
}
|
|
28342
|
+
out += c;
|
|
28343
|
+
i += 1;
|
|
28344
|
+
}
|
|
28345
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
28346
|
+
tokens.push({
|
|
28347
|
+
type: "string",
|
|
28348
|
+
value: out,
|
|
28349
|
+
pos: start
|
|
28350
|
+
});
|
|
28351
|
+
continue;
|
|
28138
28352
|
}
|
|
28139
|
-
|
|
28140
|
-
|
|
28141
|
-
|
|
28142
|
-
|
|
28143
|
-
|
|
28144
|
-
|
|
28145
|
-
|
|
28146
|
-
|
|
28147
|
-
|
|
28148
|
-
|
|
28149
|
-
|
|
28150
|
-
|
|
28151
|
-
|
|
28152
|
-
|
|
28153
|
-
|
|
28353
|
+
if (isIdentStart(ch)) {
|
|
28354
|
+
const start = i;
|
|
28355
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
28356
|
+
const text = source.slice(start, i);
|
|
28357
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
28358
|
+
type: "keyword",
|
|
28359
|
+
keyword: keywordOf(text),
|
|
28360
|
+
pos: start
|
|
28361
|
+
});
|
|
28362
|
+
else tokens.push({
|
|
28363
|
+
type: "identifier",
|
|
28364
|
+
name: text,
|
|
28365
|
+
pos: start
|
|
28366
|
+
});
|
|
28367
|
+
continue;
|
|
28154
28368
|
}
|
|
28155
|
-
|
|
28156
|
-
|
|
28157
|
-
|
|
28158
|
-
|
|
28159
|
-
|
|
28160
|
-
|
|
28161
|
-
|
|
28162
|
-
|
|
28163
|
-
|
|
28164
|
-
this.globalStateListeners.delete(cb);
|
|
28165
|
-
};
|
|
28166
|
-
}
|
|
28167
|
-
/**
|
|
28168
|
-
* Per-cap listener — fires only for state changes on `capName`,
|
|
28169
|
-
* across every device. The callback receives the deviceId so the
|
|
28170
|
-
* caller can route.
|
|
28171
|
-
*/
|
|
28172
|
-
listenCap(capName, cb) {
|
|
28173
|
-
let set = this.capListeners.get(capName);
|
|
28174
|
-
if (!set) {
|
|
28175
|
-
set = /* @__PURE__ */ new Set();
|
|
28176
|
-
this.capListeners.set(capName, set);
|
|
28369
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
28370
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
28371
|
+
tokens.push({
|
|
28372
|
+
type: "punct",
|
|
28373
|
+
punct: two,
|
|
28374
|
+
pos: i
|
|
28375
|
+
});
|
|
28376
|
+
i += 2;
|
|
28377
|
+
continue;
|
|
28177
28378
|
}
|
|
28178
|
-
|
|
28179
|
-
|
|
28180
|
-
|
|
28181
|
-
|
|
28182
|
-
|
|
28183
|
-
|
|
28184
|
-
|
|
28185
|
-
|
|
28186
|
-
*/
|
|
28187
|
-
listenDevice(deviceId, cb) {
|
|
28188
|
-
let set = this.deviceListeners.get(deviceId);
|
|
28189
|
-
if (!set) {
|
|
28190
|
-
set = /* @__PURE__ */ new Set();
|
|
28191
|
-
this.deviceListeners.set(deviceId, set);
|
|
28379
|
+
if (isSinglePunct(ch)) {
|
|
28380
|
+
tokens.push({
|
|
28381
|
+
type: "punct",
|
|
28382
|
+
punct: ch,
|
|
28383
|
+
pos: i
|
|
28384
|
+
});
|
|
28385
|
+
i += 1;
|
|
28386
|
+
continue;
|
|
28192
28387
|
}
|
|
28193
|
-
|
|
28194
|
-
return () => {
|
|
28195
|
-
set.delete(cb);
|
|
28196
|
-
if (set.size === 0) this.deviceListeners.delete(deviceId);
|
|
28197
|
-
};
|
|
28388
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
28198
28389
|
}
|
|
28199
|
-
|
|
28200
|
-
|
|
28201
|
-
|
|
28202
|
-
|
|
28203
|
-
|
|
28204
|
-
|
|
28390
|
+
tokens.push({
|
|
28391
|
+
type: "eof",
|
|
28392
|
+
pos: n
|
|
28393
|
+
});
|
|
28394
|
+
return tokens;
|
|
28395
|
+
}
|
|
28396
|
+
function keywordOf(text) {
|
|
28397
|
+
if (text === "true") return "true";
|
|
28398
|
+
if (text === "false") return "false";
|
|
28399
|
+
return "null";
|
|
28400
|
+
}
|
|
28401
|
+
function isSinglePunct(ch) {
|
|
28402
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
28403
|
+
}
|
|
28404
|
+
//#endregion
|
|
28405
|
+
//#region src/expression/parser.ts
|
|
28406
|
+
/**
|
|
28407
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
28408
|
+
*
|
|
28409
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
28410
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
28411
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
28412
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
28413
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
28414
|
+
* that references a since-removed builtin degrades at read.
|
|
28415
|
+
*
|
|
28416
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
28417
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
28418
|
+
*/
|
|
28419
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
28420
|
+
var BINARY_PRECEDENCE = {
|
|
28421
|
+
"||": 1,
|
|
28422
|
+
"&&": 2,
|
|
28423
|
+
"==": 3,
|
|
28424
|
+
"!=": 3,
|
|
28425
|
+
"<": 4,
|
|
28426
|
+
"<=": 4,
|
|
28427
|
+
">": 4,
|
|
28428
|
+
">=": 4,
|
|
28429
|
+
"+": 5,
|
|
28430
|
+
"-": 5,
|
|
28431
|
+
"*": 6,
|
|
28432
|
+
"/": 6,
|
|
28433
|
+
"%": 6
|
|
28434
|
+
};
|
|
28435
|
+
function isLogicalOp(op) {
|
|
28436
|
+
return op === "&&" || op === "||";
|
|
28437
|
+
}
|
|
28438
|
+
function isBinaryOp(op) {
|
|
28439
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
28440
|
+
}
|
|
28441
|
+
var Parser = class {
|
|
28442
|
+
tokens;
|
|
28443
|
+
pos = 0;
|
|
28444
|
+
nodeCount = 0;
|
|
28445
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
28446
|
+
callees = /* @__PURE__ */ new Set();
|
|
28447
|
+
constructor(tokens) {
|
|
28448
|
+
this.tokens = tokens;
|
|
28205
28449
|
}
|
|
28206
|
-
|
|
28207
|
-
|
|
28208
|
-
|
|
28209
|
-
|
|
28210
|
-
return
|
|
28211
|
-
|
|
28450
|
+
parse() {
|
|
28451
|
+
const ast = this.parseTernary();
|
|
28452
|
+
const tok = this.peek();
|
|
28453
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
28454
|
+
return {
|
|
28455
|
+
ast,
|
|
28456
|
+
identifiers: this.identifiers,
|
|
28457
|
+
callees: this.callees,
|
|
28458
|
+
nodeCount: this.nodeCount
|
|
28212
28459
|
};
|
|
28213
28460
|
}
|
|
28214
|
-
|
|
28215
|
-
|
|
28216
|
-
* `(deviceId, capName)` becomes true. Resolves immediately if the
|
|
28217
|
-
* current slice already matches. Rejects with `Error('timeout')`
|
|
28218
|
-
* after `timeoutMs` (default 30s; pass `Infinity` to wait forever).
|
|
28219
|
-
*
|
|
28220
|
-
* Returns the matching slice — caller can read it directly without
|
|
28221
|
-
* a second mirror lookup.
|
|
28222
|
-
*/
|
|
28223
|
-
waitForState(deviceId, capName, predicate, timeoutMs = 3e4) {
|
|
28224
|
-
return new Promise((resolve, reject) => {
|
|
28225
|
-
const check = () => {
|
|
28226
|
-
const slice = this.stateMirror.get(deviceId)?.get(capName);
|
|
28227
|
-
if (slice && predicate(slice)) return slice;
|
|
28228
|
-
return null;
|
|
28229
|
-
};
|
|
28230
|
-
const initial = check();
|
|
28231
|
-
if (initial) {
|
|
28232
|
-
resolve(initial);
|
|
28233
|
-
return;
|
|
28234
|
-
}
|
|
28235
|
-
let timer = null;
|
|
28236
|
-
const off = this.listenDevice(deviceId, (_id, cap, slice) => {
|
|
28237
|
-
if (cap !== capName) return;
|
|
28238
|
-
if (!slice) return;
|
|
28239
|
-
if (predicate(slice)) {
|
|
28240
|
-
if (timer) clearTimeout(timer);
|
|
28241
|
-
off();
|
|
28242
|
-
resolve(slice);
|
|
28243
|
-
}
|
|
28244
|
-
});
|
|
28245
|
-
if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
|
|
28246
|
-
off();
|
|
28247
|
-
reject(/* @__PURE__ */ new Error(`waitForState timed out after ${timeoutMs}ms (deviceId=${deviceId}, capName=${capName})`));
|
|
28248
|
-
}, timeoutMs);
|
|
28249
|
-
});
|
|
28461
|
+
peek() {
|
|
28462
|
+
return this.tokens[this.pos];
|
|
28250
28463
|
}
|
|
28251
|
-
|
|
28252
|
-
|
|
28253
|
-
* binding exists). Resolves immediately if already known. Rejects
|
|
28254
|
-
* with timeout.
|
|
28255
|
-
*/
|
|
28256
|
-
waitForDevice(deviceId, timeoutMs = 3e4) {
|
|
28257
|
-
return new Promise((resolve, reject) => {
|
|
28258
|
-
const existing = this.getDeviceById(deviceId);
|
|
28259
|
-
if (existing) {
|
|
28260
|
-
resolve(existing);
|
|
28261
|
-
return;
|
|
28262
|
-
}
|
|
28263
|
-
let timer = null;
|
|
28264
|
-
const off = this.onDeviceAdded((id) => {
|
|
28265
|
-
if (id !== deviceId) return;
|
|
28266
|
-
const proxy = this.getDeviceById(id);
|
|
28267
|
-
if (!proxy) return;
|
|
28268
|
-
if (timer) clearTimeout(timer);
|
|
28269
|
-
off();
|
|
28270
|
-
resolve(proxy);
|
|
28271
|
-
});
|
|
28272
|
-
if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
|
|
28273
|
-
off();
|
|
28274
|
-
reject(/* @__PURE__ */ new Error(`waitForDevice timed out after ${timeoutMs}ms (deviceId=${deviceId})`));
|
|
28275
|
-
}, timeoutMs);
|
|
28276
|
-
});
|
|
28464
|
+
next() {
|
|
28465
|
+
return this.tokens[this.pos++];
|
|
28277
28466
|
}
|
|
28278
|
-
/**
|
|
28279
|
-
|
|
28280
|
-
|
|
28281
|
-
|
|
28282
|
-
|
|
28283
|
-
async forEachCap(capName, cb) {
|
|
28284
|
-
for (const proxy of this.filterByCap(capName)) await cb(proxy);
|
|
28467
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
28468
|
+
expectPunct(punct) {
|
|
28469
|
+
const tok = this.peek();
|
|
28470
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
28471
|
+
this.pos += 1;
|
|
28285
28472
|
}
|
|
28286
|
-
|
|
28287
|
-
|
|
28288
|
-
|
|
28289
|
-
|
|
28290
|
-
|
|
28291
|
-
*
|
|
28292
|
-
* Example:
|
|
28293
|
-
*
|
|
28294
|
-
* const results = await sm.invokeCap('snapshot', 'getSnapshot', {}, { parallelism: 4 })
|
|
28295
|
-
* const failed = results.filter(r => !r.ok)
|
|
28296
|
-
*/
|
|
28297
|
-
async invokeCap(capName, methodName, args, opts = {}) {
|
|
28298
|
-
const targets = this.filterByCap(capName);
|
|
28299
|
-
const parallelism = Math.max(1, opts.parallelism ?? targets.length);
|
|
28300
|
-
const out = [];
|
|
28301
|
-
for (let i = 0; i < targets.length; i += parallelism) {
|
|
28302
|
-
const chunk = targets.slice(i, i + parallelism);
|
|
28303
|
-
const settled = await Promise.allSettled(chunk.map(async (proxy) => {
|
|
28304
|
-
const cap = proxy[capName];
|
|
28305
|
-
if (!cap || typeof cap[methodName] !== "function") throw new Error(`device '${proxy.deviceId}' does not expose '${capName}.${methodName}'`);
|
|
28306
|
-
return await cap[methodName](args);
|
|
28307
|
-
}));
|
|
28308
|
-
for (let j = 0; j < settled.length; j++) {
|
|
28309
|
-
const proxy = chunk[j];
|
|
28310
|
-
const r = settled[j];
|
|
28311
|
-
if (r.status === "fulfilled") out.push({
|
|
28312
|
-
deviceId: proxy.deviceId,
|
|
28313
|
-
ok: true,
|
|
28314
|
-
result: r.value
|
|
28315
|
-
});
|
|
28316
|
-
else out.push({
|
|
28317
|
-
deviceId: proxy.deviceId,
|
|
28318
|
-
ok: false,
|
|
28319
|
-
error: r.reason
|
|
28320
|
-
});
|
|
28321
|
-
}
|
|
28473
|
+
matchPunct(punct) {
|
|
28474
|
+
const tok = this.peek();
|
|
28475
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
28476
|
+
this.pos += 1;
|
|
28477
|
+
return true;
|
|
28322
28478
|
}
|
|
28323
|
-
return
|
|
28479
|
+
return false;
|
|
28324
28480
|
}
|
|
28325
|
-
|
|
28326
|
-
|
|
28327
|
-
|
|
28328
|
-
*/
|
|
28329
|
-
summary() {
|
|
28330
|
-
const byCap = {};
|
|
28331
|
-
const byAddon = {};
|
|
28332
|
-
const byType = {};
|
|
28333
|
-
let online = 0;
|
|
28334
|
-
let offline = 0;
|
|
28335
|
-
for (const binding of this.bindings.values()) for (const entry of binding.entries) byCap[entry.capName] = (byCap[entry.capName] ?? 0) + 1;
|
|
28336
|
-
for (const info of this.devices.values()) {
|
|
28337
|
-
byAddon[info.addonId] = (byAddon[info.addonId] ?? 0) + 1;
|
|
28338
|
-
byType[info.type] = (byType[info.type] ?? 0) + 1;
|
|
28339
|
-
if (info.online) online++;
|
|
28340
|
-
else offline++;
|
|
28341
|
-
}
|
|
28342
|
-
return {
|
|
28343
|
-
totalDevices: this.bindings.size,
|
|
28344
|
-
online,
|
|
28345
|
-
offline,
|
|
28346
|
-
byCap,
|
|
28347
|
-
byAddon,
|
|
28348
|
-
byType,
|
|
28349
|
-
statedDevices: this.stateMirror.size
|
|
28350
|
-
};
|
|
28481
|
+
countNode() {
|
|
28482
|
+
this.nodeCount += 1;
|
|
28483
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
28351
28484
|
}
|
|
28352
|
-
|
|
28353
|
-
|
|
28354
|
-
|
|
28355
|
-
|
|
28356
|
-
|
|
28357
|
-
|
|
28358
|
-
|
|
28359
|
-
const info = this.devices.get(id) ?? null;
|
|
28360
|
-
const binding = this.bindings.get(id) ?? null;
|
|
28361
|
-
const state = {};
|
|
28362
|
-
const perCap = this.stateMirror.get(id);
|
|
28363
|
-
if (perCap) for (const [cap, slice] of perCap) state[cap] = { ...slice };
|
|
28485
|
+
parseTernary() {
|
|
28486
|
+
const test = this.parseBinary(1);
|
|
28487
|
+
if (this.matchPunct("?")) {
|
|
28488
|
+
const consequent = this.parseTernary();
|
|
28489
|
+
this.expectPunct(":");
|
|
28490
|
+
const alternate = this.parseTernary();
|
|
28491
|
+
this.countNode();
|
|
28364
28492
|
return {
|
|
28365
|
-
|
|
28366
|
-
|
|
28367
|
-
|
|
28368
|
-
|
|
28369
|
-
entries: binding.entries.map((e) => ({ ...e }))
|
|
28370
|
-
} : null,
|
|
28371
|
-
state
|
|
28493
|
+
kind: "conditional",
|
|
28494
|
+
test,
|
|
28495
|
+
consequent,
|
|
28496
|
+
alternate
|
|
28372
28497
|
};
|
|
28373
|
-
}
|
|
28374
|
-
|
|
28375
|
-
const out = [];
|
|
28376
|
-
for (const id of this.bindings.keys()) out.push(dumpOne(id));
|
|
28377
|
-
return out;
|
|
28378
|
-
}
|
|
28379
|
-
/**
|
|
28380
|
-
* Direct read-only access to the underlying state mirror. Use
|
|
28381
|
-
* sparingly — `getSystemState` returns a deep copy that's safer for
|
|
28382
|
-
* exploratory work; this avoids the clone cost when iterating
|
|
28383
|
-
* thousands of slices.
|
|
28384
|
-
*/
|
|
28385
|
-
getRawMirror() {
|
|
28386
|
-
return this.stateMirror;
|
|
28498
|
+
}
|
|
28499
|
+
return test;
|
|
28387
28500
|
}
|
|
28388
|
-
|
|
28389
|
-
|
|
28390
|
-
|
|
28391
|
-
|
|
28392
|
-
|
|
28393
|
-
|
|
28394
|
-
|
|
28395
|
-
const
|
|
28396
|
-
|
|
28397
|
-
|
|
28501
|
+
parseBinary(minPrec) {
|
|
28502
|
+
let left = this.parseUnary();
|
|
28503
|
+
for (;;) {
|
|
28504
|
+
const tok = this.peek();
|
|
28505
|
+
if (tok.type !== "punct") break;
|
|
28506
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
28507
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
28508
|
+
const op = tok.punct;
|
|
28509
|
+
this.pos += 1;
|
|
28510
|
+
const right = this.parseBinary(prec + 1);
|
|
28511
|
+
this.countNode();
|
|
28512
|
+
if (isLogicalOp(op)) left = {
|
|
28513
|
+
kind: "logical",
|
|
28514
|
+
op,
|
|
28515
|
+
left,
|
|
28516
|
+
right
|
|
28517
|
+
};
|
|
28518
|
+
else if (isBinaryOp(op)) left = {
|
|
28519
|
+
kind: "binary",
|
|
28520
|
+
op,
|
|
28521
|
+
left,
|
|
28522
|
+
right
|
|
28523
|
+
};
|
|
28524
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
28525
|
+
}
|
|
28526
|
+
return left;
|
|
28527
|
+
}
|
|
28528
|
+
parseUnary() {
|
|
28529
|
+
const tok = this.peek();
|
|
28530
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
28531
|
+
const op = tok.punct;
|
|
28532
|
+
this.pos += 1;
|
|
28533
|
+
const operand = this.parseUnary();
|
|
28534
|
+
this.countNode();
|
|
28535
|
+
return {
|
|
28536
|
+
kind: "unary",
|
|
28537
|
+
op,
|
|
28538
|
+
operand
|
|
28539
|
+
};
|
|
28398
28540
|
}
|
|
28399
|
-
return
|
|
28541
|
+
return this.parsePrimary();
|
|
28400
28542
|
}
|
|
28401
|
-
|
|
28402
|
-
|
|
28403
|
-
|
|
28404
|
-
|
|
28405
|
-
|
|
28406
|
-
|
|
28407
|
-
|
|
28408
|
-
|
|
28409
|
-
|
|
28410
|
-
|
|
28411
|
-
|
|
28412
|
-
|
|
28413
|
-
|
|
28414
|
-
|
|
28415
|
-
|
|
28416
|
-
|
|
28417
|
-
|
|
28418
|
-
|
|
28419
|
-
|
|
28420
|
-
|
|
28421
|
-
|
|
28422
|
-
|
|
28423
|
-
|
|
28424
|
-
|
|
28425
|
-
|
|
28426
|
-
|
|
28427
|
-
|
|
28428
|
-
|
|
28429
|
-
|
|
28543
|
+
parsePrimary() {
|
|
28544
|
+
const tok = this.next();
|
|
28545
|
+
switch (tok.type) {
|
|
28546
|
+
case "number":
|
|
28547
|
+
this.countNode();
|
|
28548
|
+
return {
|
|
28549
|
+
kind: "literal",
|
|
28550
|
+
value: tok.value
|
|
28551
|
+
};
|
|
28552
|
+
case "string":
|
|
28553
|
+
this.countNode();
|
|
28554
|
+
return {
|
|
28555
|
+
kind: "literal",
|
|
28556
|
+
value: tok.value
|
|
28557
|
+
};
|
|
28558
|
+
case "keyword":
|
|
28559
|
+
this.countNode();
|
|
28560
|
+
return {
|
|
28561
|
+
kind: "literal",
|
|
28562
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
28563
|
+
};
|
|
28564
|
+
case "identifier": {
|
|
28565
|
+
const nextTok = this.peek();
|
|
28566
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
28567
|
+
this.identifiers.add(tok.name);
|
|
28568
|
+
this.countNode();
|
|
28569
|
+
return {
|
|
28570
|
+
kind: "identifier",
|
|
28571
|
+
name: tok.name
|
|
28572
|
+
};
|
|
28573
|
+
}
|
|
28574
|
+
case "punct":
|
|
28575
|
+
if (tok.punct === "(") {
|
|
28576
|
+
const inner = this.parseTernary();
|
|
28577
|
+
this.expectPunct(")");
|
|
28578
|
+
return inner;
|
|
28579
|
+
}
|
|
28580
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
28581
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
28582
|
+
}
|
|
28430
28583
|
}
|
|
28431
|
-
|
|
28432
|
-
|
|
28433
|
-
|
|
28434
|
-
|
|
28435
|
-
|
|
28584
|
+
parseCall(callee, pos) {
|
|
28585
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
28586
|
+
this.expectPunct("(");
|
|
28587
|
+
const args = [];
|
|
28588
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
28589
|
+
args.push(this.parseTernary());
|
|
28590
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
28591
|
+
if (this.matchPunct(",")) continue;
|
|
28592
|
+
this.expectPunct(")");
|
|
28593
|
+
break;
|
|
28436
28594
|
}
|
|
28437
|
-
|
|
28438
|
-
|
|
28439
|
-
|
|
28440
|
-
|
|
28441
|
-
|
|
28442
|
-
|
|
28443
|
-
}
|
|
28444
|
-
for (const cb of this.globalStateListeners) try {
|
|
28445
|
-
cb(deviceId, capName, slice);
|
|
28446
|
-
} catch {}
|
|
28447
|
-
const capSet = this.capListeners.get(capName);
|
|
28448
|
-
if (capSet) for (const cb of capSet) try {
|
|
28449
|
-
cb(deviceId, slice);
|
|
28450
|
-
} catch {}
|
|
28451
|
-
const devSet = this.deviceListeners.get(deviceId);
|
|
28452
|
-
if (devSet) for (const cb of devSet) try {
|
|
28453
|
-
cb(deviceId, capName, slice);
|
|
28454
|
-
} catch {}
|
|
28595
|
+
this.callees.add(callee);
|
|
28596
|
+
this.countNode();
|
|
28597
|
+
return {
|
|
28598
|
+
kind: "call",
|
|
28599
|
+
callee,
|
|
28600
|
+
args
|
|
28601
|
+
};
|
|
28455
28602
|
}
|
|
28456
|
-
|
|
28457
|
-
|
|
28458
|
-
|
|
28459
|
-
|
|
28460
|
-
|
|
28461
|
-
|
|
28462
|
-
|
|
28463
|
-
|
|
28603
|
+
};
|
|
28604
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
28605
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
28606
|
+
function parseExpression(source) {
|
|
28607
|
+
return new Parser(tokenize(source)).parse();
|
|
28608
|
+
}
|
|
28609
|
+
//#endregion
|
|
28610
|
+
//#region src/expression/compile.ts
|
|
28611
|
+
/**
|
|
28612
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
28613
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
28614
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
28615
|
+
* one per read on a hot resolve path.
|
|
28616
|
+
*
|
|
28617
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
28618
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
28619
|
+
* callers is safe and maximises hit rate.
|
|
28620
|
+
*/
|
|
28621
|
+
var cache = /* @__PURE__ */ new Map();
|
|
28622
|
+
function getCached(source) {
|
|
28623
|
+
const hit = cache.get(source);
|
|
28624
|
+
if (hit !== void 0) {
|
|
28625
|
+
cache.delete(source);
|
|
28626
|
+
cache.set(source, hit);
|
|
28627
|
+
return hit;
|
|
28464
28628
|
}
|
|
28465
|
-
|
|
28466
|
-
|
|
28467
|
-
|
|
28468
|
-
|
|
28469
|
-
|
|
28470
|
-
}
|
|
28629
|
+
let result;
|
|
28630
|
+
try {
|
|
28631
|
+
result = {
|
|
28632
|
+
ok: true,
|
|
28633
|
+
parsed: parseExpression(source)
|
|
28634
|
+
};
|
|
28635
|
+
} catch (err) {
|
|
28636
|
+
result = {
|
|
28637
|
+
ok: false,
|
|
28638
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
28639
|
+
};
|
|
28471
28640
|
}
|
|
28472
|
-
|
|
28473
|
-
|
|
28474
|
-
|
|
28475
|
-
|
|
28476
|
-
const wasNew = !this.devices.has(deviceId);
|
|
28477
|
-
this.devices.set(deviceId, info);
|
|
28478
|
-
if (kind === "added" && wasNew) {
|
|
28479
|
-
await this.refreshBinding(deviceId);
|
|
28480
|
-
for (const cb of this.addedListeners) try {
|
|
28481
|
-
cb(deviceId, info);
|
|
28482
|
-
} catch {}
|
|
28483
|
-
}
|
|
28484
|
-
} catch {}
|
|
28641
|
+
cache.set(source, result);
|
|
28642
|
+
if (cache.size > 256) {
|
|
28643
|
+
const oldest = cache.keys().next().value;
|
|
28644
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
28485
28645
|
}
|
|
28486
|
-
|
|
28487
|
-
function inSet(value, set) {
|
|
28488
|
-
if (Array.isArray(set)) return set.includes(value);
|
|
28489
|
-
return value === set;
|
|
28646
|
+
return result;
|
|
28490
28647
|
}
|
|
28491
|
-
|
|
28492
|
-
|
|
28648
|
+
/** Compile `source` to a `ParsedExpression`, throwing `ExpressionParseError`
|
|
28649
|
+
* on failure. LRU/negative-cached. */
|
|
28650
|
+
function compileExpression(source) {
|
|
28651
|
+
const result = getCached(source);
|
|
28652
|
+
if (result.ok) return result.parsed;
|
|
28653
|
+
throw new ExpressionParseError(result.error);
|
|
28493
28654
|
}
|
|
28494
|
-
|
|
28495
|
-
|
|
28496
|
-
|
|
28497
|
-
|
|
28498
|
-
if ("contains" in match) return haystack.toLowerCase().includes(match.contains.toLowerCase());
|
|
28499
|
-
return false;
|
|
28655
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
28656
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
28657
|
+
function compileExpressionSafe(source) {
|
|
28658
|
+
return getCached(source);
|
|
28500
28659
|
}
|
|
28501
28660
|
//#endregion
|
|
28502
|
-
//#region src/
|
|
28503
|
-
/** Access Zod v4 internal .def — not in public typings but stable at runtime */
|
|
28504
|
-
function zodDef(schema) {
|
|
28505
|
-
return schema.def;
|
|
28506
|
-
}
|
|
28507
|
-
/** Access internal properties on a Zod schema instance */
|
|
28508
|
-
function zodInternals(schema) {
|
|
28509
|
-
return schema;
|
|
28510
|
-
}
|
|
28661
|
+
//#region src/expression/evaluator.ts
|
|
28511
28662
|
/**
|
|
28512
|
-
*
|
|
28513
|
-
*
|
|
28514
|
-
* Each entry's Zod type is inspected to determine the correct ConfigField type:
|
|
28515
|
-
* - ZodString → 'text' (or 'password' when key contains "password"/"secret"/"token"/"apikey")
|
|
28516
|
-
* - ZodNumber → 'number' (extracts min/max/step from Zod v4 checks)
|
|
28517
|
-
* - ZodBoolean → 'boolean'
|
|
28518
|
-
* - ZodEnum → 'select' (options built from enum values)
|
|
28519
|
-
* - Anything else → 'text' fallback
|
|
28663
|
+
* Tree-walking evaluator for the safe expression mini-language.
|
|
28520
28664
|
*
|
|
28521
|
-
*
|
|
28522
|
-
*
|
|
28665
|
+
* SECURITY (spec §4 rule 2/5):
|
|
28666
|
+
* - The scope is an `Object.create(null)` copy of ONLY the caller's own
|
|
28667
|
+
* enumerable binding entries, so `name in scope` is a pure own-key check and
|
|
28668
|
+
* `constructor` / `__proto__` / `toString` are plain unknown identifiers.
|
|
28669
|
+
* - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
|
|
28670
|
+
* directly — the only external calls are into the frozen builtin table.
|
|
28671
|
+
* - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
|
|
28672
|
+
* construction; the step counter is defense-in-depth for a crafted max-size
|
|
28673
|
+
* AST. Nothing blocks: there are no timers, awaits or unbounded loops.
|
|
28523
28674
|
*/
|
|
28524
|
-
|
|
28525
|
-
|
|
28526
|
-
|
|
28527
|
-
|
|
28528
|
-
|
|
28529
|
-
|
|
28675
|
+
var EMPTY_HOOKS = Object.freeze({});
|
|
28676
|
+
/** Build a null-prototype scope from own-enumerable binding entries. Inherited
|
|
28677
|
+
* keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
|
|
28678
|
+
* so nothing smuggles in via the prototype chain. */
|
|
28679
|
+
function createExpressionScope(bindings) {
|
|
28680
|
+
const scope = Object.create(null);
|
|
28681
|
+
for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
|
|
28682
|
+
return scope;
|
|
28530
28683
|
}
|
|
28531
|
-
function
|
|
28532
|
-
|
|
28533
|
-
const defaultValue = getZodDefault(schema);
|
|
28534
|
-
const base = {
|
|
28535
|
-
key,
|
|
28536
|
-
label: description ?? humanizeKey(key),
|
|
28537
|
-
description,
|
|
28538
|
-
default: defaultValue
|
|
28539
|
-
};
|
|
28540
|
-
if (inner instanceof zod.z.ZodString) return buildStringField(key, base);
|
|
28541
|
-
if (inner instanceof zod.z.ZodNumber) return buildNumberField(inner, base);
|
|
28542
|
-
if (inner instanceof zod.z.ZodBoolean) return {
|
|
28543
|
-
...base,
|
|
28544
|
-
type: "boolean"
|
|
28545
|
-
};
|
|
28546
|
-
if (inner instanceof zod.z.ZodEnum) return buildEnumField(inner, base);
|
|
28547
|
-
if (inner instanceof zod.z.ZodArray || inner instanceof zod.z.ZodObject) return {
|
|
28548
|
-
...base,
|
|
28549
|
-
type: "textarea",
|
|
28550
|
-
rows: 6,
|
|
28551
|
-
isJson: true
|
|
28552
|
-
};
|
|
28553
|
-
return {
|
|
28554
|
-
...base,
|
|
28555
|
-
type: "text"
|
|
28556
|
-
};
|
|
28684
|
+
function isFiniteNumber(value) {
|
|
28685
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
28557
28686
|
}
|
|
28558
|
-
|
|
28559
|
-
|
|
28560
|
-
|
|
28561
|
-
...base,
|
|
28562
|
-
type: "password",
|
|
28563
|
-
showToggle: true
|
|
28564
|
-
};
|
|
28565
|
-
return {
|
|
28566
|
-
...base,
|
|
28567
|
-
type: "text"
|
|
28568
|
-
};
|
|
28687
|
+
/** JS truthiness of a primitive value. */
|
|
28688
|
+
function truthy(value) {
|
|
28689
|
+
return Boolean(value);
|
|
28569
28690
|
}
|
|
28570
|
-
function
|
|
28571
|
-
|
|
28572
|
-
|
|
28573
|
-
const rawMax = anyInner.maxValue;
|
|
28574
|
-
const min = rawMin != null && isFinite(rawMin) ? rawMin : void 0;
|
|
28575
|
-
const max = rawMax != null && isFinite(rawMax) ? rawMax : void 0;
|
|
28576
|
-
const step = getMultipleOfStep(inner);
|
|
28577
|
-
return {
|
|
28578
|
-
...base,
|
|
28579
|
-
type: "number",
|
|
28580
|
-
...min !== void 0 ? { min } : {},
|
|
28581
|
-
...max !== void 0 ? { max } : {},
|
|
28582
|
-
...step !== void 0 ? { step } : {}
|
|
28583
|
-
};
|
|
28691
|
+
function requireFinite(value, context) {
|
|
28692
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
|
|
28693
|
+
return value;
|
|
28584
28694
|
}
|
|
28585
|
-
function
|
|
28586
|
-
|
|
28587
|
-
|
|
28695
|
+
function step(ctx) {
|
|
28696
|
+
ctx.steps += 1;
|
|
28697
|
+
if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
|
|
28588
28698
|
}
|
|
28589
|
-
function
|
|
28590
|
-
|
|
28591
|
-
|
|
28592
|
-
|
|
28593
|
-
|
|
28594
|
-
|
|
28595
|
-
|
|
28596
|
-
|
|
28597
|
-
|
|
28598
|
-
|
|
28699
|
+
function evalNode(node, ctx) {
|
|
28700
|
+
step(ctx);
|
|
28701
|
+
switch (node.kind) {
|
|
28702
|
+
case "literal": return node.value;
|
|
28703
|
+
case "identifier":
|
|
28704
|
+
if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
|
|
28705
|
+
return ctx.scope[node.name];
|
|
28706
|
+
case "unary": return evalUnary(node.op, evalNode(node.operand, ctx));
|
|
28707
|
+
case "binary": return evalBinary(node.op, evalNode(node.left, ctx), evalNode(node.right, ctx));
|
|
28708
|
+
case "logical": {
|
|
28709
|
+
const left = evalNode(node.left, ctx);
|
|
28710
|
+
if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx) : left;
|
|
28711
|
+
return truthy(left) ? left : evalNode(node.right, ctx);
|
|
28712
|
+
}
|
|
28713
|
+
case "conditional": return truthy(evalNode(node.test, ctx)) ? evalNode(node.consequent, ctx) : evalNode(node.alternate, ctx);
|
|
28714
|
+
case "call": return evalCall(node.callee, node.args.map((a) => evalNode(a, ctx)), ctx.hooks);
|
|
28715
|
+
}
|
|
28599
28716
|
}
|
|
28600
|
-
function
|
|
28601
|
-
if (
|
|
28602
|
-
if (
|
|
28603
|
-
|
|
28604
|
-
return schema;
|
|
28717
|
+
function evalUnary(op, operand) {
|
|
28718
|
+
if (op === "!") return !truthy(operand);
|
|
28719
|
+
if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
|
|
28720
|
+
return requireFinite(-operand, "unary \"-\"");
|
|
28605
28721
|
}
|
|
28606
|
-
function
|
|
28607
|
-
|
|
28722
|
+
function evalBinary(op, left, right) {
|
|
28723
|
+
switch (op) {
|
|
28724
|
+
case "==": return left === right;
|
|
28725
|
+
case "!=": return left !== right;
|
|
28726
|
+
case "+":
|
|
28727
|
+
if (typeof left === "string" && typeof right === "string") return left + right;
|
|
28728
|
+
if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
|
|
28729
|
+
throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
|
|
28730
|
+
case "-":
|
|
28731
|
+
case "*":
|
|
28732
|
+
case "/":
|
|
28733
|
+
case "%":
|
|
28734
|
+
if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
|
|
28735
|
+
return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
|
|
28736
|
+
case "<":
|
|
28737
|
+
case "<=":
|
|
28738
|
+
case ">":
|
|
28739
|
+
case ">=":
|
|
28740
|
+
if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
|
|
28741
|
+
if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
|
|
28742
|
+
throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
|
|
28743
|
+
}
|
|
28608
28744
|
}
|
|
28609
|
-
function
|
|
28610
|
-
|
|
28745
|
+
function evalCall(callee, args, hooks) {
|
|
28746
|
+
if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
|
|
28747
|
+
const builtin = EXPRESSION_BUILTINS[callee];
|
|
28748
|
+
if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
|
|
28749
|
+
return builtin.apply(args, hooks);
|
|
28750
|
+
}
|
|
28751
|
+
/** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
|
|
28752
|
+
* runtime failure (unknown identifier, type mismatch, non-finite result,
|
|
28753
|
+
* step-budget exhaustion). */
|
|
28754
|
+
function evaluateAst(node, scope, opts) {
|
|
28755
|
+
return evalNode(node, {
|
|
28756
|
+
scope,
|
|
28757
|
+
hooks: opts?.hooks ?? EMPTY_HOOKS,
|
|
28758
|
+
maxSteps: opts?.maxSteps ?? 4096,
|
|
28759
|
+
steps: 0
|
|
28760
|
+
});
|
|
28761
|
+
}
|
|
28762
|
+
//#endregion
|
|
28763
|
+
//#region src/expression/expression-source.ts
|
|
28764
|
+
/** The `now` epoch-ms binding is auto-injected into every evaluation and is a
|
|
28765
|
+
* reserved binding name (authors may not rebind it). */
|
|
28766
|
+
var EXPRESSION_INJECTED_NOW = "now";
|
|
28767
|
+
/**
|
|
28768
|
+
* Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
|
|
28769
|
+
* Non-primitive values (objects, arrays, `undefined`, functions, bigint,
|
|
28770
|
+
* symbol) and non-finite numbers become `undefined` so the caller can apply
|
|
28771
|
+
* its binding-miss policy (→ `null`). `null` itself is a valid value.
|
|
28772
|
+
*/
|
|
28773
|
+
function toExpressionValue(raw) {
|
|
28774
|
+
if (raw === null) return null;
|
|
28775
|
+
if (typeof raw === "string") return raw;
|
|
28776
|
+
if (typeof raw === "boolean") return raw;
|
|
28777
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
|
|
28778
|
+
}
|
|
28779
|
+
/**
|
|
28780
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
28781
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
28782
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
28783
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
28784
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
28785
|
+
*/
|
|
28786
|
+
function validateExpressionSource(src) {
|
|
28787
|
+
const names = Object.keys(src.bindings);
|
|
28788
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
28789
|
+
for (const name of names) {
|
|
28790
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
28791
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
28792
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
28793
|
+
}
|
|
28794
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
28795
|
+
if (!compiled.ok) return compiled.error;
|
|
28796
|
+
const bound = new Set(names);
|
|
28797
|
+
for (const id of compiled.parsed.identifiers) {
|
|
28798
|
+
if (id === "now") continue;
|
|
28799
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
28800
|
+
}
|
|
28801
|
+
return null;
|
|
28802
|
+
}
|
|
28803
|
+
/**
|
|
28804
|
+
* Shared read-path evaluation. Builds a null-proto scope from `bindingValues`
|
|
28805
|
+
* plus the injected `now` (supplied by the caller for determinism and
|
|
28806
|
+
* testability), compiles via the LRU, and evaluates. Any failure (parse or
|
|
28807
|
+
* eval) returns `{ ok: false }` — the caller treats that as "skip this
|
|
28808
|
+
* derivation", never as a throw that takes the pass down.
|
|
28809
|
+
*/
|
|
28810
|
+
function evaluateExpressionSource(expr, bindingValues, now, opts) {
|
|
28811
|
+
const compiled = compileExpressionSafe(expr);
|
|
28812
|
+
if (!compiled.ok) return {
|
|
28813
|
+
ok: false,
|
|
28814
|
+
error: compiled.error
|
|
28815
|
+
};
|
|
28816
|
+
const scope = createExpressionScope({
|
|
28817
|
+
...bindingValues,
|
|
28818
|
+
["now"]: now
|
|
28819
|
+
});
|
|
28820
|
+
try {
|
|
28821
|
+
return {
|
|
28822
|
+
ok: true,
|
|
28823
|
+
value: evaluateAst(compiled.parsed.ast, scope, opts)
|
|
28824
|
+
};
|
|
28825
|
+
} catch (err) {
|
|
28826
|
+
return {
|
|
28827
|
+
ok: false,
|
|
28828
|
+
error: err instanceof ExpressionEvalError ? err.message : String(err)
|
|
28829
|
+
};
|
|
28830
|
+
}
|
|
28611
28831
|
}
|
|
28612
28832
|
//#endregion
|
|
28833
|
+
//#region src/expression/binding-source.ts
|
|
28834
|
+
/**
|
|
28835
|
+
* What an expression's named bindings READ from.
|
|
28836
|
+
*
|
|
28837
|
+
* Salvaged verbatim from the deleted device-link mechanism. Wiring's source
|
|
28838
|
+
* kinds were the one part of it worth keeping: addressing a device field by
|
|
28839
|
+
* re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
|
|
28840
|
+
* read are the vocabulary any cross-device derivation needs, and they were
|
|
28841
|
+
* already correct. What wiring got wrong was the DESTINATION — a field on
|
|
28842
|
+
* somebody else's device, with no identity — not the source.
|
|
28843
|
+
*
|
|
28844
|
+
* These shapes are therefore kept, re-homed next to the engine that consumes
|
|
28845
|
+
* them, and are the binding type of a composition recipe (the source picker
|
|
28846
|
+
* stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
|
|
28847
|
+
* binding is a read, never another expression.
|
|
28848
|
+
*
|
|
28849
|
+
* Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
|
|
28850
|
+
* a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
|
|
28851
|
+
*/
|
|
28852
|
+
/** Read a sibling accessory's status field, addressed by the sibling's key.
|
|
28853
|
+
* `kind` is optional for wire compatibility — absent means `'field'`. */
|
|
28854
|
+
var ExpressionFieldBindingSchema = zod.z.object({
|
|
28855
|
+
kind: zod.z.literal("field").optional(),
|
|
28856
|
+
sourceKey: zod.z.string(),
|
|
28857
|
+
cap: zod.z.string(),
|
|
28858
|
+
fieldPath: zod.z.string()
|
|
28859
|
+
});
|
|
28860
|
+
/** A constant. No device is read. */
|
|
28861
|
+
var ExpressionLiteralBindingSchema = zod.z.object({
|
|
28862
|
+
kind: zod.z.literal("literal"),
|
|
28863
|
+
value: zod.z.union([
|
|
28864
|
+
zod.z.string(),
|
|
28865
|
+
zod.z.number(),
|
|
28866
|
+
zod.z.boolean(),
|
|
28867
|
+
zod.z.null()
|
|
28868
|
+
])
|
|
28869
|
+
});
|
|
28870
|
+
/** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
|
|
28871
|
+
* never by numeric id, which a re-adoption reissues. */
|
|
28872
|
+
var ExpressionGlobalBindingSchema = zod.z.object({
|
|
28873
|
+
kind: zod.z.literal("global"),
|
|
28874
|
+
sourceStableId: zod.z.string(),
|
|
28875
|
+
cap: zod.z.string(),
|
|
28876
|
+
fieldPath: zod.z.string()
|
|
28877
|
+
});
|
|
28878
|
+
var ExpressionBindingSourceSchema = zod.z.union([
|
|
28879
|
+
ExpressionFieldBindingSchema,
|
|
28880
|
+
ExpressionLiteralBindingSchema,
|
|
28881
|
+
ExpressionGlobalBindingSchema
|
|
28882
|
+
]);
|
|
28883
|
+
/**
|
|
28884
|
+
* An expression plus the bindings its free identifiers resolve against.
|
|
28885
|
+
*
|
|
28886
|
+
* The `superRefine` runs the SAME author-time validation as
|
|
28887
|
+
* `validateExpressionSource` — compiles the expression, checks binding names,
|
|
28888
|
+
* checks identifier coverage — so every boundary that parses one
|
|
28889
|
+
* validates-at-write rather than discovering the problem at read time.
|
|
28890
|
+
* Compiles are LRU-cached, so repeated validation of the same string is a hit.
|
|
28891
|
+
*/
|
|
28892
|
+
var ExpressionSourceSchema = zod.z.object({
|
|
28893
|
+
expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
28894
|
+
bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
28895
|
+
}).superRefine((src, ctx) => {
|
|
28896
|
+
const err = validateExpressionSource(src);
|
|
28897
|
+
if (err !== null) ctx.addIssue({
|
|
28898
|
+
code: "custom",
|
|
28899
|
+
message: err,
|
|
28900
|
+
path: ["expr"]
|
|
28901
|
+
});
|
|
28902
|
+
});
|
|
28903
|
+
//#endregion
|
|
28613
28904
|
//#region src/generated/cap-status-types.ts
|
|
28614
28905
|
/**
|
|
28615
28906
|
* Runtime list of cap names with status. Used by the settings
|
|
@@ -30411,6 +30702,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
|
|
|
30411
30702
|
addonId: null,
|
|
30412
30703
|
access: "view"
|
|
30413
30704
|
},
|
|
30705
|
+
"coreBlocks.restart": {
|
|
30706
|
+
capName: "core-blocks",
|
|
30707
|
+
capScope: "system",
|
|
30708
|
+
addonId: null,
|
|
30709
|
+
access: "create"
|
|
30710
|
+
},
|
|
30414
30711
|
"coreBlocks.setEnabled": {
|
|
30415
30712
|
capName: "core-blocks",
|
|
30416
30713
|
capScope: "system",
|
|
@@ -31047,12 +31344,6 @@ var METHOD_ACCESS_MAP = Object.freeze({
|
|
|
31047
31344
|
addonId: null,
|
|
31048
31345
|
access: "create"
|
|
31049
31346
|
},
|
|
31050
|
-
"deviceManager.setDeviceLinks": {
|
|
31051
|
-
capName: "device-manager",
|
|
31052
|
-
capScope: "system",
|
|
31053
|
-
addonId: null,
|
|
31054
|
-
access: "create"
|
|
31055
|
-
},
|
|
31056
31347
|
"deviceManager.setDisabled": {
|
|
31057
31348
|
capName: "device-manager",
|
|
31058
31349
|
capScope: "system",
|
|
@@ -35465,6 +35756,7 @@ function createSystemProxy(api) {
|
|
|
35465
35756
|
update: (input) => dispatch("coreBlocks", "update", "mutation", input),
|
|
35466
35757
|
delete: (input) => dispatch("coreBlocks", "delete", "mutation", input),
|
|
35467
35758
|
setEnabled: (input) => dispatch("coreBlocks", "setEnabled", "mutation", input),
|
|
35759
|
+
restart: (input) => dispatch("coreBlocks", "restart", "mutation", input),
|
|
35468
35760
|
compile: (input) => dispatch("coreBlocks", "compile", "mutation", input),
|
|
35469
35761
|
getTypeDefs: (input) => dispatch("coreBlocks", "getTypeDefs", "query", input)
|
|
35470
35762
|
},
|
|
@@ -36388,6 +36680,441 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
|
|
|
36388
36680
|
updatedAt: zod.z.number()
|
|
36389
36681
|
});
|
|
36390
36682
|
//#endregion
|
|
36683
|
+
//#region src/pipeline/detail-crop.ts
|
|
36684
|
+
/**
|
|
36685
|
+
* THE detail-crop convention — the single derivation of the rectangle a
|
|
36686
|
+
* detail/enrichment step (clip-embedding, face-detection, plate-detection…)
|
|
36687
|
+
* is fed.
|
|
36688
|
+
*
|
|
36689
|
+
* ## Why this is one module and not two constants
|
|
36690
|
+
*
|
|
36691
|
+
* `object-clip` is ONE vector index, and cosine similarity is only meaningful
|
|
36692
|
+
* between vectors produced from the same crop convention. Two encode paths
|
|
36693
|
+
* write into it — the live detail plane and the embedding rebuild — and they
|
|
36694
|
+
* used to derive their crops independently: `DETAIL_CROP_PADDING_RATIO = 0.15`
|
|
36695
|
+
* with no squaring on one side, `DEFAULT_CROP_MARGIN = 0.2` with squaring on
|
|
36696
|
+
* by default on the other. Every rebuild therefore poured a second, silently
|
|
36697
|
+
* incomparable feature space into the index it exists to keep consistent.
|
|
36698
|
+
*
|
|
36699
|
+
* So the rectangle is derived HERE, once, from ONE convention value. Both
|
|
36700
|
+
* paths now reach this function through `pipelineRunner.runDetailSubtree` —
|
|
36701
|
+
* the runner is the only process that cuts (see `detail-subtree.ts`), and the
|
|
36702
|
+
* convention is a cluster-global `pipeline-orchestrator` setting. There is
|
|
36703
|
+
* deliberately no per-node or per-device scope: a per-accelerator crop margin
|
|
36704
|
+
* would reintroduce the same split, merely relocated.
|
|
36705
|
+
*
|
|
36706
|
+
* ## The default IS the live convention
|
|
36707
|
+
*
|
|
36708
|
+
* {@link DEFAULT_DETAIL_CROP_CONVENTION} reproduces what the live path has
|
|
36709
|
+
* been storing (0.15, no squaring). Anything else would invalidate every
|
|
36710
|
+
* vector already in the index on the day it shipped. Changing the convention
|
|
36711
|
+
* is legitimate — that is what the operator knob is for — but it must be
|
|
36712
|
+
* followed by a rebuild, which is now guaranteed to produce crops from this
|
|
36713
|
+
* same function.
|
|
36714
|
+
*/
|
|
36715
|
+
/**
|
|
36716
|
+
* Store identity of the convention in `pipeline-orchestrator`'s GLOBAL
|
|
36717
|
+
* (cluster-wide) settings.
|
|
36718
|
+
*
|
|
36719
|
+
* These live here rather than in the orchestrator because the reader is a
|
|
36720
|
+
* different addon — the pipeline runner, over the hub-routed `addon-settings`
|
|
36721
|
+
* cap. Addons never import each other, so a key owned by the writer would have
|
|
36722
|
+
* to be hand-copied by the reader, and a hand-copied key is how a setting
|
|
36723
|
+
* silently stops arriving while both sides still look correct.
|
|
36724
|
+
*/
|
|
36725
|
+
var DETAIL_CROP_SECTION_ID = "detail-crop";
|
|
36726
|
+
var DETAIL_CROP_PADDING_KEY = "detailCropPaddingRatio";
|
|
36727
|
+
var DETAIL_CROP_SQUARE_KEY = "detailCropSquare";
|
|
36728
|
+
/**
|
|
36729
|
+
* Operator-tunable crop convention. Single-valued and cluster-wide — see the
|
|
36730
|
+
* module docblock for why it cannot be scoped per node or per device.
|
|
36731
|
+
*/
|
|
36732
|
+
var DetailCropConventionSchema = zod.z.object({
|
|
36733
|
+
/**
|
|
36734
|
+
* Fraction of the box's own size added on EACH side before cutting.
|
|
36735
|
+
*
|
|
36736
|
+
* CLIP is trained on natural images WITH surroundings; a pixel-tight crop
|
|
36737
|
+
* removes exactly the context it is strongest on (a dog cut to its outline
|
|
36738
|
+
* is a dark blob). The right value is an empirical question, which is why it
|
|
36739
|
+
* is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
|
|
36740
|
+
*/
|
|
36741
|
+
paddingRatio: zod.z.number().min(0).max(4),
|
|
36742
|
+
/**
|
|
36743
|
+
* Square the window (in PIXELS) before cutting.
|
|
36744
|
+
*
|
|
36745
|
+
* CLIP's input is square, so a tall bbox resized straight to NxN is squashed
|
|
36746
|
+
* — a standing person becomes a shape the model never saw. Squaring costs
|
|
36747
|
+
* extra background, which is context the model wants anyway. Off by default
|
|
36748
|
+
* because the live path has never squared and the stored index reflects that.
|
|
36749
|
+
*/
|
|
36750
|
+
square: zod.z.boolean()
|
|
36751
|
+
});
|
|
36752
|
+
/**
|
|
36753
|
+
* The convention in force when nobody has configured one — byte-for-byte the
|
|
36754
|
+
* behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
|
|
36755
|
+
*/
|
|
36756
|
+
var DEFAULT_DETAIL_CROP_CONVENTION = {
|
|
36757
|
+
paddingRatio: .15,
|
|
36758
|
+
square: false
|
|
36759
|
+
};
|
|
36760
|
+
/**
|
|
36761
|
+
* Narrow a FLAT settings record to the convention.
|
|
36762
|
+
*
|
|
36763
|
+
* Per-FIELD fallback, deliberately: a junk padding must not also discard a
|
|
36764
|
+
* valid squaring choice. An absent or invalid value resolves to
|
|
36765
|
+
* {@link DEFAULT_DETAIL_CROP_CONVENTION} — the historical live behaviour —
|
|
36766
|
+
* rather than to a clamped number nobody chose, so a bad read can never
|
|
36767
|
+
* quietly change what the stored vectors mean.
|
|
36768
|
+
*/
|
|
36769
|
+
function readDetailCropConvention(config) {
|
|
36770
|
+
const paddingRatio = DetailCropConventionSchema.shape.paddingRatio.safeParse(config[DETAIL_CROP_PADDING_KEY]);
|
|
36771
|
+
const square = DetailCropConventionSchema.shape.square.safeParse(config[DETAIL_CROP_SQUARE_KEY]);
|
|
36772
|
+
return {
|
|
36773
|
+
paddingRatio: paddingRatio.success ? paddingRatio.data : DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio,
|
|
36774
|
+
square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
|
|
36775
|
+
};
|
|
36776
|
+
}
|
|
36777
|
+
function isHydratedField$1(entry) {
|
|
36778
|
+
return typeof entry === "object" && entry !== null && "key" in entry;
|
|
36779
|
+
}
|
|
36780
|
+
/**
|
|
36781
|
+
* Extract the convention from an `addon-settings.getGlobalSettings` payload.
|
|
36782
|
+
*
|
|
36783
|
+
* Walks EVERY section rather than looking inside {@link DETAIL_CROP_SECTION_ID}
|
|
36784
|
+
* alone: the keys are unique across the addon's schema, and a section rename
|
|
36785
|
+
* must not silently revert the whole cluster to the default. A `null` payload
|
|
36786
|
+
* (addon mid-boot) is the default convention.
|
|
36787
|
+
*/
|
|
36788
|
+
function pickDetailCropConvention(view) {
|
|
36789
|
+
if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
|
|
36790
|
+
const flat = {};
|
|
36791
|
+
for (const section of view.sections) for (const entry of section.fields) {
|
|
36792
|
+
if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
|
|
36793
|
+
if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
|
|
36794
|
+
}
|
|
36795
|
+
return readDetailCropConvention(flat);
|
|
36796
|
+
}
|
|
36797
|
+
/** Slider bounds for the operator-facing padding knob (orchestrator settings UI). */
|
|
36798
|
+
var DETAIL_CROP_PADDING_FIELD = {
|
|
36799
|
+
min: 0,
|
|
36800
|
+
max: 1,
|
|
36801
|
+
step: .05,
|
|
36802
|
+
default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
|
|
36803
|
+
};
|
|
36804
|
+
/**
|
|
36805
|
+
* Derive the crop rectangle for one parent detection.
|
|
36806
|
+
*
|
|
36807
|
+
* Order: pad by `paddingRatio` of the box's own size → optionally square in
|
|
36808
|
+
* pixel space around the padded centre → keep it inside the frame. Pure:
|
|
36809
|
+
* always returns a new rect and never mutates `bbox`.
|
|
36810
|
+
*
|
|
36811
|
+
* Edge handling differs by mode, on purpose:
|
|
36812
|
+
*
|
|
36813
|
+
* - **unsquared** — TRUNCATED at the frame border, byte-for-byte what the live
|
|
36814
|
+
* path has always done (`padAndClampFrameBbox`). A subject against the edge
|
|
36815
|
+
* gets a slightly smaller window. Changing this would silently reinterpret
|
|
36816
|
+
* every edge-touching vector already in the index.
|
|
36817
|
+
* - **squared** — SLID inward instead, because a truncated square is not
|
|
36818
|
+
* square and squaring exists precisely to preserve the aspect the model
|
|
36819
|
+
* sees. It only shrinks when the square is larger than the frame itself.
|
|
36820
|
+
*/
|
|
36821
|
+
function deriveDetailCropRect(bbox, frameWidth, frameHeight, convention) {
|
|
36822
|
+
const padX = convention.paddingRatio * bbox.w;
|
|
36823
|
+
const padY = convention.paddingRatio * bbox.h;
|
|
36824
|
+
const padded = {
|
|
36825
|
+
x: bbox.x - padX,
|
|
36826
|
+
y: bbox.y - padY,
|
|
36827
|
+
w: bbox.w + 2 * padX,
|
|
36828
|
+
h: bbox.h + 2 * padY
|
|
36829
|
+
};
|
|
36830
|
+
return convention.square ? slideInsideFrame(squareInPixels(padded, frameWidth, frameHeight), frameWidth, frameHeight) : truncateToFrame(padded, frameWidth, frameHeight);
|
|
36831
|
+
}
|
|
36832
|
+
/**
|
|
36833
|
+
* Grow the shorter side to the longer one around the window's centre, bounded
|
|
36834
|
+
* by the frame's shorter side — a square larger than the frame cannot exist,
|
|
36835
|
+
* and collapsing to the frame's short side is the most that does.
|
|
36836
|
+
*/
|
|
36837
|
+
function squareInPixels(rect, frameWidth, frameHeight) {
|
|
36838
|
+
const side = Math.min(Math.max(rect.w, rect.h), Math.min(frameWidth, frameHeight));
|
|
36839
|
+
const cx = rect.x + rect.w / 2;
|
|
36840
|
+
const cy = rect.y + rect.h / 2;
|
|
36841
|
+
return {
|
|
36842
|
+
x: cx - side / 2,
|
|
36843
|
+
y: cy - side / 2,
|
|
36844
|
+
w: side,
|
|
36845
|
+
h: side
|
|
36846
|
+
};
|
|
36847
|
+
}
|
|
36848
|
+
/**
|
|
36849
|
+
* Cut the window at the frame border — the pre-unification live behaviour,
|
|
36850
|
+
* preserved exactly so unsquared crops keep matching the stored index.
|
|
36851
|
+
*/
|
|
36852
|
+
function truncateToFrame(rect, frameWidth, frameHeight) {
|
|
36853
|
+
const x1 = Math.max(0, rect.x);
|
|
36854
|
+
const y1 = Math.max(0, rect.y);
|
|
36855
|
+
const x2 = Math.min(frameWidth, rect.x + rect.w);
|
|
36856
|
+
const y2 = Math.min(frameHeight, rect.y + rect.h);
|
|
36857
|
+
return {
|
|
36858
|
+
x: x1,
|
|
36859
|
+
y: y1,
|
|
36860
|
+
w: Math.max(0, x2 - x1),
|
|
36861
|
+
h: Math.max(0, y2 - y1)
|
|
36862
|
+
};
|
|
36863
|
+
}
|
|
36864
|
+
/**
|
|
36865
|
+
* Move the window inside the frame keeping its extent — used only for squared
|
|
36866
|
+
* windows, where truncating would destroy the squareness that is the point.
|
|
36867
|
+
*/
|
|
36868
|
+
function slideInsideFrame(rect, frameWidth, frameHeight) {
|
|
36869
|
+
const w = Math.max(0, Math.min(rect.w, frameWidth));
|
|
36870
|
+
const h = Math.max(0, Math.min(rect.h, frameHeight));
|
|
36871
|
+
return {
|
|
36872
|
+
x: Math.min(Math.max(0, rect.x), Math.max(0, frameWidth - w)),
|
|
36873
|
+
y: Math.min(Math.max(0, rect.y), Math.max(0, frameHeight - h)),
|
|
36874
|
+
w,
|
|
36875
|
+
h
|
|
36876
|
+
};
|
|
36877
|
+
}
|
|
36878
|
+
//#endregion
|
|
36879
|
+
//#region src/pipeline/native-lease.ts
|
|
36880
|
+
/**
|
|
36881
|
+
* THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
|
|
36882
|
+
* decode worker's native-resolution frame retention.
|
|
36883
|
+
*
|
|
36884
|
+
* ## Why they live here and not in the addon that reads them
|
|
36885
|
+
*
|
|
36886
|
+
* The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
|
|
36887
|
+
* the READER is a private child process of `addon-pipeline`'s pipeline-runner.
|
|
36888
|
+
* Addons never import each other, so a key owned by either side would have to
|
|
36889
|
+
* be hand-copied by the other — and a hand-copied key is how a setting silently
|
|
36890
|
+
* stops arriving while both sides still look correct. Same reasoning, same
|
|
36891
|
+
* placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
|
|
36892
|
+
*
|
|
36893
|
+
* ## Why cluster-wide and not per-node
|
|
36894
|
+
*
|
|
36895
|
+
* The lease is a per-decode-worker RAM window. Its purpose — the late
|
|
36896
|
+
* cross-process native crop landing on a full-resolution frame rather than the
|
|
36897
|
+
* ≤640 detection fallback — is a property of the PIPELINE, not of a node's
|
|
36898
|
+
* hardware: a per-node TTL would mean the same camera produces different crop
|
|
36899
|
+
* quality depending on which node the balancer placed it on, and nobody could
|
|
36900
|
+
* tell that from the stored media. Node-level RAM pressure is already handled
|
|
36901
|
+
* by the per-session budget ceiling, which is itself one of these knobs.
|
|
36902
|
+
*
|
|
36903
|
+
* ## What each knob costs
|
|
36904
|
+
*
|
|
36905
|
+
* A retained frame is a full NATIVE-resolution copy in system RAM. With the
|
|
36906
|
+
* default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
|
|
36907
|
+
* 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
|
|
36908
|
+
* for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
|
|
36909
|
+
* resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
|
|
36910
|
+
* clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
|
|
36911
|
+
* admission" for what actually gets admitted.
|
|
36912
|
+
*/
|
|
36913
|
+
/**
|
|
36914
|
+
* Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
|
|
36915
|
+
* (cluster-wide) settings. Keys are unique across that addon's whole schema, so
|
|
36916
|
+
* the reader can walk every section instead of trusting the section id.
|
|
36917
|
+
*/
|
|
36918
|
+
var NATIVE_LEASE_SECTION_ID = "native-lease";
|
|
36919
|
+
var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
|
|
36920
|
+
var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
|
|
36921
|
+
var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
|
|
36922
|
+
var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
|
|
36923
|
+
/**
|
|
36924
|
+
* WHICH delivered frames the decode worker retains a native copy of.
|
|
36925
|
+
*
|
|
36926
|
+
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
36927
|
+
* behaviour, and the only correct one if something can ask for a crop of a
|
|
36928
|
+
* frame the runner never sent to inference.
|
|
36929
|
+
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
36930
|
+
* A native-crop request always names a `frameId` that rode an inference
|
|
36931
|
+
* result, so that is the only set a request can name. How much it drops is
|
|
36932
|
+
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
36933
|
+
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
36934
|
+
* was not throttling as hard as the sketch supposed. Read
|
|
36935
|
+
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
36936
|
+
* of you rather than quoting a number from here. The newest delivered frame is
|
|
36937
|
+
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
36938
|
+
* which covers the one-frame race between a mark and the supersede that
|
|
36939
|
+
* consumes it.
|
|
36940
|
+
*/
|
|
36941
|
+
var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
|
|
36942
|
+
/**
|
|
36943
|
+
* Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
|
|
36944
|
+
* the slider) because the value also travels to a forked child process, where a
|
|
36945
|
+
* junk number would silently become a 0-length or unbounded retention window.
|
|
36946
|
+
*/
|
|
36947
|
+
var NativeLeaseSettingsSchema = zod.z.object({
|
|
36948
|
+
/**
|
|
36949
|
+
* How long a retained native frame is served before it counts as a miss.
|
|
36950
|
+
*
|
|
36951
|
+
* Must cover the FULL late-crop horizon: detection inference + the
|
|
36952
|
+
* cross-process inference-result hop to hub post-analysis + tracking + the
|
|
36953
|
+
* tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
|
|
36954
|
+
* outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
|
|
36955
|
+
* RAM per busy camera grows linearly with no measured hit-rate gain.
|
|
36956
|
+
*/
|
|
36957
|
+
ttlMs: zod.z.number().int().min(250).max(1e4),
|
|
36958
|
+
/**
|
|
36959
|
+
* Hard per-decode-worker RAM ceiling for retained native frames, in MB.
|
|
36960
|
+
*
|
|
36961
|
+
* Intended as a SAFETY ceiling with the TTL as the effective cap — but check
|
|
36962
|
+
* which one is actually binding before reasoning from that. At the shipped
|
|
36963
|
+
* 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
|
|
36964
|
+
* at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
|
|
36965
|
+
* `leaseFrames` on the metrics line say which. When the ceiling binds, a
|
|
36966
|
+
* change that admits fewer frames buys retention WINDOW at constant RAM
|
|
36967
|
+
* rather than giving RAM back — lower this knob if RAM is what you wanted.
|
|
36968
|
+
* `0` DISABLES the lease entirely and falls the worker back to the tiny
|
|
36969
|
+
* leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
|
|
36970
|
+
* to replace).
|
|
36971
|
+
*/
|
|
36972
|
+
budgetMb: zod.z.number().int().min(0).max(4096),
|
|
36973
|
+
/**
|
|
36974
|
+
* Demand window: eager per-frame native retention runs only within this many
|
|
36975
|
+
* ms of the last native-crop request (or of the dial starting).
|
|
36976
|
+
*
|
|
36977
|
+
* `0` means ALWAYS ON — it disables the gate, it does not disable retention.
|
|
36978
|
+
* That is the legacy behaviour that saturated an N100 (24 native-4K downloads
|
|
36979
|
+
* per second on a camera with zero crop demand), so leave it non-zero unless
|
|
36980
|
+
* you are reproducing that.
|
|
36981
|
+
*/
|
|
36982
|
+
activityMs: zod.z.number().int().min(0).max(12e4),
|
|
36983
|
+
/**
|
|
36984
|
+
* Which delivered frames are retained at all — see
|
|
36985
|
+
* {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
|
|
36986
|
+
* changes WHAT is kept rather than for how long, so it is also the only one
|
|
36987
|
+
* that can turn a crop that used to hit into a miss. The worker counts every
|
|
36988
|
+
* crop request naming a frame it did NOT see marked
|
|
36989
|
+
* (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
|
|
36990
|
+
* there is the signal that some caller names frames outside the inference set
|
|
36991
|
+
* and that this must go back to `all`.
|
|
36992
|
+
*/
|
|
36993
|
+
admission: NativeLeaseAdmissionSchema
|
|
36994
|
+
});
|
|
36995
|
+
/**
|
|
36996
|
+
* The values in force when the operator has set nothing — byte-for-byte the
|
|
36997
|
+
* constants the decode worker shipped with as env-var defaults, so making these
|
|
36998
|
+
* settings changed no behaviour on the day it landed.
|
|
36999
|
+
*/
|
|
37000
|
+
var DEFAULT_NATIVE_LEASE_SETTINGS = {
|
|
37001
|
+
ttlMs: 1200,
|
|
37002
|
+
budgetMb: 1024,
|
|
37003
|
+
activityMs: 15e3,
|
|
37004
|
+
admission: "inferred"
|
|
37005
|
+
};
|
|
37006
|
+
/** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
|
|
37007
|
+
var NATIVE_LEASE_TTL_FIELD = {
|
|
37008
|
+
min: 250,
|
|
37009
|
+
max: 1e4,
|
|
37010
|
+
step: 50,
|
|
37011
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
|
|
37012
|
+
};
|
|
37013
|
+
var NATIVE_LEASE_BUDGET_FIELD = {
|
|
37014
|
+
min: 0,
|
|
37015
|
+
max: 4096,
|
|
37016
|
+
step: 64,
|
|
37017
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
|
|
37018
|
+
};
|
|
37019
|
+
var NATIVE_LEASE_ACTIVITY_FIELD = {
|
|
37020
|
+
min: 0,
|
|
37021
|
+
max: 12e4,
|
|
37022
|
+
step: 1e3,
|
|
37023
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
|
|
37024
|
+
};
|
|
37025
|
+
/** Select options for the admission knob (orchestrator settings UI). */
|
|
37026
|
+
var NATIVE_LEASE_ADMISSION_FIELD = {
|
|
37027
|
+
options: [{
|
|
37028
|
+
value: "all",
|
|
37029
|
+
label: "Every delivered frame"
|
|
37030
|
+
}, {
|
|
37031
|
+
value: "inferred",
|
|
37032
|
+
label: "Only frames sent to inference"
|
|
37033
|
+
}],
|
|
37034
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
|
|
37035
|
+
};
|
|
37036
|
+
/**
|
|
37037
|
+
* Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
|
|
37038
|
+
* value equal to the shipped default.
|
|
37039
|
+
*
|
|
37040
|
+
* That last rule is not tidiness, it is the difference between the documented
|
|
37041
|
+
* precedence being true and being a lie. `addon-settings.getGlobalSettings`
|
|
37042
|
+
* returns a HYDRATED payload, and `hydrateField` fills an unstored field with
|
|
37043
|
+
* the schema's own `default` (verified live on the hub: a cluster that has never
|
|
37044
|
+
* opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
|
|
37045
|
+
* that at face value would report all three knobs as "set" on every cluster on
|
|
37046
|
+
* the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
|
|
37047
|
+
* emergency override that the precedence promises. There is no raw-store read on
|
|
37048
|
+
* this cap to distinguish the two, so the default value itself is treated as
|
|
37049
|
+
* "the operator has expressed no preference" — which is also what leaving a
|
|
37050
|
+
* slider untouched means.
|
|
37051
|
+
*
|
|
37052
|
+
* The cost is one honest edge: an operator who deliberately selects the default
|
|
37053
|
+
* value in order to overrule an env var does not get it. Clear the env var
|
|
37054
|
+
* instead; the worker's spawn line names the source, so this is visible rather
|
|
37055
|
+
* than mysterious.
|
|
37056
|
+
*/
|
|
37057
|
+
function readKnob(knob, raw) {
|
|
37058
|
+
const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
|
|
37059
|
+
if (!parsed.success) return null;
|
|
37060
|
+
return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
|
|
37061
|
+
}
|
|
37062
|
+
/** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
|
|
37063
|
+
function readAdmissionKnob(raw) {
|
|
37064
|
+
const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
|
|
37065
|
+
if (!parsed.success) return null;
|
|
37066
|
+
return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
|
|
37067
|
+
}
|
|
37068
|
+
/**
|
|
37069
|
+
* Narrow a FLAT settings record to the knobs the operator set.
|
|
37070
|
+
*
|
|
37071
|
+
* Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
|
|
37072
|
+
* budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
|
|
37073
|
+
* clamped, not defaulted) so the caller can still fall through to the env
|
|
37074
|
+
* override — clamping here would turn a typo into a value nobody chose. See
|
|
37075
|
+
* {@link readKnob} for why the default counts as unset.
|
|
37076
|
+
*/
|
|
37077
|
+
function readNativeLeaseOverride(config) {
|
|
37078
|
+
const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
|
|
37079
|
+
const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
|
|
37080
|
+
const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
|
|
37081
|
+
const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
|
|
37082
|
+
return {
|
|
37083
|
+
...ttlMs === null ? {} : { ttlMs },
|
|
37084
|
+
...budgetMb === null ? {} : { budgetMb },
|
|
37085
|
+
...activityMs === null ? {} : { activityMs },
|
|
37086
|
+
...admission === null ? {} : { admission }
|
|
37087
|
+
};
|
|
37088
|
+
}
|
|
37089
|
+
function isHydratedField(entry) {
|
|
37090
|
+
return typeof entry === "object" && entry !== null && "key" in entry;
|
|
37091
|
+
}
|
|
37092
|
+
var LEASE_KEYS = [
|
|
37093
|
+
NATIVE_LEASE_TTL_KEY,
|
|
37094
|
+
NATIVE_LEASE_BUDGET_KEY,
|
|
37095
|
+
NATIVE_LEASE_ACTIVITY_KEY,
|
|
37096
|
+
NATIVE_LEASE_ADMISSION_KEY
|
|
37097
|
+
];
|
|
37098
|
+
/**
|
|
37099
|
+
* Extract the operator's lease overrides from an
|
|
37100
|
+
* `addon-settings.getGlobalSettings` payload.
|
|
37101
|
+
*
|
|
37102
|
+
* Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
|
|
37103
|
+
* alone: the keys are unique across the addon's schema, and a section rename
|
|
37104
|
+
* must not silently revert the whole cluster to the defaults. A `null` payload
|
|
37105
|
+
* (addon mid-boot) means "operator set nothing" — the env/default fallback then
|
|
37106
|
+
* applies, which is the correct read of "I could not ask".
|
|
37107
|
+
*/
|
|
37108
|
+
function pickNativeLeaseOverride(view) {
|
|
37109
|
+
if (view === null) return {};
|
|
37110
|
+
const flat = {};
|
|
37111
|
+
for (const section of view.sections) for (const entry of section.fields) {
|
|
37112
|
+
if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
|
|
37113
|
+
if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
|
|
37114
|
+
}
|
|
37115
|
+
return readNativeLeaseOverride(flat);
|
|
37116
|
+
}
|
|
37117
|
+
//#endregion
|
|
36391
37118
|
//#region src/types/device-type.ts
|
|
36392
37119
|
var DEVICE_TYPE_INFO = { ["camera"]: {
|
|
36393
37120
|
type: "camera",
|
|
@@ -37447,441 +38174,6 @@ function pointInPolygon(point, polygon) {
|
|
|
37447
38174
|
return inside;
|
|
37448
38175
|
}
|
|
37449
38176
|
//#endregion
|
|
37450
|
-
//#region src/pipeline/detail-crop.ts
|
|
37451
|
-
/**
|
|
37452
|
-
* THE detail-crop convention — the single derivation of the rectangle a
|
|
37453
|
-
* detail/enrichment step (clip-embedding, face-detection, plate-detection…)
|
|
37454
|
-
* is fed.
|
|
37455
|
-
*
|
|
37456
|
-
* ## Why this is one module and not two constants
|
|
37457
|
-
*
|
|
37458
|
-
* `object-clip` is ONE vector index, and cosine similarity is only meaningful
|
|
37459
|
-
* between vectors produced from the same crop convention. Two encode paths
|
|
37460
|
-
* write into it — the live detail plane and the embedding rebuild — and they
|
|
37461
|
-
* used to derive their crops independently: `DETAIL_CROP_PADDING_RATIO = 0.15`
|
|
37462
|
-
* with no squaring on one side, `DEFAULT_CROP_MARGIN = 0.2` with squaring on
|
|
37463
|
-
* by default on the other. Every rebuild therefore poured a second, silently
|
|
37464
|
-
* incomparable feature space into the index it exists to keep consistent.
|
|
37465
|
-
*
|
|
37466
|
-
* So the rectangle is derived HERE, once, from ONE convention value. Both
|
|
37467
|
-
* paths now reach this function through `pipelineRunner.runDetailSubtree` —
|
|
37468
|
-
* the runner is the only process that cuts (see `detail-subtree.ts`), and the
|
|
37469
|
-
* convention is a cluster-global `pipeline-orchestrator` setting. There is
|
|
37470
|
-
* deliberately no per-node or per-device scope: a per-accelerator crop margin
|
|
37471
|
-
* would reintroduce the same split, merely relocated.
|
|
37472
|
-
*
|
|
37473
|
-
* ## The default IS the live convention
|
|
37474
|
-
*
|
|
37475
|
-
* {@link DEFAULT_DETAIL_CROP_CONVENTION} reproduces what the live path has
|
|
37476
|
-
* been storing (0.15, no squaring). Anything else would invalidate every
|
|
37477
|
-
* vector already in the index on the day it shipped. Changing the convention
|
|
37478
|
-
* is legitimate — that is what the operator knob is for — but it must be
|
|
37479
|
-
* followed by a rebuild, which is now guaranteed to produce crops from this
|
|
37480
|
-
* same function.
|
|
37481
|
-
*/
|
|
37482
|
-
/**
|
|
37483
|
-
* Store identity of the convention in `pipeline-orchestrator`'s GLOBAL
|
|
37484
|
-
* (cluster-wide) settings.
|
|
37485
|
-
*
|
|
37486
|
-
* These live here rather than in the orchestrator because the reader is a
|
|
37487
|
-
* different addon — the pipeline runner, over the hub-routed `addon-settings`
|
|
37488
|
-
* cap. Addons never import each other, so a key owned by the writer would have
|
|
37489
|
-
* to be hand-copied by the reader, and a hand-copied key is how a setting
|
|
37490
|
-
* silently stops arriving while both sides still look correct.
|
|
37491
|
-
*/
|
|
37492
|
-
var DETAIL_CROP_SECTION_ID = "detail-crop";
|
|
37493
|
-
var DETAIL_CROP_PADDING_KEY = "detailCropPaddingRatio";
|
|
37494
|
-
var DETAIL_CROP_SQUARE_KEY = "detailCropSquare";
|
|
37495
|
-
/**
|
|
37496
|
-
* Operator-tunable crop convention. Single-valued and cluster-wide — see the
|
|
37497
|
-
* module docblock for why it cannot be scoped per node or per device.
|
|
37498
|
-
*/
|
|
37499
|
-
var DetailCropConventionSchema = zod.z.object({
|
|
37500
|
-
/**
|
|
37501
|
-
* Fraction of the box's own size added on EACH side before cutting.
|
|
37502
|
-
*
|
|
37503
|
-
* CLIP is trained on natural images WITH surroundings; a pixel-tight crop
|
|
37504
|
-
* removes exactly the context it is strongest on (a dog cut to its outline
|
|
37505
|
-
* is a dark blob). The right value is an empirical question, which is why it
|
|
37506
|
-
* is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
|
|
37507
|
-
*/
|
|
37508
|
-
paddingRatio: zod.z.number().min(0).max(4),
|
|
37509
|
-
/**
|
|
37510
|
-
* Square the window (in PIXELS) before cutting.
|
|
37511
|
-
*
|
|
37512
|
-
* CLIP's input is square, so a tall bbox resized straight to NxN is squashed
|
|
37513
|
-
* — a standing person becomes a shape the model never saw. Squaring costs
|
|
37514
|
-
* extra background, which is context the model wants anyway. Off by default
|
|
37515
|
-
* because the live path has never squared and the stored index reflects that.
|
|
37516
|
-
*/
|
|
37517
|
-
square: zod.z.boolean()
|
|
37518
|
-
});
|
|
37519
|
-
/**
|
|
37520
|
-
* The convention in force when nobody has configured one — byte-for-byte the
|
|
37521
|
-
* behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
|
|
37522
|
-
*/
|
|
37523
|
-
var DEFAULT_DETAIL_CROP_CONVENTION = {
|
|
37524
|
-
paddingRatio: .15,
|
|
37525
|
-
square: false
|
|
37526
|
-
};
|
|
37527
|
-
/**
|
|
37528
|
-
* Narrow a FLAT settings record to the convention.
|
|
37529
|
-
*
|
|
37530
|
-
* Per-FIELD fallback, deliberately: a junk padding must not also discard a
|
|
37531
|
-
* valid squaring choice. An absent or invalid value resolves to
|
|
37532
|
-
* {@link DEFAULT_DETAIL_CROP_CONVENTION} — the historical live behaviour —
|
|
37533
|
-
* rather than to a clamped number nobody chose, so a bad read can never
|
|
37534
|
-
* quietly change what the stored vectors mean.
|
|
37535
|
-
*/
|
|
37536
|
-
function readDetailCropConvention(config) {
|
|
37537
|
-
const paddingRatio = DetailCropConventionSchema.shape.paddingRatio.safeParse(config[DETAIL_CROP_PADDING_KEY]);
|
|
37538
|
-
const square = DetailCropConventionSchema.shape.square.safeParse(config[DETAIL_CROP_SQUARE_KEY]);
|
|
37539
|
-
return {
|
|
37540
|
-
paddingRatio: paddingRatio.success ? paddingRatio.data : DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio,
|
|
37541
|
-
square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
|
|
37542
|
-
};
|
|
37543
|
-
}
|
|
37544
|
-
function isHydratedField$1(entry) {
|
|
37545
|
-
return typeof entry === "object" && entry !== null && "key" in entry;
|
|
37546
|
-
}
|
|
37547
|
-
/**
|
|
37548
|
-
* Extract the convention from an `addon-settings.getGlobalSettings` payload.
|
|
37549
|
-
*
|
|
37550
|
-
* Walks EVERY section rather than looking inside {@link DETAIL_CROP_SECTION_ID}
|
|
37551
|
-
* alone: the keys are unique across the addon's schema, and a section rename
|
|
37552
|
-
* must not silently revert the whole cluster to the default. A `null` payload
|
|
37553
|
-
* (addon mid-boot) is the default convention.
|
|
37554
|
-
*/
|
|
37555
|
-
function pickDetailCropConvention(view) {
|
|
37556
|
-
if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
|
|
37557
|
-
const flat = {};
|
|
37558
|
-
for (const section of view.sections) for (const entry of section.fields) {
|
|
37559
|
-
if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
|
|
37560
|
-
if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
|
|
37561
|
-
}
|
|
37562
|
-
return readDetailCropConvention(flat);
|
|
37563
|
-
}
|
|
37564
|
-
/** Slider bounds for the operator-facing padding knob (orchestrator settings UI). */
|
|
37565
|
-
var DETAIL_CROP_PADDING_FIELD = {
|
|
37566
|
-
min: 0,
|
|
37567
|
-
max: 1,
|
|
37568
|
-
step: .05,
|
|
37569
|
-
default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
|
|
37570
|
-
};
|
|
37571
|
-
/**
|
|
37572
|
-
* Derive the crop rectangle for one parent detection.
|
|
37573
|
-
*
|
|
37574
|
-
* Order: pad by `paddingRatio` of the box's own size → optionally square in
|
|
37575
|
-
* pixel space around the padded centre → keep it inside the frame. Pure:
|
|
37576
|
-
* always returns a new rect and never mutates `bbox`.
|
|
37577
|
-
*
|
|
37578
|
-
* Edge handling differs by mode, on purpose:
|
|
37579
|
-
*
|
|
37580
|
-
* - **unsquared** — TRUNCATED at the frame border, byte-for-byte what the live
|
|
37581
|
-
* path has always done (`padAndClampFrameBbox`). A subject against the edge
|
|
37582
|
-
* gets a slightly smaller window. Changing this would silently reinterpret
|
|
37583
|
-
* every edge-touching vector already in the index.
|
|
37584
|
-
* - **squared** — SLID inward instead, because a truncated square is not
|
|
37585
|
-
* square and squaring exists precisely to preserve the aspect the model
|
|
37586
|
-
* sees. It only shrinks when the square is larger than the frame itself.
|
|
37587
|
-
*/
|
|
37588
|
-
function deriveDetailCropRect(bbox, frameWidth, frameHeight, convention) {
|
|
37589
|
-
const padX = convention.paddingRatio * bbox.w;
|
|
37590
|
-
const padY = convention.paddingRatio * bbox.h;
|
|
37591
|
-
const padded = {
|
|
37592
|
-
x: bbox.x - padX,
|
|
37593
|
-
y: bbox.y - padY,
|
|
37594
|
-
w: bbox.w + 2 * padX,
|
|
37595
|
-
h: bbox.h + 2 * padY
|
|
37596
|
-
};
|
|
37597
|
-
return convention.square ? slideInsideFrame(squareInPixels(padded, frameWidth, frameHeight), frameWidth, frameHeight) : truncateToFrame(padded, frameWidth, frameHeight);
|
|
37598
|
-
}
|
|
37599
|
-
/**
|
|
37600
|
-
* Grow the shorter side to the longer one around the window's centre, bounded
|
|
37601
|
-
* by the frame's shorter side — a square larger than the frame cannot exist,
|
|
37602
|
-
* and collapsing to the frame's short side is the most that does.
|
|
37603
|
-
*/
|
|
37604
|
-
function squareInPixels(rect, frameWidth, frameHeight) {
|
|
37605
|
-
const side = Math.min(Math.max(rect.w, rect.h), Math.min(frameWidth, frameHeight));
|
|
37606
|
-
const cx = rect.x + rect.w / 2;
|
|
37607
|
-
const cy = rect.y + rect.h / 2;
|
|
37608
|
-
return {
|
|
37609
|
-
x: cx - side / 2,
|
|
37610
|
-
y: cy - side / 2,
|
|
37611
|
-
w: side,
|
|
37612
|
-
h: side
|
|
37613
|
-
};
|
|
37614
|
-
}
|
|
37615
|
-
/**
|
|
37616
|
-
* Cut the window at the frame border — the pre-unification live behaviour,
|
|
37617
|
-
* preserved exactly so unsquared crops keep matching the stored index.
|
|
37618
|
-
*/
|
|
37619
|
-
function truncateToFrame(rect, frameWidth, frameHeight) {
|
|
37620
|
-
const x1 = Math.max(0, rect.x);
|
|
37621
|
-
const y1 = Math.max(0, rect.y);
|
|
37622
|
-
const x2 = Math.min(frameWidth, rect.x + rect.w);
|
|
37623
|
-
const y2 = Math.min(frameHeight, rect.y + rect.h);
|
|
37624
|
-
return {
|
|
37625
|
-
x: x1,
|
|
37626
|
-
y: y1,
|
|
37627
|
-
w: Math.max(0, x2 - x1),
|
|
37628
|
-
h: Math.max(0, y2 - y1)
|
|
37629
|
-
};
|
|
37630
|
-
}
|
|
37631
|
-
/**
|
|
37632
|
-
* Move the window inside the frame keeping its extent — used only for squared
|
|
37633
|
-
* windows, where truncating would destroy the squareness that is the point.
|
|
37634
|
-
*/
|
|
37635
|
-
function slideInsideFrame(rect, frameWidth, frameHeight) {
|
|
37636
|
-
const w = Math.max(0, Math.min(rect.w, frameWidth));
|
|
37637
|
-
const h = Math.max(0, Math.min(rect.h, frameHeight));
|
|
37638
|
-
return {
|
|
37639
|
-
x: Math.min(Math.max(0, rect.x), Math.max(0, frameWidth - w)),
|
|
37640
|
-
y: Math.min(Math.max(0, rect.y), Math.max(0, frameHeight - h)),
|
|
37641
|
-
w,
|
|
37642
|
-
h
|
|
37643
|
-
};
|
|
37644
|
-
}
|
|
37645
|
-
//#endregion
|
|
37646
|
-
//#region src/pipeline/native-lease.ts
|
|
37647
|
-
/**
|
|
37648
|
-
* THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
|
|
37649
|
-
* decode worker's native-resolution frame retention.
|
|
37650
|
-
*
|
|
37651
|
-
* ## Why they live here and not in the addon that reads them
|
|
37652
|
-
*
|
|
37653
|
-
* The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
|
|
37654
|
-
* the READER is a private child process of `addon-pipeline`'s pipeline-runner.
|
|
37655
|
-
* Addons never import each other, so a key owned by either side would have to
|
|
37656
|
-
* be hand-copied by the other — and a hand-copied key is how a setting silently
|
|
37657
|
-
* stops arriving while both sides still look correct. Same reasoning, same
|
|
37658
|
-
* placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
|
|
37659
|
-
*
|
|
37660
|
-
* ## Why cluster-wide and not per-node
|
|
37661
|
-
*
|
|
37662
|
-
* The lease is a per-decode-worker RAM window. Its purpose — the late
|
|
37663
|
-
* cross-process native crop landing on a full-resolution frame rather than the
|
|
37664
|
-
* ≤640 detection fallback — is a property of the PIPELINE, not of a node's
|
|
37665
|
-
* hardware: a per-node TTL would mean the same camera produces different crop
|
|
37666
|
-
* quality depending on which node the balancer placed it on, and nobody could
|
|
37667
|
-
* tell that from the stored media. Node-level RAM pressure is already handled
|
|
37668
|
-
* by the per-session budget ceiling, which is itself one of these knobs.
|
|
37669
|
-
*
|
|
37670
|
-
* ## What each knob costs
|
|
37671
|
-
*
|
|
37672
|
-
* A retained frame is a full NATIVE-resolution copy in system RAM. With the
|
|
37673
|
-
* default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
|
|
37674
|
-
* 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
|
|
37675
|
-
* for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
|
|
37676
|
-
* resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
|
|
37677
|
-
* clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
|
|
37678
|
-
* admission" for what actually gets admitted.
|
|
37679
|
-
*/
|
|
37680
|
-
/**
|
|
37681
|
-
* Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
|
|
37682
|
-
* (cluster-wide) settings. Keys are unique across that addon's whole schema, so
|
|
37683
|
-
* the reader can walk every section instead of trusting the section id.
|
|
37684
|
-
*/
|
|
37685
|
-
var NATIVE_LEASE_SECTION_ID = "native-lease";
|
|
37686
|
-
var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
|
|
37687
|
-
var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
|
|
37688
|
-
var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
|
|
37689
|
-
var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
|
|
37690
|
-
/**
|
|
37691
|
-
* WHICH delivered frames the decode worker retains a native copy of.
|
|
37692
|
-
*
|
|
37693
|
-
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
37694
|
-
* behaviour, and the only correct one if something can ask for a crop of a
|
|
37695
|
-
* frame the runner never sent to inference.
|
|
37696
|
-
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
37697
|
-
* A native-crop request always names a `frameId` that rode an inference
|
|
37698
|
-
* result, so that is the only set a request can name. How much it drops is
|
|
37699
|
-
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
37700
|
-
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
37701
|
-
* was not throttling as hard as the sketch supposed. Read
|
|
37702
|
-
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
37703
|
-
* of you rather than quoting a number from here. The newest delivered frame is
|
|
37704
|
-
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
37705
|
-
* which covers the one-frame race between a mark and the supersede that
|
|
37706
|
-
* consumes it.
|
|
37707
|
-
*/
|
|
37708
|
-
var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
|
|
37709
|
-
/**
|
|
37710
|
-
* Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
|
|
37711
|
-
* the slider) because the value also travels to a forked child process, where a
|
|
37712
|
-
* junk number would silently become a 0-length or unbounded retention window.
|
|
37713
|
-
*/
|
|
37714
|
-
var NativeLeaseSettingsSchema = zod.z.object({
|
|
37715
|
-
/**
|
|
37716
|
-
* How long a retained native frame is served before it counts as a miss.
|
|
37717
|
-
*
|
|
37718
|
-
* Must cover the FULL late-crop horizon: detection inference + the
|
|
37719
|
-
* cross-process inference-result hop to hub post-analysis + tracking + the
|
|
37720
|
-
* tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
|
|
37721
|
-
* outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
|
|
37722
|
-
* RAM per busy camera grows linearly with no measured hit-rate gain.
|
|
37723
|
-
*/
|
|
37724
|
-
ttlMs: zod.z.number().int().min(250).max(1e4),
|
|
37725
|
-
/**
|
|
37726
|
-
* Hard per-decode-worker RAM ceiling for retained native frames, in MB.
|
|
37727
|
-
*
|
|
37728
|
-
* Intended as a SAFETY ceiling with the TTL as the effective cap — but check
|
|
37729
|
-
* which one is actually binding before reasoning from that. At the shipped
|
|
37730
|
-
* 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
|
|
37731
|
-
* at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
|
|
37732
|
-
* `leaseFrames` on the metrics line say which. When the ceiling binds, a
|
|
37733
|
-
* change that admits fewer frames buys retention WINDOW at constant RAM
|
|
37734
|
-
* rather than giving RAM back — lower this knob if RAM is what you wanted.
|
|
37735
|
-
* `0` DISABLES the lease entirely and falls the worker back to the tiny
|
|
37736
|
-
* leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
|
|
37737
|
-
* to replace).
|
|
37738
|
-
*/
|
|
37739
|
-
budgetMb: zod.z.number().int().min(0).max(4096),
|
|
37740
|
-
/**
|
|
37741
|
-
* Demand window: eager per-frame native retention runs only within this many
|
|
37742
|
-
* ms of the last native-crop request (or of the dial starting).
|
|
37743
|
-
*
|
|
37744
|
-
* `0` means ALWAYS ON — it disables the gate, it does not disable retention.
|
|
37745
|
-
* That is the legacy behaviour that saturated an N100 (24 native-4K downloads
|
|
37746
|
-
* per second on a camera with zero crop demand), so leave it non-zero unless
|
|
37747
|
-
* you are reproducing that.
|
|
37748
|
-
*/
|
|
37749
|
-
activityMs: zod.z.number().int().min(0).max(12e4),
|
|
37750
|
-
/**
|
|
37751
|
-
* Which delivered frames are retained at all — see
|
|
37752
|
-
* {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
|
|
37753
|
-
* changes WHAT is kept rather than for how long, so it is also the only one
|
|
37754
|
-
* that can turn a crop that used to hit into a miss. The worker counts every
|
|
37755
|
-
* crop request naming a frame it did NOT see marked
|
|
37756
|
-
* (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
|
|
37757
|
-
* there is the signal that some caller names frames outside the inference set
|
|
37758
|
-
* and that this must go back to `all`.
|
|
37759
|
-
*/
|
|
37760
|
-
admission: NativeLeaseAdmissionSchema
|
|
37761
|
-
});
|
|
37762
|
-
/**
|
|
37763
|
-
* The values in force when the operator has set nothing — byte-for-byte the
|
|
37764
|
-
* constants the decode worker shipped with as env-var defaults, so making these
|
|
37765
|
-
* settings changed no behaviour on the day it landed.
|
|
37766
|
-
*/
|
|
37767
|
-
var DEFAULT_NATIVE_LEASE_SETTINGS = {
|
|
37768
|
-
ttlMs: 1200,
|
|
37769
|
-
budgetMb: 1024,
|
|
37770
|
-
activityMs: 15e3,
|
|
37771
|
-
admission: "inferred"
|
|
37772
|
-
};
|
|
37773
|
-
/** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
|
|
37774
|
-
var NATIVE_LEASE_TTL_FIELD = {
|
|
37775
|
-
min: 250,
|
|
37776
|
-
max: 1e4,
|
|
37777
|
-
step: 50,
|
|
37778
|
-
default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
|
|
37779
|
-
};
|
|
37780
|
-
var NATIVE_LEASE_BUDGET_FIELD = {
|
|
37781
|
-
min: 0,
|
|
37782
|
-
max: 4096,
|
|
37783
|
-
step: 64,
|
|
37784
|
-
default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
|
|
37785
|
-
};
|
|
37786
|
-
var NATIVE_LEASE_ACTIVITY_FIELD = {
|
|
37787
|
-
min: 0,
|
|
37788
|
-
max: 12e4,
|
|
37789
|
-
step: 1e3,
|
|
37790
|
-
default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
|
|
37791
|
-
};
|
|
37792
|
-
/** Select options for the admission knob (orchestrator settings UI). */
|
|
37793
|
-
var NATIVE_LEASE_ADMISSION_FIELD = {
|
|
37794
|
-
options: [{
|
|
37795
|
-
value: "all",
|
|
37796
|
-
label: "Every delivered frame"
|
|
37797
|
-
}, {
|
|
37798
|
-
value: "inferred",
|
|
37799
|
-
label: "Only frames sent to inference"
|
|
37800
|
-
}],
|
|
37801
|
-
default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
|
|
37802
|
-
};
|
|
37803
|
-
/**
|
|
37804
|
-
* Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
|
|
37805
|
-
* value equal to the shipped default.
|
|
37806
|
-
*
|
|
37807
|
-
* That last rule is not tidiness, it is the difference between the documented
|
|
37808
|
-
* precedence being true and being a lie. `addon-settings.getGlobalSettings`
|
|
37809
|
-
* returns a HYDRATED payload, and `hydrateField` fills an unstored field with
|
|
37810
|
-
* the schema's own `default` (verified live on the hub: a cluster that has never
|
|
37811
|
-
* opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
|
|
37812
|
-
* that at face value would report all three knobs as "set" on every cluster on
|
|
37813
|
-
* the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
|
|
37814
|
-
* emergency override that the precedence promises. There is no raw-store read on
|
|
37815
|
-
* this cap to distinguish the two, so the default value itself is treated as
|
|
37816
|
-
* "the operator has expressed no preference" — which is also what leaving a
|
|
37817
|
-
* slider untouched means.
|
|
37818
|
-
*
|
|
37819
|
-
* The cost is one honest edge: an operator who deliberately selects the default
|
|
37820
|
-
* value in order to overrule an env var does not get it. Clear the env var
|
|
37821
|
-
* instead; the worker's spawn line names the source, so this is visible rather
|
|
37822
|
-
* than mysterious.
|
|
37823
|
-
*/
|
|
37824
|
-
function readKnob(knob, raw) {
|
|
37825
|
-
const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
|
|
37826
|
-
if (!parsed.success) return null;
|
|
37827
|
-
return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
|
|
37828
|
-
}
|
|
37829
|
-
/** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
|
|
37830
|
-
function readAdmissionKnob(raw) {
|
|
37831
|
-
const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
|
|
37832
|
-
if (!parsed.success) return null;
|
|
37833
|
-
return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
|
|
37834
|
-
}
|
|
37835
|
-
/**
|
|
37836
|
-
* Narrow a FLAT settings record to the knobs the operator set.
|
|
37837
|
-
*
|
|
37838
|
-
* Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
|
|
37839
|
-
* budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
|
|
37840
|
-
* clamped, not defaulted) so the caller can still fall through to the env
|
|
37841
|
-
* override — clamping here would turn a typo into a value nobody chose. See
|
|
37842
|
-
* {@link readKnob} for why the default counts as unset.
|
|
37843
|
-
*/
|
|
37844
|
-
function readNativeLeaseOverride(config) {
|
|
37845
|
-
const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
|
|
37846
|
-
const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
|
|
37847
|
-
const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
|
|
37848
|
-
const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
|
|
37849
|
-
return {
|
|
37850
|
-
...ttlMs === null ? {} : { ttlMs },
|
|
37851
|
-
...budgetMb === null ? {} : { budgetMb },
|
|
37852
|
-
...activityMs === null ? {} : { activityMs },
|
|
37853
|
-
...admission === null ? {} : { admission }
|
|
37854
|
-
};
|
|
37855
|
-
}
|
|
37856
|
-
function isHydratedField(entry) {
|
|
37857
|
-
return typeof entry === "object" && entry !== null && "key" in entry;
|
|
37858
|
-
}
|
|
37859
|
-
var LEASE_KEYS = [
|
|
37860
|
-
NATIVE_LEASE_TTL_KEY,
|
|
37861
|
-
NATIVE_LEASE_BUDGET_KEY,
|
|
37862
|
-
NATIVE_LEASE_ACTIVITY_KEY,
|
|
37863
|
-
NATIVE_LEASE_ADMISSION_KEY
|
|
37864
|
-
];
|
|
37865
|
-
/**
|
|
37866
|
-
* Extract the operator's lease overrides from an
|
|
37867
|
-
* `addon-settings.getGlobalSettings` payload.
|
|
37868
|
-
*
|
|
37869
|
-
* Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
|
|
37870
|
-
* alone: the keys are unique across the addon's schema, and a section rename
|
|
37871
|
-
* must not silently revert the whole cluster to the defaults. A `null` payload
|
|
37872
|
-
* (addon mid-boot) means "operator set nothing" — the env/default fallback then
|
|
37873
|
-
* applies, which is the correct read of "I could not ask".
|
|
37874
|
-
*/
|
|
37875
|
-
function pickNativeLeaseOverride(view) {
|
|
37876
|
-
if (view === null) return {};
|
|
37877
|
-
const flat = {};
|
|
37878
|
-
for (const section of view.sections) for (const entry of section.fields) {
|
|
37879
|
-
if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
|
|
37880
|
-
if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
|
|
37881
|
-
}
|
|
37882
|
-
return readNativeLeaseOverride(flat);
|
|
37883
|
-
}
|
|
37884
|
-
//#endregion
|
|
37885
38177
|
//#region src/helpers/bind-addon-actions.ts
|
|
37886
38178
|
/**
|
|
37887
38179
|
* Bind an addon's custom-action catalog to its tRPC surface, returning a
|
|
@@ -38140,7 +38432,7 @@ exports.APPLE_SA_TO_MACRO = APPLE_SA_TO_MACRO;
|
|
|
38140
38432
|
exports.AUDIO_ANALYSIS_CAP_NAME = AUDIO_ANALYSIS_CAP_NAME;
|
|
38141
38433
|
exports.AUDIO_BACKEND_CHOICES = AUDIO_BACKEND_CHOICES;
|
|
38142
38434
|
exports.AUDIO_MACRO_LABELS = AUDIO_MACRO_LABELS;
|
|
38143
|
-
exports.AUDIO_PRESETS =
|
|
38435
|
+
exports.AUDIO_PRESETS = require_canonical_hash.AUDIO_PRESETS;
|
|
38144
38436
|
exports.AccessoriesStatusSchema = AccessoriesStatusSchema;
|
|
38145
38437
|
exports.AccessoryKind = AccessoryKind;
|
|
38146
38438
|
exports.AddBrokerInputSchema = AddBrokerInputSchema;
|
|
@@ -38237,6 +38529,7 @@ exports.CAP_NODE_PIN_CONTEXT_KEY = require_sleep.CAP_NODE_PIN_CONTEXT_KEY;
|
|
|
38237
38529
|
exports.CAP_PROVIDER_KIND_MAP = CAP_PROVIDER_KIND_MAP;
|
|
38238
38530
|
exports.COCO_80_LABELS = COCO_80_LABELS;
|
|
38239
38531
|
exports.COCO_TO_MACRO = COCO_TO_MACRO;
|
|
38532
|
+
exports.CORE_BLOCK_ADDON_PREFIX = CORE_BLOCK_ADDON_PREFIX;
|
|
38240
38533
|
exports.CamProfileSchema = require_sleep.CamProfileSchema;
|
|
38241
38534
|
exports.CamStreamDescriptorSchema = CamStreamDescriptorSchema;
|
|
38242
38535
|
exports.CamStreamKindSchema = require_sleep.CamStreamKindSchema;
|
|
@@ -38309,6 +38602,8 @@ exports.CreateUserInputSchema = CreateUserInputSchema;
|
|
|
38309
38602
|
exports.CustomActionInputSchema = CustomActionInputSchema;
|
|
38310
38603
|
exports.CustomModelDescriptorSchema = CustomModelDescriptorSchema;
|
|
38311
38604
|
exports.DATAPLANE_SECRET_HEADER = require_sleep.DATAPLANE_SECRET_HEADER;
|
|
38605
|
+
exports.DECLARED_DEVICE_SWEEP_LIMIT = DECLARED_DEVICE_SWEEP_LIMIT;
|
|
38606
|
+
exports.DECLARED_INTEGRATION_FIXED_KEY = DECLARED_INTEGRATION_FIXED_KEY;
|
|
38312
38607
|
exports.DEFAULT_ADDON_PLACEMENT = DEFAULT_ADDON_PLACEMENT;
|
|
38313
38608
|
exports.DEFAULT_AUDIO_ANALYZER_CONFIG = DEFAULT_AUDIO_ANALYZER_CONFIG;
|
|
38314
38609
|
exports.DEFAULT_DECODER_HWACCEL_CONFIG = DEFAULT_DECODER_HWACCEL_CONFIG;
|
|
@@ -38337,6 +38632,7 @@ exports.DayNightModeSchema = DayNightModeSchema;
|
|
|
38337
38632
|
exports.DayNightOptionsSchema = DayNightOptionsSchema;
|
|
38338
38633
|
exports.DayNightSettingsPatchSchema = DayNightSettingsPatchSchema;
|
|
38339
38634
|
exports.DayNightStatusSchema = DayNightStatusSchema;
|
|
38635
|
+
exports.DeclaredDevices = DeclaredDevices;
|
|
38340
38636
|
exports.DecodedAudioChunkSchema = require_sleep.DecodedAudioChunkSchema;
|
|
38341
38637
|
exports.DecodedFrameSchema = require_sleep.DecodedFrameSchema;
|
|
38342
38638
|
exports.DecoderSessionConfigSchema = DecoderSessionConfigSchema;
|
|
@@ -38352,7 +38648,6 @@ exports.DeviceExportStatusSchema = DeviceExportStatusSchema;
|
|
|
38352
38648
|
exports.DeviceExportUnexposeInputSchema = UnexposeInputSchema;
|
|
38353
38649
|
exports.DeviceFeature = require_sleep.DeviceFeature;
|
|
38354
38650
|
exports.DeviceInfoSchema = DeviceInfoSchema;
|
|
38355
|
-
exports.DeviceLinkModeSchema = DeviceLinkModeSchema;
|
|
38356
38651
|
exports.DeviceNetworkStatsSchema = DeviceNetworkStatsSchema;
|
|
38357
38652
|
exports.DeviceRole = require_sleep.DeviceRole;
|
|
38358
38653
|
exports.DeviceRuntimeState = DeviceRuntimeState;
|
|
@@ -38410,14 +38705,19 @@ exports.ExportStateSchema = ExportStateSchema;
|
|
|
38410
38705
|
exports.ExportTimelapseSchema = ExportTimelapseSchema;
|
|
38411
38706
|
exports.ExposedDeviceSchema = ExposedDeviceSchema;
|
|
38412
38707
|
exports.ExposureModeSchema = ExposureModeSchema;
|
|
38708
|
+
exports.ExpressionBindingSourceSchema = ExpressionBindingSourceSchema;
|
|
38413
38709
|
exports.ExpressionEvalError = ExpressionEvalError;
|
|
38710
|
+
exports.ExpressionFieldBindingSchema = ExpressionFieldBindingSchema;
|
|
38711
|
+
exports.ExpressionGlobalBindingSchema = ExpressionGlobalBindingSchema;
|
|
38712
|
+
exports.ExpressionLiteralBindingSchema = ExpressionLiteralBindingSchema;
|
|
38414
38713
|
exports.ExpressionParseError = ExpressionParseError;
|
|
38714
|
+
exports.ExpressionSourceSchema = ExpressionSourceSchema;
|
|
38415
38715
|
exports.FanControlStatusSchema = FanControlStatusSchema;
|
|
38416
38716
|
exports.FanDirectionSchema = FanDirectionSchema;
|
|
38417
38717
|
exports.FeatureManifestSchema = FeatureManifestSchema;
|
|
38418
38718
|
exports.FeatureProbeStatusSchema = FeatureProbeStatusSchema;
|
|
38419
38719
|
exports.FloodStatusSchema = FloodStatusSchema;
|
|
38420
|
-
exports.Fmp4BoxSplitter =
|
|
38720
|
+
exports.Fmp4BoxSplitter = require_canonical_hash.Fmp4BoxSplitter;
|
|
38421
38721
|
exports.FrameHandleFormatSchema = require_sleep.FrameHandleFormatSchema;
|
|
38422
38722
|
exports.FrameHandleSchema = require_sleep.FrameHandleSchema;
|
|
38423
38723
|
exports.FrameInputSchema = FrameInputSchema;
|
|
@@ -38459,6 +38759,7 @@ exports.LabelTierSchema = LabelTierSchema;
|
|
|
38459
38759
|
exports.LawnMowerActivitySchema = LawnMowerActivitySchema;
|
|
38460
38760
|
exports.LawnMowerControlStatusSchema = LawnMowerControlStatusSchema;
|
|
38461
38761
|
exports.LinkedDeviceSchema = LinkedDeviceSchema;
|
|
38762
|
+
exports.LinkedDevicesModeSchema = LinkedDevicesModeSchema;
|
|
38462
38763
|
exports.LlmDefaultSchema = LlmDefaultSchema;
|
|
38463
38764
|
exports.LlmDefaultSelectorSchema = LlmDefaultSelectorSchema;
|
|
38464
38765
|
exports.LlmErrorCodeSchema = LlmErrorCodeSchema;
|
|
@@ -38929,7 +39230,6 @@ exports.airQualitySensorCapability = airQualitySensorCapability;
|
|
|
38929
39230
|
exports.alarmPanelCapability = alarmPanelCapability;
|
|
38930
39231
|
exports.alertsCapability = alertsCapability;
|
|
38931
39232
|
exports.ambientLightSensorCapability = ambientLightSensorCapability;
|
|
38932
|
-
exports.applyTransform = applyTransform;
|
|
38933
39233
|
exports.asBoolean = require_sleep.asBoolean;
|
|
38934
39234
|
exports.asJsonArray = require_sleep.asJsonArray;
|
|
38935
39235
|
exports.asJsonObject = require_sleep.asJsonObject;
|
|
@@ -38939,7 +39239,7 @@ exports.audioAnalysisCapability = audioAnalysisCapability;
|
|
|
38939
39239
|
exports.audioAnalyzerCapability = audioAnalyzerCapability;
|
|
38940
39240
|
exports.audioCodecCapability = audioCodecCapability;
|
|
38941
39241
|
exports.audioMetricsCapability = audioMetricsCapability;
|
|
38942
|
-
exports.audioPlanFromEncodeProfile =
|
|
39242
|
+
exports.audioPlanFromEncodeProfile = require_canonical_hash.audioPlanFromEncodeProfile;
|
|
38943
39243
|
exports.authProviderCapability = authProviderCapability;
|
|
38944
39244
|
exports.autoAssignProfiles = autoAssignProfiles;
|
|
38945
39245
|
exports.automationControlCapability = automationControlCapability;
|
|
@@ -38952,14 +39252,14 @@ exports.bindAddonActions = bindAddonActions;
|
|
|
38952
39252
|
exports.brightnessCapability = brightnessCapability;
|
|
38953
39253
|
exports.brokerCapability = brokerCapability;
|
|
38954
39254
|
exports.buildAddonRouteProvider = buildAddonRouteProvider;
|
|
38955
|
-
exports.buildAudioArgs =
|
|
39255
|
+
exports.buildAudioArgs = require_canonical_hash.buildAudioArgs;
|
|
38956
39256
|
exports.buildEventKindDescriptor = buildEventKindDescriptor;
|
|
38957
|
-
exports.buildFfmpegArgs =
|
|
38958
|
-
exports.buildInputArgs =
|
|
39257
|
+
exports.buildFfmpegArgs = require_canonical_hash.buildFfmpegArgs;
|
|
39258
|
+
exports.buildInputArgs = require_canonical_hash.buildInputArgs;
|
|
38959
39259
|
exports.buildModelVariantGroups = buildModelVariantGroups;
|
|
38960
39260
|
exports.buildNcTaxonomy = buildNcTaxonomy;
|
|
38961
39261
|
exports.buildStreamParamsConfigSchema = buildStreamParamsConfigSchema;
|
|
38962
|
-
exports.buildVideoArgs =
|
|
39262
|
+
exports.buildVideoArgs = require_canonical_hash.buildVideoArgs;
|
|
38963
39263
|
exports.buttonCapability = buttonCapability;
|
|
38964
39264
|
exports.cameraCredentialsCapability = cameraCredentialsCapability;
|
|
38965
39265
|
exports.cameraPipelineConfigCapability = cameraPipelineConfigCapability;
|
|
@@ -38982,6 +39282,8 @@ exports.consumablesCapability = consumablesCapability;
|
|
|
38982
39282
|
exports.contactCapability = contactCapability;
|
|
38983
39283
|
exports.controlCapability = controlCapability;
|
|
38984
39284
|
exports.convertUnit = convertUnit;
|
|
39285
|
+
exports.coreBlockAddonId = coreBlockAddonId;
|
|
39286
|
+
exports.coreBlockIdFromAddonId = coreBlockIdFromAddonId;
|
|
38985
39287
|
exports.coreBlocksCapability = coreBlocksCapability;
|
|
38986
39288
|
exports.cosineSimilarity = cosineSimilarity;
|
|
38987
39289
|
exports.coverCapability = coverCapability;
|
|
@@ -38999,6 +39301,7 @@ exports.customAction = customAction;
|
|
|
38999
39301
|
exports.customModelRegistryCapability = customModelRegistryCapability;
|
|
39000
39302
|
exports.dataStoreProviderCapability = dataStoreProviderCapability;
|
|
39001
39303
|
exports.dayNightCapability = dayNightCapability;
|
|
39304
|
+
exports.declarationOwnerNodeId = declarationOwnerNodeId;
|
|
39002
39305
|
exports.decodeVectorBase64 = decodeVectorBase64;
|
|
39003
39306
|
exports.decoderCapability = decoderCapability;
|
|
39004
39307
|
exports.defaultDeviceFor = defaultDeviceFor;
|
|
@@ -39033,7 +39336,7 @@ exports.enumerateItemArrayFields = enumerateItemArrayFields;
|
|
|
39033
39336
|
exports.enumerateSchemaFields = enumerateSchemaFields;
|
|
39034
39337
|
exports.errMsg = require_err_msg.errMsg;
|
|
39035
39338
|
exports.evaluateAst = evaluateAst;
|
|
39036
|
-
exports.
|
|
39339
|
+
exports.evaluateExpressionSource = evaluateExpressionSource;
|
|
39037
39340
|
exports.evaluateZoneRules = evaluateZoneRules;
|
|
39038
39341
|
exports.event = require_sleep.event;
|
|
39039
39342
|
exports.eventEmitterCapability = eventEmitterCapability;
|
|
@@ -39064,7 +39367,7 @@ exports.imageCapability = imageCapability;
|
|
|
39064
39367
|
exports.imageSettingsCapability = imageSettingsCapability;
|
|
39065
39368
|
exports.integrationsCapability = integrationsCapability;
|
|
39066
39369
|
exports.intercomCapability = intercomCapability;
|
|
39067
|
-
exports.invocationFromEncodeProfile =
|
|
39370
|
+
exports.invocationFromEncodeProfile = require_canonical_hash.invocationFromEncodeProfile;
|
|
39068
39371
|
exports.isAgentOnlyPlacement = isAgentOnlyPlacement;
|
|
39069
39372
|
exports.isArrayOutputSchema = isArrayOutputSchema;
|
|
39070
39373
|
exports.isBaseConditionKey = isBaseConditionKey;
|
|
@@ -39077,7 +39380,7 @@ exports.isNode = isNode;
|
|
|
39077
39380
|
exports.isObjectInput = isObjectInput;
|
|
39078
39381
|
exports.isSameAddonId = isSameAddonId;
|
|
39079
39382
|
exports.isScheduleActive = isScheduleActive;
|
|
39080
|
-
exports.isSoftwareDecode =
|
|
39383
|
+
exports.isSoftwareDecode = require_canonical_hash.isSoftwareDecode;
|
|
39081
39384
|
exports.isVoidInput = isVoidInput;
|
|
39082
39385
|
exports.jobKindSchema = jobKindSchema;
|
|
39083
39386
|
exports.kebabToCamel = kebabToCamel;
|
|
@@ -39092,7 +39395,7 @@ exports.llmRuntimeCapability = llmRuntimeCapability;
|
|
|
39092
39395
|
exports.localNetworkCapability = localNetworkCapability;
|
|
39093
39396
|
exports.locationSimilarity = locationSimilarity;
|
|
39094
39397
|
exports.lockControlCapability = lockControlCapability;
|
|
39095
|
-
exports.logBannerArgs =
|
|
39398
|
+
exports.logBannerArgs = require_canonical_hash.logBannerArgs;
|
|
39096
39399
|
exports.logDestinationCapability = logDestinationCapability;
|
|
39097
39400
|
exports.logLevelAtMost = logLevelAtMost;
|
|
39098
39401
|
exports.loginMethodCapability = loginMethodCapability;
|
|
@@ -39143,7 +39446,7 @@ exports.pickAccessoryControl = pickAccessoryControl;
|
|
|
39143
39446
|
exports.pickDetailCropConvention = pickDetailCropConvention;
|
|
39144
39447
|
exports.pickNativeLeaseOverride = pickNativeLeaseOverride;
|
|
39145
39448
|
exports.pickPreferredRtspEntry = pickPreferredRtspEntry;
|
|
39146
|
-
exports.pickVideoEncoder =
|
|
39449
|
+
exports.pickVideoEncoder = require_canonical_hash.pickVideoEncoder;
|
|
39147
39450
|
exports.pickerForCondition = pickerForCondition;
|
|
39148
39451
|
exports.pipelineAnalyticsCapability = pipelineAnalyticsCapability;
|
|
39149
39452
|
exports.pipelineExecutorCapability = pipelineExecutorCapability;
|