@camstack/addon-pipeline-orchestrator 1.2.32 → 1.2.34
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/assets/viewer/.viewer-version +1 -1
- package/assets/viewer/_expo/static/js/web/{Clipboard-a7e650a0275101956cfc27665a55251d.js → Clipboard-d48d3b588462f946bb7c6bbcb6496478.js} +8 -8
- package/assets/viewer/_expo/static/js/web/{Haptics-50dc91ae29a4c2dc03a4e867706d235d.js → Haptics-53c4c122a82bf429cb93a4fe5ae9fb46.js} +3 -3
- package/assets/viewer/_expo/static/js/web/{index-e01fa0fb69f3795f76a103cb309022d6.js → index-04e8b58b1472de4d69f8a5184a3bbb9b.js} +605 -604
- package/assets/viewer/index.html +1 -1
- package/dist/_stub.js +214 -214
- package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DP-xubRv.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DMsZ8quw.mjs} +3 -3
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Xfc_tLhH.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DR6Zf4Up.mjs +26 -0
- package/dist/{hostInit-Bdc6cSED.mjs → hostInit-Cslwtzcn.mjs} +3 -3
- package/dist/index.js +2270 -1609
- package/dist/index.mjs +2270 -1609
- package/dist/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Celb3oc6.mjs +0 -26
- package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C_kzDF6N.mjs +0 -26
package/dist/index.js
CHANGED
|
@@ -7679,6 +7679,104 @@ object({
|
|
|
7679
7679
|
})
|
|
7680
7680
|
});
|
|
7681
7681
|
/**
|
|
7682
|
+
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7683
|
+
*
|
|
7684
|
+
* ## Why this exists
|
|
7685
|
+
*
|
|
7686
|
+
* `adopt({childNativeIds: [...]})` materialises one CamStack device per
|
|
7687
|
+
* candidate PLUS every accessory child, and the whole array shares ONE UDS
|
|
7688
|
+
* request deadline (60s). Measured on the live hub against Home Assistant:
|
|
7689
|
+
* each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
|
|
7690
|
+
* meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
|
|
7691
|
+
* `setRole`, … `persistConfig`) before the class is constructed — and an
|
|
7692
|
+
* accessory child costs the same as its parent. So the real unit of work is
|
|
7693
|
+
* the CHILD, not the candidate:
|
|
7694
|
+
*
|
|
7695
|
+
* - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
|
|
7696
|
+
* - ONE candidate with 217 children → ~217 devices → **>60s, times out**
|
|
7697
|
+
*
|
|
7698
|
+
* That second line is why this is a job and not a smaller batch. No chunking,
|
|
7699
|
+
* no bounded concurrency over candidates and no per-call tuning can fix a
|
|
7700
|
+
* shape where **N=1 already exceeds the deadline** — the count that blows the
|
|
7701
|
+
* budget is the source system's accessory fan-out, which the operator does not
|
|
7702
|
+
* choose and cannot see. A design that only works below some N is the same bug
|
|
7703
|
+
* deferred.
|
|
7704
|
+
*
|
|
7705
|
+
* ## What the timeout did NOT do
|
|
7706
|
+
*
|
|
7707
|
+
* It did not stop the work. The UDS deadline ends the CALLER's wait; the
|
|
7708
|
+
* provider's loop runs to completion. Measured: a 25-candidate adopt that
|
|
7709
|
+
* "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
|
|
7710
|
+
* an error and had no way to learn that. Every field below exists so that
|
|
7711
|
+
* question has an answer.
|
|
7712
|
+
*
|
|
7713
|
+
* ## Idempotency
|
|
7714
|
+
*
|
|
7715
|
+
* Jobs are in-RAM; a restart forgets them. That is safe here because adoption
|
|
7716
|
+
* is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
|
|
7717
|
+
* re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
|
|
7718
|
+
* by the engine before any provider call and lands in `alreadyAdopted`. It is
|
|
7719
|
+
* never a duplicate device, and never an error the operator has to interpret.
|
|
7720
|
+
*/
|
|
7721
|
+
var AdoptionJobStateSchema = _enum([
|
|
7722
|
+
"running",
|
|
7723
|
+
"done",
|
|
7724
|
+
"failed",
|
|
7725
|
+
"cancelled"
|
|
7726
|
+
]);
|
|
7727
|
+
/**
|
|
7728
|
+
* Per-candidate result. Every candidate the job was asked to adopt ends in
|
|
7729
|
+
* exactly one of these buckets — there is no silent drop, and the operator can
|
|
7730
|
+
* always answer "which of my 25 landed?".
|
|
7731
|
+
*
|
|
7732
|
+
* - `adopted` — created now by this job.
|
|
7733
|
+
* - `already-adopted` — a device for this candidate existed before the job
|
|
7734
|
+
* reached it (a re-run, or a retry after a timeout). Not an error.
|
|
7735
|
+
* - `failed` — the provider threw; `error` carries the message.
|
|
7736
|
+
* - `cancelled` — the operator cancelled before this candidate was reached.
|
|
7737
|
+
*/
|
|
7738
|
+
var AdoptionOutcomeSchema = _enum([
|
|
7739
|
+
"adopted",
|
|
7740
|
+
"already-adopted",
|
|
7741
|
+
"failed",
|
|
7742
|
+
"cancelled"
|
|
7743
|
+
]);
|
|
7744
|
+
var AdoptionCandidateResultSchema = object({
|
|
7745
|
+
childNativeId: string(),
|
|
7746
|
+
outcome: AdoptionOutcomeSchema,
|
|
7747
|
+
/** The materialised parent device id — null for `failed` / `cancelled`. */
|
|
7748
|
+
parentDeviceId: number().int().nonnegative().nullable(),
|
|
7749
|
+
/** Accessory children created for this candidate. */
|
|
7750
|
+
accessoryCount: number().int().nonnegative(),
|
|
7751
|
+
/** Failure message; null unless `outcome === 'failed'`. */
|
|
7752
|
+
error: string().nullable()
|
|
7753
|
+
});
|
|
7754
|
+
var AdoptionJobSchema = object({
|
|
7755
|
+
jobId: string(),
|
|
7756
|
+
/** The integration provider this job adopts through (the `addonId` pin). */
|
|
7757
|
+
addonId: string(),
|
|
7758
|
+
integrationId: string(),
|
|
7759
|
+
state: AdoptionJobStateSchema,
|
|
7760
|
+
/** Candidates the job was asked to adopt. Known up front, so never null. */
|
|
7761
|
+
total: number().int().nonnegative(),
|
|
7762
|
+
/** Candidates that have reached a terminal bucket. */
|
|
7763
|
+
processed: number().int().nonnegative(),
|
|
7764
|
+
adopted: number().int().nonnegative(),
|
|
7765
|
+
alreadyAdopted: number().int().nonnegative(),
|
|
7766
|
+
failed: number().int().nonnegative(),
|
|
7767
|
+
/** Accessory child devices created across every candidate — the real unit
|
|
7768
|
+
* of work, surfaced so a slow job is legible rather than mysterious. */
|
|
7769
|
+
accessoriesCreated: number().int().nonnegative(),
|
|
7770
|
+
/** The candidate currently being adopted; null when idle or finished. */
|
|
7771
|
+
currentChildNativeId: string().nullable(),
|
|
7772
|
+
/** One entry per candidate, in the order they were processed. */
|
|
7773
|
+
results: array(AdoptionCandidateResultSchema).readonly(),
|
|
7774
|
+
startedAt: number(),
|
|
7775
|
+
finishedAt: number().nullable(),
|
|
7776
|
+
/** Set only when the job itself broke (not a per-candidate failure). */
|
|
7777
|
+
error: string().nullable()
|
|
7778
|
+
});
|
|
7779
|
+
/**
|
|
7682
7780
|
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7683
7781
|
* pipeline functions an operator thinks in terms of.
|
|
7684
7782
|
*
|
|
@@ -8761,441 +8859,1215 @@ var ConvertResultSchema = object({
|
|
|
8761
8859
|
})).readonly()
|
|
8762
8860
|
});
|
|
8763
8861
|
/**
|
|
8764
|
-
*
|
|
8765
|
-
*
|
|
8862
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
8863
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
8864
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
8865
|
+
*/
|
|
8866
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
8867
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
8868
|
+
var ExpressionParseError = class extends Error {
|
|
8869
|
+
position;
|
|
8870
|
+
constructor(message, position) {
|
|
8871
|
+
super(message);
|
|
8872
|
+
this.name = "ExpressionParseError";
|
|
8873
|
+
this.position = position;
|
|
8874
|
+
}
|
|
8875
|
+
};
|
|
8876
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
8877
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
8878
|
+
var ExpressionEvalError = class extends Error {
|
|
8879
|
+
constructor(message) {
|
|
8880
|
+
super(message);
|
|
8881
|
+
this.name = "ExpressionEvalError";
|
|
8882
|
+
}
|
|
8883
|
+
};
|
|
8884
|
+
/**
|
|
8885
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
8886
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
8887
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
8888
|
+
* own-property check against it.
|
|
8766
8889
|
*
|
|
8767
|
-
*
|
|
8768
|
-
*
|
|
8769
|
-
*
|
|
8770
|
-
*
|
|
8771
|
-
*
|
|
8890
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
8891
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
8892
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
8893
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
8894
|
+
* callable — they are simply "unknown function" at parse time.
|
|
8772
8895
|
*
|
|
8773
|
-
*
|
|
8774
|
-
*
|
|
8775
|
-
*
|
|
8776
|
-
*
|
|
8777
|
-
* `addon-pages.router.ts` wrapper anymore.
|
|
8896
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
8897
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
8898
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
8899
|
+
* closed rather than emitting a garbage value.
|
|
8778
8900
|
*/
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
});
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
}
|
|
8794
|
-
|
|
8901
|
+
function asFiniteNumber(value, name, index) {
|
|
8902
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
8903
|
+
return value;
|
|
8904
|
+
}
|
|
8905
|
+
function asString$1(value, name, index) {
|
|
8906
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
8907
|
+
return value;
|
|
8908
|
+
}
|
|
8909
|
+
function finiteResult(value, name) {
|
|
8910
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
8911
|
+
return value;
|
|
8912
|
+
}
|
|
8913
|
+
function allFiniteNumbers(args, name) {
|
|
8914
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
8915
|
+
}
|
|
8916
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
8917
|
+
var table = {
|
|
8918
|
+
min: {
|
|
8919
|
+
minArgs: 1,
|
|
8920
|
+
maxArgs: INF,
|
|
8921
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
8922
|
+
},
|
|
8923
|
+
max: {
|
|
8924
|
+
minArgs: 1,
|
|
8925
|
+
maxArgs: INF,
|
|
8926
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
8927
|
+
},
|
|
8928
|
+
abs: {
|
|
8929
|
+
minArgs: 1,
|
|
8930
|
+
maxArgs: 1,
|
|
8931
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
8932
|
+
},
|
|
8933
|
+
floor: {
|
|
8934
|
+
minArgs: 1,
|
|
8935
|
+
maxArgs: 1,
|
|
8936
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
8937
|
+
},
|
|
8938
|
+
ceil: {
|
|
8939
|
+
minArgs: 1,
|
|
8940
|
+
maxArgs: 1,
|
|
8941
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
8942
|
+
},
|
|
8943
|
+
sqrt: {
|
|
8944
|
+
minArgs: 1,
|
|
8945
|
+
maxArgs: 1,
|
|
8946
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
8947
|
+
},
|
|
8948
|
+
round: {
|
|
8949
|
+
minArgs: 1,
|
|
8950
|
+
maxArgs: 2,
|
|
8951
|
+
apply: (args) => {
|
|
8952
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
8953
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
8954
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
8955
|
+
const factor = 10 ** digits;
|
|
8956
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
8957
|
+
}
|
|
8958
|
+
},
|
|
8959
|
+
pow: {
|
|
8960
|
+
minArgs: 2,
|
|
8961
|
+
maxArgs: 2,
|
|
8962
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
8963
|
+
},
|
|
8964
|
+
clamp: {
|
|
8965
|
+
minArgs: 3,
|
|
8966
|
+
maxArgs: 3,
|
|
8967
|
+
apply: (args) => {
|
|
8968
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
8969
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
8970
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
8971
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
8972
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
8973
|
+
}
|
|
8974
|
+
},
|
|
8975
|
+
avg: {
|
|
8976
|
+
minArgs: 1,
|
|
8977
|
+
maxArgs: INF,
|
|
8978
|
+
apply: (args) => {
|
|
8979
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
8980
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
8981
|
+
}
|
|
8982
|
+
},
|
|
8983
|
+
sum: {
|
|
8984
|
+
minArgs: 1,
|
|
8985
|
+
maxArgs: INF,
|
|
8986
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
8987
|
+
},
|
|
8988
|
+
coalesce: {
|
|
8989
|
+
minArgs: 1,
|
|
8990
|
+
maxArgs: INF,
|
|
8991
|
+
apply: (args) => {
|
|
8992
|
+
for (const a of args) if (a !== null) return a;
|
|
8993
|
+
return null;
|
|
8994
|
+
}
|
|
8995
|
+
},
|
|
8996
|
+
age: {
|
|
8997
|
+
minArgs: 2,
|
|
8998
|
+
maxArgs: 2,
|
|
8999
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
9000
|
+
},
|
|
9001
|
+
convert: {
|
|
9002
|
+
minArgs: 3,
|
|
9003
|
+
maxArgs: 3,
|
|
9004
|
+
apply: (args, hooks) => {
|
|
9005
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
9006
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
9007
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
9008
|
+
if (hooks.convert) {
|
|
9009
|
+
const out = hooks.convert(x, from, to);
|
|
9010
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
9011
|
+
return finiteResult(out, "convert");
|
|
9012
|
+
}
|
|
9013
|
+
if (from === to) return x;
|
|
9014
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
9015
|
+
}
|
|
9016
|
+
}
|
|
9017
|
+
};
|
|
9018
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
9019
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
9020
|
+
* callees at parse time (immediate author feedback). */
|
|
9021
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
8795
9022
|
/**
|
|
8796
|
-
*
|
|
8797
|
-
* declarations. Every addon that contributes a UI page registers a
|
|
8798
|
-
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
8799
|
-
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
8800
|
-
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
8801
|
-
* that admin-ui consumes.
|
|
9023
|
+
* Resource-bound constants for the safe expression engine.
|
|
8802
9024
|
*
|
|
8803
|
-
*
|
|
8804
|
-
*
|
|
8805
|
-
*
|
|
9025
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
9026
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
9027
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
9028
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
9029
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
8806
9030
|
*/
|
|
8807
|
-
|
|
8808
|
-
|
|
8809
|
-
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
remoteName: string(),
|
|
8820
|
-
/**
|
|
8821
|
-
* Bundle filename inside the addon's `dist/` dir served at
|
|
8822
|
-
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
8823
|
-
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
8824
|
-
* the static-file route can compute an mtime-based cache-buster URL
|
|
8825
|
-
* without a separate filesystem stat.
|
|
8826
|
-
*/
|
|
8827
|
-
bundle: string(),
|
|
8828
|
-
/**
|
|
8829
|
-
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
8830
|
-
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
8831
|
-
* Any OTHER string creates (or joins) a custom section rendered after
|
|
8832
|
-
* the built-in groups; its label comes from `sectionLabel` (first
|
|
8833
|
-
* declaration wins), falling back to the id. Absent → the legacy
|
|
8834
|
-
* "Addon Pages" group.
|
|
8835
|
-
*/
|
|
8836
|
-
section: string().optional(),
|
|
8837
|
-
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
8838
|
-
sectionLabel: string().optional()
|
|
8839
|
-
});
|
|
8840
|
-
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
8841
|
-
var AddonHttpRouteSchema = object({
|
|
8842
|
-
method: _enum([
|
|
8843
|
-
"GET",
|
|
8844
|
-
"POST",
|
|
8845
|
-
"PUT",
|
|
8846
|
-
"DELETE",
|
|
8847
|
-
"PATCH"
|
|
8848
|
-
]),
|
|
8849
|
-
path: string(),
|
|
8850
|
-
access: _enum([
|
|
8851
|
-
"public",
|
|
8852
|
-
"authenticated",
|
|
8853
|
-
"admin"
|
|
8854
|
-
]).optional(),
|
|
8855
|
-
description: string().optional()
|
|
8856
|
-
});
|
|
8857
|
-
/**
|
|
8858
|
-
* Cross-process route invocation envelope. The hub captures the
|
|
8859
|
-
* request as plain data, ships it to the worker via Moleculer, and
|
|
8860
|
-
* the worker runs the local handler against a capturing reply. The
|
|
8861
|
-
* envelope returned describes what the handler intended (status,
|
|
8862
|
-
* headers, body, or a redirect) so the hub can translate it back to
|
|
8863
|
-
* the Fastify reply that's actually wired to the socket.
|
|
8864
|
-
*/
|
|
8865
|
-
var InvokeRequestSchema = object({
|
|
8866
|
-
method: string(),
|
|
8867
|
-
path: string(),
|
|
8868
|
-
params: record(string(), string()),
|
|
8869
|
-
query: record(string(), string()),
|
|
8870
|
-
body: unknown(),
|
|
8871
|
-
headers: record(string(), string()),
|
|
8872
|
-
user: object({
|
|
8873
|
-
id: string(),
|
|
8874
|
-
username: string(),
|
|
8875
|
-
isAdmin: boolean()
|
|
8876
|
-
}).optional(),
|
|
8877
|
-
scopedToken: unknown().optional()
|
|
8878
|
-
});
|
|
8879
|
-
var InvokeReplyEnvelopeSchema = object({
|
|
8880
|
-
status: number().int(),
|
|
8881
|
-
headers: record(string(), string()),
|
|
8882
|
-
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
8883
|
-
* sending `body`. Status defaults to 302 when this is set unless
|
|
8884
|
-
* the handler called `reply.code(...)` explicitly. */
|
|
8885
|
-
redirectUrl: string().nullable(),
|
|
8886
|
-
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
8887
|
-
body: unknown().optional(),
|
|
8888
|
-
/** Set when the handler called `reply.type(mime)`. */
|
|
8889
|
-
contentType: string().optional()
|
|
8890
|
-
});
|
|
8891
|
-
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
8892
|
-
var ConfigTabDeclarationSchema = object({
|
|
8893
|
-
id: string(),
|
|
8894
|
-
label: string(),
|
|
8895
|
-
icon: string(),
|
|
8896
|
-
order: number().optional()
|
|
8897
|
-
});
|
|
8898
|
-
var ConfigSectionWithValuesSchema = object({
|
|
8899
|
-
id: string(),
|
|
8900
|
-
title: string(),
|
|
8901
|
-
description: string().optional(),
|
|
8902
|
-
style: _enum(["card", "accordion"]).optional(),
|
|
8903
|
-
defaultCollapsed: boolean().optional(),
|
|
8904
|
-
columns: union([
|
|
8905
|
-
literal(1),
|
|
8906
|
-
literal(2),
|
|
8907
|
-
literal(3),
|
|
8908
|
-
literal(4)
|
|
8909
|
-
]).optional(),
|
|
8910
|
-
tab: string().optional(),
|
|
8911
|
-
location: _enum(["settings", "top-tab"]).optional(),
|
|
8912
|
-
order: number().optional(),
|
|
8913
|
-
fields: array(any())
|
|
8914
|
-
});
|
|
8915
|
-
var SettingsSchemaWithValuesSchema = object({
|
|
8916
|
-
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
8917
|
-
sections: array(ConfigSectionWithValuesSchema)
|
|
8918
|
-
});
|
|
8919
|
-
/** Patch object — keys are field names, values are the new field values. */
|
|
8920
|
-
var SettingsPatchSchema = record(string(), unknown());
|
|
8921
|
-
/** Standard success response for update operations. */
|
|
8922
|
-
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
8923
|
-
method(object({
|
|
8924
|
-
addonId: string(),
|
|
8925
|
-
nodeId: string().optional(),
|
|
8926
|
-
overlay: record(string(), unknown()).optional(),
|
|
8927
|
-
cap: string().optional()
|
|
8928
|
-
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8929
|
-
addonId: string(),
|
|
8930
|
-
nodeId: string().optional(),
|
|
8931
|
-
patch: SettingsPatchSchema
|
|
8932
|
-
}), SettingsUpdateResultSchema, {
|
|
8933
|
-
kind: "mutation",
|
|
8934
|
-
auth: "admin"
|
|
8935
|
-
}), method(object({
|
|
8936
|
-
addonId: string(),
|
|
8937
|
-
deviceId: number(),
|
|
8938
|
-
nodeId: string().optional()
|
|
8939
|
-
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8940
|
-
addonId: string(),
|
|
8941
|
-
deviceId: number(),
|
|
8942
|
-
nodeId: string().optional(),
|
|
8943
|
-
patch: SettingsPatchSchema
|
|
8944
|
-
}), SettingsUpdateResultSchema, {
|
|
8945
|
-
kind: "mutation",
|
|
8946
|
-
auth: "admin"
|
|
8947
|
-
});
|
|
8948
|
-
/**
|
|
8949
|
-
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
8950
|
-
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
8951
|
-
* widgets registers a provider on this collection cap; the hub-local
|
|
8952
|
-
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
8953
|
-
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
8954
|
-
* exposes the public listing surface that admin-ui consumes.
|
|
8955
|
-
*
|
|
8956
|
-
* The split exists because the public listing has a different output
|
|
8957
|
-
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
8958
|
-
* per-provider raw declarations. Both ends flow through codegen.
|
|
8959
|
-
*
|
|
8960
|
-
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
8961
|
-
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
8962
|
-
* same `ContributionRenderer` / Module-Federation path as every other
|
|
8963
|
-
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
8964
|
-
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
8965
|
-
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
8966
|
-
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
8967
|
-
*/
|
|
8968
|
-
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
8969
|
-
var WidgetHostEnum = _enum([
|
|
8970
|
-
"device-tab",
|
|
8971
|
-
"dashboard",
|
|
8972
|
-
"integration-detail"
|
|
8973
|
-
]);
|
|
8974
|
-
var WidgetSizeEnum = _enum([
|
|
8975
|
-
"xs",
|
|
8976
|
-
"sm",
|
|
8977
|
-
"md",
|
|
8978
|
-
"lg",
|
|
8979
|
-
"xl"
|
|
9031
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
9032
|
+
* rejected without allocation. */
|
|
9033
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
9034
|
+
/** A legal binding / identifier name. */
|
|
9035
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9036
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
9037
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
9038
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
9039
|
+
"now",
|
|
9040
|
+
"true",
|
|
9041
|
+
"false",
|
|
9042
|
+
"null"
|
|
8980
9043
|
]);
|
|
8981
9044
|
/**
|
|
8982
|
-
*
|
|
8983
|
-
*
|
|
8984
|
-
*
|
|
8985
|
-
*
|
|
8986
|
-
* `
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
remoteName: string(),
|
|
8990
|
-
exposedModule: string(),
|
|
8991
|
-
componentKey: string().optional()
|
|
8992
|
-
});
|
|
8993
|
-
/**
|
|
8994
|
-
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
8995
|
-
* widget-only metadata. The `UiContribution` core fields:
|
|
8996
|
-
*
|
|
8997
|
-
* - `tab` — where the widget hosts. A widget that runs on the
|
|
8998
|
-
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
8999
|
-
* widget declares the target device-detail tab id.
|
|
9000
|
-
* - `subTab` — optional sub-tab within `tab`.
|
|
9001
|
-
* - `label` — operator-facing label.
|
|
9002
|
-
* - `order` — ordering within `(tab, subTab)`.
|
|
9003
|
-
* - `kind` — always `'remote'` for widgets.
|
|
9004
|
-
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
9005
|
-
*
|
|
9006
|
-
* Widget-only fields retained alongside the contribution core:
|
|
9007
|
-
*
|
|
9008
|
-
* - `stableId` — stable identity within the addon (the MF
|
|
9009
|
-
* `componentKey`; kept top-level so consumers have
|
|
9010
|
-
* a stable key without reaching into `remote`).
|
|
9011
|
-
* - `description` / `icon` — picker metadata.
|
|
9012
|
-
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
9013
|
-
* aggregator stamps a versioned `bundleUrl` from it.
|
|
9014
|
-
* - `hosts` — every host the widget supports (a widget can run
|
|
9015
|
-
* both on the dashboard and a device tab). `tab`
|
|
9016
|
-
* is the PRIMARY host; `hosts` is the full set the
|
|
9017
|
-
* picker filters on.
|
|
9018
|
-
* - `requires` — host-context requirements validated at mount.
|
|
9019
|
-
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
9020
|
-
* — dashboard placement hints.
|
|
9045
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
9046
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
9047
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
9048
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
9049
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
9050
|
+
* is a parse error with a source position, so member access / assignment /
|
|
9051
|
+
* template literals are lexically impossible.
|
|
9021
9052
|
*/
|
|
9022
|
-
var
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9053
|
+
var KEYWORDS = new Set([
|
|
9054
|
+
"true",
|
|
9055
|
+
"false",
|
|
9056
|
+
"null"
|
|
9057
|
+
]);
|
|
9058
|
+
function isDigit(ch) {
|
|
9059
|
+
return ch >= "0" && ch <= "9";
|
|
9060
|
+
}
|
|
9061
|
+
function isIdentStart(ch) {
|
|
9062
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
9063
|
+
}
|
|
9064
|
+
function isIdentPart(ch) {
|
|
9065
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
9066
|
+
}
|
|
9067
|
+
function isWhitespace(ch) {
|
|
9068
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
9069
|
+
}
|
|
9070
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
9071
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
9072
|
+
* string. */
|
|
9073
|
+
function tokenize(source) {
|
|
9074
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
9075
|
+
const tokens = [];
|
|
9076
|
+
let i = 0;
|
|
9077
|
+
const n = source.length;
|
|
9078
|
+
while (i < n) {
|
|
9079
|
+
const ch = source[i];
|
|
9080
|
+
if (isWhitespace(ch)) {
|
|
9081
|
+
i += 1;
|
|
9082
|
+
continue;
|
|
9083
|
+
}
|
|
9084
|
+
if (isDigit(ch)) {
|
|
9085
|
+
const start = i;
|
|
9086
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
9087
|
+
if (i < n && source[i] === ".") {
|
|
9088
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
9089
|
+
i += 1;
|
|
9090
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
9091
|
+
}
|
|
9092
|
+
const text = source.slice(start, i);
|
|
9093
|
+
const value = Number(text);
|
|
9094
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
9095
|
+
tokens.push({
|
|
9096
|
+
type: "number",
|
|
9097
|
+
value,
|
|
9098
|
+
pos: start
|
|
9099
|
+
});
|
|
9100
|
+
continue;
|
|
9101
|
+
}
|
|
9102
|
+
if (ch === "'" || ch === "\"") {
|
|
9103
|
+
const quote = ch;
|
|
9104
|
+
const start = i;
|
|
9105
|
+
i += 1;
|
|
9106
|
+
let out = "";
|
|
9107
|
+
let closed = false;
|
|
9108
|
+
while (i < n) {
|
|
9109
|
+
const c = source[i];
|
|
9110
|
+
if (c === "\\") {
|
|
9111
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
9112
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
9113
|
+
out += next;
|
|
9114
|
+
i += 2;
|
|
9115
|
+
continue;
|
|
9116
|
+
}
|
|
9117
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
9118
|
+
}
|
|
9119
|
+
if (c === quote) {
|
|
9120
|
+
closed = true;
|
|
9121
|
+
i += 1;
|
|
9122
|
+
break;
|
|
9123
|
+
}
|
|
9124
|
+
out += c;
|
|
9125
|
+
i += 1;
|
|
9126
|
+
}
|
|
9127
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
9128
|
+
tokens.push({
|
|
9129
|
+
type: "string",
|
|
9130
|
+
value: out,
|
|
9131
|
+
pos: start
|
|
9132
|
+
});
|
|
9133
|
+
continue;
|
|
9134
|
+
}
|
|
9135
|
+
if (isIdentStart(ch)) {
|
|
9136
|
+
const start = i;
|
|
9137
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
9138
|
+
const text = source.slice(start, i);
|
|
9139
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
9140
|
+
type: "keyword",
|
|
9141
|
+
keyword: keywordOf(text),
|
|
9142
|
+
pos: start
|
|
9143
|
+
});
|
|
9144
|
+
else tokens.push({
|
|
9145
|
+
type: "identifier",
|
|
9146
|
+
name: text,
|
|
9147
|
+
pos: start
|
|
9148
|
+
});
|
|
9149
|
+
continue;
|
|
9150
|
+
}
|
|
9151
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
9152
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
9153
|
+
tokens.push({
|
|
9154
|
+
type: "punct",
|
|
9155
|
+
punct: two,
|
|
9156
|
+
pos: i
|
|
9157
|
+
});
|
|
9158
|
+
i += 2;
|
|
9159
|
+
continue;
|
|
9160
|
+
}
|
|
9161
|
+
if (isSinglePunct(ch)) {
|
|
9162
|
+
tokens.push({
|
|
9163
|
+
type: "punct",
|
|
9164
|
+
punct: ch,
|
|
9165
|
+
pos: i
|
|
9166
|
+
});
|
|
9167
|
+
i += 1;
|
|
9168
|
+
continue;
|
|
9169
|
+
}
|
|
9170
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
9171
|
+
}
|
|
9172
|
+
tokens.push({
|
|
9173
|
+
type: "eof",
|
|
9174
|
+
pos: n
|
|
9175
|
+
});
|
|
9176
|
+
return tokens;
|
|
9177
|
+
}
|
|
9178
|
+
function keywordOf(text) {
|
|
9179
|
+
if (text === "true") return "true";
|
|
9180
|
+
if (text === "false") return "false";
|
|
9181
|
+
return "null";
|
|
9182
|
+
}
|
|
9183
|
+
function isSinglePunct(ch) {
|
|
9184
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
9185
|
+
}
|
|
9082
9186
|
/**
|
|
9083
|
-
*
|
|
9084
|
-
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9085
|
-
*
|
|
9086
|
-
* The provider iterates every `addon-widgets-source` (collection)
|
|
9087
|
-
* provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
|
|
9088
|
-
* `bundleUrl` strings pointing at
|
|
9089
|
-
* `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
|
|
9090
|
-
* `mtime` cache-buster lets the browser pick up addon rebuilds without
|
|
9091
|
-
* manual reload — same scheme used by `addon-pages`.
|
|
9187
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
9092
9188
|
*
|
|
9093
|
-
*
|
|
9094
|
-
*
|
|
9095
|
-
*
|
|
9096
|
-
*
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
addonId: string(),
|
|
9100
|
-
bundleUrl: string()
|
|
9101
|
-
});
|
|
9102
|
-
method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
|
|
9103
|
-
/**
|
|
9104
|
-
* Alerts capability — collection-based internal alert system.
|
|
9189
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
9190
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
9191
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
9192
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
9193
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
9194
|
+
* that references a since-removed builtin degrades at read.
|
|
9105
9195
|
*
|
|
9106
|
-
*
|
|
9107
|
-
*
|
|
9108
|
-
* in the DB and serves them to the admin UI.
|
|
9109
|
-
*/
|
|
9110
|
-
var AlertSeveritySchema = _enum([
|
|
9111
|
-
"info",
|
|
9112
|
-
"success",
|
|
9113
|
-
"warning",
|
|
9114
|
-
"error"
|
|
9115
|
-
]);
|
|
9116
|
-
var AlertStatusSchema = _enum([
|
|
9117
|
-
"active",
|
|
9118
|
-
"in-progress",
|
|
9119
|
-
"completed",
|
|
9120
|
-
"failed",
|
|
9121
|
-
"dismissed"
|
|
9122
|
-
]);
|
|
9123
|
-
var AlertSourceSchema = object({
|
|
9124
|
-
type: string(),
|
|
9125
|
-
id: string()
|
|
9126
|
-
});
|
|
9127
|
-
var AlertSchema = object({
|
|
9128
|
-
id: string(),
|
|
9129
|
-
category: string(),
|
|
9130
|
-
severity: AlertSeveritySchema,
|
|
9131
|
-
title: string(),
|
|
9132
|
-
message: string(),
|
|
9133
|
-
status: AlertStatusSchema,
|
|
9134
|
-
progress: number().optional(),
|
|
9135
|
-
read: boolean(),
|
|
9136
|
-
createdAt: number(),
|
|
9137
|
-
updatedAt: number(),
|
|
9138
|
-
source: AlertSourceSchema.optional(),
|
|
9139
|
-
metadata: record(string(), unknown()).optional()
|
|
9140
|
-
});
|
|
9141
|
-
method(AlertSchema, _void(), { kind: "mutation" }), method(object({
|
|
9142
|
-
alertId: string(),
|
|
9143
|
-
patch: AlertSchema.partial()
|
|
9144
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
9145
|
-
unreadOnly: boolean().optional(),
|
|
9146
|
-
limit: number().optional()
|
|
9147
|
-
}).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" });
|
|
9148
|
-
DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
|
|
9149
|
-
deviceId: number(),
|
|
9150
|
-
rms: number(),
|
|
9151
|
-
dbfs: number()
|
|
9152
|
-
});
|
|
9153
|
-
/** Shared Zod schemas used across detection capabilities. */
|
|
9154
|
-
/**
|
|
9155
|
-
* Canonical frame-format enum mirrored on `FrameFormat` in
|
|
9156
|
-
* `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
|
|
9157
|
-
* Zod runtime schema and TypeScript type stay in sync at the call site
|
|
9158
|
-
* — adding a new format requires changing both this enum and the
|
|
9159
|
-
* `FrameFormat` type alias together.
|
|
9196
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
9197
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
9160
9198
|
*/
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
"
|
|
9164
|
-
"
|
|
9165
|
-
"
|
|
9166
|
-
"
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
9200
|
+
var BINARY_PRECEDENCE = {
|
|
9201
|
+
"||": 1,
|
|
9202
|
+
"&&": 2,
|
|
9203
|
+
"==": 3,
|
|
9204
|
+
"!=": 3,
|
|
9205
|
+
"<": 4,
|
|
9206
|
+
"<=": 4,
|
|
9207
|
+
">": 4,
|
|
9208
|
+
">=": 4,
|
|
9209
|
+
"+": 5,
|
|
9210
|
+
"-": 5,
|
|
9211
|
+
"*": 6,
|
|
9212
|
+
"/": 6,
|
|
9213
|
+
"%": 6
|
|
9214
|
+
};
|
|
9215
|
+
function isLogicalOp(op) {
|
|
9216
|
+
return op === "&&" || op === "||";
|
|
9217
|
+
}
|
|
9218
|
+
function isBinaryOp(op) {
|
|
9219
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
9220
|
+
}
|
|
9221
|
+
var Parser = class {
|
|
9222
|
+
tokens;
|
|
9223
|
+
pos = 0;
|
|
9224
|
+
nodeCount = 0;
|
|
9225
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
9226
|
+
callees = /* @__PURE__ */ new Set();
|
|
9227
|
+
constructor(tokens) {
|
|
9228
|
+
this.tokens = tokens;
|
|
9229
|
+
}
|
|
9230
|
+
parse() {
|
|
9231
|
+
const ast = this.parseTernary();
|
|
9232
|
+
const tok = this.peek();
|
|
9233
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
9234
|
+
return {
|
|
9235
|
+
ast,
|
|
9236
|
+
identifiers: this.identifiers,
|
|
9237
|
+
callees: this.callees,
|
|
9238
|
+
nodeCount: this.nodeCount
|
|
9239
|
+
};
|
|
9240
|
+
}
|
|
9241
|
+
peek() {
|
|
9242
|
+
return this.tokens[this.pos];
|
|
9243
|
+
}
|
|
9244
|
+
next() {
|
|
9245
|
+
return this.tokens[this.pos++];
|
|
9246
|
+
}
|
|
9247
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
9248
|
+
expectPunct(punct) {
|
|
9249
|
+
const tok = this.peek();
|
|
9250
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
9251
|
+
this.pos += 1;
|
|
9252
|
+
}
|
|
9253
|
+
matchPunct(punct) {
|
|
9254
|
+
const tok = this.peek();
|
|
9255
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
9256
|
+
this.pos += 1;
|
|
9257
|
+
return true;
|
|
9258
|
+
}
|
|
9259
|
+
return false;
|
|
9260
|
+
}
|
|
9261
|
+
countNode() {
|
|
9262
|
+
this.nodeCount += 1;
|
|
9263
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
9264
|
+
}
|
|
9265
|
+
parseTernary() {
|
|
9266
|
+
const test = this.parseBinary(1);
|
|
9267
|
+
if (this.matchPunct("?")) {
|
|
9268
|
+
const consequent = this.parseTernary();
|
|
9269
|
+
this.expectPunct(":");
|
|
9270
|
+
const alternate = this.parseTernary();
|
|
9271
|
+
this.countNode();
|
|
9272
|
+
return {
|
|
9273
|
+
kind: "conditional",
|
|
9274
|
+
test,
|
|
9275
|
+
consequent,
|
|
9276
|
+
alternate
|
|
9277
|
+
};
|
|
9278
|
+
}
|
|
9279
|
+
return test;
|
|
9280
|
+
}
|
|
9281
|
+
parseBinary(minPrec) {
|
|
9282
|
+
let left = this.parseUnary();
|
|
9283
|
+
for (;;) {
|
|
9284
|
+
const tok = this.peek();
|
|
9285
|
+
if (tok.type !== "punct") break;
|
|
9286
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
9287
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
9288
|
+
const op = tok.punct;
|
|
9289
|
+
this.pos += 1;
|
|
9290
|
+
const right = this.parseBinary(prec + 1);
|
|
9291
|
+
this.countNode();
|
|
9292
|
+
if (isLogicalOp(op)) left = {
|
|
9293
|
+
kind: "logical",
|
|
9294
|
+
op,
|
|
9295
|
+
left,
|
|
9296
|
+
right
|
|
9297
|
+
};
|
|
9298
|
+
else if (isBinaryOp(op)) left = {
|
|
9299
|
+
kind: "binary",
|
|
9300
|
+
op,
|
|
9301
|
+
left,
|
|
9302
|
+
right
|
|
9303
|
+
};
|
|
9304
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
9305
|
+
}
|
|
9306
|
+
return left;
|
|
9307
|
+
}
|
|
9308
|
+
parseUnary() {
|
|
9309
|
+
const tok = this.peek();
|
|
9310
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
9311
|
+
const op = tok.punct;
|
|
9312
|
+
this.pos += 1;
|
|
9313
|
+
const operand = this.parseUnary();
|
|
9314
|
+
this.countNode();
|
|
9315
|
+
return {
|
|
9316
|
+
kind: "unary",
|
|
9317
|
+
op,
|
|
9318
|
+
operand
|
|
9319
|
+
};
|
|
9320
|
+
}
|
|
9321
|
+
return this.parsePrimary();
|
|
9322
|
+
}
|
|
9323
|
+
parsePrimary() {
|
|
9324
|
+
const tok = this.next();
|
|
9325
|
+
switch (tok.type) {
|
|
9326
|
+
case "number":
|
|
9327
|
+
this.countNode();
|
|
9328
|
+
return {
|
|
9329
|
+
kind: "literal",
|
|
9330
|
+
value: tok.value
|
|
9331
|
+
};
|
|
9332
|
+
case "string":
|
|
9333
|
+
this.countNode();
|
|
9334
|
+
return {
|
|
9335
|
+
kind: "literal",
|
|
9336
|
+
value: tok.value
|
|
9337
|
+
};
|
|
9338
|
+
case "keyword":
|
|
9339
|
+
this.countNode();
|
|
9340
|
+
return {
|
|
9341
|
+
kind: "literal",
|
|
9342
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
9343
|
+
};
|
|
9344
|
+
case "identifier": {
|
|
9345
|
+
const nextTok = this.peek();
|
|
9346
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
9347
|
+
this.identifiers.add(tok.name);
|
|
9348
|
+
this.countNode();
|
|
9349
|
+
return {
|
|
9350
|
+
kind: "identifier",
|
|
9351
|
+
name: tok.name
|
|
9352
|
+
};
|
|
9353
|
+
}
|
|
9354
|
+
case "punct":
|
|
9355
|
+
if (tok.punct === "(") {
|
|
9356
|
+
const inner = this.parseTernary();
|
|
9357
|
+
this.expectPunct(")");
|
|
9358
|
+
return inner;
|
|
9359
|
+
}
|
|
9360
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
9361
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
9362
|
+
}
|
|
9363
|
+
}
|
|
9364
|
+
parseCall(callee, pos) {
|
|
9365
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
9366
|
+
this.expectPunct("(");
|
|
9367
|
+
const args = [];
|
|
9368
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
9369
|
+
args.push(this.parseTernary());
|
|
9370
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
9371
|
+
if (this.matchPunct(",")) continue;
|
|
9372
|
+
this.expectPunct(")");
|
|
9373
|
+
break;
|
|
9374
|
+
}
|
|
9375
|
+
this.callees.add(callee);
|
|
9376
|
+
this.countNode();
|
|
9377
|
+
return {
|
|
9378
|
+
kind: "call",
|
|
9379
|
+
callee,
|
|
9380
|
+
args
|
|
9381
|
+
};
|
|
9382
|
+
}
|
|
9383
|
+
};
|
|
9384
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
9385
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
9386
|
+
function parseExpression(source) {
|
|
9387
|
+
return new Parser(tokenize(source)).parse();
|
|
9388
|
+
}
|
|
9389
|
+
/**
|
|
9390
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
9391
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
9392
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
9393
|
+
* one per read on a hot resolve path.
|
|
9394
|
+
*
|
|
9395
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
9396
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
9397
|
+
* callers is safe and maximises hit rate.
|
|
9398
|
+
*/
|
|
9399
|
+
var cache = /* @__PURE__ */ new Map();
|
|
9400
|
+
function getCached(source) {
|
|
9401
|
+
const hit = cache.get(source);
|
|
9402
|
+
if (hit !== void 0) {
|
|
9403
|
+
cache.delete(source);
|
|
9404
|
+
cache.set(source, hit);
|
|
9405
|
+
return hit;
|
|
9406
|
+
}
|
|
9407
|
+
let result;
|
|
9408
|
+
try {
|
|
9409
|
+
result = {
|
|
9410
|
+
ok: true,
|
|
9411
|
+
parsed: parseExpression(source)
|
|
9412
|
+
};
|
|
9413
|
+
} catch (err) {
|
|
9414
|
+
result = {
|
|
9415
|
+
ok: false,
|
|
9416
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
9417
|
+
};
|
|
9418
|
+
}
|
|
9419
|
+
cache.set(source, result);
|
|
9420
|
+
if (cache.size > 256) {
|
|
9421
|
+
const oldest = cache.keys().next().value;
|
|
9422
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
9423
|
+
}
|
|
9424
|
+
return result;
|
|
9425
|
+
}
|
|
9426
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
9427
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
9428
|
+
function compileExpressionSafe(source) {
|
|
9429
|
+
return getCached(source);
|
|
9430
|
+
}
|
|
9431
|
+
Object.freeze({});
|
|
9432
|
+
/**
|
|
9433
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
9434
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
9435
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
9436
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
9437
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
9438
|
+
*/
|
|
9439
|
+
function validateExpressionSource(src) {
|
|
9440
|
+
const names = Object.keys(src.bindings);
|
|
9441
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
9442
|
+
for (const name of names) {
|
|
9443
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
9444
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
9445
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
9446
|
+
}
|
|
9447
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
9448
|
+
if (!compiled.ok) return compiled.error;
|
|
9449
|
+
const bound = new Set(names);
|
|
9450
|
+
for (const id of compiled.parsed.identifiers) {
|
|
9451
|
+
if (id === "now") continue;
|
|
9452
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
9453
|
+
}
|
|
9454
|
+
return null;
|
|
9455
|
+
}
|
|
9456
|
+
var ExpressionBindingSourceSchema = union([
|
|
9457
|
+
object({
|
|
9458
|
+
kind: literal("field").optional(),
|
|
9459
|
+
sourceKey: string(),
|
|
9460
|
+
cap: string(),
|
|
9461
|
+
fieldPath: string()
|
|
9462
|
+
}),
|
|
9463
|
+
object({
|
|
9464
|
+
kind: literal("literal"),
|
|
9465
|
+
value: union([
|
|
9466
|
+
string(),
|
|
9467
|
+
number(),
|
|
9468
|
+
boolean(),
|
|
9469
|
+
_null()
|
|
9470
|
+
])
|
|
9471
|
+
}),
|
|
9472
|
+
object({
|
|
9473
|
+
kind: literal("global"),
|
|
9474
|
+
sourceStableId: string(),
|
|
9475
|
+
cap: string(),
|
|
9476
|
+
fieldPath: string()
|
|
9477
|
+
})
|
|
9478
|
+
]);
|
|
9479
|
+
object({
|
|
9480
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
9481
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
9482
|
+
}).superRefine((src, ctx) => {
|
|
9483
|
+
const err = validateExpressionSource(src);
|
|
9484
|
+
if (err !== null) ctx.addIssue({
|
|
9485
|
+
code: "custom",
|
|
9486
|
+
message: err,
|
|
9487
|
+
path: ["expr"]
|
|
9488
|
+
});
|
|
9489
|
+
});
|
|
9490
|
+
/** How a leaf compares a device field to a value. Derived from the field's
|
|
9491
|
+
* `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
|
|
9492
|
+
var AutomationConditionOperatorSchema = _enum([
|
|
9493
|
+
"eq",
|
|
9494
|
+
"ne",
|
|
9495
|
+
"gt",
|
|
9496
|
+
"gte",
|
|
9497
|
+
"lt",
|
|
9498
|
+
"lte",
|
|
9499
|
+
"contains",
|
|
9500
|
+
"in"
|
|
9501
|
+
]);
|
|
9502
|
+
var AutomationConditionLeafSchema = object({
|
|
9503
|
+
kind: literal("condition"),
|
|
9504
|
+
deviceId: number().int().nonnegative(),
|
|
9505
|
+
cap: string().min(1),
|
|
9506
|
+
fieldPath: string().min(1),
|
|
9507
|
+
operator: AutomationConditionOperatorSchema,
|
|
9508
|
+
value: union([
|
|
9509
|
+
string(),
|
|
9510
|
+
number(),
|
|
9511
|
+
boolean(),
|
|
9512
|
+
array(union([string(), number()]))
|
|
9513
|
+
])
|
|
9514
|
+
});
|
|
9515
|
+
/**
|
|
9516
|
+
* The expression leaf, declared as a plain object rather than an intersection
|
|
9517
|
+
* with {@link ExpressionSourceSchema}: a discriminated union has to be able to
|
|
9518
|
+
* read `kind` off each option, and an intersection hides it. The author-time
|
|
9519
|
+
* validation is the SAME function `ExpressionSourceSchema` runs, so the two
|
|
9520
|
+
* cannot drift — an expression that one accepts, the other accepts.
|
|
9521
|
+
*/
|
|
9522
|
+
var AutomationConditionExpressionSchema = object({
|
|
9523
|
+
kind: literal("expression"),
|
|
9524
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
9525
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
9526
|
+
}).superRefine((src, ctx) => {
|
|
9527
|
+
const err = validateExpressionSource(src);
|
|
9528
|
+
if (err !== null) ctx.addIssue({
|
|
9529
|
+
code: "custom",
|
|
9530
|
+
message: err,
|
|
9531
|
+
path: ["expr"]
|
|
9532
|
+
});
|
|
9533
|
+
});
|
|
9534
|
+
var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
|
|
9535
|
+
object({
|
|
9536
|
+
kind: literal("all"),
|
|
9537
|
+
children: array(AutomationConditionSchema)
|
|
9538
|
+
}),
|
|
9539
|
+
object({
|
|
9540
|
+
kind: literal("any"),
|
|
9541
|
+
children: array(AutomationConditionSchema)
|
|
9542
|
+
}),
|
|
9543
|
+
object({
|
|
9544
|
+
kind: literal("not"),
|
|
9545
|
+
child: AutomationConditionSchema
|
|
9546
|
+
}),
|
|
9547
|
+
AutomationConditionLeafSchema,
|
|
9548
|
+
AutomationConditionExpressionSchema
|
|
9549
|
+
]));
|
|
9550
|
+
/**
|
|
9551
|
+
* What starts a run.
|
|
9552
|
+
*
|
|
9553
|
+
* D8 compliance, and it is the reason `device-state` is not merely an event
|
|
9554
|
+
* subscription: the trigger evaluates against the **state mirror**, which is
|
|
9555
|
+
* reconciled, and an event only WAKES the evaluation. A dropped event therefore
|
|
9556
|
+
* DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
|
|
9557
|
+
* already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
|
|
9558
|
+
* not.
|
|
9559
|
+
*/
|
|
9560
|
+
var AutomationTriggerSchema = discriminatedUnion("kind", [
|
|
9561
|
+
object({
|
|
9562
|
+
kind: literal("device-state"),
|
|
9563
|
+
deviceId: number().int().nonnegative(),
|
|
9564
|
+
cap: string().min(1),
|
|
9565
|
+
fieldPath: string().min(1),
|
|
9566
|
+
/** Fire when the field takes this value. Omit to fire on any change. */
|
|
9567
|
+
becomes: union([
|
|
9568
|
+
string(),
|
|
9569
|
+
number(),
|
|
9570
|
+
boolean()
|
|
9571
|
+
]).optional(),
|
|
9572
|
+
/** Only on a CHANGE of value, not on every re-report. */
|
|
9573
|
+
edge: boolean().optional(),
|
|
9574
|
+
/** The condition must hold this long before the run starts. */
|
|
9575
|
+
forMs: number().int().min(0).max(864e5).optional(),
|
|
9576
|
+
/** Collapse a burst into one run. */
|
|
9577
|
+
debounceMs: number().int().min(0).max(6e5).optional()
|
|
9578
|
+
}),
|
|
9579
|
+
object({
|
|
9580
|
+
kind: literal("device-event"),
|
|
9581
|
+
/** An `EventCategory` value. */
|
|
9582
|
+
category: string().min(1),
|
|
9583
|
+
deviceId: number().int().nonnegative().optional()
|
|
9584
|
+
}),
|
|
9585
|
+
object({
|
|
9586
|
+
kind: literal("schedule"),
|
|
9587
|
+
cron: string().min(1).max(120)
|
|
9588
|
+
}),
|
|
9589
|
+
object({ kind: literal("manual") })
|
|
9590
|
+
]);
|
|
9591
|
+
/**
|
|
9592
|
+
* One action step.
|
|
9593
|
+
*
|
|
9594
|
+
* `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
|
|
9595
|
+
* identical so `NcRuleActionRunner` runs them unchanged — its device-scope
|
|
9596
|
+
* check, stop-at-first-failure and per-sequence throttle are the whole reason
|
|
9597
|
+
* to reuse it, and none of them are re-implemented here.
|
|
9598
|
+
*
|
|
9599
|
+
* **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
|
|
9600
|
+
* a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
|
|
9601
|
+
* documents its subject device as *"for the log tag, never for routing"*. So an
|
|
9602
|
+
* NC action can never target the device that triggered it — which is fine for
|
|
9603
|
+
* the NC (its rules already scope to a device) and fatal for an automation
|
|
9604
|
+
* ("sound the siren of the camera that saw the person"). `deviceId` therefore
|
|
9605
|
+
* also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
|
|
9606
|
+
* is called. The runner still receives a number and is untouched; the
|
|
9607
|
+
* resolution is the recipe's job, not the runner's.
|
|
9608
|
+
*/
|
|
9609
|
+
var AutomationActionSchema = discriminatedUnion("kind", [
|
|
9610
|
+
object({
|
|
9611
|
+
kind: literal("wait"),
|
|
9612
|
+
seconds: number().min(0).max(300)
|
|
9613
|
+
}),
|
|
9614
|
+
object({
|
|
9615
|
+
kind: literal("cap"),
|
|
9616
|
+
deviceId: union([number().int(), object({ $var: string().min(1) })]),
|
|
9617
|
+
cap: string().min(1),
|
|
9618
|
+
method: string().min(1),
|
|
9619
|
+
/** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
|
|
9620
|
+
* evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
|
|
9621
|
+
args: record(string(), unknown()).optional()
|
|
9622
|
+
}),
|
|
9623
|
+
object({
|
|
9624
|
+
kind: literal("code"),
|
|
9625
|
+
/** Compiled into the automation's OWN block by esbuild — not a third
|
|
9626
|
+
* runtime, not a `vm`, and not dynamically evaluated. */
|
|
9627
|
+
code: string().min(1).max(2e4)
|
|
9628
|
+
})
|
|
9629
|
+
]);
|
|
9630
|
+
object({
|
|
9631
|
+
triggers: array(AutomationTriggerSchema),
|
|
9632
|
+
conditions: AutomationConditionSchema.optional(),
|
|
9633
|
+
actions: array(AutomationActionSchema)
|
|
9634
|
+
});
|
|
9635
|
+
/**
|
|
9636
|
+
* `addon-pages` — system-scoped singleton aggregator cap. Public-facing
|
|
9637
|
+
* surface that admin-ui consumes through `useAddonPagesListPages()`.
|
|
9638
|
+
*
|
|
9639
|
+
* The provider iterates every `addon-pages-source` (collection) provider
|
|
9640
|
+
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
9641
|
+
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
9642
|
+
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
9643
|
+
* rebuilds without manual reload.
|
|
9644
|
+
*
|
|
9645
|
+
* The hub-local builtin `addon-pages-aggregator` (see
|
|
9646
|
+
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
9647
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9648
|
+
* keeps both ends in codegen — there's no hand-written
|
|
9649
|
+
* `addon-pages.router.ts` wrapper anymore.
|
|
9650
|
+
*/
|
|
9651
|
+
var AddonPageDeclarationSchema$1 = object({
|
|
9652
|
+
id: string(),
|
|
9653
|
+
label: string(),
|
|
9654
|
+
icon: string(),
|
|
9655
|
+
path: string(),
|
|
9656
|
+
remoteName: string(),
|
|
9657
|
+
bundle: string(),
|
|
9658
|
+
section: string().optional(),
|
|
9659
|
+
sectionLabel: string().optional()
|
|
9660
|
+
});
|
|
9661
|
+
var AddonPageInfoSchema = object({
|
|
9662
|
+
addonId: string(),
|
|
9663
|
+
page: AddonPageDeclarationSchema$1,
|
|
9664
|
+
bundleUrl: string()
|
|
9665
|
+
});
|
|
9666
|
+
method(_void(), array(AddonPageInfoSchema).readonly());
|
|
9667
|
+
/**
|
|
9668
|
+
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
9669
|
+
* declarations. Every addon that contributes a UI page registers a
|
|
9670
|
+
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
9671
|
+
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
9672
|
+
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
9673
|
+
* that admin-ui consumes.
|
|
9674
|
+
*
|
|
9675
|
+
* The split exists because the public listing has a different output
|
|
9676
|
+
* shape than the per-provider raw declarations, and we want both ends
|
|
9677
|
+
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
9678
|
+
*/
|
|
9679
|
+
var AddonPageDeclarationSchema = object({
|
|
9680
|
+
id: string(),
|
|
9681
|
+
label: string(),
|
|
9682
|
+
icon: string(),
|
|
9683
|
+
path: string(),
|
|
9684
|
+
/**
|
|
9685
|
+
* Module Federation remote name — must match the `name` field on the
|
|
9686
|
+
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
9687
|
+
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
9688
|
+
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
9689
|
+
* contain hyphens).
|
|
9690
|
+
*/
|
|
9691
|
+
remoteName: string(),
|
|
9692
|
+
/**
|
|
9693
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9694
|
+
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
9695
|
+
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
9696
|
+
* the static-file route can compute an mtime-based cache-buster URL
|
|
9697
|
+
* without a separate filesystem stat.
|
|
9698
|
+
*/
|
|
9699
|
+
bundle: string(),
|
|
9700
|
+
/**
|
|
9701
|
+
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
9702
|
+
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
9703
|
+
* Any OTHER string creates (or joins) a custom section rendered after
|
|
9704
|
+
* the built-in groups; its label comes from `sectionLabel` (first
|
|
9705
|
+
* declaration wins), falling back to the id. Absent → the legacy
|
|
9706
|
+
* "Addon Pages" group.
|
|
9707
|
+
*/
|
|
9708
|
+
section: string().optional(),
|
|
9709
|
+
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
9710
|
+
sectionLabel: string().optional()
|
|
9711
|
+
});
|
|
9712
|
+
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
9713
|
+
var AddonHttpRouteSchema = object({
|
|
9714
|
+
method: _enum([
|
|
9715
|
+
"GET",
|
|
9716
|
+
"POST",
|
|
9717
|
+
"PUT",
|
|
9718
|
+
"DELETE",
|
|
9719
|
+
"PATCH"
|
|
9720
|
+
]),
|
|
9721
|
+
path: string(),
|
|
9722
|
+
access: _enum([
|
|
9723
|
+
"public",
|
|
9724
|
+
"authenticated",
|
|
9725
|
+
"admin"
|
|
9726
|
+
]).optional(),
|
|
9727
|
+
description: string().optional()
|
|
9728
|
+
});
|
|
9729
|
+
/**
|
|
9730
|
+
* Cross-process route invocation envelope. The hub captures the
|
|
9731
|
+
* request as plain data, ships it to the worker via Moleculer, and
|
|
9732
|
+
* the worker runs the local handler against a capturing reply. The
|
|
9733
|
+
* envelope returned describes what the handler intended (status,
|
|
9734
|
+
* headers, body, or a redirect) so the hub can translate it back to
|
|
9735
|
+
* the Fastify reply that's actually wired to the socket.
|
|
9736
|
+
*/
|
|
9737
|
+
var InvokeRequestSchema = object({
|
|
9738
|
+
method: string(),
|
|
9739
|
+
path: string(),
|
|
9740
|
+
params: record(string(), string()),
|
|
9741
|
+
query: record(string(), string()),
|
|
9742
|
+
body: unknown(),
|
|
9743
|
+
headers: record(string(), string()),
|
|
9744
|
+
user: object({
|
|
9745
|
+
id: string(),
|
|
9746
|
+
username: string(),
|
|
9747
|
+
isAdmin: boolean()
|
|
9748
|
+
}).optional(),
|
|
9749
|
+
scopedToken: unknown().optional()
|
|
9750
|
+
});
|
|
9751
|
+
var InvokeReplyEnvelopeSchema = object({
|
|
9752
|
+
status: number().int(),
|
|
9753
|
+
headers: record(string(), string()),
|
|
9754
|
+
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
9755
|
+
* sending `body`. Status defaults to 302 when this is set unless
|
|
9756
|
+
* the handler called `reply.code(...)` explicitly. */
|
|
9757
|
+
redirectUrl: string().nullable(),
|
|
9758
|
+
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
9759
|
+
body: unknown().optional(),
|
|
9760
|
+
/** Set when the handler called `reply.type(mime)`. */
|
|
9761
|
+
contentType: string().optional()
|
|
9762
|
+
});
|
|
9763
|
+
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
9764
|
+
var ConfigTabDeclarationSchema = object({
|
|
9765
|
+
id: string(),
|
|
9766
|
+
label: string(),
|
|
9767
|
+
icon: string(),
|
|
9768
|
+
order: number().optional()
|
|
9769
|
+
});
|
|
9770
|
+
var ConfigSectionWithValuesSchema = object({
|
|
9771
|
+
id: string(),
|
|
9772
|
+
title: string(),
|
|
9773
|
+
description: string().optional(),
|
|
9774
|
+
style: _enum(["card", "accordion"]).optional(),
|
|
9775
|
+
defaultCollapsed: boolean().optional(),
|
|
9776
|
+
columns: union([
|
|
9777
|
+
literal(1),
|
|
9778
|
+
literal(2),
|
|
9779
|
+
literal(3),
|
|
9780
|
+
literal(4)
|
|
9781
|
+
]).optional(),
|
|
9782
|
+
tab: string().optional(),
|
|
9783
|
+
location: _enum(["settings", "top-tab"]).optional(),
|
|
9784
|
+
order: number().optional(),
|
|
9785
|
+
fields: array(any())
|
|
9786
|
+
});
|
|
9787
|
+
var SettingsSchemaWithValuesSchema = object({
|
|
9788
|
+
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
9789
|
+
sections: array(ConfigSectionWithValuesSchema)
|
|
9790
|
+
});
|
|
9791
|
+
/** Patch object — keys are field names, values are the new field values. */
|
|
9792
|
+
var SettingsPatchSchema = record(string(), unknown());
|
|
9793
|
+
/** Standard success response for update operations. */
|
|
9794
|
+
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
9795
|
+
method(object({
|
|
9796
|
+
addonId: string(),
|
|
9797
|
+
nodeId: string().optional(),
|
|
9798
|
+
overlay: record(string(), unknown()).optional(),
|
|
9799
|
+
cap: string().optional()
|
|
9800
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9801
|
+
addonId: string(),
|
|
9802
|
+
nodeId: string().optional(),
|
|
9803
|
+
patch: SettingsPatchSchema
|
|
9804
|
+
}), SettingsUpdateResultSchema, {
|
|
9805
|
+
kind: "mutation",
|
|
9806
|
+
auth: "admin"
|
|
9807
|
+
}), method(object({
|
|
9808
|
+
addonId: string(),
|
|
9809
|
+
deviceId: number(),
|
|
9810
|
+
nodeId: string().optional()
|
|
9811
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9812
|
+
addonId: string(),
|
|
9813
|
+
deviceId: number(),
|
|
9814
|
+
nodeId: string().optional(),
|
|
9815
|
+
patch: SettingsPatchSchema
|
|
9816
|
+
}), SettingsUpdateResultSchema, {
|
|
9817
|
+
kind: "mutation",
|
|
9818
|
+
auth: "admin"
|
|
9819
|
+
});
|
|
9820
|
+
/**
|
|
9821
|
+
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
9822
|
+
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
9823
|
+
* widgets registers a provider on this collection cap; the hub-local
|
|
9824
|
+
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
9825
|
+
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
9826
|
+
* exposes the public listing surface that admin-ui consumes.
|
|
9827
|
+
*
|
|
9828
|
+
* The split exists because the public listing has a different output
|
|
9829
|
+
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
9830
|
+
* per-provider raw declarations. Both ends flow through codegen.
|
|
9831
|
+
*
|
|
9832
|
+
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
9833
|
+
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
9834
|
+
* same `ContributionRenderer` / Module-Federation path as every other
|
|
9835
|
+
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
9836
|
+
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
9837
|
+
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
9838
|
+
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
9839
|
+
*/
|
|
9840
|
+
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
9841
|
+
var WidgetHostEnum = _enum([
|
|
9842
|
+
"device-tab",
|
|
9843
|
+
"dashboard",
|
|
9844
|
+
"integration-detail"
|
|
9845
|
+
]);
|
|
9846
|
+
var WidgetSizeEnum = _enum([
|
|
9847
|
+
"xs",
|
|
9848
|
+
"sm",
|
|
9849
|
+
"md",
|
|
9850
|
+
"lg",
|
|
9851
|
+
"xl"
|
|
9852
|
+
]);
|
|
9853
|
+
/**
|
|
9854
|
+
* MF remote descriptor — mirrors `UiContributionRemote` from
|
|
9855
|
+
* `capability-definition.ts`. Widget remotes expose a single
|
|
9856
|
+
* `'./widgets'` module whose default export is a
|
|
9857
|
+
* `Record<componentKey, Component>` map; `componentKey` (the widget
|
|
9858
|
+
* `stableId`) picks the entry the host mounts.
|
|
9859
|
+
*/
|
|
9860
|
+
var WidgetRemoteSchema = object({
|
|
9861
|
+
remoteName: string(),
|
|
9862
|
+
exposedModule: string(),
|
|
9863
|
+
componentKey: string().optional()
|
|
9864
|
+
});
|
|
9865
|
+
/**
|
|
9866
|
+
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
9867
|
+
* widget-only metadata. The `UiContribution` core fields:
|
|
9868
|
+
*
|
|
9869
|
+
* - `tab` — where the widget hosts. A widget that runs on the
|
|
9870
|
+
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
9871
|
+
* widget declares the target device-detail tab id.
|
|
9872
|
+
* - `subTab` — optional sub-tab within `tab`.
|
|
9873
|
+
* - `label` — operator-facing label.
|
|
9874
|
+
* - `order` — ordering within `(tab, subTab)`.
|
|
9875
|
+
* - `kind` — always `'remote'` for widgets.
|
|
9876
|
+
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
9877
|
+
*
|
|
9878
|
+
* Widget-only fields retained alongside the contribution core:
|
|
9879
|
+
*
|
|
9880
|
+
* - `stableId` — stable identity within the addon (the MF
|
|
9881
|
+
* `componentKey`; kept top-level so consumers have
|
|
9882
|
+
* a stable key without reaching into `remote`).
|
|
9883
|
+
* - `description` / `icon` — picker metadata.
|
|
9884
|
+
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
9885
|
+
* aggregator stamps a versioned `bundleUrl` from it.
|
|
9886
|
+
* - `hosts` — every host the widget supports (a widget can run
|
|
9887
|
+
* both on the dashboard and a device tab). `tab`
|
|
9888
|
+
* is the PRIMARY host; `hosts` is the full set the
|
|
9889
|
+
* picker filters on.
|
|
9890
|
+
* - `requires` — host-context requirements validated at mount.
|
|
9891
|
+
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
9892
|
+
* — dashboard placement hints.
|
|
9893
|
+
*/
|
|
9894
|
+
var WidgetMetadataSchema = object({
|
|
9895
|
+
/** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
|
|
9896
|
+
tab: string(),
|
|
9897
|
+
/** Optional sub-tab within `tab`. */
|
|
9898
|
+
subTab: string().optional(),
|
|
9899
|
+
/** Operator-facing label. */
|
|
9900
|
+
label: string(),
|
|
9901
|
+
/** Ordering within `(tab, subTab)`, ascending. */
|
|
9902
|
+
order: number().optional(),
|
|
9903
|
+
/** Always `'remote'` — a widget is a Module Federation remote. */
|
|
9904
|
+
kind: literal("remote"),
|
|
9905
|
+
/** MF remote descriptor. */
|
|
9906
|
+
remote: WidgetRemoteSchema,
|
|
9907
|
+
/** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
|
|
9908
|
+
stableId: string(),
|
|
9909
|
+
description: string().optional(),
|
|
9910
|
+
icon: string().optional(),
|
|
9911
|
+
/**
|
|
9912
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9913
|
+
* `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
|
|
9914
|
+
* this is always `'remoteEntry.js'` — the value is kept on the
|
|
9915
|
+
* metadata so the static-file route can compute an mtime-based
|
|
9916
|
+
* cache-buster URL without a separate filesystem stat.
|
|
9917
|
+
*/
|
|
9918
|
+
bundle: string(),
|
|
9919
|
+
/** Every host the widget supports. The picker filters on this set. */
|
|
9920
|
+
hosts: array(WidgetHostEnum).readonly(),
|
|
9921
|
+
/** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
|
|
9922
|
+
requires: object({
|
|
9923
|
+
deviceContext: boolean().default(false),
|
|
9924
|
+
integrationContext: boolean().default(false)
|
|
9925
|
+
}),
|
|
9926
|
+
/**
|
|
9927
|
+
* Loadable BEFORE authentication. The normal widget registry listing
|
|
9928
|
+
* (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
|
|
9929
|
+
* (the login page) cannot discover a widget through it. A widget that
|
|
9930
|
+
* declares `preAuth: true` marks itself as safe to mount on a pre-auth
|
|
9931
|
+
* screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
|
|
9932
|
+
* login-method contribution channel (see `login-method.cap.ts`) rather
|
|
9933
|
+
* than the authenticated registry, and its bundle is served by the
|
|
9934
|
+
* public `/api/addon-widgets/:addonId/*` static route. Defaults false.
|
|
9935
|
+
*/
|
|
9936
|
+
preAuth: boolean().optional().default(false),
|
|
9937
|
+
/** Dashboard placement HINTS (operator can override per instance). */
|
|
9938
|
+
defaultSize: WidgetSizeEnum.default("md"),
|
|
9939
|
+
allowedSizes: array(WidgetSizeEnum).readonly().default([
|
|
9940
|
+
"sm",
|
|
9941
|
+
"md",
|
|
9942
|
+
"lg"
|
|
9943
|
+
]),
|
|
9944
|
+
defaultColumns: number().int().min(1).max(12).default(6),
|
|
9945
|
+
defaultRows: number().int().min(1).max(12).default(1)
|
|
9946
|
+
});
|
|
9947
|
+
var addonWidgetsSourceCapability = {
|
|
9948
|
+
name: "addon-widgets-source",
|
|
9949
|
+
scope: "system",
|
|
9950
|
+
mode: "collection",
|
|
9951
|
+
internal: true,
|
|
9952
|
+
methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
|
|
9953
|
+
};
|
|
9954
|
+
/**
|
|
9955
|
+
* `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
|
|
9956
|
+
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9957
|
+
*
|
|
9958
|
+
* The provider iterates every `addon-widgets-source` (collection)
|
|
9959
|
+
* provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
|
|
9960
|
+
* `bundleUrl` strings pointing at
|
|
9961
|
+
* `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
|
|
9962
|
+
* `mtime` cache-buster lets the browser pick up addon rebuilds without
|
|
9963
|
+
* manual reload — same scheme used by `addon-pages`.
|
|
9964
|
+
*
|
|
9965
|
+
* The hub-local builtin `addon-widgets-aggregator` (see
|
|
9966
|
+
* `@camstack/system/builtins/addon-widgets-aggregator`) registers the
|
|
9967
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9968
|
+
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
9969
|
+
*/
|
|
9970
|
+
var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
|
|
9971
|
+
addonId: string(),
|
|
9972
|
+
bundleUrl: string()
|
|
9973
|
+
});
|
|
9974
|
+
method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
|
|
9975
|
+
/**
|
|
9976
|
+
* Alerts capability — collection-based internal alert system.
|
|
9977
|
+
*
|
|
9978
|
+
* Multiple providers can register. Each provider filters by EventBus category
|
|
9979
|
+
* and creates/updates alerts. The built-in Alert Center addon persists alerts
|
|
9980
|
+
* in the DB and serves them to the admin UI.
|
|
9981
|
+
*/
|
|
9982
|
+
var AlertSeveritySchema = _enum([
|
|
9983
|
+
"info",
|
|
9984
|
+
"success",
|
|
9985
|
+
"warning",
|
|
9986
|
+
"error"
|
|
9987
|
+
]);
|
|
9988
|
+
var AlertStatusSchema = _enum([
|
|
9989
|
+
"active",
|
|
9990
|
+
"in-progress",
|
|
9991
|
+
"completed",
|
|
9992
|
+
"failed",
|
|
9993
|
+
"dismissed"
|
|
9994
|
+
]);
|
|
9995
|
+
var AlertSourceSchema = object({
|
|
9996
|
+
type: string(),
|
|
9997
|
+
id: string()
|
|
9998
|
+
});
|
|
9999
|
+
var AlertSchema = object({
|
|
10000
|
+
id: string(),
|
|
10001
|
+
category: string(),
|
|
10002
|
+
severity: AlertSeveritySchema,
|
|
10003
|
+
title: string(),
|
|
10004
|
+
message: string(),
|
|
10005
|
+
status: AlertStatusSchema,
|
|
10006
|
+
progress: number().optional(),
|
|
10007
|
+
read: boolean(),
|
|
10008
|
+
createdAt: number(),
|
|
10009
|
+
updatedAt: number(),
|
|
10010
|
+
source: AlertSourceSchema.optional(),
|
|
10011
|
+
metadata: record(string(), unknown()).optional()
|
|
10012
|
+
});
|
|
10013
|
+
method(AlertSchema, _void(), { kind: "mutation" }), method(object({
|
|
10014
|
+
alertId: string(),
|
|
10015
|
+
patch: AlertSchema.partial()
|
|
10016
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
10017
|
+
unreadOnly: boolean().optional(),
|
|
10018
|
+
limit: number().optional()
|
|
10019
|
+
}).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" });
|
|
10020
|
+
DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
|
|
10021
|
+
deviceId: number(),
|
|
10022
|
+
rms: number(),
|
|
10023
|
+
dbfs: number()
|
|
10024
|
+
});
|
|
10025
|
+
/** Shared Zod schemas used across detection capabilities. */
|
|
10026
|
+
/**
|
|
10027
|
+
* Canonical frame-format enum mirrored on `FrameFormat` in
|
|
10028
|
+
* `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
|
|
10029
|
+
* Zod runtime schema and TypeScript type stay in sync at the call site
|
|
10030
|
+
* — adding a new format requires changing both this enum and the
|
|
10031
|
+
* `FrameFormat` type alias together.
|
|
10032
|
+
*/
|
|
10033
|
+
var FrameFormatSchema = _enum([
|
|
10034
|
+
"jpeg",
|
|
10035
|
+
"rgb",
|
|
10036
|
+
"bgr",
|
|
10037
|
+
"yuv420",
|
|
10038
|
+
"gray"
|
|
10039
|
+
]);
|
|
10040
|
+
var FrameInputSchema = object({
|
|
10041
|
+
data: custom(),
|
|
10042
|
+
format: FrameFormatSchema,
|
|
10043
|
+
width: number(),
|
|
10044
|
+
height: number(),
|
|
10045
|
+
timestamp: number()
|
|
10046
|
+
});
|
|
10047
|
+
var BoundingBoxSchema = object({
|
|
10048
|
+
x: number(),
|
|
10049
|
+
y: number(),
|
|
10050
|
+
w: number(),
|
|
10051
|
+
h: number()
|
|
10052
|
+
});
|
|
10053
|
+
object({
|
|
10054
|
+
class: string(),
|
|
10055
|
+
originalClass: string(),
|
|
10056
|
+
score: number(),
|
|
10057
|
+
bbox: BoundingBoxSchema
|
|
10058
|
+
});
|
|
10059
|
+
/**
|
|
10060
|
+
* `data` carries the raw f32le bytes of the PCM samples (4 bytes per
|
|
10061
|
+
* sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
|
|
10062
|
+
* because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
|
|
10063
|
+
* round-trips losslessly over the UDS transport. `Float32Array` is NOT
|
|
10064
|
+
* preserved — the encoder serialises it as `bin` (its raw bytes) but
|
|
10065
|
+
* the decoder returns `Uint8Array`, so treating a `Float32Array` as the
|
|
10066
|
+
* wire type causes receivers to read bytes as sample values, producing
|
|
10067
|
+
* wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
|
|
10068
|
+
*
|
|
10069
|
+
* Callers that need float arithmetic reconstruct the view with:
|
|
10070
|
+
* `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
|
|
9199
10071
|
*/
|
|
9200
10072
|
var AudioChunkInputSchema = object({
|
|
9201
10073
|
data: _instanceof(Uint8Array),
|
|
@@ -11900,6 +12772,15 @@ method(object({
|
|
|
11900
12772
|
}), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
|
|
11901
12773
|
kind: "mutation",
|
|
11902
12774
|
auth: "admin"
|
|
12775
|
+
}), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
|
|
12776
|
+
kind: "mutation",
|
|
12777
|
+
auth: "admin"
|
|
12778
|
+
}), method(object({
|
|
12779
|
+
addonId: string(),
|
|
12780
|
+
integrationId: string().optional()
|
|
12781
|
+
}), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
12782
|
+
kind: "mutation",
|
|
12783
|
+
auth: "admin"
|
|
11903
12784
|
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
11904
12785
|
kind: "mutation",
|
|
11905
12786
|
auth: "admin"
|
|
@@ -12622,7 +13503,7 @@ DeviceType.Camera, method(object({
|
|
|
12622
13503
|
* Why: pub/sub routing over the system event-bus loses fidelity
|
|
12623
13504
|
* (callback shape, QoS guarantees, will/retain semantics) and adds
|
|
12624
13505
|
* refcount bookkeeping that addons would rather own themselves. The
|
|
12625
|
-
* canonical consumer
|
|
13506
|
+
* canonical consumer needs raw `mqtt.js`
|
|
12626
13507
|
* features anyway — give it the connection config, get out of the way.
|
|
12627
13508
|
*
|
|
12628
13509
|
* Consumer flow:
|
|
@@ -14779,6 +15660,65 @@ object({
|
|
|
14779
15660
|
* Each provider returns a static descriptor; the core enumerates them
|
|
14780
15661
|
* to validate the `integration=` query param and resolve the consent
|
|
14781
15662
|
* label + the scopes baked into the issued token.
|
|
15663
|
+
*
|
|
15664
|
+
* ## Declaring one
|
|
15665
|
+
*
|
|
15666
|
+
* An OAuth client is integration-specific knowledge — who the client is, what
|
|
15667
|
+
* it may ask for, where it may be sent — so it is declared by the ADDON that
|
|
15668
|
+
* owns the integration, never by the kernel and never as a branch inside
|
|
15669
|
+
* `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
|
|
15670
|
+
* steps, no others:
|
|
15671
|
+
*
|
|
15672
|
+
* 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
|
|
15673
|
+
* manifest entry. This is also what tells the hub, at addon-LOAD time, that
|
|
15674
|
+
* a descriptor is owed — see "the boot window" below.
|
|
15675
|
+
* 2. Return a provider from `onInitialize()`:
|
|
15676
|
+
*
|
|
15677
|
+
* ```ts
|
|
15678
|
+
* const provider: IOauthIntegrationProvider = {
|
|
15679
|
+
* getDescriptor: async () => ({
|
|
15680
|
+
* integrationId: 'my-thing', // the `integration=` query param
|
|
15681
|
+
* displayName: 'My Thing',
|
|
15682
|
+
* requestedScopes: [ … ], // see below
|
|
15683
|
+
* allowedRedirectPrefixes: ['https://callback.example/'],
|
|
15684
|
+
* }),
|
|
15685
|
+
* }
|
|
15686
|
+
* return [{ capability: oauthIntegrationCapability, provider }]
|
|
15687
|
+
* ```
|
|
15688
|
+
*
|
|
15689
|
+
* The descriptor must be **static** — it is read on the authorize path, so
|
|
15690
|
+
* never put an await on network or disk behind it, and never register it
|
|
15691
|
+
* behind one either (a provider is registered only once `onInitialize`
|
|
15692
|
+
* RETURNS, so anything awaited before the return delays linking).
|
|
15693
|
+
* 3. Nothing else. There is no allow-list to join, no id to register with the
|
|
15694
|
+
* core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
|
|
15695
|
+
* `/api/oauth2/integrations` are built from this collection alone.
|
|
15696
|
+
*
|
|
15697
|
+
* **Scopes.** `requestedScopes` is baked into every token this integration is
|
|
15698
|
+
* ever issued and the operator consents to it once. Derive it from the tRPC
|
|
15699
|
+
* paths the client calls **with that token**, against `METHOD_ACCESS_MAP`, and
|
|
15700
|
+
* prefer a narrow `capability:` scope to a `category:` one unless the client
|
|
15701
|
+
* genuinely needs a whole family. A category scope grants every future member
|
|
15702
|
+
* of that category too. `category:system [create]` has been rejected once and
|
|
15703
|
+
* should stay rejected: it hands `addons.installPackage` to an integration.
|
|
15704
|
+
*
|
|
15705
|
+
* What it does NOT cover: calls the ADDON makes over `ctx.api`, which run as
|
|
15706
|
+
* the addon and are not scope-checked. Alexa's descriptor is narrower than
|
|
15707
|
+
* Home Assistant's for exactly that reason — its Lambda posts directives and
|
|
15708
|
+
* the addon does the work, while the Home Assistant component calls tRPC
|
|
15709
|
+
* directly with the token. So `requestedScopes` describes the blast radius of
|
|
15710
|
+
* the GRANT, not the reach of the integration; do not widen one to describe the
|
|
15711
|
+
* other.
|
|
15712
|
+
*
|
|
15713
|
+
* **The boot window.** An addon registers its provider after its runner forks
|
|
15714
|
+
* and initialises, so between hub start and that moment this collection is
|
|
15715
|
+
* incomplete and an `integrationId` can be legitimately absent. The core does
|
|
15716
|
+
* not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
|
|
15717
|
+
* it compares the manifest declarers against the registered providers and
|
|
15718
|
+
* answers `503 temporarily_unavailable` (with `Retry-After` and the pending
|
|
15719
|
+
* addon ids) instead of `400 unknown integration`, and reports
|
|
15720
|
+
* `complete: false` on `GET /api/oauth2/integrations`. A client should retry
|
|
15721
|
+
* while the list is incomplete rather than conclude the hub cannot do OAuth.
|
|
14782
15722
|
*/
|
|
14783
15723
|
var OauthIntegrationDescriptorSchema = object({
|
|
14784
15724
|
/** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
|
|
@@ -15162,8 +16102,26 @@ var TrackSchema = object({
|
|
|
15162
16102
|
/** Periodic snapshots at snapshotIntervalMs cadence (subject to
|
|
15163
16103
|
* saveThumbnails policy). */
|
|
15164
16104
|
snapshots: array(TrackSnapshotSchema).readonly(),
|
|
15165
|
-
/** Deduplicated zones the track has entered at least once. */
|
|
16105
|
+
/** Deduplicated zones the track has entered at least once. Zone IDS. */
|
|
15166
16106
|
zonesVisited: array(string()).readonly(),
|
|
16107
|
+
/**
|
|
16108
|
+
* Human NAMES for {@link zonesVisited}, resolved at READ time against the
|
|
16109
|
+
* `zones` capability.
|
|
16110
|
+
*
|
|
16111
|
+
* `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
|
|
16112
|
+
* and no card can render — so every free-text search surface was structurally
|
|
16113
|
+
* unable to answer "show me the tracks in Uscio", and did not fail loudly, it
|
|
16114
|
+
* just returned nothing. Resolving here rather than in each client keeps ONE
|
|
16115
|
+
* derivation and costs the clients no extra call (the `zones` cap is
|
|
16116
|
+
* per-device, so a client-side resolve would be a per-camera fan-out on a
|
|
16117
|
+
* surface built to avoid exactly that).
|
|
16118
|
+
*
|
|
16119
|
+
* Resolved, never invented: a zone deleted since the track was written has no
|
|
16120
|
+
* name and is DROPPED, so this array can be shorter than `zonesVisited` — the
|
|
16121
|
+
* two are not positionally aligned. Absent when the track visited no zone, or
|
|
16122
|
+
* when the zone catalogue could not be read.
|
|
16123
|
+
*/
|
|
16124
|
+
zoneNames: array(string()).readonly().optional(),
|
|
15167
16125
|
/** Deduplicated set of detector classes observed for this track over its
|
|
15168
16126
|
* life (a track may be reclassified, e.g. person→vehicle). Absent on
|
|
15169
16127
|
* legacy rows written before class accumulation shipped. */
|
|
@@ -17557,6 +18515,23 @@ var CameraRecordingStatusSchema = object({
|
|
|
17557
18515
|
active: boolean(),
|
|
17558
18516
|
storageBytes: number()
|
|
17559
18517
|
});
|
|
18518
|
+
/** One stage of the fan-out that could NOT be read, and how long it cost. */
|
|
18519
|
+
var CameraStatusDegradationSchema = object({
|
|
18520
|
+
stage: _enum([
|
|
18521
|
+
"source",
|
|
18522
|
+
"broker",
|
|
18523
|
+
"detection",
|
|
18524
|
+
"recording",
|
|
18525
|
+
"switches"
|
|
18526
|
+
]),
|
|
18527
|
+
reason: _enum([
|
|
18528
|
+
"timeout",
|
|
18529
|
+
"error",
|
|
18530
|
+
"partial"
|
|
18531
|
+
]),
|
|
18532
|
+
/** Wall-clock ms spent on the stage before it was abandoned. */
|
|
18533
|
+
elapsedMs: number()
|
|
18534
|
+
});
|
|
17560
18535
|
/**
|
|
17561
18536
|
* Aggregated per-camera pipeline status — server-composed, single call.
|
|
17562
18537
|
*
|
|
@@ -17587,9 +18562,28 @@ var CameraStatusSchema = object({
|
|
|
17587
18562
|
* differently — a quiet camera that looks identical to a dead one is the
|
|
17588
18563
|
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
17589
18564
|
*
|
|
17590
|
-
* Empty when nothing is off
|
|
18565
|
+
* Empty when nothing is off, and never contains a switch no provider offers
|
|
18566
|
+
* — but an empty list is only a POSITIVE claim when `degraded` does not name
|
|
18567
|
+
* `'switches'`. When it does, the switch set could not be read and nothing
|
|
18568
|
+
* here may be rendered as "the operator turned nothing off": that is the
|
|
18569
|
+
* D62 failure (a camera we could not read painted as broken) in the very
|
|
18570
|
+
* field that exists to prevent it.
|
|
17591
18571
|
*/
|
|
17592
18572
|
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
18573
|
+
/**
|
|
18574
|
+
* Stages of the bounded fan-out that were CUT SHORT — a timeout or a
|
|
18575
|
+
* rejection — and whose block is therefore `null` because we could not
|
|
18576
|
+
* READ it, not because there is nothing there.
|
|
18577
|
+
*
|
|
18578
|
+
* Without this, three different facts arrive as the same `null`: "the stage
|
|
18579
|
+
* timed out", "the stage failed", and "this camera legitimately has no
|
|
18580
|
+
* decoder / no recording". Every surface that draws a conclusion from a null
|
|
18581
|
+
* block (or from an empty `switchedOff`) must consult this first; a stage
|
|
18582
|
+
* named here supports no conclusion at all, only "unknown".
|
|
18583
|
+
*
|
|
18584
|
+
* Empty on a clean read — the overwhelmingly common case.
|
|
18585
|
+
*/
|
|
18586
|
+
degraded: array(CameraStatusDegradationSchema).readonly(),
|
|
17593
18587
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
17594
18588
|
fetchedAt: number()
|
|
17595
18589
|
});
|
|
@@ -18399,6 +19393,37 @@ DeviceType.Camera, method(object({
|
|
|
18399
19393
|
lastCapturedAt: number().nullable(),
|
|
18400
19394
|
cacheAgeMs: number().nullable(),
|
|
18401
19395
|
etag: string().nullable()
|
|
19396
|
+
}))), systemMethod(object({
|
|
19397
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
19398
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
19399
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
19400
|
+
targets: array(object({
|
|
19401
|
+
deviceId: number(),
|
|
19402
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
19403
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
19404
|
+
width: number().int().positive().optional()
|
|
19405
|
+
})).min(1).max(200) }), array(object({
|
|
19406
|
+
deviceId: number(),
|
|
19407
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
19408
|
+
* served (no data-plane facility). Present even for a device that has
|
|
19409
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
19410
|
+
url: string().nullable(),
|
|
19411
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
19412
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
19413
|
+
capturedAt: number().nullable(),
|
|
19414
|
+
/** Age of that frame at the moment the answer was built. */
|
|
19415
|
+
ageMs: number().nullable(),
|
|
19416
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
19417
|
+
expiresAt: number().nullable(),
|
|
19418
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
19419
|
+
width: number().nullable(),
|
|
19420
|
+
/** The device has never produced a frame. An empty state, not a
|
|
19421
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
19422
|
+
neverCaptured: boolean(),
|
|
19423
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
19424
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
19425
|
+
* present it as current. */
|
|
19426
|
+
sleeping: boolean()
|
|
18402
19427
|
})));
|
|
18403
19428
|
/**
|
|
18404
19429
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -20149,6 +21174,37 @@ DeviceType.Light, method(object({
|
|
|
20149
21174
|
mireds: number().int().optional(),
|
|
20150
21175
|
lastChangedAt: number()
|
|
20151
21176
|
});
|
|
21177
|
+
var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
|
|
21178
|
+
object({
|
|
21179
|
+
outcome: literal("validated"),
|
|
21180
|
+
/** Round-trip of the sign-in, when the provider measured it. */
|
|
21181
|
+
latencyMs: number().nonnegative().optional(),
|
|
21182
|
+
/** Optional human detail worth showing next to the tick
|
|
21183
|
+
* ("3 devices visible on this account"). */
|
|
21184
|
+
detail: string().optional()
|
|
21185
|
+
}).strict(),
|
|
21186
|
+
object({
|
|
21187
|
+
outcome: literal("rejected"),
|
|
21188
|
+
error: string()
|
|
21189
|
+
}).strict(),
|
|
21190
|
+
object({
|
|
21191
|
+
outcome: literal("inconclusive"),
|
|
21192
|
+
error: string()
|
|
21193
|
+
}).strict()
|
|
21194
|
+
]);
|
|
21195
|
+
var ConnectionTestInputSchema = object({
|
|
21196
|
+
/** Candidate integration settings, exactly as the create form collected them. */
|
|
21197
|
+
settings: record(string(), unknown()) });
|
|
21198
|
+
/**
|
|
21199
|
+
* What the provider's test actually DOES, so the UI can say it in words before
|
|
21200
|
+
* the operator presses the button ("Signs in to the Dreo cloud"). Purely
|
|
21201
|
+
* descriptive — it never changes routing.
|
|
21202
|
+
*/
|
|
21203
|
+
var ConnectionTestDescriptorSchema = object({ label: string() });
|
|
21204
|
+
method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
|
|
21205
|
+
kind: "mutation",
|
|
21206
|
+
auth: "admin"
|
|
21207
|
+
}), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
|
|
20152
21208
|
object({
|
|
20153
21209
|
/** True when the upstream system considers the entity connected. */
|
|
20154
21210
|
connected: boolean(),
|
|
@@ -21050,15 +22106,57 @@ var AvailableIntegrationTypeSchema = object({
|
|
|
21050
22106
|
* flow can import (e.g. HA areas). Drives the adopt modal's "import
|
|
21051
22107
|
* locations" checkbox. Provider-declared in the addon manifest. */
|
|
21052
22108
|
supportsLocationImport: boolean(),
|
|
22109
|
+
/**
|
|
22110
|
+
* True when this integration DECLARES a pre-creation test (the
|
|
22111
|
+
* `connection-test` cap, or a broker whose settings it stores). Drives the
|
|
22112
|
+
* Test button: an integration that cannot be tested must say so up front
|
|
22113
|
+
* rather than offering a button that always answers the same nonsense.
|
|
22114
|
+
*/
|
|
22115
|
+
canTest: boolean(),
|
|
21053
22116
|
existingInstances: array(object({
|
|
21054
22117
|
id: string(),
|
|
21055
22118
|
name: string()
|
|
21056
22119
|
})),
|
|
21057
22120
|
canAdd: boolean()
|
|
21058
22121
|
});
|
|
22122
|
+
/**
|
|
22123
|
+
* Why a test could not be answered as a plain boolean.
|
|
22124
|
+
*
|
|
22125
|
+
* `success` alone collapsed four different situations into one red box, and the
|
|
22126
|
+
* one that mattered most — "nobody ever asked the remote anything" — looked
|
|
22127
|
+
* exactly like "the remote said no". The status is the discriminator:
|
|
22128
|
+
*
|
|
22129
|
+
* - `validated` — a provider-declared test ran and the remote ACCEPTED.
|
|
22130
|
+
* - `rejected` — a provider-declared test ran and the remote REFUSED.
|
|
22131
|
+
* The only status that blocks `integrations.create`.
|
|
22132
|
+
* - `inconclusive` — a test IS declared but could not complete (timeout,
|
|
22133
|
+
* DNS, 5xx). Nothing was observed; not a failure.
|
|
22134
|
+
* - `unsupported` — this integration declares NO test. Nothing was
|
|
22135
|
+
* observed either; not a failure, and not a pass.
|
|
22136
|
+
*
|
|
22137
|
+
* `unsupported` and `inconclusive` both carry `success: false` so an older
|
|
22138
|
+
* client can never read them as a green tick, and both carry an `error` string
|
|
22139
|
+
* that SAYS the test did not run rather than inventing a failure.
|
|
22140
|
+
*/
|
|
22141
|
+
var TestConnectionStatusEnum = _enum([
|
|
22142
|
+
"validated",
|
|
22143
|
+
"rejected",
|
|
22144
|
+
"inconclusive",
|
|
22145
|
+
"unsupported"
|
|
22146
|
+
]);
|
|
21059
22147
|
var TestConnectionResultSchema$1 = object({
|
|
22148
|
+
/** True ONLY for `validated`. Never true for a test that did not run. */
|
|
21060
22149
|
success: boolean(),
|
|
21061
|
-
error: string().optional()
|
|
22150
|
+
error: string().optional(),
|
|
22151
|
+
/** Optional for wire back-compat with clients built before the tri-state;
|
|
22152
|
+
* the server always sets it. */
|
|
22153
|
+
status: TestConnectionStatusEnum.optional(),
|
|
22154
|
+
/** Addon id whose declared test answered — `null` when none did. Lets the UI
|
|
22155
|
+
* attribute a result instead of blaming "the integration". */
|
|
22156
|
+
testedBy: string().nullable().optional(),
|
|
22157
|
+
latencyMs: number().nonnegative().optional(),
|
|
22158
|
+
/** Human detail from a `validated` result ("3 devices on this account"). */
|
|
22159
|
+
detail: string().optional()
|
|
21062
22160
|
});
|
|
21063
22161
|
var CreateIntegrationInputSchema = object({
|
|
21064
22162
|
addonId: string(),
|
|
@@ -24059,1182 +25157,553 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
24059
25157
|
}), method(object({
|
|
24060
25158
|
username: string(),
|
|
24061
25159
|
password: string()
|
|
24062
|
-
}), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
|
|
24063
|
-
kind: "mutation",
|
|
24064
|
-
access: "view"
|
|
24065
|
-
}), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
|
|
24066
|
-
kind: "mutation",
|
|
24067
|
-
auth: "admin",
|
|
24068
|
-
access: "create"
|
|
24069
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24070
|
-
kind: "mutation",
|
|
24071
|
-
auth: "admin",
|
|
24072
|
-
access: "delete"
|
|
24073
|
-
}), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
|
|
24074
|
-
kind: "mutation",
|
|
24075
|
-
access: "view"
|
|
24076
|
-
}), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
|
|
24077
|
-
kind: "mutation",
|
|
24078
|
-
auth: "admin",
|
|
24079
|
-
access: "create"
|
|
24080
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24081
|
-
kind: "mutation",
|
|
24082
|
-
auth: "admin",
|
|
24083
|
-
access: "delete"
|
|
24084
|
-
}), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
|
|
24085
|
-
kind: "mutation",
|
|
24086
|
-
auth: "admin",
|
|
24087
|
-
access: "create"
|
|
24088
|
-
}), method(object({
|
|
24089
|
-
userId: string(),
|
|
24090
|
-
code: string()
|
|
24091
|
-
}), object({ success: literal(true) }), {
|
|
24092
|
-
kind: "mutation",
|
|
24093
|
-
auth: "admin",
|
|
24094
|
-
access: "create"
|
|
24095
|
-
}), method(object({ userId: string() }), object({ success: literal(true) }), {
|
|
24096
|
-
kind: "mutation",
|
|
24097
|
-
auth: "admin",
|
|
24098
|
-
access: "delete"
|
|
24099
|
-
}), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
|
|
24100
|
-
userId: string(),
|
|
24101
|
-
code: string()
|
|
24102
|
-
}), object({ valid: boolean() }), {
|
|
24103
|
-
kind: "mutation",
|
|
24104
|
-
access: "view"
|
|
24105
|
-
}), method(object({
|
|
24106
|
-
integrationId: string(),
|
|
24107
|
-
userId: string(),
|
|
24108
|
-
username: string(),
|
|
24109
|
-
scopes: array(TokenScopeSchema),
|
|
24110
|
-
redirectUri: string(),
|
|
24111
|
-
hubUrl: string(),
|
|
24112
|
-
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
24113
|
-
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
24114
|
-
codeChallenge: string().optional()
|
|
24115
|
-
}), object({ code: string() }), {
|
|
24116
|
-
kind: "mutation",
|
|
24117
|
-
access: "create"
|
|
24118
|
-
}), method(object({
|
|
24119
|
-
code: string(),
|
|
24120
|
-
redirectUri: string(),
|
|
24121
|
-
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
24122
|
-
codeVerifier: string().optional()
|
|
24123
|
-
}), object({
|
|
24124
|
-
accessToken: string(),
|
|
24125
|
-
refreshToken: string(),
|
|
24126
|
-
expiresIn: number()
|
|
24127
|
-
}).nullable(), {
|
|
24128
|
-
kind: "mutation",
|
|
24129
|
-
access: "view"
|
|
24130
|
-
}), method(object({ refreshToken: string() }), object({
|
|
24131
|
-
accessToken: string(),
|
|
24132
|
-
refreshToken: string(),
|
|
24133
|
-
expiresIn: number()
|
|
24134
|
-
}).nullable(), {
|
|
24135
|
-
kind: "mutation",
|
|
24136
|
-
access: "view"
|
|
24137
|
-
}), method(object({ token: string() }), object({
|
|
24138
|
-
userId: string(),
|
|
24139
|
-
username: string(),
|
|
24140
|
-
scopes: array(TokenScopeSchema)
|
|
24141
|
-
}).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
24142
|
-
kind: "mutation",
|
|
24143
|
-
auth: "admin",
|
|
24144
|
-
access: "delete"
|
|
24145
|
-
});
|
|
24146
|
-
/**
|
|
24147
|
-
* Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
|
|
24148
|
-
* cleaning lifecycle plus a return-to-base / locate surface and an
|
|
24149
|
-
* optional fan-speed selector.
|
|
24150
|
-
*
|
|
24151
|
-
* State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
|
|
24152
|
-
* `paused` / `returning` / `docked` / `error`. `batteryLevel`
|
|
24153
|
-
* (0..100) is nullable — some vacuums don't report a battery
|
|
24154
|
-
* percentage. `fanSpeed` is the current speed token (provider-verbatim,
|
|
24155
|
-
* e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
|
|
24156
|
-
* tokens the hardware accepts so the UI renders only supported choices.
|
|
24157
|
-
*
|
|
24158
|
-
* The `setFanSpeed` method takes the bare `speed` token — the provider
|
|
24159
|
-
* validates it against the vacuum's own list. `locate` triggers the
|
|
24160
|
-
* find-me chirp; `returnToBase` sends it home.
|
|
24161
|
-
*
|
|
24162
|
-
* Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
|
|
24163
|
-
* `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
|
|
24164
|
-
* 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
|
|
24165
|
-
* SHOULD populate whichever tanks the hardware has — leave a field `null`
|
|
24166
|
-
* only when the device has no such tank at all, and use a `TankStatus`
|
|
24167
|
-
* with both inner fields `null` when the tank exists but its level is
|
|
24168
|
-
* currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
|
|
24169
|
-
* so the HA provider leaves all four `null`.
|
|
24170
|
-
*/
|
|
24171
|
-
var VacuumStateSchema = _enum([
|
|
24172
|
-
"idle",
|
|
24173
|
-
"cleaning",
|
|
24174
|
-
"paused",
|
|
24175
|
-
"returning",
|
|
24176
|
-
"docked",
|
|
24177
|
-
"drying",
|
|
24178
|
-
"error"
|
|
24179
|
-
]);
|
|
24180
|
-
/**
|
|
24181
|
-
* One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
|
|
24182
|
-
* detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
|
|
24183
|
-
* a discrete `status` (binary-style hardware), or both. Both `null` means the
|
|
24184
|
-
* level is currently unknown; the OWNING field being `null` means the
|
|
24185
|
-
* hardware has no such tank at all.
|
|
24186
|
-
*/
|
|
24187
|
-
var TankStatusSchema = object({
|
|
24188
|
-
/** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
|
|
24189
|
-
level: number().min(0).max(100).nullable(),
|
|
24190
|
-
/** Discrete state when the hardware is binary-mode; null otherwise. */
|
|
24191
|
-
status: _enum([
|
|
24192
|
-
"ok",
|
|
24193
|
-
"low",
|
|
24194
|
-
"full"
|
|
24195
|
-
]).nullable()
|
|
24196
|
-
});
|
|
24197
|
-
object({
|
|
24198
|
-
/** Lifecycle state of the vacuum. */
|
|
24199
|
-
state: VacuumStateSchema,
|
|
24200
|
-
/** 0..100 battery percentage. Null when the device has no battery
|
|
24201
|
-
* reading. */
|
|
24202
|
-
batteryLevel: number().min(0).max(100).nullable(),
|
|
24203
|
-
/** Current fan-speed token (provider-verbatim). Null when unknown or
|
|
24204
|
-
* the vacuum has no speed control. */
|
|
24205
|
-
fanSpeed: string().nullable(),
|
|
24206
|
-
/** Speed tokens the hardware accepts — drives the UI selector. */
|
|
24207
|
-
availableFanSpeeds: array(string()),
|
|
24208
|
-
/** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
|
|
24209
|
-
cleanWater: TankStatusSchema.nullable(),
|
|
24210
|
-
/** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
|
|
24211
|
-
dirtyWater: TankStatusSchema.nullable(),
|
|
24212
|
-
/** Detergent tank. Null when the hardware has no detergent tank. */
|
|
24213
|
-
detergent: TankStatusSchema.nullable(),
|
|
24214
|
-
/** Dust bin. Null when the hardware has no dust bin. */
|
|
24215
|
-
dustBin: TankStatusSchema.nullable(),
|
|
24216
|
-
/** 0..100 cleaning-completion percentage of the current task, or null. */
|
|
24217
|
-
progressPercent: number().min(0).max(100).nullable(),
|
|
24218
|
-
/** Current error code (0 / null = no error). */
|
|
24219
|
-
errorCode: number().nullable(),
|
|
24220
|
-
/** Human label for {@link errorCode}, or null when none / undecodable. */
|
|
24221
|
-
errorLabel: string().nullable(),
|
|
24222
|
-
/** Ms epoch when the slice was last updated. */
|
|
24223
|
-
lastChangedAt: number()
|
|
24224
|
-
});
|
|
24225
|
-
DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24226
|
-
kind: "mutation",
|
|
24227
|
-
auth: "admin"
|
|
24228
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24229
|
-
kind: "mutation",
|
|
24230
|
-
auth: "admin"
|
|
24231
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24232
|
-
kind: "mutation",
|
|
24233
|
-
auth: "admin"
|
|
24234
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24235
|
-
kind: "mutation",
|
|
24236
|
-
auth: "admin"
|
|
24237
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24238
|
-
kind: "mutation",
|
|
24239
|
-
auth: "admin"
|
|
24240
|
-
}), method(object({
|
|
24241
|
-
deviceId: number().int().nonnegative(),
|
|
24242
|
-
speed: string().min(1)
|
|
24243
|
-
}), _void(), {
|
|
24244
|
-
kind: "mutation",
|
|
24245
|
-
auth: "admin"
|
|
24246
|
-
});
|
|
24247
|
-
object({
|
|
24248
|
-
/** Lifecycle state of the valve. */
|
|
24249
|
-
state: _enum([
|
|
24250
|
-
"open",
|
|
24251
|
-
"opening",
|
|
24252
|
-
"closing",
|
|
24253
|
-
"closed",
|
|
24254
|
-
"stopped"
|
|
24255
|
-
]),
|
|
24256
|
-
/** 0 = fully closed, 100 = fully open. Null when the device has no
|
|
24257
|
-
* intermediate position surface. */
|
|
24258
|
-
position: number().min(0).max(100).nullable(),
|
|
24259
|
-
/** Ms epoch when the slice was last updated. */
|
|
24260
|
-
lastChangedAt: number()
|
|
24261
|
-
});
|
|
24262
|
-
DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25160
|
+
}), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
|
|
24263
25161
|
kind: "mutation",
|
|
24264
|
-
|
|
24265
|
-
}), method(
|
|
25162
|
+
access: "view"
|
|
25163
|
+
}), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
|
|
24266
25164
|
kind: "mutation",
|
|
24267
|
-
auth: "admin"
|
|
24268
|
-
|
|
25165
|
+
auth: "admin",
|
|
25166
|
+
access: "create"
|
|
25167
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24269
25168
|
kind: "mutation",
|
|
24270
|
-
auth: "admin"
|
|
24271
|
-
|
|
24272
|
-
|
|
24273
|
-
position: number().min(0).max(100)
|
|
24274
|
-
}), _void(), {
|
|
25169
|
+
auth: "admin",
|
|
25170
|
+
access: "delete"
|
|
25171
|
+
}), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
|
|
24275
25172
|
kind: "mutation",
|
|
24276
|
-
|
|
24277
|
-
})
|
|
24278
|
-
object({
|
|
24279
|
-
detected: boolean(),
|
|
24280
|
-
/** Ms epoch of the last transition. 0 if never observed. */
|
|
24281
|
-
lastChangedAt: number()
|
|
24282
|
-
});
|
|
24283
|
-
DeviceType.Sensor;
|
|
24284
|
-
object({
|
|
24285
|
-
/** Current measured temperature. Null when not reported. */
|
|
24286
|
-
currentTemp: number().nullable(),
|
|
24287
|
-
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
24288
|
-
targetTemp: number().nullable(),
|
|
24289
|
-
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
24290
|
-
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
24291
|
-
* device reports an unknown state. */
|
|
24292
|
-
operationMode: string().nullable(),
|
|
24293
|
-
/** Available operation modes = HA `operation_list`. */
|
|
24294
|
-
availableModes: array(string()),
|
|
24295
|
-
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
24296
|
-
* has no away surface. */
|
|
24297
|
-
away: boolean().nullable(),
|
|
24298
|
-
/** HA `min_temp` attribute. Null when not reported. */
|
|
24299
|
-
minTemp: number().nullable(),
|
|
24300
|
-
/** HA `max_temp` attribute. Null when not reported. */
|
|
24301
|
-
maxTemp: number().nullable(),
|
|
24302
|
-
/** Ms epoch when the slice was last updated. */
|
|
24303
|
-
lastChangedAt: number()
|
|
24304
|
-
});
|
|
24305
|
-
DeviceType.WaterHeater, method(object({
|
|
24306
|
-
deviceId: number().int().nonnegative(),
|
|
24307
|
-
temp: number().finite()
|
|
24308
|
-
}), _void(), {
|
|
25173
|
+
access: "view"
|
|
25174
|
+
}), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
|
|
24309
25175
|
kind: "mutation",
|
|
24310
|
-
auth: "admin"
|
|
25176
|
+
auth: "admin",
|
|
25177
|
+
access: "create"
|
|
25178
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
25179
|
+
kind: "mutation",
|
|
25180
|
+
auth: "admin",
|
|
25181
|
+
access: "delete"
|
|
25182
|
+
}), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
|
|
25183
|
+
kind: "mutation",
|
|
25184
|
+
auth: "admin",
|
|
25185
|
+
access: "create"
|
|
24311
25186
|
}), method(object({
|
|
24312
|
-
|
|
24313
|
-
|
|
24314
|
-
}),
|
|
25187
|
+
userId: string(),
|
|
25188
|
+
code: string()
|
|
25189
|
+
}), object({ success: literal(true) }), {
|
|
24315
25190
|
kind: "mutation",
|
|
24316
|
-
auth: "admin"
|
|
25191
|
+
auth: "admin",
|
|
25192
|
+
access: "create"
|
|
25193
|
+
}), method(object({ userId: string() }), object({ success: literal(true) }), {
|
|
25194
|
+
kind: "mutation",
|
|
25195
|
+
auth: "admin",
|
|
25196
|
+
access: "delete"
|
|
25197
|
+
}), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
|
|
25198
|
+
userId: string(),
|
|
25199
|
+
code: string()
|
|
25200
|
+
}), object({ valid: boolean() }), {
|
|
25201
|
+
kind: "mutation",
|
|
25202
|
+
access: "view"
|
|
24317
25203
|
}), method(object({
|
|
24318
|
-
|
|
24319
|
-
|
|
24320
|
-
|
|
25204
|
+
integrationId: string(),
|
|
25205
|
+
userId: string(),
|
|
25206
|
+
username: string(),
|
|
25207
|
+
scopes: array(TokenScopeSchema),
|
|
25208
|
+
redirectUri: string(),
|
|
25209
|
+
hubUrl: string(),
|
|
25210
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
25211
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
25212
|
+
codeChallenge: string().optional()
|
|
25213
|
+
}), object({ code: string() }), {
|
|
24321
25214
|
kind: "mutation",
|
|
24322
|
-
|
|
24323
|
-
})
|
|
24324
|
-
|
|
24325
|
-
|
|
24326
|
-
|
|
24327
|
-
|
|
24328
|
-
|
|
24329
|
-
|
|
24330
|
-
|
|
24331
|
-
|
|
24332
|
-
|
|
24333
|
-
|
|
24334
|
-
|
|
24335
|
-
|
|
24336
|
-
|
|
24337
|
-
|
|
24338
|
-
|
|
24339
|
-
|
|
24340
|
-
|
|
24341
|
-
|
|
24342
|
-
|
|
24343
|
-
|
|
24344
|
-
|
|
24345
|
-
|
|
24346
|
-
})
|
|
24347
|
-
|
|
24348
|
-
|
|
24349
|
-
|
|
24350
|
-
|
|
24351
|
-
* matrix the operator UI needs: total objects everywhere, total
|
|
24352
|
-
* objects per zone, single class everywhere, single class per zone,
|
|
24353
|
-
* objects outside any zone.
|
|
24354
|
-
*
|
|
24355
|
-
* Counts are derived from the analytics tracker (tracked detections
|
|
24356
|
-
* with stable trackIds), not raw detector hits — this filters out
|
|
24357
|
-
* one-off detector flickers and gives counts that match what the user
|
|
24358
|
-
* sees on the live overlay.
|
|
24359
|
-
*
|
|
24360
|
-
* Overlap policy: a detection that intersects two zones counts in
|
|
24361
|
-
* BOTH zones' `byClass` and `totalObjects` (count-in-each). The
|
|
24362
|
-
* `frame` aggregate de-duplicates trivially since it's frame-wide.
|
|
24363
|
-
* `unzoned` counts only detections that landed in zero zones.
|
|
24364
|
-
*/
|
|
24365
|
-
var PerScopeBreakdownSchema = object({
|
|
24366
|
-
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
24367
|
-
totalObjects: number().int().nonnegative(),
|
|
24368
|
-
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
24369
|
-
byClass: record(string(), number().int().nonnegative())
|
|
24370
|
-
});
|
|
24371
|
-
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
24372
|
-
zoneId: string(),
|
|
24373
|
-
zoneName: string(),
|
|
24374
|
-
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
24375
|
-
* with the per-track detail panel and live overlay. */
|
|
24376
|
-
trackIds: array(string()).readonly()
|
|
24377
|
-
});
|
|
24378
|
-
/**
|
|
24379
|
-
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
24380
|
-
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
24381
|
-
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
24382
|
-
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
24383
|
-
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
24384
|
-
* of flooding the live event feed.
|
|
24385
|
-
*/
|
|
24386
|
-
var StationaryObjectSchema = object({
|
|
24387
|
-
id: string(),
|
|
24388
|
-
className: string(),
|
|
24389
|
-
bbox: object({
|
|
24390
|
-
x: number(),
|
|
24391
|
-
y: number(),
|
|
24392
|
-
w: number(),
|
|
24393
|
-
h: number()
|
|
24394
|
-
}),
|
|
24395
|
-
frameWidth: number().int().nonnegative(),
|
|
24396
|
-
frameHeight: number().int().nonnegative(),
|
|
24397
|
-
/** When the source track was first seen. */
|
|
24398
|
-
firstSeenAt: number().int(),
|
|
24399
|
-
/** When the object was recognised as parked (promotion time). */
|
|
24400
|
-
becameStationaryAt: number().int(),
|
|
24401
|
-
/** Last frame a detection confirmed the object is still there. */
|
|
24402
|
-
lastConfirmedAt: number().int(),
|
|
24403
|
-
/** Enrichment label carried from the source track (identity / plate). */
|
|
24404
|
-
label: string().optional(),
|
|
24405
|
-
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
24406
|
-
keyFrameMediaKey: string().optional()
|
|
24407
|
-
});
|
|
24408
|
-
var CameraOccupancySnapshotSchema = object({
|
|
24409
|
-
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
24410
|
-
ts: number().int(),
|
|
24411
|
-
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
24412
|
-
frameWidth: number().int().nonnegative(),
|
|
24413
|
-
frameHeight: number().int().nonnegative(),
|
|
24414
|
-
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
24415
|
-
zones: array(ZoneScopeBreakdownSchema).readonly(),
|
|
24416
|
-
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
24417
|
-
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
24418
|
-
frame: PerScopeBreakdownSchema,
|
|
24419
|
-
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
24420
|
-
unzoned: PerScopeBreakdownSchema,
|
|
24421
|
-
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
24422
|
-
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
24423
|
-
* than as repeated tracks/events. */
|
|
24424
|
-
stationaryObjects: array(StationaryObjectSchema).readonly().optional()
|
|
24425
|
-
});
|
|
24426
|
-
/**
|
|
24427
|
-
* Time-series resolution. The history methods return one bucket per
|
|
24428
|
-
* step over the requested range. Smaller resolutions cost more
|
|
24429
|
-
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
24430
|
-
* for arbitrary fractional buckets.
|
|
24431
|
-
*/
|
|
24432
|
-
var HistoryResolutionEnum = _enum([
|
|
24433
|
-
"minute",
|
|
24434
|
-
"5min",
|
|
24435
|
-
"hour"
|
|
24436
|
-
]);
|
|
24437
|
-
var HistoryRangeSchema = object({
|
|
24438
|
-
/** Range start (epoch ms, inclusive). */
|
|
24439
|
-
from: number().int(),
|
|
24440
|
-
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
24441
|
-
to: number().int(),
|
|
24442
|
-
resolution: HistoryResolutionEnum
|
|
24443
|
-
});
|
|
24444
|
-
var HistoryPointSchema = object({
|
|
24445
|
-
/** Bucket midpoint (epoch ms). */
|
|
24446
|
-
ts: number().int(),
|
|
24447
|
-
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
24448
|
-
count: number().int().nonnegative()
|
|
24449
|
-
});
|
|
24450
|
-
DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
|
|
24451
|
-
deviceId: number(),
|
|
24452
|
-
zoneId: string(),
|
|
24453
|
-
className: string().optional()
|
|
24454
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
24455
|
-
deviceId: number(),
|
|
24456
|
-
className: string().optional()
|
|
24457
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
24458
|
-
deviceId: number(),
|
|
24459
|
-
className: string().optional()
|
|
24460
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
|
|
24461
|
-
/**
|
|
24462
|
-
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
24463
|
-
* cap so a single CRUD surface backs every consumer; each stage has
|
|
24464
|
-
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
24465
|
-
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
24466
|
-
*
|
|
24467
|
-
* Extend the enum here when a new gating consumer comes online (audio
|
|
24468
|
-
* gating, alert filtering, …) — no other surface needs to change.
|
|
24469
|
-
*/
|
|
24470
|
-
var ZoneRuleStageEnum = _enum([
|
|
24471
|
-
"motion",
|
|
24472
|
-
"detection",
|
|
24473
|
-
"package"
|
|
24474
|
-
]);
|
|
25215
|
+
access: "create"
|
|
25216
|
+
}), method(object({
|
|
25217
|
+
code: string(),
|
|
25218
|
+
redirectUri: string(),
|
|
25219
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
25220
|
+
codeVerifier: string().optional()
|
|
25221
|
+
}), object({
|
|
25222
|
+
accessToken: string(),
|
|
25223
|
+
refreshToken: string(),
|
|
25224
|
+
expiresIn: number()
|
|
25225
|
+
}).nullable(), {
|
|
25226
|
+
kind: "mutation",
|
|
25227
|
+
access: "view"
|
|
25228
|
+
}), method(object({ refreshToken: string() }), object({
|
|
25229
|
+
accessToken: string(),
|
|
25230
|
+
refreshToken: string(),
|
|
25231
|
+
expiresIn: number()
|
|
25232
|
+
}).nullable(), {
|
|
25233
|
+
kind: "mutation",
|
|
25234
|
+
access: "view"
|
|
25235
|
+
}), method(object({ token: string() }), object({
|
|
25236
|
+
userId: string(),
|
|
25237
|
+
username: string(),
|
|
25238
|
+
scopes: array(TokenScopeSchema)
|
|
25239
|
+
}).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
25240
|
+
kind: "mutation",
|
|
25241
|
+
auth: "admin",
|
|
25242
|
+
access: "delete"
|
|
25243
|
+
});
|
|
24475
25244
|
/**
|
|
24476
|
-
*
|
|
24477
|
-
*
|
|
24478
|
-
*
|
|
24479
|
-
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
24480
|
-
* so the operator has a single hub-side source of truth for both
|
|
24481
|
-
* geometry and behaviour. Per-stage rules are stored under the
|
|
24482
|
-
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
24483
|
-
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
24484
|
-
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
24485
|
-
* subscribe to that slice and refresh their gating without
|
|
24486
|
-
* round-tripping the cap.
|
|
25245
|
+
* Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
|
|
25246
|
+
* cleaning lifecycle plus a return-to-base / locate surface and an
|
|
25247
|
+
* optional fan-speed selector.
|
|
24487
25248
|
*
|
|
24488
|
-
*
|
|
24489
|
-
*
|
|
24490
|
-
*
|
|
24491
|
-
|
|
24492
|
-
|
|
24493
|
-
|
|
24494
|
-
scope: "device",
|
|
24495
|
-
mode: "singleton",
|
|
24496
|
-
deviceTypes: [DeviceType.Camera],
|
|
24497
|
-
methods: {
|
|
24498
|
-
/** Read the full rule list for a given stage (empty when no rules
|
|
24499
|
-
* are defined yet). */
|
|
24500
|
-
listRules: method(object({
|
|
24501
|
-
deviceId: number(),
|
|
24502
|
-
stage: ZoneRuleStageEnum
|
|
24503
|
-
}), array(ZoneRuleSchema).readonly()),
|
|
24504
|
-
/** Bulk-replace the rule list for one stage. The provider validates
|
|
24505
|
-
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
24506
|
-
* thresholds in range) and rejects the whole patch if any entry
|
|
24507
|
-
* is invalid — partial writes are a configuration footgun. */
|
|
24508
|
-
setRules: method(object({
|
|
24509
|
-
deviceId: number(),
|
|
24510
|
-
stage: ZoneRuleStageEnum,
|
|
24511
|
-
rules: array(ZoneRuleSchema).readonly()
|
|
24512
|
-
}), _void(), {
|
|
24513
|
-
kind: "mutation",
|
|
24514
|
-
auth: "admin"
|
|
24515
|
-
})
|
|
24516
|
-
},
|
|
24517
|
-
/**
|
|
24518
|
-
* Runtime-state slice — every stage mirrored together so consumers
|
|
24519
|
-
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
24520
|
-
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
24521
|
-
* `{motion, detection, package}` shape, so subscribers always get the
|
|
24522
|
-
* complete current set. Consumers that only care about one stage
|
|
24523
|
-
* just read the matching property.
|
|
24524
|
-
*
|
|
24525
|
-
* `package` backs the package-drop detector — a package zone is a
|
|
24526
|
-
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
24527
|
-
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
24528
|
-
* The orchestrator provider writes this stage as a first-class slice
|
|
24529
|
-
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
24530
|
-
* package}` shape, so consumers read the current package rules directly
|
|
24531
|
-
* off `device.state.zoneRules.value.package`.
|
|
24532
|
-
*/
|
|
24533
|
-
runtimeState: object({
|
|
24534
|
-
motion: array(ZoneRuleSchema).readonly(),
|
|
24535
|
-
detection: array(ZoneRuleSchema).readonly(),
|
|
24536
|
-
package: array(ZoneRuleSchema).readonly()
|
|
24537
|
-
})
|
|
24538
|
-
};
|
|
24539
|
-
/**
|
|
24540
|
-
* Accessory device helpers — shared across drivers.
|
|
25249
|
+
* State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
|
|
25250
|
+
* `paused` / `returning` / `docked` / `error`. `batteryLevel`
|
|
25251
|
+
* (0..100) is nullable — some vacuums don't report a battery
|
|
25252
|
+
* percentage. `fanSpeed` is the current speed token (provider-verbatim,
|
|
25253
|
+
* e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
|
|
25254
|
+
* tokens the hardware accepts so the UI renders only supported choices.
|
|
24541
25255
|
*
|
|
24542
|
-
*
|
|
24543
|
-
*
|
|
24544
|
-
*
|
|
24545
|
-
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
24546
|
-
* spawning, builds a name derived from the parent, and produces a
|
|
24547
|
-
* stableId tied to the parent so boot-restore can reconstruct the
|
|
24548
|
-
* relationship.
|
|
25256
|
+
* The `setFanSpeed` method takes the bare `speed` token — the provider
|
|
25257
|
+
* validates it against the vacuum's own list. `locate` triggers the
|
|
25258
|
+
* find-me chirp; `returnToBase` sends it home.
|
|
24549
25259
|
*
|
|
24550
|
-
*
|
|
24551
|
-
*
|
|
24552
|
-
*
|
|
24553
|
-
*
|
|
24554
|
-
*
|
|
24555
|
-
*
|
|
24556
|
-
*
|
|
24557
|
-
|
|
24558
|
-
/**
|
|
24559
|
-
* Subset of `DeviceRole` values that drivers register as child
|
|
24560
|
-
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
24561
|
-
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
24562
|
-
* children, so the call site reads as
|
|
24563
|
-
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
24564
|
-
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
24565
|
-
* any role works, including non-accessory ones like Doorbell).
|
|
24566
|
-
*/
|
|
24567
|
-
var AccessoryKind = {
|
|
24568
|
-
Siren: DeviceRole.Siren,
|
|
24569
|
-
Floodlight: DeviceRole.Floodlight,
|
|
24570
|
-
Spotlight: DeviceRole.Spotlight,
|
|
24571
|
-
PirSensor: DeviceRole.PirSensor,
|
|
24572
|
-
Chime: DeviceRole.Chime,
|
|
24573
|
-
Autotrack: DeviceRole.Autotrack,
|
|
24574
|
-
Nightvision: DeviceRole.Nightvision,
|
|
24575
|
-
PrivacyMask: DeviceRole.PrivacyMask
|
|
24576
|
-
};
|
|
24577
|
-
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
24578
|
-
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
|
|
24579
|
-
new Set(Object.values(DeviceType));
|
|
24580
|
-
/**
|
|
24581
|
-
* Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
|
|
24582
|
-
*/
|
|
24583
|
-
function deviceMatchesProfile(features, profile) {
|
|
24584
|
-
if (!features || features.length === 0) return false;
|
|
24585
|
-
return features.includes(profile.when.hasFeature);
|
|
24586
|
-
}
|
|
24587
|
-
/**
|
|
24588
|
-
* Profile registry — order matters when multiple profiles match the
|
|
24589
|
-
* same device (first match wins). Today there's only one entry.
|
|
24590
|
-
*/
|
|
24591
|
-
var DEVICE_PROFILES = [{
|
|
24592
|
-
id: "battery",
|
|
24593
|
-
label: "Battery-operated camera",
|
|
24594
|
-
when: { hasFeature: DeviceFeature.BatteryOperated },
|
|
24595
|
-
defaults: {
|
|
24596
|
-
audioMode: "disabled",
|
|
24597
|
-
detectionMode: "on-motion"
|
|
24598
|
-
},
|
|
24599
|
-
settings: {}
|
|
24600
|
-
}];
|
|
24601
|
-
/**
|
|
24602
|
-
* Resolve the profile that matches a device's features, or `null` when
|
|
24603
|
-
* no profile matches. First-match-wins.
|
|
25260
|
+
* Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
|
|
25261
|
+
* `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
|
|
25262
|
+
* 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
|
|
25263
|
+
* SHOULD populate whichever tanks the hardware has — leave a field `null`
|
|
25264
|
+
* only when the device has no such tank at all, and use a `TankStatus`
|
|
25265
|
+
* with both inner fields `null` when the tank exists but its level is
|
|
25266
|
+
* currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
|
|
25267
|
+
* so the HA provider leaves all four `null`.
|
|
24604
25268
|
*/
|
|
24605
|
-
|
|
24606
|
-
|
|
24607
|
-
|
|
24608
|
-
|
|
25269
|
+
var VacuumStateSchema = _enum([
|
|
25270
|
+
"idle",
|
|
25271
|
+
"cleaning",
|
|
25272
|
+
"paused",
|
|
25273
|
+
"returning",
|
|
25274
|
+
"docked",
|
|
25275
|
+
"drying",
|
|
25276
|
+
"error"
|
|
25277
|
+
]);
|
|
24609
25278
|
/**
|
|
24610
|
-
*
|
|
24611
|
-
*
|
|
24612
|
-
*
|
|
25279
|
+
* One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
|
|
25280
|
+
* detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
|
|
25281
|
+
* a discrete `status` (binary-style hardware), or both. Both `null` means the
|
|
25282
|
+
* level is currently unknown; the OWNING field being `null` means the
|
|
25283
|
+
* hardware has no such tank at all.
|
|
24613
25284
|
*/
|
|
24614
|
-
|
|
24615
|
-
|
|
24616
|
-
|
|
24617
|
-
|
|
24618
|
-
|
|
24619
|
-
|
|
24620
|
-
|
|
24621
|
-
|
|
24622
|
-
|
|
24623
|
-
};
|
|
24624
|
-
|
|
24625
|
-
|
|
24626
|
-
|
|
24627
|
-
|
|
24628
|
-
|
|
24629
|
-
|
|
24630
|
-
|
|
24631
|
-
|
|
25285
|
+
var TankStatusSchema = object({
|
|
25286
|
+
/** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
|
|
25287
|
+
level: number().min(0).max(100).nullable(),
|
|
25288
|
+
/** Discrete state when the hardware is binary-mode; null otherwise. */
|
|
25289
|
+
status: _enum([
|
|
25290
|
+
"ok",
|
|
25291
|
+
"low",
|
|
25292
|
+
"full"
|
|
25293
|
+
]).nullable()
|
|
25294
|
+
});
|
|
25295
|
+
object({
|
|
25296
|
+
/** Lifecycle state of the vacuum. */
|
|
25297
|
+
state: VacuumStateSchema,
|
|
25298
|
+
/** 0..100 battery percentage. Null when the device has no battery
|
|
25299
|
+
* reading. */
|
|
25300
|
+
batteryLevel: number().min(0).max(100).nullable(),
|
|
25301
|
+
/** Current fan-speed token (provider-verbatim). Null when unknown or
|
|
25302
|
+
* the vacuum has no speed control. */
|
|
25303
|
+
fanSpeed: string().nullable(),
|
|
25304
|
+
/** Speed tokens the hardware accepts — drives the UI selector. */
|
|
25305
|
+
availableFanSpeeds: array(string()),
|
|
25306
|
+
/** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
|
|
25307
|
+
cleanWater: TankStatusSchema.nullable(),
|
|
25308
|
+
/** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
|
|
25309
|
+
dirtyWater: TankStatusSchema.nullable(),
|
|
25310
|
+
/** Detergent tank. Null when the hardware has no detergent tank. */
|
|
25311
|
+
detergent: TankStatusSchema.nullable(),
|
|
25312
|
+
/** Dust bin. Null when the hardware has no dust bin. */
|
|
25313
|
+
dustBin: TankStatusSchema.nullable(),
|
|
25314
|
+
/** 0..100 cleaning-completion percentage of the current task, or null. */
|
|
25315
|
+
progressPercent: number().min(0).max(100).nullable(),
|
|
25316
|
+
/** Current error code (0 / null = no error). */
|
|
25317
|
+
errorCode: number().nullable(),
|
|
25318
|
+
/** Human label for {@link errorCode}, or null when none / undecodable. */
|
|
25319
|
+
errorLabel: string().nullable(),
|
|
25320
|
+
/** Ms epoch when the slice was last updated. */
|
|
25321
|
+
lastChangedAt: number()
|
|
25322
|
+
});
|
|
25323
|
+
DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25324
|
+
kind: "mutation",
|
|
25325
|
+
auth: "admin"
|
|
25326
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25327
|
+
kind: "mutation",
|
|
25328
|
+
auth: "admin"
|
|
25329
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25330
|
+
kind: "mutation",
|
|
25331
|
+
auth: "admin"
|
|
25332
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25333
|
+
kind: "mutation",
|
|
25334
|
+
auth: "admin"
|
|
25335
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25336
|
+
kind: "mutation",
|
|
25337
|
+
auth: "admin"
|
|
25338
|
+
}), method(object({
|
|
25339
|
+
deviceId: number().int().nonnegative(),
|
|
25340
|
+
speed: string().min(1)
|
|
25341
|
+
}), _void(), {
|
|
25342
|
+
kind: "mutation",
|
|
25343
|
+
auth: "admin"
|
|
25344
|
+
});
|
|
25345
|
+
object({
|
|
25346
|
+
/** Lifecycle state of the valve. */
|
|
25347
|
+
state: _enum([
|
|
25348
|
+
"open",
|
|
25349
|
+
"opening",
|
|
25350
|
+
"closing",
|
|
25351
|
+
"closed",
|
|
25352
|
+
"stopped"
|
|
25353
|
+
]),
|
|
25354
|
+
/** 0 = fully closed, 100 = fully open. Null when the device has no
|
|
25355
|
+
* intermediate position surface. */
|
|
25356
|
+
position: number().min(0).max(100).nullable(),
|
|
25357
|
+
/** Ms epoch when the slice was last updated. */
|
|
25358
|
+
lastChangedAt: number()
|
|
25359
|
+
});
|
|
25360
|
+
DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25361
|
+
kind: "mutation",
|
|
25362
|
+
auth: "admin"
|
|
25363
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25364
|
+
kind: "mutation",
|
|
25365
|
+
auth: "admin"
|
|
25366
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25367
|
+
kind: "mutation",
|
|
25368
|
+
auth: "admin"
|
|
25369
|
+
}), method(object({
|
|
25370
|
+
deviceId: number().int().nonnegative(),
|
|
25371
|
+
position: number().min(0).max(100)
|
|
25372
|
+
}), _void(), {
|
|
25373
|
+
kind: "mutation",
|
|
25374
|
+
auth: "admin"
|
|
25375
|
+
});
|
|
25376
|
+
object({
|
|
25377
|
+
detected: boolean(),
|
|
25378
|
+
/** Ms epoch of the last transition. 0 if never observed. */
|
|
25379
|
+
lastChangedAt: number()
|
|
25380
|
+
});
|
|
25381
|
+
DeviceType.Sensor;
|
|
25382
|
+
object({
|
|
25383
|
+
/** Current measured temperature. Null when not reported. */
|
|
25384
|
+
currentTemp: number().nullable(),
|
|
25385
|
+
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
25386
|
+
targetTemp: number().nullable(),
|
|
25387
|
+
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
25388
|
+
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
25389
|
+
* device reports an unknown state. */
|
|
25390
|
+
operationMode: string().nullable(),
|
|
25391
|
+
/** Available operation modes = HA `operation_list`. */
|
|
25392
|
+
availableModes: array(string()),
|
|
25393
|
+
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
25394
|
+
* has no away surface. */
|
|
25395
|
+
away: boolean().nullable(),
|
|
25396
|
+
/** HA `min_temp` attribute. Null when not reported. */
|
|
25397
|
+
minTemp: number().nullable(),
|
|
25398
|
+
/** HA `max_temp` attribute. Null when not reported. */
|
|
25399
|
+
maxTemp: number().nullable(),
|
|
25400
|
+
/** Ms epoch when the slice was last updated. */
|
|
25401
|
+
lastChangedAt: number()
|
|
25402
|
+
});
|
|
25403
|
+
DeviceType.WaterHeater, method(object({
|
|
25404
|
+
deviceId: number().int().nonnegative(),
|
|
25405
|
+
temp: number().finite()
|
|
25406
|
+
}), _void(), {
|
|
25407
|
+
kind: "mutation",
|
|
25408
|
+
auth: "admin"
|
|
25409
|
+
}), method(object({
|
|
25410
|
+
deviceId: number().int().nonnegative(),
|
|
25411
|
+
mode: string().min(1)
|
|
25412
|
+
}), _void(), {
|
|
25413
|
+
kind: "mutation",
|
|
25414
|
+
auth: "admin"
|
|
25415
|
+
}), method(object({
|
|
25416
|
+
deviceId: number().int().nonnegative(),
|
|
25417
|
+
on: boolean()
|
|
25418
|
+
}), _void(), {
|
|
25419
|
+
kind: "mutation",
|
|
25420
|
+
auth: "admin"
|
|
25421
|
+
});
|
|
25422
|
+
object({
|
|
25423
|
+
/** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
|
|
25424
|
+
* when no condition has been reported yet. */
|
|
25425
|
+
condition: string().nullable(),
|
|
25426
|
+
/** Current temperature in the reported unit. Null when not provided. */
|
|
25427
|
+
temperature: number().nullable(),
|
|
25428
|
+
/** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
|
|
25429
|
+
temperatureUnit: string().nullable(),
|
|
25430
|
+
/** Relative humidity (0..100). Null when not provided. */
|
|
25431
|
+
humidity: number().min(0).max(100).nullable(),
|
|
25432
|
+
/** Barometric pressure in the reported unit. Null when not provided. */
|
|
25433
|
+
pressure: number().nullable(),
|
|
25434
|
+
/** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
|
|
25435
|
+
pressureUnit: string().nullable(),
|
|
25436
|
+
/** Wind speed in the reported unit. Null when not provided. */
|
|
25437
|
+
windSpeed: number().nullable(),
|
|
25438
|
+
/** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
|
|
25439
|
+
windSpeedUnit: string().nullable(),
|
|
25440
|
+
/** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
|
|
25441
|
+
windBearing: number().nullable(),
|
|
25442
|
+
/** Ms epoch when the slice was last updated. */
|
|
25443
|
+
lastFetchedAt: number()
|
|
25444
|
+
});
|
|
25445
|
+
DeviceType.Weather;
|
|
24632
25446
|
/**
|
|
24633
|
-
*
|
|
24634
|
-
*
|
|
24635
|
-
*
|
|
24636
|
-
*
|
|
24637
|
-
*
|
|
24638
|
-
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
24639
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
24640
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
24641
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
24642
|
-
* callable — they are simply "unknown function" at parse time.
|
|
25447
|
+
* Per-zone occupancy aggregation produced by the analytics frame
|
|
25448
|
+
* processor on every inference result. Covers the full combinatorial
|
|
25449
|
+
* matrix the operator UI needs: total objects everywhere, total
|
|
25450
|
+
* objects per zone, single class everywhere, single class per zone,
|
|
25451
|
+
* objects outside any zone.
|
|
24643
25452
|
*
|
|
24644
|
-
*
|
|
24645
|
-
*
|
|
24646
|
-
*
|
|
24647
|
-
*
|
|
24648
|
-
*/
|
|
24649
|
-
function asFiniteNumber(value, name, index) {
|
|
24650
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
24651
|
-
return value;
|
|
24652
|
-
}
|
|
24653
|
-
function asString$1(value, name, index) {
|
|
24654
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
24655
|
-
return value;
|
|
24656
|
-
}
|
|
24657
|
-
function finiteResult(value, name) {
|
|
24658
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
24659
|
-
return value;
|
|
24660
|
-
}
|
|
24661
|
-
function allFiniteNumbers(args, name) {
|
|
24662
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
24663
|
-
}
|
|
24664
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
24665
|
-
var table = {
|
|
24666
|
-
min: {
|
|
24667
|
-
minArgs: 1,
|
|
24668
|
-
maxArgs: INF,
|
|
24669
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
24670
|
-
},
|
|
24671
|
-
max: {
|
|
24672
|
-
minArgs: 1,
|
|
24673
|
-
maxArgs: INF,
|
|
24674
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
24675
|
-
},
|
|
24676
|
-
abs: {
|
|
24677
|
-
minArgs: 1,
|
|
24678
|
-
maxArgs: 1,
|
|
24679
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
24680
|
-
},
|
|
24681
|
-
floor: {
|
|
24682
|
-
minArgs: 1,
|
|
24683
|
-
maxArgs: 1,
|
|
24684
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
24685
|
-
},
|
|
24686
|
-
ceil: {
|
|
24687
|
-
minArgs: 1,
|
|
24688
|
-
maxArgs: 1,
|
|
24689
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
24690
|
-
},
|
|
24691
|
-
sqrt: {
|
|
24692
|
-
minArgs: 1,
|
|
24693
|
-
maxArgs: 1,
|
|
24694
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
24695
|
-
},
|
|
24696
|
-
round: {
|
|
24697
|
-
minArgs: 1,
|
|
24698
|
-
maxArgs: 2,
|
|
24699
|
-
apply: (args) => {
|
|
24700
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
24701
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
24702
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
24703
|
-
const factor = 10 ** digits;
|
|
24704
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
24705
|
-
}
|
|
24706
|
-
},
|
|
24707
|
-
pow: {
|
|
24708
|
-
minArgs: 2,
|
|
24709
|
-
maxArgs: 2,
|
|
24710
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
24711
|
-
},
|
|
24712
|
-
clamp: {
|
|
24713
|
-
minArgs: 3,
|
|
24714
|
-
maxArgs: 3,
|
|
24715
|
-
apply: (args) => {
|
|
24716
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
24717
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
24718
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
24719
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
24720
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
24721
|
-
}
|
|
24722
|
-
},
|
|
24723
|
-
avg: {
|
|
24724
|
-
minArgs: 1,
|
|
24725
|
-
maxArgs: INF,
|
|
24726
|
-
apply: (args) => {
|
|
24727
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
24728
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
24729
|
-
}
|
|
24730
|
-
},
|
|
24731
|
-
sum: {
|
|
24732
|
-
minArgs: 1,
|
|
24733
|
-
maxArgs: INF,
|
|
24734
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
24735
|
-
},
|
|
24736
|
-
coalesce: {
|
|
24737
|
-
minArgs: 1,
|
|
24738
|
-
maxArgs: INF,
|
|
24739
|
-
apply: (args) => {
|
|
24740
|
-
for (const a of args) if (a !== null) return a;
|
|
24741
|
-
return null;
|
|
24742
|
-
}
|
|
24743
|
-
},
|
|
24744
|
-
age: {
|
|
24745
|
-
minArgs: 2,
|
|
24746
|
-
maxArgs: 2,
|
|
24747
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
24748
|
-
},
|
|
24749
|
-
convert: {
|
|
24750
|
-
minArgs: 3,
|
|
24751
|
-
maxArgs: 3,
|
|
24752
|
-
apply: (args, hooks) => {
|
|
24753
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
24754
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
24755
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
24756
|
-
if (hooks.convert) {
|
|
24757
|
-
const out = hooks.convert(x, from, to);
|
|
24758
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
24759
|
-
return finiteResult(out, "convert");
|
|
24760
|
-
}
|
|
24761
|
-
if (from === to) return x;
|
|
24762
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
24763
|
-
}
|
|
24764
|
-
}
|
|
24765
|
-
};
|
|
24766
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
24767
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
24768
|
-
* callees at parse time (immediate author feedback). */
|
|
24769
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
24770
|
-
/**
|
|
24771
|
-
* Resource-bound constants for the safe expression engine.
|
|
25453
|
+
* Counts are derived from the analytics tracker (tracked detections
|
|
25454
|
+
* with stable trackIds), not raw detector hits — this filters out
|
|
25455
|
+
* one-off detector flickers and gives counts that match what the user
|
|
25456
|
+
* sees on the live overlay.
|
|
24772
25457
|
*
|
|
24773
|
-
*
|
|
24774
|
-
*
|
|
24775
|
-
*
|
|
24776
|
-
*
|
|
24777
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
25458
|
+
* Overlap policy: a detection that intersects two zones counts in
|
|
25459
|
+
* BOTH zones' `byClass` and `totalObjects` (count-in-each). The
|
|
25460
|
+
* `frame` aggregate de-duplicates trivially since it's frame-wide.
|
|
25461
|
+
* `unzoned` counts only detections that landed in zero zones.
|
|
24778
25462
|
*/
|
|
24779
|
-
|
|
24780
|
-
|
|
24781
|
-
|
|
24782
|
-
/**
|
|
24783
|
-
|
|
24784
|
-
|
|
24785
|
-
|
|
24786
|
-
|
|
24787
|
-
|
|
24788
|
-
|
|
24789
|
-
|
|
24790
|
-
|
|
24791
|
-
|
|
25463
|
+
var PerScopeBreakdownSchema = object({
|
|
25464
|
+
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
25465
|
+
totalObjects: number().int().nonnegative(),
|
|
25466
|
+
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
25467
|
+
byClass: record(string(), number().int().nonnegative())
|
|
25468
|
+
});
|
|
25469
|
+
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
25470
|
+
zoneId: string(),
|
|
25471
|
+
zoneName: string(),
|
|
25472
|
+
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
25473
|
+
* with the per-track detail panel and live overlay. */
|
|
25474
|
+
trackIds: array(string()).readonly()
|
|
25475
|
+
});
|
|
24792
25476
|
/**
|
|
24793
|
-
*
|
|
24794
|
-
*
|
|
24795
|
-
*
|
|
24796
|
-
*
|
|
24797
|
-
*
|
|
24798
|
-
*
|
|
24799
|
-
* template literals are lexically impossible.
|
|
25477
|
+
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
25478
|
+
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
25479
|
+
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
25480
|
+
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
25481
|
+
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
25482
|
+
* of flooding the live event feed.
|
|
24800
25483
|
*/
|
|
24801
|
-
var
|
|
24802
|
-
|
|
24803
|
-
|
|
24804
|
-
|
|
25484
|
+
var StationaryObjectSchema = object({
|
|
25485
|
+
id: string(),
|
|
25486
|
+
className: string(),
|
|
25487
|
+
bbox: object({
|
|
25488
|
+
x: number(),
|
|
25489
|
+
y: number(),
|
|
25490
|
+
w: number(),
|
|
25491
|
+
h: number()
|
|
25492
|
+
}),
|
|
25493
|
+
frameWidth: number().int().nonnegative(),
|
|
25494
|
+
frameHeight: number().int().nonnegative(),
|
|
25495
|
+
/** When the source track was first seen. */
|
|
25496
|
+
firstSeenAt: number().int(),
|
|
25497
|
+
/** When the object was recognised as parked (promotion time). */
|
|
25498
|
+
becameStationaryAt: number().int(),
|
|
25499
|
+
/** Last frame a detection confirmed the object is still there. */
|
|
25500
|
+
lastConfirmedAt: number().int(),
|
|
25501
|
+
/** Enrichment label carried from the source track (identity / plate). */
|
|
25502
|
+
label: string().optional(),
|
|
25503
|
+
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
25504
|
+
keyFrameMediaKey: string().optional()
|
|
25505
|
+
});
|
|
25506
|
+
var CameraOccupancySnapshotSchema = object({
|
|
25507
|
+
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
25508
|
+
ts: number().int(),
|
|
25509
|
+
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
25510
|
+
frameWidth: number().int().nonnegative(),
|
|
25511
|
+
frameHeight: number().int().nonnegative(),
|
|
25512
|
+
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
25513
|
+
zones: array(ZoneScopeBreakdownSchema).readonly(),
|
|
25514
|
+
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
25515
|
+
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
25516
|
+
frame: PerScopeBreakdownSchema,
|
|
25517
|
+
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
25518
|
+
unzoned: PerScopeBreakdownSchema,
|
|
25519
|
+
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
25520
|
+
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
25521
|
+
* than as repeated tracks/events. */
|
|
25522
|
+
stationaryObjects: array(StationaryObjectSchema).readonly().optional()
|
|
25523
|
+
});
|
|
25524
|
+
/**
|
|
25525
|
+
* Time-series resolution. The history methods return one bucket per
|
|
25526
|
+
* step over the requested range. Smaller resolutions cost more
|
|
25527
|
+
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
25528
|
+
* for arbitrary fractional buckets.
|
|
25529
|
+
*/
|
|
25530
|
+
var HistoryResolutionEnum = _enum([
|
|
25531
|
+
"minute",
|
|
25532
|
+
"5min",
|
|
25533
|
+
"hour"
|
|
24805
25534
|
]);
|
|
24806
|
-
|
|
24807
|
-
|
|
24808
|
-
|
|
24809
|
-
|
|
24810
|
-
|
|
24811
|
-
|
|
24812
|
-
|
|
24813
|
-
|
|
24814
|
-
|
|
24815
|
-
|
|
24816
|
-
|
|
24817
|
-
|
|
24818
|
-
|
|
24819
|
-
|
|
24820
|
-
|
|
24821
|
-
|
|
24822
|
-
|
|
24823
|
-
|
|
24824
|
-
|
|
24825
|
-
|
|
24826
|
-
|
|
24827
|
-
|
|
24828
|
-
|
|
24829
|
-
|
|
24830
|
-
continue;
|
|
24831
|
-
}
|
|
24832
|
-
if (isDigit(ch)) {
|
|
24833
|
-
const start = i;
|
|
24834
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
24835
|
-
if (i < n && source[i] === ".") {
|
|
24836
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
24837
|
-
i += 1;
|
|
24838
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
24839
|
-
}
|
|
24840
|
-
const text = source.slice(start, i);
|
|
24841
|
-
const value = Number(text);
|
|
24842
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
24843
|
-
tokens.push({
|
|
24844
|
-
type: "number",
|
|
24845
|
-
value,
|
|
24846
|
-
pos: start
|
|
24847
|
-
});
|
|
24848
|
-
continue;
|
|
24849
|
-
}
|
|
24850
|
-
if (ch === "'" || ch === "\"") {
|
|
24851
|
-
const quote = ch;
|
|
24852
|
-
const start = i;
|
|
24853
|
-
i += 1;
|
|
24854
|
-
let out = "";
|
|
24855
|
-
let closed = false;
|
|
24856
|
-
while (i < n) {
|
|
24857
|
-
const c = source[i];
|
|
24858
|
-
if (c === "\\") {
|
|
24859
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
24860
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
24861
|
-
out += next;
|
|
24862
|
-
i += 2;
|
|
24863
|
-
continue;
|
|
24864
|
-
}
|
|
24865
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
24866
|
-
}
|
|
24867
|
-
if (c === quote) {
|
|
24868
|
-
closed = true;
|
|
24869
|
-
i += 1;
|
|
24870
|
-
break;
|
|
24871
|
-
}
|
|
24872
|
-
out += c;
|
|
24873
|
-
i += 1;
|
|
24874
|
-
}
|
|
24875
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
24876
|
-
tokens.push({
|
|
24877
|
-
type: "string",
|
|
24878
|
-
value: out,
|
|
24879
|
-
pos: start
|
|
24880
|
-
});
|
|
24881
|
-
continue;
|
|
24882
|
-
}
|
|
24883
|
-
if (isIdentStart(ch)) {
|
|
24884
|
-
const start = i;
|
|
24885
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
24886
|
-
const text = source.slice(start, i);
|
|
24887
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
24888
|
-
type: "keyword",
|
|
24889
|
-
keyword: keywordOf(text),
|
|
24890
|
-
pos: start
|
|
24891
|
-
});
|
|
24892
|
-
else tokens.push({
|
|
24893
|
-
type: "identifier",
|
|
24894
|
-
name: text,
|
|
24895
|
-
pos: start
|
|
24896
|
-
});
|
|
24897
|
-
continue;
|
|
24898
|
-
}
|
|
24899
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
24900
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
24901
|
-
tokens.push({
|
|
24902
|
-
type: "punct",
|
|
24903
|
-
punct: two,
|
|
24904
|
-
pos: i
|
|
24905
|
-
});
|
|
24906
|
-
i += 2;
|
|
24907
|
-
continue;
|
|
24908
|
-
}
|
|
24909
|
-
if (isSinglePunct(ch)) {
|
|
24910
|
-
tokens.push({
|
|
24911
|
-
type: "punct",
|
|
24912
|
-
punct: ch,
|
|
24913
|
-
pos: i
|
|
24914
|
-
});
|
|
24915
|
-
i += 1;
|
|
24916
|
-
continue;
|
|
24917
|
-
}
|
|
24918
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
24919
|
-
}
|
|
24920
|
-
tokens.push({
|
|
24921
|
-
type: "eof",
|
|
24922
|
-
pos: n
|
|
24923
|
-
});
|
|
24924
|
-
return tokens;
|
|
24925
|
-
}
|
|
24926
|
-
function keywordOf(text) {
|
|
24927
|
-
if (text === "true") return "true";
|
|
24928
|
-
if (text === "false") return "false";
|
|
24929
|
-
return "null";
|
|
24930
|
-
}
|
|
24931
|
-
function isSinglePunct(ch) {
|
|
24932
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
24933
|
-
}
|
|
25535
|
+
var HistoryRangeSchema = object({
|
|
25536
|
+
/** Range start (epoch ms, inclusive). */
|
|
25537
|
+
from: number().int(),
|
|
25538
|
+
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
25539
|
+
to: number().int(),
|
|
25540
|
+
resolution: HistoryResolutionEnum
|
|
25541
|
+
});
|
|
25542
|
+
var HistoryPointSchema = object({
|
|
25543
|
+
/** Bucket midpoint (epoch ms). */
|
|
25544
|
+
ts: number().int(),
|
|
25545
|
+
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
25546
|
+
count: number().int().nonnegative()
|
|
25547
|
+
});
|
|
25548
|
+
DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
|
|
25549
|
+
deviceId: number(),
|
|
25550
|
+
zoneId: string(),
|
|
25551
|
+
className: string().optional()
|
|
25552
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
25553
|
+
deviceId: number(),
|
|
25554
|
+
className: string().optional()
|
|
25555
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
25556
|
+
deviceId: number(),
|
|
25557
|
+
className: string().optional()
|
|
25558
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
|
|
24934
25559
|
/**
|
|
24935
|
-
*
|
|
25560
|
+
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
25561
|
+
* cap so a single CRUD surface backs every consumer; each stage has
|
|
25562
|
+
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
25563
|
+
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
24936
25564
|
*
|
|
24937
|
-
*
|
|
24938
|
-
*
|
|
24939
|
-
|
|
24940
|
-
|
|
24941
|
-
|
|
24942
|
-
|
|
25565
|
+
* Extend the enum here when a new gating consumer comes online (audio
|
|
25566
|
+
* gating, alert filtering, …) — no other surface needs to change.
|
|
25567
|
+
*/
|
|
25568
|
+
var ZoneRuleStageEnum = _enum([
|
|
25569
|
+
"motion",
|
|
25570
|
+
"detection",
|
|
25571
|
+
"package"
|
|
25572
|
+
]);
|
|
25573
|
+
/**
|
|
25574
|
+
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
25575
|
+
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
24943
25576
|
*
|
|
24944
|
-
*
|
|
24945
|
-
*
|
|
25577
|
+
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
25578
|
+
* so the operator has a single hub-side source of truth for both
|
|
25579
|
+
* geometry and behaviour. Per-stage rules are stored under the
|
|
25580
|
+
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
25581
|
+
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
25582
|
+
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
25583
|
+
* subscribe to that slice and refresh their gating without
|
|
25584
|
+
* round-tripping the cap.
|
|
25585
|
+
*
|
|
25586
|
+
* Sets are bulk-replace — the operator UI sends the new rule list
|
|
25587
|
+
* wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
|
|
25588
|
+
* naturally in the rule editor without per-rule mutation chatter.
|
|
24946
25589
|
*/
|
|
24947
|
-
|
|
24948
|
-
|
|
24949
|
-
"
|
|
24950
|
-
"
|
|
24951
|
-
|
|
24952
|
-
|
|
24953
|
-
|
|
24954
|
-
|
|
24955
|
-
|
|
24956
|
-
|
|
24957
|
-
|
|
24958
|
-
|
|
24959
|
-
|
|
24960
|
-
|
|
24961
|
-
|
|
24962
|
-
|
|
24963
|
-
|
|
24964
|
-
|
|
24965
|
-
|
|
24966
|
-
|
|
24967
|
-
|
|
24968
|
-
|
|
24969
|
-
|
|
24970
|
-
|
|
24971
|
-
|
|
24972
|
-
|
|
24973
|
-
|
|
24974
|
-
|
|
24975
|
-
|
|
24976
|
-
|
|
24977
|
-
|
|
24978
|
-
|
|
24979
|
-
|
|
24980
|
-
|
|
24981
|
-
|
|
24982
|
-
|
|
24983
|
-
|
|
24984
|
-
|
|
24985
|
-
|
|
24986
|
-
|
|
24987
|
-
|
|
24988
|
-
|
|
24989
|
-
|
|
24990
|
-
|
|
24991
|
-
|
|
24992
|
-
|
|
24993
|
-
return this.tokens[this.pos++];
|
|
24994
|
-
}
|
|
24995
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
24996
|
-
expectPunct(punct) {
|
|
24997
|
-
const tok = this.peek();
|
|
24998
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
24999
|
-
this.pos += 1;
|
|
25000
|
-
}
|
|
25001
|
-
matchPunct(punct) {
|
|
25002
|
-
const tok = this.peek();
|
|
25003
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
25004
|
-
this.pos += 1;
|
|
25005
|
-
return true;
|
|
25006
|
-
}
|
|
25007
|
-
return false;
|
|
25008
|
-
}
|
|
25009
|
-
countNode() {
|
|
25010
|
-
this.nodeCount += 1;
|
|
25011
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
25012
|
-
}
|
|
25013
|
-
parseTernary() {
|
|
25014
|
-
const test = this.parseBinary(1);
|
|
25015
|
-
if (this.matchPunct("?")) {
|
|
25016
|
-
const consequent = this.parseTernary();
|
|
25017
|
-
this.expectPunct(":");
|
|
25018
|
-
const alternate = this.parseTernary();
|
|
25019
|
-
this.countNode();
|
|
25020
|
-
return {
|
|
25021
|
-
kind: "conditional",
|
|
25022
|
-
test,
|
|
25023
|
-
consequent,
|
|
25024
|
-
alternate
|
|
25025
|
-
};
|
|
25026
|
-
}
|
|
25027
|
-
return test;
|
|
25028
|
-
}
|
|
25029
|
-
parseBinary(minPrec) {
|
|
25030
|
-
let left = this.parseUnary();
|
|
25031
|
-
for (;;) {
|
|
25032
|
-
const tok = this.peek();
|
|
25033
|
-
if (tok.type !== "punct") break;
|
|
25034
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
25035
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
25036
|
-
const op = tok.punct;
|
|
25037
|
-
this.pos += 1;
|
|
25038
|
-
const right = this.parseBinary(prec + 1);
|
|
25039
|
-
this.countNode();
|
|
25040
|
-
if (isLogicalOp(op)) left = {
|
|
25041
|
-
kind: "logical",
|
|
25042
|
-
op,
|
|
25043
|
-
left,
|
|
25044
|
-
right
|
|
25045
|
-
};
|
|
25046
|
-
else if (isBinaryOp(op)) left = {
|
|
25047
|
-
kind: "binary",
|
|
25048
|
-
op,
|
|
25049
|
-
left,
|
|
25050
|
-
right
|
|
25051
|
-
};
|
|
25052
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
25053
|
-
}
|
|
25054
|
-
return left;
|
|
25055
|
-
}
|
|
25056
|
-
parseUnary() {
|
|
25057
|
-
const tok = this.peek();
|
|
25058
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
25059
|
-
const op = tok.punct;
|
|
25060
|
-
this.pos += 1;
|
|
25061
|
-
const operand = this.parseUnary();
|
|
25062
|
-
this.countNode();
|
|
25063
|
-
return {
|
|
25064
|
-
kind: "unary",
|
|
25065
|
-
op,
|
|
25066
|
-
operand
|
|
25067
|
-
};
|
|
25068
|
-
}
|
|
25069
|
-
return this.parsePrimary();
|
|
25070
|
-
}
|
|
25071
|
-
parsePrimary() {
|
|
25072
|
-
const tok = this.next();
|
|
25073
|
-
switch (tok.type) {
|
|
25074
|
-
case "number":
|
|
25075
|
-
this.countNode();
|
|
25076
|
-
return {
|
|
25077
|
-
kind: "literal",
|
|
25078
|
-
value: tok.value
|
|
25079
|
-
};
|
|
25080
|
-
case "string":
|
|
25081
|
-
this.countNode();
|
|
25082
|
-
return {
|
|
25083
|
-
kind: "literal",
|
|
25084
|
-
value: tok.value
|
|
25085
|
-
};
|
|
25086
|
-
case "keyword":
|
|
25087
|
-
this.countNode();
|
|
25088
|
-
return {
|
|
25089
|
-
kind: "literal",
|
|
25090
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
25091
|
-
};
|
|
25092
|
-
case "identifier": {
|
|
25093
|
-
const nextTok = this.peek();
|
|
25094
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
25095
|
-
this.identifiers.add(tok.name);
|
|
25096
|
-
this.countNode();
|
|
25097
|
-
return {
|
|
25098
|
-
kind: "identifier",
|
|
25099
|
-
name: tok.name
|
|
25100
|
-
};
|
|
25101
|
-
}
|
|
25102
|
-
case "punct":
|
|
25103
|
-
if (tok.punct === "(") {
|
|
25104
|
-
const inner = this.parseTernary();
|
|
25105
|
-
this.expectPunct(")");
|
|
25106
|
-
return inner;
|
|
25107
|
-
}
|
|
25108
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
25109
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
25110
|
-
}
|
|
25111
|
-
}
|
|
25112
|
-
parseCall(callee, pos) {
|
|
25113
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
25114
|
-
this.expectPunct("(");
|
|
25115
|
-
const args = [];
|
|
25116
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
25117
|
-
args.push(this.parseTernary());
|
|
25118
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
25119
|
-
if (this.matchPunct(",")) continue;
|
|
25120
|
-
this.expectPunct(")");
|
|
25121
|
-
break;
|
|
25122
|
-
}
|
|
25123
|
-
this.callees.add(callee);
|
|
25124
|
-
this.countNode();
|
|
25125
|
-
return {
|
|
25126
|
-
kind: "call",
|
|
25127
|
-
callee,
|
|
25128
|
-
args
|
|
25129
|
-
};
|
|
25130
|
-
}
|
|
25590
|
+
var zoneRulesCapability = {
|
|
25591
|
+
name: "zone-rules",
|
|
25592
|
+
scope: "device",
|
|
25593
|
+
mode: "singleton",
|
|
25594
|
+
deviceTypes: [DeviceType.Camera],
|
|
25595
|
+
methods: {
|
|
25596
|
+
/** Read the full rule list for a given stage (empty when no rules
|
|
25597
|
+
* are defined yet). */
|
|
25598
|
+
listRules: method(object({
|
|
25599
|
+
deviceId: number(),
|
|
25600
|
+
stage: ZoneRuleStageEnum
|
|
25601
|
+
}), array(ZoneRuleSchema).readonly()),
|
|
25602
|
+
/** Bulk-replace the rule list for one stage. The provider validates
|
|
25603
|
+
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
25604
|
+
* thresholds in range) and rejects the whole patch if any entry
|
|
25605
|
+
* is invalid — partial writes are a configuration footgun. */
|
|
25606
|
+
setRules: method(object({
|
|
25607
|
+
deviceId: number(),
|
|
25608
|
+
stage: ZoneRuleStageEnum,
|
|
25609
|
+
rules: array(ZoneRuleSchema).readonly()
|
|
25610
|
+
}), _void(), {
|
|
25611
|
+
kind: "mutation",
|
|
25612
|
+
auth: "admin"
|
|
25613
|
+
})
|
|
25614
|
+
},
|
|
25615
|
+
/**
|
|
25616
|
+
* Runtime-state slice — every stage mirrored together so consumers
|
|
25617
|
+
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
25618
|
+
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
25619
|
+
* `{motion, detection, package}` shape, so subscribers always get the
|
|
25620
|
+
* complete current set. Consumers that only care about one stage
|
|
25621
|
+
* just read the matching property.
|
|
25622
|
+
*
|
|
25623
|
+
* `package` backs the package-drop detector — a package zone is a
|
|
25624
|
+
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
25625
|
+
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
25626
|
+
* The orchestrator provider writes this stage as a first-class slice
|
|
25627
|
+
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
25628
|
+
* package}` shape, so consumers read the current package rules directly
|
|
25629
|
+
* off `device.state.zoneRules.value.package`.
|
|
25630
|
+
*/
|
|
25631
|
+
runtimeState: object({
|
|
25632
|
+
motion: array(ZoneRuleSchema).readonly(),
|
|
25633
|
+
detection: array(ZoneRuleSchema).readonly(),
|
|
25634
|
+
package: array(ZoneRuleSchema).readonly()
|
|
25635
|
+
})
|
|
25131
25636
|
};
|
|
25132
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
25133
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
25134
|
-
function parseExpression(source) {
|
|
25135
|
-
return new Parser(tokenize(source)).parse();
|
|
25136
|
-
}
|
|
25137
25637
|
/**
|
|
25138
|
-
*
|
|
25139
|
-
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
25140
|
-
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
25141
|
-
* one per read on a hot resolve path.
|
|
25638
|
+
* Accessory device helpers — shared across drivers.
|
|
25142
25639
|
*
|
|
25143
|
-
*
|
|
25144
|
-
*
|
|
25145
|
-
*
|
|
25640
|
+
* Many vendor-specific drivers register accessory child devices on
|
|
25641
|
+
* top of a parent (Reolink: siren / floodlight / PIR / autotrack /
|
|
25642
|
+
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
25643
|
+
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
25644
|
+
* spawning, builds a name derived from the parent, and produces a
|
|
25645
|
+
* stableId tied to the parent so boot-restore can reconstruct the
|
|
25646
|
+
* relationship.
|
|
25647
|
+
*
|
|
25648
|
+
* Centralised `(kind → DeviceType)` mapping was dropped on purpose:
|
|
25649
|
+
* drivers may reasonably disagree on the right type for an accessory
|
|
25650
|
+
* (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
|
|
25651
|
+
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
25652
|
+
* one canonical mapping was over-prescriptive and added a layer of
|
|
25653
|
+
* indirection without saving meaningful code at call sites — the
|
|
25654
|
+
* driver knows its own hardware best.
|
|
25146
25655
|
*/
|
|
25147
|
-
|
|
25148
|
-
|
|
25149
|
-
|
|
25150
|
-
|
|
25151
|
-
|
|
25152
|
-
|
|
25153
|
-
|
|
25154
|
-
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
25174
|
-
|
|
25175
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
25176
|
-
function compileExpressionSafe(source) {
|
|
25177
|
-
return getCached(source);
|
|
25656
|
+
/**
|
|
25657
|
+
* Subset of `DeviceRole` values that drivers register as child
|
|
25658
|
+
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
25659
|
+
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
25660
|
+
* children, so the call site reads as
|
|
25661
|
+
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
25662
|
+
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
25663
|
+
* any role works, including non-accessory ones like Doorbell).
|
|
25664
|
+
*/
|
|
25665
|
+
var AccessoryKind = {
|
|
25666
|
+
Siren: DeviceRole.Siren,
|
|
25667
|
+
Floodlight: DeviceRole.Floodlight,
|
|
25668
|
+
Spotlight: DeviceRole.Spotlight,
|
|
25669
|
+
PirSensor: DeviceRole.PirSensor,
|
|
25670
|
+
Chime: DeviceRole.Chime,
|
|
25671
|
+
Autotrack: DeviceRole.Autotrack,
|
|
25672
|
+
Nightvision: DeviceRole.Nightvision,
|
|
25673
|
+
PrivacyMask: DeviceRole.PrivacyMask
|
|
25674
|
+
};
|
|
25675
|
+
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
25676
|
+
DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
|
|
25677
|
+
new Set(Object.values(DeviceType));
|
|
25678
|
+
/**
|
|
25679
|
+
* Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
|
|
25680
|
+
*/
|
|
25681
|
+
function deviceMatchesProfile(features, profile) {
|
|
25682
|
+
if (!features || features.length === 0) return false;
|
|
25683
|
+
return features.includes(profile.when.hasFeature);
|
|
25178
25684
|
}
|
|
25179
|
-
Object.freeze({});
|
|
25180
25685
|
/**
|
|
25181
|
-
*
|
|
25182
|
-
*
|
|
25183
|
-
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
25184
|
-
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
25185
|
-
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
25686
|
+
* Profile registry — order matters when multiple profiles match the
|
|
25687
|
+
* same device (first match wins). Today there's only one entry.
|
|
25186
25688
|
*/
|
|
25187
|
-
|
|
25188
|
-
|
|
25189
|
-
|
|
25190
|
-
|
|
25191
|
-
|
|
25192
|
-
|
|
25193
|
-
|
|
25194
|
-
}
|
|
25195
|
-
|
|
25196
|
-
|
|
25197
|
-
|
|
25198
|
-
|
|
25199
|
-
|
|
25200
|
-
|
|
25201
|
-
|
|
25689
|
+
var DEVICE_PROFILES = [{
|
|
25690
|
+
id: "battery",
|
|
25691
|
+
label: "Battery-operated camera",
|
|
25692
|
+
when: { hasFeature: DeviceFeature.BatteryOperated },
|
|
25693
|
+
defaults: {
|
|
25694
|
+
audioMode: "disabled",
|
|
25695
|
+
detectionMode: "on-motion"
|
|
25696
|
+
},
|
|
25697
|
+
settings: {}
|
|
25698
|
+
}];
|
|
25699
|
+
/**
|
|
25700
|
+
* Resolve the profile that matches a device's features, or `null` when
|
|
25701
|
+
* no profile matches. First-match-wins.
|
|
25702
|
+
*/
|
|
25703
|
+
function resolveDeviceProfile(features) {
|
|
25704
|
+
for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
|
|
25202
25705
|
return null;
|
|
25203
25706
|
}
|
|
25204
|
-
var ExpressionBindingSourceSchema = union([
|
|
25205
|
-
object({
|
|
25206
|
-
kind: literal("field").optional(),
|
|
25207
|
-
sourceKey: string(),
|
|
25208
|
-
cap: string(),
|
|
25209
|
-
fieldPath: string()
|
|
25210
|
-
}),
|
|
25211
|
-
object({
|
|
25212
|
-
kind: literal("literal"),
|
|
25213
|
-
value: union([
|
|
25214
|
-
string(),
|
|
25215
|
-
number(),
|
|
25216
|
-
boolean(),
|
|
25217
|
-
_null()
|
|
25218
|
-
])
|
|
25219
|
-
}),
|
|
25220
|
-
object({
|
|
25221
|
-
kind: literal("global"),
|
|
25222
|
-
sourceStableId: string(),
|
|
25223
|
-
cap: string(),
|
|
25224
|
-
fieldPath: string()
|
|
25225
|
-
})
|
|
25226
|
-
]);
|
|
25227
|
-
object({
|
|
25228
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
25229
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
25230
|
-
}).superRefine((src, ctx) => {
|
|
25231
|
-
const err = validateExpressionSource(src);
|
|
25232
|
-
if (err !== null) ctx.addIssue({
|
|
25233
|
-
code: "custom",
|
|
25234
|
-
message: err,
|
|
25235
|
-
path: ["expr"]
|
|
25236
|
-
});
|
|
25237
|
-
});
|
|
25238
25707
|
Object.freeze({
|
|
25239
25708
|
"accessories.setChildHidden": {
|
|
25240
25709
|
capName: "accessories",
|
|
@@ -26010,6 +26479,18 @@ Object.freeze({
|
|
|
26010
26479
|
addonId: null,
|
|
26011
26480
|
access: "create"
|
|
26012
26481
|
},
|
|
26482
|
+
"connectionTest.describeTest": {
|
|
26483
|
+
capName: "connection-test",
|
|
26484
|
+
capScope: "system",
|
|
26485
|
+
addonId: null,
|
|
26486
|
+
access: "view"
|
|
26487
|
+
},
|
|
26488
|
+
"connectionTest.testSettings": {
|
|
26489
|
+
capName: "connection-test",
|
|
26490
|
+
capScope: "system",
|
|
26491
|
+
addonId: null,
|
|
26492
|
+
access: "create"
|
|
26493
|
+
},
|
|
26013
26494
|
"consumables.reset": {
|
|
26014
26495
|
capName: "consumables",
|
|
26015
26496
|
capScope: "device",
|
|
@@ -26400,6 +26881,12 @@ Object.freeze({
|
|
|
26400
26881
|
addonId: null,
|
|
26401
26882
|
access: "create"
|
|
26402
26883
|
},
|
|
26884
|
+
"deviceManager.adoptionCancelJob": {
|
|
26885
|
+
capName: "device-manager",
|
|
26886
|
+
capScope: "system",
|
|
26887
|
+
addonId: null,
|
|
26888
|
+
access: "create"
|
|
26889
|
+
},
|
|
26403
26890
|
"deviceManager.adoptionListCandidateFilters": {
|
|
26404
26891
|
capName: "device-manager",
|
|
26405
26892
|
capScope: "system",
|
|
@@ -26412,6 +26899,12 @@ Object.freeze({
|
|
|
26412
26899
|
addonId: null,
|
|
26413
26900
|
access: "view"
|
|
26414
26901
|
},
|
|
26902
|
+
"deviceManager.adoptionListJobs": {
|
|
26903
|
+
capName: "device-manager",
|
|
26904
|
+
capScope: "system",
|
|
26905
|
+
addonId: null,
|
|
26906
|
+
access: "view"
|
|
26907
|
+
},
|
|
26415
26908
|
"deviceManager.adoptionRefresh": {
|
|
26416
26909
|
capName: "device-manager",
|
|
26417
26910
|
capScope: "system",
|
|
@@ -26430,6 +26923,12 @@ Object.freeze({
|
|
|
26430
26923
|
addonId: null,
|
|
26431
26924
|
access: "create"
|
|
26432
26925
|
},
|
|
26926
|
+
"deviceManager.adoptionStartJob": {
|
|
26927
|
+
capName: "device-manager",
|
|
26928
|
+
capScope: "system",
|
|
26929
|
+
addonId: null,
|
|
26930
|
+
access: "create"
|
|
26931
|
+
},
|
|
26433
26932
|
"deviceManager.allocateDeviceId": {
|
|
26434
26933
|
capName: "device-manager",
|
|
26435
26934
|
capScope: "system",
|
|
@@ -29568,6 +30067,12 @@ Object.freeze({
|
|
|
29568
30067
|
addonId: null,
|
|
29569
30068
|
access: "view"
|
|
29570
30069
|
},
|
|
30070
|
+
"snapshot.getSnapshotLinks": {
|
|
30071
|
+
capName: "snapshot",
|
|
30072
|
+
capScope: "device",
|
|
30073
|
+
addonId: null,
|
|
30074
|
+
access: "view"
|
|
30075
|
+
},
|
|
29571
30076
|
"snapshot.getSnapshotOverview": {
|
|
29572
30077
|
capName: "snapshot",
|
|
29573
30078
|
capScope: "device",
|
|
@@ -32933,6 +33438,7 @@ function composeCameraStatus(input) {
|
|
|
32933
33438
|
audio: mapAudio(input.audioResult),
|
|
32934
33439
|
recording: mapRecording(input.recordingResult),
|
|
32935
33440
|
switchedOff: input.switchedOff,
|
|
33441
|
+
degraded: input.degraded,
|
|
32936
33442
|
fetchedAt: input.fetchedAt
|
|
32937
33443
|
};
|
|
32938
33444
|
}
|
|
@@ -32984,24 +33490,74 @@ var AUDIO_SILENCING_SWITCH_IDS = [
|
|
|
32984
33490
|
"broker-audio",
|
|
32985
33491
|
"audio-analysis"
|
|
32986
33492
|
];
|
|
33493
|
+
/**
|
|
33494
|
+
* Sentinel resolved by the per-stage timer. A unique symbol rather than `null`
|
|
33495
|
+
* so the race result can be narrowed WITHOUT a cast — and, more to the point,
|
|
33496
|
+
* so a stage that legitimately resolves `null` is never mistaken for one that
|
|
33497
|
+
* ran out of time.
|
|
33498
|
+
*/
|
|
33499
|
+
var STAGE_TIMED_OUT = Symbol("camera-status-stage-timeout");
|
|
32987
33500
|
var CameraStatusService = class {
|
|
32988
33501
|
deps;
|
|
32989
33502
|
constructor(deps) {
|
|
32990
33503
|
this.deps = deps;
|
|
32991
33504
|
}
|
|
32992
33505
|
/**
|
|
32993
|
-
*
|
|
33506
|
+
* Record — and ANNOUNCE — a stage whose value could not be trusted.
|
|
33507
|
+
*
|
|
33508
|
+
* Both halves matter. The entry is what a client reads to tell a `null` that
|
|
33509
|
+
* means "we could not look" from a `null` that means "there is nothing
|
|
33510
|
+
* there"; the `warn` is what makes the next hang findable at all. The line
|
|
33511
|
+
* carries `tags.deviceId` with the numeric id because the question is always
|
|
33512
|
+
* per-camera ("why is 614 worse than 615?") and a line without the tag
|
|
33513
|
+
* cannot be grouped to answer it.
|
|
33514
|
+
*/
|
|
33515
|
+
recordDegraded(sink, deviceId, stage, reason, elapsedMs, extraMeta = {}) {
|
|
33516
|
+
sink.entries.push({
|
|
33517
|
+
stage,
|
|
33518
|
+
reason,
|
|
33519
|
+
elapsedMs
|
|
33520
|
+
});
|
|
33521
|
+
this.deps.logger.warn("camera status stage could not be read", {
|
|
33522
|
+
tags: { deviceId },
|
|
33523
|
+
meta: {
|
|
33524
|
+
stage,
|
|
33525
|
+
reason,
|
|
33526
|
+
elapsedMs,
|
|
33527
|
+
...extraMeta
|
|
33528
|
+
}
|
|
33529
|
+
});
|
|
33530
|
+
}
|
|
33531
|
+
/**
|
|
33532
|
+
* Races a promise against a timeout. Returns `null` on timeout OR rejection —
|
|
33533
|
+
* but the two are no longer the same event: whichever happened is pushed to
|
|
33534
|
+
* `sink` (and logged) under `stage`, so the `null` that reaches the payload
|
|
33535
|
+
* arrives with its cause attached.
|
|
33536
|
+
*
|
|
32994
33537
|
* Never throws — individual stage failures become `null` in the aggregate.
|
|
32995
33538
|
*
|
|
32996
|
-
* @param p
|
|
32997
|
-
* @param ms
|
|
33539
|
+
* @param p The stage fetch promise.
|
|
33540
|
+
* @param ms Timeout in milliseconds.
|
|
33541
|
+
* @param stage Which stage this is, as it appears in `CameraStatus.degraded`.
|
|
33542
|
+
* @param deviceId The camera — every line about it carries the tag.
|
|
33543
|
+
* @param sink Per-call collector for stages that could not be read.
|
|
32998
33544
|
*/
|
|
32999
|
-
boundedStage(p, ms) {
|
|
33545
|
+
boundedStage(p, ms, stage, deviceId, sink) {
|
|
33546
|
+
const startedAt = Date.now();
|
|
33000
33547
|
let timer;
|
|
33001
33548
|
const timeout = new Promise((resolve) => {
|
|
33002
|
-
timer = setTimeout(() => resolve(
|
|
33549
|
+
timer = setTimeout(() => resolve(STAGE_TIMED_OUT), ms);
|
|
33003
33550
|
});
|
|
33004
|
-
return Promise.race([p, timeout]).
|
|
33551
|
+
return Promise.race([p, timeout]).then((value) => {
|
|
33552
|
+
if (value === STAGE_TIMED_OUT) {
|
|
33553
|
+
this.recordDegraded(sink, deviceId, stage, "timeout", Date.now() - startedAt, { timeoutMs: ms });
|
|
33554
|
+
return null;
|
|
33555
|
+
}
|
|
33556
|
+
return value;
|
|
33557
|
+
}).catch((err) => {
|
|
33558
|
+
this.recordDegraded(sink, deviceId, stage, "error", Date.now() - startedAt, { error: err instanceof Error ? err.message : String(err) });
|
|
33559
|
+
return null;
|
|
33560
|
+
}).finally(() => {
|
|
33005
33561
|
if (timer !== void 0) clearTimeout(timer);
|
|
33006
33562
|
});
|
|
33007
33563
|
}
|
|
@@ -33027,7 +33583,7 @@ var CameraStatusService = class {
|
|
|
33027
33583
|
decoder: false,
|
|
33028
33584
|
audio: audioPinned
|
|
33029
33585
|
},
|
|
33030
|
-
detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : void 0,
|
|
33586
|
+
detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
|
|
33031
33587
|
audioNodeId,
|
|
33032
33588
|
audioPinned
|
|
33033
33589
|
};
|
|
@@ -33038,7 +33594,7 @@ var CameraStatusService = class {
|
|
|
33038
33594
|
* deviceId). `allSlotsFetch` is shared with the broker stage — fetched
|
|
33039
33595
|
* ONCE by the caller — so this stage never issues its own round-trip.
|
|
33040
33596
|
*/
|
|
33041
|
-
buildSourceStage(api, allSlotsFetch, deviceId) {
|
|
33597
|
+
buildSourceStage(api, allSlotsFetch, deviceId, sink) {
|
|
33042
33598
|
if (!api || !allSlotsFetch) return Promise.resolve(null);
|
|
33043
33599
|
return this.boundedStage(allSlotsFetch.then((slots) => {
|
|
33044
33600
|
return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
|
|
@@ -33049,7 +33605,7 @@ var CameraStatusService = class {
|
|
|
33049
33605
|
fps: 0,
|
|
33050
33606
|
kind: s.profile
|
|
33051
33607
|
})) };
|
|
33052
|
-
}), STAGE_TIMEOUT_MS);
|
|
33608
|
+
}), STAGE_TIMEOUT_MS, "source", deviceId, sink);
|
|
33053
33609
|
}
|
|
33054
33610
|
/**
|
|
33055
33611
|
* Broker stage (per-profile slot stats + client counts). Reuses
|
|
@@ -33060,7 +33616,7 @@ var CameraStatusService = class {
|
|
|
33060
33616
|
* broker's actual decode-session node into `liveDecoder` (T6) — the first
|
|
33061
33617
|
* slot that reports one wins.
|
|
33062
33618
|
*/
|
|
33063
|
-
buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder) {
|
|
33619
|
+
buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, sink) {
|
|
33064
33620
|
if (!api || !allSlotsFetch) return Promise.resolve(null);
|
|
33065
33621
|
return this.boundedStage(allSlotsFetch.then(async (slots) => {
|
|
33066
33622
|
const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
|
|
@@ -33097,7 +33653,7 @@ var CameraStatusService = class {
|
|
|
33097
33653
|
return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
|
|
33098
33654
|
}) ?? false
|
|
33099
33655
|
};
|
|
33100
|
-
}), STAGE_TIMEOUT_MS);
|
|
33656
|
+
}), STAGE_TIMEOUT_MS, "broker", deviceId, sink);
|
|
33101
33657
|
}
|
|
33102
33658
|
/**
|
|
33103
33659
|
* Decoder stage. decoder cap methods (listActiveSessions/getInfo/getShmStats)
|
|
@@ -33136,7 +33692,7 @@ var CameraStatusService = class {
|
|
|
33136
33692
|
};
|
|
33137
33693
|
}
|
|
33138
33694
|
/** Detection stage (pipeline-executor + runner metrics). */
|
|
33139
|
-
buildDetectionStage(api, detectionNodeId, deviceId) {
|
|
33695
|
+
buildDetectionStage(api, detectionNodeId, deviceId, sink) {
|
|
33140
33696
|
if (!api || !detectionNodeId) return Promise.resolve(null);
|
|
33141
33697
|
return this.boundedStage(Promise.all([api.pipelineExecutor.getEngineProvisioning.query({ nodeId: detectionNodeId }).catch(() => null), api.pipelineExecutor.getSelectedEngine.query({ nodeId: detectionNodeId }).catch(() => null)]).then(async ([provisioning, engine]) => {
|
|
33142
33698
|
const metrics = await api.pipelineRunner.getCameraMetrics.query({
|
|
@@ -33166,7 +33722,7 @@ var CameraStatusService = class {
|
|
|
33166
33722
|
...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
|
|
33167
33723
|
}
|
|
33168
33724
|
};
|
|
33169
|
-
}), STAGE_TIMEOUT_MS);
|
|
33725
|
+
}), STAGE_TIMEOUT_MS, "detection", deviceId, sink);
|
|
33170
33726
|
}
|
|
33171
33727
|
/**
|
|
33172
33728
|
* Audio stage. `nodeId` is orchestrator-local (the cached assignment);
|
|
@@ -33185,6 +33741,12 @@ var CameraStatusService = class {
|
|
|
33185
33741
|
* analyzer with nothing, and reporting `enabled: true` for the other two
|
|
33186
33742
|
* would reintroduce the exact hardcoded lie the previous paragraph is about
|
|
33187
33743
|
* — one row further down the group.
|
|
33744
|
+
*
|
|
33745
|
+
* `enabled` is a boolean and cannot say "unknown", so when the switch read
|
|
33746
|
+
* was cut short or partial this block is OPTIMISTIC by construction. That is
|
|
33747
|
+
* survivable only because `CameraStatus.degraded` names `'switches'` in
|
|
33748
|
+
* exactly those cases — a surface drawing "audio is running" from this field
|
|
33749
|
+
* must consult it first.
|
|
33188
33750
|
*/
|
|
33189
33751
|
buildAudioStage(audioNodeId, switchedOff) {
|
|
33190
33752
|
if (audioNodeId === null) return null;
|
|
@@ -33199,8 +33761,13 @@ var CameraStatusService = class {
|
|
|
33199
33761
|
* `{ deviceId }` and returns `RecordingStatus`, typed `unknown` in the
|
|
33200
33762
|
* generated router (DEVICE_STATUS_METHOD) — narrowed via
|
|
33201
33763
|
* {@link isRecordingStatus} instead of an unsafe cast.
|
|
33764
|
+
*
|
|
33765
|
+
* There is deliberately NO `.catch(() => null)` on the query any more: it
|
|
33766
|
+
* swallowed the rejection before `boundedStage` could see it, so a recording
|
|
33767
|
+
* source that was refusing every call produced the same anonymous `null` as
|
|
33768
|
+
* a camera that simply records nothing.
|
|
33202
33769
|
*/
|
|
33203
|
-
buildRecordingStage(api, deviceId) {
|
|
33770
|
+
buildRecordingStage(api, deviceId, sink) {
|
|
33204
33771
|
if (!api) return Promise.resolve(null);
|
|
33205
33772
|
return this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
|
|
33206
33773
|
if (!isRecordingStatus(rawStatus)) return null;
|
|
@@ -33209,7 +33776,40 @@ var CameraStatusService = class {
|
|
|
33209
33776
|
active: rawStatus.enabled && rawStatus.activeMode !== "off",
|
|
33210
33777
|
storageBytes: rawStatus.storageBytes
|
|
33211
33778
|
};
|
|
33212
|
-
})
|
|
33779
|
+
}), STAGE_TIMEOUT_MS, "recording", deviceId, sink);
|
|
33780
|
+
}
|
|
33781
|
+
/**
|
|
33782
|
+
* The operator's function switches (D61/D62), bounded like every other stage.
|
|
33783
|
+
*
|
|
33784
|
+
* Three outcomes, and they must not collapse into one:
|
|
33785
|
+
*
|
|
33786
|
+
* - READ — the group came back whole. The derived id list is a total, and an
|
|
33787
|
+
* empty one really does mean the operator turned nothing off.
|
|
33788
|
+
* - PARTIAL — the group came back with at least one switch marked
|
|
33789
|
+
* `source-unreachable`. `CameraSwitchService` bounds each source
|
|
33790
|
+
* individually, so a slow source drops ITS switch and leaves the rest;
|
|
33791
|
+
* the derived list is then a FLOOR. Marked `'partial'`.
|
|
33792
|
+
* - NOT READ — the whole group timed out or rejected. Marked by
|
|
33793
|
+
* `boundedStage` itself.
|
|
33794
|
+
*
|
|
33795
|
+
* In the last two the returned list is `[]`, and `[]` was previously handed
|
|
33796
|
+
* to the payload as the positive claim "the operator has switched nothing
|
|
33797
|
+
* off". Measured on the live hub 2026-08-08: `getCameraStatus(614)` said
|
|
33798
|
+
* `switchedOff: []` at the very moment `getCameraSwitches(614)` reported
|
|
33799
|
+
* `stream-broker` AND `recording` switched off by the operator — the camera
|
|
33800
|
+
* read as BROKEN because it was switched OFF, which is exactly the
|
|
33801
|
+
* inversion D62 exists to forbid. The list stays empty (a guess would put a
|
|
33802
|
+
* permanent "off" badge on a working camera); what changed is that the
|
|
33803
|
+
* emptiness now travels with the reason it is empty.
|
|
33804
|
+
*/
|
|
33805
|
+
buildSwitchStage(deviceId, sink) {
|
|
33806
|
+
const startedAt = Date.now();
|
|
33807
|
+
return this.boundedStage(this.deps.cameraSwitchesFor(deviceId), STAGE_TIMEOUT_MS, "switches", deviceId, sink).then((group) => {
|
|
33808
|
+
if (group === null) return [];
|
|
33809
|
+
const unreadable = group.switches.filter((s) => s.unavailableReason === "source-unreachable");
|
|
33810
|
+
if (unreadable.length > 0) this.recordDegraded(sink, deviceId, "switches", "partial", Date.now() - startedAt, { unreadableSwitches: unreadable.map((s) => s.id) });
|
|
33811
|
+
return switchedOffIds(group.switches);
|
|
33812
|
+
});
|
|
33213
33813
|
}
|
|
33214
33814
|
/**
|
|
33215
33815
|
* Server-composed aggregated status for a single camera.
|
|
@@ -33217,20 +33817,23 @@ var CameraStatusService = class {
|
|
|
33217
33817
|
* Fans out in parallel (bounded, per-stage graceful degradation) to
|
|
33218
33818
|
* broker / decoder / motion / detection / audio / recording source caps
|
|
33219
33819
|
* via `ctx.api`. A stage whose source errors or times out becomes `null`
|
|
33220
|
-
* in the returned payload — one slow agent never breaks the whole call
|
|
33820
|
+
* in the returned payload — one slow agent never breaks the whole call —
|
|
33821
|
+
* and is NAMED in `degraded`, so a consumer can tell that `null` from the
|
|
33822
|
+
* `null` of a camera that legitimately has no such stage.
|
|
33221
33823
|
*/
|
|
33222
33824
|
async getCameraStatus(deviceId) {
|
|
33223
33825
|
const api = this.deps.api();
|
|
33826
|
+
const degradations = { entries: [] };
|
|
33224
33827
|
const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
|
|
33225
33828
|
const liveDecoder = { nodeId: null };
|
|
33226
33829
|
const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
|
|
33227
|
-
const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId);
|
|
33228
|
-
const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder);
|
|
33830
|
+
const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
|
|
33831
|
+
const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, degradations);
|
|
33229
33832
|
const decoderFetch = this.buildDecoderStage(detectionNodeId);
|
|
33230
33833
|
const motionResult = this.buildMotionStage(deviceId);
|
|
33231
|
-
const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId);
|
|
33232
|
-
const recordingFetch = this.buildRecordingStage(api, deviceId);
|
|
33233
|
-
const switchesFetch = this.
|
|
33834
|
+
const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, degradations);
|
|
33835
|
+
const recordingFetch = this.buildRecordingStage(api, deviceId, degradations);
|
|
33836
|
+
const switchesFetch = this.buildSwitchStage(deviceId, degradations);
|
|
33234
33837
|
const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
|
|
33235
33838
|
sourceFetch,
|
|
33236
33839
|
brokerFetch,
|
|
@@ -33263,7 +33866,8 @@ var CameraStatusService = class {
|
|
|
33263
33866
|
detectionResult,
|
|
33264
33867
|
audioResult,
|
|
33265
33868
|
recordingResult,
|
|
33266
|
-
switchedOff
|
|
33869
|
+
switchedOff,
|
|
33870
|
+
degraded: [...degradations.entries]
|
|
33267
33871
|
});
|
|
33268
33872
|
}
|
|
33269
33873
|
/**
|
|
@@ -33331,6 +33935,25 @@ function readPrivacyMaskFacts(v) {
|
|
|
33331
33935
|
enabled: typeof enabled === "boolean" ? enabled : null
|
|
33332
33936
|
};
|
|
33333
33937
|
}
|
|
33938
|
+
/**
|
|
33939
|
+
* How long ONE source of the gather may take before it is treated as
|
|
33940
|
+
* unreachable.
|
|
33941
|
+
*
|
|
33942
|
+
* The gather runs in two sequential stages, so an operator's worst case is
|
|
33943
|
+
* twice this — 2.4s — which has to stay under the 3s `STAGE_TIMEOUT_MS` that
|
|
33944
|
+
* `CameraStatusService` allows the whole switch read. That ordering is the
|
|
33945
|
+
* whole point of the number: if the gather can outlast its caller's budget,
|
|
33946
|
+
* the caller degrades to "nothing switched off" and a camera the operator
|
|
33947
|
+
* DISABLED renders as broken.
|
|
33948
|
+
*
|
|
33949
|
+
* Measured on the live hub, 2026-08-08: seven of the eight sources answered in
|
|
33950
|
+
* 5–15ms on every camera. The eighth, `privacyMask.getStatus`, probes the
|
|
33951
|
+
* camera itself and took 3.1–3.6s on device 614 on every single call (18s
|
|
33952
|
+
* under load) — so a bound generous enough to make the fast sources safe still
|
|
33953
|
+
* has to be willing to drop the slow one, because waiting for it costs the
|
|
33954
|
+
* other six switches.
|
|
33955
|
+
*/
|
|
33956
|
+
var SOURCE_TIMEOUT_MS = 1200;
|
|
33334
33957
|
var CameraSwitchService = class {
|
|
33335
33958
|
deps;
|
|
33336
33959
|
constructor(deps) {
|
|
@@ -33467,11 +34090,16 @@ var CameraSwitchService = class {
|
|
|
33467
34090
|
wrapperAddonIdByCap: /* @__PURE__ */ new Map(),
|
|
33468
34091
|
recordingConfig: null
|
|
33469
34092
|
};
|
|
33470
|
-
const devicePromise = api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
|
|
34093
|
+
const devicePromise = this.bounded(deviceId, "deviceManager.getDevice", api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
|
|
33471
34094
|
this.warn(deviceId, "getDevice", err);
|
|
33472
34095
|
return null;
|
|
33473
|
-
});
|
|
33474
|
-
const
|
|
34096
|
+
}), null);
|
|
34097
|
+
const unknownBindings = {
|
|
34098
|
+
activeWrapperCapNames: null,
|
|
34099
|
+
providerAddonIdByCap: /* @__PURE__ */ new Map(),
|
|
34100
|
+
allCapNames: null
|
|
34101
|
+
};
|
|
34102
|
+
const bindingsPromise = this.bounded(deviceId, "deviceManager.getBindings", api.deviceManager.getBindings.query({ deviceId }).then((b) => {
|
|
33475
34103
|
const active = [];
|
|
33476
34104
|
const all = [];
|
|
33477
34105
|
const providers = /* @__PURE__ */ new Map();
|
|
@@ -33488,24 +34116,20 @@ var CameraSwitchService = class {
|
|
|
33488
34116
|
};
|
|
33489
34117
|
}).catch((err) => {
|
|
33490
34118
|
this.warn(deviceId, "getBindings", err);
|
|
33491
|
-
return
|
|
33492
|
-
|
|
33493
|
-
|
|
33494
|
-
allCapNames: null
|
|
33495
|
-
};
|
|
33496
|
-
});
|
|
33497
|
-
const recordingPromise = api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
|
|
34119
|
+
return unknownBindings;
|
|
34120
|
+
}), unknownBindings);
|
|
34121
|
+
const recordingPromise = this.bounded(deviceId, "recording.getDeviceConfig", api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
|
|
33498
34122
|
this.warn(deviceId, "recording.getDeviceConfig", err);
|
|
33499
34123
|
return null;
|
|
33500
|
-
});
|
|
33501
|
-
const mutesPromise = api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
|
|
34124
|
+
}), null);
|
|
34125
|
+
const mutesPromise = this.bounded(deviceId, "notificationRules.listDeviceMutes", api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
|
|
33502
34126
|
this.warn(deviceId, "notificationRules.listDeviceMutes", err);
|
|
33503
34127
|
return null;
|
|
33504
|
-
});
|
|
33505
|
-
const brokerAudioPromise = api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
|
|
34128
|
+
}), null);
|
|
34129
|
+
const brokerAudioPromise = this.bounded(deviceId, "streamBroker.getDeviceAudioMute", api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
|
|
33506
34130
|
this.warn(deviceId, "streamBroker.getDeviceAudioMute", err);
|
|
33507
34131
|
return null;
|
|
33508
|
-
});
|
|
34132
|
+
}), null);
|
|
33509
34133
|
const [device, bindings, recordingConfig, mutedDeviceIds, brokerAudio] = await Promise.all([
|
|
33510
34134
|
devicePromise,
|
|
33511
34135
|
bindingsPromise,
|
|
@@ -33513,10 +34137,10 @@ var CameraSwitchService = class {
|
|
|
33513
34137
|
mutesPromise,
|
|
33514
34138
|
brokerAudioPromise
|
|
33515
34139
|
]);
|
|
33516
|
-
const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
|
|
34140
|
+
const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : this.bounded(deviceId, "deviceManager.listBindableCapsForDeviceType", api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
|
|
33517
34141
|
this.warn(deviceId, "listBindableCapsForDeviceType", err);
|
|
33518
34142
|
return null;
|
|
33519
|
-
}), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
|
|
34143
|
+
}), null), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
|
|
33520
34144
|
const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
|
|
33521
34145
|
for (const entry of bindable ?? []) {
|
|
33522
34146
|
const first = entry.wrappers[0];
|
|
@@ -33572,13 +34196,13 @@ var CameraSwitchService = class {
|
|
|
33572
34196
|
enabled: null
|
|
33573
34197
|
}
|
|
33574
34198
|
};
|
|
33575
|
-
const [options, status] = await Promise.all([api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
|
|
34199
|
+
const [options, status] = await Promise.all([this.bounded(deviceId, "privacyMask.getOptions", api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
|
|
33576
34200
|
this.warn(deviceId, "privacyMask.getOptions", err);
|
|
33577
34201
|
return null;
|
|
33578
|
-
}), api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
|
|
34202
|
+
}), null), this.bounded(deviceId, "privacyMask.getStatus", api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
|
|
33579
34203
|
this.warn(deviceId, "privacyMask.getStatus", err);
|
|
33580
34204
|
return null;
|
|
33581
|
-
})]);
|
|
34205
|
+
}), null)]);
|
|
33582
34206
|
if (options === null) return {
|
|
33583
34207
|
deviceAudio: null,
|
|
33584
34208
|
privacyMask: null
|
|
@@ -33602,6 +34226,41 @@ var CameraSwitchService = class {
|
|
|
33602
34226
|
}
|
|
33603
34227
|
};
|
|
33604
34228
|
}
|
|
34229
|
+
/**
|
|
34230
|
+
* Bound ONE source. A source that has not answered within
|
|
34231
|
+
* {@link SOURCE_TIMEOUT_MS} yields `fallback` — the same value its `.catch`
|
|
34232
|
+
* yields — so a slow source and a broken one produce the identical group:
|
|
34233
|
+
* that switch is `available: false`, and every other switch is untouched.
|
|
34234
|
+
*
|
|
34235
|
+
* The bound is per SOURCE rather than around the whole gather on purpose.
|
|
34236
|
+
* One deadline over the fan-out would let the slowest camera probe consume
|
|
34237
|
+
* the budget of the seven sources that already answered, which is precisely
|
|
34238
|
+
* how `getCameraStatus` came to report `switchedOff: []` for a camera whose
|
|
34239
|
+
* `stream-broker` and `recording` switches the operator had turned off.
|
|
34240
|
+
*
|
|
34241
|
+
* @param deviceId The camera — every line about it carries the tag.
|
|
34242
|
+
* @param source The cap method, as named in the warn line.
|
|
34243
|
+
* @param p The source's promise. Already `.catch`-ed; never rejects.
|
|
34244
|
+
* @param fallback What the caller reads when the source did not answer.
|
|
34245
|
+
*/
|
|
34246
|
+
bounded(deviceId, source, p, fallback) {
|
|
34247
|
+
let timer;
|
|
34248
|
+
const timeout = new Promise((resolve) => {
|
|
34249
|
+
timer = setTimeout(() => {
|
|
34250
|
+
this.deps.logger.warn("camera switch source TIMED OUT — its switch is not offered", {
|
|
34251
|
+
tags: { deviceId },
|
|
34252
|
+
meta: {
|
|
34253
|
+
source,
|
|
34254
|
+
timeoutMs: SOURCE_TIMEOUT_MS
|
|
34255
|
+
}
|
|
34256
|
+
});
|
|
34257
|
+
resolve(fallback);
|
|
34258
|
+
}, SOURCE_TIMEOUT_MS);
|
|
34259
|
+
});
|
|
34260
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
34261
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
34262
|
+
});
|
|
34263
|
+
}
|
|
33605
34264
|
warn(deviceId, source, err) {
|
|
33606
34265
|
this.deps.logger.warn("camera switch source unreachable — its switch is not offered", {
|
|
33607
34266
|
tags: { deviceId },
|
|
@@ -39318,6 +39977,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
39318
39977
|
});
|
|
39319
39978
|
const cameraStatusService = new CameraStatusService({
|
|
39320
39979
|
api: () => deps.ctx().api,
|
|
39980
|
+
logger: deps.ctx().logger,
|
|
39321
39981
|
getAssignment: (deviceId) => ledger.getAssignment(deviceId),
|
|
39322
39982
|
getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
|
|
39323
39983
|
getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
|
|
@@ -39325,7 +39985,8 @@ async function buildOrchestratorControllers(deps) {
|
|
|
39325
39985
|
getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
|
|
39326
39986
|
assignSource: (deviceId) => topology.assignSource(deviceId),
|
|
39327
39987
|
listAssignedDeviceIds: () => ledger.listAssignedDeviceIds(),
|
|
39328
|
-
|
|
39988
|
+
isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
|
|
39989
|
+
cameraSwitchesFor: (deviceId) => cameraSwitchService.getCameraSwitches(deviceId)
|
|
39329
39990
|
});
|
|
39330
39991
|
const reconcile = new ReconcileController({
|
|
39331
39992
|
api: () => deps.ctx().api ?? null,
|