@camstack/addon-provider-homematic 1.2.10 → 1.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +1921 -1244
- package/dist/addon.mjs +1921 -1244
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -8,7 +8,7 @@ let http = require("http");
|
|
|
8
8
|
let crypto$1 = require("crypto");
|
|
9
9
|
let fs_promises = require("fs/promises");
|
|
10
10
|
let path = require("path");
|
|
11
|
-
//#region ../types/dist/event-category-
|
|
11
|
+
//#region ../types/dist/event-category-Cv9dO26A.mjs
|
|
12
12
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
13
13
|
EventCategory["SystemBoot"] = "system.boot";
|
|
14
14
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -24,6 +24,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
24
24
|
*/
|
|
25
25
|
EventCategory["SystemRestartCompleted"] = "system.restart-completed";
|
|
26
26
|
/**
|
|
27
|
+
* A newer addon or server-root package version was found by the
|
|
28
|
+
* authoritative registry check. Emitted once per
|
|
29
|
+
* `(target, packageName, currentVersion, latestVersion)` transition; repeated
|
|
30
|
+
* polling of the same result is deduplicated by the checker.
|
|
31
|
+
*/
|
|
32
|
+
EventCategory["UpdateAvailable"] = "update.available";
|
|
33
|
+
/**
|
|
27
34
|
* Readiness transition for a capability provider. Every producer emits
|
|
28
35
|
* this event on `onInitialize` completion, `onDestroy`, and
|
|
29
36
|
* `$node.reconnect`; every consumer that needs to gate on a cross-process
|
|
@@ -7123,6 +7130,104 @@ object({
|
|
|
7123
7130
|
})
|
|
7124
7131
|
});
|
|
7125
7132
|
/**
|
|
7133
|
+
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7134
|
+
*
|
|
7135
|
+
* ## Why this exists
|
|
7136
|
+
*
|
|
7137
|
+
* `adopt({childNativeIds: [...]})` materialises one CamStack device per
|
|
7138
|
+
* candidate PLUS every accessory child, and the whole array shares ONE UDS
|
|
7139
|
+
* request deadline (60s). Measured on the live hub against Home Assistant:
|
|
7140
|
+
* each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
|
|
7141
|
+
* meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
|
|
7142
|
+
* `setRole`, … `persistConfig`) before the class is constructed — and an
|
|
7143
|
+
* accessory child costs the same as its parent. So the real unit of work is
|
|
7144
|
+
* the CHILD, not the candidate:
|
|
7145
|
+
*
|
|
7146
|
+
* - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
|
|
7147
|
+
* - ONE candidate with 217 children → ~217 devices → **>60s, times out**
|
|
7148
|
+
*
|
|
7149
|
+
* That second line is why this is a job and not a smaller batch. No chunking,
|
|
7150
|
+
* no bounded concurrency over candidates and no per-call tuning can fix a
|
|
7151
|
+
* shape where **N=1 already exceeds the deadline** — the count that blows the
|
|
7152
|
+
* budget is the source system's accessory fan-out, which the operator does not
|
|
7153
|
+
* choose and cannot see. A design that only works below some N is the same bug
|
|
7154
|
+
* deferred.
|
|
7155
|
+
*
|
|
7156
|
+
* ## What the timeout did NOT do
|
|
7157
|
+
*
|
|
7158
|
+
* It did not stop the work. The UDS deadline ends the CALLER's wait; the
|
|
7159
|
+
* provider's loop runs to completion. Measured: a 25-candidate adopt that
|
|
7160
|
+
* "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
|
|
7161
|
+
* an error and had no way to learn that. Every field below exists so that
|
|
7162
|
+
* question has an answer.
|
|
7163
|
+
*
|
|
7164
|
+
* ## Idempotency
|
|
7165
|
+
*
|
|
7166
|
+
* Jobs are in-RAM; a restart forgets them. That is safe here because adoption
|
|
7167
|
+
* is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
|
|
7168
|
+
* re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
|
|
7169
|
+
* by the engine before any provider call and lands in `alreadyAdopted`. It is
|
|
7170
|
+
* never a duplicate device, and never an error the operator has to interpret.
|
|
7171
|
+
*/
|
|
7172
|
+
var AdoptionJobStateSchema = _enum([
|
|
7173
|
+
"running",
|
|
7174
|
+
"done",
|
|
7175
|
+
"failed",
|
|
7176
|
+
"cancelled"
|
|
7177
|
+
]);
|
|
7178
|
+
/**
|
|
7179
|
+
* Per-candidate result. Every candidate the job was asked to adopt ends in
|
|
7180
|
+
* exactly one of these buckets — there is no silent drop, and the operator can
|
|
7181
|
+
* always answer "which of my 25 landed?".
|
|
7182
|
+
*
|
|
7183
|
+
* - `adopted` — created now by this job.
|
|
7184
|
+
* - `already-adopted` — a device for this candidate existed before the job
|
|
7185
|
+
* reached it (a re-run, or a retry after a timeout). Not an error.
|
|
7186
|
+
* - `failed` — the provider threw; `error` carries the message.
|
|
7187
|
+
* - `cancelled` — the operator cancelled before this candidate was reached.
|
|
7188
|
+
*/
|
|
7189
|
+
var AdoptionOutcomeSchema = _enum([
|
|
7190
|
+
"adopted",
|
|
7191
|
+
"already-adopted",
|
|
7192
|
+
"failed",
|
|
7193
|
+
"cancelled"
|
|
7194
|
+
]);
|
|
7195
|
+
var AdoptionCandidateResultSchema = object({
|
|
7196
|
+
childNativeId: string(),
|
|
7197
|
+
outcome: AdoptionOutcomeSchema,
|
|
7198
|
+
/** The materialised parent device id — null for `failed` / `cancelled`. */
|
|
7199
|
+
parentDeviceId: number().int().nonnegative().nullable(),
|
|
7200
|
+
/** Accessory children created for this candidate. */
|
|
7201
|
+
accessoryCount: number().int().nonnegative(),
|
|
7202
|
+
/** Failure message; null unless `outcome === 'failed'`. */
|
|
7203
|
+
error: string().nullable()
|
|
7204
|
+
});
|
|
7205
|
+
var AdoptionJobSchema = object({
|
|
7206
|
+
jobId: string(),
|
|
7207
|
+
/** The integration provider this job adopts through (the `addonId` pin). */
|
|
7208
|
+
addonId: string(),
|
|
7209
|
+
integrationId: string(),
|
|
7210
|
+
state: AdoptionJobStateSchema,
|
|
7211
|
+
/** Candidates the job was asked to adopt. Known up front, so never null. */
|
|
7212
|
+
total: number().int().nonnegative(),
|
|
7213
|
+
/** Candidates that have reached a terminal bucket. */
|
|
7214
|
+
processed: number().int().nonnegative(),
|
|
7215
|
+
adopted: number().int().nonnegative(),
|
|
7216
|
+
alreadyAdopted: number().int().nonnegative(),
|
|
7217
|
+
failed: number().int().nonnegative(),
|
|
7218
|
+
/** Accessory child devices created across every candidate — the real unit
|
|
7219
|
+
* of work, surfaced so a slow job is legible rather than mysterious. */
|
|
7220
|
+
accessoriesCreated: number().int().nonnegative(),
|
|
7221
|
+
/** The candidate currently being adopted; null when idle or finished. */
|
|
7222
|
+
currentChildNativeId: string().nullable(),
|
|
7223
|
+
/** One entry per candidate, in the order they were processed. */
|
|
7224
|
+
results: array(AdoptionCandidateResultSchema).readonly(),
|
|
7225
|
+
startedAt: number(),
|
|
7226
|
+
finishedAt: number().nullable(),
|
|
7227
|
+
/** Set only when the job itself broke (not a per-candidate failure). */
|
|
7228
|
+
error: string().nullable()
|
|
7229
|
+
});
|
|
7230
|
+
/**
|
|
7126
7231
|
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7127
7232
|
* pipeline functions an operator thinks in terms of.
|
|
7128
7233
|
*
|
|
@@ -7942,314 +8047,1088 @@ var ConvertResultSchema = object({
|
|
|
7942
8047
|
})).readonly()
|
|
7943
8048
|
});
|
|
7944
8049
|
/**
|
|
7945
|
-
*
|
|
7946
|
-
*
|
|
7947
|
-
*
|
|
7948
|
-
* The provider iterates every `addon-pages-source` (collection) provider
|
|
7949
|
-
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
7950
|
-
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
7951
|
-
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
7952
|
-
* rebuilds without manual reload.
|
|
7953
|
-
*
|
|
7954
|
-
* The hub-local builtin `addon-pages-aggregator` (see
|
|
7955
|
-
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
7956
|
-
* provider. Splitting the public aggregator from the raw collection
|
|
7957
|
-
* keeps both ends in codegen — there's no hand-written
|
|
7958
|
-
* `addon-pages.router.ts` wrapper anymore.
|
|
8050
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
8051
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
8052
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
7959
8053
|
*/
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
}
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
8054
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
8055
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
8056
|
+
var ExpressionParseError = class extends Error {
|
|
8057
|
+
position;
|
|
8058
|
+
constructor(message, position) {
|
|
8059
|
+
super(message);
|
|
8060
|
+
this.name = "ExpressionParseError";
|
|
8061
|
+
this.position = position;
|
|
8062
|
+
}
|
|
8063
|
+
};
|
|
8064
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
8065
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
8066
|
+
var ExpressionEvalError = class extends Error {
|
|
8067
|
+
constructor(message) {
|
|
8068
|
+
super(message);
|
|
8069
|
+
this.name = "ExpressionEvalError";
|
|
8070
|
+
}
|
|
8071
|
+
};
|
|
7976
8072
|
/**
|
|
7977
|
-
*
|
|
7978
|
-
*
|
|
7979
|
-
*
|
|
7980
|
-
*
|
|
7981
|
-
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
7982
|
-
* that admin-ui consumes.
|
|
8073
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
8074
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
8075
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
8076
|
+
* own-property check against it.
|
|
7983
8077
|
*
|
|
7984
|
-
*
|
|
7985
|
-
*
|
|
7986
|
-
*
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
* Module Federation remote name — must match the `name` field on the
|
|
7995
|
-
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
7996
|
-
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
7997
|
-
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
7998
|
-
* contain hyphens).
|
|
7999
|
-
*/
|
|
8000
|
-
remoteName: string(),
|
|
8001
|
-
/**
|
|
8002
|
-
* Bundle filename inside the addon's `dist/` dir served at
|
|
8003
|
-
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
8004
|
-
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
8005
|
-
* the static-file route can compute an mtime-based cache-buster URL
|
|
8006
|
-
* without a separate filesystem stat.
|
|
8007
|
-
*/
|
|
8008
|
-
bundle: string(),
|
|
8009
|
-
/**
|
|
8010
|
-
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
8011
|
-
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
8012
|
-
* Any OTHER string creates (or joins) a custom section rendered after
|
|
8013
|
-
* the built-in groups; its label comes from `sectionLabel` (first
|
|
8014
|
-
* declaration wins), falling back to the id. Absent → the legacy
|
|
8015
|
-
* "Addon Pages" group.
|
|
8016
|
-
*/
|
|
8017
|
-
section: string().optional(),
|
|
8018
|
-
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
8019
|
-
sectionLabel: string().optional()
|
|
8020
|
-
});
|
|
8021
|
-
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
8022
|
-
var AddonHttpRouteSchema = object({
|
|
8023
|
-
method: _enum([
|
|
8024
|
-
"GET",
|
|
8025
|
-
"POST",
|
|
8026
|
-
"PUT",
|
|
8027
|
-
"DELETE",
|
|
8028
|
-
"PATCH"
|
|
8029
|
-
]),
|
|
8030
|
-
path: string(),
|
|
8031
|
-
access: _enum([
|
|
8032
|
-
"public",
|
|
8033
|
-
"authenticated",
|
|
8034
|
-
"admin"
|
|
8035
|
-
]).optional(),
|
|
8036
|
-
description: string().optional()
|
|
8037
|
-
});
|
|
8038
|
-
/**
|
|
8039
|
-
* Cross-process route invocation envelope. The hub captures the
|
|
8040
|
-
* request as plain data, ships it to the worker via Moleculer, and
|
|
8041
|
-
* the worker runs the local handler against a capturing reply. The
|
|
8042
|
-
* envelope returned describes what the handler intended (status,
|
|
8043
|
-
* headers, body, or a redirect) so the hub can translate it back to
|
|
8044
|
-
* the Fastify reply that's actually wired to the socket.
|
|
8078
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
8079
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
8080
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
8081
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
8082
|
+
* callable — they are simply "unknown function" at parse time.
|
|
8083
|
+
*
|
|
8084
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
8085
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
8086
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
8087
|
+
* closed rather than emitting a garbage value.
|
|
8045
8088
|
*/
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
8126
|
-
|
|
8127
|
-
|
|
8128
|
-
|
|
8089
|
+
function asFiniteNumber(value, name, index) {
|
|
8090
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
8091
|
+
return value;
|
|
8092
|
+
}
|
|
8093
|
+
function asString$1(value, name, index) {
|
|
8094
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
8095
|
+
return value;
|
|
8096
|
+
}
|
|
8097
|
+
function finiteResult(value, name) {
|
|
8098
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
8099
|
+
return value;
|
|
8100
|
+
}
|
|
8101
|
+
function allFiniteNumbers(args, name) {
|
|
8102
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
8103
|
+
}
|
|
8104
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
8105
|
+
var table = {
|
|
8106
|
+
min: {
|
|
8107
|
+
minArgs: 1,
|
|
8108
|
+
maxArgs: INF,
|
|
8109
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
8110
|
+
},
|
|
8111
|
+
max: {
|
|
8112
|
+
minArgs: 1,
|
|
8113
|
+
maxArgs: INF,
|
|
8114
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
8115
|
+
},
|
|
8116
|
+
abs: {
|
|
8117
|
+
minArgs: 1,
|
|
8118
|
+
maxArgs: 1,
|
|
8119
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
8120
|
+
},
|
|
8121
|
+
floor: {
|
|
8122
|
+
minArgs: 1,
|
|
8123
|
+
maxArgs: 1,
|
|
8124
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
8125
|
+
},
|
|
8126
|
+
ceil: {
|
|
8127
|
+
minArgs: 1,
|
|
8128
|
+
maxArgs: 1,
|
|
8129
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
8130
|
+
},
|
|
8131
|
+
sqrt: {
|
|
8132
|
+
minArgs: 1,
|
|
8133
|
+
maxArgs: 1,
|
|
8134
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
8135
|
+
},
|
|
8136
|
+
round: {
|
|
8137
|
+
minArgs: 1,
|
|
8138
|
+
maxArgs: 2,
|
|
8139
|
+
apply: (args) => {
|
|
8140
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
8141
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
8142
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
8143
|
+
const factor = 10 ** digits;
|
|
8144
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
8145
|
+
}
|
|
8146
|
+
},
|
|
8147
|
+
pow: {
|
|
8148
|
+
minArgs: 2,
|
|
8149
|
+
maxArgs: 2,
|
|
8150
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
8151
|
+
},
|
|
8152
|
+
clamp: {
|
|
8153
|
+
minArgs: 3,
|
|
8154
|
+
maxArgs: 3,
|
|
8155
|
+
apply: (args) => {
|
|
8156
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
8157
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
8158
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
8159
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
8160
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
8161
|
+
}
|
|
8162
|
+
},
|
|
8163
|
+
avg: {
|
|
8164
|
+
minArgs: 1,
|
|
8165
|
+
maxArgs: INF,
|
|
8166
|
+
apply: (args) => {
|
|
8167
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
8168
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
8169
|
+
}
|
|
8170
|
+
},
|
|
8171
|
+
sum: {
|
|
8172
|
+
minArgs: 1,
|
|
8173
|
+
maxArgs: INF,
|
|
8174
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
8175
|
+
},
|
|
8176
|
+
coalesce: {
|
|
8177
|
+
minArgs: 1,
|
|
8178
|
+
maxArgs: INF,
|
|
8179
|
+
apply: (args) => {
|
|
8180
|
+
for (const a of args) if (a !== null) return a;
|
|
8181
|
+
return null;
|
|
8182
|
+
}
|
|
8183
|
+
},
|
|
8184
|
+
age: {
|
|
8185
|
+
minArgs: 2,
|
|
8186
|
+
maxArgs: 2,
|
|
8187
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
8188
|
+
},
|
|
8189
|
+
convert: {
|
|
8190
|
+
minArgs: 3,
|
|
8191
|
+
maxArgs: 3,
|
|
8192
|
+
apply: (args, hooks) => {
|
|
8193
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
8194
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
8195
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
8196
|
+
if (hooks.convert) {
|
|
8197
|
+
const out = hooks.convert(x, from, to);
|
|
8198
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
8199
|
+
return finiteResult(out, "convert");
|
|
8200
|
+
}
|
|
8201
|
+
if (from === to) return x;
|
|
8202
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
8203
|
+
}
|
|
8204
|
+
}
|
|
8205
|
+
};
|
|
8206
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
8207
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
8208
|
+
* callees at parse time (immediate author feedback). */
|
|
8209
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
8129
8210
|
/**
|
|
8130
|
-
*
|
|
8131
|
-
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
8132
|
-
* widgets registers a provider on this collection cap; the hub-local
|
|
8133
|
-
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
8134
|
-
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
8135
|
-
* exposes the public listing surface that admin-ui consumes.
|
|
8136
|
-
*
|
|
8137
|
-
* The split exists because the public listing has a different output
|
|
8138
|
-
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
8139
|
-
* per-provider raw declarations. Both ends flow through codegen.
|
|
8211
|
+
* Resource-bound constants for the safe expression engine.
|
|
8140
8212
|
*
|
|
8141
|
-
*
|
|
8142
|
-
*
|
|
8143
|
-
*
|
|
8144
|
-
*
|
|
8145
|
-
*
|
|
8146
|
-
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
8147
|
-
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
8213
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
8214
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
8215
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
8216
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
8217
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
8148
8218
|
*/
|
|
8149
|
-
/**
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
"
|
|
8158
|
-
"
|
|
8159
|
-
"
|
|
8160
|
-
"
|
|
8219
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
8220
|
+
* rejected without allocation. */
|
|
8221
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
8222
|
+
/** A legal binding / identifier name. */
|
|
8223
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8224
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
8225
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
8226
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
8227
|
+
"now",
|
|
8228
|
+
"true",
|
|
8229
|
+
"false",
|
|
8230
|
+
"null"
|
|
8161
8231
|
]);
|
|
8162
8232
|
/**
|
|
8163
|
-
*
|
|
8164
|
-
*
|
|
8165
|
-
*
|
|
8166
|
-
*
|
|
8167
|
-
* `
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
remoteName: string(),
|
|
8171
|
-
exposedModule: string(),
|
|
8172
|
-
componentKey: string().optional()
|
|
8173
|
-
});
|
|
8174
|
-
/**
|
|
8175
|
-
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
8176
|
-
* widget-only metadata. The `UiContribution` core fields:
|
|
8177
|
-
*
|
|
8178
|
-
* - `tab` — where the widget hosts. A widget that runs on the
|
|
8179
|
-
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
8180
|
-
* widget declares the target device-detail tab id.
|
|
8181
|
-
* - `subTab` — optional sub-tab within `tab`.
|
|
8182
|
-
* - `label` — operator-facing label.
|
|
8183
|
-
* - `order` — ordering within `(tab, subTab)`.
|
|
8184
|
-
* - `kind` — always `'remote'` for widgets.
|
|
8185
|
-
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
8186
|
-
*
|
|
8187
|
-
* Widget-only fields retained alongside the contribution core:
|
|
8188
|
-
*
|
|
8189
|
-
* - `stableId` — stable identity within the addon (the MF
|
|
8190
|
-
* `componentKey`; kept top-level so consumers have
|
|
8191
|
-
* a stable key without reaching into `remote`).
|
|
8192
|
-
* - `description` / `icon` — picker metadata.
|
|
8193
|
-
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
8194
|
-
* aggregator stamps a versioned `bundleUrl` from it.
|
|
8195
|
-
* - `hosts` — every host the widget supports (a widget can run
|
|
8196
|
-
* both on the dashboard and a device tab). `tab`
|
|
8197
|
-
* is the PRIMARY host; `hosts` is the full set the
|
|
8198
|
-
* picker filters on.
|
|
8199
|
-
* - `requires` — host-context requirements validated at mount.
|
|
8200
|
-
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
8201
|
-
* — dashboard placement hints.
|
|
8233
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
8234
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
8235
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
8236
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
8237
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
8238
|
+
* is a parse error with a source position, so member access / assignment /
|
|
8239
|
+
* template literals are lexically impossible.
|
|
8202
8240
|
*/
|
|
8203
|
-
var
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8241
|
+
var KEYWORDS = new Set([
|
|
8242
|
+
"true",
|
|
8243
|
+
"false",
|
|
8244
|
+
"null"
|
|
8245
|
+
]);
|
|
8246
|
+
function isDigit(ch) {
|
|
8247
|
+
return ch >= "0" && ch <= "9";
|
|
8248
|
+
}
|
|
8249
|
+
function isIdentStart(ch) {
|
|
8250
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
8251
|
+
}
|
|
8252
|
+
function isIdentPart(ch) {
|
|
8253
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
8254
|
+
}
|
|
8255
|
+
function isWhitespace(ch) {
|
|
8256
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
8257
|
+
}
|
|
8258
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
8259
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
8260
|
+
* string. */
|
|
8261
|
+
function tokenize(source) {
|
|
8262
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
8263
|
+
const tokens = [];
|
|
8264
|
+
let i = 0;
|
|
8265
|
+
const n = source.length;
|
|
8266
|
+
while (i < n) {
|
|
8267
|
+
const ch = source[i];
|
|
8268
|
+
if (isWhitespace(ch)) {
|
|
8269
|
+
i += 1;
|
|
8270
|
+
continue;
|
|
8271
|
+
}
|
|
8272
|
+
if (isDigit(ch)) {
|
|
8273
|
+
const start = i;
|
|
8274
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
8275
|
+
if (i < n && source[i] === ".") {
|
|
8276
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
8277
|
+
i += 1;
|
|
8278
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
8279
|
+
}
|
|
8280
|
+
const text = source.slice(start, i);
|
|
8281
|
+
const value = Number(text);
|
|
8282
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
8283
|
+
tokens.push({
|
|
8284
|
+
type: "number",
|
|
8285
|
+
value,
|
|
8286
|
+
pos: start
|
|
8287
|
+
});
|
|
8288
|
+
continue;
|
|
8289
|
+
}
|
|
8290
|
+
if (ch === "'" || ch === "\"") {
|
|
8291
|
+
const quote = ch;
|
|
8292
|
+
const start = i;
|
|
8293
|
+
i += 1;
|
|
8294
|
+
let out = "";
|
|
8295
|
+
let closed = false;
|
|
8296
|
+
while (i < n) {
|
|
8297
|
+
const c = source[i];
|
|
8298
|
+
if (c === "\\") {
|
|
8299
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
8300
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
8301
|
+
out += next;
|
|
8302
|
+
i += 2;
|
|
8303
|
+
continue;
|
|
8304
|
+
}
|
|
8305
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
8306
|
+
}
|
|
8307
|
+
if (c === quote) {
|
|
8308
|
+
closed = true;
|
|
8309
|
+
i += 1;
|
|
8310
|
+
break;
|
|
8311
|
+
}
|
|
8312
|
+
out += c;
|
|
8313
|
+
i += 1;
|
|
8314
|
+
}
|
|
8315
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
8316
|
+
tokens.push({
|
|
8317
|
+
type: "string",
|
|
8318
|
+
value: out,
|
|
8319
|
+
pos: start
|
|
8320
|
+
});
|
|
8321
|
+
continue;
|
|
8322
|
+
}
|
|
8323
|
+
if (isIdentStart(ch)) {
|
|
8324
|
+
const start = i;
|
|
8325
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
8326
|
+
const text = source.slice(start, i);
|
|
8327
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
8328
|
+
type: "keyword",
|
|
8329
|
+
keyword: keywordOf(text),
|
|
8330
|
+
pos: start
|
|
8331
|
+
});
|
|
8332
|
+
else tokens.push({
|
|
8333
|
+
type: "identifier",
|
|
8334
|
+
name: text,
|
|
8335
|
+
pos: start
|
|
8336
|
+
});
|
|
8337
|
+
continue;
|
|
8338
|
+
}
|
|
8339
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
8340
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
8341
|
+
tokens.push({
|
|
8342
|
+
type: "punct",
|
|
8343
|
+
punct: two,
|
|
8344
|
+
pos: i
|
|
8345
|
+
});
|
|
8346
|
+
i += 2;
|
|
8347
|
+
continue;
|
|
8348
|
+
}
|
|
8349
|
+
if (isSinglePunct(ch)) {
|
|
8350
|
+
tokens.push({
|
|
8351
|
+
type: "punct",
|
|
8352
|
+
punct: ch,
|
|
8353
|
+
pos: i
|
|
8354
|
+
});
|
|
8355
|
+
i += 1;
|
|
8356
|
+
continue;
|
|
8357
|
+
}
|
|
8358
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
8359
|
+
}
|
|
8360
|
+
tokens.push({
|
|
8361
|
+
type: "eof",
|
|
8362
|
+
pos: n
|
|
8363
|
+
});
|
|
8364
|
+
return tokens;
|
|
8365
|
+
}
|
|
8366
|
+
function keywordOf(text) {
|
|
8367
|
+
if (text === "true") return "true";
|
|
8368
|
+
if (text === "false") return "false";
|
|
8369
|
+
return "null";
|
|
8370
|
+
}
|
|
8371
|
+
function isSinglePunct(ch) {
|
|
8372
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
8373
|
+
}
|
|
8374
|
+
/**
|
|
8375
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
8376
|
+
*
|
|
8377
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
8378
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
8379
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
8380
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
8381
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
8382
|
+
* that references a since-removed builtin degrades at read.
|
|
8383
|
+
*
|
|
8384
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
8385
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
8386
|
+
*/
|
|
8387
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
8388
|
+
var BINARY_PRECEDENCE = {
|
|
8389
|
+
"||": 1,
|
|
8390
|
+
"&&": 2,
|
|
8391
|
+
"==": 3,
|
|
8392
|
+
"!=": 3,
|
|
8393
|
+
"<": 4,
|
|
8394
|
+
"<=": 4,
|
|
8395
|
+
">": 4,
|
|
8396
|
+
">=": 4,
|
|
8397
|
+
"+": 5,
|
|
8398
|
+
"-": 5,
|
|
8399
|
+
"*": 6,
|
|
8400
|
+
"/": 6,
|
|
8401
|
+
"%": 6
|
|
8402
|
+
};
|
|
8403
|
+
function isLogicalOp(op) {
|
|
8404
|
+
return op === "&&" || op === "||";
|
|
8405
|
+
}
|
|
8406
|
+
function isBinaryOp(op) {
|
|
8407
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
8408
|
+
}
|
|
8409
|
+
var Parser = class {
|
|
8410
|
+
tokens;
|
|
8411
|
+
pos = 0;
|
|
8412
|
+
nodeCount = 0;
|
|
8413
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
8414
|
+
callees = /* @__PURE__ */ new Set();
|
|
8415
|
+
constructor(tokens) {
|
|
8416
|
+
this.tokens = tokens;
|
|
8417
|
+
}
|
|
8418
|
+
parse() {
|
|
8419
|
+
const ast = this.parseTernary();
|
|
8420
|
+
const tok = this.peek();
|
|
8421
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
8422
|
+
return {
|
|
8423
|
+
ast,
|
|
8424
|
+
identifiers: this.identifiers,
|
|
8425
|
+
callees: this.callees,
|
|
8426
|
+
nodeCount: this.nodeCount
|
|
8427
|
+
};
|
|
8428
|
+
}
|
|
8429
|
+
peek() {
|
|
8430
|
+
return this.tokens[this.pos];
|
|
8431
|
+
}
|
|
8432
|
+
next() {
|
|
8433
|
+
return this.tokens[this.pos++];
|
|
8434
|
+
}
|
|
8435
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
8436
|
+
expectPunct(punct) {
|
|
8437
|
+
const tok = this.peek();
|
|
8438
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
8439
|
+
this.pos += 1;
|
|
8440
|
+
}
|
|
8441
|
+
matchPunct(punct) {
|
|
8442
|
+
const tok = this.peek();
|
|
8443
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
8444
|
+
this.pos += 1;
|
|
8445
|
+
return true;
|
|
8446
|
+
}
|
|
8447
|
+
return false;
|
|
8448
|
+
}
|
|
8449
|
+
countNode() {
|
|
8450
|
+
this.nodeCount += 1;
|
|
8451
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
8452
|
+
}
|
|
8453
|
+
parseTernary() {
|
|
8454
|
+
const test = this.parseBinary(1);
|
|
8455
|
+
if (this.matchPunct("?")) {
|
|
8456
|
+
const consequent = this.parseTernary();
|
|
8457
|
+
this.expectPunct(":");
|
|
8458
|
+
const alternate = this.parseTernary();
|
|
8459
|
+
this.countNode();
|
|
8460
|
+
return {
|
|
8461
|
+
kind: "conditional",
|
|
8462
|
+
test,
|
|
8463
|
+
consequent,
|
|
8464
|
+
alternate
|
|
8465
|
+
};
|
|
8466
|
+
}
|
|
8467
|
+
return test;
|
|
8468
|
+
}
|
|
8469
|
+
parseBinary(minPrec) {
|
|
8470
|
+
let left = this.parseUnary();
|
|
8471
|
+
for (;;) {
|
|
8472
|
+
const tok = this.peek();
|
|
8473
|
+
if (tok.type !== "punct") break;
|
|
8474
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
8475
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
8476
|
+
const op = tok.punct;
|
|
8477
|
+
this.pos += 1;
|
|
8478
|
+
const right = this.parseBinary(prec + 1);
|
|
8479
|
+
this.countNode();
|
|
8480
|
+
if (isLogicalOp(op)) left = {
|
|
8481
|
+
kind: "logical",
|
|
8482
|
+
op,
|
|
8483
|
+
left,
|
|
8484
|
+
right
|
|
8485
|
+
};
|
|
8486
|
+
else if (isBinaryOp(op)) left = {
|
|
8487
|
+
kind: "binary",
|
|
8488
|
+
op,
|
|
8489
|
+
left,
|
|
8490
|
+
right
|
|
8491
|
+
};
|
|
8492
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
8493
|
+
}
|
|
8494
|
+
return left;
|
|
8495
|
+
}
|
|
8496
|
+
parseUnary() {
|
|
8497
|
+
const tok = this.peek();
|
|
8498
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
8499
|
+
const op = tok.punct;
|
|
8500
|
+
this.pos += 1;
|
|
8501
|
+
const operand = this.parseUnary();
|
|
8502
|
+
this.countNode();
|
|
8503
|
+
return {
|
|
8504
|
+
kind: "unary",
|
|
8505
|
+
op,
|
|
8506
|
+
operand
|
|
8507
|
+
};
|
|
8508
|
+
}
|
|
8509
|
+
return this.parsePrimary();
|
|
8510
|
+
}
|
|
8511
|
+
parsePrimary() {
|
|
8512
|
+
const tok = this.next();
|
|
8513
|
+
switch (tok.type) {
|
|
8514
|
+
case "number":
|
|
8515
|
+
this.countNode();
|
|
8516
|
+
return {
|
|
8517
|
+
kind: "literal",
|
|
8518
|
+
value: tok.value
|
|
8519
|
+
};
|
|
8520
|
+
case "string":
|
|
8521
|
+
this.countNode();
|
|
8522
|
+
return {
|
|
8523
|
+
kind: "literal",
|
|
8524
|
+
value: tok.value
|
|
8525
|
+
};
|
|
8526
|
+
case "keyword":
|
|
8527
|
+
this.countNode();
|
|
8528
|
+
return {
|
|
8529
|
+
kind: "literal",
|
|
8530
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
8531
|
+
};
|
|
8532
|
+
case "identifier": {
|
|
8533
|
+
const nextTok = this.peek();
|
|
8534
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
8535
|
+
this.identifiers.add(tok.name);
|
|
8536
|
+
this.countNode();
|
|
8537
|
+
return {
|
|
8538
|
+
kind: "identifier",
|
|
8539
|
+
name: tok.name
|
|
8540
|
+
};
|
|
8541
|
+
}
|
|
8542
|
+
case "punct":
|
|
8543
|
+
if (tok.punct === "(") {
|
|
8544
|
+
const inner = this.parseTernary();
|
|
8545
|
+
this.expectPunct(")");
|
|
8546
|
+
return inner;
|
|
8547
|
+
}
|
|
8548
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
8549
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
8550
|
+
}
|
|
8551
|
+
}
|
|
8552
|
+
parseCall(callee, pos) {
|
|
8553
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
8554
|
+
this.expectPunct("(");
|
|
8555
|
+
const args = [];
|
|
8556
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
8557
|
+
args.push(this.parseTernary());
|
|
8558
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
8559
|
+
if (this.matchPunct(",")) continue;
|
|
8560
|
+
this.expectPunct(")");
|
|
8561
|
+
break;
|
|
8562
|
+
}
|
|
8563
|
+
this.callees.add(callee);
|
|
8564
|
+
this.countNode();
|
|
8565
|
+
return {
|
|
8566
|
+
kind: "call",
|
|
8567
|
+
callee,
|
|
8568
|
+
args
|
|
8569
|
+
};
|
|
8570
|
+
}
|
|
8571
|
+
};
|
|
8572
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
8573
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
8574
|
+
function parseExpression(source) {
|
|
8575
|
+
return new Parser(tokenize(source)).parse();
|
|
8576
|
+
}
|
|
8577
|
+
/**
|
|
8578
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
8579
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
8580
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
8581
|
+
* one per read on a hot resolve path.
|
|
8582
|
+
*
|
|
8583
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
8584
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
8585
|
+
* callers is safe and maximises hit rate.
|
|
8586
|
+
*/
|
|
8587
|
+
var cache = /* @__PURE__ */ new Map();
|
|
8588
|
+
function getCached(source) {
|
|
8589
|
+
const hit = cache.get(source);
|
|
8590
|
+
if (hit !== void 0) {
|
|
8591
|
+
cache.delete(source);
|
|
8592
|
+
cache.set(source, hit);
|
|
8593
|
+
return hit;
|
|
8594
|
+
}
|
|
8595
|
+
let result;
|
|
8596
|
+
try {
|
|
8597
|
+
result = {
|
|
8598
|
+
ok: true,
|
|
8599
|
+
parsed: parseExpression(source)
|
|
8600
|
+
};
|
|
8601
|
+
} catch (err) {
|
|
8602
|
+
result = {
|
|
8603
|
+
ok: false,
|
|
8604
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
8605
|
+
};
|
|
8606
|
+
}
|
|
8607
|
+
cache.set(source, result);
|
|
8608
|
+
if (cache.size > 256) {
|
|
8609
|
+
const oldest = cache.keys().next().value;
|
|
8610
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
8611
|
+
}
|
|
8612
|
+
return result;
|
|
8613
|
+
}
|
|
8614
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
8615
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
8616
|
+
function compileExpressionSafe(source) {
|
|
8617
|
+
return getCached(source);
|
|
8618
|
+
}
|
|
8619
|
+
Object.freeze({});
|
|
8620
|
+
/**
|
|
8621
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
8622
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
8623
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
8624
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
8625
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
8626
|
+
*/
|
|
8627
|
+
function validateExpressionSource(src) {
|
|
8628
|
+
const names = Object.keys(src.bindings);
|
|
8629
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
8630
|
+
for (const name of names) {
|
|
8631
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
8632
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
8633
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
8634
|
+
}
|
|
8635
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
8636
|
+
if (!compiled.ok) return compiled.error;
|
|
8637
|
+
const bound = new Set(names);
|
|
8638
|
+
for (const id of compiled.parsed.identifiers) {
|
|
8639
|
+
if (id === "now") continue;
|
|
8640
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
8641
|
+
}
|
|
8642
|
+
return null;
|
|
8643
|
+
}
|
|
8644
|
+
var ExpressionBindingSourceSchema = union([
|
|
8645
|
+
object({
|
|
8646
|
+
kind: literal("field").optional(),
|
|
8647
|
+
sourceKey: string(),
|
|
8648
|
+
cap: string(),
|
|
8649
|
+
fieldPath: string()
|
|
8650
|
+
}),
|
|
8651
|
+
object({
|
|
8652
|
+
kind: literal("literal"),
|
|
8653
|
+
value: union([
|
|
8654
|
+
string(),
|
|
8655
|
+
number(),
|
|
8656
|
+
boolean(),
|
|
8657
|
+
_null()
|
|
8658
|
+
])
|
|
8659
|
+
}),
|
|
8660
|
+
object({
|
|
8661
|
+
kind: literal("global"),
|
|
8662
|
+
sourceStableId: string(),
|
|
8663
|
+
cap: string(),
|
|
8664
|
+
fieldPath: string()
|
|
8665
|
+
})
|
|
8666
|
+
]);
|
|
8667
|
+
object({
|
|
8668
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
8669
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
8670
|
+
}).superRefine((src, ctx) => {
|
|
8671
|
+
const err = validateExpressionSource(src);
|
|
8672
|
+
if (err !== null) ctx.addIssue({
|
|
8673
|
+
code: "custom",
|
|
8674
|
+
message: err,
|
|
8675
|
+
path: ["expr"]
|
|
8676
|
+
});
|
|
8677
|
+
});
|
|
8678
|
+
/** How a leaf compares a device field to a value. Derived from the field's
|
|
8679
|
+
* `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
|
|
8680
|
+
var AutomationConditionOperatorSchema = _enum([
|
|
8681
|
+
"eq",
|
|
8682
|
+
"ne",
|
|
8683
|
+
"gt",
|
|
8684
|
+
"gte",
|
|
8685
|
+
"lt",
|
|
8686
|
+
"lte",
|
|
8687
|
+
"contains",
|
|
8688
|
+
"in"
|
|
8689
|
+
]);
|
|
8690
|
+
var AutomationConditionLeafSchema = object({
|
|
8691
|
+
kind: literal("condition"),
|
|
8692
|
+
deviceId: number().int().nonnegative(),
|
|
8693
|
+
cap: string().min(1),
|
|
8694
|
+
fieldPath: string().min(1),
|
|
8695
|
+
operator: AutomationConditionOperatorSchema,
|
|
8696
|
+
value: union([
|
|
8697
|
+
string(),
|
|
8698
|
+
number(),
|
|
8699
|
+
boolean(),
|
|
8700
|
+
array(union([string(), number()]))
|
|
8701
|
+
])
|
|
8702
|
+
});
|
|
8703
|
+
/**
|
|
8704
|
+
* The expression leaf, declared as a plain object rather than an intersection
|
|
8705
|
+
* with {@link ExpressionSourceSchema}: a discriminated union has to be able to
|
|
8706
|
+
* read `kind` off each option, and an intersection hides it. The author-time
|
|
8707
|
+
* validation is the SAME function `ExpressionSourceSchema` runs, so the two
|
|
8708
|
+
* cannot drift — an expression that one accepts, the other accepts.
|
|
8709
|
+
*/
|
|
8710
|
+
var AutomationConditionExpressionSchema = object({
|
|
8711
|
+
kind: literal("expression"),
|
|
8712
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
8713
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
8714
|
+
}).superRefine((src, ctx) => {
|
|
8715
|
+
const err = validateExpressionSource(src);
|
|
8716
|
+
if (err !== null) ctx.addIssue({
|
|
8717
|
+
code: "custom",
|
|
8718
|
+
message: err,
|
|
8719
|
+
path: ["expr"]
|
|
8720
|
+
});
|
|
8721
|
+
});
|
|
8722
|
+
var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
|
|
8723
|
+
object({
|
|
8724
|
+
kind: literal("all"),
|
|
8725
|
+
children: array(AutomationConditionSchema)
|
|
8726
|
+
}),
|
|
8727
|
+
object({
|
|
8728
|
+
kind: literal("any"),
|
|
8729
|
+
children: array(AutomationConditionSchema)
|
|
8730
|
+
}),
|
|
8731
|
+
object({
|
|
8732
|
+
kind: literal("not"),
|
|
8733
|
+
child: AutomationConditionSchema
|
|
8734
|
+
}),
|
|
8735
|
+
AutomationConditionLeafSchema,
|
|
8736
|
+
AutomationConditionExpressionSchema
|
|
8737
|
+
]));
|
|
8738
|
+
/**
|
|
8739
|
+
* What starts a run.
|
|
8740
|
+
*
|
|
8741
|
+
* D8 compliance, and it is the reason `device-state` is not merely an event
|
|
8742
|
+
* subscription: the trigger evaluates against the **state mirror**, which is
|
|
8743
|
+
* reconciled, and an event only WAKES the evaluation. A dropped event therefore
|
|
8744
|
+
* DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
|
|
8745
|
+
* already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
|
|
8746
|
+
* not.
|
|
8747
|
+
*/
|
|
8748
|
+
var AutomationTriggerSchema = discriminatedUnion("kind", [
|
|
8749
|
+
object({
|
|
8750
|
+
kind: literal("device-state"),
|
|
8751
|
+
deviceId: number().int().nonnegative(),
|
|
8752
|
+
cap: string().min(1),
|
|
8753
|
+
fieldPath: string().min(1),
|
|
8754
|
+
/** Fire when the field takes this value. Omit to fire on any change. */
|
|
8755
|
+
becomes: union([
|
|
8756
|
+
string(),
|
|
8757
|
+
number(),
|
|
8758
|
+
boolean()
|
|
8759
|
+
]).optional(),
|
|
8760
|
+
/** Only on a CHANGE of value, not on every re-report. */
|
|
8761
|
+
edge: boolean().optional(),
|
|
8762
|
+
/** The condition must hold this long before the run starts. */
|
|
8763
|
+
forMs: number().int().min(0).max(864e5).optional(),
|
|
8764
|
+
/** Collapse a burst into one run. */
|
|
8765
|
+
debounceMs: number().int().min(0).max(6e5).optional()
|
|
8766
|
+
}),
|
|
8767
|
+
object({
|
|
8768
|
+
kind: literal("device-event"),
|
|
8769
|
+
/** An `EventCategory` value. */
|
|
8770
|
+
category: string().min(1),
|
|
8771
|
+
deviceId: number().int().nonnegative().optional()
|
|
8772
|
+
}),
|
|
8773
|
+
object({
|
|
8774
|
+
kind: literal("schedule"),
|
|
8775
|
+
cron: string().min(1).max(120)
|
|
8776
|
+
}),
|
|
8777
|
+
object({ kind: literal("manual") })
|
|
8778
|
+
]);
|
|
8779
|
+
/**
|
|
8780
|
+
* One action step.
|
|
8781
|
+
*
|
|
8782
|
+
* `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
|
|
8783
|
+
* identical so `NcRuleActionRunner` runs them unchanged — its device-scope
|
|
8784
|
+
* check, stop-at-first-failure and per-sequence throttle are the whole reason
|
|
8785
|
+
* to reuse it, and none of them are re-implemented here.
|
|
8786
|
+
*
|
|
8787
|
+
* **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
|
|
8788
|
+
* a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
|
|
8789
|
+
* documents its subject device as *"for the log tag, never for routing"*. So an
|
|
8790
|
+
* NC action can never target the device that triggered it — which is fine for
|
|
8791
|
+
* the NC (its rules already scope to a device) and fatal for an automation
|
|
8792
|
+
* ("sound the siren of the camera that saw the person"). `deviceId` therefore
|
|
8793
|
+
* also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
|
|
8794
|
+
* is called. The runner still receives a number and is untouched; the
|
|
8795
|
+
* resolution is the recipe's job, not the runner's.
|
|
8796
|
+
*/
|
|
8797
|
+
var AutomationActionSchema = discriminatedUnion("kind", [
|
|
8798
|
+
object({
|
|
8799
|
+
kind: literal("wait"),
|
|
8800
|
+
seconds: number().min(0).max(300)
|
|
8801
|
+
}),
|
|
8802
|
+
object({
|
|
8803
|
+
kind: literal("cap"),
|
|
8804
|
+
deviceId: union([number().int(), object({ $var: string().min(1) })]),
|
|
8805
|
+
cap: string().min(1),
|
|
8806
|
+
method: string().min(1),
|
|
8807
|
+
/** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
|
|
8808
|
+
* evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
|
|
8809
|
+
args: record(string(), unknown()).optional()
|
|
8810
|
+
}),
|
|
8811
|
+
object({
|
|
8812
|
+
kind: literal("code"),
|
|
8813
|
+
/** Compiled into the automation's OWN block by esbuild — not a third
|
|
8814
|
+
* runtime, not a `vm`, and not dynamically evaluated. */
|
|
8815
|
+
code: string().min(1).max(2e4)
|
|
8816
|
+
})
|
|
8817
|
+
]);
|
|
8818
|
+
object({
|
|
8819
|
+
triggers: array(AutomationTriggerSchema),
|
|
8820
|
+
conditions: AutomationConditionSchema.optional(),
|
|
8821
|
+
actions: array(AutomationActionSchema)
|
|
8822
|
+
});
|
|
8823
|
+
/**
|
|
8824
|
+
* `addon-pages` — system-scoped singleton aggregator cap. Public-facing
|
|
8825
|
+
* surface that admin-ui consumes through `useAddonPagesListPages()`.
|
|
8826
|
+
*
|
|
8827
|
+
* The provider iterates every `addon-pages-source` (collection) provider
|
|
8828
|
+
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
8829
|
+
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
8830
|
+
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
8831
|
+
* rebuilds without manual reload.
|
|
8832
|
+
*
|
|
8833
|
+
* The hub-local builtin `addon-pages-aggregator` (see
|
|
8834
|
+
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
8835
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
8836
|
+
* keeps both ends in codegen — there's no hand-written
|
|
8837
|
+
* `addon-pages.router.ts` wrapper anymore.
|
|
8838
|
+
*/
|
|
8839
|
+
var AddonPageDeclarationSchema$1 = object({
|
|
8840
|
+
id: string(),
|
|
8841
|
+
label: string(),
|
|
8842
|
+
icon: string(),
|
|
8843
|
+
path: string(),
|
|
8844
|
+
remoteName: string(),
|
|
8845
|
+
bundle: string(),
|
|
8846
|
+
section: string().optional(),
|
|
8847
|
+
sectionLabel: string().optional()
|
|
8848
|
+
});
|
|
8849
|
+
var AddonPageInfoSchema = object({
|
|
8850
|
+
addonId: string(),
|
|
8851
|
+
page: AddonPageDeclarationSchema$1,
|
|
8852
|
+
bundleUrl: string()
|
|
8853
|
+
});
|
|
8854
|
+
method(_void(), array(AddonPageInfoSchema).readonly());
|
|
8855
|
+
/**
|
|
8856
|
+
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
8857
|
+
* declarations. Every addon that contributes a UI page registers a
|
|
8858
|
+
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
8859
|
+
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
8860
|
+
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
8861
|
+
* that admin-ui consumes.
|
|
8862
|
+
*
|
|
8863
|
+
* The split exists because the public listing has a different output
|
|
8864
|
+
* shape than the per-provider raw declarations, and we want both ends
|
|
8865
|
+
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
8866
|
+
*/
|
|
8867
|
+
var AddonPageDeclarationSchema = object({
|
|
8868
|
+
id: string(),
|
|
8869
|
+
label: string(),
|
|
8870
|
+
icon: string(),
|
|
8871
|
+
path: string(),
|
|
8872
|
+
/**
|
|
8873
|
+
* Module Federation remote name — must match the `name` field on the
|
|
8874
|
+
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
8875
|
+
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
8876
|
+
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
8877
|
+
* contain hyphens).
|
|
8878
|
+
*/
|
|
8879
|
+
remoteName: string(),
|
|
8880
|
+
/**
|
|
8881
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
8882
|
+
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
8883
|
+
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
8884
|
+
* the static-file route can compute an mtime-based cache-buster URL
|
|
8885
|
+
* without a separate filesystem stat.
|
|
8886
|
+
*/
|
|
8887
|
+
bundle: string(),
|
|
8888
|
+
/**
|
|
8889
|
+
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
8890
|
+
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
8891
|
+
* Any OTHER string creates (or joins) a custom section rendered after
|
|
8892
|
+
* the built-in groups; its label comes from `sectionLabel` (first
|
|
8893
|
+
* declaration wins), falling back to the id. Absent → the legacy
|
|
8894
|
+
* "Addon Pages" group.
|
|
8895
|
+
*/
|
|
8896
|
+
section: string().optional(),
|
|
8897
|
+
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
8898
|
+
sectionLabel: string().optional()
|
|
8899
|
+
});
|
|
8900
|
+
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
8901
|
+
var AddonHttpRouteSchema = object({
|
|
8902
|
+
method: _enum([
|
|
8903
|
+
"GET",
|
|
8904
|
+
"POST",
|
|
8905
|
+
"PUT",
|
|
8906
|
+
"DELETE",
|
|
8907
|
+
"PATCH"
|
|
8908
|
+
]),
|
|
8909
|
+
path: string(),
|
|
8910
|
+
access: _enum([
|
|
8911
|
+
"public",
|
|
8912
|
+
"authenticated",
|
|
8913
|
+
"admin"
|
|
8914
|
+
]).optional(),
|
|
8915
|
+
description: string().optional()
|
|
8916
|
+
});
|
|
8917
|
+
/**
|
|
8918
|
+
* Cross-process route invocation envelope. The hub captures the
|
|
8919
|
+
* request as plain data, ships it to the worker via Moleculer, and
|
|
8920
|
+
* the worker runs the local handler against a capturing reply. The
|
|
8921
|
+
* envelope returned describes what the handler intended (status,
|
|
8922
|
+
* headers, body, or a redirect) so the hub can translate it back to
|
|
8923
|
+
* the Fastify reply that's actually wired to the socket.
|
|
8924
|
+
*/
|
|
8925
|
+
var InvokeRequestSchema = object({
|
|
8926
|
+
method: string(),
|
|
8927
|
+
path: string(),
|
|
8928
|
+
params: record(string(), string()),
|
|
8929
|
+
query: record(string(), string()),
|
|
8930
|
+
body: unknown(),
|
|
8931
|
+
headers: record(string(), string()),
|
|
8932
|
+
user: object({
|
|
8933
|
+
id: string(),
|
|
8934
|
+
username: string(),
|
|
8935
|
+
isAdmin: boolean()
|
|
8936
|
+
}).optional(),
|
|
8937
|
+
scopedToken: unknown().optional()
|
|
8938
|
+
});
|
|
8939
|
+
var InvokeReplyEnvelopeSchema = object({
|
|
8940
|
+
status: number().int(),
|
|
8941
|
+
headers: record(string(), string()),
|
|
8942
|
+
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
8943
|
+
* sending `body`. Status defaults to 302 when this is set unless
|
|
8944
|
+
* the handler called `reply.code(...)` explicitly. */
|
|
8945
|
+
redirectUrl: string().nullable(),
|
|
8946
|
+
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
8947
|
+
body: unknown().optional(),
|
|
8948
|
+
/** Set when the handler called `reply.type(mime)`. */
|
|
8949
|
+
contentType: string().optional()
|
|
8950
|
+
});
|
|
8951
|
+
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
8952
|
+
var ConfigTabDeclarationSchema = object({
|
|
8953
|
+
id: string(),
|
|
8954
|
+
label: string(),
|
|
8955
|
+
icon: string(),
|
|
8956
|
+
order: number().optional()
|
|
8957
|
+
});
|
|
8958
|
+
var ConfigSectionWithValuesSchema = object({
|
|
8959
|
+
id: string(),
|
|
8960
|
+
title: string(),
|
|
8961
|
+
description: string().optional(),
|
|
8962
|
+
style: _enum(["card", "accordion"]).optional(),
|
|
8963
|
+
defaultCollapsed: boolean().optional(),
|
|
8964
|
+
columns: union([
|
|
8965
|
+
literal(1),
|
|
8966
|
+
literal(2),
|
|
8967
|
+
literal(3),
|
|
8968
|
+
literal(4)
|
|
8969
|
+
]).optional(),
|
|
8970
|
+
tab: string().optional(),
|
|
8971
|
+
location: _enum(["settings", "top-tab"]).optional(),
|
|
8972
|
+
order: number().optional(),
|
|
8973
|
+
fields: array(any())
|
|
8974
|
+
});
|
|
8975
|
+
var SettingsSchemaWithValuesSchema = object({
|
|
8976
|
+
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
8977
|
+
sections: array(ConfigSectionWithValuesSchema)
|
|
8978
|
+
});
|
|
8979
|
+
/** Patch object — keys are field names, values are the new field values. */
|
|
8980
|
+
var SettingsPatchSchema = record(string(), unknown());
|
|
8981
|
+
/** Standard success response for update operations. */
|
|
8982
|
+
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
8983
|
+
method(object({
|
|
8984
|
+
addonId: string(),
|
|
8985
|
+
nodeId: string().optional(),
|
|
8986
|
+
overlay: record(string(), unknown()).optional(),
|
|
8987
|
+
cap: string().optional()
|
|
8988
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8989
|
+
addonId: string(),
|
|
8990
|
+
nodeId: string().optional(),
|
|
8991
|
+
patch: SettingsPatchSchema
|
|
8992
|
+
}), SettingsUpdateResultSchema, {
|
|
8993
|
+
kind: "mutation",
|
|
8994
|
+
auth: "admin"
|
|
8995
|
+
}), method(object({
|
|
8996
|
+
addonId: string(),
|
|
8997
|
+
deviceId: number(),
|
|
8998
|
+
nodeId: string().optional()
|
|
8999
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9000
|
+
addonId: string(),
|
|
9001
|
+
deviceId: number(),
|
|
9002
|
+
nodeId: string().optional(),
|
|
9003
|
+
patch: SettingsPatchSchema
|
|
9004
|
+
}), SettingsUpdateResultSchema, {
|
|
9005
|
+
kind: "mutation",
|
|
9006
|
+
auth: "admin"
|
|
9007
|
+
});
|
|
9008
|
+
/**
|
|
9009
|
+
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
9010
|
+
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
9011
|
+
* widgets registers a provider on this collection cap; the hub-local
|
|
9012
|
+
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
9013
|
+
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
9014
|
+
* exposes the public listing surface that admin-ui consumes.
|
|
9015
|
+
*
|
|
9016
|
+
* The split exists because the public listing has a different output
|
|
9017
|
+
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
9018
|
+
* per-provider raw declarations. Both ends flow through codegen.
|
|
9019
|
+
*
|
|
9020
|
+
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
9021
|
+
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
9022
|
+
* same `ContributionRenderer` / Module-Federation path as every other
|
|
9023
|
+
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
9024
|
+
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
9025
|
+
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
9026
|
+
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
9027
|
+
*/
|
|
9028
|
+
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
9029
|
+
var WidgetHostEnum = _enum([
|
|
9030
|
+
"device-tab",
|
|
9031
|
+
"dashboard",
|
|
9032
|
+
"integration-detail"
|
|
9033
|
+
]);
|
|
9034
|
+
var WidgetSizeEnum = _enum([
|
|
9035
|
+
"xs",
|
|
9036
|
+
"sm",
|
|
9037
|
+
"md",
|
|
9038
|
+
"lg",
|
|
9039
|
+
"xl"
|
|
9040
|
+
]);
|
|
9041
|
+
/**
|
|
9042
|
+
* MF remote descriptor — mirrors `UiContributionRemote` from
|
|
9043
|
+
* `capability-definition.ts`. Widget remotes expose a single
|
|
9044
|
+
* `'./widgets'` module whose default export is a
|
|
9045
|
+
* `Record<componentKey, Component>` map; `componentKey` (the widget
|
|
9046
|
+
* `stableId`) picks the entry the host mounts.
|
|
9047
|
+
*/
|
|
9048
|
+
var WidgetRemoteSchema = object({
|
|
9049
|
+
remoteName: string(),
|
|
9050
|
+
exposedModule: string(),
|
|
9051
|
+
componentKey: string().optional()
|
|
9052
|
+
});
|
|
9053
|
+
/**
|
|
9054
|
+
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
9055
|
+
* widget-only metadata. The `UiContribution` core fields:
|
|
9056
|
+
*
|
|
9057
|
+
* - `tab` — where the widget hosts. A widget that runs on the
|
|
9058
|
+
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
9059
|
+
* widget declares the target device-detail tab id.
|
|
9060
|
+
* - `subTab` — optional sub-tab within `tab`.
|
|
9061
|
+
* - `label` — operator-facing label.
|
|
9062
|
+
* - `order` — ordering within `(tab, subTab)`.
|
|
9063
|
+
* - `kind` — always `'remote'` for widgets.
|
|
9064
|
+
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
9065
|
+
*
|
|
9066
|
+
* Widget-only fields retained alongside the contribution core:
|
|
9067
|
+
*
|
|
9068
|
+
* - `stableId` — stable identity within the addon (the MF
|
|
9069
|
+
* `componentKey`; kept top-level so consumers have
|
|
9070
|
+
* a stable key without reaching into `remote`).
|
|
9071
|
+
* - `description` / `icon` — picker metadata.
|
|
9072
|
+
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
9073
|
+
* aggregator stamps a versioned `bundleUrl` from it.
|
|
9074
|
+
* - `hosts` — every host the widget supports (a widget can run
|
|
9075
|
+
* both on the dashboard and a device tab). `tab`
|
|
9076
|
+
* is the PRIMARY host; `hosts` is the full set the
|
|
9077
|
+
* picker filters on.
|
|
9078
|
+
* - `requires` — host-context requirements validated at mount.
|
|
9079
|
+
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
9080
|
+
* — dashboard placement hints.
|
|
9081
|
+
*/
|
|
9082
|
+
var WidgetMetadataSchema = object({
|
|
9083
|
+
/** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
|
|
9084
|
+
tab: string(),
|
|
9085
|
+
/** Optional sub-tab within `tab`. */
|
|
9086
|
+
subTab: string().optional(),
|
|
9087
|
+
/** Operator-facing label. */
|
|
9088
|
+
label: string(),
|
|
9089
|
+
/** Ordering within `(tab, subTab)`, ascending. */
|
|
9090
|
+
order: number().optional(),
|
|
9091
|
+
/** Always `'remote'` — a widget is a Module Federation remote. */
|
|
9092
|
+
kind: literal("remote"),
|
|
9093
|
+
/** MF remote descriptor. */
|
|
9094
|
+
remote: WidgetRemoteSchema,
|
|
9095
|
+
/** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
|
|
9096
|
+
stableId: string(),
|
|
9097
|
+
description: string().optional(),
|
|
9098
|
+
icon: string().optional(),
|
|
9099
|
+
/**
|
|
9100
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9101
|
+
* `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
|
|
9102
|
+
* this is always `'remoteEntry.js'` — the value is kept on the
|
|
9103
|
+
* metadata so the static-file route can compute an mtime-based
|
|
9104
|
+
* cache-buster URL without a separate filesystem stat.
|
|
9105
|
+
*/
|
|
9106
|
+
bundle: string(),
|
|
9107
|
+
/** Every host the widget supports. The picker filters on this set. */
|
|
9108
|
+
hosts: array(WidgetHostEnum).readonly(),
|
|
9109
|
+
/** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
|
|
9110
|
+
requires: object({
|
|
9111
|
+
deviceContext: boolean().default(false),
|
|
9112
|
+
integrationContext: boolean().default(false)
|
|
9113
|
+
}),
|
|
9114
|
+
/**
|
|
9115
|
+
* Loadable BEFORE authentication. The normal widget registry listing
|
|
9116
|
+
* (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
|
|
9117
|
+
* (the login page) cannot discover a widget through it. A widget that
|
|
9118
|
+
* declares `preAuth: true` marks itself as safe to mount on a pre-auth
|
|
9119
|
+
* screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
|
|
9120
|
+
* login-method contribution channel (see `login-method.cap.ts`) rather
|
|
9121
|
+
* than the authenticated registry, and its bundle is served by the
|
|
9122
|
+
* public `/api/addon-widgets/:addonId/*` static route. Defaults false.
|
|
9123
|
+
*/
|
|
9124
|
+
preAuth: boolean().optional().default(false),
|
|
9125
|
+
/** Dashboard placement HINTS (operator can override per instance). */
|
|
9126
|
+
defaultSize: WidgetSizeEnum.default("md"),
|
|
9127
|
+
allowedSizes: array(WidgetSizeEnum).readonly().default([
|
|
9128
|
+
"sm",
|
|
9129
|
+
"md",
|
|
9130
|
+
"lg"
|
|
9131
|
+
]),
|
|
8253
9132
|
defaultColumns: number().int().min(1).max(12).default(6),
|
|
8254
9133
|
defaultRows: number().int().min(1).max(12).default(1)
|
|
8255
9134
|
});
|
|
@@ -11331,6 +12210,15 @@ method(object({
|
|
|
11331
12210
|
}), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
|
|
11332
12211
|
kind: "mutation",
|
|
11333
12212
|
auth: "admin"
|
|
12213
|
+
}), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
|
|
12214
|
+
kind: "mutation",
|
|
12215
|
+
auth: "admin"
|
|
12216
|
+
}), method(object({
|
|
12217
|
+
addonId: string(),
|
|
12218
|
+
integrationId: string().optional()
|
|
12219
|
+
}), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
12220
|
+
kind: "mutation",
|
|
12221
|
+
auth: "admin"
|
|
11334
12222
|
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
11335
12223
|
kind: "mutation",
|
|
11336
12224
|
auth: "admin"
|
|
@@ -12053,7 +12941,7 @@ DeviceType.Camera, method(object({
|
|
|
12053
12941
|
* Why: pub/sub routing over the system event-bus loses fidelity
|
|
12054
12942
|
* (callback shape, QoS guarantees, will/retain semantics) and adds
|
|
12055
12943
|
* refcount bookkeeping that addons would rather own themselves. The
|
|
12056
|
-
* canonical consumer
|
|
12944
|
+
* canonical consumer needs raw `mqtt.js`
|
|
12057
12945
|
* features anyway — give it the connection config, get out of the way.
|
|
12058
12946
|
*
|
|
12059
12947
|
* Consumer flow:
|
|
@@ -13246,8 +14134,35 @@ var NcDeliverySchema = _enum([
|
|
|
13246
14134
|
"immediate",
|
|
13247
14135
|
"track-end",
|
|
13248
14136
|
"device-event",
|
|
13249
|
-
"package-event"
|
|
14137
|
+
"package-event",
|
|
14138
|
+
"system-event"
|
|
13250
14139
|
]);
|
|
14140
|
+
/**
|
|
14141
|
+
* Stable Notification Center vocabulary over infrastructure/liveness events.
|
|
14142
|
+
* Bus categories are normalized into these intent-level kinds so rules do not
|
|
14143
|
+
* depend on a provider's raw event name or payload shape.
|
|
14144
|
+
*/
|
|
14145
|
+
var NcSystemEventKindSchema = _enum([
|
|
14146
|
+
"camera-online",
|
|
14147
|
+
"camera-offline",
|
|
14148
|
+
"stream-online",
|
|
14149
|
+
"stream-offline",
|
|
14150
|
+
"node-online",
|
|
14151
|
+
"node-offline",
|
|
14152
|
+
"addon-update-available",
|
|
14153
|
+
"server-update-available"
|
|
14154
|
+
]);
|
|
14155
|
+
/**
|
|
14156
|
+
* One coherent system-event condition. `kinds` is the required opt-in safety
|
|
14157
|
+
* gate; the remaining lists are optional narrowing filters relevant to the
|
|
14158
|
+
* selected kinds.
|
|
14159
|
+
*/
|
|
14160
|
+
var NcSystemEventConditionSchema = object({
|
|
14161
|
+
kinds: array(NcSystemEventKindSchema).min(1),
|
|
14162
|
+
deviceIds: array(number().int()).min(1).optional(),
|
|
14163
|
+
nodeIds: array(string().min(1)).min(1).optional(),
|
|
14164
|
+
packageNames: array(string().min(1)).min(1).optional()
|
|
14165
|
+
});
|
|
13251
14166
|
/** Weekly schedule — OR of windows; absence on the rule = always active. */
|
|
13252
14167
|
var NcScheduleSchema = object({
|
|
13253
14168
|
windows: array(object({
|
|
@@ -13552,6 +14467,8 @@ var NcConditionsSchema = object({
|
|
|
13552
14467
|
"picked-up",
|
|
13553
14468
|
"both"
|
|
13554
14469
|
]).optional(),
|
|
14470
|
+
/** Infrastructure/liveness/update event matcher (`system-event` delivery). */
|
|
14471
|
+
systemEvent: NcSystemEventConditionSchema.optional(),
|
|
13555
14472
|
/**
|
|
13556
14473
|
* PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
|
|
13557
14474
|
* (MaskShape vocabulary). A record passes when its bbox overlaps ANY
|
|
@@ -13771,7 +14688,8 @@ var NcTestResultSchema = object({
|
|
|
13771
14688
|
"object-event",
|
|
13772
14689
|
"track",
|
|
13773
14690
|
"device-event",
|
|
13774
|
-
"package-event"
|
|
14691
|
+
"package-event",
|
|
14692
|
+
"system-event"
|
|
13775
14693
|
]),
|
|
13776
14694
|
deviceId: number(),
|
|
13777
14695
|
timestamp: number(),
|
|
@@ -13793,7 +14711,8 @@ var NcConditionDescriptorSchema = object({
|
|
|
13793
14711
|
"schedule",
|
|
13794
14712
|
"device",
|
|
13795
14713
|
"package",
|
|
13796
|
-
"occupancy"
|
|
14714
|
+
"occupancy",
|
|
14715
|
+
"system"
|
|
13797
14716
|
]),
|
|
13798
14717
|
label: string(),
|
|
13799
14718
|
/** Editor widget the UI renders — never hardcode per-condition forms. */
|
|
@@ -13811,7 +14730,8 @@ var NcConditionDescriptorSchema = object({
|
|
|
13811
14730
|
"crossingSelect",
|
|
13812
14731
|
"polygonDraw",
|
|
13813
14732
|
"occupancy",
|
|
13814
|
-
"deviceState"
|
|
14733
|
+
"deviceState",
|
|
14734
|
+
"systemEvent"
|
|
13815
14735
|
]),
|
|
13816
14736
|
operator: _enum([
|
|
13817
14737
|
"in",
|
|
@@ -13870,7 +14790,8 @@ var NcHistoryRecordKindSchema = _enum([
|
|
|
13870
14790
|
"object-event",
|
|
13871
14791
|
"track-end",
|
|
13872
14792
|
"device-event",
|
|
13873
|
-
"package-event"
|
|
14793
|
+
"package-event",
|
|
14794
|
+
"system-event"
|
|
13874
14795
|
]);
|
|
13875
14796
|
/** Subject summary frozen on the row at fire time (survives rule/record edits). */
|
|
13876
14797
|
var NcHistorySubjectSchema = object({
|
|
@@ -13878,7 +14799,14 @@ var NcHistorySubjectSchema = object({
|
|
|
13878
14799
|
label: string().optional(),
|
|
13879
14800
|
confidence: number().optional(),
|
|
13880
14801
|
zones: array(string()),
|
|
13881
|
-
timestamp: number()
|
|
14802
|
+
timestamp: number(),
|
|
14803
|
+
systemEvent: object({
|
|
14804
|
+
kind: NcSystemEventKindSchema,
|
|
14805
|
+
subject: string(),
|
|
14806
|
+
title: string(),
|
|
14807
|
+
body: string(),
|
|
14808
|
+
data: record(string(), unknown())
|
|
14809
|
+
}).optional()
|
|
13882
14810
|
});
|
|
13883
14811
|
/**
|
|
13884
14812
|
* One delivery-history row. This is a read-only VIEW over the durable
|
|
@@ -14238,6 +15166,76 @@ object({
|
|
|
14238
15166
|
* Each provider returns a static descriptor; the core enumerates them
|
|
14239
15167
|
* to validate the `integration=` query param and resolve the consent
|
|
14240
15168
|
* label + the scopes baked into the issued token.
|
|
15169
|
+
*
|
|
15170
|
+
* ## Declaring one
|
|
15171
|
+
*
|
|
15172
|
+
* An OAuth client is integration-specific knowledge — who the client is, what
|
|
15173
|
+
* it may ask for, where it may be sent — so it is declared by the ADDON that
|
|
15174
|
+
* owns the integration, never by the kernel and never as a branch inside
|
|
15175
|
+
* `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
|
|
15176
|
+
* steps, no others:
|
|
15177
|
+
*
|
|
15178
|
+
* 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
|
|
15179
|
+
* manifest entry. This is also what tells the hub, at addon-LOAD time, that
|
|
15180
|
+
* a descriptor is owed — see "the boot window" below.
|
|
15181
|
+
* 2. Return a provider from `onInitialize()`:
|
|
15182
|
+
*
|
|
15183
|
+
* ```ts
|
|
15184
|
+
* const provider: IOauthIntegrationProvider = {
|
|
15185
|
+
* getDescriptor: async () => ({
|
|
15186
|
+
* integrationId: 'my-thing', // the `integration=` query param
|
|
15187
|
+
* displayName: 'My Thing',
|
|
15188
|
+
* requestedScopes: [ … ], // see below
|
|
15189
|
+
* allowedRedirectPrefixes: ['https://callback.example/'],
|
|
15190
|
+
* }),
|
|
15191
|
+
* }
|
|
15192
|
+
* return [{ capability: oauthIntegrationCapability, provider }]
|
|
15193
|
+
* ```
|
|
15194
|
+
*
|
|
15195
|
+
* The descriptor must be **static** — it is read on the authorize path, so
|
|
15196
|
+
* never put an await on network or disk behind it, and never register it
|
|
15197
|
+
* behind one either (a provider is registered only once `onInitialize`
|
|
15198
|
+
* RETURNS, so anything awaited before the return delays linking).
|
|
15199
|
+
* 3. Nothing else. There is no allow-list to join, no id to register with the
|
|
15200
|
+
* core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
|
|
15201
|
+
* `/api/oauth2/integrations` are built from this collection alone.
|
|
15202
|
+
*
|
|
15203
|
+
* **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
|
|
15204
|
+
* NEEDS to function.** Not a blast radius, not a conservative
|
|
15205
|
+
* under-declaration, not a description of some other path the addon happens to
|
|
15206
|
+
* have. Derive it from what the client actually calls **with this token** —
|
|
15207
|
+
* every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
|
|
15208
|
+
* addon HTTP route it posts to — and write the call that justifies each entry
|
|
15209
|
+
* next to it. Two integrations once used this field to mean two different
|
|
15210
|
+
* things; the operator ruled there is one meaning, and any third integration
|
|
15211
|
+
* inherits it (2026-08-09).
|
|
15212
|
+
*
|
|
15213
|
+
* This is not documentation, it is the ENFORCEMENT INPUT. Since
|
|
15214
|
+
* [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
|
|
15215
|
+
* checks an integration token's grant before letting it reach an
|
|
15216
|
+
* `access: 'authenticated'` route, so an **under-declaration is an integration
|
|
15217
|
+
* that stops working** — a missing `addon:` entry means `403 Token scope
|
|
15218
|
+
* mismatch` on every control the client tries to actuate. Widen the descriptor
|
|
15219
|
+
* honestly rather than weakening a check to make a route pass.
|
|
15220
|
+
*
|
|
15221
|
+
* Prefer a narrow `capability:` scope to a `category:` one unless the client
|
|
15222
|
+
* genuinely needs a whole family; a category scope grants every future member
|
|
15223
|
+
* of that category too. `category:system [create]` has been rejected once and
|
|
15224
|
+
* should stay rejected: it hands `addons.installPackage` to an integration.
|
|
15225
|
+
*
|
|
15226
|
+
* Calls the ADDON itself makes over `ctx.api` run as the addon and are not
|
|
15227
|
+
* scope-checked, so they are not what this field describes — but reaching the
|
|
15228
|
+
* addon's route in the first place IS, and that is the entry to declare.
|
|
15229
|
+
*
|
|
15230
|
+
* **The boot window.** An addon registers its provider after its runner forks
|
|
15231
|
+
* and initialises, so between hub start and that moment this collection is
|
|
15232
|
+
* incomplete and an `integrationId` can be legitimately absent. The core does
|
|
15233
|
+
* not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
|
|
15234
|
+
* it compares the manifest declarers against the registered providers and
|
|
15235
|
+
* answers `503 temporarily_unavailable` (with `Retry-After` and the pending
|
|
15236
|
+
* addon ids) instead of `400 unknown integration`, and reports
|
|
15237
|
+
* `complete: false` on `GET /api/oauth2/integrations`. A client should retry
|
|
15238
|
+
* while the list is incomplete rather than conclude the hub cannot do OAuth.
|
|
14241
15239
|
*/
|
|
14242
15240
|
var OauthIntegrationDescriptorSchema = object({
|
|
14243
15241
|
/** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
|
|
@@ -14268,7 +15266,30 @@ var OauthIntegrationDescriptorSchema = object({
|
|
|
14268
15266
|
* present, /api/oauth2/authorize bakes THIS into the code instead of the
|
|
14269
15267
|
* hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
|
|
14270
15268
|
* the hub's env) drives the claim that its cloud Lambda routes back on. */
|
|
14271
|
-
hubUrl: string().optional()
|
|
15269
|
+
hubUrl: string().optional(),
|
|
15270
|
+
/**
|
|
15271
|
+
* How long a REFRESH token issued for this integration lives — seconds, or
|
|
15272
|
+
* `'never'` for a token minted with no `exp` claim at all. Omit to keep the
|
|
15273
|
+
* 30-day default, which is what every link used before this field existed.
|
|
15274
|
+
*
|
|
15275
|
+
* Declared here for the same reason `requestedScopes` is: the integration
|
|
15276
|
+
* knows what it needs. Amazon's account linking and a Home Assistant config
|
|
15277
|
+
* entry are both meant to survive indefinitely, and re-linking is a manual
|
|
15278
|
+
* user action, so a 30-day expiry silently unlinks a working integration.
|
|
15279
|
+
*
|
|
15280
|
+
* **The security posture, stated so it is owned deliberately.** A refresh
|
|
15281
|
+
* token that never expires is permanent access if it leaks. What bounds it is
|
|
15282
|
+
* revocation, not time: `oauthRefresh` re-reads the session on every use and
|
|
15283
|
+
* returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
|
|
15284
|
+
* The one gap is the ACCESS token — it is a plain signed JWT that nothing
|
|
15285
|
+
* re-checks against the session on the `/trpc` and `/addon/*` paths, so
|
|
15286
|
+
* revoking a link takes effect there only after its remaining hour. That hour
|
|
15287
|
+
* is why the access TTL is not configurable.
|
|
15288
|
+
*
|
|
15289
|
+
* The value is baked into the authorization code at `/authorize` and travels
|
|
15290
|
+
* on the tokens, so editing this field changes FUTURE links only.
|
|
15291
|
+
*/
|
|
15292
|
+
refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
|
|
14272
15293
|
});
|
|
14273
15294
|
method(_void(), OauthIntegrationDescriptorSchema);
|
|
14274
15295
|
/**
|
|
@@ -14621,8 +15642,26 @@ var TrackSchema = object({
|
|
|
14621
15642
|
/** Periodic snapshots at snapshotIntervalMs cadence (subject to
|
|
14622
15643
|
* saveThumbnails policy). */
|
|
14623
15644
|
snapshots: array(TrackSnapshotSchema).readonly(),
|
|
14624
|
-
/** Deduplicated zones the track has entered at least once. */
|
|
15645
|
+
/** Deduplicated zones the track has entered at least once. Zone IDS. */
|
|
14625
15646
|
zonesVisited: array(string()).readonly(),
|
|
15647
|
+
/**
|
|
15648
|
+
* Human NAMES for {@link zonesVisited}, resolved at READ time against the
|
|
15649
|
+
* `zones` capability.
|
|
15650
|
+
*
|
|
15651
|
+
* `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
|
|
15652
|
+
* and no card can render — so every free-text search surface was structurally
|
|
15653
|
+
* unable to answer "show me the tracks in Uscio", and did not fail loudly, it
|
|
15654
|
+
* just returned nothing. Resolving here rather than in each client keeps ONE
|
|
15655
|
+
* derivation and costs the clients no extra call (the `zones` cap is
|
|
15656
|
+
* per-device, so a client-side resolve would be a per-camera fan-out on a
|
|
15657
|
+
* surface built to avoid exactly that).
|
|
15658
|
+
*
|
|
15659
|
+
* Resolved, never invented: a zone deleted since the track was written has no
|
|
15660
|
+
* name and is DROPPED, so this array can be shorter than `zonesVisited` — the
|
|
15661
|
+
* two are not positionally aligned. Absent when the track visited no zone, or
|
|
15662
|
+
* when the zone catalogue could not be read.
|
|
15663
|
+
*/
|
|
15664
|
+
zoneNames: array(string()).readonly().optional(),
|
|
14626
15665
|
/** Deduplicated set of detector classes observed for this track over its
|
|
14627
15666
|
* life (a track may be reclassified, e.g. person→vehicle). Absent on
|
|
14628
15667
|
* legacy rows written before class accumulation shipped. */
|
|
@@ -16725,6 +17764,23 @@ var CameraRecordingStatusSchema = object({
|
|
|
16725
17764
|
active: boolean(),
|
|
16726
17765
|
storageBytes: number()
|
|
16727
17766
|
});
|
|
17767
|
+
/** One stage of the fan-out that could NOT be read, and how long it cost. */
|
|
17768
|
+
var CameraStatusDegradationSchema = object({
|
|
17769
|
+
stage: _enum([
|
|
17770
|
+
"source",
|
|
17771
|
+
"broker",
|
|
17772
|
+
"detection",
|
|
17773
|
+
"recording",
|
|
17774
|
+
"switches"
|
|
17775
|
+
]),
|
|
17776
|
+
reason: _enum([
|
|
17777
|
+
"timeout",
|
|
17778
|
+
"error",
|
|
17779
|
+
"partial"
|
|
17780
|
+
]),
|
|
17781
|
+
/** Wall-clock ms spent on the stage before it was abandoned. */
|
|
17782
|
+
elapsedMs: number()
|
|
17783
|
+
});
|
|
16728
17784
|
/**
|
|
16729
17785
|
* Aggregated per-camera pipeline status — server-composed, single call.
|
|
16730
17786
|
*
|
|
@@ -16755,9 +17811,28 @@ var CameraStatusSchema = object({
|
|
|
16755
17811
|
* differently — a quiet camera that looks identical to a dead one is the
|
|
16756
17812
|
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
16757
17813
|
*
|
|
16758
|
-
* Empty when nothing is off
|
|
17814
|
+
* Empty when nothing is off, and never contains a switch no provider offers
|
|
17815
|
+
* — but an empty list is only a POSITIVE claim when `degraded` does not name
|
|
17816
|
+
* `'switches'`. When it does, the switch set could not be read and nothing
|
|
17817
|
+
* here may be rendered as "the operator turned nothing off": that is the
|
|
17818
|
+
* D62 failure (a camera we could not read painted as broken) in the very
|
|
17819
|
+
* field that exists to prevent it.
|
|
16759
17820
|
*/
|
|
16760
17821
|
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
17822
|
+
/**
|
|
17823
|
+
* Stages of the bounded fan-out that were CUT SHORT — a timeout or a
|
|
17824
|
+
* rejection — and whose block is therefore `null` because we could not
|
|
17825
|
+
* READ it, not because there is nothing there.
|
|
17826
|
+
*
|
|
17827
|
+
* Without this, three different facts arrive as the same `null`: "the stage
|
|
17828
|
+
* timed out", "the stage failed", and "this camera legitimately has no
|
|
17829
|
+
* decoder / no recording". Every surface that draws a conclusion from a null
|
|
17830
|
+
* block (or from an empty `switchedOff`) must consult this first; a stage
|
|
17831
|
+
* named here supports no conclusion at all, only "unknown".
|
|
17832
|
+
*
|
|
17833
|
+
* Empty on a clean read — the overwhelmingly common case.
|
|
17834
|
+
*/
|
|
17835
|
+
degraded: array(CameraStatusDegradationSchema).readonly(),
|
|
16761
17836
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16762
17837
|
fetchedAt: number()
|
|
16763
17838
|
});
|
|
@@ -17275,6 +18350,37 @@ DeviceType.Camera, method(object({
|
|
|
17275
18350
|
lastCapturedAt: number().nullable(),
|
|
17276
18351
|
cacheAgeMs: number().nullable(),
|
|
17277
18352
|
etag: string().nullable()
|
|
18353
|
+
}))), systemMethod(object({
|
|
18354
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18355
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18356
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18357
|
+
targets: array(object({
|
|
18358
|
+
deviceId: number(),
|
|
18359
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18360
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18361
|
+
width: number().int().positive().optional()
|
|
18362
|
+
})).min(1).max(200) }), array(object({
|
|
18363
|
+
deviceId: number(),
|
|
18364
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18365
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18366
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18367
|
+
url: string().nullable(),
|
|
18368
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18369
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18370
|
+
capturedAt: number().nullable(),
|
|
18371
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18372
|
+
ageMs: number().nullable(),
|
|
18373
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18374
|
+
expiresAt: number().nullable(),
|
|
18375
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18376
|
+
width: number().nullable(),
|
|
18377
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18378
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18379
|
+
neverCaptured: boolean(),
|
|
18380
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18381
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18382
|
+
* present it as current. */
|
|
18383
|
+
sleeping: boolean()
|
|
17278
18384
|
})));
|
|
17279
18385
|
/**
|
|
17280
18386
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -17320,11 +18426,29 @@ var SsoBridgeClaimsSchema = object({
|
|
|
17320
18426
|
codeChallenge: string().optional(),
|
|
17321
18427
|
/** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
|
|
17322
18428
|
* tokens so the verify path can check the session is not revoked. */
|
|
17323
|
-
sessionId: string().optional()
|
|
18429
|
+
sessionId: string().optional(),
|
|
18430
|
+
/**
|
|
18431
|
+
* The refresh lifetime this LINK was created with, in seconds, or `'never'`.
|
|
18432
|
+
* Baked into the code at `/authorize` from the integration's descriptor and
|
|
18433
|
+
* carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
|
|
18434
|
+
* on the token rather than being re-read from the descriptor on purpose:
|
|
18435
|
+
* editing a descriptor must not retroactively extend or shorten a link the
|
|
18436
|
+
* operator already consented to.
|
|
18437
|
+
*/
|
|
18438
|
+
refreshTtl: union([number().int().positive(), literal("never")]).optional()
|
|
17324
18439
|
});
|
|
17325
18440
|
method(object({
|
|
17326
18441
|
claims: SsoBridgeClaimsSchema,
|
|
17327
|
-
|
|
18442
|
+
/**
|
|
18443
|
+
* Seconds, or `'never'` for a token minted with NO `exp` claim.
|
|
18444
|
+
*
|
|
18445
|
+
* `'never'` is a literal rather than `undefined`/`0` because omitting
|
|
18446
|
+
* this field already means "the 5-minute SSO hand-off default", and
|
|
18447
|
+
* `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
|
|
18448
|
+
* went through the numeric path would fail at mint time and break
|
|
18449
|
+
* linking rather than produce an eternal token.
|
|
18450
|
+
*/
|
|
18451
|
+
ttlSec: union([number().int().positive(), literal("never")]).optional()
|
|
17328
18452
|
}), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
|
|
17329
18453
|
var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
17330
18454
|
providerId: string().min(1),
|
|
@@ -19305,6 +20429,37 @@ onColorChanged: { data: object({
|
|
|
19305
20429
|
*/
|
|
19306
20430
|
runtimeState: ColorStatusSchema
|
|
19307
20431
|
};
|
|
20432
|
+
var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
|
|
20433
|
+
object({
|
|
20434
|
+
outcome: literal("validated"),
|
|
20435
|
+
/** Round-trip of the sign-in, when the provider measured it. */
|
|
20436
|
+
latencyMs: number().nonnegative().optional(),
|
|
20437
|
+
/** Optional human detail worth showing next to the tick
|
|
20438
|
+
* ("3 devices visible on this account"). */
|
|
20439
|
+
detail: string().optional()
|
|
20440
|
+
}).strict(),
|
|
20441
|
+
object({
|
|
20442
|
+
outcome: literal("rejected"),
|
|
20443
|
+
error: string()
|
|
20444
|
+
}).strict(),
|
|
20445
|
+
object({
|
|
20446
|
+
outcome: literal("inconclusive"),
|
|
20447
|
+
error: string()
|
|
20448
|
+
}).strict()
|
|
20449
|
+
]);
|
|
20450
|
+
var ConnectionTestInputSchema = object({
|
|
20451
|
+
/** Candidate integration settings, exactly as the create form collected them. */
|
|
20452
|
+
settings: record(string(), unknown()) });
|
|
20453
|
+
/**
|
|
20454
|
+
* What the provider's test actually DOES, so the UI can say it in words before
|
|
20455
|
+
* the operator presses the button ("Signs in to the Dreo cloud"). Purely
|
|
20456
|
+
* descriptive — it never changes routing.
|
|
20457
|
+
*/
|
|
20458
|
+
var ConnectionTestDescriptorSchema = object({ label: string() });
|
|
20459
|
+
method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
|
|
20460
|
+
kind: "mutation",
|
|
20461
|
+
auth: "admin"
|
|
20462
|
+
}), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
|
|
19308
20463
|
/**
|
|
19309
20464
|
* Upstream-system connectivity sensor — distinct from `device-status`,
|
|
19310
20465
|
* which is the kernel-managed online/offline flag for the device's
|
|
@@ -19939,7 +21094,29 @@ var FaceInfoSchema = object({
|
|
|
19939
21094
|
recognizedIdentityId: string().optional(),
|
|
19940
21095
|
identityName: string().optional(),
|
|
19941
21096
|
assigned: boolean(),
|
|
21097
|
+
/**
|
|
21098
|
+
* The crop, inline, base64.
|
|
21099
|
+
*
|
|
21100
|
+
* **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
|
|
21101
|
+
* field alone is ~2.87 MiB, re-sent in full on every operator assign and
|
|
21102
|
+
* every 30 s poll, base64-inflated over the msgpack socket and held in the
|
|
21103
|
+
* query heap. It stays for callers that have not migrated; `includeCrops:
|
|
21104
|
+
* false` turns it off once they have.
|
|
21105
|
+
*/
|
|
19942
21106
|
base64: string().optional(),
|
|
21107
|
+
/**
|
|
21108
|
+
* Same crop, as a data-plane URL for `<img src>` — the move the admin
|
|
21109
|
+
* snapshot surfaces made on 2026-08-08.
|
|
21110
|
+
*
|
|
21111
|
+
* Served by the `event-media` plane, which resolves a raw MediaStore key and
|
|
21112
|
+
* is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
|
|
21113
|
+
* cookie, so no header plumbing is needed. The bytes then ride the browser's
|
|
21114
|
+
* HTTP cache with an ETag and `immutable`, instead of the WebSocket.
|
|
21115
|
+
*
|
|
21116
|
+
* Absent when the face has no stored crop, or when the addon has no data
|
|
21117
|
+
* plane — callers fall back to {@link base64}.
|
|
21118
|
+
*/
|
|
21119
|
+
cropUrl: string().optional(),
|
|
19943
21120
|
/** Design B: the face bbox (pixel space) on the key frame — lets a detail
|
|
19944
21121
|
* view draw the box over the native `keyFrameMediaKey` frame. Absent on
|
|
19945
21122
|
* legacy rows written before design B. */
|
|
@@ -20004,7 +21181,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
|
|
|
20004
21181
|
auth: "admin"
|
|
20005
21182
|
}), method(object({
|
|
20006
21183
|
limit: number().int().positive().optional(),
|
|
20007
|
-
filter: FaceFilterEnum.optional()
|
|
21184
|
+
filter: FaceFilterEnum.optional(),
|
|
21185
|
+
/**
|
|
21186
|
+
* Inline the base64 crop on every row. Default `true` — the existing
|
|
21187
|
+
* behaviour, kept so no caller breaks.
|
|
21188
|
+
*
|
|
21189
|
+
* Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
|
|
21190
|
+
* drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
|
|
21191
|
+
* the browser cache the images.
|
|
21192
|
+
*
|
|
21193
|
+
* **This is an INPUT field, so it does not reach the addon until the
|
|
21194
|
+
* next train.** The hub router validates cap inputs against its own
|
|
21195
|
+
* compiled Zod, which strips a key it does not know — verified today
|
|
21196
|
+
* on the OUTPUT side, where an additive field DOES arrive immediately
|
|
21197
|
+
* (`Track.hasFace`). Until the train ships, sending `false` is
|
|
21198
|
+
* harmless and simply keeps the crops inline.
|
|
21199
|
+
*/
|
|
21200
|
+
includeCrops: boolean().optional()
|
|
20008
21201
|
}).optional(), array(FaceInfoSchema).readonly()), method(object({
|
|
20009
21202
|
deviceId: number().int(),
|
|
20010
21203
|
trackId: string()
|
|
@@ -20628,15 +21821,57 @@ var AvailableIntegrationTypeSchema = object({
|
|
|
20628
21821
|
* flow can import (e.g. HA areas). Drives the adopt modal's "import
|
|
20629
21822
|
* locations" checkbox. Provider-declared in the addon manifest. */
|
|
20630
21823
|
supportsLocationImport: boolean(),
|
|
21824
|
+
/**
|
|
21825
|
+
* True when this integration DECLARES a pre-creation test (the
|
|
21826
|
+
* `connection-test` cap, or a broker whose settings it stores). Drives the
|
|
21827
|
+
* Test button: an integration that cannot be tested must say so up front
|
|
21828
|
+
* rather than offering a button that always answers the same nonsense.
|
|
21829
|
+
*/
|
|
21830
|
+
canTest: boolean(),
|
|
20631
21831
|
existingInstances: array(object({
|
|
20632
21832
|
id: string(),
|
|
20633
21833
|
name: string()
|
|
20634
21834
|
})),
|
|
20635
21835
|
canAdd: boolean()
|
|
20636
21836
|
});
|
|
21837
|
+
/**
|
|
21838
|
+
* Why a test could not be answered as a plain boolean.
|
|
21839
|
+
*
|
|
21840
|
+
* `success` alone collapsed four different situations into one red box, and the
|
|
21841
|
+
* one that mattered most — "nobody ever asked the remote anything" — looked
|
|
21842
|
+
* exactly like "the remote said no". The status is the discriminator:
|
|
21843
|
+
*
|
|
21844
|
+
* - `validated` — a provider-declared test ran and the remote ACCEPTED.
|
|
21845
|
+
* - `rejected` — a provider-declared test ran and the remote REFUSED.
|
|
21846
|
+
* The only status that blocks `integrations.create`.
|
|
21847
|
+
* - `inconclusive` — a test IS declared but could not complete (timeout,
|
|
21848
|
+
* DNS, 5xx). Nothing was observed; not a failure.
|
|
21849
|
+
* - `unsupported` — this integration declares NO test. Nothing was
|
|
21850
|
+
* observed either; not a failure, and not a pass.
|
|
21851
|
+
*
|
|
21852
|
+
* `unsupported` and `inconclusive` both carry `success: false` so an older
|
|
21853
|
+
* client can never read them as a green tick, and both carry an `error` string
|
|
21854
|
+
* that SAYS the test did not run rather than inventing a failure.
|
|
21855
|
+
*/
|
|
21856
|
+
var TestConnectionStatusEnum = _enum([
|
|
21857
|
+
"validated",
|
|
21858
|
+
"rejected",
|
|
21859
|
+
"inconclusive",
|
|
21860
|
+
"unsupported"
|
|
21861
|
+
]);
|
|
20637
21862
|
var TestConnectionResultSchema$1 = object({
|
|
21863
|
+
/** True ONLY for `validated`. Never true for a test that did not run. */
|
|
20638
21864
|
success: boolean(),
|
|
20639
|
-
error: string().optional()
|
|
21865
|
+
error: string().optional(),
|
|
21866
|
+
/** Optional for wire back-compat with clients built before the tri-state;
|
|
21867
|
+
* the server always sets it. */
|
|
21868
|
+
status: TestConnectionStatusEnum.optional(),
|
|
21869
|
+
/** Addon id whose declared test answered — `null` when none did. Lets the UI
|
|
21870
|
+
* attribute a result instead of blaming "the integration". */
|
|
21871
|
+
testedBy: string().nullable().optional(),
|
|
21872
|
+
latencyMs: number().nonnegative().optional(),
|
|
21873
|
+
/** Human detail from a `validated` result ("3 devices on this account"). */
|
|
21874
|
+
detail: string().optional()
|
|
20640
21875
|
});
|
|
20641
21876
|
var CreateIntegrationInputSchema = object({
|
|
20642
21877
|
addonId: string(),
|
|
@@ -24405,7 +25640,12 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
24405
25640
|
hubUrl: string(),
|
|
24406
25641
|
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
24407
25642
|
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
24408
|
-
codeChallenge: string().optional()
|
|
25643
|
+
codeChallenge: string().optional(),
|
|
25644
|
+
/** The integration's declared refresh lifetime — seconds, or `'never'`.
|
|
25645
|
+
* From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
|
|
25646
|
+
* code so the link carries its own lifetime; omit for the 30-day
|
|
25647
|
+
* default. */
|
|
25648
|
+
refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
|
|
24409
25649
|
}), object({ code: string() }), {
|
|
24410
25650
|
kind: "mutation",
|
|
24411
25651
|
access: "create"
|
|
@@ -25745,974 +26985,345 @@ var BaseDevice = class {
|
|
|
25745
26985
|
* is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
|
|
25746
26986
|
* `hasSupplementalLight/hasAlarmIo`, etc).
|
|
25747
26987
|
*
|
|
25748
|
-
* Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
|
|
25749
|
-
* the kernel treats it as ready immediately. A device that derives its shape
|
|
25750
|
-
* from a spec (a container, or an accessory sensor) rather than from a
|
|
25751
|
-
* hardware probe has no probe to "complete"; without stamping `lastProbedAt`
|
|
25752
|
-
* it would look perpetually un-probed — logging "Initial probe did not
|
|
25753
|
-
* complete" on every boot and spinning a pointless retry chain. Drivers that
|
|
25754
|
-
* DO probe override this and write their own `feature-probe` slice (including
|
|
25755
|
-
* `lastProbedAt`) once their probe actually succeeds.
|
|
25756
|
-
*/
|
|
25757
|
-
async onProbe() {
|
|
25758
|
-
const base = this.runtimeState.getCapState("feature-probe") ?? {
|
|
25759
|
-
flags: {},
|
|
25760
|
-
deviceType: null,
|
|
25761
|
-
model: null,
|
|
25762
|
-
channelCount: null,
|
|
25763
|
-
lastProbedAt: 0,
|
|
25764
|
-
lastFetchedAt: 0
|
|
25765
|
-
};
|
|
25766
|
-
this.runtimeState.setCapState("feature-probe", {
|
|
25767
|
-
...base,
|
|
25768
|
-
lastProbedAt: Date.now()
|
|
25769
|
-
});
|
|
25770
|
-
}
|
|
25771
|
-
/**
|
|
25772
|
-
* Phase 5 — fired after the device + its accessories are registered.
|
|
25773
|
-
* Drivers publish streams to the broker, kick off background tasks,
|
|
25774
|
-
* or subscribe to lib events that need a fully-registered device id.
|
|
25775
|
-
*
|
|
25776
|
-
* Default: no-op.
|
|
25777
|
-
*
|
|
25778
|
-
* RENAMED FROM `onCreated` (which still exists for back-compat in this
|
|
25779
|
-
* pass). The new name reflects the post-probe, post-accessory contract.
|
|
25780
|
-
*/
|
|
25781
|
-
async onActivate() {}
|
|
25782
|
-
/**
|
|
25783
|
-
* Re-run the probe + reconcile accessories + refresh features meta.
|
|
25784
|
-
* Drivers call this when device-side state changes (battery cam wakes,
|
|
25785
|
-
* firmware update, manual operator trigger).
|
|
25786
|
-
*
|
|
25787
|
-
* The kernel injects `_kernelReprobe` on registration so this method
|
|
25788
|
-
* delegates to the same orchestrator that runs the boot-time phase
|
|
25789
|
-
* 3 + 4 sequence. Drivers should NOT override this — they override
|
|
25790
|
-
* `onProbe()` instead.
|
|
25791
|
-
*/
|
|
25792
|
-
async reprobe() {
|
|
25793
|
-
if (this._kernelReprobe) await this._kernelReprobe();
|
|
25794
|
-
else await this.onProbe();
|
|
25795
|
-
}
|
|
25796
|
-
/**
|
|
25797
|
-
* Kernel-injected callback that runs the full post-probe orchestration
|
|
25798
|
-
* (onProbe → registerDevice meta refresh → accessory reconciliation).
|
|
25799
|
-
* Set by `device-cap-proxy.register()`. Drivers should not touch this
|
|
25800
|
-
* directly — call `reprobe()` instead.
|
|
25801
|
-
*/
|
|
25802
|
-
_kernelReprobe;
|
|
25803
|
-
/**
|
|
25804
|
-
* Declare accessory child devices the kernel should auto-spawn
|
|
25805
|
-
* after `onProbe()` resolves. Each spec fully describes one child
|
|
25806
|
-
* — stableId suffix (deterministic per kind for restore-safety),
|
|
25807
|
-
* meta (type / name / location), config (initial blob the child
|
|
25808
|
-
* self-hydrates), and a factory that constructs the concrete
|
|
25809
|
-
* class with whatever closure-captured refs it needs (typically
|
|
25810
|
-
* `this` for the parent reference).
|
|
25811
|
-
*
|
|
25812
|
-
* The kernel handles the rest: allocateDeviceId, persistInitialConfig
|
|
25813
|
-
* (skipped on restore when the row already exists),
|
|
25814
|
-
* persistInitialMeta, createContext, factory invocation, register,
|
|
25815
|
-
* and recursive lifecycle (probe + accessories + activate).
|
|
25816
|
-
*
|
|
25817
|
-
* Implementations should derive children from
|
|
25818
|
-
* `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
|
|
25819
|
-
* Drivers can use the `getProbeFlags()` helper to read the flag bag
|
|
25820
|
-
* with a typed cast.
|
|
25821
|
-
*
|
|
25822
|
-
* Default: no children.
|
|
25823
|
-
*/
|
|
25824
|
-
getAccessoryChildren() {
|
|
25825
|
-
return [];
|
|
25826
|
-
}
|
|
25827
|
-
/**
|
|
25828
|
-
* Read the current feature-probe flag bag with a typed cast. Helper
|
|
25829
|
-
* for `getAccessoryChildren()` and `features` getters that derive
|
|
25830
|
-
* outputs from the probe results.
|
|
25831
|
-
*/
|
|
25832
|
-
getProbeFlags() {
|
|
25833
|
-
return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
|
|
25834
|
-
}
|
|
25835
|
-
/**
|
|
25836
|
-
* Returns true once `onProbe` has completed at least once
|
|
25837
|
-
* (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
|
|
25838
|
-
* to avoid spawning stale accessories on a fresh device whose probe
|
|
25839
|
-
* hasn't landed yet.
|
|
25840
|
-
*/
|
|
25841
|
-
hasProbed() {
|
|
25842
|
-
return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
|
|
25843
|
-
}
|
|
25844
|
-
};
|
|
25845
|
-
/**
|
|
25846
|
-
* Convert an IDevice to the flat DeviceSummary shape expected by the
|
|
25847
|
-
* device-provider cap router. Shared across all providers.
|
|
25848
|
-
*/
|
|
25849
|
-
function toDeviceSummary(device, addonId) {
|
|
25850
|
-
const config = {};
|
|
25851
|
-
for (const entry of device.config.entries()) config[entry.key] = entry.value;
|
|
25852
|
-
return {
|
|
25853
|
-
id: device.id,
|
|
25854
|
-
stableId: device.stableId,
|
|
25855
|
-
addonId,
|
|
25856
|
-
type: String(device.type),
|
|
25857
|
-
name: device.name,
|
|
25858
|
-
parentDeviceId: device.parentDeviceId,
|
|
25859
|
-
online: device.online,
|
|
25860
|
-
features: [...device.features],
|
|
25861
|
-
config,
|
|
25862
|
-
sourceInfo: device.sourceInfo
|
|
25863
|
-
};
|
|
25864
|
-
}
|
|
25865
|
-
/**
|
|
25866
|
-
* Base class for device-provider addons (rtsp, onvif, frigate).
|
|
25867
|
-
*
|
|
25868
|
-
* Provides default implementations for the common device-provider cap
|
|
25869
|
-
* methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
|
|
25870
|
-
* `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
|
|
25871
|
-
* methods that differ per provider.
|
|
25872
|
-
*
|
|
25873
|
-
* @example
|
|
25874
|
-
* ```ts
|
|
25875
|
-
* class RtspProvider extends BaseDeviceProvider {
|
|
25876
|
-
* protected readonly addonId = 'provider-rtsp'
|
|
25877
|
-
* protected readonly providerName = 'RTSP'
|
|
25878
|
-
*
|
|
25879
|
-
* protected async onCreateDevice(input) { ... }
|
|
25880
|
-
* protected async onGetCreationSchema(type) { ... }
|
|
25881
|
-
* protected async onRestoreDevices(saved) { ... }
|
|
25882
|
-
* }
|
|
25883
|
-
* ```
|
|
25884
|
-
*/
|
|
25885
|
-
var BaseDeviceProvider = class extends BaseAddon {
|
|
25886
|
-
async onInitialize() {
|
|
25887
|
-
this.ctx.logger.info(`${this.providerName} Provider initialized`);
|
|
25888
|
-
return [{
|
|
25889
|
-
capability: deviceProviderCapability,
|
|
25890
|
-
provider: this
|
|
25891
|
-
}];
|
|
25892
|
-
}
|
|
25893
|
-
async onShutdown() {
|
|
25894
|
-
const devices = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
25895
|
-
for (const device of devices) try {
|
|
25896
|
-
await this.ctx.kernel.devices?.decommission(device.id);
|
|
25897
|
-
} catch (err) {
|
|
25898
|
-
this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
|
|
25899
|
-
tags: {
|
|
25900
|
-
deviceId: device.id,
|
|
25901
|
-
stableId: device.stableId
|
|
25902
|
-
},
|
|
25903
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
25904
|
-
});
|
|
25905
|
-
}
|
|
25906
|
-
this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
|
|
25907
|
-
}
|
|
25908
|
-
async start() {}
|
|
25909
|
-
async stop() {}
|
|
25910
|
-
async getStatus() {
|
|
25911
|
-
return {
|
|
25912
|
-
connected: true,
|
|
25913
|
-
deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
|
|
25914
|
-
};
|
|
25915
|
-
}
|
|
25916
|
-
async getDevices() {
|
|
25917
|
-
return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
|
|
25918
|
-
id: d.stableId,
|
|
25919
|
-
name: d.name,
|
|
25920
|
-
type: String(d.type)
|
|
25921
|
-
}));
|
|
25922
|
-
}
|
|
25923
|
-
async supportsDiscovery() {
|
|
25924
|
-
return false;
|
|
25925
|
-
}
|
|
25926
|
-
async discoverDevices(_input) {
|
|
25927
|
-
return [];
|
|
25928
|
-
}
|
|
25929
|
-
/** Extra per-scan input form (e.g. a broadcast address for another subnet).
|
|
25930
|
-
* Null = no extra params. Override in providers that support scoped scans. */
|
|
25931
|
-
async getDiscoveryParamsSchema() {
|
|
25932
|
-
return null;
|
|
25933
|
-
}
|
|
25934
|
-
/**
|
|
25935
|
-
* The DeviceType this provider creates via manual add — derived from the
|
|
25936
|
-
* `deviceClasses` map (first registered type). `null` when manual creation is
|
|
25937
|
-
* unsupported. Lets the Add-Device dialog pick the right type per provider.
|
|
25938
|
-
*/
|
|
25939
|
-
async getManualCreationType() {
|
|
25940
|
-
if (!await this.supportsManualCreation()) return { deviceType: null };
|
|
25941
|
-
return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
|
|
25942
|
-
}
|
|
25943
|
-
async adoptDiscoveredDevice(_input) {
|
|
25944
|
-
throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
|
|
25945
|
-
}
|
|
25946
|
-
async supportsManualCreation() {
|
|
25947
|
-
return true;
|
|
25948
|
-
}
|
|
25949
|
-
async getChildCreationSchema(input) {
|
|
25950
|
-
return this.onGetCreationSchema(input.type);
|
|
25951
|
-
}
|
|
25952
|
-
/**
|
|
25953
|
-
* Default kernel-orchestrated `createDevice` implementation. The
|
|
25954
|
-
* subclass's `onCreateDevice` returns a declarative
|
|
25955
|
-
* `CreateDeviceSpec` (`{meta, config}`) — this method handles
|
|
25956
|
-
* stableId generation, class lookup, kernel.devices.create
|
|
25957
|
-
* dispatch, and DeviceSummary mapping. Subclasses should NOT
|
|
25958
|
-
* override this method; override `onCreateDevice` and
|
|
25959
|
-
* `deviceClasses` instead.
|
|
25960
|
-
*/
|
|
25961
|
-
async createDevice(input) {
|
|
25962
|
-
const spec = await this.onCreateDevice(input.type, input.config);
|
|
25963
|
-
const Class = this.deviceClasses[spec.meta.type];
|
|
25964
|
-
if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
|
|
25965
|
-
const stableId = this.generateStableId(spec.meta.type, spec.config);
|
|
25966
|
-
const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
|
|
25967
|
-
if (spec.onAfterCreate) try {
|
|
25968
|
-
await spec.onAfterCreate(device);
|
|
25969
|
-
} catch (err) {
|
|
25970
|
-
this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
|
|
25971
|
-
tags: {
|
|
25972
|
-
deviceId: device.id,
|
|
25973
|
-
stableId
|
|
25974
|
-
},
|
|
25975
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
25976
|
-
});
|
|
25977
|
-
}
|
|
25978
|
-
return this.toSummary(device);
|
|
25979
|
-
}
|
|
25980
|
-
/**
|
|
25981
|
-
* Generate a stableId for a newly-created device. Default uses the
|
|
25982
|
-
* `${addonId}-${Date.now()}` pattern as a unique-but-opaque
|
|
25983
|
-
* fallback; any provider that has access to durable hardware
|
|
25984
|
-
* identity (UID, MAC, serial) should override and derive from it
|
|
25985
|
-
* so re-adding the same physical device reuses its persisted row.
|
|
25986
|
-
*
|
|
25987
|
-
* `config` is the parsed CreateDeviceSpec.config the subclass
|
|
25988
|
-
* returned from `onCreateDevice` — the override has access to
|
|
25989
|
-
* every operator-supplied + autodetect-resolved field. Optional
|
|
25990
|
-
* for back-compat: existing overrides that take only `type`
|
|
25991
|
-
* keep working unchanged.
|
|
26988
|
+
* Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
|
|
26989
|
+
* the kernel treats it as ready immediately. A device that derives its shape
|
|
26990
|
+
* from a spec (a container, or an accessory sensor) rather than from a
|
|
26991
|
+
* hardware probe has no probe to "complete"; without stamping `lastProbedAt`
|
|
26992
|
+
* it would look perpetually un-probed — logging "Initial probe did not
|
|
26993
|
+
* complete" on every boot and spinning a pointless retry chain. Drivers that
|
|
26994
|
+
* DO probe override this and write their own `feature-probe` slice (including
|
|
26995
|
+
* `lastProbedAt`) once their probe actually succeeds.
|
|
25992
26996
|
*/
|
|
25993
|
-
|
|
25994
|
-
|
|
25995
|
-
|
|
25996
|
-
|
|
25997
|
-
|
|
25998
|
-
|
|
25999
|
-
|
|
26997
|
+
async onProbe() {
|
|
26998
|
+
const base = this.runtimeState.getCapState("feature-probe") ?? {
|
|
26999
|
+
flags: {},
|
|
27000
|
+
deviceType: null,
|
|
27001
|
+
model: null,
|
|
27002
|
+
channelCount: null,
|
|
27003
|
+
lastProbedAt: 0,
|
|
27004
|
+
lastFetchedAt: 0
|
|
26000
27005
|
};
|
|
26001
|
-
|
|
26002
|
-
|
|
26003
|
-
|
|
26004
|
-
|
|
27006
|
+
this.runtimeState.setCapState("feature-probe", {
|
|
27007
|
+
...base,
|
|
27008
|
+
lastProbedAt: Date.now()
|
|
27009
|
+
});
|
|
26005
27010
|
}
|
|
26006
27011
|
/**
|
|
26007
|
-
*
|
|
27012
|
+
* Phase 5 — fired after the device + its accessories are registered.
|
|
27013
|
+
* Drivers publish streams to the broker, kick off background tasks,
|
|
27014
|
+
* or subscribe to lib events that need a fully-registered device id.
|
|
26008
27015
|
*
|
|
26009
|
-
*
|
|
26010
|
-
* `parentDeviceId === null` row using the `deviceClasses` map.
|
|
26011
|
-
* The kernel's register flow handles `getAccessoryChildren()` for
|
|
26012
|
-
* each parent (siren / floodlight / PIR / etc).
|
|
27016
|
+
* Default: no-op.
|
|
26013
27017
|
*
|
|
26014
|
-
*
|
|
26015
|
-
*
|
|
26016
|
-
|
|
26017
|
-
|
|
26018
|
-
|
|
26019
|
-
*
|
|
26020
|
-
*
|
|
26021
|
-
*
|
|
26022
|
-
* spawn them — that callback is only for purpose-built
|
|
26023
|
-
* accessory roles).
|
|
27018
|
+
* RENAMED FROM `onCreated` (which still exists for back-compat in this
|
|
27019
|
+
* pass). The new name reflects the post-probe, post-accessory contract.
|
|
27020
|
+
*/
|
|
27021
|
+
async onActivate() {}
|
|
27022
|
+
/**
|
|
27023
|
+
* Re-run the probe + reconcile accessories + refresh features meta.
|
|
27024
|
+
* Drivers call this when device-side state changes (battery cam wakes,
|
|
27025
|
+
* firmware update, manual operator trigger).
|
|
26024
27026
|
*
|
|
26025
|
-
*
|
|
26026
|
-
*
|
|
26027
|
-
*
|
|
26028
|
-
* `
|
|
26029
|
-
* fit.
|
|
27027
|
+
* The kernel injects `_kernelReprobe` on registration so this method
|
|
27028
|
+
* delegates to the same orchestrator that runs the boot-time phase
|
|
27029
|
+
* 3 + 4 sequence. Drivers should NOT override this — they override
|
|
27030
|
+
* `onProbe()` instead.
|
|
26030
27031
|
*/
|
|
26031
|
-
async
|
|
26032
|
-
|
|
26033
|
-
|
|
26034
|
-
if (saved.parentDeviceId !== null) continue;
|
|
26035
|
-
const Class = this.deviceClasses[saved.type];
|
|
26036
|
-
if (!Class) {
|
|
26037
|
-
this.ctx.logger.warn("No device class registered for restored type — skipping", {
|
|
26038
|
-
tags: { stableId: saved.stableId },
|
|
26039
|
-
meta: { type: saved.type }
|
|
26040
|
-
});
|
|
26041
|
-
continue;
|
|
26042
|
-
}
|
|
26043
|
-
try {
|
|
26044
|
-
await this.ctx.kernel.devices.create(saved.stableId, Class, {});
|
|
26045
|
-
restored.add(saved.id);
|
|
26046
|
-
} catch (err) {
|
|
26047
|
-
this.ctx.logger.warn("Failed to restore device", {
|
|
26048
|
-
tags: { stableId: saved.stableId },
|
|
26049
|
-
meta: {
|
|
26050
|
-
type: saved.type,
|
|
26051
|
-
error: err instanceof Error ? err.message : String(err)
|
|
26052
|
-
}
|
|
26053
|
-
});
|
|
26054
|
-
}
|
|
26055
|
-
}
|
|
26056
|
-
const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
|
|
26057
|
-
for (const saved of childRows) {
|
|
26058
|
-
const Class = this.deviceClasses[saved.type];
|
|
26059
|
-
if (!Class) continue;
|
|
26060
|
-
if (saved.parentDeviceId === null) continue;
|
|
26061
|
-
if (!restored.has(saved.parentDeviceId)) continue;
|
|
26062
|
-
try {
|
|
26063
|
-
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
26064
|
-
restored.add(saved.id);
|
|
26065
|
-
} catch (err) {
|
|
26066
|
-
this.ctx.logger.warn("Failed to restore hub-adopted child", {
|
|
26067
|
-
tags: {
|
|
26068
|
-
stableId: saved.stableId,
|
|
26069
|
-
parentDeviceId: saved.parentDeviceId
|
|
26070
|
-
},
|
|
26071
|
-
meta: {
|
|
26072
|
-
type: saved.type,
|
|
26073
|
-
error: err instanceof Error ? err.message : String(err)
|
|
26074
|
-
}
|
|
26075
|
-
});
|
|
26076
|
-
}
|
|
26077
|
-
}
|
|
26078
|
-
}
|
|
26079
|
-
/** Convert an IDevice to the flat DeviceSummary for the cap router. */
|
|
26080
|
-
toSummary(device) {
|
|
26081
|
-
return toDeviceSummary(device, this.addonId);
|
|
27032
|
+
async reprobe() {
|
|
27033
|
+
if (this._kernelReprobe) await this._kernelReprobe();
|
|
27034
|
+
else await this.onProbe();
|
|
26082
27035
|
}
|
|
26083
|
-
|
|
26084
|
-
|
|
26085
|
-
|
|
26086
|
-
|
|
26087
|
-
|
|
26088
|
-
|
|
26089
|
-
|
|
26090
|
-
|
|
26091
|
-
|
|
26092
|
-
|
|
26093
|
-
*
|
|
26094
|
-
|
|
26095
|
-
|
|
26096
|
-
|
|
26097
|
-
|
|
26098
|
-
|
|
26099
|
-
|
|
27036
|
+
/**
|
|
27037
|
+
* Kernel-injected callback that runs the full post-probe orchestration
|
|
27038
|
+
* (onProbe → registerDevice meta refresh → accessory reconciliation).
|
|
27039
|
+
* Set by `device-cap-proxy.register()`. Drivers should not touch this
|
|
27040
|
+
* directly — call `reprobe()` instead.
|
|
27041
|
+
*/
|
|
27042
|
+
_kernelReprobe;
|
|
27043
|
+
/**
|
|
27044
|
+
* Declare accessory child devices the kernel should auto-spawn
|
|
27045
|
+
* after `onProbe()` resolves. Each spec fully describes one child
|
|
27046
|
+
* — stableId suffix (deterministic per kind for restore-safety),
|
|
27047
|
+
* meta (type / name / location), config (initial blob the child
|
|
27048
|
+
* self-hydrates), and a factory that constructs the concrete
|
|
27049
|
+
* class with whatever closure-captured refs it needs (typically
|
|
27050
|
+
* `this` for the parent reference).
|
|
27051
|
+
*
|
|
27052
|
+
* The kernel handles the rest: allocateDeviceId, persistInitialConfig
|
|
27053
|
+
* (skipped on restore when the row already exists),
|
|
27054
|
+
* persistInitialMeta, createContext, factory invocation, register,
|
|
27055
|
+
* and recursive lifecycle (probe + accessories + activate).
|
|
27056
|
+
*
|
|
27057
|
+
* Implementations should derive children from
|
|
27058
|
+
* `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
|
|
27059
|
+
* Drivers can use the `getProbeFlags()` helper to read the flag bag
|
|
27060
|
+
* with a typed cast.
|
|
27061
|
+
*
|
|
27062
|
+
* Default: no children.
|
|
27063
|
+
*/
|
|
27064
|
+
getAccessoryChildren() {
|
|
27065
|
+
return [];
|
|
26100
27066
|
}
|
|
26101
|
-
|
|
26102
|
-
|
|
26103
|
-
*
|
|
26104
|
-
|
|
26105
|
-
|
|
26106
|
-
|
|
26107
|
-
this.
|
|
27067
|
+
/**
|
|
27068
|
+
* Read the current feature-probe flag bag with a typed cast. Helper
|
|
27069
|
+
* for `getAccessoryChildren()` and `features` getters that derive
|
|
27070
|
+
* outputs from the probe results.
|
|
27071
|
+
*/
|
|
27072
|
+
getProbeFlags() {
|
|
27073
|
+
return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
|
|
26108
27074
|
}
|
|
26109
|
-
|
|
26110
|
-
|
|
26111
|
-
*
|
|
26112
|
-
*
|
|
26113
|
-
*
|
|
26114
|
-
|
|
26115
|
-
|
|
26116
|
-
|
|
26117
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
26118
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
26119
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
26120
|
-
* callable — they are simply "unknown function" at parse time.
|
|
26121
|
-
*
|
|
26122
|
-
* Every numeric argument is validated as a finite number and every numeric
|
|
26123
|
-
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
26124
|
-
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
26125
|
-
* closed rather than emitting a garbage value.
|
|
26126
|
-
*/
|
|
26127
|
-
function asFiniteNumber(value, name, index) {
|
|
26128
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
26129
|
-
return value;
|
|
26130
|
-
}
|
|
26131
|
-
function asString$1(value, name, index) {
|
|
26132
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
26133
|
-
return value;
|
|
26134
|
-
}
|
|
26135
|
-
function finiteResult(value, name) {
|
|
26136
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
26137
|
-
return value;
|
|
26138
|
-
}
|
|
26139
|
-
function allFiniteNumbers(args, name) {
|
|
26140
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
26141
|
-
}
|
|
26142
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
26143
|
-
var table = {
|
|
26144
|
-
min: {
|
|
26145
|
-
minArgs: 1,
|
|
26146
|
-
maxArgs: INF,
|
|
26147
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
26148
|
-
},
|
|
26149
|
-
max: {
|
|
26150
|
-
minArgs: 1,
|
|
26151
|
-
maxArgs: INF,
|
|
26152
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
26153
|
-
},
|
|
26154
|
-
abs: {
|
|
26155
|
-
minArgs: 1,
|
|
26156
|
-
maxArgs: 1,
|
|
26157
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
26158
|
-
},
|
|
26159
|
-
floor: {
|
|
26160
|
-
minArgs: 1,
|
|
26161
|
-
maxArgs: 1,
|
|
26162
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
26163
|
-
},
|
|
26164
|
-
ceil: {
|
|
26165
|
-
minArgs: 1,
|
|
26166
|
-
maxArgs: 1,
|
|
26167
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
26168
|
-
},
|
|
26169
|
-
sqrt: {
|
|
26170
|
-
minArgs: 1,
|
|
26171
|
-
maxArgs: 1,
|
|
26172
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
26173
|
-
},
|
|
26174
|
-
round: {
|
|
26175
|
-
minArgs: 1,
|
|
26176
|
-
maxArgs: 2,
|
|
26177
|
-
apply: (args) => {
|
|
26178
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
26179
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
26180
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
26181
|
-
const factor = 10 ** digits;
|
|
26182
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
26183
|
-
}
|
|
26184
|
-
},
|
|
26185
|
-
pow: {
|
|
26186
|
-
minArgs: 2,
|
|
26187
|
-
maxArgs: 2,
|
|
26188
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
26189
|
-
},
|
|
26190
|
-
clamp: {
|
|
26191
|
-
minArgs: 3,
|
|
26192
|
-
maxArgs: 3,
|
|
26193
|
-
apply: (args) => {
|
|
26194
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
26195
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
26196
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
26197
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
26198
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
26199
|
-
}
|
|
26200
|
-
},
|
|
26201
|
-
avg: {
|
|
26202
|
-
minArgs: 1,
|
|
26203
|
-
maxArgs: INF,
|
|
26204
|
-
apply: (args) => {
|
|
26205
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
26206
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
26207
|
-
}
|
|
26208
|
-
},
|
|
26209
|
-
sum: {
|
|
26210
|
-
minArgs: 1,
|
|
26211
|
-
maxArgs: INF,
|
|
26212
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
26213
|
-
},
|
|
26214
|
-
coalesce: {
|
|
26215
|
-
minArgs: 1,
|
|
26216
|
-
maxArgs: INF,
|
|
26217
|
-
apply: (args) => {
|
|
26218
|
-
for (const a of args) if (a !== null) return a;
|
|
26219
|
-
return null;
|
|
26220
|
-
}
|
|
26221
|
-
},
|
|
26222
|
-
age: {
|
|
26223
|
-
minArgs: 2,
|
|
26224
|
-
maxArgs: 2,
|
|
26225
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
26226
|
-
},
|
|
26227
|
-
convert: {
|
|
26228
|
-
minArgs: 3,
|
|
26229
|
-
maxArgs: 3,
|
|
26230
|
-
apply: (args, hooks) => {
|
|
26231
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
26232
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
26233
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
26234
|
-
if (hooks.convert) {
|
|
26235
|
-
const out = hooks.convert(x, from, to);
|
|
26236
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
26237
|
-
return finiteResult(out, "convert");
|
|
26238
|
-
}
|
|
26239
|
-
if (from === to) return x;
|
|
26240
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
26241
|
-
}
|
|
27075
|
+
/**
|
|
27076
|
+
* Returns true once `onProbe` has completed at least once
|
|
27077
|
+
* (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
|
|
27078
|
+
* to avoid spawning stale accessories on a fresh device whose probe
|
|
27079
|
+
* hasn't landed yet.
|
|
27080
|
+
*/
|
|
27081
|
+
hasProbed() {
|
|
27082
|
+
return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
|
|
26242
27083
|
}
|
|
26243
27084
|
};
|
|
26244
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
26245
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
26246
|
-
* callees at parse time (immediate author feedback). */
|
|
26247
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
26248
|
-
/**
|
|
26249
|
-
* Resource-bound constants for the safe expression engine.
|
|
26250
|
-
*
|
|
26251
|
-
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
26252
|
-
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
26253
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
26254
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
26255
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
26256
|
-
*/
|
|
26257
|
-
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
26258
|
-
* rejected without allocation. */
|
|
26259
|
-
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
26260
|
-
/** A legal binding / identifier name. */
|
|
26261
|
-
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
26262
|
-
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
26263
|
-
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
26264
|
-
var RESERVED_BINDING_NAMES = new Set([
|
|
26265
|
-
"now",
|
|
26266
|
-
"true",
|
|
26267
|
-
"false",
|
|
26268
|
-
"null"
|
|
26269
|
-
]);
|
|
26270
27085
|
/**
|
|
26271
|
-
*
|
|
26272
|
-
*
|
|
26273
|
-
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
26274
|
-
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
26275
|
-
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
26276
|
-
* is a parse error with a source position, so member access / assignment /
|
|
26277
|
-
* template literals are lexically impossible.
|
|
27086
|
+
* Convert an IDevice to the flat DeviceSummary shape expected by the
|
|
27087
|
+
* device-provider cap router. Shared across all providers.
|
|
26278
27088
|
*/
|
|
26279
|
-
|
|
26280
|
-
|
|
26281
|
-
|
|
26282
|
-
|
|
26283
|
-
|
|
26284
|
-
|
|
26285
|
-
|
|
26286
|
-
|
|
26287
|
-
|
|
26288
|
-
|
|
26289
|
-
|
|
26290
|
-
|
|
26291
|
-
|
|
26292
|
-
|
|
26293
|
-
|
|
26294
|
-
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
26295
|
-
}
|
|
26296
|
-
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
26297
|
-
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
26298
|
-
* string. */
|
|
26299
|
-
function tokenize(source) {
|
|
26300
|
-
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
26301
|
-
const tokens = [];
|
|
26302
|
-
let i = 0;
|
|
26303
|
-
const n = source.length;
|
|
26304
|
-
while (i < n) {
|
|
26305
|
-
const ch = source[i];
|
|
26306
|
-
if (isWhitespace(ch)) {
|
|
26307
|
-
i += 1;
|
|
26308
|
-
continue;
|
|
26309
|
-
}
|
|
26310
|
-
if (isDigit(ch)) {
|
|
26311
|
-
const start = i;
|
|
26312
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
26313
|
-
if (i < n && source[i] === ".") {
|
|
26314
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
26315
|
-
i += 1;
|
|
26316
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
26317
|
-
}
|
|
26318
|
-
const text = source.slice(start, i);
|
|
26319
|
-
const value = Number(text);
|
|
26320
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
26321
|
-
tokens.push({
|
|
26322
|
-
type: "number",
|
|
26323
|
-
value,
|
|
26324
|
-
pos: start
|
|
26325
|
-
});
|
|
26326
|
-
continue;
|
|
26327
|
-
}
|
|
26328
|
-
if (ch === "'" || ch === "\"") {
|
|
26329
|
-
const quote = ch;
|
|
26330
|
-
const start = i;
|
|
26331
|
-
i += 1;
|
|
26332
|
-
let out = "";
|
|
26333
|
-
let closed = false;
|
|
26334
|
-
while (i < n) {
|
|
26335
|
-
const c = source[i];
|
|
26336
|
-
if (c === "\\") {
|
|
26337
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
26338
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
26339
|
-
out += next;
|
|
26340
|
-
i += 2;
|
|
26341
|
-
continue;
|
|
26342
|
-
}
|
|
26343
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
26344
|
-
}
|
|
26345
|
-
if (c === quote) {
|
|
26346
|
-
closed = true;
|
|
26347
|
-
i += 1;
|
|
26348
|
-
break;
|
|
26349
|
-
}
|
|
26350
|
-
out += c;
|
|
26351
|
-
i += 1;
|
|
26352
|
-
}
|
|
26353
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
26354
|
-
tokens.push({
|
|
26355
|
-
type: "string",
|
|
26356
|
-
value: out,
|
|
26357
|
-
pos: start
|
|
26358
|
-
});
|
|
26359
|
-
continue;
|
|
26360
|
-
}
|
|
26361
|
-
if (isIdentStart(ch)) {
|
|
26362
|
-
const start = i;
|
|
26363
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
26364
|
-
const text = source.slice(start, i);
|
|
26365
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
26366
|
-
type: "keyword",
|
|
26367
|
-
keyword: keywordOf(text),
|
|
26368
|
-
pos: start
|
|
26369
|
-
});
|
|
26370
|
-
else tokens.push({
|
|
26371
|
-
type: "identifier",
|
|
26372
|
-
name: text,
|
|
26373
|
-
pos: start
|
|
26374
|
-
});
|
|
26375
|
-
continue;
|
|
26376
|
-
}
|
|
26377
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
26378
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
26379
|
-
tokens.push({
|
|
26380
|
-
type: "punct",
|
|
26381
|
-
punct: two,
|
|
26382
|
-
pos: i
|
|
26383
|
-
});
|
|
26384
|
-
i += 2;
|
|
26385
|
-
continue;
|
|
26386
|
-
}
|
|
26387
|
-
if (isSinglePunct(ch)) {
|
|
26388
|
-
tokens.push({
|
|
26389
|
-
type: "punct",
|
|
26390
|
-
punct: ch,
|
|
26391
|
-
pos: i
|
|
26392
|
-
});
|
|
26393
|
-
i += 1;
|
|
26394
|
-
continue;
|
|
26395
|
-
}
|
|
26396
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
26397
|
-
}
|
|
26398
|
-
tokens.push({
|
|
26399
|
-
type: "eof",
|
|
26400
|
-
pos: n
|
|
26401
|
-
});
|
|
26402
|
-
return tokens;
|
|
26403
|
-
}
|
|
26404
|
-
function keywordOf(text) {
|
|
26405
|
-
if (text === "true") return "true";
|
|
26406
|
-
if (text === "false") return "false";
|
|
26407
|
-
return "null";
|
|
26408
|
-
}
|
|
26409
|
-
function isSinglePunct(ch) {
|
|
26410
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
27089
|
+
function toDeviceSummary(device, addonId) {
|
|
27090
|
+
const config = {};
|
|
27091
|
+
for (const entry of device.config.entries()) config[entry.key] = entry.value;
|
|
27092
|
+
return {
|
|
27093
|
+
id: device.id,
|
|
27094
|
+
stableId: device.stableId,
|
|
27095
|
+
addonId,
|
|
27096
|
+
type: String(device.type),
|
|
27097
|
+
name: device.name,
|
|
27098
|
+
parentDeviceId: device.parentDeviceId,
|
|
27099
|
+
online: device.online,
|
|
27100
|
+
features: [...device.features],
|
|
27101
|
+
config,
|
|
27102
|
+
sourceInfo: device.sourceInfo
|
|
27103
|
+
};
|
|
26411
27104
|
}
|
|
26412
27105
|
/**
|
|
26413
|
-
*
|
|
27106
|
+
* Base class for device-provider addons (rtsp, onvif, frigate).
|
|
26414
27107
|
*
|
|
26415
|
-
*
|
|
26416
|
-
*
|
|
26417
|
-
*
|
|
26418
|
-
*
|
|
26419
|
-
* function is rejected immediately (author feedback) and a persisted expression
|
|
26420
|
-
* that references a since-removed builtin degrades at read.
|
|
27108
|
+
* Provides default implementations for the common device-provider cap
|
|
27109
|
+
* methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
|
|
27110
|
+
* `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
|
|
27111
|
+
* methods that differ per provider.
|
|
26421
27112
|
*
|
|
26422
|
-
*
|
|
26423
|
-
*
|
|
27113
|
+
* @example
|
|
27114
|
+
* ```ts
|
|
27115
|
+
* class RtspProvider extends BaseDeviceProvider {
|
|
27116
|
+
* protected readonly addonId = 'provider-rtsp'
|
|
27117
|
+
* protected readonly providerName = 'RTSP'
|
|
27118
|
+
*
|
|
27119
|
+
* protected async onCreateDevice(input) { ... }
|
|
27120
|
+
* protected async onGetCreationSchema(type) { ... }
|
|
27121
|
+
* protected async onRestoreDevices(saved) { ... }
|
|
27122
|
+
* }
|
|
27123
|
+
* ```
|
|
26424
27124
|
*/
|
|
26425
|
-
|
|
26426
|
-
|
|
26427
|
-
|
|
26428
|
-
|
|
26429
|
-
|
|
26430
|
-
|
|
26431
|
-
|
|
26432
|
-
"<=": 4,
|
|
26433
|
-
">": 4,
|
|
26434
|
-
">=": 4,
|
|
26435
|
-
"+": 5,
|
|
26436
|
-
"-": 5,
|
|
26437
|
-
"*": 6,
|
|
26438
|
-
"/": 6,
|
|
26439
|
-
"%": 6
|
|
26440
|
-
};
|
|
26441
|
-
function isLogicalOp(op) {
|
|
26442
|
-
return op === "&&" || op === "||";
|
|
26443
|
-
}
|
|
26444
|
-
function isBinaryOp(op) {
|
|
26445
|
-
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
26446
|
-
}
|
|
26447
|
-
var Parser = class {
|
|
26448
|
-
tokens;
|
|
26449
|
-
pos = 0;
|
|
26450
|
-
nodeCount = 0;
|
|
26451
|
-
identifiers = /* @__PURE__ */ new Set();
|
|
26452
|
-
callees = /* @__PURE__ */ new Set();
|
|
26453
|
-
constructor(tokens) {
|
|
26454
|
-
this.tokens = tokens;
|
|
27125
|
+
var BaseDeviceProvider = class extends BaseAddon {
|
|
27126
|
+
async onInitialize() {
|
|
27127
|
+
this.ctx.logger.info(`${this.providerName} Provider initialized`);
|
|
27128
|
+
return [{
|
|
27129
|
+
capability: deviceProviderCapability,
|
|
27130
|
+
provider: this
|
|
27131
|
+
}];
|
|
26455
27132
|
}
|
|
26456
|
-
|
|
26457
|
-
const
|
|
26458
|
-
const
|
|
26459
|
-
|
|
27133
|
+
async onShutdown() {
|
|
27134
|
+
const devices = await this.ctx.kernel.devices?.getAll() ?? [];
|
|
27135
|
+
for (const device of devices) try {
|
|
27136
|
+
await this.ctx.kernel.devices?.decommission(device.id);
|
|
27137
|
+
} catch (err) {
|
|
27138
|
+
this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
|
|
27139
|
+
tags: {
|
|
27140
|
+
deviceId: device.id,
|
|
27141
|
+
stableId: device.stableId
|
|
27142
|
+
},
|
|
27143
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
27144
|
+
});
|
|
27145
|
+
}
|
|
27146
|
+
this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
|
|
27147
|
+
}
|
|
27148
|
+
async start() {}
|
|
27149
|
+
async stop() {}
|
|
27150
|
+
async getStatus() {
|
|
26460
27151
|
return {
|
|
26461
|
-
|
|
26462
|
-
|
|
26463
|
-
callees: this.callees,
|
|
26464
|
-
nodeCount: this.nodeCount
|
|
27152
|
+
connected: true,
|
|
27153
|
+
deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
|
|
26465
27154
|
};
|
|
26466
27155
|
}
|
|
26467
|
-
|
|
26468
|
-
return this.
|
|
26469
|
-
|
|
26470
|
-
|
|
26471
|
-
|
|
26472
|
-
|
|
26473
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
26474
|
-
expectPunct(punct) {
|
|
26475
|
-
const tok = this.peek();
|
|
26476
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
26477
|
-
this.pos += 1;
|
|
27156
|
+
async getDevices() {
|
|
27157
|
+
return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
|
|
27158
|
+
id: d.stableId,
|
|
27159
|
+
name: d.name,
|
|
27160
|
+
type: String(d.type)
|
|
27161
|
+
}));
|
|
26478
27162
|
}
|
|
26479
|
-
|
|
26480
|
-
const tok = this.peek();
|
|
26481
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
26482
|
-
this.pos += 1;
|
|
26483
|
-
return true;
|
|
26484
|
-
}
|
|
27163
|
+
async supportsDiscovery() {
|
|
26485
27164
|
return false;
|
|
26486
27165
|
}
|
|
26487
|
-
|
|
26488
|
-
|
|
26489
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
27166
|
+
async discoverDevices(_input) {
|
|
27167
|
+
return [];
|
|
26490
27168
|
}
|
|
26491
|
-
|
|
26492
|
-
|
|
26493
|
-
|
|
26494
|
-
|
|
26495
|
-
this.expectPunct(":");
|
|
26496
|
-
const alternate = this.parseTernary();
|
|
26497
|
-
this.countNode();
|
|
26498
|
-
return {
|
|
26499
|
-
kind: "conditional",
|
|
26500
|
-
test,
|
|
26501
|
-
consequent,
|
|
26502
|
-
alternate
|
|
26503
|
-
};
|
|
26504
|
-
}
|
|
26505
|
-
return test;
|
|
27169
|
+
/** Extra per-scan input form (e.g. a broadcast address for another subnet).
|
|
27170
|
+
* Null = no extra params. Override in providers that support scoped scans. */
|
|
27171
|
+
async getDiscoveryParamsSchema() {
|
|
27172
|
+
return null;
|
|
26506
27173
|
}
|
|
26507
|
-
|
|
26508
|
-
|
|
26509
|
-
|
|
26510
|
-
|
|
26511
|
-
|
|
26512
|
-
|
|
26513
|
-
|
|
26514
|
-
|
|
26515
|
-
this.pos += 1;
|
|
26516
|
-
const right = this.parseBinary(prec + 1);
|
|
26517
|
-
this.countNode();
|
|
26518
|
-
if (isLogicalOp(op)) left = {
|
|
26519
|
-
kind: "logical",
|
|
26520
|
-
op,
|
|
26521
|
-
left,
|
|
26522
|
-
right
|
|
26523
|
-
};
|
|
26524
|
-
else if (isBinaryOp(op)) left = {
|
|
26525
|
-
kind: "binary",
|
|
26526
|
-
op,
|
|
26527
|
-
left,
|
|
26528
|
-
right
|
|
26529
|
-
};
|
|
26530
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
26531
|
-
}
|
|
26532
|
-
return left;
|
|
27174
|
+
/**
|
|
27175
|
+
* The DeviceType this provider creates via manual add — derived from the
|
|
27176
|
+
* `deviceClasses` map (first registered type). `null` when manual creation is
|
|
27177
|
+
* unsupported. Lets the Add-Device dialog pick the right type per provider.
|
|
27178
|
+
*/
|
|
27179
|
+
async getManualCreationType() {
|
|
27180
|
+
if (!await this.supportsManualCreation()) return { deviceType: null };
|
|
27181
|
+
return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
|
|
26533
27182
|
}
|
|
26534
|
-
|
|
26535
|
-
|
|
26536
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
26537
|
-
const op = tok.punct;
|
|
26538
|
-
this.pos += 1;
|
|
26539
|
-
const operand = this.parseUnary();
|
|
26540
|
-
this.countNode();
|
|
26541
|
-
return {
|
|
26542
|
-
kind: "unary",
|
|
26543
|
-
op,
|
|
26544
|
-
operand
|
|
26545
|
-
};
|
|
26546
|
-
}
|
|
26547
|
-
return this.parsePrimary();
|
|
27183
|
+
async adoptDiscoveredDevice(_input) {
|
|
27184
|
+
throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
|
|
26548
27185
|
}
|
|
26549
|
-
|
|
26550
|
-
|
|
26551
|
-
switch (tok.type) {
|
|
26552
|
-
case "number":
|
|
26553
|
-
this.countNode();
|
|
26554
|
-
return {
|
|
26555
|
-
kind: "literal",
|
|
26556
|
-
value: tok.value
|
|
26557
|
-
};
|
|
26558
|
-
case "string":
|
|
26559
|
-
this.countNode();
|
|
26560
|
-
return {
|
|
26561
|
-
kind: "literal",
|
|
26562
|
-
value: tok.value
|
|
26563
|
-
};
|
|
26564
|
-
case "keyword":
|
|
26565
|
-
this.countNode();
|
|
26566
|
-
return {
|
|
26567
|
-
kind: "literal",
|
|
26568
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
26569
|
-
};
|
|
26570
|
-
case "identifier": {
|
|
26571
|
-
const nextTok = this.peek();
|
|
26572
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
26573
|
-
this.identifiers.add(tok.name);
|
|
26574
|
-
this.countNode();
|
|
26575
|
-
return {
|
|
26576
|
-
kind: "identifier",
|
|
26577
|
-
name: tok.name
|
|
26578
|
-
};
|
|
26579
|
-
}
|
|
26580
|
-
case "punct":
|
|
26581
|
-
if (tok.punct === "(") {
|
|
26582
|
-
const inner = this.parseTernary();
|
|
26583
|
-
this.expectPunct(")");
|
|
26584
|
-
return inner;
|
|
26585
|
-
}
|
|
26586
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
26587
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
26588
|
-
}
|
|
27186
|
+
async supportsManualCreation() {
|
|
27187
|
+
return true;
|
|
26589
27188
|
}
|
|
26590
|
-
|
|
26591
|
-
|
|
26592
|
-
|
|
26593
|
-
|
|
26594
|
-
|
|
26595
|
-
|
|
26596
|
-
|
|
26597
|
-
|
|
26598
|
-
|
|
26599
|
-
|
|
27189
|
+
async getChildCreationSchema(input) {
|
|
27190
|
+
return this.onGetCreationSchema(input.type);
|
|
27191
|
+
}
|
|
27192
|
+
/**
|
|
27193
|
+
* Default kernel-orchestrated `createDevice` implementation. The
|
|
27194
|
+
* subclass's `onCreateDevice` returns a declarative
|
|
27195
|
+
* `CreateDeviceSpec` (`{meta, config}`) — this method handles
|
|
27196
|
+
* stableId generation, class lookup, kernel.devices.create
|
|
27197
|
+
* dispatch, and DeviceSummary mapping. Subclasses should NOT
|
|
27198
|
+
* override this method; override `onCreateDevice` and
|
|
27199
|
+
* `deviceClasses` instead.
|
|
27200
|
+
*/
|
|
27201
|
+
async createDevice(input) {
|
|
27202
|
+
const spec = await this.onCreateDevice(input.type, input.config);
|
|
27203
|
+
const Class = this.deviceClasses[spec.meta.type];
|
|
27204
|
+
if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
|
|
27205
|
+
const stableId = this.generateStableId(spec.meta.type, spec.config);
|
|
27206
|
+
const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
|
|
27207
|
+
if (spec.onAfterCreate) try {
|
|
27208
|
+
await spec.onAfterCreate(device);
|
|
27209
|
+
} catch (err) {
|
|
27210
|
+
this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
|
|
27211
|
+
tags: {
|
|
27212
|
+
deviceId: device.id,
|
|
27213
|
+
stableId
|
|
27214
|
+
},
|
|
27215
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
27216
|
+
});
|
|
26600
27217
|
}
|
|
26601
|
-
this.
|
|
26602
|
-
this.countNode();
|
|
26603
|
-
return {
|
|
26604
|
-
kind: "call",
|
|
26605
|
-
callee,
|
|
26606
|
-
args
|
|
26607
|
-
};
|
|
27218
|
+
return this.toSummary(device);
|
|
26608
27219
|
}
|
|
26609
|
-
|
|
26610
|
-
|
|
26611
|
-
*
|
|
26612
|
-
|
|
26613
|
-
|
|
26614
|
-
|
|
26615
|
-
|
|
26616
|
-
*
|
|
26617
|
-
*
|
|
26618
|
-
*
|
|
26619
|
-
*
|
|
26620
|
-
*
|
|
26621
|
-
|
|
26622
|
-
|
|
26623
|
-
|
|
26624
|
-
*/
|
|
26625
|
-
var cache = /* @__PURE__ */ new Map();
|
|
26626
|
-
function getCached(source) {
|
|
26627
|
-
const hit = cache.get(source);
|
|
26628
|
-
if (hit !== void 0) {
|
|
26629
|
-
cache.delete(source);
|
|
26630
|
-
cache.set(source, hit);
|
|
26631
|
-
return hit;
|
|
27220
|
+
/**
|
|
27221
|
+
* Generate a stableId for a newly-created device. Default uses the
|
|
27222
|
+
* `${addonId}-${Date.now()}` pattern as a unique-but-opaque
|
|
27223
|
+
* fallback; any provider that has access to durable hardware
|
|
27224
|
+
* identity (UID, MAC, serial) should override and derive from it
|
|
27225
|
+
* so re-adding the same physical device reuses its persisted row.
|
|
27226
|
+
*
|
|
27227
|
+
* `config` is the parsed CreateDeviceSpec.config the subclass
|
|
27228
|
+
* returned from `onCreateDevice` — the override has access to
|
|
27229
|
+
* every operator-supplied + autodetect-resolved field. Optional
|
|
27230
|
+
* for back-compat: existing overrides that take only `type`
|
|
27231
|
+
* keep working unchanged.
|
|
27232
|
+
*/
|
|
27233
|
+
generateStableId(_type, _config) {
|
|
27234
|
+
return `${this.addonId}-${Date.now()}`;
|
|
26632
27235
|
}
|
|
26633
|
-
|
|
26634
|
-
|
|
26635
|
-
|
|
26636
|
-
|
|
26637
|
-
parsed: parseExpression(source)
|
|
26638
|
-
};
|
|
26639
|
-
} catch (err) {
|
|
26640
|
-
result = {
|
|
26641
|
-
ok: false,
|
|
26642
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
27236
|
+
async testCreationField(_input) {
|
|
27237
|
+
return {
|
|
27238
|
+
status: "ok",
|
|
27239
|
+
labels: ["probe not implemented"]
|
|
26643
27240
|
};
|
|
26644
27241
|
}
|
|
26645
|
-
|
|
26646
|
-
|
|
26647
|
-
|
|
26648
|
-
if (oldest !== void 0) cache.delete(oldest);
|
|
27242
|
+
async restoreDevices(savedDevices) {
|
|
27243
|
+
await this.onRestoreDevices(savedDevices);
|
|
27244
|
+
if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
|
|
26649
27245
|
}
|
|
26650
|
-
|
|
26651
|
-
|
|
26652
|
-
|
|
26653
|
-
*
|
|
26654
|
-
|
|
26655
|
-
|
|
26656
|
-
|
|
26657
|
-
|
|
26658
|
-
|
|
26659
|
-
*
|
|
26660
|
-
*
|
|
26661
|
-
*
|
|
26662
|
-
*
|
|
26663
|
-
*
|
|
26664
|
-
|
|
26665
|
-
|
|
26666
|
-
|
|
26667
|
-
|
|
26668
|
-
|
|
26669
|
-
|
|
26670
|
-
|
|
26671
|
-
|
|
27246
|
+
/**
|
|
27247
|
+
* Restore devices from persisted state. Two-pass:
|
|
27248
|
+
*
|
|
27249
|
+
* 1. **Top-level pass** — invokes `kernel.devices.create()` for every
|
|
27250
|
+
* `parentDeviceId === null` row using the `deviceClasses` map.
|
|
27251
|
+
* The kernel's register flow handles `getAccessoryChildren()` for
|
|
27252
|
+
* each parent (siren / floodlight / PIR / etc).
|
|
27253
|
+
*
|
|
27254
|
+
* 2. **Hub-adopted children pass** — for rows with
|
|
27255
|
+
* `parentDeviceId !== null` whose `type` IS in `deviceClasses`
|
|
27256
|
+
* (e.g. Reolink hub-adopted cameras under an NVR), spawn them
|
|
27257
|
+
* explicitly with the persisted `parentDeviceId`. These are
|
|
27258
|
+
* NOT accessory children — they're first-class adopted devices
|
|
27259
|
+
* that just happen to have a parent. Without this pass, every
|
|
27260
|
+
* server restart would lose hub-adopted cameras (their type is
|
|
27261
|
+
* in `deviceClasses` but parent's `getAccessoryChildren` doesn't
|
|
27262
|
+
* spawn them — that callback is only for purpose-built
|
|
27263
|
+
* accessory roles).
|
|
27264
|
+
*
|
|
27265
|
+
* Rows whose `type` is NOT in `deviceClasses` are skipped — those
|
|
27266
|
+
* are accessory children (siren/light/sensor) that the kernel's
|
|
27267
|
+
* accessory-spawn flow handles via the parent's
|
|
27268
|
+
* `getAccessoryChildren()`. Override only when the default doesn't
|
|
27269
|
+
* fit.
|
|
27270
|
+
*/
|
|
27271
|
+
async onRestoreDevices(savedDevices) {
|
|
27272
|
+
const restored = /* @__PURE__ */ new Set();
|
|
27273
|
+
for (const saved of savedDevices) {
|
|
27274
|
+
if (saved.parentDeviceId !== null) continue;
|
|
27275
|
+
const Class = this.deviceClasses[saved.type];
|
|
27276
|
+
if (!Class) {
|
|
27277
|
+
this.ctx.logger.warn("No device class registered for restored type — skipping", {
|
|
27278
|
+
tags: { stableId: saved.stableId },
|
|
27279
|
+
meta: { type: saved.type }
|
|
27280
|
+
});
|
|
27281
|
+
continue;
|
|
27282
|
+
}
|
|
27283
|
+
try {
|
|
27284
|
+
await this.ctx.kernel.devices.create(saved.stableId, Class, {});
|
|
27285
|
+
restored.add(saved.id);
|
|
27286
|
+
} catch (err) {
|
|
27287
|
+
this.ctx.logger.warn("Failed to restore device", {
|
|
27288
|
+
tags: { stableId: saved.stableId },
|
|
27289
|
+
meta: {
|
|
27290
|
+
type: saved.type,
|
|
27291
|
+
error: err instanceof Error ? err.message : String(err)
|
|
27292
|
+
}
|
|
27293
|
+
});
|
|
27294
|
+
}
|
|
27295
|
+
}
|
|
27296
|
+
const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
|
|
27297
|
+
for (const saved of childRows) {
|
|
27298
|
+
const Class = this.deviceClasses[saved.type];
|
|
27299
|
+
if (!Class) continue;
|
|
27300
|
+
if (saved.parentDeviceId === null) continue;
|
|
27301
|
+
if (!restored.has(saved.parentDeviceId)) continue;
|
|
27302
|
+
try {
|
|
27303
|
+
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
27304
|
+
restored.add(saved.id);
|
|
27305
|
+
} catch (err) {
|
|
27306
|
+
this.ctx.logger.warn("Failed to restore hub-adopted child", {
|
|
27307
|
+
tags: {
|
|
27308
|
+
stableId: saved.stableId,
|
|
27309
|
+
parentDeviceId: saved.parentDeviceId
|
|
27310
|
+
},
|
|
27311
|
+
meta: {
|
|
27312
|
+
type: saved.type,
|
|
27313
|
+
error: err instanceof Error ? err.message : String(err)
|
|
27314
|
+
}
|
|
27315
|
+
});
|
|
27316
|
+
}
|
|
27317
|
+
}
|
|
26672
27318
|
}
|
|
26673
|
-
|
|
26674
|
-
|
|
26675
|
-
|
|
26676
|
-
for (const id of compiled.parsed.identifiers) {
|
|
26677
|
-
if (id === "now") continue;
|
|
26678
|
-
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
27319
|
+
/** Convert an IDevice to the flat DeviceSummary for the cap router. */
|
|
27320
|
+
toSummary(device) {
|
|
27321
|
+
return toDeviceSummary(device, this.addonId);
|
|
26679
27322
|
}
|
|
26680
|
-
|
|
26681
|
-
|
|
26682
|
-
|
|
26683
|
-
|
|
26684
|
-
kind: literal("field").optional(),
|
|
26685
|
-
sourceKey: string(),
|
|
26686
|
-
cap: string(),
|
|
26687
|
-
fieldPath: string()
|
|
26688
|
-
}),
|
|
26689
|
-
object({
|
|
26690
|
-
kind: literal("literal"),
|
|
26691
|
-
value: union([
|
|
26692
|
-
string(),
|
|
26693
|
-
number(),
|
|
26694
|
-
boolean(),
|
|
26695
|
-
_null()
|
|
26696
|
-
])
|
|
26697
|
-
}),
|
|
26698
|
-
object({
|
|
26699
|
-
kind: literal("global"),
|
|
26700
|
-
sourceStableId: string(),
|
|
26701
|
-
cap: string(),
|
|
26702
|
-
fieldPath: string()
|
|
26703
|
-
})
|
|
26704
|
-
]);
|
|
26705
|
-
object({
|
|
26706
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
26707
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
26708
|
-
}).superRefine((src, ctx) => {
|
|
26709
|
-
const err = validateExpressionSource(src);
|
|
26710
|
-
if (err !== null) ctx.addIssue({
|
|
26711
|
-
code: "custom",
|
|
26712
|
-
message: err,
|
|
26713
|
-
path: ["expr"]
|
|
26714
|
-
});
|
|
26715
|
-
});
|
|
27323
|
+
};
|
|
27324
|
+
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;
|
|
27325
|
+
new Set(Object.values(DeviceType));
|
|
27326
|
+
DeviceFeature.BatteryOperated;
|
|
26716
27327
|
Object.freeze({
|
|
26717
27328
|
"accessories.setChildHidden": {
|
|
26718
27329
|
capName: "accessories",
|
|
@@ -27488,6 +28099,18 @@ Object.freeze({
|
|
|
27488
28099
|
addonId: null,
|
|
27489
28100
|
access: "create"
|
|
27490
28101
|
},
|
|
28102
|
+
"connectionTest.describeTest": {
|
|
28103
|
+
capName: "connection-test",
|
|
28104
|
+
capScope: "system",
|
|
28105
|
+
addonId: null,
|
|
28106
|
+
access: "view"
|
|
28107
|
+
},
|
|
28108
|
+
"connectionTest.testSettings": {
|
|
28109
|
+
capName: "connection-test",
|
|
28110
|
+
capScope: "system",
|
|
28111
|
+
addonId: null,
|
|
28112
|
+
access: "create"
|
|
28113
|
+
},
|
|
27491
28114
|
"consumables.reset": {
|
|
27492
28115
|
capName: "consumables",
|
|
27493
28116
|
capScope: "device",
|
|
@@ -27878,6 +28501,12 @@ Object.freeze({
|
|
|
27878
28501
|
addonId: null,
|
|
27879
28502
|
access: "create"
|
|
27880
28503
|
},
|
|
28504
|
+
"deviceManager.adoptionCancelJob": {
|
|
28505
|
+
capName: "device-manager",
|
|
28506
|
+
capScope: "system",
|
|
28507
|
+
addonId: null,
|
|
28508
|
+
access: "create"
|
|
28509
|
+
},
|
|
27881
28510
|
"deviceManager.adoptionListCandidateFilters": {
|
|
27882
28511
|
capName: "device-manager",
|
|
27883
28512
|
capScope: "system",
|
|
@@ -27890,6 +28519,12 @@ Object.freeze({
|
|
|
27890
28519
|
addonId: null,
|
|
27891
28520
|
access: "view"
|
|
27892
28521
|
},
|
|
28522
|
+
"deviceManager.adoptionListJobs": {
|
|
28523
|
+
capName: "device-manager",
|
|
28524
|
+
capScope: "system",
|
|
28525
|
+
addonId: null,
|
|
28526
|
+
access: "view"
|
|
28527
|
+
},
|
|
27893
28528
|
"deviceManager.adoptionRefresh": {
|
|
27894
28529
|
capName: "device-manager",
|
|
27895
28530
|
capScope: "system",
|
|
@@ -27908,6 +28543,12 @@ Object.freeze({
|
|
|
27908
28543
|
addonId: null,
|
|
27909
28544
|
access: "create"
|
|
27910
28545
|
},
|
|
28546
|
+
"deviceManager.adoptionStartJob": {
|
|
28547
|
+
capName: "device-manager",
|
|
28548
|
+
capScope: "system",
|
|
28549
|
+
addonId: null,
|
|
28550
|
+
access: "create"
|
|
28551
|
+
},
|
|
27911
28552
|
"deviceManager.allocateDeviceId": {
|
|
27912
28553
|
capName: "device-manager",
|
|
27913
28554
|
capScope: "system",
|
|
@@ -31046,6 +31687,12 @@ Object.freeze({
|
|
|
31046
31687
|
addonId: null,
|
|
31047
31688
|
access: "view"
|
|
31048
31689
|
},
|
|
31690
|
+
"snapshot.getSnapshotLinks": {
|
|
31691
|
+
capName: "snapshot",
|
|
31692
|
+
capScope: "device",
|
|
31693
|
+
addonId: null,
|
|
31694
|
+
access: "view"
|
|
31695
|
+
},
|
|
31049
31696
|
"snapshot.getSnapshotOverview": {
|
|
31050
31697
|
capName: "snapshot",
|
|
31051
31698
|
capScope: "device",
|
|
@@ -56796,8 +57443,9 @@ var HmChildDevice = class extends BaseDevice {
|
|
|
56796
57443
|
}
|
|
56797
57444
|
/**
|
|
56798
57445
|
* Attach a facade `valueChanged` listener filtered to this child's device
|
|
56799
|
-
* address
|
|
56800
|
-
*
|
|
57446
|
+
* address AND — when the child owns one — to its channel. The
|
|
57447
|
+
* subclass-supplied `handle` runs only for matching events. No-ops when no
|
|
57448
|
+
* facade is present (the child reconnects on next reconcile).
|
|
56801
57449
|
*/
|
|
56802
57450
|
attachValueChangedListener(handle) {
|
|
56803
57451
|
this.valueHandler = handle;
|
|
@@ -56805,12 +57453,41 @@ var HmChildDevice = class extends BaseDevice {
|
|
|
56805
57453
|
if (!facade) return;
|
|
56806
57454
|
this.valueChangedUnsub = facade.on("valueChanged", (event) => {
|
|
56807
57455
|
if (event.device !== this.address) return;
|
|
57456
|
+
if (!this.ownsEventChannel(event)) return;
|
|
56808
57457
|
handle(event);
|
|
56809
57458
|
});
|
|
56810
57459
|
this.seedTriggerUnsubs = [facade.on("ready", () => this.seedCurrentValues()), facade.on("connection", () => this.seedCurrentValues())];
|
|
56811
57460
|
this.seedCurrentValues();
|
|
56812
57461
|
}
|
|
56813
57462
|
/**
|
|
57463
|
+
* Whether an inbound value event belongs to THIS child's channel.
|
|
57464
|
+
*
|
|
57465
|
+
* One CCU device address can host SEVERAL independent functional entities —
|
|
57466
|
+
* an HmIP-BS2 carries two unrelated relays, on `<addr>:4` and `<addr>:8`, and
|
|
57467
|
+
* BOTH emit `STATE`. Filtering on the device address alone let either relay's
|
|
57468
|
+
* event write the other's slice: actuating one made both report ON while only
|
|
57469
|
+
* one was physically on. The seed path (`seedCurrentValues`) has always been
|
|
57470
|
+
* channel-scoped; this makes the live path agree with it.
|
|
57471
|
+
*
|
|
57472
|
+
* Channel-less children (event-emitter, firmware update) span the whole
|
|
57473
|
+
* device by design and accept every channel.
|
|
57474
|
+
*/
|
|
57475
|
+
ownsEventChannel(event) {
|
|
57476
|
+
const owned = this.channel;
|
|
57477
|
+
if (owned === void 0) return true;
|
|
57478
|
+
const channel = event.channel;
|
|
57479
|
+
if (typeof channel === "string" && channel.length > 0) return channel === owned;
|
|
57480
|
+
this.ctx.logger.warn("Homematic value event without a channel — dropped", {
|
|
57481
|
+
tags: { deviceId: this.ctx.id },
|
|
57482
|
+
meta: {
|
|
57483
|
+
address: this.address,
|
|
57484
|
+
ownedChannel: owned,
|
|
57485
|
+
parameter: event.parameter
|
|
57486
|
+
}
|
|
57487
|
+
});
|
|
57488
|
+
return false;
|
|
57489
|
+
}
|
|
57490
|
+
/**
|
|
56814
57491
|
* Replay this child's CURRENT cached datapoint values through the registered
|
|
56815
57492
|
* value handler. Reads the live `HmDevice` snapshot (the library backfills the
|
|
56816
57493
|
* seeded values onto its datapoints) and feeds each non-null value on THIS
|