@camstack/addon-post-analysis 1.2.52 → 1.2.53
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/{dist-k21GqaWS.js → dist-DGdY1GSf.js} +2003 -1810
- package/dist/{dist-CDLLzMJw.mjs → dist-Da97q0x6.mjs} +1999 -1812
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-B5brepKo.js → node-CpXZkQ6V.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C-EUVj-u.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-tCAuhv5m.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DcqnDg2z.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DJUXj7Ya.mjs → hostInit-CyLkuQ7j.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1855 -829
- package/dist/pipeline-analytics/index.mjs +1846 -820
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-PkY2z2Wo.mjs +0 -26
|
@@ -9652,12 +9652,34 @@ method(object({
|
|
|
9652
9652
|
kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
|
|
9653
9653
|
/** GIF geometry. The video keeps the source's own. */
|
|
9654
9654
|
gifMaxWidth: number().int().min(120).max(1280).default(640),
|
|
9655
|
-
gifFps: number().int().min(1).max(15).default(8),
|
|
9656
9655
|
/**
|
|
9657
|
-
*
|
|
9658
|
-
*
|
|
9656
|
+
* The gif's own PLAYBACK rate in frames per second — what the finished
|
|
9657
|
+
* gif runs at, not how many source frames feed it. The decimation that
|
|
9658
|
+
* feeds it samples `gifFps / gifSpeed` source frames per second, so at
|
|
9659
|
+
* the defaults a 12 fps gif is built out of 3 source frames a second.
|
|
9659
9660
|
*/
|
|
9660
|
-
|
|
9661
|
+
gifFps: number().int().min(1).max(15).default(12),
|
|
9662
|
+
/**
|
|
9663
|
+
* How fast the GIF plays against real time, independent of `speed`.
|
|
9664
|
+
*
|
|
9665
|
+
* 4× by default, by operator request: a notification gif is glanced at
|
|
9666
|
+
* on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
|
|
9667
|
+
* a separate knob from `speed` even though both now default to 4 —
|
|
9668
|
+
* a caller wanting a real-time video and a fast gif must not have to
|
|
9669
|
+
* choose.
|
|
9670
|
+
*/
|
|
9671
|
+
gifSpeed: number().min(1).max(8).default(4),
|
|
9672
|
+
/**
|
|
9673
|
+
* Playback rate of the VIDEO. Also 4× by default, by operator decision.
|
|
9674
|
+
*
|
|
9675
|
+
* `1` is real time and is the ONLY value that allows the copy branch —
|
|
9676
|
+
* anything else forces `libx264` over the window. That was priced
|
|
9677
|
+
* before it was chosen: a per-event burst measured at 0.23 s and 254 KB
|
|
9678
|
+
* on a real 615 720p cut, against 922 KB for the copy it replaces. A
|
|
9679
|
+
* re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
|
|
9680
|
+
* once the decode is forced the width stops being free.
|
|
9681
|
+
*/
|
|
9682
|
+
speed: number().min(1).max(8).default(4)
|
|
9661
9683
|
}), EventMediaProductionSchema, {
|
|
9662
9684
|
kind: "mutation",
|
|
9663
9685
|
auth: "admin"
|
|
@@ -9959,25 +9981,6 @@ var cameraStreamsCapability = {
|
|
|
9959
9981
|
function kebabToCamel(s) {
|
|
9960
9982
|
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
9961
9983
|
}
|
|
9962
|
-
/**
|
|
9963
|
-
* core-blocks — user-authored TypeScript, stored in the kernel and executed in
|
|
9964
|
-
* its own process.
|
|
9965
|
-
*
|
|
9966
|
-
* Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
|
|
9967
|
-
*
|
|
9968
|
-
* The first use is **owning devices without being a device provider**: a block
|
|
9969
|
-
* declares devices under a system or custom integration and drives their state,
|
|
9970
|
-
* with the same `ctx` an addon gets. Automations come later; nothing here
|
|
9971
|
-
* models a trigger.
|
|
9972
|
-
*
|
|
9973
|
-
* **Stated plainly, because it does not change by being true:** a block has an
|
|
9974
|
-
* addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
|
|
9975
|
-
* with no review step. What makes that survivable is not a sandbox, it is
|
|
9976
|
-
* PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
|
|
9977
|
-
* so a block that throws or never returns is marked `failed` and visible
|
|
9978
|
-
* instead of taking the hub with it (D6). Every method here is admin-only, and
|
|
9979
|
-
* must stay so.
|
|
9980
|
-
*/
|
|
9981
9984
|
/** Where a block runs. The operator chooses — a block driving a device on an
|
|
9982
9985
|
* agent is the reason placement is not fixed to the hub. */
|
|
9983
9986
|
var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
|
|
@@ -10049,6 +10052,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
10049
10052
|
}), object({ block: CoreBlockSchema }), {
|
|
10050
10053
|
kind: "mutation",
|
|
10051
10054
|
auth: "admin"
|
|
10055
|
+
}), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
|
|
10056
|
+
kind: "mutation",
|
|
10057
|
+
auth: "admin"
|
|
10052
10058
|
}), method(object({ code: string() }), CoreBlockCompileResultSchema, {
|
|
10053
10059
|
kind: "mutation",
|
|
10054
10060
|
auth: "admin"
|
|
@@ -10849,713 +10855,118 @@ var ExposeInputSchema = object({
|
|
|
10849
10855
|
});
|
|
10850
10856
|
var UnexposeInputSchema = object({ deviceId: string() });
|
|
10851
10857
|
method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
|
|
10858
|
+
var ProviderStatusSchema = object({
|
|
10859
|
+
connected: boolean(),
|
|
10860
|
+
deviceCount: number(),
|
|
10861
|
+
error: string().optional()
|
|
10862
|
+
});
|
|
10863
|
+
object({
|
|
10864
|
+
externalId: string(),
|
|
10865
|
+
name: string(),
|
|
10866
|
+
type: string(),
|
|
10867
|
+
metadata: record(string(), unknown()).optional()
|
|
10868
|
+
});
|
|
10852
10869
|
/**
|
|
10853
|
-
*
|
|
10854
|
-
*
|
|
10855
|
-
*
|
|
10856
|
-
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
10857
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
10858
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
10859
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
10870
|
+
* Candidate handed back from discovery and accepted by
|
|
10871
|
+
* `adoptDiscoveredDevice`. Shape mirrors the in-process
|
|
10872
|
+
* `DiscoveredDevice` interface used by `DeviceDiscovery`.
|
|
10860
10873
|
*/
|
|
10861
|
-
|
|
10862
|
-
|
|
10863
|
-
|
|
10864
|
-
|
|
10865
|
-
|
|
10866
|
-
/**
|
|
10867
|
-
*
|
|
10868
|
-
|
|
10869
|
-
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
10874
|
+
var DiscoveryCandidateSchema = object({
|
|
10875
|
+
stableId: string(),
|
|
10876
|
+
type: _enum(DeviceType),
|
|
10877
|
+
suggestedName: string(),
|
|
10878
|
+
prefilledConfig: record(string(), unknown()),
|
|
10879
|
+
/**
|
|
10880
|
+
* Optional upstream-system identity (HA entity_id, vendor MAC, …).
|
|
10881
|
+
* Discovery pre-populates this for systems that know the upstream
|
|
10882
|
+
* identity ahead of adoption. Rendering metadata (unit, precision)
|
|
10883
|
+
* flows live through the cap STATUS SLICE after adoption.
|
|
10884
|
+
*/
|
|
10885
|
+
sourceInfo: SourceInfoSchema.optional()
|
|
10886
|
+
});
|
|
10874
10887
|
/**
|
|
10875
|
-
*
|
|
10876
|
-
*
|
|
10877
|
-
*
|
|
10888
|
+
* Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
|
|
10889
|
+
* Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
|
|
10890
|
+
* tRPC layer can pass it through without reshaping.
|
|
10878
10891
|
*/
|
|
10879
|
-
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
|
|
10884
|
-
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
/**
|
|
10890
|
-
*
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
|
|
10894
|
-
|
|
10895
|
-
}
|
|
10896
|
-
};
|
|
10892
|
+
var DeviceSummarySchema = object({
|
|
10893
|
+
id: number(),
|
|
10894
|
+
stableId: string(),
|
|
10895
|
+
addonId: string(),
|
|
10896
|
+
type: string(),
|
|
10897
|
+
name: string(),
|
|
10898
|
+
parentDeviceId: number().nullable(),
|
|
10899
|
+
online: boolean(),
|
|
10900
|
+
features: array(string()),
|
|
10901
|
+
config: record(string(), unknown()),
|
|
10902
|
+
/** Optional upstream-system identity (dispatch key + system tag).
|
|
10903
|
+
* See `SourceInfo`. Present when the device has a non-synthetic
|
|
10904
|
+
* source identifier (HA entities, vendor MAC, …); omitted when the
|
|
10905
|
+
* synthetic backfill is in effect. */
|
|
10906
|
+
sourceInfo: SourceInfoSchema.optional()
|
|
10907
|
+
});
|
|
10897
10908
|
/**
|
|
10898
|
-
*
|
|
10899
|
-
*
|
|
10900
|
-
*
|
|
10901
|
-
*
|
|
10909
|
+
* Result of a live field test (e.g. probing an RTSP URL during device
|
|
10910
|
+
* creation). Matches the UI-side `FieldProbeResult` in
|
|
10911
|
+
* `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
|
|
10912
|
+
* returned `labels` as chips next to the input.
|
|
10913
|
+
*/
|
|
10914
|
+
var FieldProbeResultSchema = object({
|
|
10915
|
+
status: _enum(["ok", "error"]),
|
|
10916
|
+
labels: array(string()).optional(),
|
|
10917
|
+
error: string().optional()
|
|
10918
|
+
});
|
|
10919
|
+
/**
|
|
10920
|
+
* The output of `getChildCreationSchema` is a UI schema tree. We store
|
|
10921
|
+
* it as `unknown` at the capability layer — the router just passes it
|
|
10922
|
+
* through and the admin UI renders it via `FormBuilder`. The actual
|
|
10923
|
+
* type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
|
|
10924
|
+
* but we deliberately avoid a Zod mirror because the union is large and
|
|
10925
|
+
* not meant for runtime validation at this seam.
|
|
10926
|
+
*/
|
|
10927
|
+
var CreationSchemaOutputSchema = unknown();
|
|
10928
|
+
method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
|
|
10929
|
+
id: string(),
|
|
10930
|
+
name: string(),
|
|
10931
|
+
type: string()
|
|
10932
|
+
}))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
|
|
10933
|
+
kind: "mutation",
|
|
10934
|
+
auth: "admin"
|
|
10935
|
+
}), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
|
|
10936
|
+
kind: "mutation",
|
|
10937
|
+
auth: "admin"
|
|
10938
|
+
}), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
|
|
10939
|
+
type: _enum(DeviceType),
|
|
10940
|
+
config: record(string(), unknown())
|
|
10941
|
+
}), DeviceSummarySchema, {
|
|
10942
|
+
kind: "mutation",
|
|
10943
|
+
auth: "admin"
|
|
10944
|
+
}), method(object({
|
|
10945
|
+
type: _enum(DeviceType),
|
|
10946
|
+
key: string(),
|
|
10947
|
+
value: unknown(),
|
|
10948
|
+
formValues: record(string(), unknown()).optional()
|
|
10949
|
+
}), FieldProbeResultSchema, {
|
|
10950
|
+
kind: "mutation",
|
|
10951
|
+
auth: "admin"
|
|
10952
|
+
});
|
|
10953
|
+
/**
|
|
10954
|
+
* Device Manager capability — hub-side singleton that unifies device persistence,
|
|
10955
|
+
* live registry access, and all management operations into a single tRPC surface.
|
|
10902
10956
|
*
|
|
10903
|
-
*
|
|
10904
|
-
*
|
|
10905
|
-
*
|
|
10906
|
-
*
|
|
10907
|
-
* callable — they are simply "unknown function" at parse time.
|
|
10957
|
+
* Replaces:
|
|
10958
|
+
* - `device-persistence` capability (persistence methods absorbed here)
|
|
10959
|
+
* - `device-management.router.ts` (deleted in Phase 2)
|
|
10960
|
+
* - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
|
|
10908
10961
|
*
|
|
10909
|
-
*
|
|
10910
|
-
*
|
|
10911
|
-
*
|
|
10912
|
-
*
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
10917
|
-
}
|
|
10918
|
-
function asString$1(value, name, index) {
|
|
10919
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
10920
|
-
return value;
|
|
10921
|
-
}
|
|
10922
|
-
function finiteResult(value, name) {
|
|
10923
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
10924
|
-
return value;
|
|
10925
|
-
}
|
|
10926
|
-
function allFiniteNumbers(args, name) {
|
|
10927
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
10928
|
-
}
|
|
10929
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
10930
|
-
var table = {
|
|
10931
|
-
min: {
|
|
10932
|
-
minArgs: 1,
|
|
10933
|
-
maxArgs: INF,
|
|
10934
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
10935
|
-
},
|
|
10936
|
-
max: {
|
|
10937
|
-
minArgs: 1,
|
|
10938
|
-
maxArgs: INF,
|
|
10939
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
10940
|
-
},
|
|
10941
|
-
abs: {
|
|
10942
|
-
minArgs: 1,
|
|
10943
|
-
maxArgs: 1,
|
|
10944
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
10945
|
-
},
|
|
10946
|
-
floor: {
|
|
10947
|
-
minArgs: 1,
|
|
10948
|
-
maxArgs: 1,
|
|
10949
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
10950
|
-
},
|
|
10951
|
-
ceil: {
|
|
10952
|
-
minArgs: 1,
|
|
10953
|
-
maxArgs: 1,
|
|
10954
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
10955
|
-
},
|
|
10956
|
-
sqrt: {
|
|
10957
|
-
minArgs: 1,
|
|
10958
|
-
maxArgs: 1,
|
|
10959
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
10960
|
-
},
|
|
10961
|
-
round: {
|
|
10962
|
-
minArgs: 1,
|
|
10963
|
-
maxArgs: 2,
|
|
10964
|
-
apply: (args) => {
|
|
10965
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
10966
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
10967
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
10968
|
-
const factor = 10 ** digits;
|
|
10969
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
10970
|
-
}
|
|
10971
|
-
},
|
|
10972
|
-
pow: {
|
|
10973
|
-
minArgs: 2,
|
|
10974
|
-
maxArgs: 2,
|
|
10975
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
10976
|
-
},
|
|
10977
|
-
clamp: {
|
|
10978
|
-
minArgs: 3,
|
|
10979
|
-
maxArgs: 3,
|
|
10980
|
-
apply: (args) => {
|
|
10981
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
10982
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
10983
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
10984
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
10985
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
10986
|
-
}
|
|
10987
|
-
},
|
|
10988
|
-
avg: {
|
|
10989
|
-
minArgs: 1,
|
|
10990
|
-
maxArgs: INF,
|
|
10991
|
-
apply: (args) => {
|
|
10992
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
10993
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
10994
|
-
}
|
|
10995
|
-
},
|
|
10996
|
-
sum: {
|
|
10997
|
-
minArgs: 1,
|
|
10998
|
-
maxArgs: INF,
|
|
10999
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
11000
|
-
},
|
|
11001
|
-
coalesce: {
|
|
11002
|
-
minArgs: 1,
|
|
11003
|
-
maxArgs: INF,
|
|
11004
|
-
apply: (args) => {
|
|
11005
|
-
for (const a of args) if (a !== null) return a;
|
|
11006
|
-
return null;
|
|
11007
|
-
}
|
|
11008
|
-
},
|
|
11009
|
-
age: {
|
|
11010
|
-
minArgs: 2,
|
|
11011
|
-
maxArgs: 2,
|
|
11012
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
11013
|
-
},
|
|
11014
|
-
convert: {
|
|
11015
|
-
minArgs: 3,
|
|
11016
|
-
maxArgs: 3,
|
|
11017
|
-
apply: (args, hooks) => {
|
|
11018
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
11019
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
11020
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
11021
|
-
if (hooks.convert) {
|
|
11022
|
-
const out = hooks.convert(x, from, to);
|
|
11023
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
11024
|
-
return finiteResult(out, "convert");
|
|
11025
|
-
}
|
|
11026
|
-
if (from === to) return x;
|
|
11027
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
11028
|
-
}
|
|
11029
|
-
}
|
|
11030
|
-
};
|
|
11031
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
11032
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
11033
|
-
* callees at parse time (immediate author feedback). */
|
|
11034
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
11035
|
-
/**
|
|
11036
|
-
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
11037
|
-
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
11038
|
-
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
11039
|
-
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
11040
|
-
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
11041
|
-
* is a parse error with a source position, so member access / assignment /
|
|
11042
|
-
* template literals are lexically impossible.
|
|
11043
|
-
*/
|
|
11044
|
-
var KEYWORDS = new Set([
|
|
11045
|
-
"true",
|
|
11046
|
-
"false",
|
|
11047
|
-
"null"
|
|
11048
|
-
]);
|
|
11049
|
-
function isDigit(ch) {
|
|
11050
|
-
return ch >= "0" && ch <= "9";
|
|
11051
|
-
}
|
|
11052
|
-
function isIdentStart(ch) {
|
|
11053
|
-
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
11054
|
-
}
|
|
11055
|
-
function isIdentPart(ch) {
|
|
11056
|
-
return isIdentStart(ch) || isDigit(ch);
|
|
11057
|
-
}
|
|
11058
|
-
function isWhitespace(ch) {
|
|
11059
|
-
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
11060
|
-
}
|
|
11061
|
-
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
11062
|
-
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
11063
|
-
* string. */
|
|
11064
|
-
function tokenize(source) {
|
|
11065
|
-
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
11066
|
-
const tokens = [];
|
|
11067
|
-
let i = 0;
|
|
11068
|
-
const n = source.length;
|
|
11069
|
-
while (i < n) {
|
|
11070
|
-
const ch = source[i];
|
|
11071
|
-
if (isWhitespace(ch)) {
|
|
11072
|
-
i += 1;
|
|
11073
|
-
continue;
|
|
11074
|
-
}
|
|
11075
|
-
if (isDigit(ch)) {
|
|
11076
|
-
const start = i;
|
|
11077
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
11078
|
-
if (i < n && source[i] === ".") {
|
|
11079
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
11080
|
-
i += 1;
|
|
11081
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
11082
|
-
}
|
|
11083
|
-
const text = source.slice(start, i);
|
|
11084
|
-
const value = Number(text);
|
|
11085
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
11086
|
-
tokens.push({
|
|
11087
|
-
type: "number",
|
|
11088
|
-
value,
|
|
11089
|
-
pos: start
|
|
11090
|
-
});
|
|
11091
|
-
continue;
|
|
11092
|
-
}
|
|
11093
|
-
if (ch === "'" || ch === "\"") {
|
|
11094
|
-
const quote = ch;
|
|
11095
|
-
const start = i;
|
|
11096
|
-
i += 1;
|
|
11097
|
-
let out = "";
|
|
11098
|
-
let closed = false;
|
|
11099
|
-
while (i < n) {
|
|
11100
|
-
const c = source[i];
|
|
11101
|
-
if (c === "\\") {
|
|
11102
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
11103
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
11104
|
-
out += next;
|
|
11105
|
-
i += 2;
|
|
11106
|
-
continue;
|
|
11107
|
-
}
|
|
11108
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
11109
|
-
}
|
|
11110
|
-
if (c === quote) {
|
|
11111
|
-
closed = true;
|
|
11112
|
-
i += 1;
|
|
11113
|
-
break;
|
|
11114
|
-
}
|
|
11115
|
-
out += c;
|
|
11116
|
-
i += 1;
|
|
11117
|
-
}
|
|
11118
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
11119
|
-
tokens.push({
|
|
11120
|
-
type: "string",
|
|
11121
|
-
value: out,
|
|
11122
|
-
pos: start
|
|
11123
|
-
});
|
|
11124
|
-
continue;
|
|
11125
|
-
}
|
|
11126
|
-
if (isIdentStart(ch)) {
|
|
11127
|
-
const start = i;
|
|
11128
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
11129
|
-
const text = source.slice(start, i);
|
|
11130
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
11131
|
-
type: "keyword",
|
|
11132
|
-
keyword: keywordOf(text),
|
|
11133
|
-
pos: start
|
|
11134
|
-
});
|
|
11135
|
-
else tokens.push({
|
|
11136
|
-
type: "identifier",
|
|
11137
|
-
name: text,
|
|
11138
|
-
pos: start
|
|
11139
|
-
});
|
|
11140
|
-
continue;
|
|
11141
|
-
}
|
|
11142
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
11143
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
11144
|
-
tokens.push({
|
|
11145
|
-
type: "punct",
|
|
11146
|
-
punct: two,
|
|
11147
|
-
pos: i
|
|
11148
|
-
});
|
|
11149
|
-
i += 2;
|
|
11150
|
-
continue;
|
|
11151
|
-
}
|
|
11152
|
-
if (isSinglePunct(ch)) {
|
|
11153
|
-
tokens.push({
|
|
11154
|
-
type: "punct",
|
|
11155
|
-
punct: ch,
|
|
11156
|
-
pos: i
|
|
11157
|
-
});
|
|
11158
|
-
i += 1;
|
|
11159
|
-
continue;
|
|
11160
|
-
}
|
|
11161
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
11162
|
-
}
|
|
11163
|
-
tokens.push({
|
|
11164
|
-
type: "eof",
|
|
11165
|
-
pos: n
|
|
11166
|
-
});
|
|
11167
|
-
return tokens;
|
|
11168
|
-
}
|
|
11169
|
-
function keywordOf(text) {
|
|
11170
|
-
if (text === "true") return "true";
|
|
11171
|
-
if (text === "false") return "false";
|
|
11172
|
-
return "null";
|
|
11173
|
-
}
|
|
11174
|
-
function isSinglePunct(ch) {
|
|
11175
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
11176
|
-
}
|
|
11177
|
-
/**
|
|
11178
|
-
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
11179
|
-
*
|
|
11180
|
-
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
11181
|
-
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
11182
|
-
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
11183
|
-
* string validated against the builtin table at parse time, so an unknown
|
|
11184
|
-
* function is rejected immediately (author feedback) and a persisted expression
|
|
11185
|
-
* that references a since-removed builtin degrades at read.
|
|
11186
|
-
*
|
|
11187
|
-
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
11188
|
-
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
11189
|
-
*/
|
|
11190
|
-
/** Binary/logical operator precedence (higher binds tighter). */
|
|
11191
|
-
var BINARY_PRECEDENCE = {
|
|
11192
|
-
"||": 1,
|
|
11193
|
-
"&&": 2,
|
|
11194
|
-
"==": 3,
|
|
11195
|
-
"!=": 3,
|
|
11196
|
-
"<": 4,
|
|
11197
|
-
"<=": 4,
|
|
11198
|
-
">": 4,
|
|
11199
|
-
">=": 4,
|
|
11200
|
-
"+": 5,
|
|
11201
|
-
"-": 5,
|
|
11202
|
-
"*": 6,
|
|
11203
|
-
"/": 6,
|
|
11204
|
-
"%": 6
|
|
11205
|
-
};
|
|
11206
|
-
function isLogicalOp(op) {
|
|
11207
|
-
return op === "&&" || op === "||";
|
|
11208
|
-
}
|
|
11209
|
-
function isBinaryOp(op) {
|
|
11210
|
-
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
11211
|
-
}
|
|
11212
|
-
var Parser = class {
|
|
11213
|
-
tokens;
|
|
11214
|
-
pos = 0;
|
|
11215
|
-
nodeCount = 0;
|
|
11216
|
-
identifiers = /* @__PURE__ */ new Set();
|
|
11217
|
-
callees = /* @__PURE__ */ new Set();
|
|
11218
|
-
constructor(tokens) {
|
|
11219
|
-
this.tokens = tokens;
|
|
11220
|
-
}
|
|
11221
|
-
parse() {
|
|
11222
|
-
const ast = this.parseTernary();
|
|
11223
|
-
const tok = this.peek();
|
|
11224
|
-
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
11225
|
-
return {
|
|
11226
|
-
ast,
|
|
11227
|
-
identifiers: this.identifiers,
|
|
11228
|
-
callees: this.callees,
|
|
11229
|
-
nodeCount: this.nodeCount
|
|
11230
|
-
};
|
|
11231
|
-
}
|
|
11232
|
-
peek() {
|
|
11233
|
-
return this.tokens[this.pos];
|
|
11234
|
-
}
|
|
11235
|
-
next() {
|
|
11236
|
-
return this.tokens[this.pos++];
|
|
11237
|
-
}
|
|
11238
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
11239
|
-
expectPunct(punct) {
|
|
11240
|
-
const tok = this.peek();
|
|
11241
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
11242
|
-
this.pos += 1;
|
|
11243
|
-
}
|
|
11244
|
-
matchPunct(punct) {
|
|
11245
|
-
const tok = this.peek();
|
|
11246
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
11247
|
-
this.pos += 1;
|
|
11248
|
-
return true;
|
|
11249
|
-
}
|
|
11250
|
-
return false;
|
|
11251
|
-
}
|
|
11252
|
-
countNode() {
|
|
11253
|
-
this.nodeCount += 1;
|
|
11254
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
11255
|
-
}
|
|
11256
|
-
parseTernary() {
|
|
11257
|
-
const test = this.parseBinary(1);
|
|
11258
|
-
if (this.matchPunct("?")) {
|
|
11259
|
-
const consequent = this.parseTernary();
|
|
11260
|
-
this.expectPunct(":");
|
|
11261
|
-
const alternate = this.parseTernary();
|
|
11262
|
-
this.countNode();
|
|
11263
|
-
return {
|
|
11264
|
-
kind: "conditional",
|
|
11265
|
-
test,
|
|
11266
|
-
consequent,
|
|
11267
|
-
alternate
|
|
11268
|
-
};
|
|
11269
|
-
}
|
|
11270
|
-
return test;
|
|
11271
|
-
}
|
|
11272
|
-
parseBinary(minPrec) {
|
|
11273
|
-
let left = this.parseUnary();
|
|
11274
|
-
for (;;) {
|
|
11275
|
-
const tok = this.peek();
|
|
11276
|
-
if (tok.type !== "punct") break;
|
|
11277
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
11278
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
11279
|
-
const op = tok.punct;
|
|
11280
|
-
this.pos += 1;
|
|
11281
|
-
const right = this.parseBinary(prec + 1);
|
|
11282
|
-
this.countNode();
|
|
11283
|
-
if (isLogicalOp(op)) left = {
|
|
11284
|
-
kind: "logical",
|
|
11285
|
-
op,
|
|
11286
|
-
left,
|
|
11287
|
-
right
|
|
11288
|
-
};
|
|
11289
|
-
else if (isBinaryOp(op)) left = {
|
|
11290
|
-
kind: "binary",
|
|
11291
|
-
op,
|
|
11292
|
-
left,
|
|
11293
|
-
right
|
|
11294
|
-
};
|
|
11295
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
11296
|
-
}
|
|
11297
|
-
return left;
|
|
11298
|
-
}
|
|
11299
|
-
parseUnary() {
|
|
11300
|
-
const tok = this.peek();
|
|
11301
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
11302
|
-
const op = tok.punct;
|
|
11303
|
-
this.pos += 1;
|
|
11304
|
-
const operand = this.parseUnary();
|
|
11305
|
-
this.countNode();
|
|
11306
|
-
return {
|
|
11307
|
-
kind: "unary",
|
|
11308
|
-
op,
|
|
11309
|
-
operand
|
|
11310
|
-
};
|
|
11311
|
-
}
|
|
11312
|
-
return this.parsePrimary();
|
|
11313
|
-
}
|
|
11314
|
-
parsePrimary() {
|
|
11315
|
-
const tok = this.next();
|
|
11316
|
-
switch (tok.type) {
|
|
11317
|
-
case "number":
|
|
11318
|
-
this.countNode();
|
|
11319
|
-
return {
|
|
11320
|
-
kind: "literal",
|
|
11321
|
-
value: tok.value
|
|
11322
|
-
};
|
|
11323
|
-
case "string":
|
|
11324
|
-
this.countNode();
|
|
11325
|
-
return {
|
|
11326
|
-
kind: "literal",
|
|
11327
|
-
value: tok.value
|
|
11328
|
-
};
|
|
11329
|
-
case "keyword":
|
|
11330
|
-
this.countNode();
|
|
11331
|
-
return {
|
|
11332
|
-
kind: "literal",
|
|
11333
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
11334
|
-
};
|
|
11335
|
-
case "identifier": {
|
|
11336
|
-
const nextTok = this.peek();
|
|
11337
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
11338
|
-
this.identifiers.add(tok.name);
|
|
11339
|
-
this.countNode();
|
|
11340
|
-
return {
|
|
11341
|
-
kind: "identifier",
|
|
11342
|
-
name: tok.name
|
|
11343
|
-
};
|
|
11344
|
-
}
|
|
11345
|
-
case "punct":
|
|
11346
|
-
if (tok.punct === "(") {
|
|
11347
|
-
const inner = this.parseTernary();
|
|
11348
|
-
this.expectPunct(")");
|
|
11349
|
-
return inner;
|
|
11350
|
-
}
|
|
11351
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
11352
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
11353
|
-
}
|
|
11354
|
-
}
|
|
11355
|
-
parseCall(callee, pos) {
|
|
11356
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
11357
|
-
this.expectPunct("(");
|
|
11358
|
-
const args = [];
|
|
11359
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
11360
|
-
args.push(this.parseTernary());
|
|
11361
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
11362
|
-
if (this.matchPunct(",")) continue;
|
|
11363
|
-
this.expectPunct(")");
|
|
11364
|
-
break;
|
|
11365
|
-
}
|
|
11366
|
-
this.callees.add(callee);
|
|
11367
|
-
this.countNode();
|
|
11368
|
-
return {
|
|
11369
|
-
kind: "call",
|
|
11370
|
-
callee,
|
|
11371
|
-
args
|
|
11372
|
-
};
|
|
11373
|
-
}
|
|
11374
|
-
};
|
|
11375
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
11376
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
11377
|
-
function parseExpression(source) {
|
|
11378
|
-
return new Parser(tokenize(source)).parse();
|
|
11379
|
-
}
|
|
11380
|
-
/**
|
|
11381
|
-
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
11382
|
-
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
11383
|
-
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
11384
|
-
* one per read on a hot resolve path.
|
|
11385
|
-
*
|
|
11386
|
-
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
11387
|
-
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
11388
|
-
* callers is safe and maximises hit rate.
|
|
11389
|
-
*/
|
|
11390
|
-
var cache = /* @__PURE__ */ new Map();
|
|
11391
|
-
function getCached(source) {
|
|
11392
|
-
const hit = cache.get(source);
|
|
11393
|
-
if (hit !== void 0) {
|
|
11394
|
-
cache.delete(source);
|
|
11395
|
-
cache.set(source, hit);
|
|
11396
|
-
return hit;
|
|
11397
|
-
}
|
|
11398
|
-
let result;
|
|
11399
|
-
try {
|
|
11400
|
-
result = {
|
|
11401
|
-
ok: true,
|
|
11402
|
-
parsed: parseExpression(source)
|
|
11403
|
-
};
|
|
11404
|
-
} catch (err) {
|
|
11405
|
-
result = {
|
|
11406
|
-
ok: false,
|
|
11407
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
11408
|
-
};
|
|
11409
|
-
}
|
|
11410
|
-
cache.set(source, result);
|
|
11411
|
-
if (cache.size > 256) {
|
|
11412
|
-
const oldest = cache.keys().next().value;
|
|
11413
|
-
if (oldest !== void 0) cache.delete(oldest);
|
|
11414
|
-
}
|
|
11415
|
-
return result;
|
|
11416
|
-
}
|
|
11417
|
-
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
11418
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
11419
|
-
function compileExpressionSafe(source) {
|
|
11420
|
-
return getCached(source);
|
|
11421
|
-
}
|
|
11422
|
-
Object.freeze({});
|
|
11423
|
-
/**
|
|
11424
|
-
* Author-time validation. Returns `null` when the source is valid, else a
|
|
11425
|
-
* human-readable error message. Checks: the expression compiles; binding count
|
|
11426
|
-
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
11427
|
-
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
11428
|
-
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
11429
|
-
*/
|
|
11430
|
-
function validateExpressionSource(src) {
|
|
11431
|
-
const names = Object.keys(src.bindings);
|
|
11432
|
-
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
11433
|
-
for (const name of names) {
|
|
11434
|
-
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
11435
|
-
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
11436
|
-
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
11437
|
-
}
|
|
11438
|
-
const compiled = compileExpressionSafe(src.expr);
|
|
11439
|
-
if (!compiled.ok) return compiled.error;
|
|
11440
|
-
const bound = new Set(names);
|
|
11441
|
-
for (const id of compiled.parsed.identifiers) {
|
|
11442
|
-
if (id === "now") continue;
|
|
11443
|
-
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
11444
|
-
}
|
|
11445
|
-
return null;
|
|
11446
|
-
}
|
|
11447
|
-
var ProviderStatusSchema = object({
|
|
11448
|
-
connected: boolean(),
|
|
11449
|
-
deviceCount: number(),
|
|
11450
|
-
error: string().optional()
|
|
11451
|
-
});
|
|
11452
|
-
object({
|
|
11453
|
-
externalId: string(),
|
|
11454
|
-
name: string(),
|
|
11455
|
-
type: string(),
|
|
11456
|
-
metadata: record(string(), unknown()).optional()
|
|
11457
|
-
});
|
|
11458
|
-
/**
|
|
11459
|
-
* Candidate handed back from discovery and accepted by
|
|
11460
|
-
* `adoptDiscoveredDevice`. Shape mirrors the in-process
|
|
11461
|
-
* `DiscoveredDevice` interface used by `DeviceDiscovery`.
|
|
11462
|
-
*/
|
|
11463
|
-
var DiscoveryCandidateSchema = object({
|
|
11464
|
-
stableId: string(),
|
|
11465
|
-
type: _enum(DeviceType),
|
|
11466
|
-
suggestedName: string(),
|
|
11467
|
-
prefilledConfig: record(string(), unknown()),
|
|
11468
|
-
/**
|
|
11469
|
-
* Optional upstream-system identity (HA entity_id, vendor MAC, …).
|
|
11470
|
-
* Discovery pre-populates this for systems that know the upstream
|
|
11471
|
-
* identity ahead of adoption. Rendering metadata (unit, precision)
|
|
11472
|
-
* flows live through the cap STATUS SLICE after adoption.
|
|
11473
|
-
*/
|
|
11474
|
-
sourceInfo: SourceInfoSchema.optional()
|
|
11475
|
-
});
|
|
11476
|
-
/**
|
|
11477
|
-
* Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
|
|
11478
|
-
* Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
|
|
11479
|
-
* tRPC layer can pass it through without reshaping.
|
|
11480
|
-
*/
|
|
11481
|
-
var DeviceSummarySchema = object({
|
|
11482
|
-
id: number(),
|
|
11483
|
-
stableId: string(),
|
|
11484
|
-
addonId: string(),
|
|
11485
|
-
type: string(),
|
|
11486
|
-
name: string(),
|
|
11487
|
-
parentDeviceId: number().nullable(),
|
|
11488
|
-
online: boolean(),
|
|
11489
|
-
features: array(string()),
|
|
11490
|
-
config: record(string(), unknown()),
|
|
11491
|
-
/** Optional upstream-system identity (dispatch key + system tag).
|
|
11492
|
-
* See `SourceInfo`. Present when the device has a non-synthetic
|
|
11493
|
-
* source identifier (HA entities, vendor MAC, …); omitted when the
|
|
11494
|
-
* synthetic backfill is in effect. */
|
|
11495
|
-
sourceInfo: SourceInfoSchema.optional()
|
|
11496
|
-
});
|
|
11497
|
-
/**
|
|
11498
|
-
* Result of a live field test (e.g. probing an RTSP URL during device
|
|
11499
|
-
* creation). Matches the UI-side `FieldProbeResult` in
|
|
11500
|
-
* `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
|
|
11501
|
-
* returned `labels` as chips next to the input.
|
|
11502
|
-
*/
|
|
11503
|
-
var FieldProbeResultSchema = object({
|
|
11504
|
-
status: _enum(["ok", "error"]),
|
|
11505
|
-
labels: array(string()).optional(),
|
|
11506
|
-
error: string().optional()
|
|
11507
|
-
});
|
|
11508
|
-
/**
|
|
11509
|
-
* The output of `getChildCreationSchema` is a UI schema tree. We store
|
|
11510
|
-
* it as `unknown` at the capability layer — the router just passes it
|
|
11511
|
-
* through and the admin UI renders it via `FormBuilder`. The actual
|
|
11512
|
-
* type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
|
|
11513
|
-
* but we deliberately avoid a Zod mirror because the union is large and
|
|
11514
|
-
* not meant for runtime validation at this seam.
|
|
11515
|
-
*/
|
|
11516
|
-
var CreationSchemaOutputSchema = unknown();
|
|
11517
|
-
method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
|
|
11518
|
-
id: string(),
|
|
11519
|
-
name: string(),
|
|
11520
|
-
type: string()
|
|
11521
|
-
}))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
|
|
11522
|
-
kind: "mutation",
|
|
11523
|
-
auth: "admin"
|
|
11524
|
-
}), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
|
|
11525
|
-
kind: "mutation",
|
|
11526
|
-
auth: "admin"
|
|
11527
|
-
}), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
|
|
11528
|
-
type: _enum(DeviceType),
|
|
11529
|
-
config: record(string(), unknown())
|
|
11530
|
-
}), DeviceSummarySchema, {
|
|
11531
|
-
kind: "mutation",
|
|
11532
|
-
auth: "admin"
|
|
11533
|
-
}), method(object({
|
|
11534
|
-
type: _enum(DeviceType),
|
|
11535
|
-
key: string(),
|
|
11536
|
-
value: unknown(),
|
|
11537
|
-
formValues: record(string(), unknown()).optional()
|
|
11538
|
-
}), FieldProbeResultSchema, {
|
|
11539
|
-
kind: "mutation",
|
|
11540
|
-
auth: "admin"
|
|
11541
|
-
});
|
|
11542
|
-
/**
|
|
11543
|
-
* Device Manager capability — hub-side singleton that unifies device persistence,
|
|
11544
|
-
* live registry access, and all management operations into a single tRPC surface.
|
|
11545
|
-
*
|
|
11546
|
-
* Replaces:
|
|
11547
|
-
* - `device-persistence` capability (persistence methods absorbed here)
|
|
11548
|
-
* - `device-management.router.ts` (deleted in Phase 2)
|
|
11549
|
-
* - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
|
|
11550
|
-
*
|
|
11551
|
-
* All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
|
|
11552
|
-
* fork into separate processes but never run on remote cluster agents. Therefore:
|
|
11553
|
-
* - No nodeId routing needed — this is a pure hub singleton.
|
|
11554
|
-
* - The hub's DeviceRegistry is the single source of truth for all live devices.
|
|
11555
|
-
* - No shadow registry or cross-node aggregation required.
|
|
11556
|
-
*
|
|
11557
|
-
* Forked workers register devices back to the hub via `ctx.devices`
|
|
11558
|
-
* (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
|
|
10962
|
+
* All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
|
|
10963
|
+
* fork into separate processes but never run on remote cluster agents. Therefore:
|
|
10964
|
+
* - No nodeId routing needed — this is a pure hub singleton.
|
|
10965
|
+
* - The hub's DeviceRegistry is the single source of truth for all live devices.
|
|
10966
|
+
* - No shadow registry or cross-node aggregation required.
|
|
10967
|
+
*
|
|
10968
|
+
* Forked workers register devices back to the hub via `ctx.devices`
|
|
10969
|
+
* (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
|
|
11559
10970
|
*/
|
|
11560
10971
|
/** One child-placement directive on a container's `childLayout`. Structurally
|
|
11561
10972
|
* identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
|
|
@@ -11568,92 +10979,6 @@ var ChildLayoutEntrySchema = object({
|
|
|
11568
10979
|
order: number().optional(),
|
|
11569
10980
|
collapsed: boolean().optional()
|
|
11570
10981
|
});
|
|
11571
|
-
/** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
|
|
11572
|
-
* `device-management.ts`. Source is a union: a FIELD source copies a sibling
|
|
11573
|
-
* accessory's status field (`kind` optional/absent for wire compat); a
|
|
11574
|
-
* LITERAL source carries a per-device constant (no sibling is read); a
|
|
11575
|
-
* GLOBAL source (P2e) copies ANY device's status field, addressed by the
|
|
11576
|
-
* source device's full re-sync-stable `stableId`. */
|
|
11577
|
-
var DeviceLinkFieldSourceSchema = object({
|
|
11578
|
-
kind: literal("field").optional(),
|
|
11579
|
-
sourceKey: string(),
|
|
11580
|
-
cap: string(),
|
|
11581
|
-
fieldPath: string()
|
|
11582
|
-
});
|
|
11583
|
-
var DeviceLinkLiteralSourceSchema = object({
|
|
11584
|
-
kind: literal("literal"),
|
|
11585
|
-
value: union([
|
|
11586
|
-
string(),
|
|
11587
|
-
number(),
|
|
11588
|
-
boolean(),
|
|
11589
|
-
_null()
|
|
11590
|
-
])
|
|
11591
|
-
});
|
|
11592
|
-
var DeviceLinkGlobalSourceSchema = object({
|
|
11593
|
-
kind: literal("global"),
|
|
11594
|
-
sourceStableId: string(),
|
|
11595
|
-
cap: string(),
|
|
11596
|
-
fieldPath: string()
|
|
11597
|
-
});
|
|
11598
|
-
/** Expression source (Stage X): compute the target field from N named bindings
|
|
11599
|
-
* via the safe expression engine. Bindings are field | literal | global — never
|
|
11600
|
-
* another expression (no nesting). The `superRefine` runs the SAME author-time
|
|
11601
|
-
* validation as `validateExpressionSource` (compiles the expr, checks binding
|
|
11602
|
-
* names + identifier coverage) so every wire boundary that parses a DeviceLink
|
|
11603
|
-
* (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
|
|
11604
|
-
* Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
|
|
11605
|
-
var DeviceLinkExpressionSourceSchema = object({
|
|
11606
|
-
kind: literal("expression"),
|
|
11607
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
11608
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
|
|
11609
|
-
DeviceLinkFieldSourceSchema,
|
|
11610
|
-
DeviceLinkLiteralSourceSchema,
|
|
11611
|
-
DeviceLinkGlobalSourceSchema
|
|
11612
|
-
]))
|
|
11613
|
-
}).superRefine((src, ctx) => {
|
|
11614
|
-
const err = validateExpressionSource(src);
|
|
11615
|
-
if (err !== null) ctx.addIssue({
|
|
11616
|
-
code: "custom",
|
|
11617
|
-
message: err,
|
|
11618
|
-
path: ["expr"]
|
|
11619
|
-
});
|
|
11620
|
-
});
|
|
11621
|
-
var DeviceLinkSchema = object({
|
|
11622
|
-
id: string(),
|
|
11623
|
-
source: union([
|
|
11624
|
-
DeviceLinkFieldSourceSchema,
|
|
11625
|
-
DeviceLinkLiteralSourceSchema,
|
|
11626
|
-
DeviceLinkGlobalSourceSchema,
|
|
11627
|
-
DeviceLinkExpressionSourceSchema
|
|
11628
|
-
]),
|
|
11629
|
-
target: object({
|
|
11630
|
-
cap: string(),
|
|
11631
|
-
fieldPath: string(),
|
|
11632
|
-
itemKey: string().optional()
|
|
11633
|
-
}),
|
|
11634
|
-
transform: discriminatedUnion("kind", [
|
|
11635
|
-
object({ kind: literal("identity") }),
|
|
11636
|
-
object({
|
|
11637
|
-
kind: literal("enum-map"),
|
|
11638
|
-
mapping: record(string(), union([
|
|
11639
|
-
string(),
|
|
11640
|
-
number(),
|
|
11641
|
-
boolean()
|
|
11642
|
-
])),
|
|
11643
|
-
fallback: union([
|
|
11644
|
-
string(),
|
|
11645
|
-
number(),
|
|
11646
|
-
boolean()
|
|
11647
|
-
]).optional()
|
|
11648
|
-
}),
|
|
11649
|
-
object({
|
|
11650
|
-
kind: literal("linear"),
|
|
11651
|
-
scale: number(),
|
|
11652
|
-
offset: number(),
|
|
11653
|
-
clamp: tuple([number(), number()]).readonly().optional()
|
|
11654
|
-
})
|
|
11655
|
-
]).optional()
|
|
11656
|
-
});
|
|
11657
10982
|
/** Cap-wire shape of a per-cap display refinement — mirrors
|
|
11658
10983
|
* `DeviceCapDisplayOverride` in `device-management.ts`. */
|
|
11659
10984
|
var DeviceCapDisplayOverrideSchema = object({
|
|
@@ -11733,8 +11058,6 @@ var DeviceInfoSchema = object({
|
|
|
11733
11058
|
* named accordion sections (with optional intra-section order). See
|
|
11734
11059
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
11735
11060
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
11736
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
11737
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
11738
11061
|
/** Operator-authored per-device display override. See `DeviceMeta.display`. */
|
|
11739
11062
|
display: DeviceDisplayOverrideSchema.optional()
|
|
11740
11063
|
});
|
|
@@ -11743,7 +11066,7 @@ var ConfigEntrySchema = object({
|
|
|
11743
11066
|
value: unknown(),
|
|
11744
11067
|
description: string().optional()
|
|
11745
11068
|
});
|
|
11746
|
-
var
|
|
11069
|
+
var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
|
|
11747
11070
|
/** One resolved linked device — the compact projection consumers need. */
|
|
11748
11071
|
var LinkedDeviceSchema = object({
|
|
11749
11072
|
deviceId: number(),
|
|
@@ -11806,8 +11129,6 @@ var DeviceMetaSchema = object({
|
|
|
11806
11129
|
* accordion sections (with optional intra-section order). See
|
|
11807
11130
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
11808
11131
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
11809
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
11810
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
11811
11132
|
/** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
|
|
11812
11133
|
* Optional: only present for accessory children that carry a known role. */
|
|
11813
11134
|
role: string().nullable().optional(),
|
|
@@ -11900,12 +11221,6 @@ method(object({
|
|
|
11900
11221
|
}), _void(), {
|
|
11901
11222
|
kind: "mutation",
|
|
11902
11223
|
auth: "admin"
|
|
11903
|
-
}), method(object({
|
|
11904
|
-
deviceId: number(),
|
|
11905
|
-
deviceLinks: array(DeviceLinkSchema).readonly()
|
|
11906
|
-
}), _void(), {
|
|
11907
|
-
kind: "mutation",
|
|
11908
|
-
auth: "admin"
|
|
11909
11224
|
}), method(object({
|
|
11910
11225
|
deviceId: number(),
|
|
11911
11226
|
display: DeviceDisplayOverrideSchema.nullable()
|
|
@@ -11987,7 +11302,7 @@ method(object({
|
|
|
11987
11302
|
* shipping 293 rows to find 12. */
|
|
11988
11303
|
isCamera: boolean().optional()
|
|
11989
11304
|
}), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
|
|
11990
|
-
mode:
|
|
11305
|
+
mode: LinkedDevicesModeSchema,
|
|
11991
11306
|
devices: array(LinkedDeviceSchema)
|
|
11992
11307
|
})), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
|
|
11993
11308
|
deviceId: number(),
|
|
@@ -12020,11 +11335,7 @@ method(object({
|
|
|
12020
11335
|
deviceId: number(),
|
|
12021
11336
|
entries: array(object({
|
|
12022
11337
|
capName: string(),
|
|
12023
|
-
kind: _enum([
|
|
12024
|
-
"native",
|
|
12025
|
-
"wrapped",
|
|
12026
|
-
"linked"
|
|
12027
|
-
]),
|
|
11338
|
+
kind: _enum(["native", "wrapped"]),
|
|
12028
11339
|
providerAddonId: string(),
|
|
12029
11340
|
providerNodeId: string(),
|
|
12030
11341
|
nativeAddonId: string()
|
|
@@ -12033,11 +11344,7 @@ method(object({
|
|
|
12033
11344
|
deviceId: number(),
|
|
12034
11345
|
entries: array(object({
|
|
12035
11346
|
capName: string(),
|
|
12036
|
-
kind: _enum([
|
|
12037
|
-
"native",
|
|
12038
|
-
"wrapped",
|
|
12039
|
-
"linked"
|
|
12040
|
-
]),
|
|
11347
|
+
kind: _enum(["native", "wrapped"]),
|
|
12041
11348
|
providerAddonId: string(),
|
|
12042
11349
|
providerNodeId: string(),
|
|
12043
11350
|
nativeAddonId: string()
|
|
@@ -12850,7 +12157,7 @@ var MotionAnalysisResultSchema = object({
|
|
|
12850
12157
|
frameHeight: number(),
|
|
12851
12158
|
analysisMs: number()
|
|
12852
12159
|
});
|
|
12853
|
-
method(object({
|
|
12160
|
+
DeviceType.Camera, method(object({
|
|
12854
12161
|
deviceId: number(),
|
|
12855
12162
|
frame: FrameInputSchema.optional(),
|
|
12856
12163
|
frameHandle: FrameHandleSchema.optional()
|
|
@@ -15522,6 +14829,18 @@ var OauthIntegrationDescriptorSchema = object({
|
|
|
15522
14829
|
* redirect_uri that does not start with one of these. Required —
|
|
15523
14830
|
* an empty list means the integration can never complete linking. */
|
|
15524
14831
|
allowedRedirectPrefixes: array(string()).min(1),
|
|
14832
|
+
/** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
|
|
14833
|
+
* RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
|
|
14834
|
+
* `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
|
|
14835
|
+
* whose address the hub cannot know in advance (a Home Assistant at
|
|
14836
|
+
* `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
|
|
14837
|
+
* exactly; a public host never satisfies this branch, so it is not a
|
|
14838
|
+
* wildcard prefix by another name. */
|
|
14839
|
+
allowedPrivateHostPaths: array(string()).optional(),
|
|
14840
|
+
/** When true this is a PUBLIC client (source is published, no secret can be
|
|
14841
|
+
* protected) and PKCE is mandatory: `/authorize` refuses without an S256
|
|
14842
|
+
* `code_challenge`, `/token` refuses without the matching `code_verifier`. */
|
|
14843
|
+
requiresPkce: boolean().optional(),
|
|
15525
14844
|
/** Optional public origin (no trailing slash) that this integration's
|
|
15526
14845
|
* issued codes/tokens should carry as the `hubUrl` claim — typically the
|
|
15527
14846
|
* operator-selected external-access endpoint resolved by the addon. When
|
|
@@ -15672,7 +14991,7 @@ var TrackEnvelopeSchema = object({
|
|
|
15672
14991
|
* `snapshots[]` references — megabytes across a page of tracks. `slim`
|
|
15673
14992
|
* keeps every scalar the list surfaces actually render (ids, class(es),
|
|
15674
14993
|
* label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
|
|
15675
|
-
* zonesVisited, bestEventId, envelope) and returns `positions` /
|
|
14994
|
+
* zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
|
|
15676
14995
|
* `snapshots` as EMPTY arrays — detail views re-fetch the full row via
|
|
15677
14996
|
* `getTrack`. Mirrors the event-store `projection` convention
|
|
15678
14997
|
* (`getObjectEvents` et al.).
|
|
@@ -15909,6 +15228,24 @@ var TrackSchema = object({
|
|
|
15909
15228
|
* Populated from the persisted envelope columns on historical reads;
|
|
15910
15229
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
15911
15230
|
envelope: TrackEnvelopeSchema.optional(),
|
|
15231
|
+
/**
|
|
15232
|
+
* A face DETECTOR found a face on this track — nothing more. It says the
|
|
15233
|
+
* detail plane produced a `face` detail; it does NOT say the face was
|
|
15234
|
+
* embedded, matched, above `minFacePx`, or that the recognizer was even
|
|
15235
|
+
* enabled. Set once and never cleared.
|
|
15236
|
+
*
|
|
15237
|
+
* **This exists so "face present but not recognised" is expressible.** A
|
|
15238
|
+
* recognised identity lands in `subLabel` (attributed to the face chain via
|
|
15239
|
+
* `subLabelMeta.stepId`), so before this field a track with an unmatched face
|
|
15240
|
+
* and a track with no face at all were byte-identical on the wire and no
|
|
15241
|
+
* surface could tell them apart. The read is `hasFace === true && subLabel
|
|
15242
|
+
* === undefined`.
|
|
15243
|
+
*
|
|
15244
|
+
* **Absent ≠ false.** Every row written before the column existed omits it,
|
|
15245
|
+
* and so does every server that predates the field — a consumer must test
|
|
15246
|
+
* `=== true` and render nothing otherwise, never infer "no face".
|
|
15247
|
+
*/
|
|
15248
|
+
hasFace: boolean().optional(),
|
|
15912
15249
|
...TrackFlagFields,
|
|
15913
15250
|
...TrackRetrainFields
|
|
15914
15251
|
});
|
|
@@ -18977,6 +18314,10 @@ var SsoBridgeClaimsSchema = object({
|
|
|
18977
18314
|
integrationId: string().optional(),
|
|
18978
18315
|
/** JWT ID — unique per issued code; consumed-set enforces single-use. */
|
|
18979
18316
|
jti: string().optional(),
|
|
18317
|
+
/** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
|
|
18318
|
+
* client. Its PRESENCE is what makes the verifier mandatory at exchange,
|
|
18319
|
+
* so the requirement travels with the code and not with mutable config. */
|
|
18320
|
+
codeChallenge: string().optional(),
|
|
18980
18321
|
/** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
|
|
18981
18322
|
* tokens so the verify path can check the session is not revoked. */
|
|
18982
18323
|
sessionId: string().optional()
|
|
@@ -19535,6 +18876,10 @@ var videoclipsCapability = {
|
|
|
19535
18876
|
mode: "singleton",
|
|
19536
18877
|
kind: "wrapper",
|
|
19537
18878
|
defaultActive: true,
|
|
18879
|
+
/** A clip is a window over a camera's footage — the cap is meaningless on a
|
|
18880
|
+
* sensor, a button or an event emitter, and the `defaultActive` auto-bind
|
|
18881
|
+
* reads this to decide which devices it may claim. */
|
|
18882
|
+
deviceTypes: [DeviceType.Camera],
|
|
19538
18883
|
methods: {
|
|
19539
18884
|
listClips: method(object({
|
|
19540
18885
|
deviceId: number(),
|
|
@@ -26131,13 +25476,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
26131
25476
|
username: string(),
|
|
26132
25477
|
scopes: array(TokenScopeSchema),
|
|
26133
25478
|
redirectUri: string(),
|
|
26134
|
-
hubUrl: string()
|
|
25479
|
+
hubUrl: string(),
|
|
25480
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
25481
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
25482
|
+
codeChallenge: string().optional()
|
|
26135
25483
|
}), object({ code: string() }), {
|
|
26136
25484
|
kind: "mutation",
|
|
26137
25485
|
access: "create"
|
|
26138
25486
|
}), method(object({
|
|
26139
25487
|
code: string(),
|
|
26140
|
-
redirectUri: string()
|
|
25488
|
+
redirectUri: string(),
|
|
25489
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
25490
|
+
codeVerifier: string().optional()
|
|
26141
25491
|
}), object({
|
|
26142
25492
|
accessToken: string(),
|
|
26143
25493
|
refreshToken: string(),
|
|
@@ -26616,1013 +25966,1850 @@ var HistoryPointSchema = object({
|
|
|
26616
25966
|
* outside-any-zone). Crossing them with `className?` gives the full
|
|
26617
25967
|
* combinatorial coverage the operator UI requested.
|
|
26618
25968
|
*/
|
|
26619
|
-
var zoneAnalyticsCapability = {
|
|
26620
|
-
name: "zone-analytics",
|
|
25969
|
+
var zoneAnalyticsCapability = {
|
|
25970
|
+
name: "zone-analytics",
|
|
25971
|
+
scope: "device",
|
|
25972
|
+
mode: "singleton",
|
|
25973
|
+
deviceTypes: [DeviceType.Camera],
|
|
25974
|
+
methods: {
|
|
25975
|
+
/** Latest computed occupancy snapshot for this camera. Null when
|
|
25976
|
+
* the analytics pipeline hasn't seen a frame for this device yet
|
|
25977
|
+
* (no inference result emitted since boot or since binding was
|
|
25978
|
+
* activated). */
|
|
25979
|
+
getCurrentSnapshot: method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()),
|
|
25980
|
+
/** Time-series object count inside one zone. `className` optional —
|
|
25981
|
+
* omit to count every class in the zone. */
|
|
25982
|
+
getZoneHistory: method(object({
|
|
25983
|
+
deviceId: number(),
|
|
25984
|
+
zoneId: string(),
|
|
25985
|
+
className: string().optional()
|
|
25986
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
|
|
25987
|
+
/** Time-series frame-wide object count (everywhere). */
|
|
25988
|
+
getCameraHistory: method(object({
|
|
25989
|
+
deviceId: number(),
|
|
25990
|
+
className: string().optional()
|
|
25991
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
|
|
25992
|
+
/** Time-series count of objects outside every zone. */
|
|
25993
|
+
getUnzonedHistory: method(object({
|
|
25994
|
+
deviceId: number(),
|
|
25995
|
+
className: string().optional()
|
|
25996
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly())
|
|
25997
|
+
},
|
|
25998
|
+
/**
|
|
25999
|
+
* Runtime-state slice — the latest occupancy snapshot mirrored by
|
|
26000
|
+
* the analytics frame processor on every inference result. Consumers
|
|
26001
|
+
* read via `device.state.zoneAnalytics.value` and stay in sync
|
|
26002
|
+
* automatically; the explicit `getCurrentSnapshot` cap method is
|
|
26003
|
+
* still useful for one-off polls without a subscription.
|
|
26004
|
+
*/
|
|
26005
|
+
runtimeState: CameraOccupancySnapshotSchema
|
|
26006
|
+
};
|
|
26007
|
+
/**
|
|
26008
|
+
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
26009
|
+
* cap so a single CRUD surface backs every consumer; each stage has
|
|
26010
|
+
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
26011
|
+
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
26012
|
+
*
|
|
26013
|
+
* Extend the enum here when a new gating consumer comes online (audio
|
|
26014
|
+
* gating, alert filtering, …) — no other surface needs to change.
|
|
26015
|
+
*/
|
|
26016
|
+
var ZoneRuleStageEnum = _enum([
|
|
26017
|
+
"motion",
|
|
26018
|
+
"detection",
|
|
26019
|
+
"package"
|
|
26020
|
+
]);
|
|
26021
|
+
/**
|
|
26022
|
+
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
26023
|
+
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
26024
|
+
*
|
|
26025
|
+
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
26026
|
+
* so the operator has a single hub-side source of truth for both
|
|
26027
|
+
* geometry and behaviour. Per-stage rules are stored under the
|
|
26028
|
+
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
26029
|
+
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
26030
|
+
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
26031
|
+
* subscribe to that slice and refresh their gating without
|
|
26032
|
+
* round-tripping the cap.
|
|
26033
|
+
*
|
|
26034
|
+
* Sets are bulk-replace — the operator UI sends the new rule list
|
|
26035
|
+
* wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
|
|
26036
|
+
* naturally in the rule editor without per-rule mutation chatter.
|
|
26037
|
+
*/
|
|
26038
|
+
var zoneRulesCapability = {
|
|
26039
|
+
name: "zone-rules",
|
|
26621
26040
|
scope: "device",
|
|
26622
26041
|
mode: "singleton",
|
|
26623
26042
|
deviceTypes: [DeviceType.Camera],
|
|
26624
26043
|
methods: {
|
|
26625
|
-
/**
|
|
26626
|
-
*
|
|
26627
|
-
|
|
26628
|
-
* activated). */
|
|
26629
|
-
getCurrentSnapshot: method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()),
|
|
26630
|
-
/** Time-series object count inside one zone. `className` optional —
|
|
26631
|
-
* omit to count every class in the zone. */
|
|
26632
|
-
getZoneHistory: method(object({
|
|
26633
|
-
deviceId: number(),
|
|
26634
|
-
zoneId: string(),
|
|
26635
|
-
className: string().optional()
|
|
26636
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
|
|
26637
|
-
/** Time-series frame-wide object count (everywhere). */
|
|
26638
|
-
getCameraHistory: method(object({
|
|
26044
|
+
/** Read the full rule list for a given stage (empty when no rules
|
|
26045
|
+
* are defined yet). */
|
|
26046
|
+
listRules: method(object({
|
|
26639
26047
|
deviceId: number(),
|
|
26640
|
-
|
|
26641
|
-
})
|
|
26642
|
-
/**
|
|
26643
|
-
|
|
26048
|
+
stage: ZoneRuleStageEnum
|
|
26049
|
+
}), array(ZoneRuleSchema).readonly()),
|
|
26050
|
+
/** Bulk-replace the rule list for one stage. The provider validates
|
|
26051
|
+
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
26052
|
+
* thresholds in range) and rejects the whole patch if any entry
|
|
26053
|
+
* is invalid — partial writes are a configuration footgun. */
|
|
26054
|
+
setRules: method(object({
|
|
26644
26055
|
deviceId: number(),
|
|
26645
|
-
|
|
26646
|
-
|
|
26056
|
+
stage: ZoneRuleStageEnum,
|
|
26057
|
+
rules: array(ZoneRuleSchema).readonly()
|
|
26058
|
+
}), _void(), {
|
|
26059
|
+
kind: "mutation",
|
|
26060
|
+
auth: "admin"
|
|
26061
|
+
})
|
|
26647
26062
|
},
|
|
26648
26063
|
/**
|
|
26649
|
-
* Runtime-state slice —
|
|
26650
|
-
*
|
|
26651
|
-
*
|
|
26652
|
-
*
|
|
26653
|
-
*
|
|
26064
|
+
* Runtime-state slice — every stage mirrored together so consumers
|
|
26065
|
+
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
26066
|
+
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
26067
|
+
* `{motion, detection, package}` shape, so subscribers always get the
|
|
26068
|
+
* complete current set. Consumers that only care about one stage
|
|
26069
|
+
* just read the matching property.
|
|
26070
|
+
*
|
|
26071
|
+
* `package` backs the package-drop detector — a package zone is a
|
|
26072
|
+
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
26073
|
+
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
26074
|
+
* The orchestrator provider writes this stage as a first-class slice
|
|
26075
|
+
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
26076
|
+
* package}` shape, so consumers read the current package rules directly
|
|
26077
|
+
* off `device.state.zoneRules.value.package`.
|
|
26654
26078
|
*/
|
|
26655
|
-
runtimeState:
|
|
26079
|
+
runtimeState: object({
|
|
26080
|
+
motion: array(ZoneRuleSchema).readonly(),
|
|
26081
|
+
detection: array(ZoneRuleSchema).readonly(),
|
|
26082
|
+
package: array(ZoneRuleSchema).readonly()
|
|
26083
|
+
})
|
|
26084
|
+
};
|
|
26085
|
+
/**
|
|
26086
|
+
* Most specific first. Extending this list is how a new device kind becomes
|
|
26087
|
+
* gateable; nothing else needs to change.
|
|
26088
|
+
*/
|
|
26089
|
+
var DEVICE_STATE_READERS = [
|
|
26090
|
+
{
|
|
26091
|
+
cap: "alarm-panel",
|
|
26092
|
+
field: "state"
|
|
26093
|
+
},
|
|
26094
|
+
{
|
|
26095
|
+
cap: "cover",
|
|
26096
|
+
field: "state"
|
|
26097
|
+
},
|
|
26098
|
+
{
|
|
26099
|
+
cap: "presence",
|
|
26100
|
+
field: "state"
|
|
26101
|
+
},
|
|
26102
|
+
{
|
|
26103
|
+
cap: "lock",
|
|
26104
|
+
field: "locked",
|
|
26105
|
+
booleanWords: ["locked", "unlocked"]
|
|
26106
|
+
},
|
|
26107
|
+
{
|
|
26108
|
+
cap: "contact",
|
|
26109
|
+
field: "entryOpen",
|
|
26110
|
+
booleanWords: ["open", "closed"]
|
|
26111
|
+
},
|
|
26112
|
+
{
|
|
26113
|
+
cap: "switch",
|
|
26114
|
+
field: "on",
|
|
26115
|
+
booleanWords: ["on", "off"]
|
|
26116
|
+
},
|
|
26117
|
+
{
|
|
26118
|
+
cap: "binary",
|
|
26119
|
+
field: "on",
|
|
26120
|
+
booleanWords: ["on", "off"]
|
|
26121
|
+
}
|
|
26122
|
+
];
|
|
26123
|
+
/**
|
|
26124
|
+
* Collapse a device's full runtime state to the one string a rule compares
|
|
26125
|
+
* against, or `undefined` when nothing in the table applies.
|
|
26126
|
+
*
|
|
26127
|
+
* `undefined` is the safe answer everywhere: the gate treats it as "does not
|
|
26128
|
+
* match", so a device whose kind we cannot read simply never arms a rule.
|
|
26129
|
+
*/
|
|
26130
|
+
function readDeviceStateFrom(runtimeState) {
|
|
26131
|
+
for (const reader of DEVICE_STATE_READERS) {
|
|
26132
|
+
const slice = runtimeState[reader.cap];
|
|
26133
|
+
if (slice === null || typeof slice !== "object") continue;
|
|
26134
|
+
const value = slice[reader.field];
|
|
26135
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
26136
|
+
if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
|
|
26137
|
+
}
|
|
26138
|
+
}
|
|
26139
|
+
/**
|
|
26140
|
+
* Accessory device helpers — shared across drivers.
|
|
26141
|
+
*
|
|
26142
|
+
* Many vendor-specific drivers register accessory child devices on
|
|
26143
|
+
* top of a parent (Reolink: siren / floodlight / PIR / autotrack /
|
|
26144
|
+
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
26145
|
+
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
26146
|
+
* spawning, builds a name derived from the parent, and produces a
|
|
26147
|
+
* stableId tied to the parent so boot-restore can reconstruct the
|
|
26148
|
+
* relationship.
|
|
26149
|
+
*
|
|
26150
|
+
* Centralised `(kind → DeviceType)` mapping was dropped on purpose:
|
|
26151
|
+
* drivers may reasonably disagree on the right type for an accessory
|
|
26152
|
+
* (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
|
|
26153
|
+
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
26154
|
+
* one canonical mapping was over-prescriptive and added a layer of
|
|
26155
|
+
* indirection without saving meaningful code at call sites — the
|
|
26156
|
+
* driver knows its own hardware best.
|
|
26157
|
+
*/
|
|
26158
|
+
/**
|
|
26159
|
+
* Subset of `DeviceRole` values that drivers register as child
|
|
26160
|
+
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
26161
|
+
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
26162
|
+
* children, so the call site reads as
|
|
26163
|
+
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
26164
|
+
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
26165
|
+
* any role works, including non-accessory ones like Doorbell).
|
|
26166
|
+
*/
|
|
26167
|
+
var AccessoryKind = {
|
|
26168
|
+
Siren: DeviceRole.Siren,
|
|
26169
|
+
Floodlight: DeviceRole.Floodlight,
|
|
26170
|
+
Spotlight: DeviceRole.Spotlight,
|
|
26171
|
+
PirSensor: DeviceRole.PirSensor,
|
|
26172
|
+
Chime: DeviceRole.Chime,
|
|
26173
|
+
Autotrack: DeviceRole.Autotrack,
|
|
26174
|
+
Nightvision: DeviceRole.Nightvision,
|
|
26175
|
+
PrivacyMask: DeviceRole.PrivacyMask
|
|
26176
|
+
};
|
|
26177
|
+
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
26178
|
+
var DeviceConfig = class DeviceConfig {
|
|
26179
|
+
schema;
|
|
26180
|
+
data;
|
|
26181
|
+
persistFn;
|
|
26182
|
+
constructor(schema, data, persist) {
|
|
26183
|
+
this.schema = schema;
|
|
26184
|
+
this.data = data;
|
|
26185
|
+
this.persistFn = persist;
|
|
26186
|
+
}
|
|
26187
|
+
/**
|
|
26188
|
+
* Build a `DeviceConfig` from a persisted blob, with automatic
|
|
26189
|
+
* recovery from schema-validation failures. Boot must never be
|
|
26190
|
+
* blocked by stale persisted values: if Zod rejects the blob,
|
|
26191
|
+
* we drop every offending top-level field, retry, and persist
|
|
26192
|
+
* the cleaned blob so the bad value is healed in the DB on next
|
|
26193
|
+
* write. The most common trigger is a tightened range constraint
|
|
26194
|
+
* (e.g. `max(100) → max(50)`) on a field that already has an
|
|
26195
|
+
* out-of-range value persisted from the previous schema. Without
|
|
26196
|
+
* this safety net, the device would fail to instantiate and end
|
|
26197
|
+
* up with no caps registered — exactly the failure mode that
|
|
26198
|
+
* stranded device 15 when `motionSensitivity: 90` no longer fit
|
|
26199
|
+
* the new `1..50` schema.
|
|
26200
|
+
*
|
|
26201
|
+
* Recovery rules:
|
|
26202
|
+
* 1. Try `safeParse(initialData)`. If it succeeds, done.
|
|
26203
|
+
* 2. On failure, walk `error.issues`, collect the top-level path
|
|
26204
|
+
* of each issue, and drop those keys from `initialData`.
|
|
26205
|
+
* 3. Re-run `safeParse`. If the cleaned blob now passes (Zod
|
|
26206
|
+
* fills the missing keys with schema defaults / undefined for
|
|
26207
|
+
* `.optional()`), persist it via `persist()` so the bad
|
|
26208
|
+
* values disappear from the DB, and return the device.
|
|
26209
|
+
* 4. If the cleaned blob STILL fails (very rare — would require
|
|
26210
|
+
* a non-recoverable required field), fall back to
|
|
26211
|
+
* `schema.parse({})` so the device still boots with pure
|
|
26212
|
+
* schema defaults. Persist nothing in that path so the next
|
|
26213
|
+
* successful `setAll` still writes a coherent blob.
|
|
26214
|
+
*/
|
|
26215
|
+
static fromSchema(schema, persist, initialData = {}, onRecover) {
|
|
26216
|
+
const first = schema.safeParse(initialData);
|
|
26217
|
+
if (first.success) return new DeviceConfig(schema, first.data, persist);
|
|
26218
|
+
const droppedKeys = /* @__PURE__ */ new Set();
|
|
26219
|
+
for (const issue of first.error.issues) {
|
|
26220
|
+
const top = issue.path[0];
|
|
26221
|
+
if (typeof top === "string") droppedKeys.add(top);
|
|
26222
|
+
}
|
|
26223
|
+
const cleaned = { ...initialData };
|
|
26224
|
+
for (const k of droppedKeys) delete cleaned[k];
|
|
26225
|
+
const second = schema.safeParse(cleaned);
|
|
26226
|
+
onRecover?.({
|
|
26227
|
+
droppedKeys: [...droppedKeys],
|
|
26228
|
+
issues: first.error.issues
|
|
26229
|
+
});
|
|
26230
|
+
if (second.success) {
|
|
26231
|
+
persist(second.data).catch(() => {});
|
|
26232
|
+
return new DeviceConfig(schema, second.data, persist);
|
|
26233
|
+
}
|
|
26234
|
+
return new DeviceConfig(schema, schema.parse({}), persist);
|
|
26235
|
+
}
|
|
26236
|
+
get values() {
|
|
26237
|
+
return this.data;
|
|
26238
|
+
}
|
|
26239
|
+
get(key) {
|
|
26240
|
+
return this.data[key];
|
|
26241
|
+
}
|
|
26242
|
+
async set(key, value) {
|
|
26243
|
+
const next = this.schema.parse({
|
|
26244
|
+
...this.data,
|
|
26245
|
+
[key]: value
|
|
26246
|
+
});
|
|
26247
|
+
this.data = next;
|
|
26248
|
+
await this.persistFn(this.data);
|
|
26249
|
+
}
|
|
26250
|
+
/**
|
|
26251
|
+
* Merge an untyped patch onto the current config and persist. Accepts
|
|
26252
|
+
* `Record<string, unknown>` because the patch typically comes from the
|
|
26253
|
+
* UI form layer (a `ConfigField.key → value` map) where the caller
|
|
26254
|
+
* doesn't hold the Zod schema's static type. Runtime validation is
|
|
26255
|
+
* authoritative: `this.schema.parse` rejects unknown keys or invalid
|
|
26256
|
+
* shapes before touching storage.
|
|
26257
|
+
*/
|
|
26258
|
+
async setAll(partial) {
|
|
26259
|
+
const next = this.schema.parse({
|
|
26260
|
+
...this.data,
|
|
26261
|
+
...partial
|
|
26262
|
+
});
|
|
26263
|
+
this.data = next;
|
|
26264
|
+
await this.persistFn(this.data);
|
|
26265
|
+
}
|
|
26266
|
+
async deleteKey(key) {
|
|
26267
|
+
const { [key]: _, ...rest } = this.data;
|
|
26268
|
+
const next = this.schema.parse(rest);
|
|
26269
|
+
this.data = next;
|
|
26270
|
+
await this.persistFn(this.data);
|
|
26271
|
+
}
|
|
26272
|
+
entries() {
|
|
26273
|
+
const shape = this.schema.shape;
|
|
26274
|
+
return Object.entries(shape).map(([key, fieldSchema]) => ({
|
|
26275
|
+
key,
|
|
26276
|
+
schema: fieldSchema,
|
|
26277
|
+
value: this.data[key],
|
|
26278
|
+
description: fieldSchema.description
|
|
26279
|
+
}));
|
|
26280
|
+
}
|
|
26656
26281
|
};
|
|
26657
26282
|
/**
|
|
26658
|
-
*
|
|
26659
|
-
*
|
|
26660
|
-
*
|
|
26661
|
-
*
|
|
26662
|
-
*
|
|
26663
|
-
* Extend the enum here when a new gating consumer comes online (audio
|
|
26664
|
-
* gating, alert filtering, …) — no other surface needs to change.
|
|
26665
|
-
*/
|
|
26666
|
-
var ZoneRuleStageEnum = _enum([
|
|
26667
|
-
"motion",
|
|
26668
|
-
"detection",
|
|
26669
|
-
"package"
|
|
26670
|
-
]);
|
|
26671
|
-
/**
|
|
26672
|
-
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
26673
|
-
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
26283
|
+
* Concrete implementation. Routes every successful write through
|
|
26284
|
+
* `writer(capName, slice)` — the kernel hooks this up to
|
|
26285
|
+
* `device-state.setCapSlice`, the canonical cross-layer write
|
|
26286
|
+
* entrypoint, which handles disk persistence (debounced on the hub)
|
|
26287
|
+
* and mirror updates.
|
|
26674
26288
|
*
|
|
26675
|
-
*
|
|
26676
|
-
*
|
|
26677
|
-
*
|
|
26678
|
-
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
26679
|
-
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
26680
|
-
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
26681
|
-
* subscribe to that slice and refresh their gating without
|
|
26682
|
-
* round-tripping the cap.
|
|
26289
|
+
* Schema validation runs in-process before the writer is called —
|
|
26290
|
+
* the round-trip should never carry an invalid slice. `flush()`
|
|
26291
|
+
* awaits any in-flight writer promises so shutdown is lossless.
|
|
26683
26292
|
*
|
|
26684
|
-
*
|
|
26685
|
-
*
|
|
26686
|
-
*
|
|
26293
|
+
* `initial` is the persisted blob loaded at boot. Slices for caps
|
|
26294
|
+
* whose schema hasn't been installed yet are kept in-memory verbatim
|
|
26295
|
+
* and validated when the cap registers later.
|
|
26687
26296
|
*/
|
|
26688
|
-
var
|
|
26689
|
-
|
|
26690
|
-
|
|
26691
|
-
|
|
26692
|
-
|
|
26693
|
-
|
|
26694
|
-
|
|
26695
|
-
|
|
26696
|
-
|
|
26697
|
-
|
|
26698
|
-
|
|
26699
|
-
|
|
26700
|
-
|
|
26701
|
-
|
|
26702
|
-
|
|
26703
|
-
|
|
26704
|
-
|
|
26705
|
-
|
|
26706
|
-
|
|
26707
|
-
|
|
26708
|
-
|
|
26709
|
-
|
|
26710
|
-
|
|
26711
|
-
}
|
|
26712
|
-
|
|
26297
|
+
var DeviceRuntimeState = class DeviceRuntimeState {
|
|
26298
|
+
writer;
|
|
26299
|
+
/** In-flight writer promises tracked so `flush()` can await them. */
|
|
26300
|
+
pendingWrites = /* @__PURE__ */ new Set();
|
|
26301
|
+
/** Per-cap committed slice — after schema validation when known. */
|
|
26302
|
+
slices;
|
|
26303
|
+
/** Per-cap registered schema (set by `installCapSchema`). */
|
|
26304
|
+
schemas = /* @__PURE__ */ new Map();
|
|
26305
|
+
listeners = /* @__PURE__ */ new Set();
|
|
26306
|
+
capListeners = /* @__PURE__ */ new Map();
|
|
26307
|
+
constructor(initial, writer) {
|
|
26308
|
+
this.writer = writer;
|
|
26309
|
+
this.slices = /* @__PURE__ */ new Map();
|
|
26310
|
+
for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
|
|
26311
|
+
}
|
|
26312
|
+
static fromInitial(initial, writer) {
|
|
26313
|
+
return new DeviceRuntimeState(initial, writer);
|
|
26314
|
+
}
|
|
26315
|
+
installCapSchema(capName, schema) {
|
|
26316
|
+
const existing = this.schemas.get(capName);
|
|
26317
|
+
if (existing) {
|
|
26318
|
+
if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
|
|
26319
|
+
return;
|
|
26320
|
+
}
|
|
26321
|
+
this.schemas.set(capName, schema);
|
|
26322
|
+
const stored = this.slices.get(capName);
|
|
26323
|
+
if (stored) {
|
|
26324
|
+
const result = schema.safeParse(stored);
|
|
26325
|
+
if (result.success) this.slices.set(capName, result.data);
|
|
26326
|
+
else this.slices.delete(capName);
|
|
26327
|
+
}
|
|
26328
|
+
}
|
|
26329
|
+
getCapState(capName) {
|
|
26330
|
+
const slice = this.slices.get(capName);
|
|
26331
|
+
if (!slice) return void 0;
|
|
26332
|
+
return Object.freeze({ ...slice });
|
|
26333
|
+
}
|
|
26334
|
+
getCapField(capName, key) {
|
|
26335
|
+
return this.slices.get(capName)?.[key];
|
|
26336
|
+
}
|
|
26337
|
+
setCapState(capName, value) {
|
|
26338
|
+
this.applyCapWrite(capName, value, false);
|
|
26339
|
+
}
|
|
26340
|
+
patchCapState(capName, partial) {
|
|
26341
|
+
this.applyCapWrite(capName, partial, true);
|
|
26342
|
+
}
|
|
26713
26343
|
/**
|
|
26714
|
-
*
|
|
26715
|
-
*
|
|
26716
|
-
*
|
|
26717
|
-
* `{motion, detection, package}` shape, so subscribers always get the
|
|
26718
|
-
* complete current set. Consumers that only care about one stage
|
|
26719
|
-
* just read the matching property.
|
|
26720
|
-
*
|
|
26721
|
-
* `package` backs the package-drop detector — a package zone is a
|
|
26722
|
-
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
26723
|
-
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
26724
|
-
* The orchestrator provider writes this stage as a first-class slice
|
|
26725
|
-
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
26726
|
-
* package}` shape, so consumers read the current package rules directly
|
|
26727
|
-
* off `device.state.zoneRules.value.package`.
|
|
26344
|
+
* Internal worker. `merge` controls whether `value` replaces or
|
|
26345
|
+
* shallow-merges into the existing slice. Schema validation runs
|
|
26346
|
+
* on the FINAL composed object regardless.
|
|
26728
26347
|
*/
|
|
26729
|
-
|
|
26730
|
-
|
|
26731
|
-
|
|
26732
|
-
|
|
26733
|
-
|
|
26734
|
-
|
|
26735
|
-
|
|
26736
|
-
|
|
26737
|
-
|
|
26738
|
-
|
|
26739
|
-
|
|
26740
|
-
|
|
26741
|
-
|
|
26742
|
-
|
|
26743
|
-
|
|
26744
|
-
|
|
26745
|
-
|
|
26746
|
-
field: "state"
|
|
26747
|
-
},
|
|
26748
|
-
{
|
|
26749
|
-
cap: "presence",
|
|
26750
|
-
field: "state"
|
|
26751
|
-
},
|
|
26752
|
-
{
|
|
26753
|
-
cap: "lock",
|
|
26754
|
-
field: "locked",
|
|
26755
|
-
booleanWords: ["locked", "unlocked"]
|
|
26756
|
-
},
|
|
26757
|
-
{
|
|
26758
|
-
cap: "contact",
|
|
26759
|
-
field: "entryOpen",
|
|
26760
|
-
booleanWords: ["open", "closed"]
|
|
26761
|
-
},
|
|
26762
|
-
{
|
|
26763
|
-
cap: "switch",
|
|
26764
|
-
field: "on",
|
|
26765
|
-
booleanWords: ["on", "off"]
|
|
26766
|
-
},
|
|
26767
|
-
{
|
|
26768
|
-
cap: "binary",
|
|
26769
|
-
field: "on",
|
|
26770
|
-
booleanWords: ["on", "off"]
|
|
26348
|
+
applyCapWrite(capName, value, merge) {
|
|
26349
|
+
const schema = this.schemas.get(capName);
|
|
26350
|
+
if (!schema) throw new Error(`[DeviceRuntimeState] no schema registered for cap "${capName}" — did the device register it via ctx.registerNativeCap before writing?`);
|
|
26351
|
+
const current = this.slices.get(capName) ?? {};
|
|
26352
|
+
const next = merge ? {
|
|
26353
|
+
...current,
|
|
26354
|
+
...value
|
|
26355
|
+
} : { ...value };
|
|
26356
|
+
const parsed = schema.parse(next);
|
|
26357
|
+
if (shallowEqual(current, parsed)) return;
|
|
26358
|
+
this.slices.set(capName, parsed);
|
|
26359
|
+
this.fireListeners([capName]);
|
|
26360
|
+
const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
|
|
26361
|
+
this.pendingWrites.add(writePromise);
|
|
26362
|
+
writePromise.finally(() => {
|
|
26363
|
+
this.pendingWrites.delete(writePromise);
|
|
26364
|
+
});
|
|
26771
26365
|
}
|
|
26772
|
-
|
|
26773
|
-
|
|
26774
|
-
|
|
26775
|
-
|
|
26776
|
-
|
|
26777
|
-
|
|
26778
|
-
|
|
26779
|
-
|
|
26780
|
-
|
|
26781
|
-
|
|
26782
|
-
|
|
26783
|
-
|
|
26784
|
-
|
|
26785
|
-
|
|
26786
|
-
|
|
26366
|
+
fireListeners(changed) {
|
|
26367
|
+
const snap = this.snapshot();
|
|
26368
|
+
for (const cb of this.listeners) try {
|
|
26369
|
+
cb(changed, snap);
|
|
26370
|
+
} catch {}
|
|
26371
|
+
for (const capName of changed) {
|
|
26372
|
+
const subs = this.capListeners.get(capName);
|
|
26373
|
+
if (!subs) continue;
|
|
26374
|
+
const slice = this.getCapState(capName);
|
|
26375
|
+
for (const cb of subs) try {
|
|
26376
|
+
cb(slice);
|
|
26377
|
+
} catch {}
|
|
26378
|
+
}
|
|
26379
|
+
}
|
|
26380
|
+
subscribe(cb) {
|
|
26381
|
+
this.listeners.add(cb);
|
|
26382
|
+
return () => {
|
|
26383
|
+
this.listeners.delete(cb);
|
|
26384
|
+
};
|
|
26385
|
+
}
|
|
26386
|
+
subscribeCap(capName, cb) {
|
|
26387
|
+
let subs = this.capListeners.get(capName);
|
|
26388
|
+
if (!subs) {
|
|
26389
|
+
subs = /* @__PURE__ */ new Set();
|
|
26390
|
+
this.capListeners.set(capName, subs);
|
|
26391
|
+
}
|
|
26392
|
+
const adapter = (slice) => {
|
|
26393
|
+
cb(slice);
|
|
26394
|
+
};
|
|
26395
|
+
subs.add(adapter);
|
|
26396
|
+
return () => {
|
|
26397
|
+
const set = this.capListeners.get(capName);
|
|
26398
|
+
if (!set) return;
|
|
26399
|
+
set.delete(adapter);
|
|
26400
|
+
if (set.size === 0) this.capListeners.delete(capName);
|
|
26401
|
+
};
|
|
26402
|
+
}
|
|
26403
|
+
snapshot() {
|
|
26404
|
+
const out = {};
|
|
26405
|
+
for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
|
|
26406
|
+
return Object.freeze(out);
|
|
26787
26407
|
}
|
|
26408
|
+
async flush() {
|
|
26409
|
+
if (this.pendingWrites.size === 0) return;
|
|
26410
|
+
const inflight = [...this.pendingWrites];
|
|
26411
|
+
await Promise.allSettled(inflight);
|
|
26412
|
+
}
|
|
26413
|
+
};
|
|
26414
|
+
function shallowEqual(a, b) {
|
|
26415
|
+
const ak = Object.keys(a);
|
|
26416
|
+
const bk = Object.keys(b);
|
|
26417
|
+
if (ak.length !== bk.length) return false;
|
|
26418
|
+
for (const k of ak) if (a[k] !== b[k]) return false;
|
|
26419
|
+
return true;
|
|
26788
26420
|
}
|
|
26789
26421
|
/**
|
|
26790
|
-
*
|
|
26791
|
-
*
|
|
26792
|
-
*
|
|
26793
|
-
*
|
|
26794
|
-
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
26795
|
-
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
26796
|
-
* spawning, builds a name derived from the parent, and produces a
|
|
26797
|
-
* stableId tied to the parent so boot-restore can reconstruct the
|
|
26798
|
-
* relationship.
|
|
26799
|
-
*
|
|
26800
|
-
* Centralised `(kind → DeviceType)` mapping was dropped on purpose:
|
|
26801
|
-
* drivers may reasonably disagree on the right type for an accessory
|
|
26802
|
-
* (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
|
|
26803
|
-
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
26804
|
-
* one canonical mapping was over-prescriptive and added a layer of
|
|
26805
|
-
* indirection without saving meaningful code at call sites — the
|
|
26806
|
-
* driver knows its own hardware best.
|
|
26807
|
-
*/
|
|
26808
|
-
/**
|
|
26809
|
-
* Subset of `DeviceRole` values that drivers register as child
|
|
26810
|
-
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
26811
|
-
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
26812
|
-
* children, so the call site reads as
|
|
26813
|
-
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
26814
|
-
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
26815
|
-
* any role works, including non-accessory ones like Doorbell).
|
|
26422
|
+
* Runtime registry: cap-property-name → cap definition. `BaseDevice`'s
|
|
26423
|
+
* `state` getter looks up the cap definition here to construct a
|
|
26424
|
+
* `sliceProxy()` lazily on first access. Generated alongside the type
|
|
26425
|
+
* so type and runtime registry can never drift apart.
|
|
26816
26426
|
*/
|
|
26817
|
-
var
|
|
26818
|
-
|
|
26819
|
-
|
|
26820
|
-
|
|
26821
|
-
|
|
26822
|
-
|
|
26823
|
-
|
|
26824
|
-
|
|
26825
|
-
|
|
26427
|
+
var DEVICE_LOCAL_STATE_CAPS = {
|
|
26428
|
+
airQualitySensor: airQualitySensorCapability,
|
|
26429
|
+
alarmPanel: alarmPanelCapability,
|
|
26430
|
+
ambientLightSensor: ambientLightSensorCapability,
|
|
26431
|
+
audioMetrics: audioMetricsCapability,
|
|
26432
|
+
automationControl: automationControlCapability,
|
|
26433
|
+
battery: batteryCapability,
|
|
26434
|
+
binary: binaryCapability,
|
|
26435
|
+
brightness: brightnessCapability,
|
|
26436
|
+
cameraStreams: cameraStreamsCapability,
|
|
26437
|
+
carbonMonoxide: carbonMonoxideCapability,
|
|
26438
|
+
climateControl: climateControlCapability,
|
|
26439
|
+
color: colorCapability,
|
|
26440
|
+
connectivity: connectivityCapability,
|
|
26441
|
+
consumables: consumablesCapability,
|
|
26442
|
+
contact: contactCapability,
|
|
26443
|
+
control: controlCapability,
|
|
26444
|
+
cover: coverCapability,
|
|
26445
|
+
dayNight: dayNightCapability,
|
|
26446
|
+
deviceDiscovery: deviceDiscoveryCapability,
|
|
26447
|
+
deviceStatus: deviceStatusCapability,
|
|
26448
|
+
doorbell: doorbellCapability,
|
|
26449
|
+
enumSensor: enumSensorCapability,
|
|
26450
|
+
eventEmitter: eventEmitterCapability,
|
|
26451
|
+
fanControl: fanControlCapability,
|
|
26452
|
+
featureProbe: featureProbeCapability,
|
|
26453
|
+
flood: floodCapability,
|
|
26454
|
+
gas: gasCapability,
|
|
26455
|
+
humidifier: humidifierCapability,
|
|
26456
|
+
humiditySensor: humiditySensorCapability,
|
|
26457
|
+
image: imageCapability,
|
|
26458
|
+
imageSettings: imageSettingsCapability,
|
|
26459
|
+
lawnMowerControl: lawnMowerControlCapability,
|
|
26460
|
+
lockControl: lockControlCapability,
|
|
26461
|
+
mediaPlayer: mediaPlayerCapability,
|
|
26462
|
+
motion: motionCapability,
|
|
26463
|
+
motionTrigger: motionTriggerCapability,
|
|
26464
|
+
motionZones: motionZonesCapability,
|
|
26465
|
+
nativeObjectDetection: nativeObjectDetectionCapability,
|
|
26466
|
+
notifier: notifierCapability,
|
|
26467
|
+
numericSensor: numericSensorCapability,
|
|
26468
|
+
petFeeder: petFeederCapability,
|
|
26469
|
+
powerMeter: powerMeterCapability,
|
|
26470
|
+
presence: presenceCapability,
|
|
26471
|
+
pressureSensor: pressureSensorCapability,
|
|
26472
|
+
privacyMask: privacyMaskCapability,
|
|
26473
|
+
ptzAutotrack: ptzAutotrackCapability,
|
|
26474
|
+
sceneMonitor: sceneMonitorCapability,
|
|
26475
|
+
scriptRunner: scriptRunnerCapability,
|
|
26476
|
+
smoke: smokeCapability,
|
|
26477
|
+
streamParams: streamParamsCapability,
|
|
26478
|
+
switch: switchCapability,
|
|
26479
|
+
tamper: tamperCapability,
|
|
26480
|
+
temperatureSensor: temperatureSensorCapability,
|
|
26481
|
+
update: updateCapability,
|
|
26482
|
+
vacuumControl: vacuumControlCapability,
|
|
26483
|
+
valve: valveCapability,
|
|
26484
|
+
vibration: vibrationCapability,
|
|
26485
|
+
waterHeater: waterHeaterCapability,
|
|
26486
|
+
weather: weatherCapability,
|
|
26487
|
+
zoneAnalytics: zoneAnalyticsCapability,
|
|
26488
|
+
zoneRules: zoneRulesCapability,
|
|
26489
|
+
zones: zonesCapability
|
|
26826
26490
|
};
|
|
26827
|
-
|
|
26828
|
-
|
|
26829
|
-
|
|
26830
|
-
|
|
26831
|
-
|
|
26832
|
-
|
|
26833
|
-
|
|
26834
|
-
|
|
26835
|
-
|
|
26491
|
+
var BaseDevice = class {
|
|
26492
|
+
id;
|
|
26493
|
+
stableId;
|
|
26494
|
+
type;
|
|
26495
|
+
name;
|
|
26496
|
+
parentDeviceId;
|
|
26497
|
+
role;
|
|
26498
|
+
/**
|
|
26499
|
+
* Cap-keyed runtime-state slice is the single source of truth for
|
|
26500
|
+
* `online`. Both getter and setter proxy to the slice — drivers can
|
|
26501
|
+
* write `this.online = true` ergonomically, and the cap event fires
|
|
26502
|
+
* automatically through the runtime-state writer. `markOnline()` is
|
|
26503
|
+
* kept as the explicit method form mandated by `IDevice`.
|
|
26504
|
+
*/
|
|
26505
|
+
get online() {
|
|
26506
|
+
return this.runtimeState.getCapState("device-status")?.online ?? false;
|
|
26507
|
+
}
|
|
26508
|
+
set online(value) {
|
|
26509
|
+
this.markOnline(value);
|
|
26836
26510
|
}
|
|
26837
26511
|
/**
|
|
26838
|
-
*
|
|
26839
|
-
*
|
|
26840
|
-
*
|
|
26841
|
-
* we drop every offending top-level field, retry, and persist
|
|
26842
|
-
* the cleaned blob so the bad value is healed in the DB on next
|
|
26843
|
-
* write. The most common trigger is a tightened range constraint
|
|
26844
|
-
* (e.g. `max(100) → max(50)`) on a field that already has an
|
|
26845
|
-
* out-of-range value persisted from the previous schema. Without
|
|
26846
|
-
* this safety net, the device would fail to instantiate and end
|
|
26847
|
-
* up with no caps registered — exactly the failure mode that
|
|
26848
|
-
* stranded device 15 when `motionSensitivity: 90` no longer fit
|
|
26849
|
-
* the new `1..50` schema.
|
|
26512
|
+
* Generic per-cap runtime-state namespace. One entry per cap with
|
|
26513
|
+
* `runtimeState:` declared, auto-generated by codegen — see
|
|
26514
|
+
* `device-local-state.ts`. Drivers access via:
|
|
26850
26515
|
*
|
|
26851
|
-
*
|
|
26852
|
-
*
|
|
26853
|
-
*
|
|
26854
|
-
*
|
|
26855
|
-
*
|
|
26856
|
-
*
|
|
26857
|
-
*
|
|
26858
|
-
*
|
|
26859
|
-
*
|
|
26860
|
-
*
|
|
26861
|
-
*
|
|
26862
|
-
*
|
|
26863
|
-
*
|
|
26516
|
+
* `this.state.battery.sleeping = true` // patches the battery slice
|
|
26517
|
+
* `const pct = this.state.battery.percentage` // reads the battery slice
|
|
26518
|
+
* `this.state.deviceStatus.online = true` // mirrors `markOnline(true)`
|
|
26519
|
+
*
|
|
26520
|
+
* Adding a new cap with `runtimeState:` automatically extends this
|
|
26521
|
+
* namespace — drivers don't have to declare proxies. Reads return
|
|
26522
|
+
* `undefined` when the slice hasn't been seeded; writes patch via
|
|
26523
|
+
* `runtimeState.patchCapState` and validate against the cap's schema
|
|
26524
|
+
* (so partial writes need the slice to be seeded with the required
|
|
26525
|
+
* fields first — drivers do this on cap registration).
|
|
26526
|
+
*
|
|
26527
|
+
* For caps not exposed in `DeviceLocalState`, drivers can build their
|
|
26528
|
+
* own typed proxy via `this.sliceProxy(cap)`.
|
|
26864
26529
|
*/
|
|
26865
|
-
|
|
26866
|
-
|
|
26867
|
-
|
|
26868
|
-
|
|
26869
|
-
|
|
26870
|
-
|
|
26871
|
-
|
|
26530
|
+
get state() {
|
|
26531
|
+
if (!this._stateProxyCache) {
|
|
26532
|
+
const cache = {};
|
|
26533
|
+
const handler = { get: (_target, key) => {
|
|
26534
|
+
const k = key;
|
|
26535
|
+
if (k in cache) return cache[k];
|
|
26536
|
+
const cap = DEVICE_LOCAL_STATE_CAPS[k];
|
|
26537
|
+
if (!cap) return void 0;
|
|
26538
|
+
const proxy = this.sliceProxy(cap);
|
|
26539
|
+
cache[k] = proxy;
|
|
26540
|
+
return proxy;
|
|
26541
|
+
} };
|
|
26542
|
+
this._stateProxyCache = new Proxy(cache, handler);
|
|
26872
26543
|
}
|
|
26873
|
-
|
|
26874
|
-
|
|
26875
|
-
|
|
26876
|
-
|
|
26877
|
-
|
|
26878
|
-
|
|
26544
|
+
return this._stateProxyCache;
|
|
26545
|
+
}
|
|
26546
|
+
_stateProxyCache;
|
|
26547
|
+
config;
|
|
26548
|
+
/**
|
|
26549
|
+
* Per-device runtime state, cap-keyed. Always installed — slices
|
|
26550
|
+
* for individual caps materialise as those caps register their
|
|
26551
|
+
* native providers (`ctx.registerNativeCap`). The cap's own
|
|
26552
|
+
* `runtimeState` schema is the source of truth for the slice
|
|
26553
|
+
* shape; drivers don't redeclare it, they just write through.
|
|
26554
|
+
*
|
|
26555
|
+
* Read: `this.runtimeState.getCapState('battery')` →
|
|
26556
|
+
* `{percentage, charging, sleeping, lastUpdated}` for any
|
|
26557
|
+
* provider that registers `batteryCapability`.
|
|
26558
|
+
* Write: `this.runtimeState.setCapState('battery', { … })`.
|
|
26559
|
+
*
|
|
26560
|
+
* Cross-process consumers reach this state through the
|
|
26561
|
+
* `deviceState` cap router (or via cap-specific events the driver
|
|
26562
|
+
* emits — e.g. `battery.onStatusChanged`). The local handle is
|
|
26563
|
+
* accessed in-process by the driver to avoid roundtrips.
|
|
26564
|
+
*/
|
|
26565
|
+
runtimeState;
|
|
26566
|
+
ctx;
|
|
26567
|
+
/**
|
|
26568
|
+
* Operator-organisational location label (room / area / zone).
|
|
26569
|
+
* Read from `ctx.deviceMeta.location`; mutated via
|
|
26570
|
+
* `kernel.devices.setLocation(id, value)`. Free-text — providers
|
|
26571
|
+
* don't interpret it; the UI groups devices by this for filters
|
|
26572
|
+
* like "show me all cameras in Kitchen". `null` when unset.
|
|
26573
|
+
*/
|
|
26574
|
+
location;
|
|
26575
|
+
/**
|
|
26576
|
+
* Soft-disabled flag. When `true`, the device class is still
|
|
26577
|
+
* instantiated and visible in the UI (so the operator can flip
|
|
26578
|
+
* back on without re-adding) but lifecycle hooks (publishToBroker,
|
|
26579
|
+
* alarm-stream subscribe, …) MUST be gated by the driver to skip
|
|
26580
|
+
* work. The `BaseDevice` enforces this by exposing the flag here;
|
|
26581
|
+
* it does NOT mutate cap behaviour automatically — drivers consult
|
|
26582
|
+
* `this.disabled` at the top of their lifecycle methods. Read from
|
|
26583
|
+
* `ctx.deviceMeta.disabled`; mutated via
|
|
26584
|
+
* `kernel.devices.setDisabled(id, value)`.
|
|
26585
|
+
*/
|
|
26586
|
+
disabled;
|
|
26587
|
+
/**
|
|
26588
|
+
* Cached materialised `SourceInfo` — either the value persisted under
|
|
26589
|
+
* `metadata.sourceInfo` at construction time, or a synthetic
|
|
26590
|
+
* `{ id: stableId, system: addonId }` for providers that haven't
|
|
26591
|
+
* migrated yet. Lazily populated on first `sourceInfo` read so the
|
|
26592
|
+
* cost of Zod-parsing the meta blob is paid once per device boot.
|
|
26593
|
+
* Invalidated by `updateSourceInfo()` so providers see the new value
|
|
26594
|
+
* back through the getter without a re-fetch from the meta surface.
|
|
26595
|
+
*/
|
|
26596
|
+
_sourceInfoCache = null;
|
|
26597
|
+
constructor(ctx, schema, options) {
|
|
26598
|
+
this.ctx = ctx;
|
|
26599
|
+
this.id = ctx.id;
|
|
26600
|
+
this.stableId = ctx.stableId;
|
|
26601
|
+
this.type = options.type;
|
|
26602
|
+
if (!ctx.deviceMeta) throw new Error(`BaseDevice constructor: ctx.deviceMeta is required (id=${ctx.id} stableId=${ctx.stableId})`);
|
|
26603
|
+
this.name = ctx.deviceMeta.name;
|
|
26604
|
+
this.location = ctx.deviceMeta.location;
|
|
26605
|
+
this.disabled = ctx.deviceMeta.disabled;
|
|
26606
|
+
this.role = options.role;
|
|
26607
|
+
this.parentDeviceId = ctx.parentDeviceId;
|
|
26608
|
+
const seedData = ctx.persistedConfig ?? {};
|
|
26609
|
+
this.config = DeviceConfig.fromSchema(schema, (data) => ctx.persistConfig(data), seedData, ({ droppedKeys, issues }) => {
|
|
26610
|
+
ctx.logger.warn("Device config recovery: dropping invalid persisted fields", {
|
|
26611
|
+
tags: {
|
|
26612
|
+
deviceId: ctx.id,
|
|
26613
|
+
stableId: ctx.stableId
|
|
26614
|
+
},
|
|
26615
|
+
meta: {
|
|
26616
|
+
droppedKeys: [...droppedKeys],
|
|
26617
|
+
firstIssue: issues[0]?.message ?? null
|
|
26618
|
+
}
|
|
26619
|
+
});
|
|
26620
|
+
});
|
|
26621
|
+
let cachedProxy = null;
|
|
26622
|
+
const writer = async (capName, slice) => {
|
|
26623
|
+
if (!cachedProxy) cachedProxy = ctx.fetchDevice(ctx.id);
|
|
26624
|
+
await (await cachedProxy).deviceState.setCapSlice({
|
|
26625
|
+
capName,
|
|
26626
|
+
slice
|
|
26627
|
+
});
|
|
26628
|
+
};
|
|
26629
|
+
const initial = ctx.initialRuntimeState ?? {};
|
|
26630
|
+
this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
|
|
26631
|
+
ctx.bindRuntimeState?.(this.runtimeState);
|
|
26632
|
+
ctx.registerNativeCap?.(deviceStatusCapability, {});
|
|
26633
|
+
const seed = {
|
|
26634
|
+
online: true,
|
|
26635
|
+
lastChangedAt: Date.now()
|
|
26636
|
+
};
|
|
26637
|
+
this.runtimeState.setCapState("device-status", seed);
|
|
26638
|
+
ctx.registerNativeCap?.(featureProbeCapability, {});
|
|
26639
|
+
this.runtimeState.setCapState("feature-probe", {
|
|
26640
|
+
flags: {},
|
|
26641
|
+
deviceType: null,
|
|
26642
|
+
model: null,
|
|
26643
|
+
channelCount: null,
|
|
26644
|
+
lastProbedAt: 0,
|
|
26645
|
+
lastFetchedAt: 0
|
|
26879
26646
|
});
|
|
26880
|
-
if (second.success) {
|
|
26881
|
-
persist(second.data).catch(() => {});
|
|
26882
|
-
return new DeviceConfig(schema, second.data, persist);
|
|
26883
|
-
}
|
|
26884
|
-
return new DeviceConfig(schema, schema.parse({}), persist);
|
|
26885
26647
|
}
|
|
26886
|
-
|
|
26887
|
-
|
|
26648
|
+
deviceActions = /* @__PURE__ */ new Map();
|
|
26649
|
+
/** Declare a device custom action + its typed handler. Idempotent per name. */
|
|
26650
|
+
registerDeviceAction(name, spec, handler) {
|
|
26651
|
+
this.deviceActions.set(name, {
|
|
26652
|
+
spec,
|
|
26653
|
+
handler
|
|
26654
|
+
});
|
|
26888
26655
|
}
|
|
26889
|
-
|
|
26890
|
-
|
|
26656
|
+
/** Invoke a registered device action. Validates input against the spec. */
|
|
26657
|
+
async runDeviceAction(action, input) {
|
|
26658
|
+
const entry = this.deviceActions.get(action);
|
|
26659
|
+
if (!entry) throw new Error(`unknown device action "${action}" on device ${this.id}`);
|
|
26660
|
+
const parsed = entry.spec.input.parse(input);
|
|
26661
|
+
return entry.handler(parsed);
|
|
26891
26662
|
}
|
|
26892
|
-
async
|
|
26893
|
-
|
|
26894
|
-
|
|
26895
|
-
|
|
26896
|
-
|
|
26897
|
-
|
|
26898
|
-
|
|
26663
|
+
async removeDevice() {}
|
|
26664
|
+
/**
|
|
26665
|
+
* Set the device's online flag. Called by `BaseDeviceProvider` after
|
|
26666
|
+
* aggregating per-profile stream-broker health, or directly by drivers
|
|
26667
|
+
* that have provider-side liveness signals (e.g. ONVIF heartbeats,
|
|
26668
|
+
* Reolink Baichuan firmware push events). Mirrors the new value into
|
|
26669
|
+
* the `device-status` runtime-state slice so cross-process consumers
|
|
26670
|
+
* pick it up via the standard cap-state channel. Subclasses can
|
|
26671
|
+
* override to gate side effects on the transition.
|
|
26672
|
+
*/
|
|
26673
|
+
markOnline(online) {
|
|
26674
|
+
if (this.online === online) return;
|
|
26675
|
+
const next = {
|
|
26676
|
+
online,
|
|
26677
|
+
lastChangedAt: Date.now()
|
|
26678
|
+
};
|
|
26679
|
+
this.runtimeState.setCapState("device-status", next);
|
|
26899
26680
|
}
|
|
26900
26681
|
/**
|
|
26901
|
-
*
|
|
26902
|
-
* `
|
|
26903
|
-
*
|
|
26904
|
-
*
|
|
26905
|
-
*
|
|
26906
|
-
*
|
|
26682
|
+
* Upstream-system identity + rendering envelope for this device. See
|
|
26683
|
+
* `SourceInfo` for the field contract. Always returns a valid object:
|
|
26684
|
+
* if the persisted `metadata.sourceInfo` blob is absent or fails Zod
|
|
26685
|
+
* validation, falls back to a synthetic `{ id: stableId, system: addonId }`
|
|
26686
|
+
* so providers that haven't migrated keep working without code changes.
|
|
26687
|
+
*
|
|
26688
|
+
* The value is cached after the first read. `updateSourceInfo()`
|
|
26689
|
+
* invalidates the cache so subsequent reads see the new patch. The
|
|
26690
|
+
* returned object is frozen to prevent accidental in-place mutation —
|
|
26691
|
+
* use `updateSourceInfo({ patch })` to change fields.
|
|
26907
26692
|
*/
|
|
26908
|
-
|
|
26909
|
-
|
|
26910
|
-
|
|
26911
|
-
|
|
26693
|
+
get sourceInfo() {
|
|
26694
|
+
if (this._sourceInfoCache) return this._sourceInfoCache;
|
|
26695
|
+
const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
|
|
26696
|
+
stableId: this.stableId,
|
|
26697
|
+
addonId: this.ctx.deviceMeta.addonId
|
|
26912
26698
|
});
|
|
26913
|
-
this.
|
|
26914
|
-
|
|
26699
|
+
this._sourceInfoCache = Object.freeze({ ...resolved });
|
|
26700
|
+
return this._sourceInfoCache;
|
|
26915
26701
|
}
|
|
26916
|
-
|
|
26917
|
-
|
|
26918
|
-
|
|
26919
|
-
|
|
26920
|
-
|
|
26702
|
+
/**
|
|
26703
|
+
* Convenience accessor for the upstream dispatch key. Equivalent to
|
|
26704
|
+
* `this.sourceInfo.id` — providers use this to keep a
|
|
26705
|
+
* `Map<sourceId, IDevice>` for routing inbound push events.
|
|
26706
|
+
*/
|
|
26707
|
+
get sourceId() {
|
|
26708
|
+
return this.sourceInfo.id;
|
|
26921
26709
|
}
|
|
26922
|
-
|
|
26923
|
-
|
|
26924
|
-
|
|
26925
|
-
|
|
26926
|
-
|
|
26927
|
-
|
|
26928
|
-
|
|
26710
|
+
/**
|
|
26711
|
+
* Patch the device's `SourceInfo`. Shallow-merges `patch` over the
|
|
26712
|
+
* current value, persists the merged result under
|
|
26713
|
+
* `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
|
|
26714
|
+
* emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
|
|
26715
|
+
*
|
|
26716
|
+
* Safe to call from anywhere in the device's lifetime — the call is
|
|
26717
|
+
* idempotent for `undefined` patch values (ignored) and best-effort
|
|
26718
|
+
* for persistence (a transient device-manager error doesn't unwind
|
|
26719
|
+
* the local cache update, so subsequent reads still see the patch).
|
|
26720
|
+
*
|
|
26721
|
+
* Drivers populate this on adoption + on every metadata change push
|
|
26722
|
+
* from the upstream source. Subscribers (UI, export adapters) react
|
|
26723
|
+
* via the `DeviceSourceInfoChanged` event without polling.
|
|
26724
|
+
*/
|
|
26725
|
+
async updateSourceInfo(patch) {
|
|
26726
|
+
const next = mergeSourceInfo(this.sourceInfo, patch);
|
|
26727
|
+
this._sourceInfoCache = Object.freeze({ ...next });
|
|
26728
|
+
const action = this.ctx.api?.deviceManager?.setMetadata;
|
|
26729
|
+
if (action) try {
|
|
26730
|
+
await action.mutate({
|
|
26731
|
+
deviceId: this.id,
|
|
26732
|
+
patch: { [SOURCE_INFO_METADATA_KEY]: next }
|
|
26733
|
+
});
|
|
26734
|
+
} catch {}
|
|
26735
|
+
this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
|
|
26736
|
+
type: "device",
|
|
26737
|
+
id: this.stableId
|
|
26738
|
+
}, {
|
|
26739
|
+
deviceId: this.id,
|
|
26740
|
+
sourceInfo: next
|
|
26929
26741
|
}));
|
|
26930
26742
|
}
|
|
26931
|
-
|
|
26932
|
-
|
|
26933
|
-
*
|
|
26934
|
-
* `
|
|
26935
|
-
* `
|
|
26936
|
-
*
|
|
26937
|
-
*
|
|
26938
|
-
*
|
|
26939
|
-
*
|
|
26940
|
-
*
|
|
26941
|
-
*
|
|
26942
|
-
*
|
|
26943
|
-
*
|
|
26944
|
-
*
|
|
26945
|
-
*
|
|
26946
|
-
|
|
26947
|
-
|
|
26948
|
-
|
|
26949
|
-
|
|
26950
|
-
|
|
26951
|
-
|
|
26952
|
-
|
|
26953
|
-
|
|
26954
|
-
|
|
26955
|
-
|
|
26956
|
-
|
|
26957
|
-
|
|
26958
|
-
|
|
26959
|
-
|
|
26960
|
-
|
|
26961
|
-
|
|
26962
|
-
|
|
26963
|
-
|
|
26964
|
-
|
|
26965
|
-
installCapSchema(capName, schema) {
|
|
26966
|
-
const existing = this.schemas.get(capName);
|
|
26967
|
-
if (existing) {
|
|
26968
|
-
if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
|
|
26969
|
-
return;
|
|
26970
|
-
}
|
|
26971
|
-
this.schemas.set(capName, schema);
|
|
26972
|
-
const stored = this.slices.get(capName);
|
|
26973
|
-
if (stored) {
|
|
26974
|
-
const result = schema.safeParse(stored);
|
|
26975
|
-
if (result.success) this.slices.set(capName, result.data);
|
|
26976
|
-
else this.slices.delete(capName);
|
|
26977
|
-
}
|
|
26978
|
-
}
|
|
26979
|
-
getCapState(capName) {
|
|
26980
|
-
const slice = this.slices.get(capName);
|
|
26981
|
-
if (!slice) return void 0;
|
|
26982
|
-
return Object.freeze({ ...slice });
|
|
26983
|
-
}
|
|
26984
|
-
getCapField(capName, key) {
|
|
26985
|
-
return this.slices.get(capName)?.[key];
|
|
26743
|
+
/**
|
|
26744
|
+
* Re-publish the device's current `features` array to the persisted
|
|
26745
|
+
* meta blob. Drivers call this after a probe finishes when the live
|
|
26746
|
+
* `features` getter has gained new flags (e.g. `hasIntercom` flips
|
|
26747
|
+
* to true → `DeviceFeature.TwoWayAudio` joins the list).
|
|
26748
|
+
*
|
|
26749
|
+
* Without this, only the construction-time snapshot is written —
|
|
26750
|
+
* `deviceManager.registerDevice` is invoked once per boot, so probe-
|
|
26751
|
+
* driven additions don't reach the persisted index until the next
|
|
26752
|
+
* server restart, and `getDevice` / `listAll` keep returning the
|
|
26753
|
+
* stale list for forked-worker devices (whose live IDevice instance
|
|
26754
|
+
* is invisible to the hub registry).
|
|
26755
|
+
*
|
|
26756
|
+
* Idempotent: re-calling with the same features just no-ops on the
|
|
26757
|
+
* persisted meta. Best-effort: lookup or write failures are logged
|
|
26758
|
+
* at debug and swallowed — the live `device.features` getter is
|
|
26759
|
+
* still authoritative within this process, so callers never block
|
|
26760
|
+
* device boot on a meta refresh.
|
|
26761
|
+
*/
|
|
26762
|
+
async refreshFeatures() {
|
|
26763
|
+
const action = this.ctx.api?.deviceManager?.registerDevice;
|
|
26764
|
+
if (!action) return;
|
|
26765
|
+
try {
|
|
26766
|
+
await action.mutate({
|
|
26767
|
+
addonId: this.ctx.deviceMeta.addonId,
|
|
26768
|
+
stableId: this.stableId,
|
|
26769
|
+
id: this.id,
|
|
26770
|
+
type: this.type,
|
|
26771
|
+
name: this.name,
|
|
26772
|
+
parentDeviceId: this.parentDeviceId,
|
|
26773
|
+
features: [...this.features],
|
|
26774
|
+
config: {}
|
|
26775
|
+
});
|
|
26776
|
+
} catch (err) {}
|
|
26986
26777
|
}
|
|
26987
|
-
|
|
26988
|
-
|
|
26778
|
+
/**
|
|
26779
|
+
* Typed read-through to a cap-keyed runtime-state slice. Drivers
|
|
26780
|
+
* call `this.getCapSlice(batteryCapability)` and the return type
|
|
26781
|
+
* is inferred from the cap's `runtimeState` Zod schema — no string
|
|
26782
|
+
* key, no manual generic. Returns `null` when the slice hasn't
|
|
26783
|
+
* been written yet (e.g. driver hasn't seeded battery yet).
|
|
26784
|
+
*/
|
|
26785
|
+
getCapSlice(cap) {
|
|
26786
|
+
return this.runtimeState.getCapState(cap.name) ?? null;
|
|
26989
26787
|
}
|
|
26990
|
-
|
|
26991
|
-
|
|
26788
|
+
/**
|
|
26789
|
+
* Typed writer to a cap-keyed runtime-state slice. Routes through
|
|
26790
|
+
* the runtime-state writer (validate → persist → emit cap event).
|
|
26791
|
+
* Equivalent to `this.runtimeState.setCapState(cap.name, value)`
|
|
26792
|
+
* but with the cap's `runtimeState` schema enforcing the value
|
|
26793
|
+
* shape at compile time. Mirrors the symmetry of
|
|
26794
|
+
* `getCapSlice` / `setCapSlice` for cross-cap consistency.
|
|
26795
|
+
*/
|
|
26796
|
+
setCapSlice(cap, value) {
|
|
26797
|
+
this.runtimeState.setCapState(cap.name, value);
|
|
26992
26798
|
}
|
|
26993
26799
|
/**
|
|
26994
|
-
*
|
|
26995
|
-
*
|
|
26996
|
-
*
|
|
26800
|
+
* Field-level read/write proxy over a cap's runtime-state slice.
|
|
26801
|
+
* Drivers that want ergonomic per-field access declare:
|
|
26802
|
+
*
|
|
26803
|
+
* ```ts
|
|
26804
|
+
* protected battery = this.sliceProxy(batteryCapability)
|
|
26805
|
+
* // …
|
|
26806
|
+
* this.battery.sleeping = true // patches the slice
|
|
26807
|
+
* const charging = this.battery.charging // reads the slice
|
|
26808
|
+
* ```
|
|
26809
|
+
*
|
|
26810
|
+
* Reads return `undefined` when the slice hasn't been seeded yet
|
|
26811
|
+
* (cap not registered, or seeded but the field is absent). Writes
|
|
26812
|
+
* route through `runtimeState.patchCapState` so the cap's `runtimeState`
|
|
26813
|
+
* schema validates the merged result and the cap event fires.
|
|
26814
|
+
*
|
|
26815
|
+
* Pattern is generic — same shape works for `battery`, `device-status`,
|
|
26816
|
+
* `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
|
|
26817
|
+
* declare one proxy per cap they read/write directly.
|
|
26997
26818
|
*/
|
|
26998
|
-
|
|
26999
|
-
|
|
27000
|
-
|
|
27001
|
-
|
|
27002
|
-
|
|
27003
|
-
|
|
27004
|
-
|
|
27005
|
-
|
|
27006
|
-
|
|
27007
|
-
|
|
27008
|
-
|
|
27009
|
-
|
|
27010
|
-
|
|
27011
|
-
|
|
27012
|
-
|
|
27013
|
-
|
|
26819
|
+
sliceProxy(cap) {
|
|
26820
|
+
return new Proxy({}, {
|
|
26821
|
+
get: (_, key) => {
|
|
26822
|
+
return this.runtimeState.getCapState(cap.name)?.[key];
|
|
26823
|
+
},
|
|
26824
|
+
set: (_, key, value) => {
|
|
26825
|
+
this.runtimeState.patchCapState(cap.name, { [key]: value });
|
|
26826
|
+
return true;
|
|
26827
|
+
},
|
|
26828
|
+
has: (_, key) => {
|
|
26829
|
+
const slice = this.runtimeState.getCapState(cap.name);
|
|
26830
|
+
return slice ? key in slice : false;
|
|
26831
|
+
},
|
|
26832
|
+
ownKeys: () => {
|
|
26833
|
+
const slice = this.runtimeState.getCapState(cap.name);
|
|
26834
|
+
return slice ? Object.keys(slice) : [];
|
|
26835
|
+
},
|
|
26836
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
26837
|
+
const slice = this.runtimeState.getCapState(cap.name);
|
|
26838
|
+
if (!slice || !(key in slice)) return void 0;
|
|
26839
|
+
return {
|
|
26840
|
+
configurable: true,
|
|
26841
|
+
enumerable: true,
|
|
26842
|
+
value: slice[key]
|
|
26843
|
+
};
|
|
26844
|
+
}
|
|
27014
26845
|
});
|
|
27015
26846
|
}
|
|
27016
|
-
|
|
27017
|
-
|
|
27018
|
-
|
|
27019
|
-
|
|
27020
|
-
|
|
27021
|
-
|
|
27022
|
-
|
|
27023
|
-
|
|
27024
|
-
const slice = this.getCapState(capName);
|
|
27025
|
-
for (const cb of subs) try {
|
|
27026
|
-
cb(slice);
|
|
27027
|
-
} catch {}
|
|
27028
|
-
}
|
|
26847
|
+
/**
|
|
26848
|
+
* Default empty settings UI. Drivers override this to expose an
|
|
26849
|
+
* editable form in the device-details page. Returning an empty sections
|
|
26850
|
+
* array signals "nothing to contribute" — the aggregator drops the
|
|
26851
|
+
* contribution entirely rather than rendering a blank panel.
|
|
26852
|
+
*/
|
|
26853
|
+
getSettingsUISchema() {
|
|
26854
|
+
return { sections: [] };
|
|
27029
26855
|
}
|
|
27030
|
-
|
|
27031
|
-
|
|
27032
|
-
|
|
27033
|
-
|
|
27034
|
-
|
|
26856
|
+
/**
|
|
26857
|
+
* Default write path: forward the flat patch directly to storage.
|
|
26858
|
+
* Drivers that project a UI shape different from storage (e.g. `RtspCamera`
|
|
26859
|
+
* exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
|
|
26860
|
+
* to reshape before `config.setAll`.
|
|
26861
|
+
*/
|
|
26862
|
+
async applySettingsPatch(patch) {
|
|
26863
|
+
await this.config.setAll(patch);
|
|
27035
26864
|
}
|
|
27036
|
-
|
|
27037
|
-
|
|
27038
|
-
|
|
27039
|
-
|
|
27040
|
-
|
|
27041
|
-
|
|
27042
|
-
|
|
27043
|
-
|
|
27044
|
-
|
|
27045
|
-
|
|
27046
|
-
|
|
27047
|
-
|
|
27048
|
-
|
|
27049
|
-
|
|
27050
|
-
|
|
26865
|
+
/**
|
|
26866
|
+
* Phase 3 — populate device-scoped state needed by downstream phases
|
|
26867
|
+
* (accessory reconciliation, public `features` array, optional cap
|
|
26868
|
+
* registration). Called ONCE per construction, after register but
|
|
26869
|
+
* before `getAccessoryChildren()`.
|
|
26870
|
+
*
|
|
26871
|
+
* Drivers write the `feature-probe` runtime-state slice via
|
|
26872
|
+
* `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
|
|
26873
|
+
* is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
|
|
26874
|
+
* `hasSupplementalLight/hasAlarmIo`, etc).
|
|
26875
|
+
*
|
|
26876
|
+
* Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
|
|
26877
|
+
* the kernel treats it as ready immediately. A device that derives its shape
|
|
26878
|
+
* from a spec (a container, or an accessory sensor) rather than from a
|
|
26879
|
+
* hardware probe has no probe to "complete"; without stamping `lastProbedAt`
|
|
26880
|
+
* it would look perpetually un-probed — logging "Initial probe did not
|
|
26881
|
+
* complete" on every boot and spinning a pointless retry chain. Drivers that
|
|
26882
|
+
* DO probe override this and write their own `feature-probe` slice (including
|
|
26883
|
+
* `lastProbedAt`) once their probe actually succeeds.
|
|
26884
|
+
*/
|
|
26885
|
+
async onProbe() {
|
|
26886
|
+
const base = this.runtimeState.getCapState("feature-probe") ?? {
|
|
26887
|
+
flags: {},
|
|
26888
|
+
deviceType: null,
|
|
26889
|
+
model: null,
|
|
26890
|
+
channelCount: null,
|
|
26891
|
+
lastProbedAt: 0,
|
|
26892
|
+
lastFetchedAt: 0
|
|
27051
26893
|
};
|
|
26894
|
+
this.runtimeState.setCapState("feature-probe", {
|
|
26895
|
+
...base,
|
|
26896
|
+
lastProbedAt: Date.now()
|
|
26897
|
+
});
|
|
27052
26898
|
}
|
|
27053
|
-
|
|
27054
|
-
|
|
27055
|
-
|
|
27056
|
-
|
|
26899
|
+
/**
|
|
26900
|
+
* Phase 5 — fired after the device + its accessories are registered.
|
|
26901
|
+
* Drivers publish streams to the broker, kick off background tasks,
|
|
26902
|
+
* or subscribe to lib events that need a fully-registered device id.
|
|
26903
|
+
*
|
|
26904
|
+
* Default: no-op.
|
|
26905
|
+
*
|
|
26906
|
+
* RENAMED FROM `onCreated` (which still exists for back-compat in this
|
|
26907
|
+
* pass). The new name reflects the post-probe, post-accessory contract.
|
|
26908
|
+
*/
|
|
26909
|
+
async onActivate() {}
|
|
26910
|
+
/**
|
|
26911
|
+
* Re-run the probe + reconcile accessories + refresh features meta.
|
|
26912
|
+
* Drivers call this when device-side state changes (battery cam wakes,
|
|
26913
|
+
* firmware update, manual operator trigger).
|
|
26914
|
+
*
|
|
26915
|
+
* The kernel injects `_kernelReprobe` on registration so this method
|
|
26916
|
+
* delegates to the same orchestrator that runs the boot-time phase
|
|
26917
|
+
* 3 + 4 sequence. Drivers should NOT override this — they override
|
|
26918
|
+
* `onProbe()` instead.
|
|
26919
|
+
*/
|
|
26920
|
+
async reprobe() {
|
|
26921
|
+
if (this._kernelReprobe) await this._kernelReprobe();
|
|
26922
|
+
else await this.onProbe();
|
|
27057
26923
|
}
|
|
27058
|
-
|
|
27059
|
-
|
|
27060
|
-
|
|
27061
|
-
|
|
26924
|
+
/**
|
|
26925
|
+
* Kernel-injected callback that runs the full post-probe orchestration
|
|
26926
|
+
* (onProbe → registerDevice meta refresh → accessory reconciliation).
|
|
26927
|
+
* Set by `device-cap-proxy.register()`. Drivers should not touch this
|
|
26928
|
+
* directly — call `reprobe()` instead.
|
|
26929
|
+
*/
|
|
26930
|
+
_kernelReprobe;
|
|
26931
|
+
/**
|
|
26932
|
+
* Declare accessory child devices the kernel should auto-spawn
|
|
26933
|
+
* after `onProbe()` resolves. Each spec fully describes one child
|
|
26934
|
+
* — stableId suffix (deterministic per kind for restore-safety),
|
|
26935
|
+
* meta (type / name / location), config (initial blob the child
|
|
26936
|
+
* self-hydrates), and a factory that constructs the concrete
|
|
26937
|
+
* class with whatever closure-captured refs it needs (typically
|
|
26938
|
+
* `this` for the parent reference).
|
|
26939
|
+
*
|
|
26940
|
+
* The kernel handles the rest: allocateDeviceId, persistInitialConfig
|
|
26941
|
+
* (skipped on restore when the row already exists),
|
|
26942
|
+
* persistInitialMeta, createContext, factory invocation, register,
|
|
26943
|
+
* and recursive lifecycle (probe + accessories + activate).
|
|
26944
|
+
*
|
|
26945
|
+
* Implementations should derive children from
|
|
26946
|
+
* `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
|
|
26947
|
+
* Drivers can use the `getProbeFlags()` helper to read the flag bag
|
|
26948
|
+
* with a typed cast.
|
|
26949
|
+
*
|
|
26950
|
+
* Default: no children.
|
|
26951
|
+
*/
|
|
26952
|
+
getAccessoryChildren() {
|
|
26953
|
+
return [];
|
|
27062
26954
|
}
|
|
27063
|
-
|
|
27064
|
-
|
|
27065
|
-
|
|
27066
|
-
|
|
27067
|
-
|
|
27068
|
-
|
|
27069
|
-
|
|
26955
|
+
/**
|
|
26956
|
+
* Read the current feature-probe flag bag with a typed cast. Helper
|
|
26957
|
+
* for `getAccessoryChildren()` and `features` getters that derive
|
|
26958
|
+
* outputs from the probe results.
|
|
26959
|
+
*/
|
|
26960
|
+
getProbeFlags() {
|
|
26961
|
+
return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
|
|
26962
|
+
}
|
|
26963
|
+
/**
|
|
26964
|
+
* Returns true once `onProbe` has completed at least once
|
|
26965
|
+
* (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
|
|
26966
|
+
* to avoid spawning stale accessories on a fresh device whose probe
|
|
26967
|
+
* hasn't landed yet.
|
|
26968
|
+
*/
|
|
26969
|
+
hasProbed() {
|
|
26970
|
+
return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
|
|
26971
|
+
}
|
|
26972
|
+
};
|
|
26973
|
+
/** Marker written to a declared integration's `info`. */
|
|
26974
|
+
var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
|
|
26975
|
+
/**
|
|
26976
|
+
* Strip the `<node>/<addon>` suffix a forked child carries.
|
|
26977
|
+
*
|
|
26978
|
+
* Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
|
|
26979
|
+
* that was supposed to act — because on the hub it reads `hub/<addon>`.
|
|
26980
|
+
*/
|
|
26981
|
+
function declarationOwnerNodeId(localNodeId) {
|
|
26982
|
+
const raw = localNodeId ?? "hub";
|
|
26983
|
+
if (!raw.includes("/")) return raw;
|
|
26984
|
+
return raw.split("/")[0] ?? "hub";
|
|
27070
26985
|
}
|
|
27071
26986
|
/**
|
|
27072
|
-
*
|
|
27073
|
-
*
|
|
27074
|
-
*
|
|
27075
|
-
*
|
|
26987
|
+
* The one way an addon owns a device it declares.
|
|
26988
|
+
*
|
|
26989
|
+
* Construct once with the addon's ports, then call {@link reconcile} on boot and
|
|
26990
|
+
* on every convergence tick. There is no second get-or-create helper — a guard
|
|
26991
|
+
* in `scripts/` enforces that.
|
|
27076
26992
|
*/
|
|
27077
|
-
var
|
|
27078
|
-
|
|
27079
|
-
|
|
27080
|
-
|
|
27081
|
-
|
|
27082
|
-
automationControl: automationControlCapability,
|
|
27083
|
-
battery: batteryCapability,
|
|
27084
|
-
binary: binaryCapability,
|
|
27085
|
-
brightness: brightnessCapability,
|
|
27086
|
-
cameraStreams: cameraStreamsCapability,
|
|
27087
|
-
carbonMonoxide: carbonMonoxideCapability,
|
|
27088
|
-
climateControl: climateControlCapability,
|
|
27089
|
-
color: colorCapability,
|
|
27090
|
-
connectivity: connectivityCapability,
|
|
27091
|
-
consumables: consumablesCapability,
|
|
27092
|
-
contact: contactCapability,
|
|
27093
|
-
control: controlCapability,
|
|
27094
|
-
cover: coverCapability,
|
|
27095
|
-
dayNight: dayNightCapability,
|
|
27096
|
-
deviceDiscovery: deviceDiscoveryCapability,
|
|
27097
|
-
deviceStatus: deviceStatusCapability,
|
|
27098
|
-
doorbell: doorbellCapability,
|
|
27099
|
-
enumSensor: enumSensorCapability,
|
|
27100
|
-
eventEmitter: eventEmitterCapability,
|
|
27101
|
-
fanControl: fanControlCapability,
|
|
27102
|
-
featureProbe: featureProbeCapability,
|
|
27103
|
-
flood: floodCapability,
|
|
27104
|
-
gas: gasCapability,
|
|
27105
|
-
humidifier: humidifierCapability,
|
|
27106
|
-
humiditySensor: humiditySensorCapability,
|
|
27107
|
-
image: imageCapability,
|
|
27108
|
-
imageSettings: imageSettingsCapability,
|
|
27109
|
-
lawnMowerControl: lawnMowerControlCapability,
|
|
27110
|
-
lockControl: lockControlCapability,
|
|
27111
|
-
mediaPlayer: mediaPlayerCapability,
|
|
27112
|
-
motion: motionCapability,
|
|
27113
|
-
motionTrigger: motionTriggerCapability,
|
|
27114
|
-
motionZones: motionZonesCapability,
|
|
27115
|
-
nativeObjectDetection: nativeObjectDetectionCapability,
|
|
27116
|
-
notifier: notifierCapability,
|
|
27117
|
-
numericSensor: numericSensorCapability,
|
|
27118
|
-
petFeeder: petFeederCapability,
|
|
27119
|
-
powerMeter: powerMeterCapability,
|
|
27120
|
-
presence: presenceCapability,
|
|
27121
|
-
pressureSensor: pressureSensorCapability,
|
|
27122
|
-
privacyMask: privacyMaskCapability,
|
|
27123
|
-
ptzAutotrack: ptzAutotrackCapability,
|
|
27124
|
-
sceneMonitor: sceneMonitorCapability,
|
|
27125
|
-
scriptRunner: scriptRunnerCapability,
|
|
27126
|
-
smoke: smokeCapability,
|
|
27127
|
-
streamParams: streamParamsCapability,
|
|
27128
|
-
switch: switchCapability,
|
|
27129
|
-
tamper: tamperCapability,
|
|
27130
|
-
temperatureSensor: temperatureSensorCapability,
|
|
27131
|
-
update: updateCapability,
|
|
27132
|
-
vacuumControl: vacuumControlCapability,
|
|
27133
|
-
valve: valveCapability,
|
|
27134
|
-
vibration: vibrationCapability,
|
|
27135
|
-
waterHeater: waterHeaterCapability,
|
|
27136
|
-
weather: weatherCapability,
|
|
27137
|
-
zoneAnalytics: zoneAnalyticsCapability,
|
|
27138
|
-
zoneRules: zoneRulesCapability,
|
|
27139
|
-
zones: zonesCapability
|
|
27140
|
-
};
|
|
27141
|
-
var BaseDevice = class {
|
|
27142
|
-
id;
|
|
27143
|
-
stableId;
|
|
27144
|
-
type;
|
|
27145
|
-
name;
|
|
27146
|
-
parentDeviceId;
|
|
27147
|
-
role;
|
|
26993
|
+
var DeclaredDevices = class {
|
|
26994
|
+
ports;
|
|
26995
|
+
constructor(ports) {
|
|
26996
|
+
this.ports = ports;
|
|
26997
|
+
}
|
|
27148
26998
|
/**
|
|
27149
|
-
*
|
|
27150
|
-
*
|
|
27151
|
-
*
|
|
27152
|
-
*
|
|
27153
|
-
*
|
|
27154
|
-
*/
|
|
27155
|
-
|
|
27156
|
-
|
|
26999
|
+
* Converge the declared set. Idempotent, and safe to call repeatedly.
|
|
27000
|
+
*
|
|
27001
|
+
* Throws only what the ports throw on the FIRST index read; every other
|
|
27002
|
+
* failure is per-device and logged, so one bad declaration never takes the
|
|
27003
|
+
* others down.
|
|
27004
|
+
*/
|
|
27005
|
+
async reconcile(spec) {
|
|
27006
|
+
if ((spec.placement ?? "hub") === "hub") {
|
|
27007
|
+
const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
|
|
27008
|
+
if (nodeId !== "hub") {
|
|
27009
|
+
this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
|
|
27010
|
+
nodeId,
|
|
27011
|
+
rawNodeId: this.ports.localNodeId ?? null
|
|
27012
|
+
} });
|
|
27013
|
+
return {
|
|
27014
|
+
integrationId: null,
|
|
27015
|
+
devices: [],
|
|
27016
|
+
removed: [],
|
|
27017
|
+
owned: false
|
|
27018
|
+
};
|
|
27019
|
+
}
|
|
27020
|
+
}
|
|
27021
|
+
const integrationId = await this.ensureIntegration(spec.integrationName);
|
|
27022
|
+
const index = await this.readIndex();
|
|
27023
|
+
const outcomes = [];
|
|
27024
|
+
for (const declaration of spec.devices) {
|
|
27025
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
27026
|
+
if (outcome !== null) outcomes.push(outcome);
|
|
27027
|
+
}
|
|
27028
|
+
return {
|
|
27029
|
+
integrationId,
|
|
27030
|
+
devices: outcomes,
|
|
27031
|
+
removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
|
|
27032
|
+
owned: true
|
|
27033
|
+
};
|
|
27157
27034
|
}
|
|
27158
|
-
|
|
27159
|
-
|
|
27035
|
+
/**
|
|
27036
|
+
* Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
|
|
27037
|
+
*
|
|
27038
|
+
* The re-assertion is the fix for the defect the hand-rolled version shipped
|
|
27039
|
+
* with: writing `info.fixed` only on the create path left every pre-existing
|
|
27040
|
+
* install without it, and the kernel kept offering to delete an integration
|
|
27041
|
+
* the addon owns.
|
|
27042
|
+
*/
|
|
27043
|
+
async ensureIntegration(integrationName) {
|
|
27044
|
+
const existing = await this.ports.getIntegration(this.ports.addonId);
|
|
27045
|
+
if (existing === null) {
|
|
27046
|
+
const created = await this.ports.createIntegration({
|
|
27047
|
+
addonId: this.ports.addonId,
|
|
27048
|
+
name: integrationName,
|
|
27049
|
+
info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
|
|
27050
|
+
});
|
|
27051
|
+
this.ports.logger.info("declared a fixed integration", { meta: {
|
|
27052
|
+
integrationId: created.id,
|
|
27053
|
+
name: integrationName
|
|
27054
|
+
} });
|
|
27055
|
+
return created.id;
|
|
27056
|
+
}
|
|
27057
|
+
if (existing.info?.["fixed"] !== true) {
|
|
27058
|
+
await this.ports.updateIntegration({
|
|
27059
|
+
id: existing.id,
|
|
27060
|
+
info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
|
|
27061
|
+
});
|
|
27062
|
+
this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
|
|
27063
|
+
}
|
|
27064
|
+
return existing.id;
|
|
27065
|
+
}
|
|
27066
|
+
async readIndex() {
|
|
27067
|
+
const rows = await this.ports.listOwnDevices();
|
|
27068
|
+
return new Map(rows.map((row) => [row.stableId, row]));
|
|
27160
27069
|
}
|
|
27161
27070
|
/**
|
|
27162
|
-
*
|
|
27163
|
-
* `runtimeState:` declared, auto-generated by codegen — see
|
|
27164
|
-
* `device-local-state.ts`. Drivers access via:
|
|
27071
|
+
* One declaration: adopt what exists, create what does not.
|
|
27165
27072
|
*
|
|
27166
|
-
*
|
|
27167
|
-
*
|
|
27168
|
-
*
|
|
27073
|
+
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
27074
|
+
* `initialMeta.name` lands as an unconditional `setName`. A transiently empty
|
|
27075
|
+
* index therefore looks exactly like a first boot and would silently re-stamp
|
|
27076
|
+
* the declared name over the operator's rename. D49: that branch needs a
|
|
27077
|
+
* second read to agree.
|
|
27078
|
+
*/
|
|
27079
|
+
async applyDeclaration(declaration, integrationId, index) {
|
|
27080
|
+
try {
|
|
27081
|
+
let existing = index.get(declaration.stableId);
|
|
27082
|
+
if (existing === void 0) {
|
|
27083
|
+
existing = (await this.readIndex()).get(declaration.stableId);
|
|
27084
|
+
if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
|
|
27085
|
+
tags: { deviceId: existing.id },
|
|
27086
|
+
meta: {
|
|
27087
|
+
stableId: declaration.stableId,
|
|
27088
|
+
addonId: this.ports.addonId
|
|
27089
|
+
}
|
|
27090
|
+
});
|
|
27091
|
+
}
|
|
27092
|
+
if (existing !== void 0) {
|
|
27093
|
+
const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
|
|
27094
|
+
this.ports.logger.info("declared device adopted", {
|
|
27095
|
+
tags: { deviceId: device.id },
|
|
27096
|
+
meta: {
|
|
27097
|
+
stableId: declaration.stableId,
|
|
27098
|
+
integrationId
|
|
27099
|
+
}
|
|
27100
|
+
});
|
|
27101
|
+
return {
|
|
27102
|
+
stableId: declaration.stableId,
|
|
27103
|
+
deviceId: device.id,
|
|
27104
|
+
device,
|
|
27105
|
+
created: false
|
|
27106
|
+
};
|
|
27107
|
+
}
|
|
27108
|
+
const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
|
|
27109
|
+
type: declaration.type,
|
|
27110
|
+
name: declaration.name,
|
|
27111
|
+
integrationId,
|
|
27112
|
+
...declaration.role === void 0 ? {} : { role: declaration.role }
|
|
27113
|
+
});
|
|
27114
|
+
this.ports.logger.info("declared device created", {
|
|
27115
|
+
tags: { deviceId: device.id },
|
|
27116
|
+
meta: {
|
|
27117
|
+
stableId: declaration.stableId,
|
|
27118
|
+
integrationId
|
|
27119
|
+
}
|
|
27120
|
+
});
|
|
27121
|
+
return {
|
|
27122
|
+
stableId: declaration.stableId,
|
|
27123
|
+
deviceId: device.id,
|
|
27124
|
+
device,
|
|
27125
|
+
created: true
|
|
27126
|
+
};
|
|
27127
|
+
} catch (err) {
|
|
27128
|
+
this.ports.logger.warn("a declared device could not be brought up", { meta: {
|
|
27129
|
+
stableId: declaration.stableId,
|
|
27130
|
+
error: err instanceof Error ? err.message : String(err)
|
|
27131
|
+
} });
|
|
27132
|
+
return null;
|
|
27133
|
+
}
|
|
27134
|
+
}
|
|
27135
|
+
/**
|
|
27136
|
+
* Remove rows under the addon's FIXED integration whose declaration is gone.
|
|
27169
27137
|
*
|
|
27170
|
-
*
|
|
27171
|
-
*
|
|
27172
|
-
*
|
|
27173
|
-
* `runtimeState.patchCapState` and validate against the cap's schema
|
|
27174
|
-
* (so partial writes need the slice to be seeded with the required
|
|
27175
|
-
* fields first — drivers do this on cap registration).
|
|
27138
|
+
* Bounded to that integration: a declared integration has no operator
|
|
27139
|
+
* add-flow, so every row under it got there by declaration. Devices this
|
|
27140
|
+
* addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
|
|
27176
27141
|
*
|
|
27177
|
-
*
|
|
27178
|
-
*
|
|
27179
|
-
|
|
27180
|
-
|
|
27181
|
-
|
|
27182
|
-
|
|
27183
|
-
|
|
27184
|
-
|
|
27185
|
-
|
|
27186
|
-
|
|
27187
|
-
|
|
27188
|
-
|
|
27189
|
-
|
|
27190
|
-
|
|
27191
|
-
|
|
27192
|
-
|
|
27142
|
+
* Bounded in count, and every deletion is logged with its `deviceId` — a
|
|
27143
|
+
* withdrawal that removes an operator-visible row silently is the failure
|
|
27144
|
+
* mode, not the removal itself.
|
|
27145
|
+
*/
|
|
27146
|
+
async sweepWithdrawn(declarations, integrationId, index) {
|
|
27147
|
+
const declared = new Set(declarations.map((d) => d.stableId));
|
|
27148
|
+
const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
|
|
27149
|
+
if (candidates.length === 0) return [];
|
|
27150
|
+
if (candidates.length > 32) {
|
|
27151
|
+
this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
|
|
27152
|
+
integrationId,
|
|
27153
|
+
candidates: candidates.length,
|
|
27154
|
+
bound: 32
|
|
27155
|
+
} });
|
|
27156
|
+
return [];
|
|
27157
|
+
}
|
|
27158
|
+
const removed = [];
|
|
27159
|
+
for (const row of candidates) try {
|
|
27160
|
+
await this.ports.devices.remove(row.id);
|
|
27161
|
+
removed.push(row.id);
|
|
27162
|
+
this.ports.logger.info("declared device removed — its declaration was withdrawn", {
|
|
27163
|
+
tags: { deviceId: row.id },
|
|
27164
|
+
meta: {
|
|
27165
|
+
stableId: row.stableId,
|
|
27166
|
+
integrationId
|
|
27167
|
+
}
|
|
27168
|
+
});
|
|
27169
|
+
} catch (err) {
|
|
27170
|
+
this.ports.logger.warn("a withdrawn declared device could not be removed", {
|
|
27171
|
+
tags: { deviceId: row.id },
|
|
27172
|
+
meta: {
|
|
27173
|
+
stableId: row.stableId,
|
|
27174
|
+
error: err instanceof Error ? err.message : String(err)
|
|
27175
|
+
}
|
|
27176
|
+
});
|
|
27177
|
+
}
|
|
27178
|
+
return removed;
|
|
27179
|
+
}
|
|
27180
|
+
};
|
|
27181
|
+
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
|
|
27182
|
+
new Set(Object.values(DeviceType));
|
|
27183
|
+
DeviceFeature.BatteryOperated;
|
|
27184
|
+
/**
|
|
27185
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
27186
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
27187
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
27188
|
+
*/
|
|
27189
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
27190
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
27191
|
+
var ExpressionParseError = class extends Error {
|
|
27192
|
+
position;
|
|
27193
|
+
constructor(message, position) {
|
|
27194
|
+
super(message);
|
|
27195
|
+
this.name = "ExpressionParseError";
|
|
27196
|
+
this.position = position;
|
|
27197
|
+
}
|
|
27198
|
+
};
|
|
27199
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
27200
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
27201
|
+
var ExpressionEvalError = class extends Error {
|
|
27202
|
+
constructor(message) {
|
|
27203
|
+
super(message);
|
|
27204
|
+
this.name = "ExpressionEvalError";
|
|
27205
|
+
}
|
|
27206
|
+
};
|
|
27207
|
+
/**
|
|
27208
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
27209
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
27210
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
27211
|
+
* own-property check against it.
|
|
27212
|
+
*
|
|
27213
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
27214
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
27215
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
27216
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
27217
|
+
* callable — they are simply "unknown function" at parse time.
|
|
27218
|
+
*
|
|
27219
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
27220
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
27221
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
27222
|
+
* closed rather than emitting a garbage value.
|
|
27223
|
+
*/
|
|
27224
|
+
function asFiniteNumber(value, name, index) {
|
|
27225
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
27226
|
+
return value;
|
|
27227
|
+
}
|
|
27228
|
+
function asString$1(value, name, index) {
|
|
27229
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
27230
|
+
return value;
|
|
27231
|
+
}
|
|
27232
|
+
function finiteResult(value, name) {
|
|
27233
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
27234
|
+
return value;
|
|
27235
|
+
}
|
|
27236
|
+
function allFiniteNumbers(args, name) {
|
|
27237
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
27238
|
+
}
|
|
27239
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
27240
|
+
var table = {
|
|
27241
|
+
min: {
|
|
27242
|
+
minArgs: 1,
|
|
27243
|
+
maxArgs: INF,
|
|
27244
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
27245
|
+
},
|
|
27246
|
+
max: {
|
|
27247
|
+
minArgs: 1,
|
|
27248
|
+
maxArgs: INF,
|
|
27249
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
27250
|
+
},
|
|
27251
|
+
abs: {
|
|
27252
|
+
minArgs: 1,
|
|
27253
|
+
maxArgs: 1,
|
|
27254
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
27255
|
+
},
|
|
27256
|
+
floor: {
|
|
27257
|
+
minArgs: 1,
|
|
27258
|
+
maxArgs: 1,
|
|
27259
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
27260
|
+
},
|
|
27261
|
+
ceil: {
|
|
27262
|
+
minArgs: 1,
|
|
27263
|
+
maxArgs: 1,
|
|
27264
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
27265
|
+
},
|
|
27266
|
+
sqrt: {
|
|
27267
|
+
minArgs: 1,
|
|
27268
|
+
maxArgs: 1,
|
|
27269
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
27270
|
+
},
|
|
27271
|
+
round: {
|
|
27272
|
+
minArgs: 1,
|
|
27273
|
+
maxArgs: 2,
|
|
27274
|
+
apply: (args) => {
|
|
27275
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
27276
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
27277
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
27278
|
+
const factor = 10 ** digits;
|
|
27279
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
27280
|
+
}
|
|
27281
|
+
},
|
|
27282
|
+
pow: {
|
|
27283
|
+
minArgs: 2,
|
|
27284
|
+
maxArgs: 2,
|
|
27285
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
27286
|
+
},
|
|
27287
|
+
clamp: {
|
|
27288
|
+
minArgs: 3,
|
|
27289
|
+
maxArgs: 3,
|
|
27290
|
+
apply: (args) => {
|
|
27291
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
27292
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
27293
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
27294
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
27295
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
27296
|
+
}
|
|
27297
|
+
},
|
|
27298
|
+
avg: {
|
|
27299
|
+
minArgs: 1,
|
|
27300
|
+
maxArgs: INF,
|
|
27301
|
+
apply: (args) => {
|
|
27302
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
27303
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
27304
|
+
}
|
|
27305
|
+
},
|
|
27306
|
+
sum: {
|
|
27307
|
+
minArgs: 1,
|
|
27308
|
+
maxArgs: INF,
|
|
27309
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
27310
|
+
},
|
|
27311
|
+
coalesce: {
|
|
27312
|
+
minArgs: 1,
|
|
27313
|
+
maxArgs: INF,
|
|
27314
|
+
apply: (args) => {
|
|
27315
|
+
for (const a of args) if (a !== null) return a;
|
|
27316
|
+
return null;
|
|
27317
|
+
}
|
|
27318
|
+
},
|
|
27319
|
+
age: {
|
|
27320
|
+
minArgs: 2,
|
|
27321
|
+
maxArgs: 2,
|
|
27322
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
27323
|
+
},
|
|
27324
|
+
convert: {
|
|
27325
|
+
minArgs: 3,
|
|
27326
|
+
maxArgs: 3,
|
|
27327
|
+
apply: (args, hooks) => {
|
|
27328
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
27329
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
27330
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
27331
|
+
if (hooks.convert) {
|
|
27332
|
+
const out = hooks.convert(x, from, to);
|
|
27333
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
27334
|
+
return finiteResult(out, "convert");
|
|
27335
|
+
}
|
|
27336
|
+
if (from === to) return x;
|
|
27337
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
27193
27338
|
}
|
|
27194
|
-
return this._stateProxyCache;
|
|
27195
27339
|
}
|
|
27196
|
-
|
|
27197
|
-
|
|
27198
|
-
|
|
27199
|
-
|
|
27200
|
-
|
|
27201
|
-
|
|
27202
|
-
|
|
27203
|
-
|
|
27204
|
-
|
|
27205
|
-
|
|
27206
|
-
|
|
27207
|
-
|
|
27208
|
-
|
|
27209
|
-
|
|
27210
|
-
|
|
27211
|
-
|
|
27212
|
-
|
|
27213
|
-
|
|
27214
|
-
|
|
27215
|
-
|
|
27216
|
-
|
|
27217
|
-
|
|
27218
|
-
|
|
27219
|
-
|
|
27220
|
-
|
|
27221
|
-
|
|
27222
|
-
|
|
27223
|
-
|
|
27224
|
-
|
|
27225
|
-
|
|
27226
|
-
|
|
27227
|
-
|
|
27228
|
-
|
|
27229
|
-
|
|
27230
|
-
|
|
27231
|
-
|
|
27232
|
-
|
|
27233
|
-
|
|
27234
|
-
|
|
27235
|
-
|
|
27236
|
-
|
|
27237
|
-
|
|
27238
|
-
|
|
27239
|
-
|
|
27240
|
-
|
|
27241
|
-
|
|
27242
|
-
|
|
27243
|
-
|
|
27244
|
-
|
|
27245
|
-
|
|
27246
|
-
|
|
27247
|
-
|
|
27248
|
-
|
|
27249
|
-
|
|
27250
|
-
|
|
27251
|
-
|
|
27252
|
-
|
|
27253
|
-
|
|
27254
|
-
|
|
27255
|
-
|
|
27256
|
-
|
|
27257
|
-
|
|
27258
|
-
const
|
|
27259
|
-
|
|
27260
|
-
|
|
27261
|
-
|
|
27262
|
-
|
|
27263
|
-
|
|
27264
|
-
|
|
27265
|
-
|
|
27266
|
-
|
|
27267
|
-
|
|
27340
|
+
};
|
|
27341
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
27342
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
27343
|
+
* callees at parse time (immediate author feedback). */
|
|
27344
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
27345
|
+
/**
|
|
27346
|
+
* Resource-bound constants for the safe expression engine.
|
|
27347
|
+
*
|
|
27348
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
27349
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
27350
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
27351
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
27352
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
27353
|
+
*/
|
|
27354
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
27355
|
+
* rejected without allocation. */
|
|
27356
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
27357
|
+
/** A legal binding / identifier name. */
|
|
27358
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
27359
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
27360
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
27361
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
27362
|
+
"now",
|
|
27363
|
+
"true",
|
|
27364
|
+
"false",
|
|
27365
|
+
"null"
|
|
27366
|
+
]);
|
|
27367
|
+
/**
|
|
27368
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
27369
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
27370
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
27371
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
27372
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
27373
|
+
* is a parse error with a source position, so member access / assignment /
|
|
27374
|
+
* template literals are lexically impossible.
|
|
27375
|
+
*/
|
|
27376
|
+
var KEYWORDS = new Set([
|
|
27377
|
+
"true",
|
|
27378
|
+
"false",
|
|
27379
|
+
"null"
|
|
27380
|
+
]);
|
|
27381
|
+
function isDigit(ch) {
|
|
27382
|
+
return ch >= "0" && ch <= "9";
|
|
27383
|
+
}
|
|
27384
|
+
function isIdentStart(ch) {
|
|
27385
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
27386
|
+
}
|
|
27387
|
+
function isIdentPart(ch) {
|
|
27388
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
27389
|
+
}
|
|
27390
|
+
function isWhitespace(ch) {
|
|
27391
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
27392
|
+
}
|
|
27393
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
27394
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
27395
|
+
* string. */
|
|
27396
|
+
function tokenize(source) {
|
|
27397
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
27398
|
+
const tokens = [];
|
|
27399
|
+
let i = 0;
|
|
27400
|
+
const n = source.length;
|
|
27401
|
+
while (i < n) {
|
|
27402
|
+
const ch = source[i];
|
|
27403
|
+
if (isWhitespace(ch)) {
|
|
27404
|
+
i += 1;
|
|
27405
|
+
continue;
|
|
27406
|
+
}
|
|
27407
|
+
if (isDigit(ch)) {
|
|
27408
|
+
const start = i;
|
|
27409
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
27410
|
+
if (i < n && source[i] === ".") {
|
|
27411
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
27412
|
+
i += 1;
|
|
27413
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
27414
|
+
}
|
|
27415
|
+
const text = source.slice(start, i);
|
|
27416
|
+
const value = Number(text);
|
|
27417
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
27418
|
+
tokens.push({
|
|
27419
|
+
type: "number",
|
|
27420
|
+
value,
|
|
27421
|
+
pos: start
|
|
27422
|
+
});
|
|
27423
|
+
continue;
|
|
27424
|
+
}
|
|
27425
|
+
if (ch === "'" || ch === "\"") {
|
|
27426
|
+
const quote = ch;
|
|
27427
|
+
const start = i;
|
|
27428
|
+
i += 1;
|
|
27429
|
+
let out = "";
|
|
27430
|
+
let closed = false;
|
|
27431
|
+
while (i < n) {
|
|
27432
|
+
const c = source[i];
|
|
27433
|
+
if (c === "\\") {
|
|
27434
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
27435
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
27436
|
+
out += next;
|
|
27437
|
+
i += 2;
|
|
27438
|
+
continue;
|
|
27439
|
+
}
|
|
27440
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
27441
|
+
}
|
|
27442
|
+
if (c === quote) {
|
|
27443
|
+
closed = true;
|
|
27444
|
+
i += 1;
|
|
27445
|
+
break;
|
|
27268
27446
|
}
|
|
27447
|
+
out += c;
|
|
27448
|
+
i += 1;
|
|
27449
|
+
}
|
|
27450
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
27451
|
+
tokens.push({
|
|
27452
|
+
type: "string",
|
|
27453
|
+
value: out,
|
|
27454
|
+
pos: start
|
|
27269
27455
|
});
|
|
27270
|
-
|
|
27271
|
-
|
|
27272
|
-
|
|
27273
|
-
|
|
27274
|
-
|
|
27275
|
-
|
|
27276
|
-
|
|
27456
|
+
continue;
|
|
27457
|
+
}
|
|
27458
|
+
if (isIdentStart(ch)) {
|
|
27459
|
+
const start = i;
|
|
27460
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
27461
|
+
const text = source.slice(start, i);
|
|
27462
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
27463
|
+
type: "keyword",
|
|
27464
|
+
keyword: keywordOf(text),
|
|
27465
|
+
pos: start
|
|
27277
27466
|
});
|
|
27278
|
-
|
|
27279
|
-
|
|
27280
|
-
|
|
27281
|
-
|
|
27282
|
-
|
|
27283
|
-
|
|
27284
|
-
|
|
27285
|
-
|
|
27286
|
-
|
|
27287
|
-
|
|
27288
|
-
|
|
27289
|
-
|
|
27290
|
-
|
|
27291
|
-
|
|
27292
|
-
|
|
27293
|
-
|
|
27294
|
-
|
|
27295
|
-
|
|
27296
|
-
|
|
27297
|
-
|
|
27298
|
-
|
|
27299
|
-
|
|
27300
|
-
|
|
27301
|
-
|
|
27302
|
-
|
|
27303
|
-
|
|
27304
|
-
});
|
|
27467
|
+
else tokens.push({
|
|
27468
|
+
type: "identifier",
|
|
27469
|
+
name: text,
|
|
27470
|
+
pos: start
|
|
27471
|
+
});
|
|
27472
|
+
continue;
|
|
27473
|
+
}
|
|
27474
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
27475
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
27476
|
+
tokens.push({
|
|
27477
|
+
type: "punct",
|
|
27478
|
+
punct: two,
|
|
27479
|
+
pos: i
|
|
27480
|
+
});
|
|
27481
|
+
i += 2;
|
|
27482
|
+
continue;
|
|
27483
|
+
}
|
|
27484
|
+
if (isSinglePunct(ch)) {
|
|
27485
|
+
tokens.push({
|
|
27486
|
+
type: "punct",
|
|
27487
|
+
punct: ch,
|
|
27488
|
+
pos: i
|
|
27489
|
+
});
|
|
27490
|
+
i += 1;
|
|
27491
|
+
continue;
|
|
27492
|
+
}
|
|
27493
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
27305
27494
|
}
|
|
27306
|
-
|
|
27307
|
-
|
|
27308
|
-
|
|
27309
|
-
|
|
27310
|
-
|
|
27311
|
-
|
|
27495
|
+
tokens.push({
|
|
27496
|
+
type: "eof",
|
|
27497
|
+
pos: n
|
|
27498
|
+
});
|
|
27499
|
+
return tokens;
|
|
27500
|
+
}
|
|
27501
|
+
function keywordOf(text) {
|
|
27502
|
+
if (text === "true") return "true";
|
|
27503
|
+
if (text === "false") return "false";
|
|
27504
|
+
return "null";
|
|
27505
|
+
}
|
|
27506
|
+
function isSinglePunct(ch) {
|
|
27507
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
27508
|
+
}
|
|
27509
|
+
/**
|
|
27510
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
27511
|
+
*
|
|
27512
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
27513
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
27514
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
27515
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
27516
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
27517
|
+
* that references a since-removed builtin degrades at read.
|
|
27518
|
+
*
|
|
27519
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
27520
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
27521
|
+
*/
|
|
27522
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
27523
|
+
var BINARY_PRECEDENCE = {
|
|
27524
|
+
"||": 1,
|
|
27525
|
+
"&&": 2,
|
|
27526
|
+
"==": 3,
|
|
27527
|
+
"!=": 3,
|
|
27528
|
+
"<": 4,
|
|
27529
|
+
"<=": 4,
|
|
27530
|
+
">": 4,
|
|
27531
|
+
">=": 4,
|
|
27532
|
+
"+": 5,
|
|
27533
|
+
"-": 5,
|
|
27534
|
+
"*": 6,
|
|
27535
|
+
"/": 6,
|
|
27536
|
+
"%": 6
|
|
27537
|
+
};
|
|
27538
|
+
function isLogicalOp(op) {
|
|
27539
|
+
return op === "&&" || op === "||";
|
|
27540
|
+
}
|
|
27541
|
+
function isBinaryOp(op) {
|
|
27542
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
27543
|
+
}
|
|
27544
|
+
var Parser = class {
|
|
27545
|
+
tokens;
|
|
27546
|
+
pos = 0;
|
|
27547
|
+
nodeCount = 0;
|
|
27548
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
27549
|
+
callees = /* @__PURE__ */ new Set();
|
|
27550
|
+
constructor(tokens) {
|
|
27551
|
+
this.tokens = tokens;
|
|
27312
27552
|
}
|
|
27313
|
-
|
|
27314
|
-
|
|
27315
|
-
|
|
27316
|
-
|
|
27317
|
-
|
|
27318
|
-
|
|
27319
|
-
|
|
27320
|
-
|
|
27321
|
-
|
|
27322
|
-
*/
|
|
27323
|
-
markOnline(online) {
|
|
27324
|
-
if (this.online === online) return;
|
|
27325
|
-
const next = {
|
|
27326
|
-
online,
|
|
27327
|
-
lastChangedAt: Date.now()
|
|
27553
|
+
parse() {
|
|
27554
|
+
const ast = this.parseTernary();
|
|
27555
|
+
const tok = this.peek();
|
|
27556
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
27557
|
+
return {
|
|
27558
|
+
ast,
|
|
27559
|
+
identifiers: this.identifiers,
|
|
27560
|
+
callees: this.callees,
|
|
27561
|
+
nodeCount: this.nodeCount
|
|
27328
27562
|
};
|
|
27329
|
-
this.runtimeState.setCapState("device-status", next);
|
|
27330
|
-
}
|
|
27331
|
-
/**
|
|
27332
|
-
* Upstream-system identity + rendering envelope for this device. See
|
|
27333
|
-
* `SourceInfo` for the field contract. Always returns a valid object:
|
|
27334
|
-
* if the persisted `metadata.sourceInfo` blob is absent or fails Zod
|
|
27335
|
-
* validation, falls back to a synthetic `{ id: stableId, system: addonId }`
|
|
27336
|
-
* so providers that haven't migrated keep working without code changes.
|
|
27337
|
-
*
|
|
27338
|
-
* The value is cached after the first read. `updateSourceInfo()`
|
|
27339
|
-
* invalidates the cache so subsequent reads see the new patch. The
|
|
27340
|
-
* returned object is frozen to prevent accidental in-place mutation —
|
|
27341
|
-
* use `updateSourceInfo({ patch })` to change fields.
|
|
27342
|
-
*/
|
|
27343
|
-
get sourceInfo() {
|
|
27344
|
-
if (this._sourceInfoCache) return this._sourceInfoCache;
|
|
27345
|
-
const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
|
|
27346
|
-
stableId: this.stableId,
|
|
27347
|
-
addonId: this.ctx.deviceMeta.addonId
|
|
27348
|
-
});
|
|
27349
|
-
this._sourceInfoCache = Object.freeze({ ...resolved });
|
|
27350
|
-
return this._sourceInfoCache;
|
|
27351
|
-
}
|
|
27352
|
-
/**
|
|
27353
|
-
* Convenience accessor for the upstream dispatch key. Equivalent to
|
|
27354
|
-
* `this.sourceInfo.id` — providers use this to keep a
|
|
27355
|
-
* `Map<sourceId, IDevice>` for routing inbound push events.
|
|
27356
|
-
*/
|
|
27357
|
-
get sourceId() {
|
|
27358
|
-
return this.sourceInfo.id;
|
|
27359
|
-
}
|
|
27360
|
-
/**
|
|
27361
|
-
* Patch the device's `SourceInfo`. Shallow-merges `patch` over the
|
|
27362
|
-
* current value, persists the merged result under
|
|
27363
|
-
* `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
|
|
27364
|
-
* emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
|
|
27365
|
-
*
|
|
27366
|
-
* Safe to call from anywhere in the device's lifetime — the call is
|
|
27367
|
-
* idempotent for `undefined` patch values (ignored) and best-effort
|
|
27368
|
-
* for persistence (a transient device-manager error doesn't unwind
|
|
27369
|
-
* the local cache update, so subsequent reads still see the patch).
|
|
27370
|
-
*
|
|
27371
|
-
* Drivers populate this on adoption + on every metadata change push
|
|
27372
|
-
* from the upstream source. Subscribers (UI, export adapters) react
|
|
27373
|
-
* via the `DeviceSourceInfoChanged` event without polling.
|
|
27374
|
-
*/
|
|
27375
|
-
async updateSourceInfo(patch) {
|
|
27376
|
-
const next = mergeSourceInfo(this.sourceInfo, patch);
|
|
27377
|
-
this._sourceInfoCache = Object.freeze({ ...next });
|
|
27378
|
-
const action = this.ctx.api?.deviceManager?.setMetadata;
|
|
27379
|
-
if (action) try {
|
|
27380
|
-
await action.mutate({
|
|
27381
|
-
deviceId: this.id,
|
|
27382
|
-
patch: { [SOURCE_INFO_METADATA_KEY]: next }
|
|
27383
|
-
});
|
|
27384
|
-
} catch {}
|
|
27385
|
-
this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
|
|
27386
|
-
type: "device",
|
|
27387
|
-
id: this.stableId
|
|
27388
|
-
}, {
|
|
27389
|
-
deviceId: this.id,
|
|
27390
|
-
sourceInfo: next
|
|
27391
|
-
}));
|
|
27392
|
-
}
|
|
27393
|
-
/**
|
|
27394
|
-
* Re-publish the device's current `features` array to the persisted
|
|
27395
|
-
* meta blob. Drivers call this after a probe finishes when the live
|
|
27396
|
-
* `features` getter has gained new flags (e.g. `hasIntercom` flips
|
|
27397
|
-
* to true → `DeviceFeature.TwoWayAudio` joins the list).
|
|
27398
|
-
*
|
|
27399
|
-
* Without this, only the construction-time snapshot is written —
|
|
27400
|
-
* `deviceManager.registerDevice` is invoked once per boot, so probe-
|
|
27401
|
-
* driven additions don't reach the persisted index until the next
|
|
27402
|
-
* server restart, and `getDevice` / `listAll` keep returning the
|
|
27403
|
-
* stale list for forked-worker devices (whose live IDevice instance
|
|
27404
|
-
* is invisible to the hub registry).
|
|
27405
|
-
*
|
|
27406
|
-
* Idempotent: re-calling with the same features just no-ops on the
|
|
27407
|
-
* persisted meta. Best-effort: lookup or write failures are logged
|
|
27408
|
-
* at debug and swallowed — the live `device.features` getter is
|
|
27409
|
-
* still authoritative within this process, so callers never block
|
|
27410
|
-
* device boot on a meta refresh.
|
|
27411
|
-
*/
|
|
27412
|
-
async refreshFeatures() {
|
|
27413
|
-
const action = this.ctx.api?.deviceManager?.registerDevice;
|
|
27414
|
-
if (!action) return;
|
|
27415
|
-
try {
|
|
27416
|
-
await action.mutate({
|
|
27417
|
-
addonId: this.ctx.deviceMeta.addonId,
|
|
27418
|
-
stableId: this.stableId,
|
|
27419
|
-
id: this.id,
|
|
27420
|
-
type: this.type,
|
|
27421
|
-
name: this.name,
|
|
27422
|
-
parentDeviceId: this.parentDeviceId,
|
|
27423
|
-
features: [...this.features],
|
|
27424
|
-
config: {}
|
|
27425
|
-
});
|
|
27426
|
-
} catch (err) {}
|
|
27427
27563
|
}
|
|
27428
|
-
|
|
27429
|
-
|
|
27430
|
-
* call `this.getCapSlice(batteryCapability)` and the return type
|
|
27431
|
-
* is inferred from the cap's `runtimeState` Zod schema — no string
|
|
27432
|
-
* key, no manual generic. Returns `null` when the slice hasn't
|
|
27433
|
-
* been written yet (e.g. driver hasn't seeded battery yet).
|
|
27434
|
-
*/
|
|
27435
|
-
getCapSlice(cap) {
|
|
27436
|
-
return this.runtimeState.getCapState(cap.name) ?? null;
|
|
27564
|
+
peek() {
|
|
27565
|
+
return this.tokens[this.pos];
|
|
27437
27566
|
}
|
|
27438
|
-
|
|
27439
|
-
|
|
27440
|
-
* the runtime-state writer (validate → persist → emit cap event).
|
|
27441
|
-
* Equivalent to `this.runtimeState.setCapState(cap.name, value)`
|
|
27442
|
-
* but with the cap's `runtimeState` schema enforcing the value
|
|
27443
|
-
* shape at compile time. Mirrors the symmetry of
|
|
27444
|
-
* `getCapSlice` / `setCapSlice` for cross-cap consistency.
|
|
27445
|
-
*/
|
|
27446
|
-
setCapSlice(cap, value) {
|
|
27447
|
-
this.runtimeState.setCapState(cap.name, value);
|
|
27567
|
+
next() {
|
|
27568
|
+
return this.tokens[this.pos++];
|
|
27448
27569
|
}
|
|
27449
|
-
/**
|
|
27450
|
-
|
|
27451
|
-
|
|
27452
|
-
|
|
27453
|
-
|
|
27454
|
-
|
|
27455
|
-
|
|
27456
|
-
|
|
27457
|
-
|
|
27458
|
-
|
|
27459
|
-
|
|
27460
|
-
|
|
27461
|
-
|
|
27462
|
-
|
|
27463
|
-
|
|
27464
|
-
|
|
27465
|
-
|
|
27466
|
-
|
|
27467
|
-
|
|
27468
|
-
|
|
27469
|
-
|
|
27470
|
-
|
|
27471
|
-
|
|
27472
|
-
|
|
27473
|
-
|
|
27474
|
-
|
|
27475
|
-
|
|
27476
|
-
|
|
27477
|
-
|
|
27478
|
-
|
|
27479
|
-
|
|
27480
|
-
|
|
27481
|
-
|
|
27482
|
-
|
|
27483
|
-
|
|
27484
|
-
|
|
27485
|
-
|
|
27486
|
-
|
|
27487
|
-
|
|
27488
|
-
|
|
27570
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
27571
|
+
expectPunct(punct) {
|
|
27572
|
+
const tok = this.peek();
|
|
27573
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
27574
|
+
this.pos += 1;
|
|
27575
|
+
}
|
|
27576
|
+
matchPunct(punct) {
|
|
27577
|
+
const tok = this.peek();
|
|
27578
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
27579
|
+
this.pos += 1;
|
|
27580
|
+
return true;
|
|
27581
|
+
}
|
|
27582
|
+
return false;
|
|
27583
|
+
}
|
|
27584
|
+
countNode() {
|
|
27585
|
+
this.nodeCount += 1;
|
|
27586
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
27587
|
+
}
|
|
27588
|
+
parseTernary() {
|
|
27589
|
+
const test = this.parseBinary(1);
|
|
27590
|
+
if (this.matchPunct("?")) {
|
|
27591
|
+
const consequent = this.parseTernary();
|
|
27592
|
+
this.expectPunct(":");
|
|
27593
|
+
const alternate = this.parseTernary();
|
|
27594
|
+
this.countNode();
|
|
27595
|
+
return {
|
|
27596
|
+
kind: "conditional",
|
|
27597
|
+
test,
|
|
27598
|
+
consequent,
|
|
27599
|
+
alternate
|
|
27600
|
+
};
|
|
27601
|
+
}
|
|
27602
|
+
return test;
|
|
27603
|
+
}
|
|
27604
|
+
parseBinary(minPrec) {
|
|
27605
|
+
let left = this.parseUnary();
|
|
27606
|
+
for (;;) {
|
|
27607
|
+
const tok = this.peek();
|
|
27608
|
+
if (tok.type !== "punct") break;
|
|
27609
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
27610
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
27611
|
+
const op = tok.punct;
|
|
27612
|
+
this.pos += 1;
|
|
27613
|
+
const right = this.parseBinary(prec + 1);
|
|
27614
|
+
this.countNode();
|
|
27615
|
+
if (isLogicalOp(op)) left = {
|
|
27616
|
+
kind: "logical",
|
|
27617
|
+
op,
|
|
27618
|
+
left,
|
|
27619
|
+
right
|
|
27620
|
+
};
|
|
27621
|
+
else if (isBinaryOp(op)) left = {
|
|
27622
|
+
kind: "binary",
|
|
27623
|
+
op,
|
|
27624
|
+
left,
|
|
27625
|
+
right
|
|
27626
|
+
};
|
|
27627
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
27628
|
+
}
|
|
27629
|
+
return left;
|
|
27630
|
+
}
|
|
27631
|
+
parseUnary() {
|
|
27632
|
+
const tok = this.peek();
|
|
27633
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
27634
|
+
const op = tok.punct;
|
|
27635
|
+
this.pos += 1;
|
|
27636
|
+
const operand = this.parseUnary();
|
|
27637
|
+
this.countNode();
|
|
27638
|
+
return {
|
|
27639
|
+
kind: "unary",
|
|
27640
|
+
op,
|
|
27641
|
+
operand
|
|
27642
|
+
};
|
|
27643
|
+
}
|
|
27644
|
+
return this.parsePrimary();
|
|
27645
|
+
}
|
|
27646
|
+
parsePrimary() {
|
|
27647
|
+
const tok = this.next();
|
|
27648
|
+
switch (tok.type) {
|
|
27649
|
+
case "number":
|
|
27650
|
+
this.countNode();
|
|
27489
27651
|
return {
|
|
27490
|
-
|
|
27491
|
-
|
|
27492
|
-
|
|
27652
|
+
kind: "literal",
|
|
27653
|
+
value: tok.value
|
|
27654
|
+
};
|
|
27655
|
+
case "string":
|
|
27656
|
+
this.countNode();
|
|
27657
|
+
return {
|
|
27658
|
+
kind: "literal",
|
|
27659
|
+
value: tok.value
|
|
27660
|
+
};
|
|
27661
|
+
case "keyword":
|
|
27662
|
+
this.countNode();
|
|
27663
|
+
return {
|
|
27664
|
+
kind: "literal",
|
|
27665
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
27666
|
+
};
|
|
27667
|
+
case "identifier": {
|
|
27668
|
+
const nextTok = this.peek();
|
|
27669
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
27670
|
+
this.identifiers.add(tok.name);
|
|
27671
|
+
this.countNode();
|
|
27672
|
+
return {
|
|
27673
|
+
kind: "identifier",
|
|
27674
|
+
name: tok.name
|
|
27493
27675
|
};
|
|
27494
27676
|
}
|
|
27495
|
-
|
|
27496
|
-
|
|
27497
|
-
|
|
27498
|
-
|
|
27499
|
-
|
|
27500
|
-
|
|
27501
|
-
|
|
27502
|
-
|
|
27503
|
-
|
|
27504
|
-
return { sections: [] };
|
|
27505
|
-
}
|
|
27506
|
-
/**
|
|
27507
|
-
* Default write path: forward the flat patch directly to storage.
|
|
27508
|
-
* Drivers that project a UI shape different from storage (e.g. `RtspCamera`
|
|
27509
|
-
* exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
|
|
27510
|
-
* to reshape before `config.setAll`.
|
|
27511
|
-
*/
|
|
27512
|
-
async applySettingsPatch(patch) {
|
|
27513
|
-
await this.config.setAll(patch);
|
|
27677
|
+
case "punct":
|
|
27678
|
+
if (tok.punct === "(") {
|
|
27679
|
+
const inner = this.parseTernary();
|
|
27680
|
+
this.expectPunct(")");
|
|
27681
|
+
return inner;
|
|
27682
|
+
}
|
|
27683
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
27684
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
27685
|
+
}
|
|
27514
27686
|
}
|
|
27515
|
-
|
|
27516
|
-
|
|
27517
|
-
|
|
27518
|
-
|
|
27519
|
-
|
|
27520
|
-
|
|
27521
|
-
|
|
27522
|
-
|
|
27523
|
-
|
|
27524
|
-
|
|
27525
|
-
|
|
27526
|
-
|
|
27527
|
-
|
|
27528
|
-
|
|
27529
|
-
|
|
27530
|
-
|
|
27531
|
-
|
|
27532
|
-
* DO probe override this and write their own `feature-probe` slice (including
|
|
27533
|
-
* `lastProbedAt`) once their probe actually succeeds.
|
|
27534
|
-
*/
|
|
27535
|
-
async onProbe() {
|
|
27536
|
-
const base = this.runtimeState.getCapState("feature-probe") ?? {
|
|
27537
|
-
flags: {},
|
|
27538
|
-
deviceType: null,
|
|
27539
|
-
model: null,
|
|
27540
|
-
channelCount: null,
|
|
27541
|
-
lastProbedAt: 0,
|
|
27542
|
-
lastFetchedAt: 0
|
|
27687
|
+
parseCall(callee, pos) {
|
|
27688
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
27689
|
+
this.expectPunct("(");
|
|
27690
|
+
const args = [];
|
|
27691
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
27692
|
+
args.push(this.parseTernary());
|
|
27693
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
27694
|
+
if (this.matchPunct(",")) continue;
|
|
27695
|
+
this.expectPunct(")");
|
|
27696
|
+
break;
|
|
27697
|
+
}
|
|
27698
|
+
this.callees.add(callee);
|
|
27699
|
+
this.countNode();
|
|
27700
|
+
return {
|
|
27701
|
+
kind: "call",
|
|
27702
|
+
callee,
|
|
27703
|
+
args
|
|
27543
27704
|
};
|
|
27544
|
-
this.runtimeState.setCapState("feature-probe", {
|
|
27545
|
-
...base,
|
|
27546
|
-
lastProbedAt: Date.now()
|
|
27547
|
-
});
|
|
27548
27705
|
}
|
|
27549
|
-
|
|
27550
|
-
|
|
27551
|
-
|
|
27552
|
-
|
|
27553
|
-
|
|
27554
|
-
|
|
27555
|
-
|
|
27556
|
-
|
|
27557
|
-
|
|
27558
|
-
|
|
27559
|
-
|
|
27560
|
-
|
|
27561
|
-
|
|
27562
|
-
|
|
27563
|
-
|
|
27564
|
-
|
|
27565
|
-
|
|
27566
|
-
|
|
27567
|
-
|
|
27568
|
-
|
|
27569
|
-
|
|
27570
|
-
|
|
27571
|
-
|
|
27572
|
-
|
|
27706
|
+
};
|
|
27707
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
27708
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
27709
|
+
function parseExpression(source) {
|
|
27710
|
+
return new Parser(tokenize(source)).parse();
|
|
27711
|
+
}
|
|
27712
|
+
/**
|
|
27713
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
27714
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
27715
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
27716
|
+
* one per read on a hot resolve path.
|
|
27717
|
+
*
|
|
27718
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
27719
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
27720
|
+
* callers is safe and maximises hit rate.
|
|
27721
|
+
*/
|
|
27722
|
+
var cache = /* @__PURE__ */ new Map();
|
|
27723
|
+
function getCached(source) {
|
|
27724
|
+
const hit = cache.get(source);
|
|
27725
|
+
if (hit !== void 0) {
|
|
27726
|
+
cache.delete(source);
|
|
27727
|
+
cache.set(source, hit);
|
|
27728
|
+
return hit;
|
|
27729
|
+
}
|
|
27730
|
+
let result;
|
|
27731
|
+
try {
|
|
27732
|
+
result = {
|
|
27733
|
+
ok: true,
|
|
27734
|
+
parsed: parseExpression(source)
|
|
27735
|
+
};
|
|
27736
|
+
} catch (err) {
|
|
27737
|
+
result = {
|
|
27738
|
+
ok: false,
|
|
27739
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
27740
|
+
};
|
|
27573
27741
|
}
|
|
27574
|
-
|
|
27575
|
-
|
|
27576
|
-
|
|
27577
|
-
|
|
27578
|
-
* directly — call `reprobe()` instead.
|
|
27579
|
-
*/
|
|
27580
|
-
_kernelReprobe;
|
|
27581
|
-
/**
|
|
27582
|
-
* Declare accessory child devices the kernel should auto-spawn
|
|
27583
|
-
* after `onProbe()` resolves. Each spec fully describes one child
|
|
27584
|
-
* — stableId suffix (deterministic per kind for restore-safety),
|
|
27585
|
-
* meta (type / name / location), config (initial blob the child
|
|
27586
|
-
* self-hydrates), and a factory that constructs the concrete
|
|
27587
|
-
* class with whatever closure-captured refs it needs (typically
|
|
27588
|
-
* `this` for the parent reference).
|
|
27589
|
-
*
|
|
27590
|
-
* The kernel handles the rest: allocateDeviceId, persistInitialConfig
|
|
27591
|
-
* (skipped on restore when the row already exists),
|
|
27592
|
-
* persistInitialMeta, createContext, factory invocation, register,
|
|
27593
|
-
* and recursive lifecycle (probe + accessories + activate).
|
|
27594
|
-
*
|
|
27595
|
-
* Implementations should derive children from
|
|
27596
|
-
* `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
|
|
27597
|
-
* Drivers can use the `getProbeFlags()` helper to read the flag bag
|
|
27598
|
-
* with a typed cast.
|
|
27599
|
-
*
|
|
27600
|
-
* Default: no children.
|
|
27601
|
-
*/
|
|
27602
|
-
getAccessoryChildren() {
|
|
27603
|
-
return [];
|
|
27742
|
+
cache.set(source, result);
|
|
27743
|
+
if (cache.size > 256) {
|
|
27744
|
+
const oldest = cache.keys().next().value;
|
|
27745
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
27604
27746
|
}
|
|
27605
|
-
|
|
27606
|
-
|
|
27607
|
-
|
|
27608
|
-
|
|
27609
|
-
|
|
27610
|
-
|
|
27611
|
-
|
|
27747
|
+
return result;
|
|
27748
|
+
}
|
|
27749
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
27750
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
27751
|
+
function compileExpressionSafe(source) {
|
|
27752
|
+
return getCached(source);
|
|
27753
|
+
}
|
|
27754
|
+
Object.freeze({});
|
|
27755
|
+
/**
|
|
27756
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
27757
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
27758
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
27759
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
27760
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
27761
|
+
*/
|
|
27762
|
+
function validateExpressionSource(src) {
|
|
27763
|
+
const names = Object.keys(src.bindings);
|
|
27764
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
27765
|
+
for (const name of names) {
|
|
27766
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
27767
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
27768
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
27612
27769
|
}
|
|
27613
|
-
|
|
27614
|
-
|
|
27615
|
-
|
|
27616
|
-
|
|
27617
|
-
|
|
27618
|
-
|
|
27619
|
-
hasProbed() {
|
|
27620
|
-
return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
|
|
27770
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
27771
|
+
if (!compiled.ok) return compiled.error;
|
|
27772
|
+
const bound = new Set(names);
|
|
27773
|
+
for (const id of compiled.parsed.identifiers) {
|
|
27774
|
+
if (id === "now") continue;
|
|
27775
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
27621
27776
|
}
|
|
27622
|
-
|
|
27623
|
-
|
|
27624
|
-
|
|
27625
|
-
|
|
27777
|
+
return null;
|
|
27778
|
+
}
|
|
27779
|
+
var ExpressionBindingSourceSchema = union([
|
|
27780
|
+
object({
|
|
27781
|
+
kind: literal("field").optional(),
|
|
27782
|
+
sourceKey: string(),
|
|
27783
|
+
cap: string(),
|
|
27784
|
+
fieldPath: string()
|
|
27785
|
+
}),
|
|
27786
|
+
object({
|
|
27787
|
+
kind: literal("literal"),
|
|
27788
|
+
value: union([
|
|
27789
|
+
string(),
|
|
27790
|
+
number(),
|
|
27791
|
+
boolean(),
|
|
27792
|
+
_null()
|
|
27793
|
+
])
|
|
27794
|
+
}),
|
|
27795
|
+
object({
|
|
27796
|
+
kind: literal("global"),
|
|
27797
|
+
sourceStableId: string(),
|
|
27798
|
+
cap: string(),
|
|
27799
|
+
fieldPath: string()
|
|
27800
|
+
})
|
|
27801
|
+
]);
|
|
27802
|
+
object({
|
|
27803
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
27804
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
27805
|
+
}).superRefine((src, ctx) => {
|
|
27806
|
+
const err = validateExpressionSource(src);
|
|
27807
|
+
if (err !== null) ctx.addIssue({
|
|
27808
|
+
code: "custom",
|
|
27809
|
+
message: err,
|
|
27810
|
+
path: ["expr"]
|
|
27811
|
+
});
|
|
27812
|
+
});
|
|
27626
27813
|
Object.freeze({
|
|
27627
27814
|
"accessories.setChildHidden": {
|
|
27628
27815
|
capName: "accessories",
|
|
@@ -28446,6 +28633,12 @@ Object.freeze({
|
|
|
28446
28633
|
addonId: null,
|
|
28447
28634
|
access: "view"
|
|
28448
28635
|
},
|
|
28636
|
+
"coreBlocks.restart": {
|
|
28637
|
+
capName: "core-blocks",
|
|
28638
|
+
capScope: "system",
|
|
28639
|
+
addonId: null,
|
|
28640
|
+
access: "create"
|
|
28641
|
+
},
|
|
28449
28642
|
"coreBlocks.setEnabled": {
|
|
28450
28643
|
capName: "core-blocks",
|
|
28451
28644
|
capScope: "system",
|
|
@@ -29082,12 +29275,6 @@ Object.freeze({
|
|
|
29082
29275
|
addonId: null,
|
|
29083
29276
|
access: "create"
|
|
29084
29277
|
},
|
|
29085
|
-
"deviceManager.setDeviceLinks": {
|
|
29086
|
-
capName: "device-manager",
|
|
29087
|
-
capScope: "system",
|
|
29088
|
-
addonId: null,
|
|
29089
|
-
access: "create"
|
|
29090
|
-
},
|
|
29091
29278
|
"deviceManager.setDisabled": {
|
|
29092
29279
|
capName: "device-manager",
|
|
29093
29280
|
capScope: "system",
|
|
@@ -33185,51 +33372,6 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
|
|
|
33185
33372
|
createdAt: number(),
|
|
33186
33373
|
updatedAt: number()
|
|
33187
33374
|
});
|
|
33188
|
-
/** Cosine similarity between two embedding vectors */
|
|
33189
|
-
function cosineSimilarity(a, b) {
|
|
33190
|
-
if (a.length !== b.length) return 0;
|
|
33191
|
-
let dotProduct = 0;
|
|
33192
|
-
let normA = 0;
|
|
33193
|
-
let normB = 0;
|
|
33194
|
-
for (let i = 0; i < a.length; i++) {
|
|
33195
|
-
dotProduct += a[i] * b[i];
|
|
33196
|
-
normA += a[i] * a[i];
|
|
33197
|
-
normB += b[i] * b[i];
|
|
33198
|
-
}
|
|
33199
|
-
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
33200
|
-
return denom === 0 ? 0 : dotProduct / denom;
|
|
33201
|
-
}
|
|
33202
|
-
function hfModelUrl(repo, path) {
|
|
33203
|
-
return `https://huggingface.co/${repo}/resolve/main/${path}`;
|
|
33204
|
-
}
|
|
33205
|
-
/**
|
|
33206
|
-
* Vector wire codec — base64 of little-endian Float32.
|
|
33207
|
-
*
|
|
33208
|
-
* The `vector-store` capability carries vectors as base64 rather than
|
|
33209
|
-
* `number[]` or a typed array, and both rejected alternatives have a scar here:
|
|
33210
|
-
*
|
|
33211
|
-
* - a `Float32Array` does NOT survive msgpack across the UDS transport (it
|
|
33212
|
-
* arrived as an empty object and froze audio dBFS until the payload was
|
|
33213
|
-
* changed to carry bytes);
|
|
33214
|
-
* - `number[]` is the ~5x-larger encoding the capability exists to stop paying
|
|
33215
|
-
* — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
|
|
33216
|
-
* 10-12 KB rendered as JSON text.
|
|
33217
|
-
*
|
|
33218
|
-
* Endianness is pinned to little-endian explicitly instead of inheriting the
|
|
33219
|
-
* platform's, so a vector written on one node decodes correctly on another.
|
|
33220
|
-
*/
|
|
33221
|
-
/** Encode a vector as base64 of little-endian Float32. */
|
|
33222
|
-
function encodeVectorBase64(vector) {
|
|
33223
|
-
const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
|
|
33224
|
-
const bytes = new Uint8Array(floats.length * 4);
|
|
33225
|
-
const view = new DataView(bytes.buffer);
|
|
33226
|
-
for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
|
|
33227
|
-
return Buffer.from(bytes).toString("base64");
|
|
33228
|
-
}
|
|
33229
|
-
/** Vector length implied by a base64 payload, without decoding it. */
|
|
33230
|
-
function vectorDimFromBase64(encoded) {
|
|
33231
|
-
return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
|
|
33232
|
-
}
|
|
33233
33375
|
object({
|
|
33234
33376
|
/**
|
|
33235
33377
|
* Fraction of the box's own size added on EACH side before cutting.
|
|
@@ -33336,6 +33478,51 @@ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
|
|
|
33336
33478
|
DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
33337
33479
|
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
33338
33480
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
33481
|
+
/** Cosine similarity between two embedding vectors */
|
|
33482
|
+
function cosineSimilarity(a, b) {
|
|
33483
|
+
if (a.length !== b.length) return 0;
|
|
33484
|
+
let dotProduct = 0;
|
|
33485
|
+
let normA = 0;
|
|
33486
|
+
let normB = 0;
|
|
33487
|
+
for (let i = 0; i < a.length; i++) {
|
|
33488
|
+
dotProduct += a[i] * b[i];
|
|
33489
|
+
normA += a[i] * a[i];
|
|
33490
|
+
normB += b[i] * b[i];
|
|
33491
|
+
}
|
|
33492
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
33493
|
+
return denom === 0 ? 0 : dotProduct / denom;
|
|
33494
|
+
}
|
|
33495
|
+
function hfModelUrl(repo, path) {
|
|
33496
|
+
return `https://huggingface.co/${repo}/resolve/main/${path}`;
|
|
33497
|
+
}
|
|
33498
|
+
/**
|
|
33499
|
+
* Vector wire codec — base64 of little-endian Float32.
|
|
33500
|
+
*
|
|
33501
|
+
* The `vector-store` capability carries vectors as base64 rather than
|
|
33502
|
+
* `number[]` or a typed array, and both rejected alternatives have a scar here:
|
|
33503
|
+
*
|
|
33504
|
+
* - a `Float32Array` does NOT survive msgpack across the UDS transport (it
|
|
33505
|
+
* arrived as an empty object and froze audio dBFS until the payload was
|
|
33506
|
+
* changed to carry bytes);
|
|
33507
|
+
* - `number[]` is the ~5x-larger encoding the capability exists to stop paying
|
|
33508
|
+
* — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
|
|
33509
|
+
* 10-12 KB rendered as JSON text.
|
|
33510
|
+
*
|
|
33511
|
+
* Endianness is pinned to little-endian explicitly instead of inheriting the
|
|
33512
|
+
* platform's, so a vector written on one node decodes correctly on another.
|
|
33513
|
+
*/
|
|
33514
|
+
/** Encode a vector as base64 of little-endian Float32. */
|
|
33515
|
+
function encodeVectorBase64(vector) {
|
|
33516
|
+
const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
|
|
33517
|
+
const bytes = new Uint8Array(floats.length * 4);
|
|
33518
|
+
const view = new DataView(bytes.buffer);
|
|
33519
|
+
for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
|
|
33520
|
+
return Buffer.from(bytes).toString("base64");
|
|
33521
|
+
}
|
|
33522
|
+
/** Vector length implied by a base64 payload, without decoding it. */
|
|
33523
|
+
function vectorDimFromBase64(encoded) {
|
|
33524
|
+
return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
|
|
33525
|
+
}
|
|
33339
33526
|
//#endregion
|
|
33340
33527
|
Object.defineProperty(exports, "BaseAddon", {
|
|
33341
33528
|
enumerable: true,
|
|
@@ -33355,6 +33542,12 @@ Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
|
|
|
33355
33542
|
return DEFAULT_EVENT_COLOR;
|
|
33356
33543
|
}
|
|
33357
33544
|
});
|
|
33545
|
+
Object.defineProperty(exports, "DeclaredDevices", {
|
|
33546
|
+
enumerable: true,
|
|
33547
|
+
get: function() {
|
|
33548
|
+
return DeclaredDevices;
|
|
33549
|
+
}
|
|
33550
|
+
});
|
|
33358
33551
|
Object.defineProperty(exports, "DeviceType", {
|
|
33359
33552
|
enumerable: true,
|
|
33360
33553
|
get: function() {
|