@camstack/system 1.2.66 → 1.2.68
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/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/blocks-integration.d.ts +24 -0
- package/dist/builtins/core-blocks/core-blocks.addon.d.ts +14 -0
- package/dist/builtins/core-blocks/core-blocks.addon.js +99 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +99 -2
- package/dist/builtins/device-manager/device-manager.addon.js +3 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +3 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +339 -2
- package/dist/builtins/snapshot/index.mjs +339 -2
- package/dist/builtins/snapshot/snapshot-link-url.d.ts +66 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +79 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-DqPyKwVB.mjs → dist-D2J8RJ7m.mjs} +1959 -1492
- package/dist/{dist-CIkGOAcz.js → dist-Dhpte164.js} +1965 -1486
- package/dist/index.js +28 -4
- package/dist/index.mjs +28 -4
- package/dist/kernel/cap-router-builder.d.ts +19 -0
- package/dist/kernel/capability-registry.d.ts +14 -1
- package/package.json +1 -1
|
@@ -4050,357 +4050,1157 @@ var ConvertResultSchema = z.object({
|
|
|
4050
4050
|
artifacts: z.array(ConvertArtifactSchema).readonly()
|
|
4051
4051
|
});
|
|
4052
4052
|
/**
|
|
4053
|
-
*
|
|
4054
|
-
*
|
|
4055
|
-
*
|
|
4056
|
-
* The provider iterates every `addon-pages-source` (collection) provider
|
|
4057
|
-
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
4058
|
-
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
4059
|
-
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
4060
|
-
* rebuilds without manual reload.
|
|
4061
|
-
*
|
|
4062
|
-
* The hub-local builtin `addon-pages-aggregator` (see
|
|
4063
|
-
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
4064
|
-
* provider. Splitting the public aggregator from the raw collection
|
|
4065
|
-
* keeps both ends in codegen — there's no hand-written
|
|
4066
|
-
* `addon-pages.router.ts` wrapper anymore.
|
|
4067
|
-
*/
|
|
4068
|
-
var AddonPageDeclarationSchema$1 = z.object({
|
|
4069
|
-
id: z.string(),
|
|
4070
|
-
label: z.string(),
|
|
4071
|
-
icon: z.string(),
|
|
4072
|
-
path: z.string(),
|
|
4073
|
-
remoteName: z.string(),
|
|
4074
|
-
bundle: z.string(),
|
|
4075
|
-
section: z.string().optional(),
|
|
4076
|
-
sectionLabel: z.string().optional()
|
|
4077
|
-
});
|
|
4078
|
-
var AddonPageInfoSchema = z.object({
|
|
4079
|
-
addonId: z.string(),
|
|
4080
|
-
page: AddonPageDeclarationSchema$1,
|
|
4081
|
-
bundleUrl: z.string()
|
|
4082
|
-
});
|
|
4083
|
-
var addonPagesCapability = {
|
|
4084
|
-
name: "addon-pages",
|
|
4085
|
-
scope: "system",
|
|
4086
|
-
mode: "singleton",
|
|
4087
|
-
methods: { listPages: method(z.void(), z.array(AddonPageInfoSchema).readonly()) }
|
|
4088
|
-
};
|
|
4089
|
-
/**
|
|
4090
|
-
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
4091
|
-
* declarations. Every addon that contributes a UI page registers a
|
|
4092
|
-
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
4093
|
-
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
4094
|
-
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
4095
|
-
* that admin-ui consumes.
|
|
4096
|
-
*
|
|
4097
|
-
* The split exists because the public listing has a different output
|
|
4098
|
-
* shape than the per-provider raw declarations, and we want both ends
|
|
4099
|
-
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
4053
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
4054
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
4055
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
4100
4056
|
*/
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
4111
|
-
* contain hyphens).
|
|
4112
|
-
*/
|
|
4113
|
-
remoteName: z.string(),
|
|
4114
|
-
/**
|
|
4115
|
-
* Bundle filename inside the addon's `dist/` dir served at
|
|
4116
|
-
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
4117
|
-
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
4118
|
-
* the static-file route can compute an mtime-based cache-buster URL
|
|
4119
|
-
* without a separate filesystem stat.
|
|
4120
|
-
*/
|
|
4121
|
-
bundle: z.string(),
|
|
4122
|
-
/**
|
|
4123
|
-
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
4124
|
-
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
4125
|
-
* Any OTHER string creates (or joins) a custom section rendered after
|
|
4126
|
-
* the built-in groups; its label comes from `sectionLabel` (first
|
|
4127
|
-
* declaration wins), falling back to the id. Absent → the legacy
|
|
4128
|
-
* "Addon Pages" group.
|
|
4129
|
-
*/
|
|
4130
|
-
section: z.string().optional(),
|
|
4131
|
-
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
4132
|
-
sectionLabel: z.string().optional()
|
|
4133
|
-
});
|
|
4134
|
-
var addonPagesSourceCapability = {
|
|
4135
|
-
name: "addon-pages-source",
|
|
4136
|
-
scope: "system",
|
|
4137
|
-
mode: "collection",
|
|
4138
|
-
internal: true,
|
|
4139
|
-
methods: { listPages: method(z.void(), z.array(AddonPageDeclarationSchema).readonly()) }
|
|
4057
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
4058
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
4059
|
+
var ExpressionParseError = class extends Error {
|
|
4060
|
+
position;
|
|
4061
|
+
constructor(message, position) {
|
|
4062
|
+
super(message);
|
|
4063
|
+
this.name = "ExpressionParseError";
|
|
4064
|
+
this.position = position;
|
|
4065
|
+
}
|
|
4140
4066
|
};
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
"
|
|
4147
|
-
|
|
4148
|
-
]),
|
|
4149
|
-
path: z.string(),
|
|
4150
|
-
access: z.enum([
|
|
4151
|
-
"public",
|
|
4152
|
-
"authenticated",
|
|
4153
|
-
"admin"
|
|
4154
|
-
]).optional(),
|
|
4155
|
-
description: z.string().optional()
|
|
4156
|
-
});
|
|
4157
|
-
/**
|
|
4158
|
-
* Cross-process route invocation envelope. The hub captures the
|
|
4159
|
-
* request as plain data, ships it to the worker via Moleculer, and
|
|
4160
|
-
* the worker runs the local handler against a capturing reply. The
|
|
4161
|
-
* envelope returned describes what the handler intended (status,
|
|
4162
|
-
* headers, body, or a redirect) so the hub can translate it back to
|
|
4163
|
-
* the Fastify reply that's actually wired to the socket.
|
|
4164
|
-
*/
|
|
4165
|
-
var InvokeRequestSchema = z.object({
|
|
4166
|
-
method: z.string(),
|
|
4167
|
-
path: z.string(),
|
|
4168
|
-
params: z.record(z.string(), z.string()),
|
|
4169
|
-
query: z.record(z.string(), z.string()),
|
|
4170
|
-
body: z.unknown(),
|
|
4171
|
-
headers: z.record(z.string(), z.string()),
|
|
4172
|
-
user: z.object({
|
|
4173
|
-
id: z.string(),
|
|
4174
|
-
username: z.string(),
|
|
4175
|
-
isAdmin: z.boolean()
|
|
4176
|
-
}).optional(),
|
|
4177
|
-
scopedToken: z.unknown().optional()
|
|
4178
|
-
});
|
|
4179
|
-
var InvokeReplyEnvelopeSchema = z.object({
|
|
4180
|
-
status: z.number().int(),
|
|
4181
|
-
headers: z.record(z.string(), z.string()),
|
|
4182
|
-
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
4183
|
-
* sending `body`. Status defaults to 302 when this is set unless
|
|
4184
|
-
* the handler called `reply.code(...)` explicitly. */
|
|
4185
|
-
redirectUrl: z.string().nullable(),
|
|
4186
|
-
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
4187
|
-
body: z.unknown().optional(),
|
|
4188
|
-
/** Set when the handler called `reply.type(mime)`. */
|
|
4189
|
-
contentType: z.string().optional()
|
|
4190
|
-
});
|
|
4191
|
-
var addonRoutesCapability = {
|
|
4192
|
-
name: "addon-routes",
|
|
4193
|
-
scope: "system",
|
|
4194
|
-
mode: "collection",
|
|
4195
|
-
internal: true,
|
|
4196
|
-
methods: {
|
|
4197
|
-
getRoutes: method(z.void(), z.array(AddonHttpRouteSchema)),
|
|
4198
|
-
/**
|
|
4199
|
-
* Cross-process dispatch entry point. Forked addons implement this
|
|
4200
|
-
* (via `buildAddonRouteProvider`) so the hub's Fastify catch-all
|
|
4201
|
-
* can route through Moleculer when the handler lives in a worker.
|
|
4202
|
-
*
|
|
4203
|
-
* Local addons can implement it for free with the same helper;
|
|
4204
|
-
* the hub bypasses the wire on co-located addons.
|
|
4205
|
-
*/
|
|
4206
|
-
invoke: method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" })
|
|
4207
|
-
},
|
|
4208
|
-
/** BIG PLAN 2: declarative mount hint — read by `@camstack/system` `buildCapRouters`. */
|
|
4209
|
-
mount: { kind: "skip" }
|
|
4067
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
4068
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
4069
|
+
var ExpressionEvalError = class extends Error {
|
|
4070
|
+
constructor(message) {
|
|
4071
|
+
super(message);
|
|
4072
|
+
this.name = "ExpressionEvalError";
|
|
4073
|
+
}
|
|
4210
4074
|
};
|
|
4211
|
-
var ConfigTabDeclarationSchema = z.object({
|
|
4212
|
-
id: z.string(),
|
|
4213
|
-
label: z.string(),
|
|
4214
|
-
icon: z.string(),
|
|
4215
|
-
order: z.number().optional()
|
|
4216
|
-
});
|
|
4217
|
-
var ConfigSectionWithValuesSchema = z.object({
|
|
4218
|
-
id: z.string(),
|
|
4219
|
-
title: z.string(),
|
|
4220
|
-
description: z.string().optional(),
|
|
4221
|
-
style: z.enum(["card", "accordion"]).optional(),
|
|
4222
|
-
defaultCollapsed: z.boolean().optional(),
|
|
4223
|
-
columns: z.union([
|
|
4224
|
-
z.literal(1),
|
|
4225
|
-
z.literal(2),
|
|
4226
|
-
z.literal(3),
|
|
4227
|
-
z.literal(4)
|
|
4228
|
-
]).optional(),
|
|
4229
|
-
tab: z.string().optional(),
|
|
4230
|
-
location: z.enum(["settings", "top-tab"]).optional(),
|
|
4231
|
-
order: z.number().optional(),
|
|
4232
|
-
fields: z.array(z.any())
|
|
4233
|
-
});
|
|
4234
|
-
var SettingsSchemaWithValuesSchema = z.object({
|
|
4235
|
-
tabs: z.array(ConfigTabDeclarationSchema).optional(),
|
|
4236
|
-
sections: z.array(ConfigSectionWithValuesSchema)
|
|
4237
|
-
});
|
|
4238
|
-
/** Patch object — keys are field names, values are the new field values. */
|
|
4239
|
-
var SettingsPatchSchema = z.record(z.string(), z.unknown());
|
|
4240
|
-
/** Standard success response for update operations. */
|
|
4241
|
-
var SettingsUpdateResultSchema = z.object({ success: z.literal(true) });
|
|
4242
4075
|
/**
|
|
4243
|
-
*
|
|
4244
|
-
*
|
|
4245
|
-
*
|
|
4246
|
-
*
|
|
4247
|
-
* addons the call is direct; for remote agents it proxies via the
|
|
4248
|
-
* per-addon Moleculer service.
|
|
4249
|
-
*
|
|
4250
|
-
* Replaces the `$addonHost` Moleculer service. Transport transparency
|
|
4251
|
-
* is handled by the provider implementation — callers (admin UI, other
|
|
4252
|
-
* addons via `ctx.api`) never know which node hosts the target addon.
|
|
4076
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
4077
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
4078
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
4079
|
+
* own-property check against it.
|
|
4253
4080
|
*
|
|
4254
|
-
*
|
|
4255
|
-
*
|
|
4256
|
-
*
|
|
4257
|
-
*
|
|
4081
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
4082
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
4083
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
4084
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
4085
|
+
* callable — they are simply "unknown function" at parse time.
|
|
4258
4086
|
*
|
|
4259
|
-
*
|
|
4260
|
-
*
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
* `addon-settings` is a **hub-centric** cap: the hub hosts the single
|
|
4264
|
-
* provider, and `nodeId` in the input is data for the hub provider's
|
|
4265
|
-
* internal dispatcher, not a routing hint for the cap-router. See
|
|
4266
|
-
* `CapabilityDefinition.nodeIdMode` for the contract.
|
|
4087
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
4088
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
4089
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
4090
|
+
* closed rather than emitting a garbage value.
|
|
4267
4091
|
*/
|
|
4268
|
-
|
|
4269
|
-
name:
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4092
|
+
function asFiniteNumber(value, name, index) {
|
|
4093
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
4094
|
+
return value;
|
|
4095
|
+
}
|
|
4096
|
+
function asString$1(value, name, index) {
|
|
4097
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
4098
|
+
return value;
|
|
4099
|
+
}
|
|
4100
|
+
function finiteResult(value, name) {
|
|
4101
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
4102
|
+
return value;
|
|
4103
|
+
}
|
|
4104
|
+
function allFiniteNumbers(args, name) {
|
|
4105
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
4106
|
+
}
|
|
4107
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
4108
|
+
var table = {
|
|
4109
|
+
min: {
|
|
4110
|
+
minArgs: 1,
|
|
4111
|
+
maxArgs: INF,
|
|
4112
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
4113
|
+
},
|
|
4114
|
+
max: {
|
|
4115
|
+
minArgs: 1,
|
|
4116
|
+
maxArgs: INF,
|
|
4117
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
4118
|
+
},
|
|
4119
|
+
abs: {
|
|
4120
|
+
minArgs: 1,
|
|
4121
|
+
maxArgs: 1,
|
|
4122
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
4123
|
+
},
|
|
4124
|
+
floor: {
|
|
4125
|
+
minArgs: 1,
|
|
4126
|
+
maxArgs: 1,
|
|
4127
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
4128
|
+
},
|
|
4129
|
+
ceil: {
|
|
4130
|
+
minArgs: 1,
|
|
4131
|
+
maxArgs: 1,
|
|
4132
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
4133
|
+
},
|
|
4134
|
+
sqrt: {
|
|
4135
|
+
minArgs: 1,
|
|
4136
|
+
maxArgs: 1,
|
|
4137
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
4138
|
+
},
|
|
4139
|
+
round: {
|
|
4140
|
+
minArgs: 1,
|
|
4141
|
+
maxArgs: 2,
|
|
4142
|
+
apply: (args) => {
|
|
4143
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
4144
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
4145
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
4146
|
+
const factor = 10 ** digits;
|
|
4147
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
4148
|
+
}
|
|
4149
|
+
},
|
|
4150
|
+
pow: {
|
|
4151
|
+
minArgs: 2,
|
|
4152
|
+
maxArgs: 2,
|
|
4153
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
4154
|
+
},
|
|
4155
|
+
clamp: {
|
|
4156
|
+
minArgs: 3,
|
|
4157
|
+
maxArgs: 3,
|
|
4158
|
+
apply: (args) => {
|
|
4159
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
4160
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
4161
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
4162
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
4163
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
4164
|
+
}
|
|
4165
|
+
},
|
|
4166
|
+
avg: {
|
|
4167
|
+
minArgs: 1,
|
|
4168
|
+
maxArgs: INF,
|
|
4169
|
+
apply: (args) => {
|
|
4170
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
4171
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
4172
|
+
}
|
|
4173
|
+
},
|
|
4174
|
+
sum: {
|
|
4175
|
+
minArgs: 1,
|
|
4176
|
+
maxArgs: INF,
|
|
4177
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
4178
|
+
},
|
|
4179
|
+
coalesce: {
|
|
4180
|
+
minArgs: 1,
|
|
4181
|
+
maxArgs: INF,
|
|
4182
|
+
apply: (args) => {
|
|
4183
|
+
for (const a of args) if (a !== null) return a;
|
|
4184
|
+
return null;
|
|
4185
|
+
}
|
|
4186
|
+
},
|
|
4187
|
+
age: {
|
|
4188
|
+
minArgs: 2,
|
|
4189
|
+
maxArgs: 2,
|
|
4190
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
4191
|
+
},
|
|
4192
|
+
convert: {
|
|
4193
|
+
minArgs: 3,
|
|
4194
|
+
maxArgs: 3,
|
|
4195
|
+
apply: (args, hooks) => {
|
|
4196
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
4197
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
4198
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
4199
|
+
if (hooks.convert) {
|
|
4200
|
+
const out = hooks.convert(x, from, to);
|
|
4201
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
4202
|
+
return finiteResult(out, "convert");
|
|
4203
|
+
}
|
|
4204
|
+
if (from === to) return x;
|
|
4205
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
4206
|
+
}
|
|
4302
4207
|
}
|
|
4303
4208
|
};
|
|
4209
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
4210
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
4211
|
+
* callees at parse time (immediate author feedback). */
|
|
4212
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
4304
4213
|
/**
|
|
4305
|
-
*
|
|
4306
|
-
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
4307
|
-
* widgets registers a provider on this collection cap; the hub-local
|
|
4308
|
-
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
4309
|
-
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
4310
|
-
* exposes the public listing surface that admin-ui consumes.
|
|
4311
|
-
*
|
|
4312
|
-
* The split exists because the public listing has a different output
|
|
4313
|
-
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
4314
|
-
* per-provider raw declarations. Both ends flow through codegen.
|
|
4214
|
+
* Resource-bound constants for the safe expression engine.
|
|
4315
4215
|
*
|
|
4316
|
-
*
|
|
4317
|
-
*
|
|
4318
|
-
*
|
|
4319
|
-
*
|
|
4320
|
-
*
|
|
4321
|
-
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
4322
|
-
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
4216
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
4217
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
4218
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
4219
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
4220
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
4323
4221
|
*/
|
|
4324
|
-
/**
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
"
|
|
4333
|
-
"
|
|
4334
|
-
"
|
|
4335
|
-
"
|
|
4222
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
4223
|
+
* rejected without allocation. */
|
|
4224
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
4225
|
+
/** A legal binding / identifier name. */
|
|
4226
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4227
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
4228
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
4229
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
4230
|
+
"now",
|
|
4231
|
+
"true",
|
|
4232
|
+
"false",
|
|
4233
|
+
"null"
|
|
4336
4234
|
]);
|
|
4337
4235
|
/**
|
|
4338
|
-
*
|
|
4339
|
-
*
|
|
4340
|
-
*
|
|
4341
|
-
*
|
|
4342
|
-
* `
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
remoteName: z.string(),
|
|
4346
|
-
exposedModule: z.string(),
|
|
4347
|
-
componentKey: z.string().optional()
|
|
4348
|
-
});
|
|
4349
|
-
/**
|
|
4350
|
-
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
4351
|
-
* widget-only metadata. The `UiContribution` core fields:
|
|
4352
|
-
*
|
|
4353
|
-
* - `tab` — where the widget hosts. A widget that runs on the
|
|
4354
|
-
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
4355
|
-
* widget declares the target device-detail tab id.
|
|
4356
|
-
* - `subTab` — optional sub-tab within `tab`.
|
|
4357
|
-
* - `label` — operator-facing label.
|
|
4358
|
-
* - `order` — ordering within `(tab, subTab)`.
|
|
4359
|
-
* - `kind` — always `'remote'` for widgets.
|
|
4360
|
-
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
4361
|
-
*
|
|
4362
|
-
* Widget-only fields retained alongside the contribution core:
|
|
4363
|
-
*
|
|
4364
|
-
* - `stableId` — stable identity within the addon (the MF
|
|
4365
|
-
* `componentKey`; kept top-level so consumers have
|
|
4366
|
-
* a stable key without reaching into `remote`).
|
|
4367
|
-
* - `description` / `icon` — picker metadata.
|
|
4368
|
-
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
4369
|
-
* aggregator stamps a versioned `bundleUrl` from it.
|
|
4370
|
-
* - `hosts` — every host the widget supports (a widget can run
|
|
4371
|
-
* both on the dashboard and a device tab). `tab`
|
|
4372
|
-
* is the PRIMARY host; `hosts` is the full set the
|
|
4373
|
-
* picker filters on.
|
|
4374
|
-
* - `requires` — host-context requirements validated at mount.
|
|
4375
|
-
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
4376
|
-
* — dashboard placement hints.
|
|
4236
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
4237
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
4238
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
4239
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
4240
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
4241
|
+
* is a parse error with a source position, so member access / assignment /
|
|
4242
|
+
* template literals are lexically impossible.
|
|
4377
4243
|
*/
|
|
4378
|
-
var
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4244
|
+
var KEYWORDS = new Set([
|
|
4245
|
+
"true",
|
|
4246
|
+
"false",
|
|
4247
|
+
"null"
|
|
4248
|
+
]);
|
|
4249
|
+
function isDigit(ch) {
|
|
4250
|
+
return ch >= "0" && ch <= "9";
|
|
4251
|
+
}
|
|
4252
|
+
function isIdentStart(ch) {
|
|
4253
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
4254
|
+
}
|
|
4255
|
+
function isIdentPart(ch) {
|
|
4256
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
4257
|
+
}
|
|
4258
|
+
function isWhitespace(ch) {
|
|
4259
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
4260
|
+
}
|
|
4261
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
4262
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
4263
|
+
* string. */
|
|
4264
|
+
function tokenize(source) {
|
|
4265
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
4266
|
+
const tokens = [];
|
|
4267
|
+
let i = 0;
|
|
4268
|
+
const n = source.length;
|
|
4269
|
+
while (i < n) {
|
|
4270
|
+
const ch = source[i];
|
|
4271
|
+
if (isWhitespace(ch)) {
|
|
4272
|
+
i += 1;
|
|
4273
|
+
continue;
|
|
4274
|
+
}
|
|
4275
|
+
if (isDigit(ch)) {
|
|
4276
|
+
const start = i;
|
|
4277
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
4278
|
+
if (i < n && source[i] === ".") {
|
|
4279
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
4280
|
+
i += 1;
|
|
4281
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
4282
|
+
}
|
|
4283
|
+
const text = source.slice(start, i);
|
|
4284
|
+
const value = Number(text);
|
|
4285
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
4286
|
+
tokens.push({
|
|
4287
|
+
type: "number",
|
|
4288
|
+
value,
|
|
4289
|
+
pos: start
|
|
4290
|
+
});
|
|
4291
|
+
continue;
|
|
4292
|
+
}
|
|
4293
|
+
if (ch === "'" || ch === "\"") {
|
|
4294
|
+
const quote = ch;
|
|
4295
|
+
const start = i;
|
|
4296
|
+
i += 1;
|
|
4297
|
+
let out = "";
|
|
4298
|
+
let closed = false;
|
|
4299
|
+
while (i < n) {
|
|
4300
|
+
const c = source[i];
|
|
4301
|
+
if (c === "\\") {
|
|
4302
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
4303
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
4304
|
+
out += next;
|
|
4305
|
+
i += 2;
|
|
4306
|
+
continue;
|
|
4307
|
+
}
|
|
4308
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
4309
|
+
}
|
|
4310
|
+
if (c === quote) {
|
|
4311
|
+
closed = true;
|
|
4312
|
+
i += 1;
|
|
4313
|
+
break;
|
|
4314
|
+
}
|
|
4315
|
+
out += c;
|
|
4316
|
+
i += 1;
|
|
4317
|
+
}
|
|
4318
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
4319
|
+
tokens.push({
|
|
4320
|
+
type: "string",
|
|
4321
|
+
value: out,
|
|
4322
|
+
pos: start
|
|
4323
|
+
});
|
|
4324
|
+
continue;
|
|
4325
|
+
}
|
|
4326
|
+
if (isIdentStart(ch)) {
|
|
4327
|
+
const start = i;
|
|
4328
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
4329
|
+
const text = source.slice(start, i);
|
|
4330
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
4331
|
+
type: "keyword",
|
|
4332
|
+
keyword: keywordOf(text),
|
|
4333
|
+
pos: start
|
|
4334
|
+
});
|
|
4335
|
+
else tokens.push({
|
|
4336
|
+
type: "identifier",
|
|
4337
|
+
name: text,
|
|
4338
|
+
pos: start
|
|
4339
|
+
});
|
|
4340
|
+
continue;
|
|
4341
|
+
}
|
|
4342
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
4343
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
4344
|
+
tokens.push({
|
|
4345
|
+
type: "punct",
|
|
4346
|
+
punct: two,
|
|
4347
|
+
pos: i
|
|
4348
|
+
});
|
|
4349
|
+
i += 2;
|
|
4350
|
+
continue;
|
|
4351
|
+
}
|
|
4352
|
+
if (isSinglePunct(ch)) {
|
|
4353
|
+
tokens.push({
|
|
4354
|
+
type: "punct",
|
|
4355
|
+
punct: ch,
|
|
4356
|
+
pos: i
|
|
4357
|
+
});
|
|
4358
|
+
i += 1;
|
|
4359
|
+
continue;
|
|
4360
|
+
}
|
|
4361
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
4362
|
+
}
|
|
4363
|
+
tokens.push({
|
|
4364
|
+
type: "eof",
|
|
4365
|
+
pos: n
|
|
4366
|
+
});
|
|
4367
|
+
return tokens;
|
|
4368
|
+
}
|
|
4369
|
+
function keywordOf(text) {
|
|
4370
|
+
if (text === "true") return "true";
|
|
4371
|
+
if (text === "false") return "false";
|
|
4372
|
+
return "null";
|
|
4373
|
+
}
|
|
4374
|
+
function isSinglePunct(ch) {
|
|
4375
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
4376
|
+
}
|
|
4377
|
+
/**
|
|
4378
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
4379
|
+
*
|
|
4380
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
4381
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
4382
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
4383
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
4384
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
4385
|
+
* that references a since-removed builtin degrades at read.
|
|
4386
|
+
*
|
|
4387
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
4388
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
4389
|
+
*/
|
|
4390
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
4391
|
+
var BINARY_PRECEDENCE = {
|
|
4392
|
+
"||": 1,
|
|
4393
|
+
"&&": 2,
|
|
4394
|
+
"==": 3,
|
|
4395
|
+
"!=": 3,
|
|
4396
|
+
"<": 4,
|
|
4397
|
+
"<=": 4,
|
|
4398
|
+
">": 4,
|
|
4399
|
+
">=": 4,
|
|
4400
|
+
"+": 5,
|
|
4401
|
+
"-": 5,
|
|
4402
|
+
"*": 6,
|
|
4403
|
+
"/": 6,
|
|
4404
|
+
"%": 6
|
|
4405
|
+
};
|
|
4406
|
+
function isLogicalOp(op) {
|
|
4407
|
+
return op === "&&" || op === "||";
|
|
4408
|
+
}
|
|
4409
|
+
function isBinaryOp(op) {
|
|
4410
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
4411
|
+
}
|
|
4412
|
+
var Parser = class {
|
|
4413
|
+
tokens;
|
|
4414
|
+
pos = 0;
|
|
4415
|
+
nodeCount = 0;
|
|
4416
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
4417
|
+
callees = /* @__PURE__ */ new Set();
|
|
4418
|
+
constructor(tokens) {
|
|
4419
|
+
this.tokens = tokens;
|
|
4420
|
+
}
|
|
4421
|
+
parse() {
|
|
4422
|
+
const ast = this.parseTernary();
|
|
4423
|
+
const tok = this.peek();
|
|
4424
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
4425
|
+
return {
|
|
4426
|
+
ast,
|
|
4427
|
+
identifiers: this.identifiers,
|
|
4428
|
+
callees: this.callees,
|
|
4429
|
+
nodeCount: this.nodeCount
|
|
4430
|
+
};
|
|
4431
|
+
}
|
|
4432
|
+
peek() {
|
|
4433
|
+
return this.tokens[this.pos];
|
|
4434
|
+
}
|
|
4435
|
+
next() {
|
|
4436
|
+
return this.tokens[this.pos++];
|
|
4437
|
+
}
|
|
4438
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
4439
|
+
expectPunct(punct) {
|
|
4440
|
+
const tok = this.peek();
|
|
4441
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
4442
|
+
this.pos += 1;
|
|
4443
|
+
}
|
|
4444
|
+
matchPunct(punct) {
|
|
4445
|
+
const tok = this.peek();
|
|
4446
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
4447
|
+
this.pos += 1;
|
|
4448
|
+
return true;
|
|
4449
|
+
}
|
|
4450
|
+
return false;
|
|
4451
|
+
}
|
|
4452
|
+
countNode() {
|
|
4453
|
+
this.nodeCount += 1;
|
|
4454
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
4455
|
+
}
|
|
4456
|
+
parseTernary() {
|
|
4457
|
+
const test = this.parseBinary(1);
|
|
4458
|
+
if (this.matchPunct("?")) {
|
|
4459
|
+
const consequent = this.parseTernary();
|
|
4460
|
+
this.expectPunct(":");
|
|
4461
|
+
const alternate = this.parseTernary();
|
|
4462
|
+
this.countNode();
|
|
4463
|
+
return {
|
|
4464
|
+
kind: "conditional",
|
|
4465
|
+
test,
|
|
4466
|
+
consequent,
|
|
4467
|
+
alternate
|
|
4468
|
+
};
|
|
4469
|
+
}
|
|
4470
|
+
return test;
|
|
4471
|
+
}
|
|
4472
|
+
parseBinary(minPrec) {
|
|
4473
|
+
let left = this.parseUnary();
|
|
4474
|
+
for (;;) {
|
|
4475
|
+
const tok = this.peek();
|
|
4476
|
+
if (tok.type !== "punct") break;
|
|
4477
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
4478
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
4479
|
+
const op = tok.punct;
|
|
4480
|
+
this.pos += 1;
|
|
4481
|
+
const right = this.parseBinary(prec + 1);
|
|
4482
|
+
this.countNode();
|
|
4483
|
+
if (isLogicalOp(op)) left = {
|
|
4484
|
+
kind: "logical",
|
|
4485
|
+
op,
|
|
4486
|
+
left,
|
|
4487
|
+
right
|
|
4488
|
+
};
|
|
4489
|
+
else if (isBinaryOp(op)) left = {
|
|
4490
|
+
kind: "binary",
|
|
4491
|
+
op,
|
|
4492
|
+
left,
|
|
4493
|
+
right
|
|
4494
|
+
};
|
|
4495
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
4496
|
+
}
|
|
4497
|
+
return left;
|
|
4498
|
+
}
|
|
4499
|
+
parseUnary() {
|
|
4500
|
+
const tok = this.peek();
|
|
4501
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
4502
|
+
const op = tok.punct;
|
|
4503
|
+
this.pos += 1;
|
|
4504
|
+
const operand = this.parseUnary();
|
|
4505
|
+
this.countNode();
|
|
4506
|
+
return {
|
|
4507
|
+
kind: "unary",
|
|
4508
|
+
op,
|
|
4509
|
+
operand
|
|
4510
|
+
};
|
|
4511
|
+
}
|
|
4512
|
+
return this.parsePrimary();
|
|
4513
|
+
}
|
|
4514
|
+
parsePrimary() {
|
|
4515
|
+
const tok = this.next();
|
|
4516
|
+
switch (tok.type) {
|
|
4517
|
+
case "number":
|
|
4518
|
+
this.countNode();
|
|
4519
|
+
return {
|
|
4520
|
+
kind: "literal",
|
|
4521
|
+
value: tok.value
|
|
4522
|
+
};
|
|
4523
|
+
case "string":
|
|
4524
|
+
this.countNode();
|
|
4525
|
+
return {
|
|
4526
|
+
kind: "literal",
|
|
4527
|
+
value: tok.value
|
|
4528
|
+
};
|
|
4529
|
+
case "keyword":
|
|
4530
|
+
this.countNode();
|
|
4531
|
+
return {
|
|
4532
|
+
kind: "literal",
|
|
4533
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
4534
|
+
};
|
|
4535
|
+
case "identifier": {
|
|
4536
|
+
const nextTok = this.peek();
|
|
4537
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
4538
|
+
this.identifiers.add(tok.name);
|
|
4539
|
+
this.countNode();
|
|
4540
|
+
return {
|
|
4541
|
+
kind: "identifier",
|
|
4542
|
+
name: tok.name
|
|
4543
|
+
};
|
|
4544
|
+
}
|
|
4545
|
+
case "punct":
|
|
4546
|
+
if (tok.punct === "(") {
|
|
4547
|
+
const inner = this.parseTernary();
|
|
4548
|
+
this.expectPunct(")");
|
|
4549
|
+
return inner;
|
|
4550
|
+
}
|
|
4551
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
4552
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
parseCall(callee, pos) {
|
|
4556
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
4557
|
+
this.expectPunct("(");
|
|
4558
|
+
const args = [];
|
|
4559
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
4560
|
+
args.push(this.parseTernary());
|
|
4561
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
4562
|
+
if (this.matchPunct(",")) continue;
|
|
4563
|
+
this.expectPunct(")");
|
|
4564
|
+
break;
|
|
4565
|
+
}
|
|
4566
|
+
this.callees.add(callee);
|
|
4567
|
+
this.countNode();
|
|
4568
|
+
return {
|
|
4569
|
+
kind: "call",
|
|
4570
|
+
callee,
|
|
4571
|
+
args
|
|
4572
|
+
};
|
|
4573
|
+
}
|
|
4574
|
+
};
|
|
4575
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
4576
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
4577
|
+
function parseExpression(source) {
|
|
4578
|
+
return new Parser(tokenize(source)).parse();
|
|
4579
|
+
}
|
|
4580
|
+
/**
|
|
4581
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
4582
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
4583
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
4584
|
+
* one per read on a hot resolve path.
|
|
4585
|
+
*
|
|
4586
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
4587
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
4588
|
+
* callers is safe and maximises hit rate.
|
|
4589
|
+
*/
|
|
4590
|
+
var cache = /* @__PURE__ */ new Map();
|
|
4591
|
+
function getCached(source) {
|
|
4592
|
+
const hit = cache.get(source);
|
|
4593
|
+
if (hit !== void 0) {
|
|
4594
|
+
cache.delete(source);
|
|
4595
|
+
cache.set(source, hit);
|
|
4596
|
+
return hit;
|
|
4597
|
+
}
|
|
4598
|
+
let result;
|
|
4599
|
+
try {
|
|
4600
|
+
result = {
|
|
4601
|
+
ok: true,
|
|
4602
|
+
parsed: parseExpression(source)
|
|
4603
|
+
};
|
|
4604
|
+
} catch (err) {
|
|
4605
|
+
result = {
|
|
4606
|
+
ok: false,
|
|
4607
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
4608
|
+
};
|
|
4609
|
+
}
|
|
4610
|
+
cache.set(source, result);
|
|
4611
|
+
if (cache.size > 256) {
|
|
4612
|
+
const oldest = cache.keys().next().value;
|
|
4613
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
4614
|
+
}
|
|
4615
|
+
return result;
|
|
4616
|
+
}
|
|
4617
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
4618
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
4619
|
+
function compileExpressionSafe(source) {
|
|
4620
|
+
return getCached(source);
|
|
4621
|
+
}
|
|
4622
|
+
Object.freeze({});
|
|
4623
|
+
/**
|
|
4624
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
4625
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
4626
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
4627
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
4628
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
4629
|
+
*/
|
|
4630
|
+
function validateExpressionSource(src) {
|
|
4631
|
+
const names = Object.keys(src.bindings);
|
|
4632
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
4633
|
+
for (const name of names) {
|
|
4634
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
4635
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
4636
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
4637
|
+
}
|
|
4638
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
4639
|
+
if (!compiled.ok) return compiled.error;
|
|
4640
|
+
const bound = new Set(names);
|
|
4641
|
+
for (const id of compiled.parsed.identifiers) {
|
|
4642
|
+
if (id === "now") continue;
|
|
4643
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
4644
|
+
}
|
|
4645
|
+
return null;
|
|
4646
|
+
}
|
|
4647
|
+
/**
|
|
4648
|
+
* What an expression's named bindings READ from.
|
|
4649
|
+
*
|
|
4650
|
+
* Salvaged verbatim from the deleted device-link mechanism. Wiring's source
|
|
4651
|
+
* kinds were the one part of it worth keeping: addressing a device field by
|
|
4652
|
+
* re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
|
|
4653
|
+
* read are the vocabulary any cross-device derivation needs, and they were
|
|
4654
|
+
* already correct. What wiring got wrong was the DESTINATION — a field on
|
|
4655
|
+
* somebody else's device, with no identity — not the source.
|
|
4656
|
+
*
|
|
4657
|
+
* These shapes are therefore kept, re-homed next to the engine that consumes
|
|
4658
|
+
* them, and are the binding type of a composition recipe (the source picker
|
|
4659
|
+
* stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
|
|
4660
|
+
* binding is a read, never another expression.
|
|
4661
|
+
*
|
|
4662
|
+
* Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
|
|
4663
|
+
* a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
|
|
4664
|
+
*/
|
|
4665
|
+
/** Read a sibling accessory's status field, addressed by the sibling's key.
|
|
4666
|
+
* `kind` is optional for wire compatibility — absent means `'field'`. */
|
|
4667
|
+
var ExpressionFieldBindingSchema = z.object({
|
|
4668
|
+
kind: z.literal("field").optional(),
|
|
4669
|
+
sourceKey: z.string(),
|
|
4670
|
+
cap: z.string(),
|
|
4671
|
+
fieldPath: z.string()
|
|
4672
|
+
});
|
|
4673
|
+
/** A constant. No device is read. */
|
|
4674
|
+
var ExpressionLiteralBindingSchema = z.object({
|
|
4675
|
+
kind: z.literal("literal"),
|
|
4676
|
+
value: z.union([
|
|
4677
|
+
z.string(),
|
|
4678
|
+
z.number(),
|
|
4679
|
+
z.boolean(),
|
|
4680
|
+
z.null()
|
|
4681
|
+
])
|
|
4682
|
+
});
|
|
4683
|
+
/** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
|
|
4684
|
+
* never by numeric id, which a re-adoption reissues. */
|
|
4685
|
+
var ExpressionGlobalBindingSchema = z.object({
|
|
4686
|
+
kind: z.literal("global"),
|
|
4687
|
+
sourceStableId: z.string(),
|
|
4688
|
+
cap: z.string(),
|
|
4689
|
+
fieldPath: z.string()
|
|
4690
|
+
});
|
|
4691
|
+
var ExpressionBindingSourceSchema = z.union([
|
|
4692
|
+
ExpressionFieldBindingSchema,
|
|
4693
|
+
ExpressionLiteralBindingSchema,
|
|
4694
|
+
ExpressionGlobalBindingSchema
|
|
4695
|
+
]);
|
|
4696
|
+
z.object({
|
|
4697
|
+
expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
4698
|
+
bindings: z.record(z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
4699
|
+
}).superRefine((src, ctx) => {
|
|
4700
|
+
const err = validateExpressionSource(src);
|
|
4701
|
+
if (err !== null) ctx.addIssue({
|
|
4702
|
+
code: "custom",
|
|
4703
|
+
message: err,
|
|
4704
|
+
path: ["expr"]
|
|
4705
|
+
});
|
|
4706
|
+
});
|
|
4707
|
+
/** How a leaf compares a device field to a value. Derived from the field's
|
|
4708
|
+
* `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
|
|
4709
|
+
var AutomationConditionOperatorSchema = z.enum([
|
|
4710
|
+
"eq",
|
|
4711
|
+
"ne",
|
|
4712
|
+
"gt",
|
|
4713
|
+
"gte",
|
|
4714
|
+
"lt",
|
|
4715
|
+
"lte",
|
|
4716
|
+
"contains",
|
|
4717
|
+
"in"
|
|
4718
|
+
]);
|
|
4719
|
+
var AutomationConditionLeafSchema = z.object({
|
|
4720
|
+
kind: z.literal("condition"),
|
|
4721
|
+
deviceId: z.number().int().nonnegative(),
|
|
4722
|
+
cap: z.string().min(1),
|
|
4723
|
+
fieldPath: z.string().min(1),
|
|
4724
|
+
operator: AutomationConditionOperatorSchema,
|
|
4725
|
+
value: z.union([
|
|
4726
|
+
z.string(),
|
|
4727
|
+
z.number(),
|
|
4728
|
+
z.boolean(),
|
|
4729
|
+
z.array(z.union([z.string(), z.number()]))
|
|
4730
|
+
])
|
|
4731
|
+
});
|
|
4732
|
+
/**
|
|
4733
|
+
* The expression leaf, declared as a plain object rather than an intersection
|
|
4734
|
+
* with {@link ExpressionSourceSchema}: a discriminated union has to be able to
|
|
4735
|
+
* read `kind` off each option, and an intersection hides it. The author-time
|
|
4736
|
+
* validation is the SAME function `ExpressionSourceSchema` runs, so the two
|
|
4737
|
+
* cannot drift — an expression that one accepts, the other accepts.
|
|
4738
|
+
*/
|
|
4739
|
+
var AutomationConditionExpressionSchema = z.object({
|
|
4740
|
+
kind: z.literal("expression"),
|
|
4741
|
+
expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
4742
|
+
bindings: z.record(z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
4743
|
+
}).superRefine((src, ctx) => {
|
|
4744
|
+
const err = validateExpressionSource(src);
|
|
4745
|
+
if (err !== null) ctx.addIssue({
|
|
4746
|
+
code: "custom",
|
|
4747
|
+
message: err,
|
|
4748
|
+
path: ["expr"]
|
|
4749
|
+
});
|
|
4750
|
+
});
|
|
4751
|
+
var AutomationConditionSchema = z.lazy(() => z.discriminatedUnion("kind", [
|
|
4752
|
+
z.object({
|
|
4753
|
+
kind: z.literal("all"),
|
|
4754
|
+
children: z.array(AutomationConditionSchema)
|
|
4755
|
+
}),
|
|
4756
|
+
z.object({
|
|
4757
|
+
kind: z.literal("any"),
|
|
4758
|
+
children: z.array(AutomationConditionSchema)
|
|
4759
|
+
}),
|
|
4760
|
+
z.object({
|
|
4761
|
+
kind: z.literal("not"),
|
|
4762
|
+
child: AutomationConditionSchema
|
|
4763
|
+
}),
|
|
4764
|
+
AutomationConditionLeafSchema,
|
|
4765
|
+
AutomationConditionExpressionSchema
|
|
4766
|
+
]));
|
|
4767
|
+
/**
|
|
4768
|
+
* What starts a run.
|
|
4769
|
+
*
|
|
4770
|
+
* D8 compliance, and it is the reason `device-state` is not merely an event
|
|
4771
|
+
* subscription: the trigger evaluates against the **state mirror**, which is
|
|
4772
|
+
* reconciled, and an event only WAKES the evaluation. A dropped event therefore
|
|
4773
|
+
* DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
|
|
4774
|
+
* already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
|
|
4775
|
+
* not.
|
|
4776
|
+
*/
|
|
4777
|
+
var AutomationTriggerSchema = z.discriminatedUnion("kind", [
|
|
4778
|
+
z.object({
|
|
4779
|
+
kind: z.literal("device-state"),
|
|
4780
|
+
deviceId: z.number().int().nonnegative(),
|
|
4781
|
+
cap: z.string().min(1),
|
|
4782
|
+
fieldPath: z.string().min(1),
|
|
4783
|
+
/** Fire when the field takes this value. Omit to fire on any change. */
|
|
4784
|
+
becomes: z.union([
|
|
4785
|
+
z.string(),
|
|
4786
|
+
z.number(),
|
|
4787
|
+
z.boolean()
|
|
4788
|
+
]).optional(),
|
|
4789
|
+
/** Only on a CHANGE of value, not on every re-report. */
|
|
4790
|
+
edge: z.boolean().optional(),
|
|
4791
|
+
/** The condition must hold this long before the run starts. */
|
|
4792
|
+
forMs: z.number().int().min(0).max(864e5).optional(),
|
|
4793
|
+
/** Collapse a burst into one run. */
|
|
4794
|
+
debounceMs: z.number().int().min(0).max(6e5).optional()
|
|
4795
|
+
}),
|
|
4796
|
+
z.object({
|
|
4797
|
+
kind: z.literal("device-event"),
|
|
4798
|
+
/** An `EventCategory` value. */
|
|
4799
|
+
category: z.string().min(1),
|
|
4800
|
+
deviceId: z.number().int().nonnegative().optional()
|
|
4801
|
+
}),
|
|
4802
|
+
z.object({
|
|
4803
|
+
kind: z.literal("schedule"),
|
|
4804
|
+
cron: z.string().min(1).max(120)
|
|
4805
|
+
}),
|
|
4806
|
+
z.object({ kind: z.literal("manual") })
|
|
4807
|
+
]);
|
|
4808
|
+
/**
|
|
4809
|
+
* One action step.
|
|
4810
|
+
*
|
|
4811
|
+
* `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
|
|
4812
|
+
* identical so `NcRuleActionRunner` runs them unchanged — its device-scope
|
|
4813
|
+
* check, stop-at-first-failure and per-sequence throttle are the whole reason
|
|
4814
|
+
* to reuse it, and none of them are re-implemented here.
|
|
4815
|
+
*
|
|
4816
|
+
* **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
|
|
4817
|
+
* a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
|
|
4818
|
+
* documents its subject device as *"for the log tag, never for routing"*. So an
|
|
4819
|
+
* NC action can never target the device that triggered it — which is fine for
|
|
4820
|
+
* the NC (its rules already scope to a device) and fatal for an automation
|
|
4821
|
+
* ("sound the siren of the camera that saw the person"). `deviceId` therefore
|
|
4822
|
+
* also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
|
|
4823
|
+
* is called. The runner still receives a number and is untouched; the
|
|
4824
|
+
* resolution is the recipe's job, not the runner's.
|
|
4825
|
+
*/
|
|
4826
|
+
var AutomationActionSchema = z.discriminatedUnion("kind", [
|
|
4827
|
+
z.object({
|
|
4828
|
+
kind: z.literal("wait"),
|
|
4829
|
+
seconds: z.number().min(0).max(300)
|
|
4830
|
+
}),
|
|
4831
|
+
z.object({
|
|
4832
|
+
kind: z.literal("cap"),
|
|
4833
|
+
deviceId: z.union([z.number().int(), z.object({ $var: z.string().min(1) })]),
|
|
4834
|
+
cap: z.string().min(1),
|
|
4835
|
+
method: z.string().min(1),
|
|
4836
|
+
/** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
|
|
4837
|
+
* evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
|
|
4838
|
+
args: z.record(z.string(), z.unknown()).optional()
|
|
4839
|
+
}),
|
|
4840
|
+
z.object({
|
|
4841
|
+
kind: z.literal("code"),
|
|
4842
|
+
/** Compiled into the automation's OWN block by esbuild — not a third
|
|
4843
|
+
* runtime, not a `vm`, and not dynamically evaluated. */
|
|
4844
|
+
code: z.string().min(1).max(2e4)
|
|
4845
|
+
})
|
|
4846
|
+
]);
|
|
4847
|
+
z.object({
|
|
4848
|
+
triggers: z.array(AutomationTriggerSchema),
|
|
4849
|
+
conditions: AutomationConditionSchema.optional(),
|
|
4850
|
+
actions: z.array(AutomationActionSchema)
|
|
4851
|
+
});
|
|
4852
|
+
/**
|
|
4853
|
+
* `addon-pages` — system-scoped singleton aggregator cap. Public-facing
|
|
4854
|
+
* surface that admin-ui consumes through `useAddonPagesListPages()`.
|
|
4855
|
+
*
|
|
4856
|
+
* The provider iterates every `addon-pages-source` (collection) provider
|
|
4857
|
+
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
4858
|
+
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
4859
|
+
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
4860
|
+
* rebuilds without manual reload.
|
|
4861
|
+
*
|
|
4862
|
+
* The hub-local builtin `addon-pages-aggregator` (see
|
|
4863
|
+
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
4864
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
4865
|
+
* keeps both ends in codegen — there's no hand-written
|
|
4866
|
+
* `addon-pages.router.ts` wrapper anymore.
|
|
4867
|
+
*/
|
|
4868
|
+
var AddonPageDeclarationSchema$1 = z.object({
|
|
4869
|
+
id: z.string(),
|
|
4870
|
+
label: z.string(),
|
|
4871
|
+
icon: z.string(),
|
|
4872
|
+
path: z.string(),
|
|
4873
|
+
remoteName: z.string(),
|
|
4874
|
+
bundle: z.string(),
|
|
4875
|
+
section: z.string().optional(),
|
|
4876
|
+
sectionLabel: z.string().optional()
|
|
4877
|
+
});
|
|
4878
|
+
var AddonPageInfoSchema = z.object({
|
|
4879
|
+
addonId: z.string(),
|
|
4880
|
+
page: AddonPageDeclarationSchema$1,
|
|
4881
|
+
bundleUrl: z.string()
|
|
4882
|
+
});
|
|
4883
|
+
var addonPagesCapability = {
|
|
4884
|
+
name: "addon-pages",
|
|
4885
|
+
scope: "system",
|
|
4886
|
+
mode: "singleton",
|
|
4887
|
+
methods: { listPages: method(z.void(), z.array(AddonPageInfoSchema).readonly()) }
|
|
4888
|
+
};
|
|
4889
|
+
/**
|
|
4890
|
+
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
4891
|
+
* declarations. Every addon that contributes a UI page registers a
|
|
4892
|
+
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
4893
|
+
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
4894
|
+
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
4895
|
+
* that admin-ui consumes.
|
|
4896
|
+
*
|
|
4897
|
+
* The split exists because the public listing has a different output
|
|
4898
|
+
* shape than the per-provider raw declarations, and we want both ends
|
|
4899
|
+
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
4900
|
+
*/
|
|
4901
|
+
var AddonPageDeclarationSchema = z.object({
|
|
4902
|
+
id: z.string(),
|
|
4903
|
+
label: z.string(),
|
|
4904
|
+
icon: z.string(),
|
|
4905
|
+
path: z.string(),
|
|
4906
|
+
/**
|
|
4907
|
+
* Module Federation remote name — must match the `name` field on the
|
|
4908
|
+
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
4909
|
+
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
4910
|
+
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
4911
|
+
* contain hyphens).
|
|
4912
|
+
*/
|
|
4913
|
+
remoteName: z.string(),
|
|
4914
|
+
/**
|
|
4915
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
4916
|
+
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
4917
|
+
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
4918
|
+
* the static-file route can compute an mtime-based cache-buster URL
|
|
4919
|
+
* without a separate filesystem stat.
|
|
4920
|
+
*/
|
|
4921
|
+
bundle: z.string(),
|
|
4922
|
+
/**
|
|
4923
|
+
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
4924
|
+
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
4925
|
+
* Any OTHER string creates (or joins) a custom section rendered after
|
|
4926
|
+
* the built-in groups; its label comes from `sectionLabel` (first
|
|
4927
|
+
* declaration wins), falling back to the id. Absent → the legacy
|
|
4928
|
+
* "Addon Pages" group.
|
|
4929
|
+
*/
|
|
4930
|
+
section: z.string().optional(),
|
|
4931
|
+
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
4932
|
+
sectionLabel: z.string().optional()
|
|
4933
|
+
});
|
|
4934
|
+
var addonPagesSourceCapability = {
|
|
4935
|
+
name: "addon-pages-source",
|
|
4936
|
+
scope: "system",
|
|
4937
|
+
mode: "collection",
|
|
4938
|
+
internal: true,
|
|
4939
|
+
methods: { listPages: method(z.void(), z.array(AddonPageDeclarationSchema).readonly()) }
|
|
4940
|
+
};
|
|
4941
|
+
var AddonHttpRouteSchema = z.object({
|
|
4942
|
+
method: z.enum([
|
|
4943
|
+
"GET",
|
|
4944
|
+
"POST",
|
|
4945
|
+
"PUT",
|
|
4946
|
+
"DELETE",
|
|
4947
|
+
"PATCH"
|
|
4948
|
+
]),
|
|
4949
|
+
path: z.string(),
|
|
4950
|
+
access: z.enum([
|
|
4951
|
+
"public",
|
|
4952
|
+
"authenticated",
|
|
4953
|
+
"admin"
|
|
4954
|
+
]).optional(),
|
|
4955
|
+
description: z.string().optional()
|
|
4956
|
+
});
|
|
4957
|
+
/**
|
|
4958
|
+
* Cross-process route invocation envelope. The hub captures the
|
|
4959
|
+
* request as plain data, ships it to the worker via Moleculer, and
|
|
4960
|
+
* the worker runs the local handler against a capturing reply. The
|
|
4961
|
+
* envelope returned describes what the handler intended (status,
|
|
4962
|
+
* headers, body, or a redirect) so the hub can translate it back to
|
|
4963
|
+
* the Fastify reply that's actually wired to the socket.
|
|
4964
|
+
*/
|
|
4965
|
+
var InvokeRequestSchema = z.object({
|
|
4966
|
+
method: z.string(),
|
|
4967
|
+
path: z.string(),
|
|
4968
|
+
params: z.record(z.string(), z.string()),
|
|
4969
|
+
query: z.record(z.string(), z.string()),
|
|
4970
|
+
body: z.unknown(),
|
|
4971
|
+
headers: z.record(z.string(), z.string()),
|
|
4972
|
+
user: z.object({
|
|
4973
|
+
id: z.string(),
|
|
4974
|
+
username: z.string(),
|
|
4975
|
+
isAdmin: z.boolean()
|
|
4976
|
+
}).optional(),
|
|
4977
|
+
scopedToken: z.unknown().optional()
|
|
4978
|
+
});
|
|
4979
|
+
var InvokeReplyEnvelopeSchema = z.object({
|
|
4980
|
+
status: z.number().int(),
|
|
4981
|
+
headers: z.record(z.string(), z.string()),
|
|
4982
|
+
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
4983
|
+
* sending `body`. Status defaults to 302 when this is set unless
|
|
4984
|
+
* the handler called `reply.code(...)` explicitly. */
|
|
4985
|
+
redirectUrl: z.string().nullable(),
|
|
4986
|
+
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
4987
|
+
body: z.unknown().optional(),
|
|
4988
|
+
/** Set when the handler called `reply.type(mime)`. */
|
|
4989
|
+
contentType: z.string().optional()
|
|
4990
|
+
});
|
|
4991
|
+
var addonRoutesCapability = {
|
|
4992
|
+
name: "addon-routes",
|
|
4993
|
+
scope: "system",
|
|
4994
|
+
mode: "collection",
|
|
4995
|
+
internal: true,
|
|
4996
|
+
methods: {
|
|
4997
|
+
getRoutes: method(z.void(), z.array(AddonHttpRouteSchema)),
|
|
4998
|
+
/**
|
|
4999
|
+
* Cross-process dispatch entry point. Forked addons implement this
|
|
5000
|
+
* (via `buildAddonRouteProvider`) so the hub's Fastify catch-all
|
|
5001
|
+
* can route through Moleculer when the handler lives in a worker.
|
|
5002
|
+
*
|
|
5003
|
+
* Local addons can implement it for free with the same helper;
|
|
5004
|
+
* the hub bypasses the wire on co-located addons.
|
|
5005
|
+
*/
|
|
5006
|
+
invoke: method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" })
|
|
5007
|
+
},
|
|
5008
|
+
/** BIG PLAN 2: declarative mount hint — read by `@camstack/system` `buildCapRouters`. */
|
|
5009
|
+
mount: { kind: "skip" }
|
|
5010
|
+
};
|
|
5011
|
+
var ConfigTabDeclarationSchema = z.object({
|
|
5012
|
+
id: z.string(),
|
|
5013
|
+
label: z.string(),
|
|
5014
|
+
icon: z.string(),
|
|
5015
|
+
order: z.number().optional()
|
|
5016
|
+
});
|
|
5017
|
+
var ConfigSectionWithValuesSchema = z.object({
|
|
5018
|
+
id: z.string(),
|
|
5019
|
+
title: z.string(),
|
|
5020
|
+
description: z.string().optional(),
|
|
5021
|
+
style: z.enum(["card", "accordion"]).optional(),
|
|
5022
|
+
defaultCollapsed: z.boolean().optional(),
|
|
5023
|
+
columns: z.union([
|
|
5024
|
+
z.literal(1),
|
|
5025
|
+
z.literal(2),
|
|
5026
|
+
z.literal(3),
|
|
5027
|
+
z.literal(4)
|
|
5028
|
+
]).optional(),
|
|
5029
|
+
tab: z.string().optional(),
|
|
5030
|
+
location: z.enum(["settings", "top-tab"]).optional(),
|
|
5031
|
+
order: z.number().optional(),
|
|
5032
|
+
fields: z.array(z.any())
|
|
5033
|
+
});
|
|
5034
|
+
var SettingsSchemaWithValuesSchema = z.object({
|
|
5035
|
+
tabs: z.array(ConfigTabDeclarationSchema).optional(),
|
|
5036
|
+
sections: z.array(ConfigSectionWithValuesSchema)
|
|
5037
|
+
});
|
|
5038
|
+
/** Patch object — keys are field names, values are the new field values. */
|
|
5039
|
+
var SettingsPatchSchema = z.record(z.string(), z.unknown());
|
|
5040
|
+
/** Standard success response for update operations. */
|
|
5041
|
+
var SettingsUpdateResultSchema = z.object({ success: z.literal(true) });
|
|
5042
|
+
/**
|
|
5043
|
+
* addon-settings — singleton gateway for three-level addon settings.
|
|
5044
|
+
*
|
|
5045
|
+
* Works like `device-manager`: a single hub-side provider that resolves
|
|
5046
|
+
* `addonId` to the target addon and delegates the call. For hub-local
|
|
5047
|
+
* addons the call is direct; for remote agents it proxies via the
|
|
5048
|
+
* per-addon Moleculer service.
|
|
5049
|
+
*
|
|
5050
|
+
* Replaces the `$addonHost` Moleculer service. Transport transparency
|
|
5051
|
+
* is handled by the provider implementation — callers (admin UI, other
|
|
5052
|
+
* addons via `ctx.api`) never know which node hosts the target addon.
|
|
5053
|
+
*
|
|
5054
|
+
* Three levels:
|
|
5055
|
+
* - **addon**: addon-scoped settings (installation config, API keys, …)
|
|
5056
|
+
* - **global**: settings applied to all devices by default
|
|
5057
|
+
* - **device**: per-device overrides
|
|
5058
|
+
*
|
|
5059
|
+
* Optional `nodeId` allows explicit node targeting. When absent the
|
|
5060
|
+
* provider resolves the addon's host node automatically.
|
|
5061
|
+
*/
|
|
5062
|
+
/**
|
|
5063
|
+
* `addon-settings` is a **hub-centric** cap: the hub hosts the single
|
|
5064
|
+
* provider, and `nodeId` in the input is data for the hub provider's
|
|
5065
|
+
* internal dispatcher, not a routing hint for the cap-router. See
|
|
5066
|
+
* `CapabilityDefinition.nodeIdMode` for the contract.
|
|
5067
|
+
*/
|
|
5068
|
+
var addonSettingsCapability = {
|
|
5069
|
+
name: "addon-settings",
|
|
5070
|
+
scope: "system",
|
|
5071
|
+
mode: "singleton",
|
|
5072
|
+
nodeIdMode: "data",
|
|
5073
|
+
methods: {
|
|
5074
|
+
getGlobalSettings: method(z.object({
|
|
5075
|
+
addonId: z.string(),
|
|
5076
|
+
nodeId: z.string().optional(),
|
|
5077
|
+
overlay: z.record(z.string(), z.unknown()).optional(),
|
|
5078
|
+
cap: z.string().optional()
|
|
5079
|
+
}), SettingsSchemaWithValuesSchema.nullable()),
|
|
5080
|
+
updateGlobalSettings: method(z.object({
|
|
5081
|
+
addonId: z.string(),
|
|
5082
|
+
nodeId: z.string().optional(),
|
|
5083
|
+
patch: SettingsPatchSchema
|
|
5084
|
+
}), SettingsUpdateResultSchema, {
|
|
5085
|
+
kind: "mutation",
|
|
5086
|
+
auth: "admin"
|
|
5087
|
+
}),
|
|
5088
|
+
getDeviceSettings: method(z.object({
|
|
5089
|
+
addonId: z.string(),
|
|
5090
|
+
deviceId: z.number(),
|
|
5091
|
+
nodeId: z.string().optional()
|
|
5092
|
+
}), SettingsSchemaWithValuesSchema.nullable()),
|
|
5093
|
+
updateDeviceSettings: method(z.object({
|
|
5094
|
+
addonId: z.string(),
|
|
5095
|
+
deviceId: z.number(),
|
|
5096
|
+
nodeId: z.string().optional(),
|
|
5097
|
+
patch: SettingsPatchSchema
|
|
5098
|
+
}), SettingsUpdateResultSchema, {
|
|
5099
|
+
kind: "mutation",
|
|
5100
|
+
auth: "admin"
|
|
5101
|
+
})
|
|
5102
|
+
}
|
|
5103
|
+
};
|
|
5104
|
+
/**
|
|
5105
|
+
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
5106
|
+
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
5107
|
+
* widgets registers a provider on this collection cap; the hub-local
|
|
5108
|
+
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
5109
|
+
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
5110
|
+
* exposes the public listing surface that admin-ui consumes.
|
|
5111
|
+
*
|
|
5112
|
+
* The split exists because the public listing has a different output
|
|
5113
|
+
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
5114
|
+
* per-provider raw declarations. Both ends flow through codegen.
|
|
5115
|
+
*
|
|
5116
|
+
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
5117
|
+
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
5118
|
+
* same `ContributionRenderer` / Module-Federation path as every other
|
|
5119
|
+
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
5120
|
+
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
5121
|
+
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
5122
|
+
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
5123
|
+
*/
|
|
5124
|
+
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
5125
|
+
var WidgetHostEnum = z.enum([
|
|
5126
|
+
"device-tab",
|
|
5127
|
+
"dashboard",
|
|
5128
|
+
"integration-detail"
|
|
5129
|
+
]);
|
|
5130
|
+
var WidgetSizeEnum = z.enum([
|
|
5131
|
+
"xs",
|
|
5132
|
+
"sm",
|
|
5133
|
+
"md",
|
|
5134
|
+
"lg",
|
|
5135
|
+
"xl"
|
|
5136
|
+
]);
|
|
5137
|
+
/**
|
|
5138
|
+
* MF remote descriptor — mirrors `UiContributionRemote` from
|
|
5139
|
+
* `capability-definition.ts`. Widget remotes expose a single
|
|
5140
|
+
* `'./widgets'` module whose default export is a
|
|
5141
|
+
* `Record<componentKey, Component>` map; `componentKey` (the widget
|
|
5142
|
+
* `stableId`) picks the entry the host mounts.
|
|
5143
|
+
*/
|
|
5144
|
+
var WidgetRemoteSchema = z.object({
|
|
5145
|
+
remoteName: z.string(),
|
|
5146
|
+
exposedModule: z.string(),
|
|
5147
|
+
componentKey: z.string().optional()
|
|
5148
|
+
});
|
|
5149
|
+
/**
|
|
5150
|
+
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
5151
|
+
* widget-only metadata. The `UiContribution` core fields:
|
|
5152
|
+
*
|
|
5153
|
+
* - `tab` — where the widget hosts. A widget that runs on the
|
|
5154
|
+
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
5155
|
+
* widget declares the target device-detail tab id.
|
|
5156
|
+
* - `subTab` — optional sub-tab within `tab`.
|
|
5157
|
+
* - `label` — operator-facing label.
|
|
5158
|
+
* - `order` — ordering within `(tab, subTab)`.
|
|
5159
|
+
* - `kind` — always `'remote'` for widgets.
|
|
5160
|
+
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
5161
|
+
*
|
|
5162
|
+
* Widget-only fields retained alongside the contribution core:
|
|
5163
|
+
*
|
|
5164
|
+
* - `stableId` — stable identity within the addon (the MF
|
|
5165
|
+
* `componentKey`; kept top-level so consumers have
|
|
5166
|
+
* a stable key without reaching into `remote`).
|
|
5167
|
+
* - `description` / `icon` — picker metadata.
|
|
5168
|
+
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
5169
|
+
* aggregator stamps a versioned `bundleUrl` from it.
|
|
5170
|
+
* - `hosts` — every host the widget supports (a widget can run
|
|
5171
|
+
* both on the dashboard and a device tab). `tab`
|
|
5172
|
+
* is the PRIMARY host; `hosts` is the full set the
|
|
5173
|
+
* picker filters on.
|
|
5174
|
+
* - `requires` — host-context requirements validated at mount.
|
|
5175
|
+
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
5176
|
+
* — dashboard placement hints.
|
|
5177
|
+
*/
|
|
5178
|
+
var WidgetMetadataSchema = z.object({
|
|
5179
|
+
/** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
|
|
5180
|
+
tab: z.string(),
|
|
5181
|
+
/** Optional sub-tab within `tab`. */
|
|
5182
|
+
subTab: z.string().optional(),
|
|
5183
|
+
/** Operator-facing label. */
|
|
5184
|
+
label: z.string(),
|
|
5185
|
+
/** Ordering within `(tab, subTab)`, ascending. */
|
|
5186
|
+
order: z.number().optional(),
|
|
5187
|
+
/** Always `'remote'` — a widget is a Module Federation remote. */
|
|
5188
|
+
kind: z.literal("remote"),
|
|
5189
|
+
/** MF remote descriptor. */
|
|
5190
|
+
remote: WidgetRemoteSchema,
|
|
5191
|
+
/** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
|
|
5192
|
+
stableId: z.string(),
|
|
5193
|
+
description: z.string().optional(),
|
|
5194
|
+
icon: z.string().optional(),
|
|
5195
|
+
/**
|
|
5196
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
5197
|
+
* `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
|
|
5198
|
+
* this is always `'remoteEntry.js'` — the value is kept on the
|
|
5199
|
+
* metadata so the static-file route can compute an mtime-based
|
|
5200
|
+
* cache-buster URL without a separate filesystem stat.
|
|
5201
|
+
*/
|
|
5202
|
+
bundle: z.string(),
|
|
5203
|
+
/** Every host the widget supports. The picker filters on this set. */
|
|
4404
5204
|
hosts: z.array(WidgetHostEnum).readonly(),
|
|
4405
5205
|
/** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
|
|
4406
5206
|
requires: z.object({
|
|
@@ -6598,6 +7398,23 @@ function kebabToCamel(s) {
|
|
|
6598
7398
|
* too many — a drift would show as a logs pane that is simply always empty.
|
|
6599
7399
|
*/
|
|
6600
7400
|
var CORE_BLOCK_ADDON_PREFIX = "core-block-";
|
|
7401
|
+
/**
|
|
7402
|
+
* The builtin's own addon id — and, because of that, the OWNER of the
|
|
7403
|
+
* integration every block's devices hang from.
|
|
7404
|
+
*
|
|
7405
|
+
* A block cannot own one. Every integration read is gated on the installed-addon
|
|
7406
|
+
* set (`addon-registry.service.ts` → `createFilteredRegistry`) and a block's
|
|
7407
|
+
* `core-block-<uuid>` is never in it, so its `getIntegrationByAddonId` answers
|
|
7408
|
+
* null forever while the write succeeds — one invisible, undeletable row per
|
|
7409
|
+
* start. `core-blocks` IS installed, so it reconciles one shared integration and
|
|
7410
|
+
* a block names it by id (`DeclaredDevicesSpec.integrationId`), minting nothing.
|
|
7411
|
+
*
|
|
7412
|
+
* Exported from the contract, next to the runner prefix and for the same reason:
|
|
7413
|
+
* a block reads it as
|
|
7414
|
+
* `ctx.api.integrations.getByAddonId.query({ addonId: CORE_BLOCKS_ADDON_ID })`,
|
|
7415
|
+
* so a second copy of the string is a block that silently declares into nowhere.
|
|
7416
|
+
*/
|
|
7417
|
+
var CORE_BLOCKS_ADDON_ID = "core-blocks";
|
|
6601
7418
|
/** The addon/runner id a block's process runs under. */
|
|
6602
7419
|
function coreBlockAddonId(blockId) {
|
|
6603
7420
|
return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
|
|
@@ -9728,7 +10545,7 @@ var motionDetectionCapability = {
|
|
|
9728
10545
|
* Why: pub/sub routing over the system event-bus loses fidelity
|
|
9729
10546
|
* (callback shape, QoS guarantees, will/retain semantics) and adds
|
|
9730
10547
|
* refcount bookkeeping that addons would rather own themselves. The
|
|
9731
|
-
* canonical consumer
|
|
10548
|
+
* canonical consumer needs raw `mqtt.js`
|
|
9732
10549
|
* features anyway — give it the connection config, get out of the way.
|
|
9733
10550
|
*
|
|
9734
10551
|
* Consumer flow:
|
|
@@ -12471,8 +13288,26 @@ var TrackSchema = z.object({
|
|
|
12471
13288
|
/** Periodic snapshots at snapshotIntervalMs cadence (subject to
|
|
12472
13289
|
* saveThumbnails policy). */
|
|
12473
13290
|
snapshots: z.array(TrackSnapshotSchema).readonly(),
|
|
12474
|
-
/** Deduplicated zones the track has entered at least once. */
|
|
13291
|
+
/** Deduplicated zones the track has entered at least once. Zone IDS. */
|
|
12475
13292
|
zonesVisited: z.array(z.string()).readonly(),
|
|
13293
|
+
/**
|
|
13294
|
+
* Human NAMES for {@link zonesVisited}, resolved at READ time against the
|
|
13295
|
+
* `zones` capability.
|
|
13296
|
+
*
|
|
13297
|
+
* `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
|
|
13298
|
+
* and no card can render — so every free-text search surface was structurally
|
|
13299
|
+
* unable to answer "show me the tracks in Uscio", and did not fail loudly, it
|
|
13300
|
+
* just returned nothing. Resolving here rather than in each client keeps ONE
|
|
13301
|
+
* derivation and costs the clients no extra call (the `zones` cap is
|
|
13302
|
+
* per-device, so a client-side resolve would be a per-camera fan-out on a
|
|
13303
|
+
* surface built to avoid exactly that).
|
|
13304
|
+
*
|
|
13305
|
+
* Resolved, never invented: a zone deleted since the track was written has no
|
|
13306
|
+
* name and is DROPPED, so this array can be shorter than `zonesVisited` — the
|
|
13307
|
+
* two are not positionally aligned. Absent when the track visited no zone, or
|
|
13308
|
+
* when the zone catalogue could not be read.
|
|
13309
|
+
*/
|
|
13310
|
+
zoneNames: z.array(z.string()).readonly().optional(),
|
|
12476
13311
|
/** Deduplicated set of detector classes observed for this track over its
|
|
12477
13312
|
* life (a track may be reclassified, e.g. person→vehicle). Absent on
|
|
12478
13313
|
* legacy rows written before class accumulation shipped. */
|
|
@@ -16277,6 +17112,77 @@ var snapshotCapability = {
|
|
|
16277
17112
|
lastCapturedAt: z.number().nullable(),
|
|
16278
17113
|
cacheAgeMs: z.number().nullable(),
|
|
16279
17114
|
etag: z.string().nullable()
|
|
17115
|
+
}))),
|
|
17116
|
+
/**
|
|
17117
|
+
* Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
|
|
17118
|
+
* that makes those frames current.
|
|
17119
|
+
*
|
|
17120
|
+
* ## The problem it replaces
|
|
17121
|
+
*
|
|
17122
|
+
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
17123
|
+
* the wrapper happens to hold and never captures. Under D93 the client
|
|
17124
|
+
* versions its image URL on that answer, and an image REQUEST is what enrols
|
|
17125
|
+
* a camera in the keep-warm loop. Both of those are satisfiable by the
|
|
17126
|
+
* client's own image cache — `expo-image` is URL-keyed and never revalidates
|
|
17127
|
+
* — so a URL painted in a previous session comes off disk with no network,
|
|
17128
|
+
* no enrolment, and nothing warming. Measured on the live hub: reopening
|
|
17129
|
+
* after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
|
|
17130
|
+
* HTTP requests, and the fleet only recovered because a later poll happened
|
|
17131
|
+
* to observe a different identity.
|
|
17132
|
+
*
|
|
17133
|
+
* ## The two properties that fix it
|
|
17134
|
+
*
|
|
17135
|
+
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
17136
|
+
* always reaches the wrapper. This method therefore MAY create keep-warm
|
|
17137
|
+
* subscriptions, where `getSnapshotOverview` must never (D93) — the
|
|
17138
|
+
* distinction is not "one is newer" but that the overview poll is app-wide
|
|
17139
|
+
* (a creating overview would warm every camera on the install) while this is
|
|
17140
|
+
* called by a rendered surface naming the tiles it is actually painting, at
|
|
17141
|
+
* the width it is painting them.
|
|
17142
|
+
*
|
|
17143
|
+
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
17144
|
+
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
17145
|
+
* cache held when the client asked, so a first paint is honest and current
|
|
17146
|
+
* instead of a generation behind. A device that does not settle inside the
|
|
17147
|
+
* bound still gets a link and its real (older) `capturedAt` — the next poll
|
|
17148
|
+
* carries it forward.
|
|
17149
|
+
*
|
|
17150
|
+
* `force` is never set on behalf of a client here. A sleeping battery camera
|
|
17151
|
+
* is reported with `sleeping: true` and the last frame it produced, however
|
|
17152
|
+
* old; the wrapper's existing sleep gate owns that decision and this method
|
|
17153
|
+
* adds no second one.
|
|
17154
|
+
*/
|
|
17155
|
+
getSnapshotLinks: systemMethod(z.object({
|
|
17156
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
17157
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
17158
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
17159
|
+
targets: z.array(z.object({
|
|
17160
|
+
deviceId: z.number(),
|
|
17161
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
17162
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
17163
|
+
width: z.number().int().positive().optional()
|
|
17164
|
+
})).min(1).max(200) }), z.array(z.object({
|
|
17165
|
+
deviceId: z.number(),
|
|
17166
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
17167
|
+
* served (no data-plane facility). Present even for a device that has
|
|
17168
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
17169
|
+
url: z.string().nullable(),
|
|
17170
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
17171
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
17172
|
+
capturedAt: z.number().nullable(),
|
|
17173
|
+
/** Age of that frame at the moment the answer was built. */
|
|
17174
|
+
ageMs: z.number().nullable(),
|
|
17175
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
17176
|
+
expiresAt: z.number().nullable(),
|
|
17177
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
17178
|
+
width: z.number().nullable(),
|
|
17179
|
+
/** The device has never produced a frame. An empty state, not a
|
|
17180
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
17181
|
+
neverCaptured: z.boolean(),
|
|
17182
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
17183
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
17184
|
+
* present it as current. */
|
|
17185
|
+
sleeping: z.boolean()
|
|
16280
17186
|
})))
|
|
16281
17187
|
},
|
|
16282
17188
|
status: {
|
|
@@ -25244,1231 +26150,786 @@ var ValveStatusSchema = z.object({
|
|
|
25244
26150
|
/** Lifecycle state of the valve. */
|
|
25245
26151
|
state: ValveStateSchema,
|
|
25246
26152
|
/** 0 = fully closed, 100 = fully open. Null when the device has no
|
|
25247
|
-
* intermediate position surface. */
|
|
25248
|
-
position: z.number().min(0).max(100).nullable(),
|
|
25249
|
-
/** Ms epoch when the slice was last updated. */
|
|
25250
|
-
lastChangedAt: z.number()
|
|
25251
|
-
});
|
|
25252
|
-
var valveCapability = {
|
|
25253
|
-
name: "valve",
|
|
25254
|
-
scope: "device",
|
|
25255
|
-
deviceNative: true,
|
|
25256
|
-
mode: "singleton",
|
|
25257
|
-
deviceTypes: [DeviceType.Valve],
|
|
25258
|
-
methods: {
|
|
25259
|
-
open: method(z.object({ deviceId: z.number().int().nonnegative() }), z.void(), {
|
|
25260
|
-
kind: "mutation",
|
|
25261
|
-
auth: "admin"
|
|
25262
|
-
}),
|
|
25263
|
-
close: method(z.object({ deviceId: z.number().int().nonnegative() }), z.void(), {
|
|
25264
|
-
kind: "mutation",
|
|
25265
|
-
auth: "admin"
|
|
25266
|
-
}),
|
|
25267
|
-
stop: method(z.object({ deviceId: z.number().int().nonnegative() }), z.void(), {
|
|
25268
|
-
kind: "mutation",
|
|
25269
|
-
auth: "admin"
|
|
25270
|
-
}),
|
|
25271
|
-
setPosition: method(z.object({
|
|
25272
|
-
deviceId: z.number().int().nonnegative(),
|
|
25273
|
-
position: z.number().min(0).max(100)
|
|
25274
|
-
}), z.void(), {
|
|
25275
|
-
kind: "mutation",
|
|
25276
|
-
auth: "admin"
|
|
25277
|
-
})
|
|
25278
|
-
},
|
|
25279
|
-
status: {
|
|
25280
|
-
schema: ValveStatusSchema,
|
|
25281
|
-
kind: "push"
|
|
25282
|
-
},
|
|
25283
|
-
/**
|
|
25284
|
-
* Runtime-state slice — mirrored by the kernel. UI controls watch the
|
|
25285
|
-
* slice for live position changes during a move.
|
|
25286
|
-
*/
|
|
25287
|
-
runtimeState: ValveStatusSchema
|
|
25288
|
-
};
|
|
25289
|
-
/**
|
|
25290
|
-
* Vibration / shake / impact sensor. Drives Home Assistant
|
|
25291
|
-
* `binary_sensor` entries with `device_class: vibration`. Push-driven.
|
|
25292
|
-
*/
|
|
25293
|
-
var VibrationStatusSchema = z.object({
|
|
25294
|
-
detected: z.boolean(),
|
|
25295
|
-
/** Ms epoch of the last transition. 0 if never observed. */
|
|
25296
|
-
lastChangedAt: z.number()
|
|
25297
|
-
});
|
|
25298
|
-
var vibrationCapability = {
|
|
25299
|
-
name: "vibration",
|
|
25300
|
-
scope: "device",
|
|
25301
|
-
deviceNative: true,
|
|
25302
|
-
mode: "singleton",
|
|
25303
|
-
deviceTypes: [DeviceType.Sensor],
|
|
25304
|
-
methods: {},
|
|
25305
|
-
status: {
|
|
25306
|
-
schema: VibrationStatusSchema,
|
|
25307
|
-
kind: "push"
|
|
25308
|
-
},
|
|
25309
|
-
runtimeState: VibrationStatusSchema
|
|
25310
|
-
};
|
|
25311
|
-
/**
|
|
25312
|
-
* Water heater / boiler cap. Models HA `water_heater.*` entities — a
|
|
25313
|
-
* climate-family actuator with a target temperature, an operation-mode
|
|
25314
|
-
* selector (`eco` / `electric` / `gas` / `heat_pump` / `high_demand` /
|
|
25315
|
-
* `performance` / `off`), and an optional away-mode toggle.
|
|
25316
|
-
*
|
|
25317
|
-
* The slice carries the current + target temperature, the active
|
|
25318
|
-
* operation mode (HA's `state`) and its available set (HA's
|
|
25319
|
-
* `operation_list`), the away flag (HA `away_mode` 'on'/'off' → bool,
|
|
25320
|
-
* null when unsupported), plus the `min_temp` / `max_temp` bounds.
|
|
25321
|
-
*
|
|
25322
|
-
* Providers populate only what the hardware reports — temperature and
|
|
25323
|
-
* away fields stay null when the device has no such surface.
|
|
25324
|
-
*/
|
|
25325
|
-
var WaterHeaterStatusSchema = z.object({
|
|
25326
|
-
/** Current measured temperature. Null when not reported. */
|
|
25327
|
-
currentTemp: z.number().nullable(),
|
|
25328
|
-
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
25329
|
-
targetTemp: z.number().nullable(),
|
|
25330
|
-
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
25331
|
-
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
25332
|
-
* device reports an unknown state. */
|
|
25333
|
-
operationMode: z.string().nullable(),
|
|
25334
|
-
/** Available operation modes = HA `operation_list`. */
|
|
25335
|
-
availableModes: z.array(z.string()),
|
|
25336
|
-
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
25337
|
-
* has no away surface. */
|
|
25338
|
-
away: z.boolean().nullable(),
|
|
25339
|
-
/** HA `min_temp` attribute. Null when not reported. */
|
|
25340
|
-
minTemp: z.number().nullable(),
|
|
25341
|
-
/** HA `max_temp` attribute. Null when not reported. */
|
|
25342
|
-
maxTemp: z.number().nullable(),
|
|
25343
|
-
/** Ms epoch when the slice was last updated. */
|
|
25344
|
-
lastChangedAt: z.number()
|
|
25345
|
-
});
|
|
25346
|
-
var waterHeaterCapability = {
|
|
25347
|
-
name: "water-heater",
|
|
25348
|
-
scope: "device",
|
|
25349
|
-
deviceNative: true,
|
|
25350
|
-
mode: "singleton",
|
|
25351
|
-
deviceTypes: [DeviceType.WaterHeater],
|
|
25352
|
-
methods: {
|
|
25353
|
-
setTargetTemp: method(z.object({
|
|
25354
|
-
deviceId: z.number().int().nonnegative(),
|
|
25355
|
-
temp: z.number().finite()
|
|
25356
|
-
}), z.void(), {
|
|
25357
|
-
kind: "mutation",
|
|
25358
|
-
auth: "admin"
|
|
25359
|
-
}),
|
|
25360
|
-
setOperationMode: method(z.object({
|
|
25361
|
-
deviceId: z.number().int().nonnegative(),
|
|
25362
|
-
mode: z.string().min(1)
|
|
25363
|
-
}), z.void(), {
|
|
25364
|
-
kind: "mutation",
|
|
25365
|
-
auth: "admin"
|
|
25366
|
-
}),
|
|
25367
|
-
setAway: method(z.object({
|
|
25368
|
-
deviceId: z.number().int().nonnegative(),
|
|
25369
|
-
on: z.boolean()
|
|
25370
|
-
}), z.void(), {
|
|
25371
|
-
kind: "mutation",
|
|
25372
|
-
auth: "admin"
|
|
25373
|
-
})
|
|
25374
|
-
},
|
|
25375
|
-
status: {
|
|
25376
|
-
schema: WaterHeaterStatusSchema,
|
|
25377
|
-
kind: "push"
|
|
25378
|
-
},
|
|
25379
|
-
/**
|
|
25380
|
-
* Runtime-state slice — mirrored by the kernel. UI controls watch the
|
|
25381
|
-
* slice for live temperature / mode / away changes.
|
|
25382
|
-
*/
|
|
25383
|
-
runtimeState: WaterHeaterStatusSchema
|
|
25384
|
-
};
|
|
25385
|
-
/**
|
|
25386
|
-
* Weather provider cap. Models HA `weather.*` entities — a read-only
|
|
25387
|
-
* snapshot of the CURRENT conditions a weather integration reports.
|
|
25388
|
-
*
|
|
25389
|
-
* Read-only: there are no setters. The slice is populated from upstream
|
|
25390
|
-
* pushes (the HA weather entity's state + attributes) and rendered by
|
|
25391
|
-
* the UI as a sky scene + readouts.
|
|
25392
|
-
*
|
|
25393
|
-
* `condition` is the verbatim HA state string (`sunny` / `cloudy` /
|
|
25394
|
-
* `rainy` / `snowy` / `partlycloudy` / `pouring` / `lightning` /
|
|
25395
|
-
* `lightning-rainy` / `fog` / `windy` / `windy-variant` / `hail` /
|
|
25396
|
-
* `clear-night` / `exceptional` / …). The UI maps it to a glyph + tint;
|
|
25397
|
-
* unknown strings fall back to a neutral cloud.
|
|
25398
|
-
*
|
|
25399
|
-
* Every numeric reading is nullable — a given weather integration only
|
|
25400
|
-
* populates the metrics it actually provides.
|
|
25401
|
-
*
|
|
25402
|
-
* Forecast deferred — current conditions only for v1.
|
|
25403
|
-
*/
|
|
25404
|
-
var WeatherStatusSchema = z.object({
|
|
25405
|
-
/** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
|
|
25406
|
-
* when no condition has been reported yet. */
|
|
25407
|
-
condition: z.string().nullable(),
|
|
25408
|
-
/** Current temperature in the reported unit. Null when not provided. */
|
|
25409
|
-
temperature: z.number().nullable(),
|
|
25410
|
-
/** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
|
|
25411
|
-
temperatureUnit: z.string().nullable(),
|
|
25412
|
-
/** Relative humidity (0..100). Null when not provided. */
|
|
25413
|
-
humidity: z.number().min(0).max(100).nullable(),
|
|
25414
|
-
/** Barometric pressure in the reported unit. Null when not provided. */
|
|
25415
|
-
pressure: z.number().nullable(),
|
|
25416
|
-
/** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
|
|
25417
|
-
pressureUnit: z.string().nullable(),
|
|
25418
|
-
/** Wind speed in the reported unit. Null when not provided. */
|
|
25419
|
-
windSpeed: z.number().nullable(),
|
|
25420
|
-
/** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
|
|
25421
|
-
windSpeedUnit: z.string().nullable(),
|
|
25422
|
-
/** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
|
|
25423
|
-
windBearing: z.number().nullable(),
|
|
26153
|
+
* intermediate position surface. */
|
|
26154
|
+
position: z.number().min(0).max(100).nullable(),
|
|
25424
26155
|
/** Ms epoch when the slice was last updated. */
|
|
25425
|
-
|
|
26156
|
+
lastChangedAt: z.number()
|
|
25426
26157
|
});
|
|
25427
|
-
var
|
|
25428
|
-
name: "
|
|
26158
|
+
var valveCapability = {
|
|
26159
|
+
name: "valve",
|
|
25429
26160
|
scope: "device",
|
|
25430
26161
|
deviceNative: true,
|
|
25431
26162
|
mode: "singleton",
|
|
25432
|
-
deviceTypes: [DeviceType.
|
|
25433
|
-
methods: {
|
|
26163
|
+
deviceTypes: [DeviceType.Valve],
|
|
26164
|
+
methods: {
|
|
26165
|
+
open: method(z.object({ deviceId: z.number().int().nonnegative() }), z.void(), {
|
|
26166
|
+
kind: "mutation",
|
|
26167
|
+
auth: "admin"
|
|
26168
|
+
}),
|
|
26169
|
+
close: method(z.object({ deviceId: z.number().int().nonnegative() }), z.void(), {
|
|
26170
|
+
kind: "mutation",
|
|
26171
|
+
auth: "admin"
|
|
26172
|
+
}),
|
|
26173
|
+
stop: method(z.object({ deviceId: z.number().int().nonnegative() }), z.void(), {
|
|
26174
|
+
kind: "mutation",
|
|
26175
|
+
auth: "admin"
|
|
26176
|
+
}),
|
|
26177
|
+
setPosition: method(z.object({
|
|
26178
|
+
deviceId: z.number().int().nonnegative(),
|
|
26179
|
+
position: z.number().min(0).max(100)
|
|
26180
|
+
}), z.void(), {
|
|
26181
|
+
kind: "mutation",
|
|
26182
|
+
auth: "admin"
|
|
26183
|
+
})
|
|
26184
|
+
},
|
|
25434
26185
|
status: {
|
|
25435
|
-
schema:
|
|
26186
|
+
schema: ValveStatusSchema,
|
|
25436
26187
|
kind: "push"
|
|
25437
26188
|
},
|
|
25438
26189
|
/**
|
|
25439
|
-
* Runtime-state slice — mirrored by the kernel.
|
|
25440
|
-
*
|
|
26190
|
+
* Runtime-state slice — mirrored by the kernel. UI controls watch the
|
|
26191
|
+
* slice for live position changes during a move.
|
|
25441
26192
|
*/
|
|
25442
|
-
runtimeState:
|
|
26193
|
+
runtimeState: ValveStatusSchema
|
|
25443
26194
|
};
|
|
25444
26195
|
/**
|
|
25445
|
-
*
|
|
25446
|
-
*
|
|
25447
|
-
* matrix the operator UI needs: total objects everywhere, total
|
|
25448
|
-
* objects per zone, single class everywhere, single class per zone,
|
|
25449
|
-
* objects outside any zone.
|
|
25450
|
-
*
|
|
25451
|
-
* Counts are derived from the analytics tracker (tracked detections
|
|
25452
|
-
* with stable trackIds), not raw detector hits — this filters out
|
|
25453
|
-
* one-off detector flickers and gives counts that match what the user
|
|
25454
|
-
* sees on the live overlay.
|
|
25455
|
-
*
|
|
25456
|
-
* Overlap policy: a detection that intersects two zones counts in
|
|
25457
|
-
* BOTH zones' `byClass` and `totalObjects` (count-in-each). The
|
|
25458
|
-
* `frame` aggregate de-duplicates trivially since it's frame-wide.
|
|
25459
|
-
* `unzoned` counts only detections that landed in zero zones.
|
|
25460
|
-
*/
|
|
25461
|
-
var PerScopeBreakdownSchema = z.object({
|
|
25462
|
-
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
25463
|
-
totalObjects: z.number().int().nonnegative(),
|
|
25464
|
-
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
25465
|
-
byClass: z.record(z.string(), z.number().int().nonnegative())
|
|
25466
|
-
});
|
|
25467
|
-
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
25468
|
-
zoneId: z.string(),
|
|
25469
|
-
zoneName: z.string(),
|
|
25470
|
-
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
25471
|
-
* with the per-track detail panel and live overlay. */
|
|
25472
|
-
trackIds: z.array(z.string()).readonly()
|
|
25473
|
-
});
|
|
25474
|
-
/**
|
|
25475
|
-
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
25476
|
-
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
25477
|
-
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
25478
|
-
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
25479
|
-
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
25480
|
-
* of flooding the live event feed.
|
|
25481
|
-
*/
|
|
25482
|
-
var StationaryObjectSchema = z.object({
|
|
25483
|
-
id: z.string(),
|
|
25484
|
-
className: z.string(),
|
|
25485
|
-
bbox: z.object({
|
|
25486
|
-
x: z.number(),
|
|
25487
|
-
y: z.number(),
|
|
25488
|
-
w: z.number(),
|
|
25489
|
-
h: z.number()
|
|
25490
|
-
}),
|
|
25491
|
-
frameWidth: z.number().int().nonnegative(),
|
|
25492
|
-
frameHeight: z.number().int().nonnegative(),
|
|
25493
|
-
/** When the source track was first seen. */
|
|
25494
|
-
firstSeenAt: z.number().int(),
|
|
25495
|
-
/** When the object was recognised as parked (promotion time). */
|
|
25496
|
-
becameStationaryAt: z.number().int(),
|
|
25497
|
-
/** Last frame a detection confirmed the object is still there. */
|
|
25498
|
-
lastConfirmedAt: z.number().int(),
|
|
25499
|
-
/** Enrichment label carried from the source track (identity / plate). */
|
|
25500
|
-
label: z.string().optional(),
|
|
25501
|
-
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
25502
|
-
keyFrameMediaKey: z.string().optional()
|
|
25503
|
-
});
|
|
25504
|
-
var CameraOccupancySnapshotSchema = z.object({
|
|
25505
|
-
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
25506
|
-
ts: z.number().int(),
|
|
25507
|
-
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
25508
|
-
frameWidth: z.number().int().nonnegative(),
|
|
25509
|
-
frameHeight: z.number().int().nonnegative(),
|
|
25510
|
-
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
25511
|
-
zones: z.array(ZoneScopeBreakdownSchema).readonly(),
|
|
25512
|
-
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
25513
|
-
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
25514
|
-
frame: PerScopeBreakdownSchema,
|
|
25515
|
-
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
25516
|
-
unzoned: PerScopeBreakdownSchema,
|
|
25517
|
-
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
25518
|
-
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
25519
|
-
* than as repeated tracks/events. */
|
|
25520
|
-
stationaryObjects: z.array(StationaryObjectSchema).readonly().optional()
|
|
25521
|
-
});
|
|
25522
|
-
/**
|
|
25523
|
-
* Time-series resolution. The history methods return one bucket per
|
|
25524
|
-
* step over the requested range. Smaller resolutions cost more
|
|
25525
|
-
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
25526
|
-
* for arbitrary fractional buckets.
|
|
26196
|
+
* Vibration / shake / impact sensor. Drives Home Assistant
|
|
26197
|
+
* `binary_sensor` entries with `device_class: vibration`. Push-driven.
|
|
25527
26198
|
*/
|
|
25528
|
-
var
|
|
25529
|
-
|
|
25530
|
-
|
|
25531
|
-
|
|
25532
|
-
]);
|
|
25533
|
-
var HistoryRangeSchema = z.object({
|
|
25534
|
-
/** Range start (epoch ms, inclusive). */
|
|
25535
|
-
from: z.number().int(),
|
|
25536
|
-
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
25537
|
-
to: z.number().int(),
|
|
25538
|
-
resolution: HistoryResolutionEnum
|
|
25539
|
-
});
|
|
25540
|
-
var HistoryPointSchema = z.object({
|
|
25541
|
-
/** Bucket midpoint (epoch ms). */
|
|
25542
|
-
ts: z.number().int(),
|
|
25543
|
-
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
25544
|
-
count: z.number().int().nonnegative()
|
|
26199
|
+
var VibrationStatusSchema = z.object({
|
|
26200
|
+
detected: z.boolean(),
|
|
26201
|
+
/** Ms epoch of the last transition. 0 if never observed. */
|
|
26202
|
+
lastChangedAt: z.number()
|
|
25545
26203
|
});
|
|
25546
|
-
|
|
25547
|
-
|
|
25548
|
-
* tracked detections. Hosted by `addon-pipeline-analytics`.
|
|
25549
|
-
*
|
|
25550
|
-
* Live snapshot is mirrored into the `zone-occupancy` device-state
|
|
25551
|
-
* slice on every frame; the cap method `getCurrentSnapshot` is a
|
|
25552
|
-
* direct read for callers that don't want to subscribe.
|
|
25553
|
-
*
|
|
25554
|
-
* History methods are sourced from a 60-minute rolling in-memory
|
|
25555
|
-
* ring of frame-level samples (sub-second resolution). Queries
|
|
25556
|
-
* outside the ring window return empty arrays — durable storage
|
|
25557
|
-
* is a follow-up (Step 4b: persisted samples → SQL store).
|
|
25558
|
-
*
|
|
25559
|
-
* Every history method takes an optional `className` filter:
|
|
25560
|
-
* - omitted → bucket count includes ALL classes in scope
|
|
25561
|
-
* - present → bucket count restricted to that class
|
|
25562
|
-
*
|
|
25563
|
-
* The `getZoneHistory` / `getCameraHistory` / `getUnzonedHistory`
|
|
25564
|
-
* triple covers the three spatial scopes (per-zone, frame-wide,
|
|
25565
|
-
* outside-any-zone). Crossing them with `className?` gives the full
|
|
25566
|
-
* combinatorial coverage the operator UI requested.
|
|
25567
|
-
*/
|
|
25568
|
-
var zoneAnalyticsCapability = {
|
|
25569
|
-
name: "zone-analytics",
|
|
26204
|
+
var vibrationCapability = {
|
|
26205
|
+
name: "vibration",
|
|
25570
26206
|
scope: "device",
|
|
26207
|
+
deviceNative: true,
|
|
25571
26208
|
mode: "singleton",
|
|
25572
|
-
deviceTypes: [DeviceType.
|
|
25573
|
-
methods: {
|
|
25574
|
-
|
|
25575
|
-
|
|
25576
|
-
|
|
25577
|
-
* activated). */
|
|
25578
|
-
getCurrentSnapshot: method(z.object({ deviceId: z.number() }), CameraOccupancySnapshotSchema.nullable()),
|
|
25579
|
-
/** Time-series object count inside one zone. `className` optional —
|
|
25580
|
-
* omit to count every class in the zone. */
|
|
25581
|
-
getZoneHistory: method(z.object({
|
|
25582
|
-
deviceId: z.number(),
|
|
25583
|
-
zoneId: z.string(),
|
|
25584
|
-
className: z.string().optional()
|
|
25585
|
-
}).extend(HistoryRangeSchema.shape), z.array(HistoryPointSchema).readonly()),
|
|
25586
|
-
/** Time-series frame-wide object count (everywhere). */
|
|
25587
|
-
getCameraHistory: method(z.object({
|
|
25588
|
-
deviceId: z.number(),
|
|
25589
|
-
className: z.string().optional()
|
|
25590
|
-
}).extend(HistoryRangeSchema.shape), z.array(HistoryPointSchema).readonly()),
|
|
25591
|
-
/** Time-series count of objects outside every zone. */
|
|
25592
|
-
getUnzonedHistory: method(z.object({
|
|
25593
|
-
deviceId: z.number(),
|
|
25594
|
-
className: z.string().optional()
|
|
25595
|
-
}).extend(HistoryRangeSchema.shape), z.array(HistoryPointSchema).readonly())
|
|
26209
|
+
deviceTypes: [DeviceType.Sensor],
|
|
26210
|
+
methods: {},
|
|
26211
|
+
status: {
|
|
26212
|
+
schema: VibrationStatusSchema,
|
|
26213
|
+
kind: "push"
|
|
25596
26214
|
},
|
|
25597
|
-
|
|
25598
|
-
* Runtime-state slice — the latest occupancy snapshot mirrored by
|
|
25599
|
-
* the analytics frame processor on every inference result. Consumers
|
|
25600
|
-
* read via `device.state.zoneAnalytics.value` and stay in sync
|
|
25601
|
-
* automatically; the explicit `getCurrentSnapshot` cap method is
|
|
25602
|
-
* still useful for one-off polls without a subscription.
|
|
25603
|
-
*/
|
|
25604
|
-
runtimeState: CameraOccupancySnapshotSchema
|
|
26215
|
+
runtimeState: VibrationStatusSchema
|
|
25605
26216
|
};
|
|
25606
26217
|
/**
|
|
25607
|
-
*
|
|
25608
|
-
*
|
|
25609
|
-
*
|
|
25610
|
-
* `
|
|
25611
|
-
*
|
|
25612
|
-
* Extend the enum here when a new gating consumer comes online (audio
|
|
25613
|
-
* gating, alert filtering, …) — no other surface needs to change.
|
|
25614
|
-
*/
|
|
25615
|
-
var ZoneRuleStageEnum = z.enum([
|
|
25616
|
-
"motion",
|
|
25617
|
-
"detection",
|
|
25618
|
-
"package"
|
|
25619
|
-
]);
|
|
25620
|
-
/**
|
|
25621
|
-
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
25622
|
-
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
26218
|
+
* Water heater / boiler cap. Models HA `water_heater.*` entities — a
|
|
26219
|
+
* climate-family actuator with a target temperature, an operation-mode
|
|
26220
|
+
* selector (`eco` / `electric` / `gas` / `heat_pump` / `high_demand` /
|
|
26221
|
+
* `performance` / `off`), and an optional away-mode toggle.
|
|
25623
26222
|
*
|
|
25624
|
-
*
|
|
25625
|
-
*
|
|
25626
|
-
*
|
|
25627
|
-
*
|
|
25628
|
-
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
25629
|
-
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
25630
|
-
* subscribe to that slice and refresh their gating without
|
|
25631
|
-
* round-tripping the cap.
|
|
26223
|
+
* The slice carries the current + target temperature, the active
|
|
26224
|
+
* operation mode (HA's `state`) and its available set (HA's
|
|
26225
|
+
* `operation_list`), the away flag (HA `away_mode` 'on'/'off' → bool,
|
|
26226
|
+
* null when unsupported), plus the `min_temp` / `max_temp` bounds.
|
|
25632
26227
|
*
|
|
25633
|
-
*
|
|
25634
|
-
*
|
|
25635
|
-
* naturally in the rule editor without per-rule mutation chatter.
|
|
26228
|
+
* Providers populate only what the hardware reports — temperature and
|
|
26229
|
+
* away fields stay null when the device has no such surface.
|
|
25636
26230
|
*/
|
|
25637
|
-
var
|
|
25638
|
-
|
|
26231
|
+
var WaterHeaterStatusSchema = z.object({
|
|
26232
|
+
/** Current measured temperature. Null when not reported. */
|
|
26233
|
+
currentTemp: z.number().nullable(),
|
|
26234
|
+
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
26235
|
+
targetTemp: z.number().nullable(),
|
|
26236
|
+
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
26237
|
+
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
26238
|
+
* device reports an unknown state. */
|
|
26239
|
+
operationMode: z.string().nullable(),
|
|
26240
|
+
/** Available operation modes = HA `operation_list`. */
|
|
26241
|
+
availableModes: z.array(z.string()),
|
|
26242
|
+
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
26243
|
+
* has no away surface. */
|
|
26244
|
+
away: z.boolean().nullable(),
|
|
26245
|
+
/** HA `min_temp` attribute. Null when not reported. */
|
|
26246
|
+
minTemp: z.number().nullable(),
|
|
26247
|
+
/** HA `max_temp` attribute. Null when not reported. */
|
|
26248
|
+
maxTemp: z.number().nullable(),
|
|
26249
|
+
/** Ms epoch when the slice was last updated. */
|
|
26250
|
+
lastChangedAt: z.number()
|
|
26251
|
+
});
|
|
26252
|
+
var waterHeaterCapability = {
|
|
26253
|
+
name: "water-heater",
|
|
25639
26254
|
scope: "device",
|
|
26255
|
+
deviceNative: true,
|
|
25640
26256
|
mode: "singleton",
|
|
25641
|
-
deviceTypes: [DeviceType.
|
|
26257
|
+
deviceTypes: [DeviceType.WaterHeater],
|
|
25642
26258
|
methods: {
|
|
25643
|
-
|
|
25644
|
-
|
|
25645
|
-
|
|
25646
|
-
|
|
25647
|
-
|
|
25648
|
-
|
|
25649
|
-
|
|
25650
|
-
|
|
25651
|
-
|
|
25652
|
-
|
|
25653
|
-
|
|
25654
|
-
|
|
25655
|
-
|
|
25656
|
-
|
|
26259
|
+
setTargetTemp: method(z.object({
|
|
26260
|
+
deviceId: z.number().int().nonnegative(),
|
|
26261
|
+
temp: z.number().finite()
|
|
26262
|
+
}), z.void(), {
|
|
26263
|
+
kind: "mutation",
|
|
26264
|
+
auth: "admin"
|
|
26265
|
+
}),
|
|
26266
|
+
setOperationMode: method(z.object({
|
|
26267
|
+
deviceId: z.number().int().nonnegative(),
|
|
26268
|
+
mode: z.string().min(1)
|
|
26269
|
+
}), z.void(), {
|
|
26270
|
+
kind: "mutation",
|
|
26271
|
+
auth: "admin"
|
|
26272
|
+
}),
|
|
26273
|
+
setAway: method(z.object({
|
|
26274
|
+
deviceId: z.number().int().nonnegative(),
|
|
26275
|
+
on: z.boolean()
|
|
25657
26276
|
}), z.void(), {
|
|
25658
26277
|
kind: "mutation",
|
|
25659
26278
|
auth: "admin"
|
|
25660
26279
|
})
|
|
25661
26280
|
},
|
|
26281
|
+
status: {
|
|
26282
|
+
schema: WaterHeaterStatusSchema,
|
|
26283
|
+
kind: "push"
|
|
26284
|
+
},
|
|
25662
26285
|
/**
|
|
25663
|
-
* Runtime-state slice —
|
|
25664
|
-
*
|
|
25665
|
-
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
25666
|
-
* `{motion, detection, package}` shape, so subscribers always get the
|
|
25667
|
-
* complete current set. Consumers that only care about one stage
|
|
25668
|
-
* just read the matching property.
|
|
25669
|
-
*
|
|
25670
|
-
* `package` backs the package-drop detector — a package zone is a
|
|
25671
|
-
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
25672
|
-
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
25673
|
-
* The orchestrator provider writes this stage as a first-class slice
|
|
25674
|
-
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
25675
|
-
* package}` shape, so consumers read the current package rules directly
|
|
25676
|
-
* off `device.state.zoneRules.value.package`.
|
|
26286
|
+
* Runtime-state slice — mirrored by the kernel. UI controls watch the
|
|
26287
|
+
* slice for live temperature / mode / away changes.
|
|
25677
26288
|
*/
|
|
25678
|
-
runtimeState:
|
|
25679
|
-
motion: z.array(ZoneRuleSchema).readonly(),
|
|
25680
|
-
detection: z.array(ZoneRuleSchema).readonly(),
|
|
25681
|
-
package: z.array(ZoneRuleSchema).readonly()
|
|
25682
|
-
})
|
|
26289
|
+
runtimeState: WaterHeaterStatusSchema
|
|
25683
26290
|
};
|
|
25684
26291
|
/**
|
|
25685
|
-
*
|
|
25686
|
-
*
|
|
26292
|
+
* Weather provider cap. Models HA `weather.*` entities — a read-only
|
|
26293
|
+
* snapshot of the CURRENT conditions a weather integration reports.
|
|
25687
26294
|
*
|
|
25688
|
-
*
|
|
25689
|
-
*
|
|
25690
|
-
|
|
25691
|
-
var RUNTIME_DEFAULTS = {
|
|
25692
|
-
"features.streaming": true,
|
|
25693
|
-
"features.notifications": true,
|
|
25694
|
-
"features.objectDetection": false,
|
|
25695
|
-
"features.remoteAccess": true,
|
|
25696
|
-
"features.agentCluster": false,
|
|
25697
|
-
"features.smartHome": true,
|
|
25698
|
-
"features.recordings": true,
|
|
25699
|
-
"features.backup": true,
|
|
25700
|
-
"features.repl": true,
|
|
25701
|
-
"retention.detectionEventsDays": 30,
|
|
25702
|
-
"retention.audioLevelsDays": 7,
|
|
25703
|
-
"logging.level": "info",
|
|
25704
|
-
"logging.retentionDays": 30,
|
|
25705
|
-
"eventBus.ringBufferSize": 1e4,
|
|
25706
|
-
"storage.provider": "sqlite-storage",
|
|
25707
|
-
"storage.locations": {
|
|
25708
|
-
data: "camstack-data/data",
|
|
25709
|
-
media: "camstack-data/media",
|
|
25710
|
-
recordings: "camstack-data/recordings",
|
|
25711
|
-
cache: "/tmp/camstack-cache",
|
|
25712
|
-
logs: "camstack-data/logs",
|
|
25713
|
-
models: "camstack-data/models"
|
|
25714
|
-
},
|
|
25715
|
-
providers: [],
|
|
25716
|
-
"ffmpeg.binaryPath": "ffmpeg",
|
|
25717
|
-
"ffmpeg.hwAccel": "auto",
|
|
25718
|
-
"ffmpeg.threadCount": 0,
|
|
25719
|
-
"auth.tokenExpiry": "7d"
|
|
25720
|
-
};
|
|
25721
|
-
/**
|
|
25722
|
-
* Accessory device helpers — shared across drivers.
|
|
26295
|
+
* Read-only: there are no setters. The slice is populated from upstream
|
|
26296
|
+
* pushes (the HA weather entity's state + attributes) and rendered by
|
|
26297
|
+
* the UI as a sky scene + readouts.
|
|
25723
26298
|
*
|
|
25724
|
-
*
|
|
25725
|
-
*
|
|
25726
|
-
*
|
|
25727
|
-
*
|
|
25728
|
-
*
|
|
25729
|
-
* stableId tied to the parent so boot-restore can reconstruct the
|
|
25730
|
-
* relationship.
|
|
26299
|
+
* `condition` is the verbatim HA state string (`sunny` / `cloudy` /
|
|
26300
|
+
* `rainy` / `snowy` / `partlycloudy` / `pouring` / `lightning` /
|
|
26301
|
+
* `lightning-rainy` / `fog` / `windy` / `windy-variant` / `hail` /
|
|
26302
|
+
* `clear-night` / `exceptional` / …). The UI maps it to a glyph + tint;
|
|
26303
|
+
* unknown strings fall back to a neutral cloud.
|
|
25731
26304
|
*
|
|
25732
|
-
*
|
|
25733
|
-
*
|
|
25734
|
-
*
|
|
25735
|
-
*
|
|
25736
|
-
* one canonical mapping was over-prescriptive and added a layer of
|
|
25737
|
-
* indirection without saving meaningful code at call sites — the
|
|
25738
|
-
* driver knows its own hardware best.
|
|
25739
|
-
*/
|
|
25740
|
-
/**
|
|
25741
|
-
* Subset of `DeviceRole` values that drivers register as child
|
|
25742
|
-
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
25743
|
-
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
25744
|
-
* children, so the call site reads as
|
|
25745
|
-
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
25746
|
-
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
25747
|
-
* any role works, including non-accessory ones like Doorbell).
|
|
26305
|
+
* Every numeric reading is nullable — a given weather integration only
|
|
26306
|
+
* populates the metrics it actually provides.
|
|
26307
|
+
*
|
|
26308
|
+
* Forecast deferred — current conditions only for v1.
|
|
25748
26309
|
*/
|
|
25749
|
-
var
|
|
25750
|
-
|
|
25751
|
-
|
|
25752
|
-
|
|
25753
|
-
|
|
25754
|
-
|
|
25755
|
-
|
|
25756
|
-
|
|
25757
|
-
|
|
25758
|
-
|
|
25759
|
-
|
|
25760
|
-
|
|
25761
|
-
|
|
25762
|
-
|
|
25763
|
-
/**
|
|
25764
|
-
|
|
25765
|
-
|
|
25766
|
-
|
|
25767
|
-
|
|
25768
|
-
|
|
25769
|
-
|
|
25770
|
-
|
|
25771
|
-
|
|
25772
|
-
|
|
25773
|
-
|
|
25774
|
-
|
|
25775
|
-
|
|
25776
|
-
|
|
25777
|
-
|
|
25778
|
-
|
|
25779
|
-
|
|
25780
|
-
|
|
25781
|
-
|
|
25782
|
-
|
|
25783
|
-
|
|
25784
|
-
*
|
|
25785
|
-
|
|
25786
|
-
|
|
25787
|
-
|
|
25788
|
-
|
|
25789
|
-
const shape = root.shape;
|
|
25790
|
-
for (const [key, field] of Object.entries(shape)) {
|
|
25791
|
-
const path = prefix ? `${prefix}.${key}` : key;
|
|
25792
|
-
const inner = unwrap(field);
|
|
25793
|
-
if (inner instanceof z.ZodObject) {
|
|
25794
|
-
out.push(...enumerateSchemaFields(inner, path));
|
|
25795
|
-
continue;
|
|
25796
|
-
}
|
|
25797
|
-
const leaf = leafKind(field);
|
|
25798
|
-
if (leaf) out.push({
|
|
25799
|
-
path,
|
|
25800
|
-
kind: leaf.kind,
|
|
25801
|
-
...leaf.enumValues ? { enumValues: leaf.enumValues } : {}
|
|
25802
|
-
});
|
|
25803
|
-
}
|
|
25804
|
-
return out;
|
|
25805
|
-
}
|
|
25806
|
-
/** Enumerate the per-item wireable fields of an item-array cap (see
|
|
25807
|
-
* `CapabilityStatusItemArray`): the item schema's leaf fields, minus the
|
|
25808
|
-
* `keyField` (the key comes from the link's `itemKey`, never from a wired
|
|
25809
|
-
* source), each tagged `item: true` so the authoring UI collects an
|
|
25810
|
-
* `itemKey` alongside the field. Never throws. */
|
|
25811
|
-
function enumerateItemArrayFields(itemArray) {
|
|
25812
|
-
return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
|
|
25813
|
-
...f,
|
|
25814
|
-
item: true
|
|
25815
|
-
}));
|
|
25816
|
-
}
|
|
26310
|
+
var WeatherStatusSchema = z.object({
|
|
26311
|
+
/** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
|
|
26312
|
+
* when no condition has been reported yet. */
|
|
26313
|
+
condition: z.string().nullable(),
|
|
26314
|
+
/** Current temperature in the reported unit. Null when not provided. */
|
|
26315
|
+
temperature: z.number().nullable(),
|
|
26316
|
+
/** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
|
|
26317
|
+
temperatureUnit: z.string().nullable(),
|
|
26318
|
+
/** Relative humidity (0..100). Null when not provided. */
|
|
26319
|
+
humidity: z.number().min(0).max(100).nullable(),
|
|
26320
|
+
/** Barometric pressure in the reported unit. Null when not provided. */
|
|
26321
|
+
pressure: z.number().nullable(),
|
|
26322
|
+
/** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
|
|
26323
|
+
pressureUnit: z.string().nullable(),
|
|
26324
|
+
/** Wind speed in the reported unit. Null when not provided. */
|
|
26325
|
+
windSpeed: z.number().nullable(),
|
|
26326
|
+
/** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
|
|
26327
|
+
windSpeedUnit: z.string().nullable(),
|
|
26328
|
+
/** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
|
|
26329
|
+
windBearing: z.number().nullable(),
|
|
26330
|
+
/** Ms epoch when the slice was last updated. */
|
|
26331
|
+
lastFetchedAt: z.number()
|
|
26332
|
+
});
|
|
26333
|
+
var weatherCapability = {
|
|
26334
|
+
name: "weather",
|
|
26335
|
+
scope: "device",
|
|
26336
|
+
deviceNative: true,
|
|
26337
|
+
mode: "singleton",
|
|
26338
|
+
deviceTypes: [DeviceType.Weather],
|
|
26339
|
+
methods: {},
|
|
26340
|
+
status: {
|
|
26341
|
+
schema: WeatherStatusSchema,
|
|
26342
|
+
kind: "push"
|
|
26343
|
+
},
|
|
26344
|
+
/**
|
|
26345
|
+
* Runtime-state slice — mirrored by the kernel. The UI reads the
|
|
26346
|
+
* current conditions directly from the slice on each weather push.
|
|
26347
|
+
*/
|
|
26348
|
+
runtimeState: WeatherStatusSchema
|
|
26349
|
+
};
|
|
25817
26350
|
/**
|
|
25818
|
-
*
|
|
25819
|
-
*
|
|
25820
|
-
*
|
|
26351
|
+
* Per-zone occupancy aggregation produced by the analytics frame
|
|
26352
|
+
* processor on every inference result. Covers the full combinatorial
|
|
26353
|
+
* matrix the operator UI needs: total objects everywhere, total
|
|
26354
|
+
* objects per zone, single class everywhere, single class per zone,
|
|
26355
|
+
* objects outside any zone.
|
|
26356
|
+
*
|
|
26357
|
+
* Counts are derived from the analytics tracker (tracked detections
|
|
26358
|
+
* with stable trackIds), not raw detector hits — this filters out
|
|
26359
|
+
* one-off detector flickers and gives counts that match what the user
|
|
26360
|
+
* sees on the live overlay.
|
|
26361
|
+
*
|
|
26362
|
+
* Overlap policy: a detection that intersects two zones counts in
|
|
26363
|
+
* BOTH zones' `byClass` and `totalObjects` (count-in-each). The
|
|
26364
|
+
* `frame` aggregate de-duplicates trivially since it's frame-wide.
|
|
26365
|
+
* `unzoned` counts only detections that landed in zero zones.
|
|
25821
26366
|
*/
|
|
25822
|
-
|
|
25823
|
-
|
|
25824
|
-
|
|
25825
|
-
|
|
25826
|
-
|
|
25827
|
-
|
|
25828
|
-
|
|
25829
|
-
|
|
25830
|
-
|
|
25831
|
-
|
|
25832
|
-
|
|
25833
|
-
|
|
25834
|
-
|
|
25835
|
-
constructor(message) {
|
|
25836
|
-
super(message);
|
|
25837
|
-
this.name = "ExpressionEvalError";
|
|
25838
|
-
}
|
|
25839
|
-
};
|
|
26367
|
+
var PerScopeBreakdownSchema = z.object({
|
|
26368
|
+
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
26369
|
+
totalObjects: z.number().int().nonnegative(),
|
|
26370
|
+
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
26371
|
+
byClass: z.record(z.string(), z.number().int().nonnegative())
|
|
26372
|
+
});
|
|
26373
|
+
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
26374
|
+
zoneId: z.string(),
|
|
26375
|
+
zoneName: z.string(),
|
|
26376
|
+
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
26377
|
+
* with the per-track detail panel and live overlay. */
|
|
26378
|
+
trackIds: z.array(z.string()).readonly()
|
|
26379
|
+
});
|
|
25840
26380
|
/**
|
|
25841
|
-
*
|
|
25842
|
-
*
|
|
25843
|
-
*
|
|
25844
|
-
*
|
|
26381
|
+
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
26382
|
+
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
26383
|
+
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
26384
|
+
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
26385
|
+
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
26386
|
+
* of flooding the live event feed.
|
|
26387
|
+
*/
|
|
26388
|
+
var StationaryObjectSchema = z.object({
|
|
26389
|
+
id: z.string(),
|
|
26390
|
+
className: z.string(),
|
|
26391
|
+
bbox: z.object({
|
|
26392
|
+
x: z.number(),
|
|
26393
|
+
y: z.number(),
|
|
26394
|
+
w: z.number(),
|
|
26395
|
+
h: z.number()
|
|
26396
|
+
}),
|
|
26397
|
+
frameWidth: z.number().int().nonnegative(),
|
|
26398
|
+
frameHeight: z.number().int().nonnegative(),
|
|
26399
|
+
/** When the source track was first seen. */
|
|
26400
|
+
firstSeenAt: z.number().int(),
|
|
26401
|
+
/** When the object was recognised as parked (promotion time). */
|
|
26402
|
+
becameStationaryAt: z.number().int(),
|
|
26403
|
+
/** Last frame a detection confirmed the object is still there. */
|
|
26404
|
+
lastConfirmedAt: z.number().int(),
|
|
26405
|
+
/** Enrichment label carried from the source track (identity / plate). */
|
|
26406
|
+
label: z.string().optional(),
|
|
26407
|
+
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
26408
|
+
keyFrameMediaKey: z.string().optional()
|
|
26409
|
+
});
|
|
26410
|
+
var CameraOccupancySnapshotSchema = z.object({
|
|
26411
|
+
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
26412
|
+
ts: z.number().int(),
|
|
26413
|
+
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
26414
|
+
frameWidth: z.number().int().nonnegative(),
|
|
26415
|
+
frameHeight: z.number().int().nonnegative(),
|
|
26416
|
+
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
26417
|
+
zones: z.array(ZoneScopeBreakdownSchema).readonly(),
|
|
26418
|
+
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
26419
|
+
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
26420
|
+
frame: PerScopeBreakdownSchema,
|
|
26421
|
+
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
26422
|
+
unzoned: PerScopeBreakdownSchema,
|
|
26423
|
+
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
26424
|
+
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
26425
|
+
* than as repeated tracks/events. */
|
|
26426
|
+
stationaryObjects: z.array(StationaryObjectSchema).readonly().optional()
|
|
26427
|
+
});
|
|
26428
|
+
/**
|
|
26429
|
+
* Time-series resolution. The history methods return one bucket per
|
|
26430
|
+
* step over the requested range. Smaller resolutions cost more
|
|
26431
|
+
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
26432
|
+
* for arbitrary fractional buckets.
|
|
26433
|
+
*/
|
|
26434
|
+
var HistoryResolutionEnum = z.enum([
|
|
26435
|
+
"minute",
|
|
26436
|
+
"5min",
|
|
26437
|
+
"hour"
|
|
26438
|
+
]);
|
|
26439
|
+
var HistoryRangeSchema = z.object({
|
|
26440
|
+
/** Range start (epoch ms, inclusive). */
|
|
26441
|
+
from: z.number().int(),
|
|
26442
|
+
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
26443
|
+
to: z.number().int(),
|
|
26444
|
+
resolution: HistoryResolutionEnum
|
|
26445
|
+
});
|
|
26446
|
+
var HistoryPointSchema = z.object({
|
|
26447
|
+
/** Bucket midpoint (epoch ms). */
|
|
26448
|
+
ts: z.number().int(),
|
|
26449
|
+
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
26450
|
+
count: z.number().int().nonnegative()
|
|
26451
|
+
});
|
|
26452
|
+
/**
|
|
26453
|
+
* Zone Analytics capability — spatial occupancy aggregations over
|
|
26454
|
+
* tracked detections. Hosted by `addon-pipeline-analytics`.
|
|
25845
26455
|
*
|
|
25846
|
-
*
|
|
25847
|
-
*
|
|
25848
|
-
*
|
|
25849
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
25850
|
-
* callable — they are simply "unknown function" at parse time.
|
|
26456
|
+
* Live snapshot is mirrored into the `zone-occupancy` device-state
|
|
26457
|
+
* slice on every frame; the cap method `getCurrentSnapshot` is a
|
|
26458
|
+
* direct read for callers that don't want to subscribe.
|
|
25851
26459
|
*
|
|
25852
|
-
*
|
|
25853
|
-
*
|
|
25854
|
-
*
|
|
25855
|
-
*
|
|
26460
|
+
* History methods are sourced from a 60-minute rolling in-memory
|
|
26461
|
+
* ring of frame-level samples (sub-second resolution). Queries
|
|
26462
|
+
* outside the ring window return empty arrays — durable storage
|
|
26463
|
+
* is a follow-up (Step 4b: persisted samples → SQL store).
|
|
26464
|
+
*
|
|
26465
|
+
* Every history method takes an optional `className` filter:
|
|
26466
|
+
* - omitted → bucket count includes ALL classes in scope
|
|
26467
|
+
* - present → bucket count restricted to that class
|
|
26468
|
+
*
|
|
26469
|
+
* The `getZoneHistory` / `getCameraHistory` / `getUnzonedHistory`
|
|
26470
|
+
* triple covers the three spatial scopes (per-zone, frame-wide,
|
|
26471
|
+
* outside-any-zone). Crossing them with `className?` gives the full
|
|
26472
|
+
* combinatorial coverage the operator UI requested.
|
|
25856
26473
|
*/
|
|
25857
|
-
|
|
25858
|
-
|
|
25859
|
-
|
|
25860
|
-
|
|
25861
|
-
|
|
25862
|
-
|
|
25863
|
-
|
|
25864
|
-
|
|
25865
|
-
|
|
25866
|
-
|
|
25867
|
-
|
|
25868
|
-
|
|
25869
|
-
|
|
25870
|
-
|
|
25871
|
-
|
|
25872
|
-
|
|
25873
|
-
|
|
25874
|
-
|
|
25875
|
-
|
|
25876
|
-
|
|
25877
|
-
|
|
25878
|
-
|
|
25879
|
-
|
|
25880
|
-
|
|
25881
|
-
|
|
25882
|
-
|
|
25883
|
-
|
|
25884
|
-
|
|
25885
|
-
minArgs: 1,
|
|
25886
|
-
maxArgs: 1,
|
|
25887
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
25888
|
-
},
|
|
25889
|
-
floor: {
|
|
25890
|
-
minArgs: 1,
|
|
25891
|
-
maxArgs: 1,
|
|
25892
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
25893
|
-
},
|
|
25894
|
-
ceil: {
|
|
25895
|
-
minArgs: 1,
|
|
25896
|
-
maxArgs: 1,
|
|
25897
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
25898
|
-
},
|
|
25899
|
-
sqrt: {
|
|
25900
|
-
minArgs: 1,
|
|
25901
|
-
maxArgs: 1,
|
|
25902
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
25903
|
-
},
|
|
25904
|
-
round: {
|
|
25905
|
-
minArgs: 1,
|
|
25906
|
-
maxArgs: 2,
|
|
25907
|
-
apply: (args) => {
|
|
25908
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
25909
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
25910
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
25911
|
-
const factor = 10 ** digits;
|
|
25912
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
25913
|
-
}
|
|
25914
|
-
},
|
|
25915
|
-
pow: {
|
|
25916
|
-
minArgs: 2,
|
|
25917
|
-
maxArgs: 2,
|
|
25918
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
25919
|
-
},
|
|
25920
|
-
clamp: {
|
|
25921
|
-
minArgs: 3,
|
|
25922
|
-
maxArgs: 3,
|
|
25923
|
-
apply: (args) => {
|
|
25924
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
25925
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
25926
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
25927
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
25928
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
25929
|
-
}
|
|
25930
|
-
},
|
|
25931
|
-
avg: {
|
|
25932
|
-
minArgs: 1,
|
|
25933
|
-
maxArgs: INF,
|
|
25934
|
-
apply: (args) => {
|
|
25935
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
25936
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
25937
|
-
}
|
|
25938
|
-
},
|
|
25939
|
-
sum: {
|
|
25940
|
-
minArgs: 1,
|
|
25941
|
-
maxArgs: INF,
|
|
25942
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
25943
|
-
},
|
|
25944
|
-
coalesce: {
|
|
25945
|
-
minArgs: 1,
|
|
25946
|
-
maxArgs: INF,
|
|
25947
|
-
apply: (args) => {
|
|
25948
|
-
for (const a of args) if (a !== null) return a;
|
|
25949
|
-
return null;
|
|
25950
|
-
}
|
|
25951
|
-
},
|
|
25952
|
-
age: {
|
|
25953
|
-
minArgs: 2,
|
|
25954
|
-
maxArgs: 2,
|
|
25955
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
26474
|
+
var zoneAnalyticsCapability = {
|
|
26475
|
+
name: "zone-analytics",
|
|
26476
|
+
scope: "device",
|
|
26477
|
+
mode: "singleton",
|
|
26478
|
+
deviceTypes: [DeviceType.Camera],
|
|
26479
|
+
methods: {
|
|
26480
|
+
/** Latest computed occupancy snapshot for this camera. Null when
|
|
26481
|
+
* the analytics pipeline hasn't seen a frame for this device yet
|
|
26482
|
+
* (no inference result emitted since boot or since binding was
|
|
26483
|
+
* activated). */
|
|
26484
|
+
getCurrentSnapshot: method(z.object({ deviceId: z.number() }), CameraOccupancySnapshotSchema.nullable()),
|
|
26485
|
+
/** Time-series object count inside one zone. `className` optional —
|
|
26486
|
+
* omit to count every class in the zone. */
|
|
26487
|
+
getZoneHistory: method(z.object({
|
|
26488
|
+
deviceId: z.number(),
|
|
26489
|
+
zoneId: z.string(),
|
|
26490
|
+
className: z.string().optional()
|
|
26491
|
+
}).extend(HistoryRangeSchema.shape), z.array(HistoryPointSchema).readonly()),
|
|
26492
|
+
/** Time-series frame-wide object count (everywhere). */
|
|
26493
|
+
getCameraHistory: method(z.object({
|
|
26494
|
+
deviceId: z.number(),
|
|
26495
|
+
className: z.string().optional()
|
|
26496
|
+
}).extend(HistoryRangeSchema.shape), z.array(HistoryPointSchema).readonly()),
|
|
26497
|
+
/** Time-series count of objects outside every zone. */
|
|
26498
|
+
getUnzonedHistory: method(z.object({
|
|
26499
|
+
deviceId: z.number(),
|
|
26500
|
+
className: z.string().optional()
|
|
26501
|
+
}).extend(HistoryRangeSchema.shape), z.array(HistoryPointSchema).readonly())
|
|
25956
26502
|
},
|
|
25957
|
-
|
|
25958
|
-
|
|
25959
|
-
|
|
25960
|
-
|
|
25961
|
-
|
|
25962
|
-
|
|
25963
|
-
|
|
25964
|
-
|
|
25965
|
-
const out = hooks.convert(x, from, to);
|
|
25966
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
25967
|
-
return finiteResult(out, "convert");
|
|
25968
|
-
}
|
|
25969
|
-
if (from === to) return x;
|
|
25970
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
25971
|
-
}
|
|
25972
|
-
}
|
|
26503
|
+
/**
|
|
26504
|
+
* Runtime-state slice — the latest occupancy snapshot mirrored by
|
|
26505
|
+
* the analytics frame processor on every inference result. Consumers
|
|
26506
|
+
* read via `device.state.zoneAnalytics.value` and stay in sync
|
|
26507
|
+
* automatically; the explicit `getCurrentSnapshot` cap method is
|
|
26508
|
+
* still useful for one-off polls without a subscription.
|
|
26509
|
+
*/
|
|
26510
|
+
runtimeState: CameraOccupancySnapshotSchema
|
|
25973
26511
|
};
|
|
25974
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
25975
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
25976
|
-
* callees at parse time (immediate author feedback). */
|
|
25977
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
25978
26512
|
/**
|
|
25979
|
-
*
|
|
26513
|
+
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
26514
|
+
* cap so a single CRUD surface backs every consumer; each stage has
|
|
26515
|
+
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
26516
|
+
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
25980
26517
|
*
|
|
25981
|
-
*
|
|
25982
|
-
*
|
|
25983
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
25984
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
25985
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
26518
|
+
* Extend the enum here when a new gating consumer comes online (audio
|
|
26519
|
+
* gating, alert filtering, …) — no other surface needs to change.
|
|
25986
26520
|
*/
|
|
25987
|
-
|
|
25988
|
-
|
|
25989
|
-
|
|
25990
|
-
|
|
25991
|
-
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25992
|
-
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
25993
|
-
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
25994
|
-
var RESERVED_BINDING_NAMES = new Set([
|
|
25995
|
-
"now",
|
|
25996
|
-
"true",
|
|
25997
|
-
"false",
|
|
25998
|
-
"null"
|
|
26521
|
+
var ZoneRuleStageEnum = z.enum([
|
|
26522
|
+
"motion",
|
|
26523
|
+
"detection",
|
|
26524
|
+
"package"
|
|
25999
26525
|
]);
|
|
26000
26526
|
/**
|
|
26001
|
-
*
|
|
26002
|
-
*
|
|
26003
|
-
*
|
|
26004
|
-
*
|
|
26005
|
-
*
|
|
26006
|
-
*
|
|
26007
|
-
*
|
|
26527
|
+
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
26528
|
+
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
26529
|
+
*
|
|
26530
|
+
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
26531
|
+
* so the operator has a single hub-side source of truth for both
|
|
26532
|
+
* geometry and behaviour. Per-stage rules are stored under the
|
|
26533
|
+
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
26534
|
+
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
26535
|
+
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
26536
|
+
* subscribe to that slice and refresh their gating without
|
|
26537
|
+
* round-tripping the cap.
|
|
26538
|
+
*
|
|
26539
|
+
* Sets are bulk-replace — the operator UI sends the new rule list
|
|
26540
|
+
* wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
|
|
26541
|
+
* naturally in the rule editor without per-rule mutation chatter.
|
|
26008
26542
|
*/
|
|
26009
|
-
var
|
|
26010
|
-
"
|
|
26011
|
-
"
|
|
26012
|
-
"
|
|
26013
|
-
]
|
|
26014
|
-
|
|
26015
|
-
|
|
26016
|
-
|
|
26017
|
-
|
|
26018
|
-
|
|
26019
|
-
|
|
26020
|
-
|
|
26021
|
-
|
|
26022
|
-
}
|
|
26023
|
-
|
|
26024
|
-
|
|
26025
|
-
|
|
26026
|
-
|
|
26027
|
-
|
|
26028
|
-
|
|
26029
|
-
|
|
26030
|
-
|
|
26031
|
-
|
|
26032
|
-
|
|
26033
|
-
|
|
26034
|
-
|
|
26035
|
-
|
|
26036
|
-
|
|
26037
|
-
|
|
26038
|
-
|
|
26039
|
-
|
|
26040
|
-
|
|
26041
|
-
|
|
26042
|
-
|
|
26043
|
-
|
|
26044
|
-
|
|
26045
|
-
|
|
26046
|
-
|
|
26047
|
-
|
|
26048
|
-
|
|
26049
|
-
|
|
26050
|
-
|
|
26051
|
-
|
|
26052
|
-
|
|
26053
|
-
|
|
26054
|
-
|
|
26055
|
-
|
|
26056
|
-
continue;
|
|
26057
|
-
}
|
|
26058
|
-
if (ch === "'" || ch === "\"") {
|
|
26059
|
-
const quote = ch;
|
|
26060
|
-
const start = i;
|
|
26061
|
-
i += 1;
|
|
26062
|
-
let out = "";
|
|
26063
|
-
let closed = false;
|
|
26064
|
-
while (i < n) {
|
|
26065
|
-
const c = source[i];
|
|
26066
|
-
if (c === "\\") {
|
|
26067
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
26068
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
26069
|
-
out += next;
|
|
26070
|
-
i += 2;
|
|
26071
|
-
continue;
|
|
26072
|
-
}
|
|
26073
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
26074
|
-
}
|
|
26075
|
-
if (c === quote) {
|
|
26076
|
-
closed = true;
|
|
26077
|
-
i += 1;
|
|
26078
|
-
break;
|
|
26079
|
-
}
|
|
26080
|
-
out += c;
|
|
26081
|
-
i += 1;
|
|
26082
|
-
}
|
|
26083
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
26084
|
-
tokens.push({
|
|
26085
|
-
type: "string",
|
|
26086
|
-
value: out,
|
|
26087
|
-
pos: start
|
|
26088
|
-
});
|
|
26089
|
-
continue;
|
|
26090
|
-
}
|
|
26091
|
-
if (isIdentStart(ch)) {
|
|
26092
|
-
const start = i;
|
|
26093
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
26094
|
-
const text = source.slice(start, i);
|
|
26095
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
26096
|
-
type: "keyword",
|
|
26097
|
-
keyword: keywordOf(text),
|
|
26098
|
-
pos: start
|
|
26099
|
-
});
|
|
26100
|
-
else tokens.push({
|
|
26101
|
-
type: "identifier",
|
|
26102
|
-
name: text,
|
|
26103
|
-
pos: start
|
|
26104
|
-
});
|
|
26105
|
-
continue;
|
|
26106
|
-
}
|
|
26107
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
26108
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
26109
|
-
tokens.push({
|
|
26110
|
-
type: "punct",
|
|
26111
|
-
punct: two,
|
|
26112
|
-
pos: i
|
|
26113
|
-
});
|
|
26114
|
-
i += 2;
|
|
26115
|
-
continue;
|
|
26116
|
-
}
|
|
26117
|
-
if (isSinglePunct(ch)) {
|
|
26118
|
-
tokens.push({
|
|
26119
|
-
type: "punct",
|
|
26120
|
-
punct: ch,
|
|
26121
|
-
pos: i
|
|
26122
|
-
});
|
|
26123
|
-
i += 1;
|
|
26124
|
-
continue;
|
|
26125
|
-
}
|
|
26126
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
26127
|
-
}
|
|
26128
|
-
tokens.push({
|
|
26129
|
-
type: "eof",
|
|
26130
|
-
pos: n
|
|
26131
|
-
});
|
|
26132
|
-
return tokens;
|
|
26133
|
-
}
|
|
26134
|
-
function keywordOf(text) {
|
|
26135
|
-
if (text === "true") return "true";
|
|
26136
|
-
if (text === "false") return "false";
|
|
26137
|
-
return "null";
|
|
26138
|
-
}
|
|
26139
|
-
function isSinglePunct(ch) {
|
|
26140
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
26141
|
-
}
|
|
26543
|
+
var zoneRulesCapability = {
|
|
26544
|
+
name: "zone-rules",
|
|
26545
|
+
scope: "device",
|
|
26546
|
+
mode: "singleton",
|
|
26547
|
+
deviceTypes: [DeviceType.Camera],
|
|
26548
|
+
methods: {
|
|
26549
|
+
/** Read the full rule list for a given stage (empty when no rules
|
|
26550
|
+
* are defined yet). */
|
|
26551
|
+
listRules: method(z.object({
|
|
26552
|
+
deviceId: z.number(),
|
|
26553
|
+
stage: ZoneRuleStageEnum
|
|
26554
|
+
}), z.array(ZoneRuleSchema).readonly()),
|
|
26555
|
+
/** Bulk-replace the rule list for one stage. The provider validates
|
|
26556
|
+
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
26557
|
+
* thresholds in range) and rejects the whole patch if any entry
|
|
26558
|
+
* is invalid — partial writes are a configuration footgun. */
|
|
26559
|
+
setRules: method(z.object({
|
|
26560
|
+
deviceId: z.number(),
|
|
26561
|
+
stage: ZoneRuleStageEnum,
|
|
26562
|
+
rules: z.array(ZoneRuleSchema).readonly()
|
|
26563
|
+
}), z.void(), {
|
|
26564
|
+
kind: "mutation",
|
|
26565
|
+
auth: "admin"
|
|
26566
|
+
})
|
|
26567
|
+
},
|
|
26568
|
+
/**
|
|
26569
|
+
* Runtime-state slice — every stage mirrored together so consumers
|
|
26570
|
+
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
26571
|
+
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
26572
|
+
* `{motion, detection, package}` shape, so subscribers always get the
|
|
26573
|
+
* complete current set. Consumers that only care about one stage
|
|
26574
|
+
* just read the matching property.
|
|
26575
|
+
*
|
|
26576
|
+
* `package` backs the package-drop detector — a package zone is a
|
|
26577
|
+
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
26578
|
+
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
26579
|
+
* The orchestrator provider writes this stage as a first-class slice
|
|
26580
|
+
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
26581
|
+
* package}` shape, so consumers read the current package rules directly
|
|
26582
|
+
* off `device.state.zoneRules.value.package`.
|
|
26583
|
+
*/
|
|
26584
|
+
runtimeState: z.object({
|
|
26585
|
+
motion: z.array(ZoneRuleSchema).readonly(),
|
|
26586
|
+
detection: z.array(ZoneRuleSchema).readonly(),
|
|
26587
|
+
package: z.array(ZoneRuleSchema).readonly()
|
|
26588
|
+
})
|
|
26589
|
+
};
|
|
26142
26590
|
/**
|
|
26143
|
-
*
|
|
26591
|
+
* Runtime defaults -- used by ConfigManager.get() for backward compatibility
|
|
26592
|
+
* until Plan B wires all runtime settings to the system_settings SQL table.
|
|
26144
26593
|
*
|
|
26145
|
-
*
|
|
26146
|
-
*
|
|
26147
|
-
|
|
26148
|
-
|
|
26149
|
-
|
|
26150
|
-
|
|
26594
|
+
* Moved from @camstack/system/config-schema to @camstack/types so that
|
|
26595
|
+
* @camstack/system can reference it without a cross-package kernel import.
|
|
26596
|
+
*/
|
|
26597
|
+
var RUNTIME_DEFAULTS = {
|
|
26598
|
+
"features.streaming": true,
|
|
26599
|
+
"features.notifications": true,
|
|
26600
|
+
"features.objectDetection": false,
|
|
26601
|
+
"features.remoteAccess": true,
|
|
26602
|
+
"features.agentCluster": false,
|
|
26603
|
+
"features.smartHome": true,
|
|
26604
|
+
"features.recordings": true,
|
|
26605
|
+
"features.backup": true,
|
|
26606
|
+
"features.repl": true,
|
|
26607
|
+
"retention.detectionEventsDays": 30,
|
|
26608
|
+
"retention.audioLevelsDays": 7,
|
|
26609
|
+
"logging.level": "info",
|
|
26610
|
+
"logging.retentionDays": 30,
|
|
26611
|
+
"eventBus.ringBufferSize": 1e4,
|
|
26612
|
+
"storage.provider": "sqlite-storage",
|
|
26613
|
+
"storage.locations": {
|
|
26614
|
+
data: "camstack-data/data",
|
|
26615
|
+
media: "camstack-data/media",
|
|
26616
|
+
recordings: "camstack-data/recordings",
|
|
26617
|
+
cache: "/tmp/camstack-cache",
|
|
26618
|
+
logs: "camstack-data/logs",
|
|
26619
|
+
models: "camstack-data/models"
|
|
26620
|
+
},
|
|
26621
|
+
providers: [],
|
|
26622
|
+
"ffmpeg.binaryPath": "ffmpeg",
|
|
26623
|
+
"ffmpeg.hwAccel": "auto",
|
|
26624
|
+
"ffmpeg.threadCount": 0,
|
|
26625
|
+
"auth.tokenExpiry": "7d"
|
|
26626
|
+
};
|
|
26627
|
+
/**
|
|
26628
|
+
* Accessory device helpers — shared across drivers.
|
|
26151
26629
|
*
|
|
26152
|
-
*
|
|
26153
|
-
*
|
|
26630
|
+
* Many vendor-specific drivers register accessory child devices on
|
|
26631
|
+
* top of a parent (Reolink: siren / floodlight / PIR / autotrack /
|
|
26632
|
+
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
26633
|
+
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
26634
|
+
* spawning, builds a name derived from the parent, and produces a
|
|
26635
|
+
* stableId tied to the parent so boot-restore can reconstruct the
|
|
26636
|
+
* relationship.
|
|
26637
|
+
*
|
|
26638
|
+
* Centralised `(kind → DeviceType)` mapping was dropped on purpose:
|
|
26639
|
+
* drivers may reasonably disagree on the right type for an accessory
|
|
26640
|
+
* (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
|
|
26641
|
+
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
26642
|
+
* one canonical mapping was over-prescriptive and added a layer of
|
|
26643
|
+
* indirection without saving meaningful code at call sites — the
|
|
26644
|
+
* driver knows its own hardware best.
|
|
26154
26645
|
*/
|
|
26155
|
-
/**
|
|
26156
|
-
|
|
26157
|
-
|
|
26158
|
-
|
|
26159
|
-
|
|
26160
|
-
|
|
26161
|
-
|
|
26162
|
-
|
|
26163
|
-
|
|
26164
|
-
|
|
26165
|
-
|
|
26166
|
-
|
|
26167
|
-
|
|
26168
|
-
|
|
26169
|
-
|
|
26646
|
+
/**
|
|
26647
|
+
* Subset of `DeviceRole` values that drivers register as child
|
|
26648
|
+
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
26649
|
+
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
26650
|
+
* children, so the call site reads as
|
|
26651
|
+
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
26652
|
+
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
26653
|
+
* any role works, including non-accessory ones like Doorbell).
|
|
26654
|
+
*/
|
|
26655
|
+
var AccessoryKind = {
|
|
26656
|
+
Siren: DeviceRole.Siren,
|
|
26657
|
+
Floodlight: DeviceRole.Floodlight,
|
|
26658
|
+
Spotlight: DeviceRole.Spotlight,
|
|
26659
|
+
PirSensor: DeviceRole.PirSensor,
|
|
26660
|
+
Chime: DeviceRole.Chime,
|
|
26661
|
+
Autotrack: DeviceRole.Autotrack,
|
|
26662
|
+
Nightvision: DeviceRole.Nightvision,
|
|
26663
|
+
PrivacyMask: DeviceRole.PrivacyMask
|
|
26170
26664
|
};
|
|
26171
|
-
|
|
26172
|
-
|
|
26173
|
-
|
|
26174
|
-
|
|
26175
|
-
|
|
26176
|
-
|
|
26177
|
-
|
|
26178
|
-
|
|
26179
|
-
|
|
26180
|
-
|
|
26181
|
-
|
|
26182
|
-
|
|
26183
|
-
|
|
26184
|
-
|
|
26185
|
-
|
|
26186
|
-
|
|
26187
|
-
|
|
26188
|
-
|
|
26189
|
-
|
|
26190
|
-
|
|
26191
|
-
|
|
26192
|
-
|
|
26193
|
-
|
|
26194
|
-
|
|
26195
|
-
|
|
26196
|
-
|
|
26197
|
-
|
|
26198
|
-
|
|
26199
|
-
|
|
26200
|
-
|
|
26201
|
-
|
|
26202
|
-
|
|
26203
|
-
|
|
26204
|
-
|
|
26205
|
-
|
|
26206
|
-
|
|
26207
|
-
|
|
26208
|
-
|
|
26209
|
-
|
|
26210
|
-
|
|
26211
|
-
|
|
26212
|
-
|
|
26213
|
-
|
|
26214
|
-
}
|
|
26215
|
-
return false;
|
|
26216
|
-
}
|
|
26217
|
-
countNode() {
|
|
26218
|
-
this.nodeCount += 1;
|
|
26219
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
26220
|
-
}
|
|
26221
|
-
parseTernary() {
|
|
26222
|
-
const test = this.parseBinary(1);
|
|
26223
|
-
if (this.matchPunct("?")) {
|
|
26224
|
-
const consequent = this.parseTernary();
|
|
26225
|
-
this.expectPunct(":");
|
|
26226
|
-
const alternate = this.parseTernary();
|
|
26227
|
-
this.countNode();
|
|
26228
|
-
return {
|
|
26229
|
-
kind: "conditional",
|
|
26230
|
-
test,
|
|
26231
|
-
consequent,
|
|
26232
|
-
alternate
|
|
26233
|
-
};
|
|
26234
|
-
}
|
|
26235
|
-
return test;
|
|
26236
|
-
}
|
|
26237
|
-
parseBinary(minPrec) {
|
|
26238
|
-
let left = this.parseUnary();
|
|
26239
|
-
for (;;) {
|
|
26240
|
-
const tok = this.peek();
|
|
26241
|
-
if (tok.type !== "punct") break;
|
|
26242
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
26243
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
26244
|
-
const op = tok.punct;
|
|
26245
|
-
this.pos += 1;
|
|
26246
|
-
const right = this.parseBinary(prec + 1);
|
|
26247
|
-
this.countNode();
|
|
26248
|
-
if (isLogicalOp(op)) left = {
|
|
26249
|
-
kind: "logical",
|
|
26250
|
-
op,
|
|
26251
|
-
left,
|
|
26252
|
-
right
|
|
26253
|
-
};
|
|
26254
|
-
else if (isBinaryOp(op)) left = {
|
|
26255
|
-
kind: "binary",
|
|
26256
|
-
op,
|
|
26257
|
-
left,
|
|
26258
|
-
right
|
|
26259
|
-
};
|
|
26260
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
26261
|
-
}
|
|
26262
|
-
return left;
|
|
26263
|
-
}
|
|
26264
|
-
parseUnary() {
|
|
26265
|
-
const tok = this.peek();
|
|
26266
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
26267
|
-
const op = tok.punct;
|
|
26268
|
-
this.pos += 1;
|
|
26269
|
-
const operand = this.parseUnary();
|
|
26270
|
-
this.countNode();
|
|
26271
|
-
return {
|
|
26272
|
-
kind: "unary",
|
|
26273
|
-
op,
|
|
26274
|
-
operand
|
|
26275
|
-
};
|
|
26276
|
-
}
|
|
26277
|
-
return this.parsePrimary();
|
|
26278
|
-
}
|
|
26279
|
-
parsePrimary() {
|
|
26280
|
-
const tok = this.next();
|
|
26281
|
-
switch (tok.type) {
|
|
26282
|
-
case "number":
|
|
26283
|
-
this.countNode();
|
|
26284
|
-
return {
|
|
26285
|
-
kind: "literal",
|
|
26286
|
-
value: tok.value
|
|
26287
|
-
};
|
|
26288
|
-
case "string":
|
|
26289
|
-
this.countNode();
|
|
26290
|
-
return {
|
|
26291
|
-
kind: "literal",
|
|
26292
|
-
value: tok.value
|
|
26293
|
-
};
|
|
26294
|
-
case "keyword":
|
|
26295
|
-
this.countNode();
|
|
26296
|
-
return {
|
|
26297
|
-
kind: "literal",
|
|
26298
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
26299
|
-
};
|
|
26300
|
-
case "identifier": {
|
|
26301
|
-
const nextTok = this.peek();
|
|
26302
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
26303
|
-
this.identifiers.add(tok.name);
|
|
26304
|
-
this.countNode();
|
|
26665
|
+
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
26666
|
+
/** Marker written to a declared integration's `info`. */
|
|
26667
|
+
var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
|
|
26668
|
+
/**
|
|
26669
|
+
* Strip the `<node>/<addon>` suffix a forked child carries.
|
|
26670
|
+
*
|
|
26671
|
+
* Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
|
|
26672
|
+
* that was supposed to act — because on the hub it reads `hub/<addon>`.
|
|
26673
|
+
*/
|
|
26674
|
+
function declarationOwnerNodeId(localNodeId) {
|
|
26675
|
+
const raw = localNodeId ?? "hub";
|
|
26676
|
+
if (!raw.includes("/")) return raw;
|
|
26677
|
+
return raw.split("/")[0] ?? "hub";
|
|
26678
|
+
}
|
|
26679
|
+
/**
|
|
26680
|
+
* The one way an addon owns a device it declares.
|
|
26681
|
+
*
|
|
26682
|
+
* Construct once with the addon's ports, then call {@link reconcile} on boot and
|
|
26683
|
+
* on every convergence tick. There is no second get-or-create helper — a guard
|
|
26684
|
+
* in `scripts/` enforces that.
|
|
26685
|
+
*/
|
|
26686
|
+
var DeclaredDevices = class {
|
|
26687
|
+
ports;
|
|
26688
|
+
constructor(ports) {
|
|
26689
|
+
const addonId = ports.addonId;
|
|
26690
|
+
if (typeof addonId !== "string" || addonId.length === 0) throw new Error(`DeclaredDevices: addonId must be the declaring addon's id, got ${JSON.stringify(addonId)}. On an addon context it is \`ctx.id\` — there is no \`ctx.addonId\`.`);
|
|
26691
|
+
this.ports = ports;
|
|
26692
|
+
}
|
|
26693
|
+
/**
|
|
26694
|
+
* Converge the declared set. Idempotent, and safe to call repeatedly.
|
|
26695
|
+
*
|
|
26696
|
+
* Throws only what the ports throw on the FIRST index read; every other
|
|
26697
|
+
* failure is per-device and logged, so one bad declaration never takes the
|
|
26698
|
+
* others down.
|
|
26699
|
+
*/
|
|
26700
|
+
async reconcile(spec) {
|
|
26701
|
+
if ((spec.placement ?? "hub") === "hub") {
|
|
26702
|
+
const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
|
|
26703
|
+
if (nodeId !== "hub") {
|
|
26704
|
+
this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
|
|
26705
|
+
nodeId,
|
|
26706
|
+
rawNodeId: this.ports.localNodeId ?? null
|
|
26707
|
+
} });
|
|
26305
26708
|
return {
|
|
26306
|
-
|
|
26307
|
-
|
|
26709
|
+
integrationId: null,
|
|
26710
|
+
devices: [],
|
|
26711
|
+
removed: [],
|
|
26712
|
+
owned: false
|
|
26308
26713
|
};
|
|
26309
26714
|
}
|
|
26310
|
-
case "punct":
|
|
26311
|
-
if (tok.punct === "(") {
|
|
26312
|
-
const inner = this.parseTernary();
|
|
26313
|
-
this.expectPunct(")");
|
|
26314
|
-
return inner;
|
|
26315
|
-
}
|
|
26316
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
26317
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
26318
26715
|
}
|
|
26319
|
-
|
|
26320
|
-
|
|
26321
|
-
|
|
26322
|
-
|
|
26323
|
-
|
|
26324
|
-
|
|
26325
|
-
args.push(this.parseTernary());
|
|
26326
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
26327
|
-
if (this.matchPunct(",")) continue;
|
|
26328
|
-
this.expectPunct(")");
|
|
26329
|
-
break;
|
|
26716
|
+
const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
|
|
26717
|
+
const index = await this.readIndex();
|
|
26718
|
+
const outcomes = [];
|
|
26719
|
+
for (const declaration of spec.devices) {
|
|
26720
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
26721
|
+
if (outcome !== null) outcomes.push(outcome);
|
|
26330
26722
|
}
|
|
26331
|
-
this.callees.add(callee);
|
|
26332
|
-
this.countNode();
|
|
26333
26723
|
return {
|
|
26334
|
-
|
|
26335
|
-
|
|
26336
|
-
|
|
26724
|
+
integrationId,
|
|
26725
|
+
devices: outcomes,
|
|
26726
|
+
removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
|
|
26727
|
+
owned: true
|
|
26337
26728
|
};
|
|
26338
26729
|
}
|
|
26339
|
-
|
|
26340
|
-
|
|
26341
|
-
*
|
|
26342
|
-
|
|
26343
|
-
|
|
26344
|
-
|
|
26345
|
-
|
|
26346
|
-
|
|
26347
|
-
|
|
26348
|
-
|
|
26349
|
-
|
|
26350
|
-
|
|
26351
|
-
|
|
26352
|
-
|
|
26353
|
-
|
|
26354
|
-
|
|
26355
|
-
|
|
26356
|
-
|
|
26357
|
-
|
|
26358
|
-
|
|
26359
|
-
|
|
26360
|
-
|
|
26361
|
-
|
|
26730
|
+
/**
|
|
26731
|
+
* Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
|
|
26732
|
+
*
|
|
26733
|
+
* The re-assertion is the fix for the defect the hand-rolled version shipped
|
|
26734
|
+
* with: writing `info.fixed` only on the create path left every pre-existing
|
|
26735
|
+
* install without it, and the kernel kept offering to delete an integration
|
|
26736
|
+
* the addon owns.
|
|
26737
|
+
*/
|
|
26738
|
+
async ensureIntegration(integrationName) {
|
|
26739
|
+
const existing = await this.ports.getIntegration(this.ports.addonId);
|
|
26740
|
+
if (existing === null) {
|
|
26741
|
+
const created = await this.ports.createIntegration({
|
|
26742
|
+
addonId: this.ports.addonId,
|
|
26743
|
+
name: integrationName,
|
|
26744
|
+
info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
|
|
26745
|
+
});
|
|
26746
|
+
this.ports.logger.info("declared a fixed integration", { meta: {
|
|
26747
|
+
integrationId: created.id,
|
|
26748
|
+
name: integrationName
|
|
26749
|
+
} });
|
|
26750
|
+
return created.id;
|
|
26751
|
+
}
|
|
26752
|
+
if (existing.info?.["fixed"] !== true) {
|
|
26753
|
+
await this.ports.updateIntegration({
|
|
26754
|
+
id: existing.id,
|
|
26755
|
+
info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
|
|
26756
|
+
});
|
|
26757
|
+
this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
|
|
26758
|
+
}
|
|
26759
|
+
return existing.id;
|
|
26362
26760
|
}
|
|
26363
|
-
|
|
26364
|
-
|
|
26365
|
-
|
|
26366
|
-
ok: true,
|
|
26367
|
-
parsed: parseExpression(source)
|
|
26368
|
-
};
|
|
26369
|
-
} catch (err) {
|
|
26370
|
-
result = {
|
|
26371
|
-
ok: false,
|
|
26372
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
26373
|
-
};
|
|
26761
|
+
async readIndex() {
|
|
26762
|
+
const rows = await this.ports.listOwnDevices();
|
|
26763
|
+
return new Map(rows.map((row) => [row.stableId, row]));
|
|
26374
26764
|
}
|
|
26375
|
-
|
|
26376
|
-
|
|
26377
|
-
|
|
26378
|
-
|
|
26765
|
+
/**
|
|
26766
|
+
* One declaration: adopt what exists, create what does not.
|
|
26767
|
+
*
|
|
26768
|
+
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
26769
|
+
* `initialMeta.name` lands as an unconditional `setName`. A transiently empty
|
|
26770
|
+
* index therefore looks exactly like a first boot and would silently re-stamp
|
|
26771
|
+
* the declared name over the operator's rename. D49: that branch needs a
|
|
26772
|
+
* second read to agree.
|
|
26773
|
+
*/
|
|
26774
|
+
async applyDeclaration(declaration, integrationId, index) {
|
|
26775
|
+
try {
|
|
26776
|
+
let existing = index.get(declaration.stableId);
|
|
26777
|
+
if (existing === void 0) {
|
|
26778
|
+
existing = (await this.readIndex()).get(declaration.stableId);
|
|
26779
|
+
if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
|
|
26780
|
+
tags: { deviceId: existing.id },
|
|
26781
|
+
meta: {
|
|
26782
|
+
stableId: declaration.stableId,
|
|
26783
|
+
addonId: this.ports.addonId
|
|
26784
|
+
}
|
|
26785
|
+
});
|
|
26786
|
+
}
|
|
26787
|
+
if (existing !== void 0) {
|
|
26788
|
+
const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
|
|
26789
|
+
this.ports.logger.info("declared device adopted", {
|
|
26790
|
+
tags: { deviceId: device.id },
|
|
26791
|
+
meta: {
|
|
26792
|
+
stableId: declaration.stableId,
|
|
26793
|
+
integrationId
|
|
26794
|
+
}
|
|
26795
|
+
});
|
|
26796
|
+
return {
|
|
26797
|
+
stableId: declaration.stableId,
|
|
26798
|
+
deviceId: device.id,
|
|
26799
|
+
device,
|
|
26800
|
+
created: false
|
|
26801
|
+
};
|
|
26802
|
+
}
|
|
26803
|
+
const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
|
|
26804
|
+
type: declaration.type,
|
|
26805
|
+
name: declaration.name,
|
|
26806
|
+
integrationId,
|
|
26807
|
+
...declaration.role === void 0 ? {} : { role: declaration.role }
|
|
26808
|
+
});
|
|
26809
|
+
this.ports.logger.info("declared device created", {
|
|
26810
|
+
tags: { deviceId: device.id },
|
|
26811
|
+
meta: {
|
|
26812
|
+
stableId: declaration.stableId,
|
|
26813
|
+
integrationId
|
|
26814
|
+
}
|
|
26815
|
+
});
|
|
26816
|
+
return {
|
|
26817
|
+
stableId: declaration.stableId,
|
|
26818
|
+
deviceId: device.id,
|
|
26819
|
+
device,
|
|
26820
|
+
created: true
|
|
26821
|
+
};
|
|
26822
|
+
} catch (err) {
|
|
26823
|
+
this.ports.logger.warn("a declared device could not be brought up", { meta: {
|
|
26824
|
+
stableId: declaration.stableId,
|
|
26825
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26826
|
+
} });
|
|
26827
|
+
return null;
|
|
26828
|
+
}
|
|
26379
26829
|
}
|
|
26380
|
-
|
|
26830
|
+
/**
|
|
26831
|
+
* Remove rows under the addon's FIXED integration whose declaration is gone.
|
|
26832
|
+
*
|
|
26833
|
+
* Bounded to that integration: a declared integration has no operator
|
|
26834
|
+
* add-flow, so every row under it got there by declaration. Devices this
|
|
26835
|
+
* addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
|
|
26836
|
+
*
|
|
26837
|
+
* Bounded in count, and every deletion is logged with its `deviceId` — a
|
|
26838
|
+
* withdrawal that removes an operator-visible row silently is the failure
|
|
26839
|
+
* mode, not the removal itself.
|
|
26840
|
+
*/
|
|
26841
|
+
async sweepWithdrawn(declarations, integrationId, index) {
|
|
26842
|
+
const declared = new Set(declarations.map((d) => d.stableId));
|
|
26843
|
+
const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
|
|
26844
|
+
if (candidates.length === 0) return [];
|
|
26845
|
+
if (candidates.length > 32) {
|
|
26846
|
+
this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
|
|
26847
|
+
integrationId,
|
|
26848
|
+
candidates: candidates.length,
|
|
26849
|
+
bound: 32
|
|
26850
|
+
} });
|
|
26851
|
+
return [];
|
|
26852
|
+
}
|
|
26853
|
+
const removed = [];
|
|
26854
|
+
for (const row of candidates) try {
|
|
26855
|
+
await this.ports.devices.remove(row.id);
|
|
26856
|
+
removed.push(row.id);
|
|
26857
|
+
this.ports.logger.info("declared device removed — its declaration was withdrawn", {
|
|
26858
|
+
tags: { deviceId: row.id },
|
|
26859
|
+
meta: {
|
|
26860
|
+
stableId: row.stableId,
|
|
26861
|
+
integrationId
|
|
26862
|
+
}
|
|
26863
|
+
});
|
|
26864
|
+
} catch (err) {
|
|
26865
|
+
this.ports.logger.warn("a withdrawn declared device could not be removed", {
|
|
26866
|
+
tags: { deviceId: row.id },
|
|
26867
|
+
meta: {
|
|
26868
|
+
stableId: row.stableId,
|
|
26869
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26870
|
+
}
|
|
26871
|
+
});
|
|
26872
|
+
}
|
|
26873
|
+
return removed;
|
|
26874
|
+
}
|
|
26875
|
+
};
|
|
26876
|
+
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;
|
|
26877
|
+
new Set(Object.values(DeviceType));
|
|
26878
|
+
DeviceFeature.BatteryOperated;
|
|
26879
|
+
/** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner type.
|
|
26880
|
+
* Zod v4 exposes `.unwrap()` on all three wrapper classes. */
|
|
26881
|
+
function unwrap(schema) {
|
|
26882
|
+
let s = schema;
|
|
26883
|
+
while (s instanceof z.ZodNullable || s instanceof z.ZodOptional || s instanceof z.ZodDefault) s = s.unwrap();
|
|
26884
|
+
return s;
|
|
26381
26885
|
}
|
|
26382
|
-
|
|
26383
|
-
|
|
26384
|
-
|
|
26385
|
-
|
|
26886
|
+
function leafKind(schema) {
|
|
26887
|
+
const s = unwrap(schema);
|
|
26888
|
+
if (s instanceof z.ZodEnum) return {
|
|
26889
|
+
kind: "enum",
|
|
26890
|
+
enumValues: s.options
|
|
26891
|
+
};
|
|
26892
|
+
if (s instanceof z.ZodNumber) return { kind: "number" };
|
|
26893
|
+
if (s instanceof z.ZodString) return { kind: "string" };
|
|
26894
|
+
if (s instanceof z.ZodBoolean) return { kind: "boolean" };
|
|
26895
|
+
return null;
|
|
26386
26896
|
}
|
|
26387
|
-
|
|
26388
|
-
|
|
26389
|
-
*
|
|
26390
|
-
*
|
|
26391
|
-
|
|
26392
|
-
|
|
26393
|
-
|
|
26394
|
-
|
|
26395
|
-
|
|
26396
|
-
const
|
|
26397
|
-
|
|
26398
|
-
|
|
26399
|
-
if (
|
|
26400
|
-
|
|
26401
|
-
|
|
26402
|
-
|
|
26403
|
-
|
|
26404
|
-
|
|
26405
|
-
|
|
26406
|
-
|
|
26407
|
-
|
|
26408
|
-
|
|
26897
|
+
/** Walk a cap status schema into flat wireable leaf fields (dotted paths).
|
|
26898
|
+
* Recurses into nested ZodObject (unwrapping nullable/optional/default first),
|
|
26899
|
+
* so fields with null live values are still offered. Arrays / unknown shapes
|
|
26900
|
+
* are skipped — never throws. */
|
|
26901
|
+
function enumerateSchemaFields(schema, prefix = "") {
|
|
26902
|
+
const root = unwrap(schema);
|
|
26903
|
+
if (!(root instanceof z.ZodObject)) return [];
|
|
26904
|
+
const out = [];
|
|
26905
|
+
const shape = root.shape;
|
|
26906
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
26907
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
26908
|
+
const inner = unwrap(field);
|
|
26909
|
+
if (inner instanceof z.ZodObject) {
|
|
26910
|
+
out.push(...enumerateSchemaFields(inner, path));
|
|
26911
|
+
continue;
|
|
26912
|
+
}
|
|
26913
|
+
const leaf = leafKind(field);
|
|
26914
|
+
if (leaf) out.push({
|
|
26915
|
+
path,
|
|
26916
|
+
kind: leaf.kind,
|
|
26917
|
+
...leaf.enumValues ? { enumValues: leaf.enumValues } : {}
|
|
26918
|
+
});
|
|
26409
26919
|
}
|
|
26410
|
-
return
|
|
26920
|
+
return out;
|
|
26921
|
+
}
|
|
26922
|
+
/** Enumerate the per-item wireable fields of an item-array cap (see
|
|
26923
|
+
* `CapabilityStatusItemArray`): the item schema's leaf fields, minus the
|
|
26924
|
+
* `keyField` (the key comes from the link's `itemKey`, never from a wired
|
|
26925
|
+
* source), each tagged `item: true` so the authoring UI collects an
|
|
26926
|
+
* `itemKey` alongside the field. Never throws. */
|
|
26927
|
+
function enumerateItemArrayFields(itemArray) {
|
|
26928
|
+
return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
|
|
26929
|
+
...f,
|
|
26930
|
+
item: true
|
|
26931
|
+
}));
|
|
26411
26932
|
}
|
|
26412
|
-
/**
|
|
26413
|
-
* What an expression's named bindings READ from.
|
|
26414
|
-
*
|
|
26415
|
-
* Salvaged verbatim from the deleted device-link mechanism. Wiring's source
|
|
26416
|
-
* kinds were the one part of it worth keeping: addressing a device field by
|
|
26417
|
-
* re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
|
|
26418
|
-
* read are the vocabulary any cross-device derivation needs, and they were
|
|
26419
|
-
* already correct. What wiring got wrong was the DESTINATION — a field on
|
|
26420
|
-
* somebody else's device, with no identity — not the source.
|
|
26421
|
-
*
|
|
26422
|
-
* These shapes are therefore kept, re-homed next to the engine that consumes
|
|
26423
|
-
* them, and are the binding type of a composition recipe (the source picker
|
|
26424
|
-
* stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
|
|
26425
|
-
* binding is a read, never another expression.
|
|
26426
|
-
*
|
|
26427
|
-
* Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
|
|
26428
|
-
* a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
|
|
26429
|
-
*/
|
|
26430
|
-
/** Read a sibling accessory's status field, addressed by the sibling's key.
|
|
26431
|
-
* `kind` is optional for wire compatibility — absent means `'field'`. */
|
|
26432
|
-
var ExpressionFieldBindingSchema = z.object({
|
|
26433
|
-
kind: z.literal("field").optional(),
|
|
26434
|
-
sourceKey: z.string(),
|
|
26435
|
-
cap: z.string(),
|
|
26436
|
-
fieldPath: z.string()
|
|
26437
|
-
});
|
|
26438
|
-
/** A constant. No device is read. */
|
|
26439
|
-
var ExpressionLiteralBindingSchema = z.object({
|
|
26440
|
-
kind: z.literal("literal"),
|
|
26441
|
-
value: z.union([
|
|
26442
|
-
z.string(),
|
|
26443
|
-
z.number(),
|
|
26444
|
-
z.boolean(),
|
|
26445
|
-
z.null()
|
|
26446
|
-
])
|
|
26447
|
-
});
|
|
26448
|
-
/** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
|
|
26449
|
-
* never by numeric id, which a re-adoption reissues. */
|
|
26450
|
-
var ExpressionGlobalBindingSchema = z.object({
|
|
26451
|
-
kind: z.literal("global"),
|
|
26452
|
-
sourceStableId: z.string(),
|
|
26453
|
-
cap: z.string(),
|
|
26454
|
-
fieldPath: z.string()
|
|
26455
|
-
});
|
|
26456
|
-
var ExpressionBindingSourceSchema = z.union([
|
|
26457
|
-
ExpressionFieldBindingSchema,
|
|
26458
|
-
ExpressionLiteralBindingSchema,
|
|
26459
|
-
ExpressionGlobalBindingSchema
|
|
26460
|
-
]);
|
|
26461
|
-
z.object({
|
|
26462
|
-
expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
26463
|
-
bindings: z.record(z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
26464
|
-
}).superRefine((src, ctx) => {
|
|
26465
|
-
const err = validateExpressionSource(src);
|
|
26466
|
-
if (err !== null) ctx.addIssue({
|
|
26467
|
-
code: "custom",
|
|
26468
|
-
message: err,
|
|
26469
|
-
path: ["expr"]
|
|
26470
|
-
});
|
|
26471
|
-
});
|
|
26472
26933
|
/**
|
|
26473
26934
|
* Runtime list of cap names with status. Used by the settings
|
|
26474
26935
|
* aggregator to enumerate caps whose `status` should be polled +
|
|
@@ -31035,6 +31496,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
|
|
|
31035
31496
|
addonId: null,
|
|
31036
31497
|
access: "view"
|
|
31037
31498
|
},
|
|
31499
|
+
"snapshot.getSnapshotLinks": {
|
|
31500
|
+
capName: "snapshot",
|
|
31501
|
+
capScope: "device",
|
|
31502
|
+
addonId: null,
|
|
31503
|
+
access: "view"
|
|
31504
|
+
},
|
|
31038
31505
|
"snapshot.getSnapshotOverview": {
|
|
31039
31506
|
capName: "snapshot",
|
|
31040
31507
|
capScope: "device",
|
|
@@ -32477,4 +32944,4 @@ function enumerateInferenceDevices(hw) {
|
|
|
32477
32944
|
return out;
|
|
32478
32945
|
}
|
|
32479
32946
|
//#endregion
|
|
32480
|
-
export {
|
|
32947
|
+
export { objectInputDeclaresAddonId as $, deviceManagerCapability as A, hydrateSchema as At, isCollectionArrayMethod as B, bareAddonId as C, asJsonObject as Ct, coreBlocksCapability as D, emitDownForOwnedCaps as Dt, coreBlockIdFromAddonId as E, createEvent as Et, enumerateItemArrayFields as F, readinessKey as Ft, lifecycleJobSchema as G, isSameAddonId as H, enumerateSchemaFields as I, resolveCapMount as It, logLevelAtMost as J, localNetworkCapability as K, extractNestedAddonId as L, scopeKey as Lt, deviceStatusCapability as M, nodePin as Mt, doorbellCapability as N, parseJsonObject as Nt, dataStoreProviderCapability as O, emitReadiness as Ot, enumerateInferenceDevices as P, parseJsonUnknown as Pt, oauthIntegrationCapability as Q, filesystemBrowseCapability as R, sleep as Rt, backupCapability as S, WELL_KNOWN_TAB_MAP as St, coreBlockAddonId as T, asString as Tt, isVoidInput as U, isObjectInput as V, kebabToCamel as W, metricsProviderCapability as X, looseSchema as Y, normalizeUnit as Z, addonPagesCapability as _, DeviceFeature as _t, CAP_NAMES_WITH_STATUS as a, snapshotCapability as at, alertsCapability as b, ReadinessRegistry as bt, CoreBlockSchema as c, streamQualityLabel as ct, METHOD_ACCESS_MAP as d, vectorStoreCapability as dt, parseStreamParamsFormPatch as et, RUNTIME_DEFAULTS as f, errMsg as ft, UserRecordSchema as g, DEVICE_STATUS_METHOD as gt, StorageLocationTypeSchema as h, DEVICE_SETTINGS_CONTRIBUTION_METHODS as ht, BatteryStatusSchema as i, settingsStoreCapability as it, deviceStateCapability as j, isDeviceConfigCap as jt, decodeVectorBase64 as k, expandCapMethods as kt, DeclaredDevices as l, userManagementCapability as lt, ScopedTokenSchema as m, DATAPLANE_SECRET_HEADER as mt, AlertSchema as n, procedureAuthKey as nt, CORE_BLOCKS_ADDON_ID as o, storageCapability as ot, STREAM_PROFILE_META as p, BaseAddon as pt, logDestinationCapability as q, ApiKeyRecordSchema as r, scoreRuntimes as rt, CORE_BLOCK_ADDON_PREFIX as s, storageProviderCapability as st, ALL_CAPABILITY_DEFINITIONS as t, platformProbeCapability as tt, DeviceStatusSchema as u, vectorDimFromBase64 as ut, addonSettingsCapability as v, DeviceRole as vt, buildStreamParamsConfigSchema as w, asNumber as wt, authProviderCapability as x, ReadinessTimeoutError as xt, addonWidgetsCapability as y, DeviceType as yt, isArrayOutputSchema as z, EventCategory as zt };
|