@camstack/addon-provider-amcrest 0.2.10 → 0.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 +2918 -1382
- package/dist/addon.mjs +2918 -1382
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -4,7 +4,7 @@ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).expor
|
|
|
4
4
|
//#endregion
|
|
5
5
|
let node_crypto = require("node:crypto");
|
|
6
6
|
let node_os = require("node:os");
|
|
7
|
-
//#region ../types/dist/event-category-
|
|
7
|
+
//#region ../types/dist/event-category-Cv9dO26A.mjs
|
|
8
8
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
9
9
|
EventCategory["SystemBoot"] = "system.boot";
|
|
10
10
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -20,6 +20,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
20
20
|
*/
|
|
21
21
|
EventCategory["SystemRestartCompleted"] = "system.restart-completed";
|
|
22
22
|
/**
|
|
23
|
+
* A newer addon or server-root package version was found by the
|
|
24
|
+
* authoritative registry check. Emitted once per
|
|
25
|
+
* `(target, packageName, currentVersion, latestVersion)` transition; repeated
|
|
26
|
+
* polling of the same result is deduplicated by the checker.
|
|
27
|
+
*/
|
|
28
|
+
EventCategory["UpdateAvailable"] = "update.available";
|
|
29
|
+
/**
|
|
23
30
|
* Readiness transition for a capability provider. Every producer emits
|
|
24
31
|
* this event on `onInitialize` completion, `onDestroy`, and
|
|
25
32
|
* `$node.reconnect`; every consumer that needs to gate on a cross-process
|
|
@@ -6466,7 +6473,20 @@ var BrokerStatsSchema = object({
|
|
|
6466
6473
|
sampleRate: number(),
|
|
6467
6474
|
channels: number(),
|
|
6468
6475
|
supported: boolean()
|
|
6469
|
-
}).nullable().optional()
|
|
6476
|
+
}).nullable().optional(),
|
|
6477
|
+
/**
|
|
6478
|
+
* BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
|
|
6479
|
+
* distributing none of the device's audio, on live or recording.
|
|
6480
|
+
*
|
|
6481
|
+
* Present so a silent camera can be told apart from a broken one on the
|
|
6482
|
+
* stream panel itself, without cross-referencing the switch group: a
|
|
6483
|
+
* broker holding an `audio` track descriptor while `audioMuted` is true is
|
|
6484
|
+
* working exactly as asked. `audioMutedDropped` counts the audio units
|
|
6485
|
+
* thrown away since the current dial — it is how you confirm from stats
|
|
6486
|
+
* alone that the mute is on the packet path and not merely persisted.
|
|
6487
|
+
*/
|
|
6488
|
+
audioMuted: boolean().optional(),
|
|
6489
|
+
audioMutedDropped: number().optional()
|
|
6470
6490
|
});
|
|
6471
6491
|
/**
|
|
6472
6492
|
* Exporter-facing "profile restream" entry. Returned by
|
|
@@ -7080,6 +7100,104 @@ object({
|
|
|
7080
7100
|
})
|
|
7081
7101
|
});
|
|
7082
7102
|
/**
|
|
7103
|
+
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7104
|
+
*
|
|
7105
|
+
* ## Why this exists
|
|
7106
|
+
*
|
|
7107
|
+
* `adopt({childNativeIds: [...]})` materialises one CamStack device per
|
|
7108
|
+
* candidate PLUS every accessory child, and the whole array shares ONE UDS
|
|
7109
|
+
* request deadline (60s). Measured on the live hub against Home Assistant:
|
|
7110
|
+
* each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
|
|
7111
|
+
* meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
|
|
7112
|
+
* `setRole`, … `persistConfig`) before the class is constructed — and an
|
|
7113
|
+
* accessory child costs the same as its parent. So the real unit of work is
|
|
7114
|
+
* the CHILD, not the candidate:
|
|
7115
|
+
*
|
|
7116
|
+
* - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
|
|
7117
|
+
* - ONE candidate with 217 children → ~217 devices → **>60s, times out**
|
|
7118
|
+
*
|
|
7119
|
+
* That second line is why this is a job and not a smaller batch. No chunking,
|
|
7120
|
+
* no bounded concurrency over candidates and no per-call tuning can fix a
|
|
7121
|
+
* shape where **N=1 already exceeds the deadline** — the count that blows the
|
|
7122
|
+
* budget is the source system's accessory fan-out, which the operator does not
|
|
7123
|
+
* choose and cannot see. A design that only works below some N is the same bug
|
|
7124
|
+
* deferred.
|
|
7125
|
+
*
|
|
7126
|
+
* ## What the timeout did NOT do
|
|
7127
|
+
*
|
|
7128
|
+
* It did not stop the work. The UDS deadline ends the CALLER's wait; the
|
|
7129
|
+
* provider's loop runs to completion. Measured: a 25-candidate adopt that
|
|
7130
|
+
* "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
|
|
7131
|
+
* an error and had no way to learn that. Every field below exists so that
|
|
7132
|
+
* question has an answer.
|
|
7133
|
+
*
|
|
7134
|
+
* ## Idempotency
|
|
7135
|
+
*
|
|
7136
|
+
* Jobs are in-RAM; a restart forgets them. That is safe here because adoption
|
|
7137
|
+
* is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
|
|
7138
|
+
* re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
|
|
7139
|
+
* by the engine before any provider call and lands in `alreadyAdopted`. It is
|
|
7140
|
+
* never a duplicate device, and never an error the operator has to interpret.
|
|
7141
|
+
*/
|
|
7142
|
+
var AdoptionJobStateSchema = _enum([
|
|
7143
|
+
"running",
|
|
7144
|
+
"done",
|
|
7145
|
+
"failed",
|
|
7146
|
+
"cancelled"
|
|
7147
|
+
]);
|
|
7148
|
+
/**
|
|
7149
|
+
* Per-candidate result. Every candidate the job was asked to adopt ends in
|
|
7150
|
+
* exactly one of these buckets — there is no silent drop, and the operator can
|
|
7151
|
+
* always answer "which of my 25 landed?".
|
|
7152
|
+
*
|
|
7153
|
+
* - `adopted` — created now by this job.
|
|
7154
|
+
* - `already-adopted` — a device for this candidate existed before the job
|
|
7155
|
+
* reached it (a re-run, or a retry after a timeout). Not an error.
|
|
7156
|
+
* - `failed` — the provider threw; `error` carries the message.
|
|
7157
|
+
* - `cancelled` — the operator cancelled before this candidate was reached.
|
|
7158
|
+
*/
|
|
7159
|
+
var AdoptionOutcomeSchema = _enum([
|
|
7160
|
+
"adopted",
|
|
7161
|
+
"already-adopted",
|
|
7162
|
+
"failed",
|
|
7163
|
+
"cancelled"
|
|
7164
|
+
]);
|
|
7165
|
+
var AdoptionCandidateResultSchema = object({
|
|
7166
|
+
childNativeId: string(),
|
|
7167
|
+
outcome: AdoptionOutcomeSchema,
|
|
7168
|
+
/** The materialised parent device id — null for `failed` / `cancelled`. */
|
|
7169
|
+
parentDeviceId: number().int().nonnegative().nullable(),
|
|
7170
|
+
/** Accessory children created for this candidate. */
|
|
7171
|
+
accessoryCount: number().int().nonnegative(),
|
|
7172
|
+
/** Failure message; null unless `outcome === 'failed'`. */
|
|
7173
|
+
error: string().nullable()
|
|
7174
|
+
});
|
|
7175
|
+
var AdoptionJobSchema = object({
|
|
7176
|
+
jobId: string(),
|
|
7177
|
+
/** The integration provider this job adopts through (the `addonId` pin). */
|
|
7178
|
+
addonId: string(),
|
|
7179
|
+
integrationId: string(),
|
|
7180
|
+
state: AdoptionJobStateSchema,
|
|
7181
|
+
/** Candidates the job was asked to adopt. Known up front, so never null. */
|
|
7182
|
+
total: number().int().nonnegative(),
|
|
7183
|
+
/** Candidates that have reached a terminal bucket. */
|
|
7184
|
+
processed: number().int().nonnegative(),
|
|
7185
|
+
adopted: number().int().nonnegative(),
|
|
7186
|
+
alreadyAdopted: number().int().nonnegative(),
|
|
7187
|
+
failed: number().int().nonnegative(),
|
|
7188
|
+
/** Accessory child devices created across every candidate — the real unit
|
|
7189
|
+
* of work, surfaced so a slow job is legible rather than mysterious. */
|
|
7190
|
+
accessoriesCreated: number().int().nonnegative(),
|
|
7191
|
+
/** The candidate currently being adopted; null when idle or finished. */
|
|
7192
|
+
currentChildNativeId: string().nullable(),
|
|
7193
|
+
/** One entry per candidate, in the order they were processed. */
|
|
7194
|
+
results: array(AdoptionCandidateResultSchema).readonly(),
|
|
7195
|
+
startedAt: number(),
|
|
7196
|
+
finishedAt: number().nullable(),
|
|
7197
|
+
/** Set only when the job itself broke (not a per-candidate failure). */
|
|
7198
|
+
error: string().nullable()
|
|
7199
|
+
});
|
|
7200
|
+
/**
|
|
7083
7201
|
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7084
7202
|
* pipeline functions an operator thinks in terms of.
|
|
7085
7203
|
*
|
|
@@ -7099,6 +7217,19 @@ object({
|
|
|
7099
7217
|
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7100
7218
|
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
7101
7219
|
* | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
|
|
7220
|
+
* | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
|
|
7221
|
+
*
|
|
7222
|
+
* ## `device-audio` and `broker-audio` are two functions, not two knobs
|
|
7223
|
+
*
|
|
7224
|
+
* They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
|
|
7225
|
+
* `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
|
|
7226
|
+
* genuinely stops, it survives CamStack entirely, and it costs a multi-second
|
|
7227
|
+
* encoder restart on every flip. `broker-audio` writes THIS server, so it is
|
|
7228
|
+
* instant, vendor-independent and reversible without touching the camera, and
|
|
7229
|
+
* a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
|
|
7230
|
+
* D62 forbids a second switch that *disagrees* with the first; these two
|
|
7231
|
+
* cannot disagree, because neither reads the other's store — the camera holds
|
|
7232
|
+
* one, the broker holds the other, and each reports its own fact.
|
|
7102
7233
|
*
|
|
7103
7234
|
* ## The two switches whose authority is not on this server
|
|
7104
7235
|
*
|
|
@@ -7160,6 +7291,7 @@ var CameraSwitchIdSchema = _enum([
|
|
|
7160
7291
|
"object-detection",
|
|
7161
7292
|
"privacy-mask",
|
|
7162
7293
|
"device-audio",
|
|
7294
|
+
"broker-audio",
|
|
7163
7295
|
"audio-analysis",
|
|
7164
7296
|
"recording",
|
|
7165
7297
|
"notifications"
|
|
@@ -7185,7 +7317,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7185
7317
|
object({
|
|
7186
7318
|
kind: literal("camera-mask"),
|
|
7187
7319
|
capName: string()
|
|
7188
|
-
})
|
|
7320
|
+
}),
|
|
7321
|
+
object({ kind: literal("broker-audio-mute") })
|
|
7189
7322
|
]);
|
|
7190
7323
|
/**
|
|
7191
7324
|
* Why a switch is not offered for this camera. Rendered instead of the
|
|
@@ -7884,408 +8017,1182 @@ var ConvertResultSchema = object({
|
|
|
7884
8017
|
})).readonly()
|
|
7885
8018
|
});
|
|
7886
8019
|
/**
|
|
7887
|
-
*
|
|
7888
|
-
*
|
|
7889
|
-
*
|
|
7890
|
-
* The provider iterates every `addon-pages-source` (collection) provider
|
|
7891
|
-
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
7892
|
-
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
7893
|
-
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
7894
|
-
* rebuilds without manual reload.
|
|
7895
|
-
*
|
|
7896
|
-
* The hub-local builtin `addon-pages-aggregator` (see
|
|
7897
|
-
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
7898
|
-
* provider. Splitting the public aggregator from the raw collection
|
|
7899
|
-
* keeps both ends in codegen — there's no hand-written
|
|
7900
|
-
* `addon-pages.router.ts` wrapper anymore.
|
|
8020
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
8021
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
8022
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
7901
8023
|
*/
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
}
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
8024
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
8025
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
8026
|
+
var ExpressionParseError = class extends Error {
|
|
8027
|
+
position;
|
|
8028
|
+
constructor(message, position) {
|
|
8029
|
+
super(message);
|
|
8030
|
+
this.name = "ExpressionParseError";
|
|
8031
|
+
this.position = position;
|
|
8032
|
+
}
|
|
8033
|
+
};
|
|
8034
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
8035
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
8036
|
+
var ExpressionEvalError = class extends Error {
|
|
8037
|
+
constructor(message) {
|
|
8038
|
+
super(message);
|
|
8039
|
+
this.name = "ExpressionEvalError";
|
|
8040
|
+
}
|
|
8041
|
+
};
|
|
7918
8042
|
/**
|
|
7919
|
-
*
|
|
7920
|
-
*
|
|
7921
|
-
*
|
|
7922
|
-
*
|
|
7923
|
-
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
7924
|
-
* that admin-ui consumes.
|
|
8043
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
8044
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
8045
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
8046
|
+
* own-property check against it.
|
|
7925
8047
|
*
|
|
7926
|
-
*
|
|
7927
|
-
*
|
|
7928
|
-
*
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
* Module Federation remote name — must match the `name` field on the
|
|
7937
|
-
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
7938
|
-
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
7939
|
-
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
7940
|
-
* contain hyphens).
|
|
7941
|
-
*/
|
|
7942
|
-
remoteName: string(),
|
|
7943
|
-
/**
|
|
7944
|
-
* Bundle filename inside the addon's `dist/` dir served at
|
|
7945
|
-
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
7946
|
-
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
7947
|
-
* the static-file route can compute an mtime-based cache-buster URL
|
|
7948
|
-
* without a separate filesystem stat.
|
|
7949
|
-
*/
|
|
7950
|
-
bundle: string(),
|
|
7951
|
-
/**
|
|
7952
|
-
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
7953
|
-
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
7954
|
-
* Any OTHER string creates (or joins) a custom section rendered after
|
|
7955
|
-
* the built-in groups; its label comes from `sectionLabel` (first
|
|
7956
|
-
* declaration wins), falling back to the id. Absent → the legacy
|
|
7957
|
-
* "Addon Pages" group.
|
|
7958
|
-
*/
|
|
7959
|
-
section: string().optional(),
|
|
7960
|
-
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
7961
|
-
sectionLabel: string().optional()
|
|
7962
|
-
});
|
|
7963
|
-
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
7964
|
-
var AddonHttpRouteSchema = object({
|
|
7965
|
-
method: _enum([
|
|
7966
|
-
"GET",
|
|
7967
|
-
"POST",
|
|
7968
|
-
"PUT",
|
|
7969
|
-
"DELETE",
|
|
7970
|
-
"PATCH"
|
|
7971
|
-
]),
|
|
7972
|
-
path: string(),
|
|
7973
|
-
access: _enum([
|
|
7974
|
-
"public",
|
|
7975
|
-
"authenticated",
|
|
7976
|
-
"admin"
|
|
7977
|
-
]).optional(),
|
|
7978
|
-
description: string().optional()
|
|
7979
|
-
});
|
|
7980
|
-
/**
|
|
7981
|
-
* Cross-process route invocation envelope. The hub captures the
|
|
7982
|
-
* request as plain data, ships it to the worker via Moleculer, and
|
|
7983
|
-
* the worker runs the local handler against a capturing reply. The
|
|
7984
|
-
* envelope returned describes what the handler intended (status,
|
|
7985
|
-
* headers, body, or a redirect) so the hub can translate it back to
|
|
7986
|
-
* the Fastify reply that's actually wired to the socket.
|
|
8048
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
8049
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
8050
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
8051
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
8052
|
+
* callable — they are simply "unknown function" at parse time.
|
|
8053
|
+
*
|
|
8054
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
8055
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
8056
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
8057
|
+
* closed rather than emitting a garbage value.
|
|
7987
8058
|
*/
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
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
|
-
|
|
8059
|
+
function asFiniteNumber(value, name, index) {
|
|
8060
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
8061
|
+
return value;
|
|
8062
|
+
}
|
|
8063
|
+
function asString$1(value, name, index) {
|
|
8064
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
8065
|
+
return value;
|
|
8066
|
+
}
|
|
8067
|
+
function finiteResult(value, name) {
|
|
8068
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
8069
|
+
return value;
|
|
8070
|
+
}
|
|
8071
|
+
function allFiniteNumbers(args, name) {
|
|
8072
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
8073
|
+
}
|
|
8074
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
8075
|
+
var table = {
|
|
8076
|
+
min: {
|
|
8077
|
+
minArgs: 1,
|
|
8078
|
+
maxArgs: INF,
|
|
8079
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
8080
|
+
},
|
|
8081
|
+
max: {
|
|
8082
|
+
minArgs: 1,
|
|
8083
|
+
maxArgs: INF,
|
|
8084
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
8085
|
+
},
|
|
8086
|
+
abs: {
|
|
8087
|
+
minArgs: 1,
|
|
8088
|
+
maxArgs: 1,
|
|
8089
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
8090
|
+
},
|
|
8091
|
+
floor: {
|
|
8092
|
+
minArgs: 1,
|
|
8093
|
+
maxArgs: 1,
|
|
8094
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
8095
|
+
},
|
|
8096
|
+
ceil: {
|
|
8097
|
+
minArgs: 1,
|
|
8098
|
+
maxArgs: 1,
|
|
8099
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
8100
|
+
},
|
|
8101
|
+
sqrt: {
|
|
8102
|
+
minArgs: 1,
|
|
8103
|
+
maxArgs: 1,
|
|
8104
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
8105
|
+
},
|
|
8106
|
+
round: {
|
|
8107
|
+
minArgs: 1,
|
|
8108
|
+
maxArgs: 2,
|
|
8109
|
+
apply: (args) => {
|
|
8110
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
8111
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
8112
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
8113
|
+
const factor = 10 ** digits;
|
|
8114
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
8115
|
+
}
|
|
8116
|
+
},
|
|
8117
|
+
pow: {
|
|
8118
|
+
minArgs: 2,
|
|
8119
|
+
maxArgs: 2,
|
|
8120
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
8121
|
+
},
|
|
8122
|
+
clamp: {
|
|
8123
|
+
minArgs: 3,
|
|
8124
|
+
maxArgs: 3,
|
|
8125
|
+
apply: (args) => {
|
|
8126
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
8127
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
8128
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
8129
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
8130
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
8131
|
+
}
|
|
8132
|
+
},
|
|
8133
|
+
avg: {
|
|
8134
|
+
minArgs: 1,
|
|
8135
|
+
maxArgs: INF,
|
|
8136
|
+
apply: (args) => {
|
|
8137
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
8138
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
8139
|
+
}
|
|
8140
|
+
},
|
|
8141
|
+
sum: {
|
|
8142
|
+
minArgs: 1,
|
|
8143
|
+
maxArgs: INF,
|
|
8144
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
8145
|
+
},
|
|
8146
|
+
coalesce: {
|
|
8147
|
+
minArgs: 1,
|
|
8148
|
+
maxArgs: INF,
|
|
8149
|
+
apply: (args) => {
|
|
8150
|
+
for (const a of args) if (a !== null) return a;
|
|
8151
|
+
return null;
|
|
8152
|
+
}
|
|
8153
|
+
},
|
|
8154
|
+
age: {
|
|
8155
|
+
minArgs: 2,
|
|
8156
|
+
maxArgs: 2,
|
|
8157
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
8158
|
+
},
|
|
8159
|
+
convert: {
|
|
8160
|
+
minArgs: 3,
|
|
8161
|
+
maxArgs: 3,
|
|
8162
|
+
apply: (args, hooks) => {
|
|
8163
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
8164
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
8165
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
8166
|
+
if (hooks.convert) {
|
|
8167
|
+
const out = hooks.convert(x, from, to);
|
|
8168
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
8169
|
+
return finiteResult(out, "convert");
|
|
8170
|
+
}
|
|
8171
|
+
if (from === to) return x;
|
|
8172
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
8173
|
+
}
|
|
8174
|
+
}
|
|
8175
|
+
};
|
|
8176
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
8177
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
8178
|
+
* callees at parse time (immediate author feedback). */
|
|
8179
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
8071
8180
|
/**
|
|
8072
|
-
*
|
|
8073
|
-
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
8074
|
-
* widgets registers a provider on this collection cap; the hub-local
|
|
8075
|
-
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
8076
|
-
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
8077
|
-
* exposes the public listing surface that admin-ui consumes.
|
|
8078
|
-
*
|
|
8079
|
-
* The split exists because the public listing has a different output
|
|
8080
|
-
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
8081
|
-
* per-provider raw declarations. Both ends flow through codegen.
|
|
8181
|
+
* Resource-bound constants for the safe expression engine.
|
|
8082
8182
|
*
|
|
8083
|
-
*
|
|
8084
|
-
*
|
|
8085
|
-
*
|
|
8086
|
-
*
|
|
8087
|
-
*
|
|
8088
|
-
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
8089
|
-
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
8183
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
8184
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
8185
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
8186
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
8187
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
8090
8188
|
*/
|
|
8091
|
-
/**
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8099
|
-
"
|
|
8100
|
-
"
|
|
8101
|
-
"
|
|
8102
|
-
"
|
|
8189
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
8190
|
+
* rejected without allocation. */
|
|
8191
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
8192
|
+
/** A legal binding / identifier name. */
|
|
8193
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8194
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
8195
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
8196
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
8197
|
+
"now",
|
|
8198
|
+
"true",
|
|
8199
|
+
"false",
|
|
8200
|
+
"null"
|
|
8103
8201
|
]);
|
|
8104
8202
|
/**
|
|
8105
|
-
*
|
|
8106
|
-
*
|
|
8107
|
-
*
|
|
8108
|
-
*
|
|
8109
|
-
* `
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
remoteName: string(),
|
|
8113
|
-
exposedModule: string(),
|
|
8114
|
-
componentKey: string().optional()
|
|
8115
|
-
});
|
|
8116
|
-
/**
|
|
8117
|
-
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
8118
|
-
* widget-only metadata. The `UiContribution` core fields:
|
|
8119
|
-
*
|
|
8120
|
-
* - `tab` — where the widget hosts. A widget that runs on the
|
|
8121
|
-
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
8122
|
-
* widget declares the target device-detail tab id.
|
|
8123
|
-
* - `subTab` — optional sub-tab within `tab`.
|
|
8124
|
-
* - `label` — operator-facing label.
|
|
8125
|
-
* - `order` — ordering within `(tab, subTab)`.
|
|
8126
|
-
* - `kind` — always `'remote'` for widgets.
|
|
8127
|
-
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
8128
|
-
*
|
|
8129
|
-
* Widget-only fields retained alongside the contribution core:
|
|
8130
|
-
*
|
|
8131
|
-
* - `stableId` — stable identity within the addon (the MF
|
|
8132
|
-
* `componentKey`; kept top-level so consumers have
|
|
8133
|
-
* a stable key without reaching into `remote`).
|
|
8134
|
-
* - `description` / `icon` — picker metadata.
|
|
8135
|
-
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
8136
|
-
* aggregator stamps a versioned `bundleUrl` from it.
|
|
8137
|
-
* - `hosts` — every host the widget supports (a widget can run
|
|
8138
|
-
* both on the dashboard and a device tab). `tab`
|
|
8139
|
-
* is the PRIMARY host; `hosts` is the full set the
|
|
8140
|
-
* picker filters on.
|
|
8141
|
-
* - `requires` — host-context requirements validated at mount.
|
|
8142
|
-
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
8143
|
-
* — dashboard placement hints.
|
|
8203
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
8204
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
8205
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
8206
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
8207
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
8208
|
+
* is a parse error with a source position, so member access / assignment /
|
|
8209
|
+
* template literals are lexically impossible.
|
|
8144
8210
|
*/
|
|
8145
|
-
var
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
8195
|
-
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8211
|
+
var KEYWORDS = new Set([
|
|
8212
|
+
"true",
|
|
8213
|
+
"false",
|
|
8214
|
+
"null"
|
|
8215
|
+
]);
|
|
8216
|
+
function isDigit(ch) {
|
|
8217
|
+
return ch >= "0" && ch <= "9";
|
|
8218
|
+
}
|
|
8219
|
+
function isIdentStart(ch) {
|
|
8220
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
8221
|
+
}
|
|
8222
|
+
function isIdentPart(ch) {
|
|
8223
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
8224
|
+
}
|
|
8225
|
+
function isWhitespace(ch) {
|
|
8226
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
8227
|
+
}
|
|
8228
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
8229
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
8230
|
+
* string. */
|
|
8231
|
+
function tokenize(source) {
|
|
8232
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
8233
|
+
const tokens = [];
|
|
8234
|
+
let i = 0;
|
|
8235
|
+
const n = source.length;
|
|
8236
|
+
while (i < n) {
|
|
8237
|
+
const ch = source[i];
|
|
8238
|
+
if (isWhitespace(ch)) {
|
|
8239
|
+
i += 1;
|
|
8240
|
+
continue;
|
|
8241
|
+
}
|
|
8242
|
+
if (isDigit(ch)) {
|
|
8243
|
+
const start = i;
|
|
8244
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
8245
|
+
if (i < n && source[i] === ".") {
|
|
8246
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
8247
|
+
i += 1;
|
|
8248
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
8249
|
+
}
|
|
8250
|
+
const text = source.slice(start, i);
|
|
8251
|
+
const value = Number(text);
|
|
8252
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
8253
|
+
tokens.push({
|
|
8254
|
+
type: "number",
|
|
8255
|
+
value,
|
|
8256
|
+
pos: start
|
|
8257
|
+
});
|
|
8258
|
+
continue;
|
|
8259
|
+
}
|
|
8260
|
+
if (ch === "'" || ch === "\"") {
|
|
8261
|
+
const quote = ch;
|
|
8262
|
+
const start = i;
|
|
8263
|
+
i += 1;
|
|
8264
|
+
let out = "";
|
|
8265
|
+
let closed = false;
|
|
8266
|
+
while (i < n) {
|
|
8267
|
+
const c = source[i];
|
|
8268
|
+
if (c === "\\") {
|
|
8269
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
8270
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
8271
|
+
out += next;
|
|
8272
|
+
i += 2;
|
|
8273
|
+
continue;
|
|
8274
|
+
}
|
|
8275
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
8276
|
+
}
|
|
8277
|
+
if (c === quote) {
|
|
8278
|
+
closed = true;
|
|
8279
|
+
i += 1;
|
|
8280
|
+
break;
|
|
8281
|
+
}
|
|
8282
|
+
out += c;
|
|
8283
|
+
i += 1;
|
|
8284
|
+
}
|
|
8285
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
8286
|
+
tokens.push({
|
|
8287
|
+
type: "string",
|
|
8288
|
+
value: out,
|
|
8289
|
+
pos: start
|
|
8290
|
+
});
|
|
8291
|
+
continue;
|
|
8292
|
+
}
|
|
8293
|
+
if (isIdentStart(ch)) {
|
|
8294
|
+
const start = i;
|
|
8295
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
8296
|
+
const text = source.slice(start, i);
|
|
8297
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
8298
|
+
type: "keyword",
|
|
8299
|
+
keyword: keywordOf(text),
|
|
8300
|
+
pos: start
|
|
8301
|
+
});
|
|
8302
|
+
else tokens.push({
|
|
8303
|
+
type: "identifier",
|
|
8304
|
+
name: text,
|
|
8305
|
+
pos: start
|
|
8306
|
+
});
|
|
8307
|
+
continue;
|
|
8308
|
+
}
|
|
8309
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
8310
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
8311
|
+
tokens.push({
|
|
8312
|
+
type: "punct",
|
|
8313
|
+
punct: two,
|
|
8314
|
+
pos: i
|
|
8315
|
+
});
|
|
8316
|
+
i += 2;
|
|
8317
|
+
continue;
|
|
8318
|
+
}
|
|
8319
|
+
if (isSinglePunct(ch)) {
|
|
8320
|
+
tokens.push({
|
|
8321
|
+
type: "punct",
|
|
8322
|
+
punct: ch,
|
|
8323
|
+
pos: i
|
|
8324
|
+
});
|
|
8325
|
+
i += 1;
|
|
8326
|
+
continue;
|
|
8327
|
+
}
|
|
8328
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
8329
|
+
}
|
|
8330
|
+
tokens.push({
|
|
8331
|
+
type: "eof",
|
|
8332
|
+
pos: n
|
|
8333
|
+
});
|
|
8334
|
+
return tokens;
|
|
8335
|
+
}
|
|
8336
|
+
function keywordOf(text) {
|
|
8337
|
+
if (text === "true") return "true";
|
|
8338
|
+
if (text === "false") return "false";
|
|
8339
|
+
return "null";
|
|
8340
|
+
}
|
|
8341
|
+
function isSinglePunct(ch) {
|
|
8342
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
8343
|
+
}
|
|
8199
8344
|
/**
|
|
8200
|
-
*
|
|
8201
|
-
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
8345
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
8202
8346
|
*
|
|
8203
|
-
*
|
|
8204
|
-
*
|
|
8205
|
-
* `
|
|
8206
|
-
*
|
|
8207
|
-
*
|
|
8208
|
-
*
|
|
8347
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
8348
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
8349
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
8350
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
8351
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
8352
|
+
* that references a since-removed builtin degrades at read.
|
|
8209
8353
|
*
|
|
8210
|
-
*
|
|
8211
|
-
*
|
|
8212
|
-
* provider. Splitting the public aggregator from the raw collection
|
|
8213
|
-
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
8354
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
8355
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
8214
8356
|
*/
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8357
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
8358
|
+
var BINARY_PRECEDENCE = {
|
|
8359
|
+
"||": 1,
|
|
8360
|
+
"&&": 2,
|
|
8361
|
+
"==": 3,
|
|
8362
|
+
"!=": 3,
|
|
8363
|
+
"<": 4,
|
|
8364
|
+
"<=": 4,
|
|
8365
|
+
">": 4,
|
|
8366
|
+
">=": 4,
|
|
8367
|
+
"+": 5,
|
|
8368
|
+
"-": 5,
|
|
8369
|
+
"*": 6,
|
|
8370
|
+
"/": 6,
|
|
8371
|
+
"%": 6
|
|
8372
|
+
};
|
|
8373
|
+
function isLogicalOp(op) {
|
|
8374
|
+
return op === "&&" || op === "||";
|
|
8375
|
+
}
|
|
8376
|
+
function isBinaryOp(op) {
|
|
8377
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
8378
|
+
}
|
|
8379
|
+
var Parser = class {
|
|
8380
|
+
tokens;
|
|
8381
|
+
pos = 0;
|
|
8382
|
+
nodeCount = 0;
|
|
8383
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
8384
|
+
callees = /* @__PURE__ */ new Set();
|
|
8385
|
+
constructor(tokens) {
|
|
8386
|
+
this.tokens = tokens;
|
|
8387
|
+
}
|
|
8388
|
+
parse() {
|
|
8389
|
+
const ast = this.parseTernary();
|
|
8390
|
+
const tok = this.peek();
|
|
8391
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
8392
|
+
return {
|
|
8393
|
+
ast,
|
|
8394
|
+
identifiers: this.identifiers,
|
|
8395
|
+
callees: this.callees,
|
|
8396
|
+
nodeCount: this.nodeCount
|
|
8397
|
+
};
|
|
8398
|
+
}
|
|
8399
|
+
peek() {
|
|
8400
|
+
return this.tokens[this.pos];
|
|
8401
|
+
}
|
|
8402
|
+
next() {
|
|
8403
|
+
return this.tokens[this.pos++];
|
|
8404
|
+
}
|
|
8405
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
8406
|
+
expectPunct(punct) {
|
|
8407
|
+
const tok = this.peek();
|
|
8408
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
8409
|
+
this.pos += 1;
|
|
8410
|
+
}
|
|
8411
|
+
matchPunct(punct) {
|
|
8412
|
+
const tok = this.peek();
|
|
8413
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
8414
|
+
this.pos += 1;
|
|
8415
|
+
return true;
|
|
8416
|
+
}
|
|
8417
|
+
return false;
|
|
8418
|
+
}
|
|
8419
|
+
countNode() {
|
|
8420
|
+
this.nodeCount += 1;
|
|
8421
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
8422
|
+
}
|
|
8423
|
+
parseTernary() {
|
|
8424
|
+
const test = this.parseBinary(1);
|
|
8425
|
+
if (this.matchPunct("?")) {
|
|
8426
|
+
const consequent = this.parseTernary();
|
|
8427
|
+
this.expectPunct(":");
|
|
8428
|
+
const alternate = this.parseTernary();
|
|
8429
|
+
this.countNode();
|
|
8430
|
+
return {
|
|
8431
|
+
kind: "conditional",
|
|
8432
|
+
test,
|
|
8433
|
+
consequent,
|
|
8434
|
+
alternate
|
|
8435
|
+
};
|
|
8436
|
+
}
|
|
8437
|
+
return test;
|
|
8438
|
+
}
|
|
8439
|
+
parseBinary(minPrec) {
|
|
8440
|
+
let left = this.parseUnary();
|
|
8441
|
+
for (;;) {
|
|
8442
|
+
const tok = this.peek();
|
|
8443
|
+
if (tok.type !== "punct") break;
|
|
8444
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
8445
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
8446
|
+
const op = tok.punct;
|
|
8447
|
+
this.pos += 1;
|
|
8448
|
+
const right = this.parseBinary(prec + 1);
|
|
8449
|
+
this.countNode();
|
|
8450
|
+
if (isLogicalOp(op)) left = {
|
|
8451
|
+
kind: "logical",
|
|
8452
|
+
op,
|
|
8453
|
+
left,
|
|
8454
|
+
right
|
|
8455
|
+
};
|
|
8456
|
+
else if (isBinaryOp(op)) left = {
|
|
8457
|
+
kind: "binary",
|
|
8458
|
+
op,
|
|
8459
|
+
left,
|
|
8460
|
+
right
|
|
8461
|
+
};
|
|
8462
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
8463
|
+
}
|
|
8464
|
+
return left;
|
|
8465
|
+
}
|
|
8466
|
+
parseUnary() {
|
|
8467
|
+
const tok = this.peek();
|
|
8468
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
8469
|
+
const op = tok.punct;
|
|
8470
|
+
this.pos += 1;
|
|
8471
|
+
const operand = this.parseUnary();
|
|
8472
|
+
this.countNode();
|
|
8473
|
+
return {
|
|
8474
|
+
kind: "unary",
|
|
8475
|
+
op,
|
|
8476
|
+
operand
|
|
8477
|
+
};
|
|
8478
|
+
}
|
|
8479
|
+
return this.parsePrimary();
|
|
8480
|
+
}
|
|
8481
|
+
parsePrimary() {
|
|
8482
|
+
const tok = this.next();
|
|
8483
|
+
switch (tok.type) {
|
|
8484
|
+
case "number":
|
|
8485
|
+
this.countNode();
|
|
8486
|
+
return {
|
|
8487
|
+
kind: "literal",
|
|
8488
|
+
value: tok.value
|
|
8489
|
+
};
|
|
8490
|
+
case "string":
|
|
8491
|
+
this.countNode();
|
|
8492
|
+
return {
|
|
8493
|
+
kind: "literal",
|
|
8494
|
+
value: tok.value
|
|
8495
|
+
};
|
|
8496
|
+
case "keyword":
|
|
8497
|
+
this.countNode();
|
|
8498
|
+
return {
|
|
8499
|
+
kind: "literal",
|
|
8500
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
8501
|
+
};
|
|
8502
|
+
case "identifier": {
|
|
8503
|
+
const nextTok = this.peek();
|
|
8504
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
8505
|
+
this.identifiers.add(tok.name);
|
|
8506
|
+
this.countNode();
|
|
8507
|
+
return {
|
|
8508
|
+
kind: "identifier",
|
|
8509
|
+
name: tok.name
|
|
8510
|
+
};
|
|
8511
|
+
}
|
|
8512
|
+
case "punct":
|
|
8513
|
+
if (tok.punct === "(") {
|
|
8514
|
+
const inner = this.parseTernary();
|
|
8515
|
+
this.expectPunct(")");
|
|
8516
|
+
return inner;
|
|
8517
|
+
}
|
|
8518
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
8519
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8522
|
+
parseCall(callee, pos) {
|
|
8523
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
8524
|
+
this.expectPunct("(");
|
|
8525
|
+
const args = [];
|
|
8526
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
8527
|
+
args.push(this.parseTernary());
|
|
8528
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
8529
|
+
if (this.matchPunct(",")) continue;
|
|
8530
|
+
this.expectPunct(")");
|
|
8531
|
+
break;
|
|
8532
|
+
}
|
|
8533
|
+
this.callees.add(callee);
|
|
8534
|
+
this.countNode();
|
|
8535
|
+
return {
|
|
8536
|
+
kind: "call",
|
|
8537
|
+
callee,
|
|
8538
|
+
args
|
|
8539
|
+
};
|
|
8540
|
+
}
|
|
8541
|
+
};
|
|
8542
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
8543
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
8544
|
+
function parseExpression(source) {
|
|
8545
|
+
return new Parser(tokenize(source)).parse();
|
|
8546
|
+
}
|
|
8220
8547
|
/**
|
|
8221
|
-
*
|
|
8548
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
8549
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
8550
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
8551
|
+
* one per read on a hot resolve path.
|
|
8222
8552
|
*
|
|
8223
|
-
*
|
|
8224
|
-
*
|
|
8225
|
-
*
|
|
8553
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
8554
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
8555
|
+
* callers is safe and maximises hit rate.
|
|
8226
8556
|
*/
|
|
8227
|
-
var
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8557
|
+
var cache = /* @__PURE__ */ new Map();
|
|
8558
|
+
function getCached(source) {
|
|
8559
|
+
const hit = cache.get(source);
|
|
8560
|
+
if (hit !== void 0) {
|
|
8561
|
+
cache.delete(source);
|
|
8562
|
+
cache.set(source, hit);
|
|
8563
|
+
return hit;
|
|
8564
|
+
}
|
|
8565
|
+
let result;
|
|
8566
|
+
try {
|
|
8567
|
+
result = {
|
|
8568
|
+
ok: true,
|
|
8569
|
+
parsed: parseExpression(source)
|
|
8570
|
+
};
|
|
8571
|
+
} catch (err) {
|
|
8572
|
+
result = {
|
|
8573
|
+
ok: false,
|
|
8574
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
8575
|
+
};
|
|
8576
|
+
}
|
|
8577
|
+
cache.set(source, result);
|
|
8578
|
+
if (cache.size > 256) {
|
|
8579
|
+
const oldest = cache.keys().next().value;
|
|
8580
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
8581
|
+
}
|
|
8582
|
+
return result;
|
|
8583
|
+
}
|
|
8584
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
8585
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
8586
|
+
function compileExpressionSafe(source) {
|
|
8587
|
+
return getCached(source);
|
|
8588
|
+
}
|
|
8589
|
+
Object.freeze({});
|
|
8590
|
+
/**
|
|
8591
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
8592
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
8593
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
8594
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
8595
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
8596
|
+
*/
|
|
8597
|
+
function validateExpressionSource(src) {
|
|
8598
|
+
const names = Object.keys(src.bindings);
|
|
8599
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
8600
|
+
for (const name of names) {
|
|
8601
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
8602
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
8603
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
8604
|
+
}
|
|
8605
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
8606
|
+
if (!compiled.ok) return compiled.error;
|
|
8607
|
+
const bound = new Set(names);
|
|
8608
|
+
for (const id of compiled.parsed.identifiers) {
|
|
8609
|
+
if (id === "now") continue;
|
|
8610
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
8611
|
+
}
|
|
8612
|
+
return null;
|
|
8613
|
+
}
|
|
8614
|
+
var ExpressionBindingSourceSchema = union([
|
|
8615
|
+
object({
|
|
8616
|
+
kind: literal("field").optional(),
|
|
8617
|
+
sourceKey: string(),
|
|
8618
|
+
cap: string(),
|
|
8619
|
+
fieldPath: string()
|
|
8620
|
+
}),
|
|
8621
|
+
object({
|
|
8622
|
+
kind: literal("literal"),
|
|
8623
|
+
value: union([
|
|
8624
|
+
string(),
|
|
8625
|
+
number(),
|
|
8626
|
+
boolean(),
|
|
8627
|
+
_null()
|
|
8628
|
+
])
|
|
8629
|
+
}),
|
|
8630
|
+
object({
|
|
8631
|
+
kind: literal("global"),
|
|
8632
|
+
sourceStableId: string(),
|
|
8633
|
+
cap: string(),
|
|
8634
|
+
fieldPath: string()
|
|
8635
|
+
})
|
|
8239
8636
|
]);
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
})
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
status: AlertStatusSchema,
|
|
8251
|
-
progress: number().optional(),
|
|
8252
|
-
read: boolean(),
|
|
8253
|
-
createdAt: number(),
|
|
8254
|
-
updatedAt: number(),
|
|
8255
|
-
source: AlertSourceSchema.optional(),
|
|
8256
|
-
metadata: record(string(), unknown()).optional()
|
|
8637
|
+
object({
|
|
8638
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
8639
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
8640
|
+
}).superRefine((src, ctx) => {
|
|
8641
|
+
const err = validateExpressionSource(src);
|
|
8642
|
+
if (err !== null) ctx.addIssue({
|
|
8643
|
+
code: "custom",
|
|
8644
|
+
message: err,
|
|
8645
|
+
path: ["expr"]
|
|
8646
|
+
});
|
|
8257
8647
|
});
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8648
|
+
/** How a leaf compares a device field to a value. Derived from the field's
|
|
8649
|
+
* `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
|
|
8650
|
+
var AutomationConditionOperatorSchema = _enum([
|
|
8651
|
+
"eq",
|
|
8652
|
+
"ne",
|
|
8653
|
+
"gt",
|
|
8654
|
+
"gte",
|
|
8655
|
+
"lt",
|
|
8656
|
+
"lte",
|
|
8657
|
+
"contains",
|
|
8658
|
+
"in"
|
|
8659
|
+
]);
|
|
8660
|
+
var AutomationConditionLeafSchema = object({
|
|
8661
|
+
kind: literal("condition"),
|
|
8662
|
+
deviceId: number().int().nonnegative(),
|
|
8663
|
+
cap: string().min(1),
|
|
8664
|
+
fieldPath: string().min(1),
|
|
8665
|
+
operator: AutomationConditionOperatorSchema,
|
|
8666
|
+
value: union([
|
|
8667
|
+
string(),
|
|
8668
|
+
number(),
|
|
8669
|
+
boolean(),
|
|
8670
|
+
array(union([string(), number()]))
|
|
8671
|
+
])
|
|
8269
8672
|
});
|
|
8270
|
-
/** Shared Zod schemas used across detection capabilities. */
|
|
8271
8673
|
/**
|
|
8272
|
-
*
|
|
8273
|
-
*
|
|
8274
|
-
*
|
|
8275
|
-
*
|
|
8276
|
-
*
|
|
8674
|
+
* The expression leaf, declared as a plain object rather than an intersection
|
|
8675
|
+
* with {@link ExpressionSourceSchema}: a discriminated union has to be able to
|
|
8676
|
+
* read `kind` off each option, and an intersection hides it. The author-time
|
|
8677
|
+
* validation is the SAME function `ExpressionSourceSchema` runs, so the two
|
|
8678
|
+
* cannot drift — an expression that one accepts, the other accepts.
|
|
8277
8679
|
*/
|
|
8278
|
-
var
|
|
8279
|
-
"
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8680
|
+
var AutomationConditionExpressionSchema = object({
|
|
8681
|
+
kind: literal("expression"),
|
|
8682
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
8683
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
8684
|
+
}).superRefine((src, ctx) => {
|
|
8685
|
+
const err = validateExpressionSource(src);
|
|
8686
|
+
if (err !== null) ctx.addIssue({
|
|
8687
|
+
code: "custom",
|
|
8688
|
+
message: err,
|
|
8689
|
+
path: ["expr"]
|
|
8690
|
+
});
|
|
8691
|
+
});
|
|
8692
|
+
var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
|
|
8693
|
+
object({
|
|
8694
|
+
kind: literal("all"),
|
|
8695
|
+
children: array(AutomationConditionSchema)
|
|
8696
|
+
}),
|
|
8697
|
+
object({
|
|
8698
|
+
kind: literal("any"),
|
|
8699
|
+
children: array(AutomationConditionSchema)
|
|
8700
|
+
}),
|
|
8701
|
+
object({
|
|
8702
|
+
kind: literal("not"),
|
|
8703
|
+
child: AutomationConditionSchema
|
|
8704
|
+
}),
|
|
8705
|
+
AutomationConditionLeafSchema,
|
|
8706
|
+
AutomationConditionExpressionSchema
|
|
8707
|
+
]));
|
|
8708
|
+
/**
|
|
8709
|
+
* What starts a run.
|
|
8710
|
+
*
|
|
8711
|
+
* D8 compliance, and it is the reason `device-state` is not merely an event
|
|
8712
|
+
* subscription: the trigger evaluates against the **state mirror**, which is
|
|
8713
|
+
* reconciled, and an event only WAKES the evaluation. A dropped event therefore
|
|
8714
|
+
* DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
|
|
8715
|
+
* already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
|
|
8716
|
+
* not.
|
|
8717
|
+
*/
|
|
8718
|
+
var AutomationTriggerSchema = discriminatedUnion("kind", [
|
|
8719
|
+
object({
|
|
8720
|
+
kind: literal("device-state"),
|
|
8721
|
+
deviceId: number().int().nonnegative(),
|
|
8722
|
+
cap: string().min(1),
|
|
8723
|
+
fieldPath: string().min(1),
|
|
8724
|
+
/** Fire when the field takes this value. Omit to fire on any change. */
|
|
8725
|
+
becomes: union([
|
|
8726
|
+
string(),
|
|
8727
|
+
number(),
|
|
8728
|
+
boolean()
|
|
8729
|
+
]).optional(),
|
|
8730
|
+
/** Only on a CHANGE of value, not on every re-report. */
|
|
8731
|
+
edge: boolean().optional(),
|
|
8732
|
+
/** The condition must hold this long before the run starts. */
|
|
8733
|
+
forMs: number().int().min(0).max(864e5).optional(),
|
|
8734
|
+
/** Collapse a burst into one run. */
|
|
8735
|
+
debounceMs: number().int().min(0).max(6e5).optional()
|
|
8736
|
+
}),
|
|
8737
|
+
object({
|
|
8738
|
+
kind: literal("device-event"),
|
|
8739
|
+
/** An `EventCategory` value. */
|
|
8740
|
+
category: string().min(1),
|
|
8741
|
+
deviceId: number().int().nonnegative().optional()
|
|
8742
|
+
}),
|
|
8743
|
+
object({
|
|
8744
|
+
kind: literal("schedule"),
|
|
8745
|
+
cron: string().min(1).max(120)
|
|
8746
|
+
}),
|
|
8747
|
+
object({ kind: literal("manual") })
|
|
8748
|
+
]);
|
|
8749
|
+
/**
|
|
8750
|
+
* One action step.
|
|
8751
|
+
*
|
|
8752
|
+
* `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
|
|
8753
|
+
* identical so `NcRuleActionRunner` runs them unchanged — its device-scope
|
|
8754
|
+
* check, stop-at-first-failure and per-sequence throttle are the whole reason
|
|
8755
|
+
* to reuse it, and none of them are re-implemented here.
|
|
8756
|
+
*
|
|
8757
|
+
* **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
|
|
8758
|
+
* a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
|
|
8759
|
+
* documents its subject device as *"for the log tag, never for routing"*. So an
|
|
8760
|
+
* NC action can never target the device that triggered it — which is fine for
|
|
8761
|
+
* the NC (its rules already scope to a device) and fatal for an automation
|
|
8762
|
+
* ("sound the siren of the camera that saw the person"). `deviceId` therefore
|
|
8763
|
+
* also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
|
|
8764
|
+
* is called. The runner still receives a number and is untouched; the
|
|
8765
|
+
* resolution is the recipe's job, not the runner's.
|
|
8766
|
+
*/
|
|
8767
|
+
var AutomationActionSchema = discriminatedUnion("kind", [
|
|
8768
|
+
object({
|
|
8769
|
+
kind: literal("wait"),
|
|
8770
|
+
seconds: number().min(0).max(300)
|
|
8771
|
+
}),
|
|
8772
|
+
object({
|
|
8773
|
+
kind: literal("cap"),
|
|
8774
|
+
deviceId: union([number().int(), object({ $var: string().min(1) })]),
|
|
8775
|
+
cap: string().min(1),
|
|
8776
|
+
method: string().min(1),
|
|
8777
|
+
/** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
|
|
8778
|
+
* evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
|
|
8779
|
+
args: record(string(), unknown()).optional()
|
|
8780
|
+
}),
|
|
8781
|
+
object({
|
|
8782
|
+
kind: literal("code"),
|
|
8783
|
+
/** Compiled into the automation's OWN block by esbuild — not a third
|
|
8784
|
+
* runtime, not a `vm`, and not dynamically evaluated. */
|
|
8785
|
+
code: string().min(1).max(2e4)
|
|
8786
|
+
})
|
|
8787
|
+
]);
|
|
8788
|
+
object({
|
|
8789
|
+
triggers: array(AutomationTriggerSchema),
|
|
8790
|
+
conditions: AutomationConditionSchema.optional(),
|
|
8791
|
+
actions: array(AutomationActionSchema)
|
|
8792
|
+
});
|
|
8793
|
+
/**
|
|
8794
|
+
* `addon-pages` — system-scoped singleton aggregator cap. Public-facing
|
|
8795
|
+
* surface that admin-ui consumes through `useAddonPagesListPages()`.
|
|
8796
|
+
*
|
|
8797
|
+
* The provider iterates every `addon-pages-source` (collection) provider
|
|
8798
|
+
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
8799
|
+
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
8800
|
+
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
8801
|
+
* rebuilds without manual reload.
|
|
8802
|
+
*
|
|
8803
|
+
* The hub-local builtin `addon-pages-aggregator` (see
|
|
8804
|
+
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
8805
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
8806
|
+
* keeps both ends in codegen — there's no hand-written
|
|
8807
|
+
* `addon-pages.router.ts` wrapper anymore.
|
|
8808
|
+
*/
|
|
8809
|
+
var AddonPageDeclarationSchema$1 = object({
|
|
8810
|
+
id: string(),
|
|
8811
|
+
label: string(),
|
|
8812
|
+
icon: string(),
|
|
8813
|
+
path: string(),
|
|
8814
|
+
remoteName: string(),
|
|
8815
|
+
bundle: string(),
|
|
8816
|
+
section: string().optional(),
|
|
8817
|
+
sectionLabel: string().optional()
|
|
8818
|
+
});
|
|
8819
|
+
var AddonPageInfoSchema = object({
|
|
8820
|
+
addonId: string(),
|
|
8821
|
+
page: AddonPageDeclarationSchema$1,
|
|
8822
|
+
bundleUrl: string()
|
|
8823
|
+
});
|
|
8824
|
+
method(_void(), array(AddonPageInfoSchema).readonly());
|
|
8825
|
+
/**
|
|
8826
|
+
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
8827
|
+
* declarations. Every addon that contributes a UI page registers a
|
|
8828
|
+
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
8829
|
+
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
8830
|
+
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
8831
|
+
* that admin-ui consumes.
|
|
8832
|
+
*
|
|
8833
|
+
* The split exists because the public listing has a different output
|
|
8834
|
+
* shape than the per-provider raw declarations, and we want both ends
|
|
8835
|
+
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
8836
|
+
*/
|
|
8837
|
+
var AddonPageDeclarationSchema = object({
|
|
8838
|
+
id: string(),
|
|
8839
|
+
label: string(),
|
|
8840
|
+
icon: string(),
|
|
8841
|
+
path: string(),
|
|
8842
|
+
/**
|
|
8843
|
+
* Module Federation remote name — must match the `name` field on the
|
|
8844
|
+
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
8845
|
+
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
8846
|
+
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
8847
|
+
* contain hyphens).
|
|
8848
|
+
*/
|
|
8849
|
+
remoteName: string(),
|
|
8850
|
+
/**
|
|
8851
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
8852
|
+
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
8853
|
+
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
8854
|
+
* the static-file route can compute an mtime-based cache-buster URL
|
|
8855
|
+
* without a separate filesystem stat.
|
|
8856
|
+
*/
|
|
8857
|
+
bundle: string(),
|
|
8858
|
+
/**
|
|
8859
|
+
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
8860
|
+
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
8861
|
+
* Any OTHER string creates (or joins) a custom section rendered after
|
|
8862
|
+
* the built-in groups; its label comes from `sectionLabel` (first
|
|
8863
|
+
* declaration wins), falling back to the id. Absent → the legacy
|
|
8864
|
+
* "Addon Pages" group.
|
|
8865
|
+
*/
|
|
8866
|
+
section: string().optional(),
|
|
8867
|
+
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
8868
|
+
sectionLabel: string().optional()
|
|
8869
|
+
});
|
|
8870
|
+
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
8871
|
+
var AddonHttpRouteSchema = object({
|
|
8872
|
+
method: _enum([
|
|
8873
|
+
"GET",
|
|
8874
|
+
"POST",
|
|
8875
|
+
"PUT",
|
|
8876
|
+
"DELETE",
|
|
8877
|
+
"PATCH"
|
|
8878
|
+
]),
|
|
8879
|
+
path: string(),
|
|
8880
|
+
access: _enum([
|
|
8881
|
+
"public",
|
|
8882
|
+
"authenticated",
|
|
8883
|
+
"admin"
|
|
8884
|
+
]).optional(),
|
|
8885
|
+
description: string().optional()
|
|
8886
|
+
});
|
|
8887
|
+
/**
|
|
8888
|
+
* Cross-process route invocation envelope. The hub captures the
|
|
8889
|
+
* request as plain data, ships it to the worker via Moleculer, and
|
|
8890
|
+
* the worker runs the local handler against a capturing reply. The
|
|
8891
|
+
* envelope returned describes what the handler intended (status,
|
|
8892
|
+
* headers, body, or a redirect) so the hub can translate it back to
|
|
8893
|
+
* the Fastify reply that's actually wired to the socket.
|
|
8894
|
+
*/
|
|
8895
|
+
var InvokeRequestSchema = object({
|
|
8896
|
+
method: string(),
|
|
8897
|
+
path: string(),
|
|
8898
|
+
params: record(string(), string()),
|
|
8899
|
+
query: record(string(), string()),
|
|
8900
|
+
body: unknown(),
|
|
8901
|
+
headers: record(string(), string()),
|
|
8902
|
+
user: object({
|
|
8903
|
+
id: string(),
|
|
8904
|
+
username: string(),
|
|
8905
|
+
isAdmin: boolean()
|
|
8906
|
+
}).optional(),
|
|
8907
|
+
scopedToken: unknown().optional()
|
|
8908
|
+
});
|
|
8909
|
+
var InvokeReplyEnvelopeSchema = object({
|
|
8910
|
+
status: number().int(),
|
|
8911
|
+
headers: record(string(), string()),
|
|
8912
|
+
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
8913
|
+
* sending `body`. Status defaults to 302 when this is set unless
|
|
8914
|
+
* the handler called `reply.code(...)` explicitly. */
|
|
8915
|
+
redirectUrl: string().nullable(),
|
|
8916
|
+
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
8917
|
+
body: unknown().optional(),
|
|
8918
|
+
/** Set when the handler called `reply.type(mime)`. */
|
|
8919
|
+
contentType: string().optional()
|
|
8920
|
+
});
|
|
8921
|
+
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
8922
|
+
var ConfigTabDeclarationSchema = object({
|
|
8923
|
+
id: string(),
|
|
8924
|
+
label: string(),
|
|
8925
|
+
icon: string(),
|
|
8926
|
+
order: number().optional()
|
|
8927
|
+
});
|
|
8928
|
+
var ConfigSectionWithValuesSchema = object({
|
|
8929
|
+
id: string(),
|
|
8930
|
+
title: string(),
|
|
8931
|
+
description: string().optional(),
|
|
8932
|
+
style: _enum(["card", "accordion"]).optional(),
|
|
8933
|
+
defaultCollapsed: boolean().optional(),
|
|
8934
|
+
columns: union([
|
|
8935
|
+
literal(1),
|
|
8936
|
+
literal(2),
|
|
8937
|
+
literal(3),
|
|
8938
|
+
literal(4)
|
|
8939
|
+
]).optional(),
|
|
8940
|
+
tab: string().optional(),
|
|
8941
|
+
location: _enum(["settings", "top-tab"]).optional(),
|
|
8942
|
+
order: number().optional(),
|
|
8943
|
+
fields: array(any())
|
|
8944
|
+
});
|
|
8945
|
+
var SettingsSchemaWithValuesSchema = object({
|
|
8946
|
+
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
8947
|
+
sections: array(ConfigSectionWithValuesSchema)
|
|
8948
|
+
});
|
|
8949
|
+
/** Patch object — keys are field names, values are the new field values. */
|
|
8950
|
+
var SettingsPatchSchema = record(string(), unknown());
|
|
8951
|
+
/** Standard success response for update operations. */
|
|
8952
|
+
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
8953
|
+
method(object({
|
|
8954
|
+
addonId: string(),
|
|
8955
|
+
nodeId: string().optional(),
|
|
8956
|
+
overlay: record(string(), unknown()).optional(),
|
|
8957
|
+
cap: string().optional()
|
|
8958
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8959
|
+
addonId: string(),
|
|
8960
|
+
nodeId: string().optional(),
|
|
8961
|
+
patch: SettingsPatchSchema
|
|
8962
|
+
}), SettingsUpdateResultSchema, {
|
|
8963
|
+
kind: "mutation",
|
|
8964
|
+
auth: "admin"
|
|
8965
|
+
}), method(object({
|
|
8966
|
+
addonId: string(),
|
|
8967
|
+
deviceId: number(),
|
|
8968
|
+
nodeId: string().optional()
|
|
8969
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8970
|
+
addonId: string(),
|
|
8971
|
+
deviceId: number(),
|
|
8972
|
+
nodeId: string().optional(),
|
|
8973
|
+
patch: SettingsPatchSchema
|
|
8974
|
+
}), SettingsUpdateResultSchema, {
|
|
8975
|
+
kind: "mutation",
|
|
8976
|
+
auth: "admin"
|
|
8977
|
+
});
|
|
8978
|
+
/**
|
|
8979
|
+
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
8980
|
+
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
8981
|
+
* widgets registers a provider on this collection cap; the hub-local
|
|
8982
|
+
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
8983
|
+
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
8984
|
+
* exposes the public listing surface that admin-ui consumes.
|
|
8985
|
+
*
|
|
8986
|
+
* The split exists because the public listing has a different output
|
|
8987
|
+
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
8988
|
+
* per-provider raw declarations. Both ends flow through codegen.
|
|
8989
|
+
*
|
|
8990
|
+
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
8991
|
+
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
8992
|
+
* same `ContributionRenderer` / Module-Federation path as every other
|
|
8993
|
+
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
8994
|
+
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
8995
|
+
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
8996
|
+
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
8997
|
+
*/
|
|
8998
|
+
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
8999
|
+
var WidgetHostEnum = _enum([
|
|
9000
|
+
"device-tab",
|
|
9001
|
+
"dashboard",
|
|
9002
|
+
"integration-detail"
|
|
9003
|
+
]);
|
|
9004
|
+
var WidgetSizeEnum = _enum([
|
|
9005
|
+
"xs",
|
|
9006
|
+
"sm",
|
|
9007
|
+
"md",
|
|
9008
|
+
"lg",
|
|
9009
|
+
"xl"
|
|
9010
|
+
]);
|
|
9011
|
+
/**
|
|
9012
|
+
* MF remote descriptor — mirrors `UiContributionRemote` from
|
|
9013
|
+
* `capability-definition.ts`. Widget remotes expose a single
|
|
9014
|
+
* `'./widgets'` module whose default export is a
|
|
9015
|
+
* `Record<componentKey, Component>` map; `componentKey` (the widget
|
|
9016
|
+
* `stableId`) picks the entry the host mounts.
|
|
9017
|
+
*/
|
|
9018
|
+
var WidgetRemoteSchema = object({
|
|
9019
|
+
remoteName: string(),
|
|
9020
|
+
exposedModule: string(),
|
|
9021
|
+
componentKey: string().optional()
|
|
9022
|
+
});
|
|
9023
|
+
/**
|
|
9024
|
+
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
9025
|
+
* widget-only metadata. The `UiContribution` core fields:
|
|
9026
|
+
*
|
|
9027
|
+
* - `tab` — where the widget hosts. A widget that runs on the
|
|
9028
|
+
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
9029
|
+
* widget declares the target device-detail tab id.
|
|
9030
|
+
* - `subTab` — optional sub-tab within `tab`.
|
|
9031
|
+
* - `label` — operator-facing label.
|
|
9032
|
+
* - `order` — ordering within `(tab, subTab)`.
|
|
9033
|
+
* - `kind` — always `'remote'` for widgets.
|
|
9034
|
+
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
9035
|
+
*
|
|
9036
|
+
* Widget-only fields retained alongside the contribution core:
|
|
9037
|
+
*
|
|
9038
|
+
* - `stableId` — stable identity within the addon (the MF
|
|
9039
|
+
* `componentKey`; kept top-level so consumers have
|
|
9040
|
+
* a stable key without reaching into `remote`).
|
|
9041
|
+
* - `description` / `icon` — picker metadata.
|
|
9042
|
+
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
9043
|
+
* aggregator stamps a versioned `bundleUrl` from it.
|
|
9044
|
+
* - `hosts` — every host the widget supports (a widget can run
|
|
9045
|
+
* both on the dashboard and a device tab). `tab`
|
|
9046
|
+
* is the PRIMARY host; `hosts` is the full set the
|
|
9047
|
+
* picker filters on.
|
|
9048
|
+
* - `requires` — host-context requirements validated at mount.
|
|
9049
|
+
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
9050
|
+
* — dashboard placement hints.
|
|
9051
|
+
*/
|
|
9052
|
+
var WidgetMetadataSchema = object({
|
|
9053
|
+
/** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
|
|
9054
|
+
tab: string(),
|
|
9055
|
+
/** Optional sub-tab within `tab`. */
|
|
9056
|
+
subTab: string().optional(),
|
|
9057
|
+
/** Operator-facing label. */
|
|
9058
|
+
label: string(),
|
|
9059
|
+
/** Ordering within `(tab, subTab)`, ascending. */
|
|
9060
|
+
order: number().optional(),
|
|
9061
|
+
/** Always `'remote'` — a widget is a Module Federation remote. */
|
|
9062
|
+
kind: literal("remote"),
|
|
9063
|
+
/** MF remote descriptor. */
|
|
9064
|
+
remote: WidgetRemoteSchema,
|
|
9065
|
+
/** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
|
|
9066
|
+
stableId: string(),
|
|
9067
|
+
description: string().optional(),
|
|
9068
|
+
icon: string().optional(),
|
|
9069
|
+
/**
|
|
9070
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9071
|
+
* `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
|
|
9072
|
+
* this is always `'remoteEntry.js'` — the value is kept on the
|
|
9073
|
+
* metadata so the static-file route can compute an mtime-based
|
|
9074
|
+
* cache-buster URL without a separate filesystem stat.
|
|
9075
|
+
*/
|
|
9076
|
+
bundle: string(),
|
|
9077
|
+
/** Every host the widget supports. The picker filters on this set. */
|
|
9078
|
+
hosts: array(WidgetHostEnum).readonly(),
|
|
9079
|
+
/** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
|
|
9080
|
+
requires: object({
|
|
9081
|
+
deviceContext: boolean().default(false),
|
|
9082
|
+
integrationContext: boolean().default(false)
|
|
9083
|
+
}),
|
|
9084
|
+
/**
|
|
9085
|
+
* Loadable BEFORE authentication. The normal widget registry listing
|
|
9086
|
+
* (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
|
|
9087
|
+
* (the login page) cannot discover a widget through it. A widget that
|
|
9088
|
+
* declares `preAuth: true` marks itself as safe to mount on a pre-auth
|
|
9089
|
+
* screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
|
|
9090
|
+
* login-method contribution channel (see `login-method.cap.ts`) rather
|
|
9091
|
+
* than the authenticated registry, and its bundle is served by the
|
|
9092
|
+
* public `/api/addon-widgets/:addonId/*` static route. Defaults false.
|
|
9093
|
+
*/
|
|
9094
|
+
preAuth: boolean().optional().default(false),
|
|
9095
|
+
/** Dashboard placement HINTS (operator can override per instance). */
|
|
9096
|
+
defaultSize: WidgetSizeEnum.default("md"),
|
|
9097
|
+
allowedSizes: array(WidgetSizeEnum).readonly().default([
|
|
9098
|
+
"sm",
|
|
9099
|
+
"md",
|
|
9100
|
+
"lg"
|
|
9101
|
+
]),
|
|
9102
|
+
defaultColumns: number().int().min(1).max(12).default(6),
|
|
9103
|
+
defaultRows: number().int().min(1).max(12).default(1)
|
|
9104
|
+
});
|
|
9105
|
+
method(_void(), array(WidgetMetadataSchema).readonly());
|
|
9106
|
+
/**
|
|
9107
|
+
* `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
|
|
9108
|
+
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9109
|
+
*
|
|
9110
|
+
* The provider iterates every `addon-widgets-source` (collection)
|
|
9111
|
+
* provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
|
|
9112
|
+
* `bundleUrl` strings pointing at
|
|
9113
|
+
* `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
|
|
9114
|
+
* `mtime` cache-buster lets the browser pick up addon rebuilds without
|
|
9115
|
+
* manual reload — same scheme used by `addon-pages`.
|
|
9116
|
+
*
|
|
9117
|
+
* The hub-local builtin `addon-widgets-aggregator` (see
|
|
9118
|
+
* `@camstack/system/builtins/addon-widgets-aggregator`) registers the
|
|
9119
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9120
|
+
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
9121
|
+
*/
|
|
9122
|
+
var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
|
|
9123
|
+
addonId: string(),
|
|
9124
|
+
bundleUrl: string()
|
|
9125
|
+
});
|
|
9126
|
+
method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
|
|
9127
|
+
/**
|
|
9128
|
+
* Alerts capability — collection-based internal alert system.
|
|
9129
|
+
*
|
|
9130
|
+
* Multiple providers can register. Each provider filters by EventBus category
|
|
9131
|
+
* and creates/updates alerts. The built-in Alert Center addon persists alerts
|
|
9132
|
+
* in the DB and serves them to the admin UI.
|
|
9133
|
+
*/
|
|
9134
|
+
var AlertSeveritySchema = _enum([
|
|
9135
|
+
"info",
|
|
9136
|
+
"success",
|
|
9137
|
+
"warning",
|
|
9138
|
+
"error"
|
|
9139
|
+
]);
|
|
9140
|
+
var AlertStatusSchema = _enum([
|
|
9141
|
+
"active",
|
|
9142
|
+
"in-progress",
|
|
9143
|
+
"completed",
|
|
9144
|
+
"failed",
|
|
9145
|
+
"dismissed"
|
|
9146
|
+
]);
|
|
9147
|
+
var AlertSourceSchema = object({
|
|
9148
|
+
type: string(),
|
|
9149
|
+
id: string()
|
|
9150
|
+
});
|
|
9151
|
+
var AlertSchema = object({
|
|
9152
|
+
id: string(),
|
|
9153
|
+
category: string(),
|
|
9154
|
+
severity: AlertSeveritySchema,
|
|
9155
|
+
title: string(),
|
|
9156
|
+
message: string(),
|
|
9157
|
+
status: AlertStatusSchema,
|
|
9158
|
+
progress: number().optional(),
|
|
9159
|
+
read: boolean(),
|
|
9160
|
+
createdAt: number(),
|
|
9161
|
+
updatedAt: number(),
|
|
9162
|
+
source: AlertSourceSchema.optional(),
|
|
9163
|
+
metadata: record(string(), unknown()).optional()
|
|
9164
|
+
});
|
|
9165
|
+
method(AlertSchema, _void(), { kind: "mutation" }), method(object({
|
|
9166
|
+
alertId: string(),
|
|
9167
|
+
patch: AlertSchema.partial()
|
|
9168
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
9169
|
+
unreadOnly: boolean().optional(),
|
|
9170
|
+
limit: number().optional()
|
|
9171
|
+
}).optional(), array(AlertSchema).readonly()), method(_void(), number()), method(object({ alertId: string() }), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(object({ alertId: string() }), _void(), { kind: "mutation" });
|
|
9172
|
+
DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
|
|
9173
|
+
deviceId: number(),
|
|
9174
|
+
rms: number(),
|
|
9175
|
+
dbfs: number()
|
|
9176
|
+
});
|
|
9177
|
+
/** Shared Zod schemas used across detection capabilities. */
|
|
9178
|
+
/**
|
|
9179
|
+
* Canonical frame-format enum mirrored on `FrameFormat` in
|
|
9180
|
+
* `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
|
|
9181
|
+
* Zod runtime schema and TypeScript type stay in sync at the call site
|
|
9182
|
+
* — adding a new format requires changing both this enum and the
|
|
9183
|
+
* `FrameFormat` type alias together.
|
|
9184
|
+
*/
|
|
9185
|
+
var FrameFormatSchema = _enum([
|
|
9186
|
+
"jpeg",
|
|
9187
|
+
"rgb",
|
|
9188
|
+
"bgr",
|
|
9189
|
+
"yuv420",
|
|
9190
|
+
"gray"
|
|
9191
|
+
]);
|
|
9192
|
+
var FrameInputSchema = object({
|
|
9193
|
+
data: custom(),
|
|
9194
|
+
format: FrameFormatSchema,
|
|
9195
|
+
width: number(),
|
|
8289
9196
|
height: number(),
|
|
8290
9197
|
timestamp: number()
|
|
8291
9198
|
});
|
|
@@ -8911,6 +9818,69 @@ var StreamFormatSchema = _enum([
|
|
|
8911
9818
|
"mjpeg",
|
|
8912
9819
|
"rtsp"
|
|
8913
9820
|
]);
|
|
9821
|
+
/** A container `produceEventMedia` can emit. */
|
|
9822
|
+
var EventMediaKindSchema = _enum(["mp4", "gif"]);
|
|
9823
|
+
/**
|
|
9824
|
+
* One produced artifact, referenced by HANDLE.
|
|
9825
|
+
*
|
|
9826
|
+
* Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
|
|
9827
|
+
* method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
|
|
9828
|
+
* [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
|
|
9829
|
+
* on demand, compressed, by handle). `bytes` is here so a caller can decide
|
|
9830
|
+
* whether it wants the fetch at all.
|
|
9831
|
+
*/
|
|
9832
|
+
var EventMediaArtifactSchema = object({
|
|
9833
|
+
kind: EventMediaKindSchema,
|
|
9834
|
+
/** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
|
|
9835
|
+
handle: string(),
|
|
9836
|
+
/**
|
|
9837
|
+
* The node holding the bytes — the ROUTING key for `fetchEventMedia`.
|
|
9838
|
+
*
|
|
9839
|
+
* `stream-broker` is a singleton cap and an unpinned call never leaves the
|
|
9840
|
+
* hub, so a handle produced on an agent's broker would be redeemed against
|
|
9841
|
+
* the hub's store and come back `null`. Same contract, same field name and
|
|
9842
|
+
* the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
|
|
9843
|
+
* lives and the consumer pins to it.
|
|
9844
|
+
*/
|
|
9845
|
+
nodeId: string(),
|
|
9846
|
+
mime: string(),
|
|
9847
|
+
bytes: number().int(),
|
|
9848
|
+
width: number().int(),
|
|
9849
|
+
height: number().int()
|
|
9850
|
+
});
|
|
9851
|
+
/**
|
|
9852
|
+
* What a production actually covered — the answer to the only question an
|
|
9853
|
+
* operator asks about a notification clip.
|
|
9854
|
+
*
|
|
9855
|
+
* `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
|
|
9856
|
+
* so a caller can state "this clip starts 4.1 s before the event" instead of
|
|
9857
|
+
* inferring it from a duration. A production whose `fromTs` is later than the
|
|
9858
|
+
* event is a production with no pre-roll, and that is exactly the defect this
|
|
9859
|
+
* method exists to make visible rather than plausible.
|
|
9860
|
+
*/
|
|
9861
|
+
var EventMediaCoverageSchema = object({
|
|
9862
|
+
fromTs: number(),
|
|
9863
|
+
toTs: number(),
|
|
9864
|
+
/** Encoded packets in the muxed window. */
|
|
9865
|
+
packets: number().int()
|
|
9866
|
+
});
|
|
9867
|
+
/**
|
|
9868
|
+
* The result of ONE cut, in every container the caller asked for.
|
|
9869
|
+
*
|
|
9870
|
+
* Every artifact in `media` came out of the SAME window of the SAME rendition —
|
|
9871
|
+
* that is the whole reason this is one method rather than one call per format.
|
|
9872
|
+
* A consumer attaching a gif and a video can no longer show two different
|
|
9873
|
+
* moments, because it never chose two sources.
|
|
9874
|
+
*/
|
|
9875
|
+
var EventMediaProductionSchema = object({
|
|
9876
|
+
media: array(EventMediaArtifactSchema).readonly(),
|
|
9877
|
+
coverage: EventMediaCoverageSchema,
|
|
9878
|
+
/** The rendition actually cut from — what the default or the fallback chose. */
|
|
9879
|
+
profile: CamProfileSchema,
|
|
9880
|
+
/** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
|
|
9881
|
+
* source, a downscale, or a playback rate other than 1). */
|
|
9882
|
+
video: _enum(["copy", "encode"])
|
|
9883
|
+
});
|
|
8914
9884
|
var RtspRestreamEntrySchema = object({
|
|
8915
9885
|
brokerId: string(),
|
|
8916
9886
|
url: string(),
|
|
@@ -9310,6 +10280,56 @@ method(object({
|
|
|
9310
10280
|
}), {
|
|
9311
10281
|
kind: "mutation",
|
|
9312
10282
|
auth: "admin"
|
|
10283
|
+
}), method(object({
|
|
10284
|
+
deviceId: number(),
|
|
10285
|
+
/** Absent = the largest H.264 rendition at or below 1080p, which is
|
|
10286
|
+
* also the one that can be copied. Falls back to whatever the ring
|
|
10287
|
+
* actually retained, and the answer says which. */
|
|
10288
|
+
profile: CamProfileSchema.optional(),
|
|
10289
|
+
aroundMs: number(),
|
|
10290
|
+
preSeconds: number().min(0).max(20).default(4),
|
|
10291
|
+
postSeconds: number().min(0).max(20).default(6),
|
|
10292
|
+
kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
|
|
10293
|
+
/** GIF geometry. The video keeps the source's own. */
|
|
10294
|
+
gifMaxWidth: number().int().min(120).max(1280).default(640),
|
|
10295
|
+
/**
|
|
10296
|
+
* The gif's own PLAYBACK rate in frames per second — what the finished
|
|
10297
|
+
* gif runs at, not how many source frames feed it. The decimation that
|
|
10298
|
+
* feeds it samples `gifFps / gifSpeed` source frames per second, so at
|
|
10299
|
+
* the defaults a 12 fps gif is built out of 3 source frames a second.
|
|
10300
|
+
*/
|
|
10301
|
+
gifFps: number().int().min(1).max(15).default(12),
|
|
10302
|
+
/**
|
|
10303
|
+
* How fast the GIF plays against real time, independent of `speed`.
|
|
10304
|
+
*
|
|
10305
|
+
* 4× by default, by operator request: a notification gif is glanced at
|
|
10306
|
+
* on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
|
|
10307
|
+
* a separate knob from `speed` even though both now default to 4 —
|
|
10308
|
+
* a caller wanting a real-time video and a fast gif must not have to
|
|
10309
|
+
* choose.
|
|
10310
|
+
*/
|
|
10311
|
+
gifSpeed: number().min(1).max(8).default(4),
|
|
10312
|
+
/**
|
|
10313
|
+
* Playback rate of the VIDEO. Also 4× by default, by operator decision.
|
|
10314
|
+
*
|
|
10315
|
+
* `1` is real time and is the ONLY value that allows the copy branch —
|
|
10316
|
+
* anything else forces `libx264` over the window. That was priced
|
|
10317
|
+
* before it was chosen: a per-event burst measured at 0.23 s and 254 KB
|
|
10318
|
+
* on a real 615 720p cut, against 922 KB for the copy it replaces. A
|
|
10319
|
+
* re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
|
|
10320
|
+
* once the decode is forced the width stops being free.
|
|
10321
|
+
*/
|
|
10322
|
+
speed: number().min(1).max(8).default(4)
|
|
10323
|
+
}), EventMediaProductionSchema, {
|
|
10324
|
+
kind: "mutation",
|
|
10325
|
+
auth: "admin"
|
|
10326
|
+
}), method(object({ handle: string() }), object({
|
|
10327
|
+
base64: string(),
|
|
10328
|
+
mime: string(),
|
|
10329
|
+
bytes: number().int()
|
|
10330
|
+
}).nullable(), {
|
|
10331
|
+
kind: "mutation",
|
|
10332
|
+
auth: "admin"
|
|
9313
10333
|
}), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
|
|
9314
10334
|
probed: boolean(),
|
|
9315
10335
|
summary: string()
|
|
@@ -9382,7 +10402,25 @@ method(object({
|
|
|
9382
10402
|
}), _void(), {
|
|
9383
10403
|
kind: "mutation",
|
|
9384
10404
|
auth: "admin"
|
|
9385
|
-
}), method(object({ brokerId: string() }), boolean()), object({
|
|
10405
|
+
}), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
|
|
10406
|
+
muted: boolean(),
|
|
10407
|
+
/**
|
|
10408
|
+
* How many live non-derived brokers currently hold the mute. Purely
|
|
10409
|
+
* diagnostic: `muted` is the policy and is authoritative on its own
|
|
10410
|
+
* (it applies to brokers that do not exist yet), while this says
|
|
10411
|
+
* whether anything is presently being silenced.
|
|
10412
|
+
*/
|
|
10413
|
+
appliedBrokers: number().int().nonnegative()
|
|
10414
|
+
})), method(object({
|
|
10415
|
+
deviceId: number().int(),
|
|
10416
|
+
muted: boolean()
|
|
10417
|
+
}), object({
|
|
10418
|
+
muted: boolean(),
|
|
10419
|
+
appliedBrokers: number().int().nonnegative()
|
|
10420
|
+
}), {
|
|
10421
|
+
kind: "mutation",
|
|
10422
|
+
auth: "admin"
|
|
10423
|
+
}), object({
|
|
9386
10424
|
deviceId: number().int().nonnegative(),
|
|
9387
10425
|
camStreamId: string(),
|
|
9388
10426
|
profile: CamProfileSchema
|
|
@@ -9579,25 +10617,6 @@ var cameraStreamsCapability = {
|
|
|
9579
10617
|
lastChangedAt: number()
|
|
9580
10618
|
})
|
|
9581
10619
|
};
|
|
9582
|
-
/**
|
|
9583
|
-
* core-blocks — user-authored TypeScript, stored in the kernel and executed in
|
|
9584
|
-
* its own process.
|
|
9585
|
-
*
|
|
9586
|
-
* Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
|
|
9587
|
-
*
|
|
9588
|
-
* The first use is **owning devices without being a device provider**: a block
|
|
9589
|
-
* declares devices under a system or custom integration and drives their state,
|
|
9590
|
-
* with the same `ctx` an addon gets. Automations come later; nothing here
|
|
9591
|
-
* models a trigger.
|
|
9592
|
-
*
|
|
9593
|
-
* **Stated plainly, because it does not change by being true:** a block has an
|
|
9594
|
-
* addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
|
|
9595
|
-
* with no review step. What makes that survivable is not a sandbox, it is
|
|
9596
|
-
* PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
|
|
9597
|
-
* so a block that throws or never returns is marked `failed` and visible
|
|
9598
|
-
* instead of taking the hub with it (D6). Every method here is admin-only, and
|
|
9599
|
-
* must stay so.
|
|
9600
|
-
*/
|
|
9601
10620
|
/** Where a block runs. The operator chooses — a block driving a device on an
|
|
9602
10621
|
* agent is the reason placement is not fixed to the hub. */
|
|
9603
10622
|
var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
|
|
@@ -9669,6 +10688,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
|
|
|
9669
10688
|
}), object({ block: CoreBlockSchema }), {
|
|
9670
10689
|
kind: "mutation",
|
|
9671
10690
|
auth: "admin"
|
|
10691
|
+
}), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
|
|
10692
|
+
kind: "mutation",
|
|
10693
|
+
auth: "admin"
|
|
9672
10694
|
}), method(object({ code: string() }), CoreBlockCompileResultSchema, {
|
|
9673
10695
|
kind: "mutation",
|
|
9674
10696
|
auth: "admin"
|
|
@@ -10205,843 +11227,248 @@ var deviceDiscoveryCapability = {
|
|
|
10205
11227
|
}
|
|
10206
11228
|
};
|
|
10207
11229
|
/**
|
|
10208
|
-
* `device-adoption` — generic discovery + adoption surface,
|
|
10209
|
-
* system-scoped singleton. The de-HA-ified successor to `ha-discovery`:
|
|
10210
|
-
* keyed by `integrationId` (the addon resolves integrationId → broker
|
|
10211
|
-
* internally) rather than a connection-specific `brokerId`, and it
|
|
10212
|
-
* REUSES the shared `DiscoveredChildDevice` candidate type from
|
|
10213
|
-
* `device-discovery.cap.ts` so a single shared adoption panel renders
|
|
10214
|
-
* any integration's candidate tree.
|
|
10215
|
-
*
|
|
10216
|
-
* UI flow (Integrations page → integration detail):
|
|
10217
|
-
* 1. `listCandidates({integrationId, page, pageSize, filter})` →
|
|
10218
|
-
* paginated candidate list, each row collapsed.
|
|
10219
|
-
* 2. User expands a row → `getCandidate({integrationId,
|
|
10220
|
-
* childNativeId})` returns the single candidate (with its nested
|
|
10221
|
-
* `children`) for the accordion render.
|
|
10222
|
-
* 3. User clicks "Adopt" on one or more candidates →
|
|
10223
|
-
* `adopt({integrationId, childNativeIds, perCandidate?})`
|
|
10224
|
-
* materialises one parent CamStack device per candidate plus its
|
|
10225
|
-
* accessory children. `perCandidate[childNativeId]` carries an
|
|
10226
|
-
* optional display-name override + a `hiddenChildIds` pre-hide set
|
|
10227
|
-
* (the parent's `accessories.hiddenChildIds` gates UI visibility).
|
|
10228
|
-
* 4. User clicks "Release" → `release({integrationId, camDeviceId})`
|
|
10229
|
-
* removes the parent + every child from the kernel registry.
|
|
10230
|
-
*/
|
|
10231
|
-
/**
|
|
10232
|
-
* Provider-declared discovery *granularity* filter (the keystone of the
|
|
10233
|
-
* single-entity-import design). A provider advertises which discovery
|
|
10234
|
-
* granularities it supports via `listCandidateFilters`; the operator picks
|
|
10235
|
-
* one and it is passed back as the opaque `filter` string on
|
|
10236
|
-
* `listCandidates`/`adopt`.
|
|
10237
|
-
*
|
|
10238
|
-
* - **`id`** is provider-defined and OPAQUE to the cap + UI layers — only the
|
|
10239
|
-
* declaring provider interprets it. The reserved id `'devices'` is the
|
|
10240
|
-
* universal default (every provider's current behavior IS the `devices`
|
|
10241
|
-
* filter). A provider that declares no filters is treated as a single
|
|
10242
|
-
* implicit `{ id:'devices', label:'Devices', isDefault:true }`.
|
|
10243
|
-
* - **`label`** is the human label for the UI selector.
|
|
10244
|
-
* - **`isDefault`** marks the default filter; exactly one SHOULD be default.
|
|
10245
|
-
*/
|
|
10246
|
-
var AdoptionFilterSchema = object({
|
|
10247
|
-
id: string(),
|
|
10248
|
-
label: string(),
|
|
10249
|
-
isDefault: boolean().optional()
|
|
10250
|
-
});
|
|
10251
|
-
/**
|
|
10252
|
-
* Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
|
|
10253
|
-
* de-HA-ified — `area` / `manufacturer` stay free-form strings so any
|
|
10254
|
-
* integration can populate them from its own metadata. Distinct from the
|
|
10255
|
-
* granularity `AdoptionFilter` above: this narrows the candidate set within a
|
|
10256
|
-
* chosen granularity, whereas the granularity `filter` selects WHAT a
|
|
10257
|
-
* candidate is (a device vs an entity).
|
|
10258
|
-
*/
|
|
10259
|
-
var CandidateQueryFilterSchema = object({
|
|
10260
|
-
/** Substring filter on name + manufacturer + model. */
|
|
10261
|
-
search: string().optional(),
|
|
10262
|
-
/** Area-name exact match. */
|
|
10263
|
-
area: string().optional(),
|
|
10264
|
-
/** Manufacturer exact match. */
|
|
10265
|
-
manufacturer: string().optional(),
|
|
10266
|
-
/** When true, only return candidates the operator already adopted. */
|
|
10267
|
-
adoptedOnly: boolean().optional(),
|
|
10268
|
-
/** When true, only return candidates the operator hasn't adopted yet. */
|
|
10269
|
-
unadoptedOnly: boolean().optional()
|
|
10270
|
-
});
|
|
10271
|
-
var ListCandidatesInputSchema = object({
|
|
10272
|
-
integrationId: string(),
|
|
10273
|
-
page: number().int().positive().default(1),
|
|
10274
|
-
pageSize: number().int().positive().max(2e4).default(50),
|
|
10275
|
-
/**
|
|
10276
|
-
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
10277
|
-
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
10278
|
-
* exactly the pre-existing behavior (fully back-compatible).
|
|
10279
|
-
*/
|
|
10280
|
-
filter: string().optional(),
|
|
10281
|
-
/** Optional candidate-list text/query narrowing within the granularity. */
|
|
10282
|
-
filterText: CandidateQueryFilterSchema.optional()
|
|
10283
|
-
});
|
|
10284
|
-
var ListCandidatesOutputSchema = object({
|
|
10285
|
-
candidates: array(DiscoveredChildDeviceSchema).readonly(),
|
|
10286
|
-
totalCount: number().int().nonnegative(),
|
|
10287
|
-
page: number().int().positive(),
|
|
10288
|
-
pageSize: number().int().positive()
|
|
10289
|
-
});
|
|
10290
|
-
var GetCandidateInputSchema = object({
|
|
10291
|
-
integrationId: string(),
|
|
10292
|
-
childNativeId: string()
|
|
10293
|
-
});
|
|
10294
|
-
var AdoptionStatusSchema = object({
|
|
10295
|
-
/** Last refresh timestamp (ms epoch) — null when never refreshed. */
|
|
10296
|
-
lastDiscoveryAt: number().int().nonnegative().nullable(),
|
|
10297
|
-
/** Count of candidates in the discovery cache. */
|
|
10298
|
-
candidateCount: number().int().nonnegative(),
|
|
10299
|
-
/** Count of candidates the operator has already adopted. */
|
|
10300
|
-
adoptedCount: number().int().nonnegative(),
|
|
10301
|
-
/** Last error message from a refresh attempt. */
|
|
10302
|
-
lastError: string().nullable()
|
|
10303
|
-
});
|
|
10304
|
-
var PerCandidateSchema = object({
|
|
10305
|
-
/** Override the default display name for this candidate's parent. */
|
|
10306
|
-
name: string().min(1).optional(),
|
|
10307
|
-
/** Pre-hide a subset of entity-children — created (state still flows)
|
|
10308
|
-
* but listed in the parent's `accessories.hiddenChildIds`. */
|
|
10309
|
-
hiddenChildIds: array(string()).optional()
|
|
10310
|
-
});
|
|
10311
|
-
var AdoptInputSchema = object({
|
|
10312
|
-
integrationId: string(),
|
|
10313
|
-
/**
|
|
10314
|
-
* Candidate native ids to adopt. Their MEANING is filter-relative: under the
|
|
10315
|
-
* default `'devices'` granularity these are device-native ids; under a
|
|
10316
|
-
* provider-declared granularity (e.g. HA `'entities'`) they are that
|
|
10317
|
-
* granularity's native ids (e.g. entity ids). The field name is kept stable
|
|
10318
|
-
* to avoid a breaking rename.
|
|
10319
|
-
*/
|
|
10320
|
-
childNativeIds: array(string()).min(1),
|
|
10321
|
-
/**
|
|
10322
|
-
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
10323
|
-
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
10324
|
-
* exactly the pre-existing behavior (fully back-compatible).
|
|
10325
|
-
*/
|
|
10326
|
-
filter: string().optional(),
|
|
10327
|
-
/** When true, import each adopted device's source-system location (e.g. HA
|
|
10328
|
-
* area) into CamStack — fuzzy-match an existing location or create it, then
|
|
10329
|
-
* assign. Omitted/false = no location work (back-compat). */
|
|
10330
|
-
importLocations: boolean().optional(),
|
|
10331
|
-
perCandidate: record(string(), PerCandidateSchema).optional()
|
|
10332
|
-
});
|
|
10333
|
-
var AdoptResultSchema = object({ adopted: array(object({
|
|
10334
|
-
childNativeId: string(),
|
|
10335
|
-
parentDeviceId: number().int().nonnegative(),
|
|
10336
|
-
accessoryDeviceIds: array(number().int().nonnegative()).readonly()
|
|
10337
|
-
})).readonly() });
|
|
10338
|
-
var ReleaseInputSchema = object({
|
|
10339
|
-
integrationId: string(),
|
|
10340
|
-
/** Parent CamStack device id (NOT an accessory child id). Removing
|
|
10341
|
-
* the parent cascades into every accessory. */
|
|
10342
|
-
camDeviceId: number().int().nonnegative()
|
|
10343
|
-
});
|
|
10344
|
-
var ResyncInputSchema = object({
|
|
10345
|
-
/** Parent CamStack device id of an adopted device. The provider resolves its
|
|
10346
|
-
* source (integration/broker + native id) and re-aligns the device's
|
|
10347
|
-
* structural spec (type/role/capabilities/units) with the live mapping,
|
|
10348
|
-
* rebuilding any child whose class changed while preserving operator edits. */
|
|
10349
|
-
camDeviceId: number().int().nonnegative(),
|
|
10350
|
-
/** "Resync from zero" (#19). When true, the kernel PURGES every accessory
|
|
10351
|
-
* child of `camDeviceId` BEFORE the provider re-derives the device, so the
|
|
10352
|
-
* children are rebuilt fresh from source — correct names, coords, and units —
|
|
10353
|
-
* instead of being preserved by the incremental reconcile. Use to recover from
|
|
10354
|
-
* legacy generic/placeholder names that the normal name-precedence keeps frozen
|
|
10355
|
-
* (the operator's explicit reset). Push-driven integrations (no-op resync)
|
|
10356
|
-
* rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
|
|
10357
|
-
* Operator edits on the PARENT (its name, layout, primary-child pick) survive —
|
|
10358
|
-
* only the children are torn down. Omitted/false ⇒ the normal incremental
|
|
10359
|
-
* re-sync that preserves children. */
|
|
10360
|
-
resetToSource: boolean().optional()
|
|
10361
|
-
});
|
|
10362
|
-
var ResyncResultSchema = object({
|
|
10363
|
-
/** True when the persisted spec actually changed (children may have been rebuilt). */
|
|
10364
|
-
changed: boolean(),
|
|
10365
|
-
/** Number of child devices rebuilt into a new class by this re-sync. */
|
|
10366
|
-
rebuiltChildren: number().int().nonnegative(),
|
|
10367
|
-
/** Number of accessory children torn down by a `resetToSource` purge before the
|
|
10368
|
-
* provider re-derived the device. 0/absent for a normal incremental re-sync. */
|
|
10369
|
-
removedChildren: number().int().nonnegative().optional()
|
|
10370
|
-
});
|
|
10371
|
-
method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
|
|
10372
|
-
kind: "mutation",
|
|
10373
|
-
auth: "admin"
|
|
10374
|
-
}), method(AdoptInputSchema, AdoptResultSchema, {
|
|
10375
|
-
kind: "mutation",
|
|
10376
|
-
auth: "admin"
|
|
10377
|
-
}), method(ReleaseInputSchema, _void(), {
|
|
10378
|
-
kind: "mutation",
|
|
10379
|
-
auth: "admin"
|
|
10380
|
-
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
10381
|
-
kind: "mutation",
|
|
10382
|
-
auth: "admin"
|
|
10383
|
-
});
|
|
10384
|
-
/**
|
|
10385
|
-
* `device-export` — collection cap for addons that export camstack
|
|
10386
|
-
* devices to external ecosystems (HomeAssistant via MQTT discovery,
|
|
10387
|
-
* HomeKit/HAP, Alexa Smart Home, …).
|
|
10388
|
-
*
|
|
10389
|
-
* No `ecosystem` enum — the addon id identifies the export target.
|
|
10390
|
-
* Each addon owns its mapping logic in its own settings UI. The cap
|
|
10391
|
-
* exposes only the COMMON surface: link state, supported device
|
|
10392
|
-
* kinds (so the UI can filter the device picker), and per-device
|
|
10393
|
-
* expose/unexpose.
|
|
10394
|
-
*/
|
|
10395
|
-
var LinkStateSchema = _enum([
|
|
10396
|
-
"unlinked",
|
|
10397
|
-
"linked",
|
|
10398
|
-
"error"
|
|
10399
|
-
]);
|
|
10400
|
-
/**
|
|
10401
|
-
* A single label/value row surfaced in the export panel's "Setup"
|
|
10402
|
-
* section. Rendered with a copy-to-clipboard button. `secret: true`
|
|
10403
|
-
* rows are masked by default with a reveal toggle (client ids,
|
|
10404
|
-
* secrets, …).
|
|
10405
|
-
*/
|
|
10406
|
-
var ExportSetupFieldSchema = object({
|
|
10407
|
-
label: string(),
|
|
10408
|
-
value: string(),
|
|
10409
|
-
/** Mask the value by default + render a reveal toggle (client id, secrets). */
|
|
10410
|
-
secret: boolean().optional()
|
|
10411
|
-
});
|
|
10412
|
-
/**
|
|
10413
|
-
* Generic, addon-agnostic pairing/account block. Any export addon can
|
|
10414
|
-
* surface a scannable QR, a set of copyable label/value rows, and a
|
|
10415
|
-
* free-form operator note — the `DeviceExportPanel` renders whatever
|
|
10416
|
-
* the provider supplies and skips the section entirely when `setup`
|
|
10417
|
-
* is unset.
|
|
10418
|
-
*/
|
|
10419
|
-
var ExportSetupSchema = object({
|
|
10420
|
-
/** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
|
|
10421
|
-
qr: string().optional(),
|
|
10422
|
-
/** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
|
|
10423
|
-
fields: array(ExportSetupFieldSchema).readonly().optional(),
|
|
10424
|
-
/** Free-form operator instructions rendered above the fields. */
|
|
10425
|
-
note: string().optional()
|
|
10426
|
-
});
|
|
10427
|
-
var DeviceExportStatusSchema = object({
|
|
10428
|
-
linkState: LinkStateSchema,
|
|
10429
|
-
exposedDeviceCount: number(),
|
|
10430
|
-
error: string().optional(),
|
|
10431
|
-
/**
|
|
10432
|
-
* Optional pairing/account info the panel renders in a generic
|
|
10433
|
-
* "Setup" section. Addon-agnostic — the addon id identifies the
|
|
10434
|
-
* export target, never an `ecosystem` key here.
|
|
10435
|
-
*/
|
|
10436
|
-
setup: ExportSetupSchema.optional()
|
|
10437
|
-
});
|
|
10438
|
-
var DeviceKindSchema = string();
|
|
10439
|
-
var ExposedDeviceSchema = object({
|
|
10440
|
-
deviceId: string(),
|
|
10441
|
-
exposedAs: string().optional(),
|
|
10442
|
-
capabilities: array(string()).optional()
|
|
10443
|
-
});
|
|
10444
|
-
var ExposeInputSchema = object({
|
|
10445
|
-
deviceId: string(),
|
|
10446
|
-
capabilities: array(string()).optional()
|
|
10447
|
-
});
|
|
10448
|
-
var UnexposeInputSchema = object({ deviceId: string() });
|
|
10449
|
-
method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
|
|
10450
|
-
/**
|
|
10451
|
-
* Resource-bound constants for the safe expression engine.
|
|
10452
|
-
*
|
|
10453
|
-
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
10454
|
-
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
10455
|
-
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
10456
|
-
* work a single author-supplied expression can request, so a hostile or
|
|
10457
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
10458
|
-
*/
|
|
10459
|
-
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
10460
|
-
* rejected without allocation. */
|
|
10461
|
-
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
10462
|
-
/** A legal binding / identifier name. */
|
|
10463
|
-
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10464
|
-
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
10465
|
-
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
10466
|
-
var RESERVED_BINDING_NAMES = new Set([
|
|
10467
|
-
"now",
|
|
10468
|
-
"true",
|
|
10469
|
-
"false",
|
|
10470
|
-
"null"
|
|
10471
|
-
]);
|
|
10472
|
-
/**
|
|
10473
|
-
* Error types for the safe expression engine. Two distinct classes so callers
|
|
10474
|
-
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
10475
|
-
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
10476
|
-
*/
|
|
10477
|
-
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
10478
|
-
* the failure is anchored to a character (author-facing inline feedback). */
|
|
10479
|
-
var ExpressionParseError = class extends Error {
|
|
10480
|
-
position;
|
|
10481
|
-
constructor(message, position) {
|
|
10482
|
-
super(message);
|
|
10483
|
-
this.name = "ExpressionParseError";
|
|
10484
|
-
this.position = position;
|
|
10485
|
-
}
|
|
10486
|
-
};
|
|
10487
|
-
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
10488
|
-
* result, unknown builtin, step-budget exceeded). */
|
|
10489
|
-
var ExpressionEvalError = class extends Error {
|
|
10490
|
-
constructor(message) {
|
|
10491
|
-
super(message);
|
|
10492
|
-
this.name = "ExpressionEvalError";
|
|
10493
|
-
}
|
|
10494
|
-
};
|
|
10495
|
-
/**
|
|
10496
|
-
* Frozen, null-prototype builtin function table for the expression engine
|
|
10497
|
-
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
10498
|
-
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
10499
|
-
* own-property check against it.
|
|
10500
|
-
*
|
|
10501
|
-
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
10502
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
10503
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
10504
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
10505
|
-
* callable — they are simply "unknown function" at parse time.
|
|
10506
|
-
*
|
|
10507
|
-
* Every numeric argument is validated as a finite number and every numeric
|
|
10508
|
-
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
10509
|
-
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
10510
|
-
* closed rather than emitting a garbage value.
|
|
10511
|
-
*/
|
|
10512
|
-
function asFiniteNumber(value, name, index) {
|
|
10513
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
10514
|
-
return value;
|
|
10515
|
-
}
|
|
10516
|
-
function asString$1(value, name, index) {
|
|
10517
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
10518
|
-
return value;
|
|
10519
|
-
}
|
|
10520
|
-
function finiteResult(value, name) {
|
|
10521
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
10522
|
-
return value;
|
|
10523
|
-
}
|
|
10524
|
-
function allFiniteNumbers(args, name) {
|
|
10525
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
10526
|
-
}
|
|
10527
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
10528
|
-
var table = {
|
|
10529
|
-
min: {
|
|
10530
|
-
minArgs: 1,
|
|
10531
|
-
maxArgs: INF,
|
|
10532
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
10533
|
-
},
|
|
10534
|
-
max: {
|
|
10535
|
-
minArgs: 1,
|
|
10536
|
-
maxArgs: INF,
|
|
10537
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
10538
|
-
},
|
|
10539
|
-
abs: {
|
|
10540
|
-
minArgs: 1,
|
|
10541
|
-
maxArgs: 1,
|
|
10542
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
10543
|
-
},
|
|
10544
|
-
floor: {
|
|
10545
|
-
minArgs: 1,
|
|
10546
|
-
maxArgs: 1,
|
|
10547
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
10548
|
-
},
|
|
10549
|
-
ceil: {
|
|
10550
|
-
minArgs: 1,
|
|
10551
|
-
maxArgs: 1,
|
|
10552
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
10553
|
-
},
|
|
10554
|
-
sqrt: {
|
|
10555
|
-
minArgs: 1,
|
|
10556
|
-
maxArgs: 1,
|
|
10557
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
10558
|
-
},
|
|
10559
|
-
round: {
|
|
10560
|
-
minArgs: 1,
|
|
10561
|
-
maxArgs: 2,
|
|
10562
|
-
apply: (args) => {
|
|
10563
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
10564
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
10565
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
10566
|
-
const factor = 10 ** digits;
|
|
10567
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
10568
|
-
}
|
|
10569
|
-
},
|
|
10570
|
-
pow: {
|
|
10571
|
-
minArgs: 2,
|
|
10572
|
-
maxArgs: 2,
|
|
10573
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
10574
|
-
},
|
|
10575
|
-
clamp: {
|
|
10576
|
-
minArgs: 3,
|
|
10577
|
-
maxArgs: 3,
|
|
10578
|
-
apply: (args) => {
|
|
10579
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
10580
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
10581
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
10582
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
10583
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
10584
|
-
}
|
|
10585
|
-
},
|
|
10586
|
-
avg: {
|
|
10587
|
-
minArgs: 1,
|
|
10588
|
-
maxArgs: INF,
|
|
10589
|
-
apply: (args) => {
|
|
10590
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
10591
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
10592
|
-
}
|
|
10593
|
-
},
|
|
10594
|
-
sum: {
|
|
10595
|
-
minArgs: 1,
|
|
10596
|
-
maxArgs: INF,
|
|
10597
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
10598
|
-
},
|
|
10599
|
-
coalesce: {
|
|
10600
|
-
minArgs: 1,
|
|
10601
|
-
maxArgs: INF,
|
|
10602
|
-
apply: (args) => {
|
|
10603
|
-
for (const a of args) if (a !== null) return a;
|
|
10604
|
-
return null;
|
|
10605
|
-
}
|
|
10606
|
-
},
|
|
10607
|
-
age: {
|
|
10608
|
-
minArgs: 2,
|
|
10609
|
-
maxArgs: 2,
|
|
10610
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
10611
|
-
},
|
|
10612
|
-
convert: {
|
|
10613
|
-
minArgs: 3,
|
|
10614
|
-
maxArgs: 3,
|
|
10615
|
-
apply: (args, hooks) => {
|
|
10616
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
10617
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
10618
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
10619
|
-
if (hooks.convert) {
|
|
10620
|
-
const out = hooks.convert(x, from, to);
|
|
10621
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
10622
|
-
return finiteResult(out, "convert");
|
|
10623
|
-
}
|
|
10624
|
-
if (from === to) return x;
|
|
10625
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
10626
|
-
}
|
|
10627
|
-
}
|
|
10628
|
-
};
|
|
10629
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
10630
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
10631
|
-
* callees at parse time (immediate author feedback). */
|
|
10632
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
10633
|
-
/**
|
|
10634
|
-
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
10635
|
-
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
10636
|
-
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
10637
|
-
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
10638
|
-
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
10639
|
-
* is a parse error with a source position, so member access / assignment /
|
|
10640
|
-
* template literals are lexically impossible.
|
|
10641
|
-
*/
|
|
10642
|
-
var KEYWORDS = new Set([
|
|
10643
|
-
"true",
|
|
10644
|
-
"false",
|
|
10645
|
-
"null"
|
|
10646
|
-
]);
|
|
10647
|
-
function isDigit(ch) {
|
|
10648
|
-
return ch >= "0" && ch <= "9";
|
|
10649
|
-
}
|
|
10650
|
-
function isIdentStart(ch) {
|
|
10651
|
-
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
10652
|
-
}
|
|
10653
|
-
function isIdentPart(ch) {
|
|
10654
|
-
return isIdentStart(ch) || isDigit(ch);
|
|
10655
|
-
}
|
|
10656
|
-
function isWhitespace(ch) {
|
|
10657
|
-
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
10658
|
-
}
|
|
10659
|
-
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
10660
|
-
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
10661
|
-
* string. */
|
|
10662
|
-
function tokenize(source) {
|
|
10663
|
-
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
10664
|
-
const tokens = [];
|
|
10665
|
-
let i = 0;
|
|
10666
|
-
const n = source.length;
|
|
10667
|
-
while (i < n) {
|
|
10668
|
-
const ch = source[i];
|
|
10669
|
-
if (isWhitespace(ch)) {
|
|
10670
|
-
i += 1;
|
|
10671
|
-
continue;
|
|
10672
|
-
}
|
|
10673
|
-
if (isDigit(ch)) {
|
|
10674
|
-
const start = i;
|
|
10675
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
10676
|
-
if (i < n && source[i] === ".") {
|
|
10677
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
10678
|
-
i += 1;
|
|
10679
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
10680
|
-
}
|
|
10681
|
-
const text = source.slice(start, i);
|
|
10682
|
-
const value = Number(text);
|
|
10683
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
10684
|
-
tokens.push({
|
|
10685
|
-
type: "number",
|
|
10686
|
-
value,
|
|
10687
|
-
pos: start
|
|
10688
|
-
});
|
|
10689
|
-
continue;
|
|
10690
|
-
}
|
|
10691
|
-
if (ch === "'" || ch === "\"") {
|
|
10692
|
-
const quote = ch;
|
|
10693
|
-
const start = i;
|
|
10694
|
-
i += 1;
|
|
10695
|
-
let out = "";
|
|
10696
|
-
let closed = false;
|
|
10697
|
-
while (i < n) {
|
|
10698
|
-
const c = source[i];
|
|
10699
|
-
if (c === "\\") {
|
|
10700
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
10701
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
10702
|
-
out += next;
|
|
10703
|
-
i += 2;
|
|
10704
|
-
continue;
|
|
10705
|
-
}
|
|
10706
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
10707
|
-
}
|
|
10708
|
-
if (c === quote) {
|
|
10709
|
-
closed = true;
|
|
10710
|
-
i += 1;
|
|
10711
|
-
break;
|
|
10712
|
-
}
|
|
10713
|
-
out += c;
|
|
10714
|
-
i += 1;
|
|
10715
|
-
}
|
|
10716
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
10717
|
-
tokens.push({
|
|
10718
|
-
type: "string",
|
|
10719
|
-
value: out,
|
|
10720
|
-
pos: start
|
|
10721
|
-
});
|
|
10722
|
-
continue;
|
|
10723
|
-
}
|
|
10724
|
-
if (isIdentStart(ch)) {
|
|
10725
|
-
const start = i;
|
|
10726
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
10727
|
-
const text = source.slice(start, i);
|
|
10728
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
10729
|
-
type: "keyword",
|
|
10730
|
-
keyword: keywordOf(text),
|
|
10731
|
-
pos: start
|
|
10732
|
-
});
|
|
10733
|
-
else tokens.push({
|
|
10734
|
-
type: "identifier",
|
|
10735
|
-
name: text,
|
|
10736
|
-
pos: start
|
|
10737
|
-
});
|
|
10738
|
-
continue;
|
|
10739
|
-
}
|
|
10740
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
10741
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
10742
|
-
tokens.push({
|
|
10743
|
-
type: "punct",
|
|
10744
|
-
punct: two,
|
|
10745
|
-
pos: i
|
|
10746
|
-
});
|
|
10747
|
-
i += 2;
|
|
10748
|
-
continue;
|
|
10749
|
-
}
|
|
10750
|
-
if (isSinglePunct(ch)) {
|
|
10751
|
-
tokens.push({
|
|
10752
|
-
type: "punct",
|
|
10753
|
-
punct: ch,
|
|
10754
|
-
pos: i
|
|
10755
|
-
});
|
|
10756
|
-
i += 1;
|
|
10757
|
-
continue;
|
|
10758
|
-
}
|
|
10759
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
10760
|
-
}
|
|
10761
|
-
tokens.push({
|
|
10762
|
-
type: "eof",
|
|
10763
|
-
pos: n
|
|
10764
|
-
});
|
|
10765
|
-
return tokens;
|
|
10766
|
-
}
|
|
10767
|
-
function keywordOf(text) {
|
|
10768
|
-
if (text === "true") return "true";
|
|
10769
|
-
if (text === "false") return "false";
|
|
10770
|
-
return "null";
|
|
10771
|
-
}
|
|
10772
|
-
function isSinglePunct(ch) {
|
|
10773
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
10774
|
-
}
|
|
10775
|
-
/**
|
|
10776
|
-
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
10777
|
-
*
|
|
10778
|
-
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
10779
|
-
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
10780
|
-
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
10781
|
-
* string validated against the builtin table at parse time, so an unknown
|
|
10782
|
-
* function is rejected immediately (author feedback) and a persisted expression
|
|
10783
|
-
* that references a since-removed builtin degrades at read.
|
|
10784
|
-
*
|
|
10785
|
-
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
10786
|
-
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
10787
|
-
*/
|
|
10788
|
-
/** Binary/logical operator precedence (higher binds tighter). */
|
|
10789
|
-
var BINARY_PRECEDENCE = {
|
|
10790
|
-
"||": 1,
|
|
10791
|
-
"&&": 2,
|
|
10792
|
-
"==": 3,
|
|
10793
|
-
"!=": 3,
|
|
10794
|
-
"<": 4,
|
|
10795
|
-
"<=": 4,
|
|
10796
|
-
">": 4,
|
|
10797
|
-
">=": 4,
|
|
10798
|
-
"+": 5,
|
|
10799
|
-
"-": 5,
|
|
10800
|
-
"*": 6,
|
|
10801
|
-
"/": 6,
|
|
10802
|
-
"%": 6
|
|
10803
|
-
};
|
|
10804
|
-
function isLogicalOp(op) {
|
|
10805
|
-
return op === "&&" || op === "||";
|
|
10806
|
-
}
|
|
10807
|
-
function isBinaryOp(op) {
|
|
10808
|
-
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
10809
|
-
}
|
|
10810
|
-
var Parser = class {
|
|
10811
|
-
tokens;
|
|
10812
|
-
pos = 0;
|
|
10813
|
-
nodeCount = 0;
|
|
10814
|
-
identifiers = /* @__PURE__ */ new Set();
|
|
10815
|
-
callees = /* @__PURE__ */ new Set();
|
|
10816
|
-
constructor(tokens) {
|
|
10817
|
-
this.tokens = tokens;
|
|
10818
|
-
}
|
|
10819
|
-
parse() {
|
|
10820
|
-
const ast = this.parseTernary();
|
|
10821
|
-
const tok = this.peek();
|
|
10822
|
-
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
10823
|
-
return {
|
|
10824
|
-
ast,
|
|
10825
|
-
identifiers: this.identifiers,
|
|
10826
|
-
callees: this.callees,
|
|
10827
|
-
nodeCount: this.nodeCount
|
|
10828
|
-
};
|
|
10829
|
-
}
|
|
10830
|
-
peek() {
|
|
10831
|
-
return this.tokens[this.pos];
|
|
10832
|
-
}
|
|
10833
|
-
next() {
|
|
10834
|
-
return this.tokens[this.pos++];
|
|
10835
|
-
}
|
|
10836
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
10837
|
-
expectPunct(punct) {
|
|
10838
|
-
const tok = this.peek();
|
|
10839
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
10840
|
-
this.pos += 1;
|
|
10841
|
-
}
|
|
10842
|
-
matchPunct(punct) {
|
|
10843
|
-
const tok = this.peek();
|
|
10844
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
10845
|
-
this.pos += 1;
|
|
10846
|
-
return true;
|
|
10847
|
-
}
|
|
10848
|
-
return false;
|
|
10849
|
-
}
|
|
10850
|
-
countNode() {
|
|
10851
|
-
this.nodeCount += 1;
|
|
10852
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
10853
|
-
}
|
|
10854
|
-
parseTernary() {
|
|
10855
|
-
const test = this.parseBinary(1);
|
|
10856
|
-
if (this.matchPunct("?")) {
|
|
10857
|
-
const consequent = this.parseTernary();
|
|
10858
|
-
this.expectPunct(":");
|
|
10859
|
-
const alternate = this.parseTernary();
|
|
10860
|
-
this.countNode();
|
|
10861
|
-
return {
|
|
10862
|
-
kind: "conditional",
|
|
10863
|
-
test,
|
|
10864
|
-
consequent,
|
|
10865
|
-
alternate
|
|
10866
|
-
};
|
|
10867
|
-
}
|
|
10868
|
-
return test;
|
|
10869
|
-
}
|
|
10870
|
-
parseBinary(minPrec) {
|
|
10871
|
-
let left = this.parseUnary();
|
|
10872
|
-
for (;;) {
|
|
10873
|
-
const tok = this.peek();
|
|
10874
|
-
if (tok.type !== "punct") break;
|
|
10875
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
10876
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
10877
|
-
const op = tok.punct;
|
|
10878
|
-
this.pos += 1;
|
|
10879
|
-
const right = this.parseBinary(prec + 1);
|
|
10880
|
-
this.countNode();
|
|
10881
|
-
if (isLogicalOp(op)) left = {
|
|
10882
|
-
kind: "logical",
|
|
10883
|
-
op,
|
|
10884
|
-
left,
|
|
10885
|
-
right
|
|
10886
|
-
};
|
|
10887
|
-
else if (isBinaryOp(op)) left = {
|
|
10888
|
-
kind: "binary",
|
|
10889
|
-
op,
|
|
10890
|
-
left,
|
|
10891
|
-
right
|
|
10892
|
-
};
|
|
10893
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
10894
|
-
}
|
|
10895
|
-
return left;
|
|
10896
|
-
}
|
|
10897
|
-
parseUnary() {
|
|
10898
|
-
const tok = this.peek();
|
|
10899
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
10900
|
-
const op = tok.punct;
|
|
10901
|
-
this.pos += 1;
|
|
10902
|
-
const operand = this.parseUnary();
|
|
10903
|
-
this.countNode();
|
|
10904
|
-
return {
|
|
10905
|
-
kind: "unary",
|
|
10906
|
-
op,
|
|
10907
|
-
operand
|
|
10908
|
-
};
|
|
10909
|
-
}
|
|
10910
|
-
return this.parsePrimary();
|
|
10911
|
-
}
|
|
10912
|
-
parsePrimary() {
|
|
10913
|
-
const tok = this.next();
|
|
10914
|
-
switch (tok.type) {
|
|
10915
|
-
case "number":
|
|
10916
|
-
this.countNode();
|
|
10917
|
-
return {
|
|
10918
|
-
kind: "literal",
|
|
10919
|
-
value: tok.value
|
|
10920
|
-
};
|
|
10921
|
-
case "string":
|
|
10922
|
-
this.countNode();
|
|
10923
|
-
return {
|
|
10924
|
-
kind: "literal",
|
|
10925
|
-
value: tok.value
|
|
10926
|
-
};
|
|
10927
|
-
case "keyword":
|
|
10928
|
-
this.countNode();
|
|
10929
|
-
return {
|
|
10930
|
-
kind: "literal",
|
|
10931
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
10932
|
-
};
|
|
10933
|
-
case "identifier": {
|
|
10934
|
-
const nextTok = this.peek();
|
|
10935
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
10936
|
-
this.identifiers.add(tok.name);
|
|
10937
|
-
this.countNode();
|
|
10938
|
-
return {
|
|
10939
|
-
kind: "identifier",
|
|
10940
|
-
name: tok.name
|
|
10941
|
-
};
|
|
10942
|
-
}
|
|
10943
|
-
case "punct":
|
|
10944
|
-
if (tok.punct === "(") {
|
|
10945
|
-
const inner = this.parseTernary();
|
|
10946
|
-
this.expectPunct(")");
|
|
10947
|
-
return inner;
|
|
10948
|
-
}
|
|
10949
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
10950
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
10951
|
-
}
|
|
10952
|
-
}
|
|
10953
|
-
parseCall(callee, pos) {
|
|
10954
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
10955
|
-
this.expectPunct("(");
|
|
10956
|
-
const args = [];
|
|
10957
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
10958
|
-
args.push(this.parseTernary());
|
|
10959
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
10960
|
-
if (this.matchPunct(",")) continue;
|
|
10961
|
-
this.expectPunct(")");
|
|
10962
|
-
break;
|
|
10963
|
-
}
|
|
10964
|
-
this.callees.add(callee);
|
|
10965
|
-
this.countNode();
|
|
10966
|
-
return {
|
|
10967
|
-
kind: "call",
|
|
10968
|
-
callee,
|
|
10969
|
-
args
|
|
10970
|
-
};
|
|
10971
|
-
}
|
|
10972
|
-
};
|
|
10973
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
10974
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
10975
|
-
function parseExpression(source) {
|
|
10976
|
-
return new Parser(tokenize(source)).parse();
|
|
10977
|
-
}
|
|
11230
|
+
* `device-adoption` — generic discovery + adoption surface,
|
|
11231
|
+
* system-scoped singleton. The de-HA-ified successor to `ha-discovery`:
|
|
11232
|
+
* keyed by `integrationId` (the addon resolves integrationId → broker
|
|
11233
|
+
* internally) rather than a connection-specific `brokerId`, and it
|
|
11234
|
+
* REUSES the shared `DiscoveredChildDevice` candidate type from
|
|
11235
|
+
* `device-discovery.cap.ts` so a single shared adoption panel renders
|
|
11236
|
+
* any integration's candidate tree.
|
|
11237
|
+
*
|
|
11238
|
+
* UI flow (Integrations page → integration detail):
|
|
11239
|
+
* 1. `listCandidates({integrationId, page, pageSize, filter})` →
|
|
11240
|
+
* paginated candidate list, each row collapsed.
|
|
11241
|
+
* 2. User expands a row → `getCandidate({integrationId,
|
|
11242
|
+
* childNativeId})` returns the single candidate (with its nested
|
|
11243
|
+
* `children`) for the accordion render.
|
|
11244
|
+
* 3. User clicks "Adopt" on one or more candidates →
|
|
11245
|
+
* `adopt({integrationId, childNativeIds, perCandidate?})`
|
|
11246
|
+
* materialises one parent CamStack device per candidate plus its
|
|
11247
|
+
* accessory children. `perCandidate[childNativeId]` carries an
|
|
11248
|
+
* optional display-name override + a `hiddenChildIds` pre-hide set
|
|
11249
|
+
* (the parent's `accessories.hiddenChildIds` gates UI visibility).
|
|
11250
|
+
* 4. User clicks "Release" → `release({integrationId, camDeviceId})`
|
|
11251
|
+
* removes the parent + every child from the kernel registry.
|
|
11252
|
+
*/
|
|
10978
11253
|
/**
|
|
10979
|
-
*
|
|
10980
|
-
*
|
|
10981
|
-
*
|
|
10982
|
-
* one
|
|
11254
|
+
* Provider-declared discovery *granularity* filter (the keystone of the
|
|
11255
|
+
* single-entity-import design). A provider advertises which discovery
|
|
11256
|
+
* granularities it supports via `listCandidateFilters`; the operator picks
|
|
11257
|
+
* one and it is passed back as the opaque `filter` string on
|
|
11258
|
+
* `listCandidates`/`adopt`.
|
|
10983
11259
|
*
|
|
10984
|
-
*
|
|
10985
|
-
*
|
|
10986
|
-
*
|
|
11260
|
+
* - **`id`** is provider-defined and OPAQUE to the cap + UI layers — only the
|
|
11261
|
+
* declaring provider interprets it. The reserved id `'devices'` is the
|
|
11262
|
+
* universal default (every provider's current behavior IS the `devices`
|
|
11263
|
+
* filter). A provider that declares no filters is treated as a single
|
|
11264
|
+
* implicit `{ id:'devices', label:'Devices', isDefault:true }`.
|
|
11265
|
+
* - **`label`** is the human label for the UI selector.
|
|
11266
|
+
* - **`isDefault`** marks the default filter; exactly one SHOULD be default.
|
|
10987
11267
|
*/
|
|
10988
|
-
var
|
|
10989
|
-
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
cache.set(source, hit);
|
|
10994
|
-
return hit;
|
|
10995
|
-
}
|
|
10996
|
-
let result;
|
|
10997
|
-
try {
|
|
10998
|
-
result = {
|
|
10999
|
-
ok: true,
|
|
11000
|
-
parsed: parseExpression(source)
|
|
11001
|
-
};
|
|
11002
|
-
} catch (err) {
|
|
11003
|
-
result = {
|
|
11004
|
-
ok: false,
|
|
11005
|
-
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
11006
|
-
};
|
|
11007
|
-
}
|
|
11008
|
-
cache.set(source, result);
|
|
11009
|
-
if (cache.size > 256) {
|
|
11010
|
-
const oldest = cache.keys().next().value;
|
|
11011
|
-
if (oldest !== void 0) cache.delete(oldest);
|
|
11012
|
-
}
|
|
11013
|
-
return result;
|
|
11014
|
-
}
|
|
11015
|
-
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
11016
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
11017
|
-
function compileExpressionSafe(source) {
|
|
11018
|
-
return getCached(source);
|
|
11019
|
-
}
|
|
11020
|
-
Object.freeze({});
|
|
11268
|
+
var AdoptionFilterSchema = object({
|
|
11269
|
+
id: string(),
|
|
11270
|
+
label: string(),
|
|
11271
|
+
isDefault: boolean().optional()
|
|
11272
|
+
});
|
|
11021
11273
|
/**
|
|
11022
|
-
*
|
|
11023
|
-
*
|
|
11024
|
-
*
|
|
11025
|
-
*
|
|
11026
|
-
*
|
|
11274
|
+
* Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
|
|
11275
|
+
* de-HA-ified — `area` / `manufacturer` stay free-form strings so any
|
|
11276
|
+
* integration can populate them from its own metadata. Distinct from the
|
|
11277
|
+
* granularity `AdoptionFilter` above: this narrows the candidate set within a
|
|
11278
|
+
* chosen granularity, whereas the granularity `filter` selects WHAT a
|
|
11279
|
+
* candidate is (a device vs an entity).
|
|
11027
11280
|
*/
|
|
11028
|
-
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
|
|
11035
|
-
|
|
11036
|
-
|
|
11037
|
-
|
|
11038
|
-
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
|
|
11281
|
+
var CandidateQueryFilterSchema = object({
|
|
11282
|
+
/** Substring filter on name + manufacturer + model. */
|
|
11283
|
+
search: string().optional(),
|
|
11284
|
+
/** Area-name exact match. */
|
|
11285
|
+
area: string().optional(),
|
|
11286
|
+
/** Manufacturer exact match. */
|
|
11287
|
+
manufacturer: string().optional(),
|
|
11288
|
+
/** When true, only return candidates the operator already adopted. */
|
|
11289
|
+
adoptedOnly: boolean().optional(),
|
|
11290
|
+
/** When true, only return candidates the operator hasn't adopted yet. */
|
|
11291
|
+
unadoptedOnly: boolean().optional()
|
|
11292
|
+
});
|
|
11293
|
+
var ListCandidatesInputSchema = object({
|
|
11294
|
+
integrationId: string(),
|
|
11295
|
+
page: number().int().positive().default(1),
|
|
11296
|
+
pageSize: number().int().positive().max(2e4).default(50),
|
|
11297
|
+
/**
|
|
11298
|
+
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
11299
|
+
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
11300
|
+
* exactly the pre-existing behavior (fully back-compatible).
|
|
11301
|
+
*/
|
|
11302
|
+
filter: string().optional(),
|
|
11303
|
+
/** Optional candidate-list text/query narrowing within the granularity. */
|
|
11304
|
+
filterText: CandidateQueryFilterSchema.optional()
|
|
11305
|
+
});
|
|
11306
|
+
var ListCandidatesOutputSchema = object({
|
|
11307
|
+
candidates: array(DiscoveredChildDeviceSchema).readonly(),
|
|
11308
|
+
totalCount: number().int().nonnegative(),
|
|
11309
|
+
page: number().int().positive(),
|
|
11310
|
+
pageSize: number().int().positive()
|
|
11311
|
+
});
|
|
11312
|
+
var GetCandidateInputSchema = object({
|
|
11313
|
+
integrationId: string(),
|
|
11314
|
+
childNativeId: string()
|
|
11315
|
+
});
|
|
11316
|
+
var AdoptionStatusSchema = object({
|
|
11317
|
+
/** Last refresh timestamp (ms epoch) — null when never refreshed. */
|
|
11318
|
+
lastDiscoveryAt: number().int().nonnegative().nullable(),
|
|
11319
|
+
/** Count of candidates in the discovery cache. */
|
|
11320
|
+
candidateCount: number().int().nonnegative(),
|
|
11321
|
+
/** Count of candidates the operator has already adopted. */
|
|
11322
|
+
adoptedCount: number().int().nonnegative(),
|
|
11323
|
+
/** Last error message from a refresh attempt. */
|
|
11324
|
+
lastError: string().nullable()
|
|
11325
|
+
});
|
|
11326
|
+
var PerCandidateSchema = object({
|
|
11327
|
+
/** Override the default display name for this candidate's parent. */
|
|
11328
|
+
name: string().min(1).optional(),
|
|
11329
|
+
/** Pre-hide a subset of entity-children — created (state still flows)
|
|
11330
|
+
* but listed in the parent's `accessories.hiddenChildIds`. */
|
|
11331
|
+
hiddenChildIds: array(string()).optional()
|
|
11332
|
+
});
|
|
11333
|
+
var AdoptInputSchema = object({
|
|
11334
|
+
integrationId: string(),
|
|
11335
|
+
/**
|
|
11336
|
+
* Candidate native ids to adopt. Their MEANING is filter-relative: under the
|
|
11337
|
+
* default `'devices'` granularity these are device-native ids; under a
|
|
11338
|
+
* provider-declared granularity (e.g. HA `'entities'`) they are that
|
|
11339
|
+
* granularity's native ids (e.g. entity ids). The field name is kept stable
|
|
11340
|
+
* to avoid a breaking rename.
|
|
11341
|
+
*/
|
|
11342
|
+
childNativeIds: array(string()).min(1),
|
|
11343
|
+
/**
|
|
11344
|
+
* Optional provider-declared discovery GRANULARITY id (opaque; see
|
|
11345
|
+
* `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
|
|
11346
|
+
* exactly the pre-existing behavior (fully back-compatible).
|
|
11347
|
+
*/
|
|
11348
|
+
filter: string().optional(),
|
|
11349
|
+
/** When true, import each adopted device's source-system location (e.g. HA
|
|
11350
|
+
* area) into CamStack — fuzzy-match an existing location or create it, then
|
|
11351
|
+
* assign. Omitted/false = no location work (back-compat). */
|
|
11352
|
+
importLocations: boolean().optional(),
|
|
11353
|
+
perCandidate: record(string(), PerCandidateSchema).optional()
|
|
11354
|
+
});
|
|
11355
|
+
var AdoptResultSchema = object({ adopted: array(object({
|
|
11356
|
+
childNativeId: string(),
|
|
11357
|
+
parentDeviceId: number().int().nonnegative(),
|
|
11358
|
+
accessoryDeviceIds: array(number().int().nonnegative()).readonly()
|
|
11359
|
+
})).readonly() });
|
|
11360
|
+
var ReleaseInputSchema = object({
|
|
11361
|
+
integrationId: string(),
|
|
11362
|
+
/** Parent CamStack device id (NOT an accessory child id). Removing
|
|
11363
|
+
* the parent cascades into every accessory. */
|
|
11364
|
+
camDeviceId: number().int().nonnegative()
|
|
11365
|
+
});
|
|
11366
|
+
var ResyncInputSchema = object({
|
|
11367
|
+
/** Parent CamStack device id of an adopted device. The provider resolves its
|
|
11368
|
+
* source (integration/broker + native id) and re-aligns the device's
|
|
11369
|
+
* structural spec (type/role/capabilities/units) with the live mapping,
|
|
11370
|
+
* rebuilding any child whose class changed while preserving operator edits. */
|
|
11371
|
+
camDeviceId: number().int().nonnegative(),
|
|
11372
|
+
/** "Resync from zero" (#19). When true, the kernel PURGES every accessory
|
|
11373
|
+
* child of `camDeviceId` BEFORE the provider re-derives the device, so the
|
|
11374
|
+
* children are rebuilt fresh from source — correct names, coords, and units —
|
|
11375
|
+
* instead of being preserved by the incremental reconcile. Use to recover from
|
|
11376
|
+
* legacy generic/placeholder names that the normal name-precedence keeps frozen
|
|
11377
|
+
* (the operator's explicit reset). Push-driven integrations (no-op resync)
|
|
11378
|
+
* rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
|
|
11379
|
+
* Operator edits on the PARENT (its name, layout, primary-child pick) survive —
|
|
11380
|
+
* only the children are torn down. Omitted/false ⇒ the normal incremental
|
|
11381
|
+
* re-sync that preserves children. */
|
|
11382
|
+
resetToSource: boolean().optional()
|
|
11383
|
+
});
|
|
11384
|
+
var ResyncResultSchema = object({
|
|
11385
|
+
/** True when the persisted spec actually changed (children may have been rebuilt). */
|
|
11386
|
+
changed: boolean(),
|
|
11387
|
+
/** Number of child devices rebuilt into a new class by this re-sync. */
|
|
11388
|
+
rebuiltChildren: number().int().nonnegative(),
|
|
11389
|
+
/** Number of accessory children torn down by a `resetToSource` purge before the
|
|
11390
|
+
* provider re-derived the device. 0/absent for a normal incremental re-sync. */
|
|
11391
|
+
removedChildren: number().int().nonnegative().optional()
|
|
11392
|
+
});
|
|
11393
|
+
method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
|
|
11394
|
+
kind: "mutation",
|
|
11395
|
+
auth: "admin"
|
|
11396
|
+
}), method(AdoptInputSchema, AdoptResultSchema, {
|
|
11397
|
+
kind: "mutation",
|
|
11398
|
+
auth: "admin"
|
|
11399
|
+
}), method(ReleaseInputSchema, _void(), {
|
|
11400
|
+
kind: "mutation",
|
|
11401
|
+
auth: "admin"
|
|
11402
|
+
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
11403
|
+
kind: "mutation",
|
|
11404
|
+
auth: "admin"
|
|
11405
|
+
});
|
|
11406
|
+
/**
|
|
11407
|
+
* `device-export` — collection cap for addons that export camstack
|
|
11408
|
+
* devices to external ecosystems (HomeAssistant via MQTT discovery,
|
|
11409
|
+
* HomeKit/HAP, Alexa Smart Home, …).
|
|
11410
|
+
*
|
|
11411
|
+
* No `ecosystem` enum — the addon id identifies the export target.
|
|
11412
|
+
* Each addon owns its mapping logic in its own settings UI. The cap
|
|
11413
|
+
* exposes only the COMMON surface: link state, supported device
|
|
11414
|
+
* kinds (so the UI can filter the device picker), and per-device
|
|
11415
|
+
* expose/unexpose.
|
|
11416
|
+
*/
|
|
11417
|
+
var LinkStateSchema = _enum([
|
|
11418
|
+
"unlinked",
|
|
11419
|
+
"linked",
|
|
11420
|
+
"error"
|
|
11421
|
+
]);
|
|
11422
|
+
/**
|
|
11423
|
+
* A single label/value row surfaced in the export panel's "Setup"
|
|
11424
|
+
* section. Rendered with a copy-to-clipboard button. `secret: true`
|
|
11425
|
+
* rows are masked by default with a reveal toggle (client ids,
|
|
11426
|
+
* secrets, …).
|
|
11427
|
+
*/
|
|
11428
|
+
var ExportSetupFieldSchema = object({
|
|
11429
|
+
label: string(),
|
|
11430
|
+
value: string(),
|
|
11431
|
+
/** Mask the value by default + render a reveal toggle (client id, secrets). */
|
|
11432
|
+
secret: boolean().optional()
|
|
11433
|
+
});
|
|
11434
|
+
/**
|
|
11435
|
+
* Generic, addon-agnostic pairing/account block. Any export addon can
|
|
11436
|
+
* surface a scannable QR, a set of copyable label/value rows, and a
|
|
11437
|
+
* free-form operator note — the `DeviceExportPanel` renders whatever
|
|
11438
|
+
* the provider supplies and skips the section entirely when `setup`
|
|
11439
|
+
* is unset.
|
|
11440
|
+
*/
|
|
11441
|
+
var ExportSetupSchema = object({
|
|
11442
|
+
/** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
|
|
11443
|
+
qr: string().optional(),
|
|
11444
|
+
/** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
|
|
11445
|
+
fields: array(ExportSetupFieldSchema).readonly().optional(),
|
|
11446
|
+
/** Free-form operator instructions rendered above the fields. */
|
|
11447
|
+
note: string().optional()
|
|
11448
|
+
});
|
|
11449
|
+
var DeviceExportStatusSchema = object({
|
|
11450
|
+
linkState: LinkStateSchema,
|
|
11451
|
+
exposedDeviceCount: number(),
|
|
11452
|
+
error: string().optional(),
|
|
11453
|
+
/**
|
|
11454
|
+
* Optional pairing/account info the panel renders in a generic
|
|
11455
|
+
* "Setup" section. Addon-agnostic — the addon id identifies the
|
|
11456
|
+
* export target, never an `ecosystem` key here.
|
|
11457
|
+
*/
|
|
11458
|
+
setup: ExportSetupSchema.optional()
|
|
11459
|
+
});
|
|
11460
|
+
var DeviceKindSchema = string();
|
|
11461
|
+
var ExposedDeviceSchema = object({
|
|
11462
|
+
deviceId: string(),
|
|
11463
|
+
exposedAs: string().optional(),
|
|
11464
|
+
capabilities: array(string()).optional()
|
|
11465
|
+
});
|
|
11466
|
+
var ExposeInputSchema = object({
|
|
11467
|
+
deviceId: string(),
|
|
11468
|
+
capabilities: array(string()).optional()
|
|
11469
|
+
});
|
|
11470
|
+
var UnexposeInputSchema = object({ deviceId: string() });
|
|
11471
|
+
method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
|
|
11045
11472
|
var ProviderStatusSchema = object({
|
|
11046
11473
|
connected: boolean(),
|
|
11047
11474
|
deviceCount: number(),
|
|
@@ -11221,92 +11648,6 @@ var ChildLayoutEntrySchema = object({
|
|
|
11221
11648
|
order: number().optional(),
|
|
11222
11649
|
collapsed: boolean().optional()
|
|
11223
11650
|
});
|
|
11224
|
-
/** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
|
|
11225
|
-
* `device-management.ts`. Source is a union: a FIELD source copies a sibling
|
|
11226
|
-
* accessory's status field (`kind` optional/absent for wire compat); a
|
|
11227
|
-
* LITERAL source carries a per-device constant (no sibling is read); a
|
|
11228
|
-
* GLOBAL source (P2e) copies ANY device's status field, addressed by the
|
|
11229
|
-
* source device's full re-sync-stable `stableId`. */
|
|
11230
|
-
var DeviceLinkFieldSourceSchema = object({
|
|
11231
|
-
kind: literal("field").optional(),
|
|
11232
|
-
sourceKey: string(),
|
|
11233
|
-
cap: string(),
|
|
11234
|
-
fieldPath: string()
|
|
11235
|
-
});
|
|
11236
|
-
var DeviceLinkLiteralSourceSchema = object({
|
|
11237
|
-
kind: literal("literal"),
|
|
11238
|
-
value: union([
|
|
11239
|
-
string(),
|
|
11240
|
-
number(),
|
|
11241
|
-
boolean(),
|
|
11242
|
-
_null()
|
|
11243
|
-
])
|
|
11244
|
-
});
|
|
11245
|
-
var DeviceLinkGlobalSourceSchema = object({
|
|
11246
|
-
kind: literal("global"),
|
|
11247
|
-
sourceStableId: string(),
|
|
11248
|
-
cap: string(),
|
|
11249
|
-
fieldPath: string()
|
|
11250
|
-
});
|
|
11251
|
-
/** Expression source (Stage X): compute the target field from N named bindings
|
|
11252
|
-
* via the safe expression engine. Bindings are field | literal | global — never
|
|
11253
|
-
* another expression (no nesting). The `superRefine` runs the SAME author-time
|
|
11254
|
-
* validation as `validateExpressionSource` (compiles the expr, checks binding
|
|
11255
|
-
* names + identifier coverage) so every wire boundary that parses a DeviceLink
|
|
11256
|
-
* (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
|
|
11257
|
-
* Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
|
|
11258
|
-
var DeviceLinkExpressionSourceSchema = object({
|
|
11259
|
-
kind: literal("expression"),
|
|
11260
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
11261
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
|
|
11262
|
-
DeviceLinkFieldSourceSchema,
|
|
11263
|
-
DeviceLinkLiteralSourceSchema,
|
|
11264
|
-
DeviceLinkGlobalSourceSchema
|
|
11265
|
-
]))
|
|
11266
|
-
}).superRefine((src, ctx) => {
|
|
11267
|
-
const err = validateExpressionSource(src);
|
|
11268
|
-
if (err !== null) ctx.addIssue({
|
|
11269
|
-
code: "custom",
|
|
11270
|
-
message: err,
|
|
11271
|
-
path: ["expr"]
|
|
11272
|
-
});
|
|
11273
|
-
});
|
|
11274
|
-
var DeviceLinkSchema = object({
|
|
11275
|
-
id: string(),
|
|
11276
|
-
source: union([
|
|
11277
|
-
DeviceLinkFieldSourceSchema,
|
|
11278
|
-
DeviceLinkLiteralSourceSchema,
|
|
11279
|
-
DeviceLinkGlobalSourceSchema,
|
|
11280
|
-
DeviceLinkExpressionSourceSchema
|
|
11281
|
-
]),
|
|
11282
|
-
target: object({
|
|
11283
|
-
cap: string(),
|
|
11284
|
-
fieldPath: string(),
|
|
11285
|
-
itemKey: string().optional()
|
|
11286
|
-
}),
|
|
11287
|
-
transform: discriminatedUnion("kind", [
|
|
11288
|
-
object({ kind: literal("identity") }),
|
|
11289
|
-
object({
|
|
11290
|
-
kind: literal("enum-map"),
|
|
11291
|
-
mapping: record(string(), union([
|
|
11292
|
-
string(),
|
|
11293
|
-
number(),
|
|
11294
|
-
boolean()
|
|
11295
|
-
])),
|
|
11296
|
-
fallback: union([
|
|
11297
|
-
string(),
|
|
11298
|
-
number(),
|
|
11299
|
-
boolean()
|
|
11300
|
-
]).optional()
|
|
11301
|
-
}),
|
|
11302
|
-
object({
|
|
11303
|
-
kind: literal("linear"),
|
|
11304
|
-
scale: number(),
|
|
11305
|
-
offset: number(),
|
|
11306
|
-
clamp: tuple([number(), number()]).readonly().optional()
|
|
11307
|
-
})
|
|
11308
|
-
]).optional()
|
|
11309
|
-
});
|
|
11310
11651
|
/** Cap-wire shape of a per-cap display refinement — mirrors
|
|
11311
11652
|
* `DeviceCapDisplayOverride` in `device-management.ts`. */
|
|
11312
11653
|
var DeviceCapDisplayOverrideSchema = object({
|
|
@@ -11386,8 +11727,6 @@ var DeviceInfoSchema = object({
|
|
|
11386
11727
|
* named accordion sections (with optional intra-section order). See
|
|
11387
11728
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
11388
11729
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
11389
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
11390
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
11391
11730
|
/** Operator-authored per-device display override. See `DeviceMeta.display`. */
|
|
11392
11731
|
display: DeviceDisplayOverrideSchema.optional()
|
|
11393
11732
|
});
|
|
@@ -11396,7 +11735,7 @@ var ConfigEntrySchema = object({
|
|
|
11396
11735
|
value: unknown(),
|
|
11397
11736
|
description: string().optional()
|
|
11398
11737
|
});
|
|
11399
|
-
var
|
|
11738
|
+
var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
|
|
11400
11739
|
/** One resolved linked device — the compact projection consumers need. */
|
|
11401
11740
|
var LinkedDeviceSchema = object({
|
|
11402
11741
|
deviceId: number(),
|
|
@@ -11459,8 +11798,6 @@ var DeviceMetaSchema = object({
|
|
|
11459
11798
|
* accordion sections (with optional intra-section order). See
|
|
11460
11799
|
* `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
|
|
11461
11800
|
childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
|
|
11462
|
-
/** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
|
|
11463
|
-
deviceLinks: array(DeviceLinkSchema).readonly().optional(),
|
|
11464
11801
|
/** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
|
|
11465
11802
|
* Optional: only present for accessory children that carry a known role. */
|
|
11466
11803
|
role: string().nullable().optional(),
|
|
@@ -11553,12 +11890,6 @@ method(object({
|
|
|
11553
11890
|
}), _void(), {
|
|
11554
11891
|
kind: "mutation",
|
|
11555
11892
|
auth: "admin"
|
|
11556
|
-
}), method(object({
|
|
11557
|
-
deviceId: number(),
|
|
11558
|
-
deviceLinks: array(DeviceLinkSchema).readonly()
|
|
11559
|
-
}), _void(), {
|
|
11560
|
-
kind: "mutation",
|
|
11561
|
-
auth: "admin"
|
|
11562
11893
|
}), method(object({
|
|
11563
11894
|
deviceId: number(),
|
|
11564
11895
|
display: DeviceDisplayOverrideSchema.nullable()
|
|
@@ -11640,7 +11971,7 @@ method(object({
|
|
|
11640
11971
|
* shipping 293 rows to find 12. */
|
|
11641
11972
|
isCamera: boolean().optional()
|
|
11642
11973
|
}), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
|
|
11643
|
-
mode:
|
|
11974
|
+
mode: LinkedDevicesModeSchema,
|
|
11644
11975
|
devices: array(LinkedDeviceSchema)
|
|
11645
11976
|
})), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
|
|
11646
11977
|
deviceId: number(),
|
|
@@ -11673,11 +12004,7 @@ method(object({
|
|
|
11673
12004
|
deviceId: number(),
|
|
11674
12005
|
entries: array(object({
|
|
11675
12006
|
capName: string(),
|
|
11676
|
-
kind: _enum([
|
|
11677
|
-
"native",
|
|
11678
|
-
"wrapped",
|
|
11679
|
-
"linked"
|
|
11680
|
-
]),
|
|
12007
|
+
kind: _enum(["native", "wrapped"]),
|
|
11681
12008
|
providerAddonId: string(),
|
|
11682
12009
|
providerNodeId: string(),
|
|
11683
12010
|
nativeAddonId: string()
|
|
@@ -11686,11 +12013,7 @@ method(object({
|
|
|
11686
12013
|
deviceId: number(),
|
|
11687
12014
|
entries: array(object({
|
|
11688
12015
|
capName: string(),
|
|
11689
|
-
kind: _enum([
|
|
11690
|
-
"native",
|
|
11691
|
-
"wrapped",
|
|
11692
|
-
"linked"
|
|
11693
|
-
]),
|
|
12016
|
+
kind: _enum(["native", "wrapped"]),
|
|
11694
12017
|
providerAddonId: string(),
|
|
11695
12018
|
providerNodeId: string(),
|
|
11696
12019
|
nativeAddonId: string()
|
|
@@ -11791,6 +12114,15 @@ method(object({
|
|
|
11791
12114
|
}), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
|
|
11792
12115
|
kind: "mutation",
|
|
11793
12116
|
auth: "admin"
|
|
12117
|
+
}), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
|
|
12118
|
+
kind: "mutation",
|
|
12119
|
+
auth: "admin"
|
|
12120
|
+
}), method(object({
|
|
12121
|
+
addonId: string(),
|
|
12122
|
+
integrationId: string().optional()
|
|
12123
|
+
}), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
12124
|
+
kind: "mutation",
|
|
12125
|
+
auth: "admin"
|
|
11794
12126
|
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
11795
12127
|
kind: "mutation",
|
|
11796
12128
|
auth: "admin"
|
|
@@ -12493,7 +12825,7 @@ var MotionAnalysisResultSchema = object({
|
|
|
12493
12825
|
frameHeight: number(),
|
|
12494
12826
|
analysisMs: number()
|
|
12495
12827
|
});
|
|
12496
|
-
method(object({
|
|
12828
|
+
DeviceType.Camera, method(object({
|
|
12497
12829
|
deviceId: number(),
|
|
12498
12830
|
frame: FrameInputSchema.optional(),
|
|
12499
12831
|
frameHandle: FrameHandleSchema.optional()
|
|
@@ -12513,7 +12845,7 @@ method(object({
|
|
|
12513
12845
|
* Why: pub/sub routing over the system event-bus loses fidelity
|
|
12514
12846
|
* (callback shape, QoS guarantees, will/retain semantics) and adds
|
|
12515
12847
|
* refcount bookkeeping that addons would rather own themselves. The
|
|
12516
|
-
* canonical consumer
|
|
12848
|
+
* canonical consumer needs raw `mqtt.js`
|
|
12517
12849
|
* features anyway — give it the connection config, get out of the way.
|
|
12518
12850
|
*
|
|
12519
12851
|
* Consumer flow:
|
|
@@ -13706,8 +14038,35 @@ var NcDeliverySchema = _enum([
|
|
|
13706
14038
|
"immediate",
|
|
13707
14039
|
"track-end",
|
|
13708
14040
|
"device-event",
|
|
13709
|
-
"package-event"
|
|
14041
|
+
"package-event",
|
|
14042
|
+
"system-event"
|
|
13710
14043
|
]);
|
|
14044
|
+
/**
|
|
14045
|
+
* Stable Notification Center vocabulary over infrastructure/liveness events.
|
|
14046
|
+
* Bus categories are normalized into these intent-level kinds so rules do not
|
|
14047
|
+
* depend on a provider's raw event name or payload shape.
|
|
14048
|
+
*/
|
|
14049
|
+
var NcSystemEventKindSchema = _enum([
|
|
14050
|
+
"camera-online",
|
|
14051
|
+
"camera-offline",
|
|
14052
|
+
"stream-online",
|
|
14053
|
+
"stream-offline",
|
|
14054
|
+
"node-online",
|
|
14055
|
+
"node-offline",
|
|
14056
|
+
"addon-update-available",
|
|
14057
|
+
"server-update-available"
|
|
14058
|
+
]);
|
|
14059
|
+
/**
|
|
14060
|
+
* One coherent system-event condition. `kinds` is the required opt-in safety
|
|
14061
|
+
* gate; the remaining lists are optional narrowing filters relevant to the
|
|
14062
|
+
* selected kinds.
|
|
14063
|
+
*/
|
|
14064
|
+
var NcSystemEventConditionSchema = object({
|
|
14065
|
+
kinds: array(NcSystemEventKindSchema).min(1),
|
|
14066
|
+
deviceIds: array(number().int()).min(1).optional(),
|
|
14067
|
+
nodeIds: array(string().min(1)).min(1).optional(),
|
|
14068
|
+
packageNames: array(string().min(1)).min(1).optional()
|
|
14069
|
+
});
|
|
13711
14070
|
/** Weekly schedule — OR of windows; absence on the rule = always active. */
|
|
13712
14071
|
var NcScheduleSchema = object({
|
|
13713
14072
|
windows: array(object({
|
|
@@ -14012,6 +14371,8 @@ var NcConditionsSchema = object({
|
|
|
14012
14371
|
"picked-up",
|
|
14013
14372
|
"both"
|
|
14014
14373
|
]).optional(),
|
|
14374
|
+
/** Infrastructure/liveness/update event matcher (`system-event` delivery). */
|
|
14375
|
+
systemEvent: NcSystemEventConditionSchema.optional(),
|
|
14015
14376
|
/**
|
|
14016
14377
|
* PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
|
|
14017
14378
|
* (MaskShape vocabulary). A record passes when its bbox overlaps ANY
|
|
@@ -14231,7 +14592,8 @@ var NcTestResultSchema = object({
|
|
|
14231
14592
|
"object-event",
|
|
14232
14593
|
"track",
|
|
14233
14594
|
"device-event",
|
|
14234
|
-
"package-event"
|
|
14595
|
+
"package-event",
|
|
14596
|
+
"system-event"
|
|
14235
14597
|
]),
|
|
14236
14598
|
deviceId: number(),
|
|
14237
14599
|
timestamp: number(),
|
|
@@ -14253,7 +14615,8 @@ var NcConditionDescriptorSchema = object({
|
|
|
14253
14615
|
"schedule",
|
|
14254
14616
|
"device",
|
|
14255
14617
|
"package",
|
|
14256
|
-
"occupancy"
|
|
14618
|
+
"occupancy",
|
|
14619
|
+
"system"
|
|
14257
14620
|
]),
|
|
14258
14621
|
label: string(),
|
|
14259
14622
|
/** Editor widget the UI renders — never hardcode per-condition forms. */
|
|
@@ -14271,7 +14634,8 @@ var NcConditionDescriptorSchema = object({
|
|
|
14271
14634
|
"crossingSelect",
|
|
14272
14635
|
"polygonDraw",
|
|
14273
14636
|
"occupancy",
|
|
14274
|
-
"deviceState"
|
|
14637
|
+
"deviceState",
|
|
14638
|
+
"systemEvent"
|
|
14275
14639
|
]),
|
|
14276
14640
|
operator: _enum([
|
|
14277
14641
|
"in",
|
|
@@ -14330,7 +14694,8 @@ var NcHistoryRecordKindSchema = _enum([
|
|
|
14330
14694
|
"object-event",
|
|
14331
14695
|
"track-end",
|
|
14332
14696
|
"device-event",
|
|
14333
|
-
"package-event"
|
|
14697
|
+
"package-event",
|
|
14698
|
+
"system-event"
|
|
14334
14699
|
]);
|
|
14335
14700
|
/** Subject summary frozen on the row at fire time (survives rule/record edits). */
|
|
14336
14701
|
var NcHistorySubjectSchema = object({
|
|
@@ -14338,7 +14703,14 @@ var NcHistorySubjectSchema = object({
|
|
|
14338
14703
|
label: string().optional(),
|
|
14339
14704
|
confidence: number().optional(),
|
|
14340
14705
|
zones: array(string()),
|
|
14341
|
-
timestamp: number()
|
|
14706
|
+
timestamp: number(),
|
|
14707
|
+
systemEvent: object({
|
|
14708
|
+
kind: NcSystemEventKindSchema,
|
|
14709
|
+
subject: string(),
|
|
14710
|
+
title: string(),
|
|
14711
|
+
body: string(),
|
|
14712
|
+
data: record(string(), unknown())
|
|
14713
|
+
}).optional()
|
|
14342
14714
|
});
|
|
14343
14715
|
/**
|
|
14344
14716
|
* One delivery-history row. This is a read-only VIEW over the durable
|
|
@@ -14698,6 +15070,76 @@ object({
|
|
|
14698
15070
|
* Each provider returns a static descriptor; the core enumerates them
|
|
14699
15071
|
* to validate the `integration=` query param and resolve the consent
|
|
14700
15072
|
* label + the scopes baked into the issued token.
|
|
15073
|
+
*
|
|
15074
|
+
* ## Declaring one
|
|
15075
|
+
*
|
|
15076
|
+
* An OAuth client is integration-specific knowledge — who the client is, what
|
|
15077
|
+
* it may ask for, where it may be sent — so it is declared by the ADDON that
|
|
15078
|
+
* owns the integration, never by the kernel and never as a branch inside
|
|
15079
|
+
* `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
|
|
15080
|
+
* steps, no others:
|
|
15081
|
+
*
|
|
15082
|
+
* 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
|
|
15083
|
+
* manifest entry. This is also what tells the hub, at addon-LOAD time, that
|
|
15084
|
+
* a descriptor is owed — see "the boot window" below.
|
|
15085
|
+
* 2. Return a provider from `onInitialize()`:
|
|
15086
|
+
*
|
|
15087
|
+
* ```ts
|
|
15088
|
+
* const provider: IOauthIntegrationProvider = {
|
|
15089
|
+
* getDescriptor: async () => ({
|
|
15090
|
+
* integrationId: 'my-thing', // the `integration=` query param
|
|
15091
|
+
* displayName: 'My Thing',
|
|
15092
|
+
* requestedScopes: [ … ], // see below
|
|
15093
|
+
* allowedRedirectPrefixes: ['https://callback.example/'],
|
|
15094
|
+
* }),
|
|
15095
|
+
* }
|
|
15096
|
+
* return [{ capability: oauthIntegrationCapability, provider }]
|
|
15097
|
+
* ```
|
|
15098
|
+
*
|
|
15099
|
+
* The descriptor must be **static** — it is read on the authorize path, so
|
|
15100
|
+
* never put an await on network or disk behind it, and never register it
|
|
15101
|
+
* behind one either (a provider is registered only once `onInitialize`
|
|
15102
|
+
* RETURNS, so anything awaited before the return delays linking).
|
|
15103
|
+
* 3. Nothing else. There is no allow-list to join, no id to register with the
|
|
15104
|
+
* core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
|
|
15105
|
+
* `/api/oauth2/integrations` are built from this collection alone.
|
|
15106
|
+
*
|
|
15107
|
+
* **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
|
|
15108
|
+
* NEEDS to function.** Not a blast radius, not a conservative
|
|
15109
|
+
* under-declaration, not a description of some other path the addon happens to
|
|
15110
|
+
* have. Derive it from what the client actually calls **with this token** —
|
|
15111
|
+
* every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
|
|
15112
|
+
* addon HTTP route it posts to — and write the call that justifies each entry
|
|
15113
|
+
* next to it. Two integrations once used this field to mean two different
|
|
15114
|
+
* things; the operator ruled there is one meaning, and any third integration
|
|
15115
|
+
* inherits it (2026-08-09).
|
|
15116
|
+
*
|
|
15117
|
+
* This is not documentation, it is the ENFORCEMENT INPUT. Since
|
|
15118
|
+
* [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
|
|
15119
|
+
* checks an integration token's grant before letting it reach an
|
|
15120
|
+
* `access: 'authenticated'` route, so an **under-declaration is an integration
|
|
15121
|
+
* that stops working** — a missing `addon:` entry means `403 Token scope
|
|
15122
|
+
* mismatch` on every control the client tries to actuate. Widen the descriptor
|
|
15123
|
+
* honestly rather than weakening a check to make a route pass.
|
|
15124
|
+
*
|
|
15125
|
+
* Prefer a narrow `capability:` scope to a `category:` one unless the client
|
|
15126
|
+
* genuinely needs a whole family; a category scope grants every future member
|
|
15127
|
+
* of that category too. `category:system [create]` has been rejected once and
|
|
15128
|
+
* should stay rejected: it hands `addons.installPackage` to an integration.
|
|
15129
|
+
*
|
|
15130
|
+
* Calls the ADDON itself makes over `ctx.api` run as the addon and are not
|
|
15131
|
+
* scope-checked, so they are not what this field describes — but reaching the
|
|
15132
|
+
* addon's route in the first place IS, and that is the entry to declare.
|
|
15133
|
+
*
|
|
15134
|
+
* **The boot window.** An addon registers its provider after its runner forks
|
|
15135
|
+
* and initialises, so between hub start and that moment this collection is
|
|
15136
|
+
* incomplete and an `integrationId` can be legitimately absent. The core does
|
|
15137
|
+
* not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
|
|
15138
|
+
* it compares the manifest declarers against the registered providers and
|
|
15139
|
+
* answers `503 temporarily_unavailable` (with `Retry-After` and the pending
|
|
15140
|
+
* addon ids) instead of `400 unknown integration`, and reports
|
|
15141
|
+
* `complete: false` on `GET /api/oauth2/integrations`. A client should retry
|
|
15142
|
+
* while the list is incomplete rather than conclude the hub cannot do OAuth.
|
|
14701
15143
|
*/
|
|
14702
15144
|
var OauthIntegrationDescriptorSchema = object({
|
|
14703
15145
|
/** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
|
|
@@ -14710,13 +15152,48 @@ var OauthIntegrationDescriptorSchema = object({
|
|
|
14710
15152
|
* redirect_uri that does not start with one of these. Required —
|
|
14711
15153
|
* an empty list means the integration can never complete linking. */
|
|
14712
15154
|
allowedRedirectPrefixes: array(string()).min(1),
|
|
15155
|
+
/** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
|
|
15156
|
+
* RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
|
|
15157
|
+
* `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
|
|
15158
|
+
* whose address the hub cannot know in advance (a Home Assistant at
|
|
15159
|
+
* `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
|
|
15160
|
+
* exactly; a public host never satisfies this branch, so it is not a
|
|
15161
|
+
* wildcard prefix by another name. */
|
|
15162
|
+
allowedPrivateHostPaths: array(string()).optional(),
|
|
15163
|
+
/** When true this is a PUBLIC client (source is published, no secret can be
|
|
15164
|
+
* protected) and PKCE is mandatory: `/authorize` refuses without an S256
|
|
15165
|
+
* `code_challenge`, `/token` refuses without the matching `code_verifier`. */
|
|
15166
|
+
requiresPkce: boolean().optional(),
|
|
14713
15167
|
/** Optional public origin (no trailing slash) that this integration's
|
|
14714
15168
|
* issued codes/tokens should carry as the `hubUrl` claim — typically the
|
|
14715
15169
|
* operator-selected external-access endpoint resolved by the addon. When
|
|
14716
15170
|
* present, /api/oauth2/authorize bakes THIS into the code instead of the
|
|
14717
15171
|
* hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
|
|
14718
15172
|
* the hub's env) drives the claim that its cloud Lambda routes back on. */
|
|
14719
|
-
hubUrl: string().optional()
|
|
15173
|
+
hubUrl: string().optional(),
|
|
15174
|
+
/**
|
|
15175
|
+
* How long a REFRESH token issued for this integration lives — seconds, or
|
|
15176
|
+
* `'never'` for a token minted with no `exp` claim at all. Omit to keep the
|
|
15177
|
+
* 30-day default, which is what every link used before this field existed.
|
|
15178
|
+
*
|
|
15179
|
+
* Declared here for the same reason `requestedScopes` is: the integration
|
|
15180
|
+
* knows what it needs. Amazon's account linking and a Home Assistant config
|
|
15181
|
+
* entry are both meant to survive indefinitely, and re-linking is a manual
|
|
15182
|
+
* user action, so a 30-day expiry silently unlinks a working integration.
|
|
15183
|
+
*
|
|
15184
|
+
* **The security posture, stated so it is owned deliberately.** A refresh
|
|
15185
|
+
* token that never expires is permanent access if it leaks. What bounds it is
|
|
15186
|
+
* revocation, not time: `oauthRefresh` re-reads the session on every use and
|
|
15187
|
+
* returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
|
|
15188
|
+
* The one gap is the ACCESS token — it is a plain signed JWT that nothing
|
|
15189
|
+
* re-checks against the session on the `/trpc` and `/addon/*` paths, so
|
|
15190
|
+
* revoking a link takes effect there only after its remaining hour. That hour
|
|
15191
|
+
* is why the access TTL is not configurable.
|
|
15192
|
+
*
|
|
15193
|
+
* The value is baked into the authorization code at `/authorize` and travels
|
|
15194
|
+
* on the tokens, so editing this field changes FUTURE links only.
|
|
15195
|
+
*/
|
|
15196
|
+
refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
|
|
14720
15197
|
});
|
|
14721
15198
|
method(_void(), OauthIntegrationDescriptorSchema);
|
|
14722
15199
|
/**
|
|
@@ -14860,7 +15337,7 @@ var TrackEnvelopeSchema = object({
|
|
|
14860
15337
|
* `snapshots[]` references — megabytes across a page of tracks. `slim`
|
|
14861
15338
|
* keeps every scalar the list surfaces actually render (ids, class(es),
|
|
14862
15339
|
* label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
|
|
14863
|
-
* zonesVisited, bestEventId, envelope) and returns `positions` /
|
|
15340
|
+
* zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
|
|
14864
15341
|
* `snapshots` as EMPTY arrays — detail views re-fetch the full row via
|
|
14865
15342
|
* `getTrack`. Mirrors the event-store `projection` convention
|
|
14866
15343
|
* (`getObjectEvents` et al.).
|
|
@@ -14902,6 +15379,30 @@ var TrackSourceSchema = _enum([
|
|
|
14902
15379
|
"audio"
|
|
14903
15380
|
]);
|
|
14904
15381
|
/**
|
|
15382
|
+
* Where a track sits in the RETRAIN lifecycle (D81).
|
|
15383
|
+
*
|
|
15384
|
+
* - `none` — never marked, or un-marked. Evictable.
|
|
15385
|
+
* - `staging` — the operator wants this track as training material and has not
|
|
15386
|
+
* finished with it. **This is the only state retention holds**: the track and
|
|
15387
|
+
* everything it owns (object events, crops, keyframes, CLIP vector) survive
|
|
15388
|
+
* the device's age window.
|
|
15389
|
+
* - `trained` — the retrain page has taken what it needed. The frames it chose
|
|
15390
|
+
* were COPIED into the retrain dataset at selection time, so the dataset no
|
|
15391
|
+
* longer depends on the track's media and the track becomes EVICTABLE again.
|
|
15392
|
+
* Terminal for the plain `markForTrain` toggle: returning it to `staging` is
|
|
15393
|
+
* a deliberate action of the retrain page, not a side effect of a checkbox.
|
|
15394
|
+
*
|
|
15395
|
+
* There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
|
|
15396
|
+
* the store's filter language has only positive equality and `whereIn` — no
|
|
15397
|
+
* negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
|
|
15398
|
+
* would make the entire pre-column history immortal in one deploy.
|
|
15399
|
+
*/
|
|
15400
|
+
var RetrainStatusSchema = _enum([
|
|
15401
|
+
"none",
|
|
15402
|
+
"staging",
|
|
15403
|
+
"trained"
|
|
15404
|
+
]);
|
|
15405
|
+
/**
|
|
14905
15406
|
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
14906
15407
|
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
14907
15408
|
* so the two surfaces cannot drift.
|
|
@@ -14911,18 +15412,31 @@ var TrackSourceSchema = _enum([
|
|
|
14911
15412
|
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
14912
15413
|
* `flag === true`, not `flag !== false`.
|
|
14913
15414
|
*
|
|
14914
|
-
*
|
|
14915
|
-
*
|
|
14916
|
-
*
|
|
14917
|
-
* `
|
|
15415
|
+
* `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
|
|
15416
|
+
* it is exactly `retrainStatus === 'staging'`, in both directions. Writing
|
|
15417
|
+
* `true` moves `none → staging`, writing `false` moves `staging → none`, and a
|
|
15418
|
+
* `trained` track reports `false` while refusing both writes. The boolean is
|
|
15419
|
+
* kept because three surfaces drive a toggle off it; anything that needs to tell
|
|
15420
|
+
* "never marked" from "already trained" must read `retrainStatus`.
|
|
15421
|
+
*
|
|
15422
|
+
* `debug` does NOT pin; it is attention, not durability.
|
|
14918
15423
|
*/
|
|
14919
15424
|
var TrackFlagFields = {
|
|
14920
|
-
/** Operator marked this track as training material.
|
|
15425
|
+
/** Operator marked this track as training material — i.e. `retrainStatus` is
|
|
15426
|
+
* `'staging'`. */
|
|
14921
15427
|
markForTrain: boolean().optional(),
|
|
14922
15428
|
/** Operator marked this track for diagnostic attention. */
|
|
14923
15429
|
debug: boolean().optional()
|
|
14924
15430
|
};
|
|
14925
15431
|
/**
|
|
15432
|
+
* The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
|
|
15433
|
+
* Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
|
|
15434
|
+
* write patch, and the status is not something the toggle sets — it is what the
|
|
15435
|
+
* toggle's boolean is derived from. Absent on an in-RAM track never touched;
|
|
15436
|
+
* always present on a persisted row (the column default materialises `'none'`).
|
|
15437
|
+
*/
|
|
15438
|
+
var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
|
|
15439
|
+
/**
|
|
14926
15440
|
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
14927
15441
|
* one flag can never clear the other — the toggles are independent and are
|
|
14928
15442
|
* driven from three surfaces that do not know about each other.
|
|
@@ -14936,13 +15450,92 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
|
14936
15450
|
var TrackFlagsSchema = object({
|
|
14937
15451
|
trackId: string(),
|
|
14938
15452
|
markForTrain: boolean(),
|
|
14939
|
-
debug: boolean()
|
|
15453
|
+
debug: boolean(),
|
|
15454
|
+
/** The lifecycle state the boolean was derived from. Required here (unlike on
|
|
15455
|
+
* a track row) because this shape is only ever produced by the write body,
|
|
15456
|
+
* which always knows it — and a surface that has just written needs to render
|
|
15457
|
+
* `trained` without a re-fetch. */
|
|
15458
|
+
retrainStatus: RetrainStatusSchema
|
|
15459
|
+
});
|
|
15460
|
+
union([literal(1), literal(2)]);
|
|
15461
|
+
/**
|
|
15462
|
+
* WHO decided a label, and when. Carried per tier so a value can be traced to
|
|
15463
|
+
* the step and model that produced it — which is what makes the write rule
|
|
15464
|
+
* arguable after the fact ("why is 592's label `dog` and not `Canis lupus`?")
|
|
15465
|
+
* and what lets a migrated, UNATTRIBUTED value be told apart from a real one.
|
|
15466
|
+
*
|
|
15467
|
+
* `stepId` is the pipeline step id (`animal-classifier`, `bird-classifier`,
|
|
15468
|
+
* `plate-ocr`, `face-embedding`, `object-detection`), or the sentinel
|
|
15469
|
+
* `migration:4g` for a value the 4g migration moved from the single-slot era —
|
|
15470
|
+
* that value has no provenance, and the write rule lets ANY properly-attributed
|
|
15471
|
+
* write of the same tier replace it regardless of score.
|
|
15472
|
+
*/
|
|
15473
|
+
var LabelAttributionSchema = object({
|
|
15474
|
+
stepId: string(),
|
|
15475
|
+
modelId: string().optional(),
|
|
15476
|
+
decidedAt: number()
|
|
15477
|
+
});
|
|
15478
|
+
/**
|
|
15479
|
+
* The TIERED label model (roadmap 4g), spread into `TrackSchema` and
|
|
15480
|
+
* `ObjectEventSchema` from ONE place so the two surfaces cannot drift — a
|
|
15481
|
+
* track and its events always answer the same question the same way.
|
|
15482
|
+
*
|
|
15483
|
+
* Two scalar columns, not an array: every consumer wants "the coarse one" or
|
|
15484
|
+
* "the fine one", and an array made both a scan. `label` is tier 1, `subLabel`
|
|
15485
|
+
* is tier 2, and each carries its own score + attribution.
|
|
15486
|
+
*
|
|
15487
|
+
* **Reading it.** What a human should be shown is `subLabel ?? label` — the
|
|
15488
|
+
* finest thing known. Before 4g the single `label` column held the finest
|
|
15489
|
+
* value, so a consumer that has not been updated reads the tier-1 slot and
|
|
15490
|
+
* shows nothing on a species-only row; that is why the migration puts every
|
|
15491
|
+
* pre-4g value in tier 2 (it cannot regress a display that reads the fallback)
|
|
15492
|
+
* and why the read surfaces were changed in the same train.
|
|
15493
|
+
*
|
|
15494
|
+
* **Writing it.** The slots are independent, which is the whole point: a
|
|
15495
|
+
* tier-1 write (`bird`) can never overwrite a tier-2 value (`Turdus
|
|
15496
|
+
* migratorius`), so fineness cannot regress by construction. Within a tier the
|
|
15497
|
+
* higher score wins. One rule, one implementation — see
|
|
15498
|
+
* `pipeline/label-tier.ts` in addon-post-analysis.
|
|
15499
|
+
*/
|
|
15500
|
+
var TieredLabelFields = {
|
|
15501
|
+
/** Tier 1 — the sub-class. See {@link LabelTierSchema}. */
|
|
15502
|
+
label: string().optional(),
|
|
15503
|
+
/** Confidence of the tier-1 value, as reported by the deciding step. */
|
|
15504
|
+
labelScore: number().optional(),
|
|
15505
|
+
/** Provenance of the tier-1 value. See {@link LabelAttributionSchema}. */
|
|
15506
|
+
labelMeta: LabelAttributionSchema.optional(),
|
|
15507
|
+
/** Tier 2 — the instance. See {@link LabelTierSchema}. */
|
|
15508
|
+
subLabel: string().optional(),
|
|
15509
|
+
/** Confidence of the tier-2 value, as reported by the deciding step. */
|
|
15510
|
+
subLabelScore: number().optional(),
|
|
15511
|
+
/** Provenance of the tier-2 value. See {@link LabelAttributionSchema}. */
|
|
15512
|
+
subLabelMeta: LabelAttributionSchema.optional()
|
|
15513
|
+
};
|
|
15514
|
+
/** Per-camera slice of a training-export estimate. */
|
|
15515
|
+
var TrainingExportDeviceTotalsSchema = object({
|
|
15516
|
+
deviceId: number(),
|
|
15517
|
+
tracks: number().int(),
|
|
15518
|
+
files: number().int(),
|
|
15519
|
+
bytes: number().int()
|
|
15520
|
+
});
|
|
15521
|
+
/**
|
|
15522
|
+
* What a training export WOULD contain. Computed from media index rows only —
|
|
15523
|
+
* no blob is read to produce this.
|
|
15524
|
+
*/
|
|
15525
|
+
var TrainingExportSummarySchema = object({
|
|
15526
|
+
generatedAt: number(),
|
|
15527
|
+
trackCount: number().int(),
|
|
15528
|
+
fileCount: number().int(),
|
|
15529
|
+
byteCount: number().int(),
|
|
15530
|
+
/** More marked tracks exist than a single pass carries. */
|
|
15531
|
+
truncated: boolean(),
|
|
15532
|
+
devices: array(TrainingExportDeviceTotalsSchema).readonly()
|
|
14940
15533
|
});
|
|
14941
15534
|
var TrackSchema = object({
|
|
14942
15535
|
trackId: string(),
|
|
14943
15536
|
deviceId: number(),
|
|
14944
15537
|
className: string(),
|
|
14945
|
-
|
|
15538
|
+
...TieredLabelFields,
|
|
14946
15539
|
producingDeviceName: string().optional(),
|
|
14947
15540
|
/** Track provenance. Absent ⇒ `pipeline` (legacy rows). */
|
|
14948
15541
|
source: TrackSourceSchema.optional(),
|
|
@@ -14953,8 +15546,26 @@ var TrackSchema = object({
|
|
|
14953
15546
|
/** Periodic snapshots at snapshotIntervalMs cadence (subject to
|
|
14954
15547
|
* saveThumbnails policy). */
|
|
14955
15548
|
snapshots: array(TrackSnapshotSchema).readonly(),
|
|
14956
|
-
/** Deduplicated zones the track has entered at least once. */
|
|
15549
|
+
/** Deduplicated zones the track has entered at least once. Zone IDS. */
|
|
14957
15550
|
zonesVisited: array(string()).readonly(),
|
|
15551
|
+
/**
|
|
15552
|
+
* Human NAMES for {@link zonesVisited}, resolved at READ time against the
|
|
15553
|
+
* `zones` capability.
|
|
15554
|
+
*
|
|
15555
|
+
* `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
|
|
15556
|
+
* and no card can render — so every free-text search surface was structurally
|
|
15557
|
+
* unable to answer "show me the tracks in Uscio", and did not fail loudly, it
|
|
15558
|
+
* just returned nothing. Resolving here rather than in each client keeps ONE
|
|
15559
|
+
* derivation and costs the clients no extra call (the `zones` cap is
|
|
15560
|
+
* per-device, so a client-side resolve would be a per-camera fan-out on a
|
|
15561
|
+
* surface built to avoid exactly that).
|
|
15562
|
+
*
|
|
15563
|
+
* Resolved, never invented: a zone deleted since the track was written has no
|
|
15564
|
+
* name and is DROPPED, so this array can be shorter than `zonesVisited` — the
|
|
15565
|
+
* two are not positionally aligned. Absent when the track visited no zone, or
|
|
15566
|
+
* when the zone catalogue could not be read.
|
|
15567
|
+
*/
|
|
15568
|
+
zoneNames: array(string()).readonly().optional(),
|
|
14958
15569
|
/** Deduplicated set of detector classes observed for this track over its
|
|
14959
15570
|
* life (a track may be reclassified, e.g. person→vehicle). Absent on
|
|
14960
15571
|
* legacy rows written before class accumulation shipped. */
|
|
@@ -14981,7 +15592,26 @@ var TrackSchema = object({
|
|
|
14981
15592
|
* Populated from the persisted envelope columns on historical reads;
|
|
14982
15593
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
14983
15594
|
envelope: TrackEnvelopeSchema.optional(),
|
|
14984
|
-
|
|
15595
|
+
/**
|
|
15596
|
+
* A face DETECTOR found a face on this track — nothing more. It says the
|
|
15597
|
+
* detail plane produced a `face` detail; it does NOT say the face was
|
|
15598
|
+
* embedded, matched, above `minFacePx`, or that the recognizer was even
|
|
15599
|
+
* enabled. Set once and never cleared.
|
|
15600
|
+
*
|
|
15601
|
+
* **This exists so "face present but not recognised" is expressible.** A
|
|
15602
|
+
* recognised identity lands in `subLabel` (attributed to the face chain via
|
|
15603
|
+
* `subLabelMeta.stepId`), so before this field a track with an unmatched face
|
|
15604
|
+
* and a track with no face at all were byte-identical on the wire and no
|
|
15605
|
+
* surface could tell them apart. The read is `hasFace === true && subLabel
|
|
15606
|
+
* === undefined`.
|
|
15607
|
+
*
|
|
15608
|
+
* **Absent ≠ false.** Every row written before the column existed omits it,
|
|
15609
|
+
* and so does every server that predates the field — a consumer must test
|
|
15610
|
+
* `=== true` and render nothing otherwise, never infer "no face".
|
|
15611
|
+
*/
|
|
15612
|
+
hasFace: boolean().optional(),
|
|
15613
|
+
...TrackFlagFields,
|
|
15614
|
+
...TrackRetrainFields
|
|
14985
15615
|
});
|
|
14986
15616
|
var BaseEventFields = {
|
|
14987
15617
|
id: string(),
|
|
@@ -15054,7 +15684,7 @@ var ObjectEventSchema = object({
|
|
|
15054
15684
|
/** Omitted in slim projection. */
|
|
15055
15685
|
trackId: string().optional(),
|
|
15056
15686
|
className: string(),
|
|
15057
|
-
|
|
15687
|
+
...TieredLabelFields,
|
|
15058
15688
|
/** Omitted in slim projection. */
|
|
15059
15689
|
confidence: number().optional(),
|
|
15060
15690
|
/** Heavy JSON — omitted in slim projection. */
|
|
@@ -15135,6 +15765,173 @@ var MediaFileSchema = object({
|
|
|
15135
15765
|
* stored blob and a `?variant=thumb` rendering without fetching either.
|
|
15136
15766
|
*/
|
|
15137
15767
|
var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
|
|
15768
|
+
/**
|
|
15769
|
+
* The MACRO tier of an annotation — a CLOSED set.
|
|
15770
|
+
*
|
|
15771
|
+
* This is what the exported detector predicts, so a typo here is a new class
|
|
15772
|
+
* with one example in it. `label` and `subLabel` are open strings by contrast:
|
|
15773
|
+
* the whole point of the page is teaching the model things it does not know
|
|
15774
|
+
* yet, and constraining that vocabulary would make it useless.
|
|
15775
|
+
*
|
|
15776
|
+
* A macro class is NEVER a label. The provider refuses a write whose `label` or
|
|
15777
|
+
* `subLabel` is one of these values, in any casing, because once `person`
|
|
15778
|
+
* exists in both tiers "every person box" stops being answerable without
|
|
15779
|
+
* knowing every string anyone ever typed — and the damage is retroactive.
|
|
15780
|
+
*/
|
|
15781
|
+
var RetrainMacroClassSchema = _enum([
|
|
15782
|
+
"person",
|
|
15783
|
+
"vehicle",
|
|
15784
|
+
"animal",
|
|
15785
|
+
"package",
|
|
15786
|
+
"face",
|
|
15787
|
+
"plate"
|
|
15788
|
+
]);
|
|
15789
|
+
/** A subject to learn, or a phantom to unlearn (taught by OMISSION). */
|
|
15790
|
+
var RetrainAnnotationKindSchema = _enum(["subject", "model_error"]);
|
|
15791
|
+
/** Did a human draw this box, or did the assist propose it? */
|
|
15792
|
+
var RetrainAnnotationSourceSchema = _enum(["operator", "assist"]);
|
|
15793
|
+
/** Normalised `[0,1]` rectangle against the FULL frame — the canonical form. */
|
|
15794
|
+
var RetrainBboxSchema = object({
|
|
15795
|
+
x: number(),
|
|
15796
|
+
y: number(),
|
|
15797
|
+
w: number(),
|
|
15798
|
+
h: number()
|
|
15799
|
+
});
|
|
15800
|
+
/**
|
|
15801
|
+
* One annotated subject.
|
|
15802
|
+
*
|
|
15803
|
+
* `bbox` is normalised against the full frame, ALWAYS. The per-model shapes
|
|
15804
|
+
* (letterboxed root / zone-cropped package / subject-cropped classifier) are
|
|
15805
|
+
* derived from it at export and never stored — storing them is how one feature
|
|
15806
|
+
* space ends up holding two crops of the same subject (D52).
|
|
15807
|
+
*/
|
|
15808
|
+
var RetrainAnnotationSchema = object({
|
|
15809
|
+
id: string(),
|
|
15810
|
+
trackId: string(),
|
|
15811
|
+
deviceId: number(),
|
|
15812
|
+
/** The COPY in retrain storage — never the source track's media key. */
|
|
15813
|
+
mediaKey: string(),
|
|
15814
|
+
bbox: RetrainBboxSchema,
|
|
15815
|
+
macroClass: RetrainMacroClassSchema,
|
|
15816
|
+
label: string().optional(),
|
|
15817
|
+
subLabel: string().optional(),
|
|
15818
|
+
kind: RetrainAnnotationKindSchema,
|
|
15819
|
+
source: RetrainAnnotationSourceSchema,
|
|
15820
|
+
/** Which model proposed this box — or, on a `model_error`, drew the phantom. */
|
|
15821
|
+
assistModelId: string().optional(),
|
|
15822
|
+
assistScore: number().optional(),
|
|
15823
|
+
exportedInBatch: string().optional(),
|
|
15824
|
+
createdAt: number()
|
|
15825
|
+
});
|
|
15826
|
+
/** The write form — the server owns `id`, `createdAt` and the frame binding. */
|
|
15827
|
+
var RetrainAnnotationDraftSchema = RetrainAnnotationSchema.omit({
|
|
15828
|
+
id: true,
|
|
15829
|
+
trackId: true,
|
|
15830
|
+
deviceId: true,
|
|
15831
|
+
mediaKey: true,
|
|
15832
|
+
createdAt: true,
|
|
15833
|
+
exportedInBatch: true
|
|
15834
|
+
});
|
|
15835
|
+
/** A track sitting in `staging`, with everything the worklist needs to rank it. */
|
|
15836
|
+
var RetrainTrackSchema = object({
|
|
15837
|
+
trackId: string(),
|
|
15838
|
+
deviceId: number(),
|
|
15839
|
+
className: string(),
|
|
15840
|
+
label: string().optional(),
|
|
15841
|
+
firstSeen: number(),
|
|
15842
|
+
lastSeen: number(),
|
|
15843
|
+
/** How many frames the dataset already holds from this track. */
|
|
15844
|
+
frameCount: number().int(),
|
|
15845
|
+
/** How many subjects have been annotated on those frames. `0` with
|
|
15846
|
+
* `frameCount: 0` is exactly "staging, still to work". */
|
|
15847
|
+
annotationCount: number().int()
|
|
15848
|
+
});
|
|
15849
|
+
/** A frame the picker may offer — an index row, no blob was read to produce it. */
|
|
15850
|
+
var RetrainFrameCandidateSchema = object({
|
|
15851
|
+
mediaKey: string(),
|
|
15852
|
+
kind: MediaFileKindEnum,
|
|
15853
|
+
timestamp: number(),
|
|
15854
|
+
sizeBytes: number().int(),
|
|
15855
|
+
/** A copy of this original already exists — selecting it is free and cannot
|
|
15856
|
+
* fail, whatever became of the original. */
|
|
15857
|
+
copied: boolean()
|
|
15858
|
+
});
|
|
15859
|
+
/** A frame the dataset OWNS: bytes copied at selection time. */
|
|
15860
|
+
var RetrainFrameSchema = object({
|
|
15861
|
+
frameId: string(),
|
|
15862
|
+
deviceId: number(),
|
|
15863
|
+
trackId: string(),
|
|
15864
|
+
/** Provenance only. It may already point at nothing — that is expected. */
|
|
15865
|
+
sourceMediaKey: string(),
|
|
15866
|
+
sourceKind: MediaFileKindEnum,
|
|
15867
|
+
sizeBytes: number().int(),
|
|
15868
|
+
width: number().int(),
|
|
15869
|
+
height: number().int(),
|
|
15870
|
+
copiedAt: number()
|
|
15871
|
+
});
|
|
15872
|
+
/** Why a copy-on-select could not be honoured — named, never a silent skip. */
|
|
15873
|
+
var RetrainCopyRefusalSchema = _enum([
|
|
15874
|
+
"source-missing",
|
|
15875
|
+
"unreadable-image",
|
|
15876
|
+
"write-failed"
|
|
15877
|
+
]);
|
|
15878
|
+
var RetrainFrameSelectionSchema = object({
|
|
15879
|
+
copied: array(RetrainFrameSchema).readonly(),
|
|
15880
|
+
refused: array(object({
|
|
15881
|
+
sourceMediaKey: string(),
|
|
15882
|
+
reason: RetrainCopyRefusalSchema
|
|
15883
|
+
})).readonly()
|
|
15884
|
+
});
|
|
15885
|
+
var RetrainFrameListSchema = object({
|
|
15886
|
+
candidates: array(RetrainFrameCandidateSchema).readonly(),
|
|
15887
|
+
copies: array(RetrainFrameSchema).readonly(),
|
|
15888
|
+
/** What the page pre-selects — the native key frame when one survives. */
|
|
15889
|
+
autoPickMediaKey: string().optional()
|
|
15890
|
+
});
|
|
15891
|
+
/** What the operator asked the assist to look for. */
|
|
15892
|
+
var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
|
|
15893
|
+
kind: literal("package"),
|
|
15894
|
+
zone: RetrainBboxSchema.optional()
|
|
15895
|
+
}), object({
|
|
15896
|
+
kind: literal("objects"),
|
|
15897
|
+
modelId: string(),
|
|
15898
|
+
minScore: number().optional()
|
|
15899
|
+
})]);
|
|
15900
|
+
/**
|
|
15901
|
+
* The assist's answer — a discriminated union, because "the model saw nothing"
|
|
15902
|
+
* and "this node cannot run that model" lead to different next moves and a
|
|
15903
|
+
* nullable result cannot tell them apart.
|
|
15904
|
+
*/
|
|
15905
|
+
var RetrainAssistResultSchema = discriminatedUnion("kind", [object({
|
|
15906
|
+
kind: literal("proposed"),
|
|
15907
|
+
modelId: string(),
|
|
15908
|
+
stepId: string(),
|
|
15909
|
+
minScore: number(),
|
|
15910
|
+
/** Drafts, ready to edit. `source: 'assist'` until the operator touches one. */
|
|
15911
|
+
proposals: array(RetrainAnnotationDraftSchema).readonly(),
|
|
15912
|
+
/** Returned by the runner but removed by the threshold. */
|
|
15913
|
+
belowThreshold: number().int()
|
|
15914
|
+
}), object({
|
|
15915
|
+
kind: literal("refused"),
|
|
15916
|
+
/** `no-zone` is ours; the rest are the runner's own refusal vocabulary. */
|
|
15917
|
+
reason: string(),
|
|
15918
|
+
detail: string().optional()
|
|
15919
|
+
})]);
|
|
15920
|
+
/** The outcome of a lifecycle move owned by the retrain page. */
|
|
15921
|
+
var RetrainTransitionResultSchema = object({
|
|
15922
|
+
trackId: string(),
|
|
15923
|
+
/** Where the track ended up, whatever happened. */
|
|
15924
|
+
retrainStatus: RetrainStatusSchema,
|
|
15925
|
+
/** `false` ⇒ the move was refused or was a no-op; `reason` says which. */
|
|
15926
|
+
changed: boolean(),
|
|
15927
|
+
reason: _enum([
|
|
15928
|
+
"unknown-track",
|
|
15929
|
+
"no-frames-copied",
|
|
15930
|
+
"not-staging",
|
|
15931
|
+
"not-trained",
|
|
15932
|
+
"unchanged"
|
|
15933
|
+
]).optional()
|
|
15934
|
+
});
|
|
15138
15935
|
var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
|
|
15139
15936
|
var MAX_EVENT_QUERY_LIMIT = 5e3;
|
|
15140
15937
|
var DeviceEventQueryInput = object({
|
|
@@ -15189,13 +15986,14 @@ var KeyEventSchema = object({
|
|
|
15189
15986
|
/** Track start time (firstSeen). */
|
|
15190
15987
|
timestamp: number(),
|
|
15191
15988
|
className: string(),
|
|
15192
|
-
|
|
15989
|
+
...TieredLabelFields,
|
|
15193
15990
|
importance: number(),
|
|
15194
15991
|
/** Highest-confidence ObjectEvent id for the track (empty when none). */
|
|
15195
15992
|
bestEventId: string(),
|
|
15196
15993
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
15197
15994
|
windowMs: number().optional(),
|
|
15198
|
-
...TrackFlagFields
|
|
15995
|
+
...TrackFlagFields,
|
|
15996
|
+
...TrackRetrainFields
|
|
15199
15997
|
});
|
|
15200
15998
|
object({
|
|
15201
15999
|
trackId: string(),
|
|
@@ -15456,6 +16254,85 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
15456
16254
|
}), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
|
|
15457
16255
|
kind: "query",
|
|
15458
16256
|
auth: "admin"
|
|
16257
|
+
}), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
|
|
16258
|
+
kind: "query",
|
|
16259
|
+
auth: "admin"
|
|
16260
|
+
}), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
|
|
16261
|
+
kind: "query",
|
|
16262
|
+
auth: "admin"
|
|
16263
|
+
}), method(object({
|
|
16264
|
+
/** Empty ⇒ every camera that has staging tracks. A LIST, not a single
|
|
16265
|
+
* `deviceId`, deliberately: `deviceId` would make this device-bound and
|
|
16266
|
+
* route it at one camera's owner, and "every camera" would stop being
|
|
16267
|
+
* expressible at all. */
|
|
16268
|
+
deviceIds: array(number()).optional(),
|
|
16269
|
+
limit: number().int().min(1).max(500).optional()
|
|
16270
|
+
}), array(RetrainTrackSchema).readonly(), {
|
|
16271
|
+
kind: "query",
|
|
16272
|
+
auth: "admin"
|
|
16273
|
+
}), method(object({ trackId: string() }), RetrainFrameListSchema, {
|
|
16274
|
+
kind: "query",
|
|
16275
|
+
auth: "admin"
|
|
16276
|
+
}), method(object({
|
|
16277
|
+
deviceId: number(),
|
|
16278
|
+
trackId: string(),
|
|
16279
|
+
mediaKeys: array(string()).min(1)
|
|
16280
|
+
}), RetrainFrameSelectionSchema, {
|
|
16281
|
+
kind: "mutation",
|
|
16282
|
+
auth: "admin"
|
|
16283
|
+
}), method(object({
|
|
16284
|
+
deviceId: number(),
|
|
16285
|
+
trackId: string(),
|
|
16286
|
+
frameId: string()
|
|
16287
|
+
}), object({
|
|
16288
|
+
removed: boolean(),
|
|
16289
|
+
removedAnnotations: number().int()
|
|
16290
|
+
}), {
|
|
16291
|
+
kind: "mutation",
|
|
16292
|
+
auth: "admin"
|
|
16293
|
+
}), method(object({ frameId: string() }), object({
|
|
16294
|
+
base64: string(),
|
|
16295
|
+
width: number().int(),
|
|
16296
|
+
height: number().int()
|
|
16297
|
+
}), {
|
|
16298
|
+
kind: "query",
|
|
16299
|
+
auth: "admin"
|
|
16300
|
+
}), method(object({
|
|
16301
|
+
deviceId: number(),
|
|
16302
|
+
trackId: string(),
|
|
16303
|
+
frameId: string(),
|
|
16304
|
+
subject: RetrainAssistSubjectSchema,
|
|
16305
|
+
/** Which node runs it. Absent ⇒ wherever an unowned call lands. */
|
|
16306
|
+
nodeId: string().optional()
|
|
16307
|
+
}), RetrainAssistResultSchema, {
|
|
16308
|
+
kind: "mutation",
|
|
16309
|
+
auth: "admin"
|
|
16310
|
+
}), method(object({ trackId: string() }), array(RetrainAnnotationSchema).readonly(), {
|
|
16311
|
+
kind: "query",
|
|
16312
|
+
auth: "admin"
|
|
16313
|
+
}), method(object({
|
|
16314
|
+
deviceId: number(),
|
|
16315
|
+
trackId: string(),
|
|
16316
|
+
frameId: string(),
|
|
16317
|
+
annotations: array(RetrainAnnotationDraftSchema)
|
|
16318
|
+
}), array(RetrainAnnotationSchema).readonly(), {
|
|
16319
|
+
kind: "mutation",
|
|
16320
|
+
auth: "admin"
|
|
16321
|
+
}), method(object({
|
|
16322
|
+
deviceId: number(),
|
|
16323
|
+
trackId: string()
|
|
16324
|
+
}), RetrainTransitionResultSchema, {
|
|
16325
|
+
kind: "mutation",
|
|
16326
|
+
auth: "admin"
|
|
16327
|
+
}), method(object({
|
|
16328
|
+
deviceId: number(),
|
|
16329
|
+
trackId: string()
|
|
16330
|
+
}), RetrainTransitionResultSchema, {
|
|
16331
|
+
kind: "mutation",
|
|
16332
|
+
auth: "admin"
|
|
16333
|
+
}), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
|
|
16334
|
+
kind: "query",
|
|
16335
|
+
auth: "admin"
|
|
15459
16336
|
}), method(object({
|
|
15460
16337
|
eventId: string(),
|
|
15461
16338
|
kind: MediaFileKindEnum.optional()
|
|
@@ -16068,6 +16945,22 @@ var DetailResultSchema = object({
|
|
|
16068
16945
|
bbox: NativeCropBboxSchema.optional(),
|
|
16069
16946
|
embedding: string().optional(),
|
|
16070
16947
|
label: string().optional(),
|
|
16948
|
+
/**
|
|
16949
|
+
* The tier `label` occupies, copied VERBATIM from the producing step's
|
|
16950
|
+
* `StepDefinition.labelTier` (roadmap 4g). Present only when `label` is.
|
|
16951
|
+
*
|
|
16952
|
+
* It rides the wire rather than being resolved by the consumer because the
|
|
16953
|
+
* declaration lives with the step definition, which only the executing node
|
|
16954
|
+
* has: post-analysis holds no step registry, and re-deriving the tier from
|
|
16955
|
+
* `className` there would be exactly the inference this model exists to
|
|
16956
|
+
* forbid. A `label` that arrives WITHOUT this field is refused by the write
|
|
16957
|
+
* rule and logged (`label tier undeclared`) — an older runner therefore
|
|
16958
|
+
* stops enriching rather than guessing, which is why addon-pipeline is
|
|
16959
|
+
* deployed BEFORE addon-post-analysis.
|
|
16960
|
+
*/
|
|
16961
|
+
labelTier: union([literal(1), literal(2)]).optional(),
|
|
16962
|
+
/** Model that produced `label` — carried into the tier's attribution. */
|
|
16963
|
+
labelModelId: string().optional(),
|
|
16071
16964
|
alignedCropJpeg: string().optional(),
|
|
16072
16965
|
/** Face short side (px) measured on the NATIVE crop surface. The `bbox`
|
|
16073
16966
|
* above is detection-frame px (≈6× smaller on a 4K camera) — min-face-size
|
|
@@ -16775,6 +17668,23 @@ var CameraRecordingStatusSchema = object({
|
|
|
16775
17668
|
active: boolean(),
|
|
16776
17669
|
storageBytes: number()
|
|
16777
17670
|
});
|
|
17671
|
+
/** One stage of the fan-out that could NOT be read, and how long it cost. */
|
|
17672
|
+
var CameraStatusDegradationSchema = object({
|
|
17673
|
+
stage: _enum([
|
|
17674
|
+
"source",
|
|
17675
|
+
"broker",
|
|
17676
|
+
"detection",
|
|
17677
|
+
"recording",
|
|
17678
|
+
"switches"
|
|
17679
|
+
]),
|
|
17680
|
+
reason: _enum([
|
|
17681
|
+
"timeout",
|
|
17682
|
+
"error",
|
|
17683
|
+
"partial"
|
|
17684
|
+
]),
|
|
17685
|
+
/** Wall-clock ms spent on the stage before it was abandoned. */
|
|
17686
|
+
elapsedMs: number()
|
|
17687
|
+
});
|
|
16778
17688
|
/**
|
|
16779
17689
|
* Aggregated per-camera pipeline status — server-composed, single call.
|
|
16780
17690
|
*
|
|
@@ -16805,9 +17715,28 @@ var CameraStatusSchema = object({
|
|
|
16805
17715
|
* differently — a quiet camera that looks identical to a dead one is the
|
|
16806
17716
|
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
16807
17717
|
*
|
|
16808
|
-
* Empty when nothing is off
|
|
17718
|
+
* Empty when nothing is off, and never contains a switch no provider offers
|
|
17719
|
+
* — but an empty list is only a POSITIVE claim when `degraded` does not name
|
|
17720
|
+
* `'switches'`. When it does, the switch set could not be read and nothing
|
|
17721
|
+
* here may be rendered as "the operator turned nothing off": that is the
|
|
17722
|
+
* D62 failure (a camera we could not read painted as broken) in the very
|
|
17723
|
+
* field that exists to prevent it.
|
|
16809
17724
|
*/
|
|
16810
17725
|
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
17726
|
+
/**
|
|
17727
|
+
* Stages of the bounded fan-out that were CUT SHORT — a timeout or a
|
|
17728
|
+
* rejection — and whose block is therefore `null` because we could not
|
|
17729
|
+
* READ it, not because there is nothing there.
|
|
17730
|
+
*
|
|
17731
|
+
* Without this, three different facts arrive as the same `null`: "the stage
|
|
17732
|
+
* timed out", "the stage failed", and "this camera legitimately has no
|
|
17733
|
+
* decoder / no recording". Every surface that draws a conclusion from a null
|
|
17734
|
+
* block (or from an empty `switchedOff`) must consult this first; a stage
|
|
17735
|
+
* named here supports no conclusion at all, only "unknown".
|
|
17736
|
+
*
|
|
17737
|
+
* Empty on a clean read — the overwhelmingly common case.
|
|
17738
|
+
*/
|
|
17739
|
+
degraded: array(CameraStatusDegradationSchema).readonly(),
|
|
16811
17740
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
16812
17741
|
fetchedAt: number()
|
|
16813
17742
|
});
|
|
@@ -17370,6 +18299,77 @@ var snapshotCapability = {
|
|
|
17370
18299
|
lastCapturedAt: number().nullable(),
|
|
17371
18300
|
cacheAgeMs: number().nullable(),
|
|
17372
18301
|
etag: string().nullable()
|
|
18302
|
+
}))),
|
|
18303
|
+
/**
|
|
18304
|
+
* Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
|
|
18305
|
+
* that makes those frames current.
|
|
18306
|
+
*
|
|
18307
|
+
* ## The problem it replaces
|
|
18308
|
+
*
|
|
18309
|
+
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18310
|
+
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18311
|
+
* versions its image URL on that answer, and an image REQUEST is what enrols
|
|
18312
|
+
* a camera in the keep-warm loop. Both of those are satisfiable by the
|
|
18313
|
+
* client's own image cache — `expo-image` is URL-keyed and never revalidates
|
|
18314
|
+
* — so a URL painted in a previous session comes off disk with no network,
|
|
18315
|
+
* no enrolment, and nothing warming. Measured on the live hub: reopening
|
|
18316
|
+
* after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
|
|
18317
|
+
* HTTP requests, and the fleet only recovered because a later poll happened
|
|
18318
|
+
* to observe a different identity.
|
|
18319
|
+
*
|
|
18320
|
+
* ## The two properties that fix it
|
|
18321
|
+
*
|
|
18322
|
+
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18323
|
+
* always reaches the wrapper. This method therefore MAY create keep-warm
|
|
18324
|
+
* subscriptions, where `getSnapshotOverview` must never (D93) — the
|
|
18325
|
+
* distinction is not "one is newer" but that the overview poll is app-wide
|
|
18326
|
+
* (a creating overview would warm every camera on the install) while this is
|
|
18327
|
+
* called by a rendered surface naming the tiles it is actually painting, at
|
|
18328
|
+
* the width it is painting them.
|
|
18329
|
+
*
|
|
18330
|
+
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18331
|
+
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
18332
|
+
* cache held when the client asked, so a first paint is honest and current
|
|
18333
|
+
* instead of a generation behind. A device that does not settle inside the
|
|
18334
|
+
* bound still gets a link and its real (older) `capturedAt` — the next poll
|
|
18335
|
+
* carries it forward.
|
|
18336
|
+
*
|
|
18337
|
+
* `force` is never set on behalf of a client here. A sleeping battery camera
|
|
18338
|
+
* is reported with `sleeping: true` and the last frame it produced, however
|
|
18339
|
+
* old; the wrapper's existing sleep gate owns that decision and this method
|
|
18340
|
+
* adds no second one.
|
|
18341
|
+
*/
|
|
18342
|
+
getSnapshotLinks: systemMethod(object({
|
|
18343
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18344
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18345
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18346
|
+
targets: array(object({
|
|
18347
|
+
deviceId: number(),
|
|
18348
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18349
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18350
|
+
width: number().int().positive().optional()
|
|
18351
|
+
})).min(1).max(200) }), array(object({
|
|
18352
|
+
deviceId: number(),
|
|
18353
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18354
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18355
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18356
|
+
url: string().nullable(),
|
|
18357
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18358
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18359
|
+
capturedAt: number().nullable(),
|
|
18360
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18361
|
+
ageMs: number().nullable(),
|
|
18362
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18363
|
+
expiresAt: number().nullable(),
|
|
18364
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18365
|
+
width: number().nullable(),
|
|
18366
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18367
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18368
|
+
neverCaptured: boolean(),
|
|
18369
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18370
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18371
|
+
* present it as current. */
|
|
18372
|
+
sleeping: boolean()
|
|
17373
18373
|
})))
|
|
17374
18374
|
},
|
|
17375
18375
|
status: {
|
|
@@ -17415,13 +18415,35 @@ var SsoBridgeClaimsSchema = object({
|
|
|
17415
18415
|
integrationId: string().optional(),
|
|
17416
18416
|
/** JWT ID — unique per issued code; consumed-set enforces single-use. */
|
|
17417
18417
|
jti: string().optional(),
|
|
18418
|
+
/** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
|
|
18419
|
+
* client. Its PRESENCE is what makes the verifier mandatory at exchange,
|
|
18420
|
+
* so the requirement travels with the code and not with mutable config. */
|
|
18421
|
+
codeChallenge: string().optional(),
|
|
17418
18422
|
/** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
|
|
17419
18423
|
* tokens so the verify path can check the session is not revoked. */
|
|
17420
|
-
sessionId: string().optional()
|
|
18424
|
+
sessionId: string().optional(),
|
|
18425
|
+
/**
|
|
18426
|
+
* The refresh lifetime this LINK was created with, in seconds, or `'never'`.
|
|
18427
|
+
* Baked into the code at `/authorize` from the integration's descriptor and
|
|
18428
|
+
* carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
|
|
18429
|
+
* on the token rather than being re-read from the descriptor on purpose:
|
|
18430
|
+
* editing a descriptor must not retroactively extend or shorten a link the
|
|
18431
|
+
* operator already consented to.
|
|
18432
|
+
*/
|
|
18433
|
+
refreshTtl: union([number().int().positive(), literal("never")]).optional()
|
|
17421
18434
|
});
|
|
17422
18435
|
method(object({
|
|
17423
18436
|
claims: SsoBridgeClaimsSchema,
|
|
17424
|
-
|
|
18437
|
+
/**
|
|
18438
|
+
* Seconds, or `'never'` for a token minted with NO `exp` claim.
|
|
18439
|
+
*
|
|
18440
|
+
* `'never'` is a literal rather than `undefined`/`0` because omitting
|
|
18441
|
+
* this field already means "the 5-minute SSO hand-off default", and
|
|
18442
|
+
* `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
|
|
18443
|
+
* went through the numeric path would fail at mint time and break
|
|
18444
|
+
* linking rather than produce an eternal token.
|
|
18445
|
+
*/
|
|
18446
|
+
ttlSec: union([number().int().positive(), literal("never")]).optional()
|
|
17425
18447
|
}), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
|
|
17426
18448
|
var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
|
|
17427
18449
|
providerId: string().min(1),
|
|
@@ -17967,7 +18989,7 @@ var ClipPlaybackSchema = object({
|
|
|
17967
18989
|
playbackEndpoints: array(string()).optional(),
|
|
17968
18990
|
token: string().optional()
|
|
17969
18991
|
});
|
|
17970
|
-
method(object({
|
|
18992
|
+
DeviceType.Camera, method(object({
|
|
17971
18993
|
deviceId: number(),
|
|
17972
18994
|
since: number(),
|
|
17973
18995
|
until: number(),
|
|
@@ -19402,6 +20424,37 @@ onColorChanged: { data: object({
|
|
|
19402
20424
|
*/
|
|
19403
20425
|
runtimeState: ColorStatusSchema
|
|
19404
20426
|
};
|
|
20427
|
+
var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
|
|
20428
|
+
object({
|
|
20429
|
+
outcome: literal("validated"),
|
|
20430
|
+
/** Round-trip of the sign-in, when the provider measured it. */
|
|
20431
|
+
latencyMs: number().nonnegative().optional(),
|
|
20432
|
+
/** Optional human detail worth showing next to the tick
|
|
20433
|
+
* ("3 devices visible on this account"). */
|
|
20434
|
+
detail: string().optional()
|
|
20435
|
+
}).strict(),
|
|
20436
|
+
object({
|
|
20437
|
+
outcome: literal("rejected"),
|
|
20438
|
+
error: string()
|
|
20439
|
+
}).strict(),
|
|
20440
|
+
object({
|
|
20441
|
+
outcome: literal("inconclusive"),
|
|
20442
|
+
error: string()
|
|
20443
|
+
}).strict()
|
|
20444
|
+
]);
|
|
20445
|
+
var ConnectionTestInputSchema = object({
|
|
20446
|
+
/** Candidate integration settings, exactly as the create form collected them. */
|
|
20447
|
+
settings: record(string(), unknown()) });
|
|
20448
|
+
/**
|
|
20449
|
+
* What the provider's test actually DOES, so the UI can say it in words before
|
|
20450
|
+
* the operator presses the button ("Signs in to the Dreo cloud"). Purely
|
|
20451
|
+
* descriptive — it never changes routing.
|
|
20452
|
+
*/
|
|
20453
|
+
var ConnectionTestDescriptorSchema = object({ label: string() });
|
|
20454
|
+
method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
|
|
20455
|
+
kind: "mutation",
|
|
20456
|
+
auth: "admin"
|
|
20457
|
+
}), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
|
|
19405
20458
|
/**
|
|
19406
20459
|
* Upstream-system connectivity sensor — distinct from `device-status`,
|
|
19407
20460
|
* which is the kernel-managed online/offline flag for the device's
|
|
@@ -20036,7 +21089,29 @@ var FaceInfoSchema = object({
|
|
|
20036
21089
|
recognizedIdentityId: string().optional(),
|
|
20037
21090
|
identityName: string().optional(),
|
|
20038
21091
|
assigned: boolean(),
|
|
21092
|
+
/**
|
|
21093
|
+
* The crop, inline, base64.
|
|
21094
|
+
*
|
|
21095
|
+
* **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
|
|
21096
|
+
* field alone is ~2.87 MiB, re-sent in full on every operator assign and
|
|
21097
|
+
* every 30 s poll, base64-inflated over the msgpack socket and held in the
|
|
21098
|
+
* query heap. It stays for callers that have not migrated; `includeCrops:
|
|
21099
|
+
* false` turns it off once they have.
|
|
21100
|
+
*/
|
|
20039
21101
|
base64: string().optional(),
|
|
21102
|
+
/**
|
|
21103
|
+
* Same crop, as a data-plane URL for `<img src>` — the move the admin
|
|
21104
|
+
* snapshot surfaces made on 2026-08-08.
|
|
21105
|
+
*
|
|
21106
|
+
* Served by the `event-media` plane, which resolves a raw MediaStore key and
|
|
21107
|
+
* is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
|
|
21108
|
+
* cookie, so no header plumbing is needed. The bytes then ride the browser's
|
|
21109
|
+
* HTTP cache with an ETag and `immutable`, instead of the WebSocket.
|
|
21110
|
+
*
|
|
21111
|
+
* Absent when the face has no stored crop, or when the addon has no data
|
|
21112
|
+
* plane — callers fall back to {@link base64}.
|
|
21113
|
+
*/
|
|
21114
|
+
cropUrl: string().optional(),
|
|
20040
21115
|
/** Design B: the face bbox (pixel space) on the key frame — lets a detail
|
|
20041
21116
|
* view draw the box over the native `keyFrameMediaKey` frame. Absent on
|
|
20042
21117
|
* legacy rows written before design B. */
|
|
@@ -20101,7 +21176,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
|
|
|
20101
21176
|
auth: "admin"
|
|
20102
21177
|
}), method(object({
|
|
20103
21178
|
limit: number().int().positive().optional(),
|
|
20104
|
-
filter: FaceFilterEnum.optional()
|
|
21179
|
+
filter: FaceFilterEnum.optional(),
|
|
21180
|
+
/**
|
|
21181
|
+
* Inline the base64 crop on every row. Default `true` — the existing
|
|
21182
|
+
* behaviour, kept so no caller breaks.
|
|
21183
|
+
*
|
|
21184
|
+
* Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
|
|
21185
|
+
* drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
|
|
21186
|
+
* the browser cache the images.
|
|
21187
|
+
*
|
|
21188
|
+
* **This is an INPUT field, so it does not reach the addon until the
|
|
21189
|
+
* next train.** The hub router validates cap inputs against its own
|
|
21190
|
+
* compiled Zod, which strips a key it does not know — verified today
|
|
21191
|
+
* on the OUTPUT side, where an additive field DOES arrive immediately
|
|
21192
|
+
* (`Track.hasFace`). Until the train ships, sending `false` is
|
|
21193
|
+
* harmless and simply keeps the crops inline.
|
|
21194
|
+
*/
|
|
21195
|
+
includeCrops: boolean().optional()
|
|
20105
21196
|
}).optional(), array(FaceInfoSchema).readonly()), method(object({
|
|
20106
21197
|
deviceId: number().int(),
|
|
20107
21198
|
trackId: string()
|
|
@@ -20725,15 +21816,57 @@ var AvailableIntegrationTypeSchema = object({
|
|
|
20725
21816
|
* flow can import (e.g. HA areas). Drives the adopt modal's "import
|
|
20726
21817
|
* locations" checkbox. Provider-declared in the addon manifest. */
|
|
20727
21818
|
supportsLocationImport: boolean(),
|
|
21819
|
+
/**
|
|
21820
|
+
* True when this integration DECLARES a pre-creation test (the
|
|
21821
|
+
* `connection-test` cap, or a broker whose settings it stores). Drives the
|
|
21822
|
+
* Test button: an integration that cannot be tested must say so up front
|
|
21823
|
+
* rather than offering a button that always answers the same nonsense.
|
|
21824
|
+
*/
|
|
21825
|
+
canTest: boolean(),
|
|
20728
21826
|
existingInstances: array(object({
|
|
20729
21827
|
id: string(),
|
|
20730
21828
|
name: string()
|
|
20731
21829
|
})),
|
|
20732
21830
|
canAdd: boolean()
|
|
20733
21831
|
});
|
|
21832
|
+
/**
|
|
21833
|
+
* Why a test could not be answered as a plain boolean.
|
|
21834
|
+
*
|
|
21835
|
+
* `success` alone collapsed four different situations into one red box, and the
|
|
21836
|
+
* one that mattered most — "nobody ever asked the remote anything" — looked
|
|
21837
|
+
* exactly like "the remote said no". The status is the discriminator:
|
|
21838
|
+
*
|
|
21839
|
+
* - `validated` — a provider-declared test ran and the remote ACCEPTED.
|
|
21840
|
+
* - `rejected` — a provider-declared test ran and the remote REFUSED.
|
|
21841
|
+
* The only status that blocks `integrations.create`.
|
|
21842
|
+
* - `inconclusive` — a test IS declared but could not complete (timeout,
|
|
21843
|
+
* DNS, 5xx). Nothing was observed; not a failure.
|
|
21844
|
+
* - `unsupported` — this integration declares NO test. Nothing was
|
|
21845
|
+
* observed either; not a failure, and not a pass.
|
|
21846
|
+
*
|
|
21847
|
+
* `unsupported` and `inconclusive` both carry `success: false` so an older
|
|
21848
|
+
* client can never read them as a green tick, and both carry an `error` string
|
|
21849
|
+
* that SAYS the test did not run rather than inventing a failure.
|
|
21850
|
+
*/
|
|
21851
|
+
var TestConnectionStatusEnum = _enum([
|
|
21852
|
+
"validated",
|
|
21853
|
+
"rejected",
|
|
21854
|
+
"inconclusive",
|
|
21855
|
+
"unsupported"
|
|
21856
|
+
]);
|
|
20734
21857
|
var TestConnectionResultSchema$1 = object({
|
|
21858
|
+
/** True ONLY for `validated`. Never true for a test that did not run. */
|
|
20735
21859
|
success: boolean(),
|
|
20736
|
-
error: string().optional()
|
|
21860
|
+
error: string().optional(),
|
|
21861
|
+
/** Optional for wire back-compat with clients built before the tri-state;
|
|
21862
|
+
* the server always sets it. */
|
|
21863
|
+
status: TestConnectionStatusEnum.optional(),
|
|
21864
|
+
/** Addon id whose declared test answered — `null` when none did. Lets the UI
|
|
21865
|
+
* attribute a result instead of blaming "the integration". */
|
|
21866
|
+
testedBy: string().nullable().optional(),
|
|
21867
|
+
latencyMs: number().nonnegative().optional(),
|
|
21868
|
+
/** Human detail from a `validated` result ("3 devices on this account"). */
|
|
21869
|
+
detail: string().optional()
|
|
20737
21870
|
});
|
|
20738
21871
|
var CreateIntegrationInputSchema = object({
|
|
20739
21872
|
addonId: string(),
|
|
@@ -22267,6 +23400,173 @@ setOverlay: method(object({
|
|
|
22267
23400
|
}] }
|
|
22268
23401
|
};
|
|
22269
23402
|
/**
|
|
23403
|
+
* `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
|
|
23404
|
+
*
|
|
23405
|
+
* The `osd` cap is the firmware contract: it probes a camera's overlay
|
|
23406
|
+
* SLOTS and writes literal text into one. It has no idea WHERE that text
|
|
23407
|
+
* comes from, and it must not — a driver that grew a "show the temperature
|
|
23408
|
+
* here" feature would grow it once per vendor.
|
|
23409
|
+
*
|
|
23410
|
+
* This cap owns the other half: a per-(camera, slot) BINDING that says
|
|
23411
|
+
* which value feeds the slot, how it is formatted, and under which
|
|
23412
|
+
* conditions it is shown at all. One addon renders every binding on every
|
|
23413
|
+
* camera, so a new source costs zero driver code.
|
|
23414
|
+
*
|
|
23415
|
+
* Three deliberate choices, each with a rejected alternative:
|
|
23416
|
+
*
|
|
23417
|
+
* 1. A source is `(capName, valuePath)` over the kernel's device
|
|
23418
|
+
* runtime-state mirror — NOT a closed enum of source kinds. Every
|
|
23419
|
+
* cap-keyed slice a device publishes is bindable the day the cap
|
|
23420
|
+
* ships. The rejected alternative (one enum member per source, with
|
|
23421
|
+
* a resolver branch each) is what makes "add the humidity too" a
|
|
23422
|
+
* code change.
|
|
23423
|
+
* 2. The display gate reuses `NcConditionsSchema` verbatim — the
|
|
23424
|
+
* notification centre's condition vocabulary — rather than a parallel
|
|
23425
|
+
* model. An operator who has learned one condition editor has learned
|
|
23426
|
+
* both.
|
|
23427
|
+
* 3. Because the renderer's facts are device STATE and not a detection
|
|
23428
|
+
* record, only a SUBSET of that vocabulary can be answered here.
|
|
23429
|
+
* `setSlotBinding` REJECTS the rest at write time (see
|
|
23430
|
+
* `getConditionSupport`). It does not accept-then-fail-closed: a
|
|
23431
|
+
* condition that can never be true renders a permanently blank
|
|
23432
|
+
* overlay, and a blank overlay looks exactly like a broken camera.
|
|
23433
|
+
*/
|
|
23434
|
+
/** Where a slot's value comes from. */
|
|
23435
|
+
var OsdSourceSchema = discriminatedUnion("kind", [
|
|
23436
|
+
object({
|
|
23437
|
+
kind: literal("static"),
|
|
23438
|
+
text: string().max(64)
|
|
23439
|
+
}),
|
|
23440
|
+
object({
|
|
23441
|
+
kind: literal("clock"),
|
|
23442
|
+
/** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
|
|
23443
|
+
pattern: string().min(1).max(32).default("HH:mm"),
|
|
23444
|
+
/** IANA zone. Omitted = the server's zone. */
|
|
23445
|
+
timezone: string().min(1).max(64).optional()
|
|
23446
|
+
}),
|
|
23447
|
+
object({
|
|
23448
|
+
kind: literal("device-state"),
|
|
23449
|
+
deviceId: number().int().optional(),
|
|
23450
|
+
capName: string().min(1).max(64),
|
|
23451
|
+
/** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
|
|
23452
|
+
valuePath: string().min(1).max(64)
|
|
23453
|
+
})
|
|
23454
|
+
]);
|
|
23455
|
+
var OsdSlotBindingSchema = object({
|
|
23456
|
+
/** Off = the manager stops driving this slot. It does NOT clear it. */
|
|
23457
|
+
enabled: boolean().default(true),
|
|
23458
|
+
source: OsdSourceSchema,
|
|
23459
|
+
/** `${value}` and `${unit}` are substituted; every occurrence. */
|
|
23460
|
+
template: string().max(96).default("${value}"),
|
|
23461
|
+
/** Truncate with an ellipsis past this length. Absent = no limit. */
|
|
23462
|
+
maxCharacters: number().int().min(4).max(64).optional(),
|
|
23463
|
+
/**
|
|
23464
|
+
* Decimal places for a numeric value. `0` yields an integer — the
|
|
23465
|
+
* documented workaround for firmwares that reject `.` in overlay text.
|
|
23466
|
+
*/
|
|
23467
|
+
maxDecimals: number().int().min(0).max(4).default(1),
|
|
23468
|
+
/** Appended via `${unit}`. The state mirror does not carry units. */
|
|
23469
|
+
unitLabel: string().max(8).optional(),
|
|
23470
|
+
/** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
|
|
23471
|
+
valueMap: record(string(), string()).optional(),
|
|
23472
|
+
/** Time windows in which the slot is shown. Absent = always. */
|
|
23473
|
+
schedule: NcScheduleSchema.optional(),
|
|
23474
|
+
/**
|
|
23475
|
+
* Display gate, in the notification centre's condition vocabulary.
|
|
23476
|
+
* Only the keys reported by `getConditionSupport` are accepted.
|
|
23477
|
+
*/
|
|
23478
|
+
conditions: NcConditionsSchema.optional(),
|
|
23479
|
+
/** Rendered when the gate is closed or the value unreadable. Empty = hide. */
|
|
23480
|
+
fallbackText: string().max(64).default("")
|
|
23481
|
+
});
|
|
23482
|
+
/** One camera slot, as the operator sees it: firmware truth + our binding. */
|
|
23483
|
+
var OsdSlotViewSchema = object({
|
|
23484
|
+
slotId: string(),
|
|
23485
|
+
kind: OsdOverlayKindEnum,
|
|
23486
|
+
/** Firmware refuses text edits (a timestamp, the channel name). */
|
|
23487
|
+
readOnly: boolean(),
|
|
23488
|
+
cameraEnabled: boolean(),
|
|
23489
|
+
cameraText: string().optional(),
|
|
23490
|
+
binding: OsdSlotBindingSchema.nullable()
|
|
23491
|
+
});
|
|
23492
|
+
/**
|
|
23493
|
+
* What happened to one slot on one render pass. `unchanged` exists so the
|
|
23494
|
+
* operator can tell "we are driving this and the value is steady" from
|
|
23495
|
+
* "we never got there" — and so the loop can prove it is not rewriting
|
|
23496
|
+
* identical text to the camera every tick.
|
|
23497
|
+
*/
|
|
23498
|
+
var OsdRenderOutcomeEnum = _enum([
|
|
23499
|
+
"written",
|
|
23500
|
+
"unchanged",
|
|
23501
|
+
"gated",
|
|
23502
|
+
"unreadable",
|
|
23503
|
+
"disabled",
|
|
23504
|
+
"unbound",
|
|
23505
|
+
"failed"
|
|
23506
|
+
]);
|
|
23507
|
+
var OsdRenderResultSchema = object({
|
|
23508
|
+
slotId: string(),
|
|
23509
|
+
outcome: OsdRenderOutcomeEnum,
|
|
23510
|
+
/** The text the slot should carry. Empty = the slot is switched off. */
|
|
23511
|
+
text: string(),
|
|
23512
|
+
/** Why, whenever the outcome is not a plain write. Never silent. */
|
|
23513
|
+
reason: string().optional()
|
|
23514
|
+
});
|
|
23515
|
+
var OsdSourceValueTypeEnum = _enum([
|
|
23516
|
+
"number",
|
|
23517
|
+
"boolean",
|
|
23518
|
+
"string",
|
|
23519
|
+
"enum"
|
|
23520
|
+
]);
|
|
23521
|
+
/**
|
|
23522
|
+
* One bindable value, derived from a cap's `runtimeState` schema — never
|
|
23523
|
+
* hand-listed. The editor renders from this, so a cap that ships a new
|
|
23524
|
+
* state field becomes bindable with no UI change.
|
|
23525
|
+
*/
|
|
23526
|
+
var OsdSourceOptionSchema = object({
|
|
23527
|
+
deviceId: number().int(),
|
|
23528
|
+
deviceName: string(),
|
|
23529
|
+
capName: string(),
|
|
23530
|
+
valuePath: string(),
|
|
23531
|
+
label: string(),
|
|
23532
|
+
valueType: OsdSourceValueTypeEnum,
|
|
23533
|
+
/** Present for `enum`; the editor offers these as `valueMap` keys. */
|
|
23534
|
+
enumValues: array(string()).readonly().optional()
|
|
23535
|
+
});
|
|
23536
|
+
method(object({ deviceId: number().int() }), object({
|
|
23537
|
+
supported: boolean(),
|
|
23538
|
+
slots: array(OsdSlotViewSchema)
|
|
23539
|
+
}), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
|
|
23540
|
+
supported: array(string()),
|
|
23541
|
+
catalog: array(NcConditionDescriptorSchema)
|
|
23542
|
+
}), { auth: "admin" }), method(object({
|
|
23543
|
+
deviceId: number().int(),
|
|
23544
|
+
slotId: string().min(1),
|
|
23545
|
+
binding: OsdSlotBindingSchema
|
|
23546
|
+
}), object({
|
|
23547
|
+
slot: OsdSlotViewSchema,
|
|
23548
|
+
render: OsdRenderResultSchema
|
|
23549
|
+
}), {
|
|
23550
|
+
kind: "mutation",
|
|
23551
|
+
auth: "admin"
|
|
23552
|
+
}), method(object({
|
|
23553
|
+
deviceId: number().int(),
|
|
23554
|
+
slotId: string().min(1)
|
|
23555
|
+
}), object({ success: literal(true) }), {
|
|
23556
|
+
kind: "mutation",
|
|
23557
|
+
auth: "admin"
|
|
23558
|
+
}), method(object({
|
|
23559
|
+
deviceId: number().int(),
|
|
23560
|
+
slotId: string().min(1),
|
|
23561
|
+
binding: OsdSlotBindingSchema.optional()
|
|
23562
|
+
}), OsdRenderResultSchema, {
|
|
23563
|
+
kind: "mutation",
|
|
23564
|
+
auth: "admin"
|
|
23565
|
+
}), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
|
|
23566
|
+
kind: "mutation",
|
|
23567
|
+
auth: "admin"
|
|
23568
|
+
});
|
|
23569
|
+
/**
|
|
22270
23570
|
* Feeder connectivity / power status — mirrors the HA petkit device-status
|
|
22271
23571
|
* enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
|
|
22272
23572
|
* `on_batteries` (running on battery backup). `null` until first reported.
|
|
@@ -24631,13 +25931,23 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
24631
25931
|
username: string(),
|
|
24632
25932
|
scopes: array(TokenScopeSchema),
|
|
24633
25933
|
redirectUri: string(),
|
|
24634
|
-
hubUrl: string()
|
|
25934
|
+
hubUrl: string(),
|
|
25935
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
25936
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
25937
|
+
codeChallenge: string().optional(),
|
|
25938
|
+
/** The integration's declared refresh lifetime — seconds, or `'never'`.
|
|
25939
|
+
* From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
|
|
25940
|
+
* code so the link carries its own lifetime; omit for the 30-day
|
|
25941
|
+
* default. */
|
|
25942
|
+
refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
|
|
24635
25943
|
}), object({ code: string() }), {
|
|
24636
25944
|
kind: "mutation",
|
|
24637
25945
|
access: "create"
|
|
24638
25946
|
}), method(object({
|
|
24639
25947
|
code: string(),
|
|
24640
|
-
redirectUri: string()
|
|
25948
|
+
redirectUri: string(),
|
|
25949
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
25950
|
+
codeVerifier: string().optional()
|
|
24641
25951
|
}), object({
|
|
24642
25952
|
accessToken: string(),
|
|
24643
25953
|
refreshToken: string(),
|
|
@@ -26376,11 +27686,52 @@ function startReachabilityPoll(options) {
|
|
|
26376
27686
|
}
|
|
26377
27687
|
var LAST_FETCHED_FIELD = "lastFetchedAt";
|
|
26378
27688
|
function createRuntimeStateBridge(params) {
|
|
26379
|
-
const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty } = params;
|
|
27689
|
+
const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
|
|
27690
|
+
const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
|
|
27691
|
+
/** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
|
|
27692
|
+
let missCooldownUntil = 0;
|
|
27693
|
+
const readFetchedAt = () => {
|
|
27694
|
+
const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
|
|
27695
|
+
return typeof value === "number" ? value : 0;
|
|
27696
|
+
};
|
|
27697
|
+
/**
|
|
27698
|
+
* Stop re-reading this camera for `missCooldownMs`, and say so. The
|
|
27699
|
+
* warn is the ONLY line an operator gets for a cap that has silently
|
|
27700
|
+
* been answering with defaults, so it names the cap and carries
|
|
27701
|
+
* `tags.deviceId` — a miss is always asked per-camera.
|
|
27702
|
+
*/
|
|
27703
|
+
const openMissCooldown = (err) => {
|
|
27704
|
+
missCooldownUntil = Date.now() + missCooldownMs;
|
|
27705
|
+
logger?.warn(`${cap.name}: refresh did not land — serving the last slice and not re-reading the camera for ${String(missCooldownMs)}ms`, {
|
|
27706
|
+
tags: { deviceId: ownDeviceId },
|
|
27707
|
+
meta: {
|
|
27708
|
+
cooldownMs: missCooldownMs,
|
|
27709
|
+
error: err === void 0 ? null : err instanceof Error ? err.message : String(err)
|
|
27710
|
+
}
|
|
27711
|
+
});
|
|
27712
|
+
};
|
|
26380
27713
|
const ensureFresh = async () => {
|
|
26381
27714
|
const slice = runtimeState.getCapState(cap.name);
|
|
26382
|
-
const fetchedAt =
|
|
26383
|
-
if (
|
|
27715
|
+
const fetchedAt = readFetchedAt();
|
|
27716
|
+
if (slice && Date.now() - fetchedAt <= staleMs) {
|
|
27717
|
+
missCooldownUntil = 0;
|
|
27718
|
+
return;
|
|
27719
|
+
}
|
|
27720
|
+
if (Date.now() < missCooldownUntil) return;
|
|
27721
|
+
try {
|
|
27722
|
+
await refresh();
|
|
27723
|
+
} catch (err) {
|
|
27724
|
+
openMissCooldown(err);
|
|
27725
|
+
throw err;
|
|
27726
|
+
}
|
|
27727
|
+
if (readFetchedAt() > fetchedAt) {
|
|
27728
|
+
if (missCooldownUntil !== 0) {
|
|
27729
|
+
missCooldownUntil = 0;
|
|
27730
|
+
logger?.info(`${cap.name}: refresh landed again — resuming normal polling`, { tags: { deviceId: ownDeviceId } });
|
|
27731
|
+
}
|
|
27732
|
+
return;
|
|
27733
|
+
}
|
|
27734
|
+
openMissCooldown(void 0);
|
|
26384
27735
|
};
|
|
26385
27736
|
const projectStatus = () => {
|
|
26386
27737
|
const slice = runtimeState.getCapState(cap.name);
|
|
@@ -27173,6 +28524,18 @@ Object.freeze({
|
|
|
27173
28524
|
addonId: null,
|
|
27174
28525
|
access: "create"
|
|
27175
28526
|
},
|
|
28527
|
+
"connectionTest.describeTest": {
|
|
28528
|
+
capName: "connection-test",
|
|
28529
|
+
capScope: "system",
|
|
28530
|
+
addonId: null,
|
|
28531
|
+
access: "view"
|
|
28532
|
+
},
|
|
28533
|
+
"connectionTest.testSettings": {
|
|
28534
|
+
capName: "connection-test",
|
|
28535
|
+
capScope: "system",
|
|
28536
|
+
addonId: null,
|
|
28537
|
+
access: "create"
|
|
28538
|
+
},
|
|
27176
28539
|
"consumables.reset": {
|
|
27177
28540
|
capName: "consumables",
|
|
27178
28541
|
capScope: "device",
|
|
@@ -27221,6 +28584,12 @@ Object.freeze({
|
|
|
27221
28584
|
addonId: null,
|
|
27222
28585
|
access: "view"
|
|
27223
28586
|
},
|
|
28587
|
+
"coreBlocks.restart": {
|
|
28588
|
+
capName: "core-blocks",
|
|
28589
|
+
capScope: "system",
|
|
28590
|
+
addonId: null,
|
|
28591
|
+
access: "create"
|
|
28592
|
+
},
|
|
27224
28593
|
"coreBlocks.setEnabled": {
|
|
27225
28594
|
capName: "core-blocks",
|
|
27226
28595
|
capScope: "system",
|
|
@@ -27557,6 +28926,12 @@ Object.freeze({
|
|
|
27557
28926
|
addonId: null,
|
|
27558
28927
|
access: "create"
|
|
27559
28928
|
},
|
|
28929
|
+
"deviceManager.adoptionCancelJob": {
|
|
28930
|
+
capName: "device-manager",
|
|
28931
|
+
capScope: "system",
|
|
28932
|
+
addonId: null,
|
|
28933
|
+
access: "create"
|
|
28934
|
+
},
|
|
27560
28935
|
"deviceManager.adoptionListCandidateFilters": {
|
|
27561
28936
|
capName: "device-manager",
|
|
27562
28937
|
capScope: "system",
|
|
@@ -27569,6 +28944,12 @@ Object.freeze({
|
|
|
27569
28944
|
addonId: null,
|
|
27570
28945
|
access: "view"
|
|
27571
28946
|
},
|
|
28947
|
+
"deviceManager.adoptionListJobs": {
|
|
28948
|
+
capName: "device-manager",
|
|
28949
|
+
capScope: "system",
|
|
28950
|
+
addonId: null,
|
|
28951
|
+
access: "view"
|
|
28952
|
+
},
|
|
27572
28953
|
"deviceManager.adoptionRefresh": {
|
|
27573
28954
|
capName: "device-manager",
|
|
27574
28955
|
capScope: "system",
|
|
@@ -27587,6 +28968,12 @@ Object.freeze({
|
|
|
27587
28968
|
addonId: null,
|
|
27588
28969
|
access: "create"
|
|
27589
28970
|
},
|
|
28971
|
+
"deviceManager.adoptionStartJob": {
|
|
28972
|
+
capName: "device-manager",
|
|
28973
|
+
capScope: "system",
|
|
28974
|
+
addonId: null,
|
|
28975
|
+
access: "create"
|
|
28976
|
+
},
|
|
27590
28977
|
"deviceManager.allocateDeviceId": {
|
|
27591
28978
|
capName: "device-manager",
|
|
27592
28979
|
capScope: "system",
|
|
@@ -27857,12 +29244,6 @@ Object.freeze({
|
|
|
27857
29244
|
addonId: null,
|
|
27858
29245
|
access: "create"
|
|
27859
29246
|
},
|
|
27860
|
-
"deviceManager.setDeviceLinks": {
|
|
27861
|
-
capName: "device-manager",
|
|
27862
|
-
capScope: "system",
|
|
27863
|
-
addonId: null,
|
|
27864
|
-
access: "create"
|
|
27865
|
-
},
|
|
27866
29247
|
"deviceManager.setDisabled": {
|
|
27867
29248
|
capName: "device-manager",
|
|
27868
29249
|
capScope: "system",
|
|
@@ -29261,6 +30642,48 @@ Object.freeze({
|
|
|
29261
30642
|
addonId: null,
|
|
29262
30643
|
access: "create"
|
|
29263
30644
|
},
|
|
30645
|
+
"osdManager.clearSlotBinding": {
|
|
30646
|
+
capName: "osd-manager",
|
|
30647
|
+
capScope: "system",
|
|
30648
|
+
addonId: null,
|
|
30649
|
+
access: "delete"
|
|
30650
|
+
},
|
|
30651
|
+
"osdManager.getConditionSupport": {
|
|
30652
|
+
capName: "osd-manager",
|
|
30653
|
+
capScope: "system",
|
|
30654
|
+
addonId: null,
|
|
30655
|
+
access: "view"
|
|
30656
|
+
},
|
|
30657
|
+
"osdManager.getDeviceOsd": {
|
|
30658
|
+
capName: "osd-manager",
|
|
30659
|
+
capScope: "system",
|
|
30660
|
+
addonId: null,
|
|
30661
|
+
access: "view"
|
|
30662
|
+
},
|
|
30663
|
+
"osdManager.getSourceCatalog": {
|
|
30664
|
+
capName: "osd-manager",
|
|
30665
|
+
capScope: "system",
|
|
30666
|
+
addonId: null,
|
|
30667
|
+
access: "view"
|
|
30668
|
+
},
|
|
30669
|
+
"osdManager.previewSlot": {
|
|
30670
|
+
capName: "osd-manager",
|
|
30671
|
+
capScope: "system",
|
|
30672
|
+
addonId: null,
|
|
30673
|
+
access: "create"
|
|
30674
|
+
},
|
|
30675
|
+
"osdManager.renderDevice": {
|
|
30676
|
+
capName: "osd-manager",
|
|
30677
|
+
capScope: "system",
|
|
30678
|
+
addonId: null,
|
|
30679
|
+
access: "create"
|
|
30680
|
+
},
|
|
30681
|
+
"osdManager.setSlotBinding": {
|
|
30682
|
+
capName: "osd-manager",
|
|
30683
|
+
capScope: "system",
|
|
30684
|
+
addonId: null,
|
|
30685
|
+
access: "create"
|
|
30686
|
+
},
|
|
29264
30687
|
"petFeeder.callPet": {
|
|
29265
30688
|
capName: "pet-feeder",
|
|
29266
30689
|
capScope: "device",
|
|
@@ -29333,6 +30756,12 @@ Object.freeze({
|
|
|
29333
30756
|
addonId: null,
|
|
29334
30757
|
access: "delete"
|
|
29335
30758
|
},
|
|
30759
|
+
"pipelineAnalytics.completeRetrainTrack": {
|
|
30760
|
+
capName: "pipeline-analytics",
|
|
30761
|
+
capScope: "device",
|
|
30762
|
+
addonId: null,
|
|
30763
|
+
access: "create"
|
|
30764
|
+
},
|
|
29336
30765
|
"pipelineAnalytics.deleteDeviceEvents": {
|
|
29337
30766
|
capName: "pipeline-analytics",
|
|
29338
30767
|
capScope: "device",
|
|
@@ -29345,6 +30774,12 @@ Object.freeze({
|
|
|
29345
30774
|
addonId: null,
|
|
29346
30775
|
access: "delete"
|
|
29347
30776
|
},
|
|
30777
|
+
"pipelineAnalytics.deselectRetrainFrame": {
|
|
30778
|
+
capName: "pipeline-analytics",
|
|
30779
|
+
capScope: "device",
|
|
30780
|
+
addonId: null,
|
|
30781
|
+
access: "create"
|
|
30782
|
+
},
|
|
29348
30783
|
"pipelineAnalytics.getActiveTracks": {
|
|
29349
30784
|
capName: "pipeline-analytics",
|
|
29350
30785
|
capScope: "device",
|
|
@@ -29405,6 +30840,18 @@ Object.freeze({
|
|
|
29405
30840
|
addonId: null,
|
|
29406
30841
|
access: "view"
|
|
29407
30842
|
},
|
|
30843
|
+
"pipelineAnalytics.getRetrainExportUrl": {
|
|
30844
|
+
capName: "pipeline-analytics",
|
|
30845
|
+
capScope: "device",
|
|
30846
|
+
addonId: null,
|
|
30847
|
+
access: "view"
|
|
30848
|
+
},
|
|
30849
|
+
"pipelineAnalytics.getRetrainFrameImage": {
|
|
30850
|
+
capName: "pipeline-analytics",
|
|
30851
|
+
capScope: "device",
|
|
30852
|
+
addonId: null,
|
|
30853
|
+
access: "view"
|
|
30854
|
+
},
|
|
29408
30855
|
"pipelineAnalytics.getSensorEvents": {
|
|
29409
30856
|
capName: "pipeline-analytics",
|
|
29410
30857
|
capScope: "device",
|
|
@@ -29423,6 +30870,18 @@ Object.freeze({
|
|
|
29423
30870
|
addonId: null,
|
|
29424
30871
|
access: "view"
|
|
29425
30872
|
},
|
|
30873
|
+
"pipelineAnalytics.getTrainingExportSummary": {
|
|
30874
|
+
capName: "pipeline-analytics",
|
|
30875
|
+
capScope: "device",
|
|
30876
|
+
addonId: null,
|
|
30877
|
+
access: "view"
|
|
30878
|
+
},
|
|
30879
|
+
"pipelineAnalytics.getTrainingExportUrl": {
|
|
30880
|
+
capName: "pipeline-analytics",
|
|
30881
|
+
capScope: "device",
|
|
30882
|
+
addonId: null,
|
|
30883
|
+
access: "view"
|
|
30884
|
+
},
|
|
29426
30885
|
"pipelineAnalytics.listEventKinds": {
|
|
29427
30886
|
capName: "pipeline-analytics",
|
|
29428
30887
|
capScope: "device",
|
|
@@ -29447,6 +30906,24 @@ Object.freeze({
|
|
|
29447
30906
|
addonId: null,
|
|
29448
30907
|
access: "view"
|
|
29449
30908
|
},
|
|
30909
|
+
"pipelineAnalytics.listRetrainAnnotations": {
|
|
30910
|
+
capName: "pipeline-analytics",
|
|
30911
|
+
capScope: "device",
|
|
30912
|
+
addonId: null,
|
|
30913
|
+
access: "view"
|
|
30914
|
+
},
|
|
30915
|
+
"pipelineAnalytics.listRetrainFrames": {
|
|
30916
|
+
capName: "pipeline-analytics",
|
|
30917
|
+
capScope: "device",
|
|
30918
|
+
addonId: null,
|
|
30919
|
+
access: "view"
|
|
30920
|
+
},
|
|
30921
|
+
"pipelineAnalytics.listRetrainStaging": {
|
|
30922
|
+
capName: "pipeline-analytics",
|
|
30923
|
+
capScope: "device",
|
|
30924
|
+
addonId: null,
|
|
30925
|
+
access: "view"
|
|
30926
|
+
},
|
|
29450
30927
|
"pipelineAnalytics.listTrackMedia": {
|
|
29451
30928
|
capName: "pipeline-analytics",
|
|
29452
30929
|
capScope: "device",
|
|
@@ -29459,6 +30936,12 @@ Object.freeze({
|
|
|
29459
30936
|
addonId: null,
|
|
29460
30937
|
access: "view"
|
|
29461
30938
|
},
|
|
30939
|
+
"pipelineAnalytics.proposeRetrainAnnotations": {
|
|
30940
|
+
capName: "pipeline-analytics",
|
|
30941
|
+
capScope: "device",
|
|
30942
|
+
addonId: null,
|
|
30943
|
+
access: "create"
|
|
30944
|
+
},
|
|
29462
30945
|
"pipelineAnalytics.pruneEvents": {
|
|
29463
30946
|
capName: "pipeline-analytics",
|
|
29464
30947
|
capScope: "device",
|
|
@@ -29489,12 +30972,30 @@ Object.freeze({
|
|
|
29489
30972
|
addonId: null,
|
|
29490
30973
|
access: "create"
|
|
29491
30974
|
},
|
|
30975
|
+
"pipelineAnalytics.restageRetrainTrack": {
|
|
30976
|
+
capName: "pipeline-analytics",
|
|
30977
|
+
capScope: "device",
|
|
30978
|
+
addonId: null,
|
|
30979
|
+
access: "create"
|
|
30980
|
+
},
|
|
30981
|
+
"pipelineAnalytics.saveRetrainAnnotations": {
|
|
30982
|
+
capName: "pipeline-analytics",
|
|
30983
|
+
capScope: "device",
|
|
30984
|
+
addonId: null,
|
|
30985
|
+
access: "create"
|
|
30986
|
+
},
|
|
29492
30987
|
"pipelineAnalytics.searchObjectEvents": {
|
|
29493
30988
|
capName: "pipeline-analytics",
|
|
29494
30989
|
capScope: "device",
|
|
29495
30990
|
addonId: null,
|
|
29496
30991
|
access: "view"
|
|
29497
30992
|
},
|
|
30993
|
+
"pipelineAnalytics.selectRetrainFrames": {
|
|
30994
|
+
capName: "pipeline-analytics",
|
|
30995
|
+
capScope: "device",
|
|
30996
|
+
addonId: null,
|
|
30997
|
+
access: "create"
|
|
30998
|
+
},
|
|
29498
30999
|
"pipelineAnalytics.setTrackFlags": {
|
|
29499
31000
|
capName: "pipeline-analytics",
|
|
29500
31001
|
capScope: "device",
|
|
@@ -30611,6 +32112,12 @@ Object.freeze({
|
|
|
30611
32112
|
addonId: null,
|
|
30612
32113
|
access: "view"
|
|
30613
32114
|
},
|
|
32115
|
+
"snapshot.getSnapshotLinks": {
|
|
32116
|
+
capName: "snapshot",
|
|
32117
|
+
capScope: "device",
|
|
32118
|
+
addonId: null,
|
|
32119
|
+
access: "view"
|
|
32120
|
+
},
|
|
30614
32121
|
"snapshot.getSnapshotOverview": {
|
|
30615
32122
|
capName: "snapshot",
|
|
30616
32123
|
capScope: "device",
|
|
@@ -30887,6 +32394,12 @@ Object.freeze({
|
|
|
30887
32394
|
addonId: null,
|
|
30888
32395
|
access: "create"
|
|
30889
32396
|
},
|
|
32397
|
+
"streamBroker.fetchEventMedia": {
|
|
32398
|
+
capName: "stream-broker",
|
|
32399
|
+
capScope: "system",
|
|
32400
|
+
addonId: null,
|
|
32401
|
+
access: "create"
|
|
32402
|
+
},
|
|
30890
32403
|
"streamBroker.getAllRtspEntries": {
|
|
30891
32404
|
capName: "stream-broker",
|
|
30892
32405
|
capScope: "system",
|
|
@@ -30899,6 +32412,12 @@ Object.freeze({
|
|
|
30899
32412
|
addonId: null,
|
|
30900
32413
|
access: "view"
|
|
30901
32414
|
},
|
|
32415
|
+
"streamBroker.getDeviceAudioMute": {
|
|
32416
|
+
capName: "stream-broker",
|
|
32417
|
+
capScope: "system",
|
|
32418
|
+
addonId: null,
|
|
32419
|
+
access: "view"
|
|
32420
|
+
},
|
|
30902
32421
|
"streamBroker.getPreBufferInfo": {
|
|
30903
32422
|
capName: "stream-broker",
|
|
30904
32423
|
capScope: "system",
|
|
@@ -30965,6 +32484,12 @@ Object.freeze({
|
|
|
30965
32484
|
addonId: null,
|
|
30966
32485
|
access: "create"
|
|
30967
32486
|
},
|
|
32487
|
+
"streamBroker.produceEventMedia": {
|
|
32488
|
+
capName: "stream-broker",
|
|
32489
|
+
capScope: "system",
|
|
32490
|
+
addonId: null,
|
|
32491
|
+
access: "create"
|
|
32492
|
+
},
|
|
30968
32493
|
"streamBroker.publishCameraStream": {
|
|
30969
32494
|
capName: "stream-broker",
|
|
30970
32495
|
capScope: "system",
|
|
@@ -31019,6 +32544,12 @@ Object.freeze({
|
|
|
31019
32544
|
addonId: null,
|
|
31020
32545
|
access: "create"
|
|
31021
32546
|
},
|
|
32547
|
+
"streamBroker.setDeviceAudioMute": {
|
|
32548
|
+
capName: "stream-broker",
|
|
32549
|
+
capScope: "system",
|
|
32550
|
+
addonId: null,
|
|
32551
|
+
access: "create"
|
|
32552
|
+
},
|
|
31022
32553
|
"streamBroker.setPreBufferDuration": {
|
|
31023
32554
|
capName: "stream-broker",
|
|
31024
32555
|
capScope: "system",
|
|
@@ -33133,6 +34664,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
33133
34664
|
ownDeviceId: this.id,
|
|
33134
34665
|
refresh: refreshFromCamera,
|
|
33135
34666
|
staleMs: STALE_MS,
|
|
34667
|
+
logger: this.ctx.logger,
|
|
33136
34668
|
empty: () => ({ lastFetchedAt: 0 })
|
|
33137
34669
|
});
|
|
33138
34670
|
const provider = {
|
|
@@ -33224,6 +34756,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
33224
34756
|
ownDeviceId: this.id,
|
|
33225
34757
|
refresh: refreshFromCamera,
|
|
33226
34758
|
staleMs: STALE_MS,
|
|
34759
|
+
logger: this.ctx.logger,
|
|
33227
34760
|
empty: () => ({
|
|
33228
34761
|
enabled: false,
|
|
33229
34762
|
sensitivity: 0,
|
|
@@ -33326,6 +34859,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
33326
34859
|
ownDeviceId: this.id,
|
|
33327
34860
|
refresh: refreshFromCamera,
|
|
33328
34861
|
staleMs: STALE_MS,
|
|
34862
|
+
logger: this.ctx.logger,
|
|
33329
34863
|
empty: () => ({
|
|
33330
34864
|
enabled: false,
|
|
33331
34865
|
regions: [],
|
|
@@ -33482,6 +35016,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
33482
35016
|
ownDeviceId: this.id,
|
|
33483
35017
|
refresh: refreshFromCamera,
|
|
33484
35018
|
staleMs: STALE_MS,
|
|
35019
|
+
logger: this.ctx.logger,
|
|
33485
35020
|
empty: () => ({
|
|
33486
35021
|
mode: "auto",
|
|
33487
35022
|
lastFetchedAt: 0
|
|
@@ -33572,6 +35107,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
33572
35107
|
ownDeviceId: this.id,
|
|
33573
35108
|
refresh: refreshFromCamera,
|
|
33574
35109
|
staleMs: STALE_MS,
|
|
35110
|
+
logger: this.ctx.logger,
|
|
33575
35111
|
empty: () => ({ lastFetchedAt: 0 })
|
|
33576
35112
|
}).getStatus,
|
|
33577
35113
|
getOptions: async ({ deviceId }) => {
|