@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.mjs
CHANGED
|
@@ -7651,6 +7651,104 @@ object({
|
|
|
7651
7651
|
})
|
|
7652
7652
|
});
|
|
7653
7653
|
/**
|
|
7654
|
+
* Adoption job — the background form of `device-adoption.adopt`.
|
|
7655
|
+
*
|
|
7656
|
+
* ## Why this exists
|
|
7657
|
+
*
|
|
7658
|
+
* `adopt({childNativeIds: [...]})` materialises one CamStack device per
|
|
7659
|
+
* candidate PLUS every accessory child, and the whole array shares ONE UDS
|
|
7660
|
+
* request deadline (60s). Measured on the live hub against Home Assistant:
|
|
7661
|
+
* each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
|
|
7662
|
+
* meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
|
|
7663
|
+
* `setRole`, … `persistConfig`) before the class is constructed — and an
|
|
7664
|
+
* accessory child costs the same as its parent. So the real unit of work is
|
|
7665
|
+
* the CHILD, not the candidate:
|
|
7666
|
+
*
|
|
7667
|
+
* - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
|
|
7668
|
+
* - ONE candidate with 217 children → ~217 devices → **>60s, times out**
|
|
7669
|
+
*
|
|
7670
|
+
* That second line is why this is a job and not a smaller batch. No chunking,
|
|
7671
|
+
* no bounded concurrency over candidates and no per-call tuning can fix a
|
|
7672
|
+
* shape where **N=1 already exceeds the deadline** — the count that blows the
|
|
7673
|
+
* budget is the source system's accessory fan-out, which the operator does not
|
|
7674
|
+
* choose and cannot see. A design that only works below some N is the same bug
|
|
7675
|
+
* deferred.
|
|
7676
|
+
*
|
|
7677
|
+
* ## What the timeout did NOT do
|
|
7678
|
+
*
|
|
7679
|
+
* It did not stop the work. The UDS deadline ends the CALLER's wait; the
|
|
7680
|
+
* provider's loop runs to completion. Measured: a 25-candidate adopt that
|
|
7681
|
+
* "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
|
|
7682
|
+
* an error and had no way to learn that. Every field below exists so that
|
|
7683
|
+
* question has an answer.
|
|
7684
|
+
*
|
|
7685
|
+
* ## Idempotency
|
|
7686
|
+
*
|
|
7687
|
+
* Jobs are in-RAM; a restart forgets them. That is safe here because adoption
|
|
7688
|
+
* is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
|
|
7689
|
+
* re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
|
|
7690
|
+
* by the engine before any provider call and lands in `alreadyAdopted`. It is
|
|
7691
|
+
* never a duplicate device, and never an error the operator has to interpret.
|
|
7692
|
+
*/
|
|
7693
|
+
var AdoptionJobStateSchema = _enum([
|
|
7694
|
+
"running",
|
|
7695
|
+
"done",
|
|
7696
|
+
"failed",
|
|
7697
|
+
"cancelled"
|
|
7698
|
+
]);
|
|
7699
|
+
/**
|
|
7700
|
+
* Per-candidate result. Every candidate the job was asked to adopt ends in
|
|
7701
|
+
* exactly one of these buckets — there is no silent drop, and the operator can
|
|
7702
|
+
* always answer "which of my 25 landed?".
|
|
7703
|
+
*
|
|
7704
|
+
* - `adopted` — created now by this job.
|
|
7705
|
+
* - `already-adopted` — a device for this candidate existed before the job
|
|
7706
|
+
* reached it (a re-run, or a retry after a timeout). Not an error.
|
|
7707
|
+
* - `failed` — the provider threw; `error` carries the message.
|
|
7708
|
+
* - `cancelled` — the operator cancelled before this candidate was reached.
|
|
7709
|
+
*/
|
|
7710
|
+
var AdoptionOutcomeSchema = _enum([
|
|
7711
|
+
"adopted",
|
|
7712
|
+
"already-adopted",
|
|
7713
|
+
"failed",
|
|
7714
|
+
"cancelled"
|
|
7715
|
+
]);
|
|
7716
|
+
var AdoptionCandidateResultSchema = object({
|
|
7717
|
+
childNativeId: string(),
|
|
7718
|
+
outcome: AdoptionOutcomeSchema,
|
|
7719
|
+
/** The materialised parent device id — null for `failed` / `cancelled`. */
|
|
7720
|
+
parentDeviceId: number().int().nonnegative().nullable(),
|
|
7721
|
+
/** Accessory children created for this candidate. */
|
|
7722
|
+
accessoryCount: number().int().nonnegative(),
|
|
7723
|
+
/** Failure message; null unless `outcome === 'failed'`. */
|
|
7724
|
+
error: string().nullable()
|
|
7725
|
+
});
|
|
7726
|
+
var AdoptionJobSchema = object({
|
|
7727
|
+
jobId: string(),
|
|
7728
|
+
/** The integration provider this job adopts through (the `addonId` pin). */
|
|
7729
|
+
addonId: string(),
|
|
7730
|
+
integrationId: string(),
|
|
7731
|
+
state: AdoptionJobStateSchema,
|
|
7732
|
+
/** Candidates the job was asked to adopt. Known up front, so never null. */
|
|
7733
|
+
total: number().int().nonnegative(),
|
|
7734
|
+
/** Candidates that have reached a terminal bucket. */
|
|
7735
|
+
processed: number().int().nonnegative(),
|
|
7736
|
+
adopted: number().int().nonnegative(),
|
|
7737
|
+
alreadyAdopted: number().int().nonnegative(),
|
|
7738
|
+
failed: number().int().nonnegative(),
|
|
7739
|
+
/** Accessory child devices created across every candidate — the real unit
|
|
7740
|
+
* of work, surfaced so a slow job is legible rather than mysterious. */
|
|
7741
|
+
accessoriesCreated: number().int().nonnegative(),
|
|
7742
|
+
/** The candidate currently being adopted; null when idle or finished. */
|
|
7743
|
+
currentChildNativeId: string().nullable(),
|
|
7744
|
+
/** One entry per candidate, in the order they were processed. */
|
|
7745
|
+
results: array(AdoptionCandidateResultSchema).readonly(),
|
|
7746
|
+
startedAt: number(),
|
|
7747
|
+
finishedAt: number().nullable(),
|
|
7748
|
+
/** Set only when the job itself broke (not a per-candidate failure). */
|
|
7749
|
+
error: string().nullable()
|
|
7750
|
+
});
|
|
7751
|
+
/**
|
|
7654
7752
|
* Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
|
|
7655
7753
|
* pipeline functions an operator thinks in terms of.
|
|
7656
7754
|
*
|
|
@@ -8733,441 +8831,1215 @@ var ConvertResultSchema = object({
|
|
|
8733
8831
|
})).readonly()
|
|
8734
8832
|
});
|
|
8735
8833
|
/**
|
|
8736
|
-
*
|
|
8737
|
-
*
|
|
8834
|
+
* Error types for the safe expression engine. Two distinct classes so callers
|
|
8835
|
+
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
8836
|
+
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
8837
|
+
*/
|
|
8838
|
+
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
8839
|
+
* the failure is anchored to a character (author-facing inline feedback). */
|
|
8840
|
+
var ExpressionParseError = class extends Error {
|
|
8841
|
+
position;
|
|
8842
|
+
constructor(message, position) {
|
|
8843
|
+
super(message);
|
|
8844
|
+
this.name = "ExpressionParseError";
|
|
8845
|
+
this.position = position;
|
|
8846
|
+
}
|
|
8847
|
+
};
|
|
8848
|
+
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
8849
|
+
* result, unknown builtin, step-budget exceeded). */
|
|
8850
|
+
var ExpressionEvalError = class extends Error {
|
|
8851
|
+
constructor(message) {
|
|
8852
|
+
super(message);
|
|
8853
|
+
this.name = "ExpressionEvalError";
|
|
8854
|
+
}
|
|
8855
|
+
};
|
|
8856
|
+
/**
|
|
8857
|
+
* Frozen, null-prototype builtin function table for the expression engine
|
|
8858
|
+
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
8859
|
+
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
8860
|
+
* own-property check against it.
|
|
8738
8861
|
*
|
|
8739
|
-
*
|
|
8740
|
-
*
|
|
8741
|
-
*
|
|
8742
|
-
*
|
|
8743
|
-
*
|
|
8862
|
+
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
8863
|
+
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
8864
|
+
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
8865
|
+
* (there is no `Object.prototype` in the chain), so those names are not
|
|
8866
|
+
* callable — they are simply "unknown function" at parse time.
|
|
8744
8867
|
*
|
|
8745
|
-
*
|
|
8746
|
-
*
|
|
8747
|
-
*
|
|
8748
|
-
*
|
|
8749
|
-
* `addon-pages.router.ts` wrapper anymore.
|
|
8868
|
+
* Every numeric argument is validated as a finite number and every numeric
|
|
8869
|
+
* RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
|
|
8870
|
+
* (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
|
|
8871
|
+
* closed rather than emitting a garbage value.
|
|
8750
8872
|
*/
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
});
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
}
|
|
8766
|
-
|
|
8873
|
+
function asFiniteNumber(value, name, index) {
|
|
8874
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
8875
|
+
return value;
|
|
8876
|
+
}
|
|
8877
|
+
function asString$1(value, name, index) {
|
|
8878
|
+
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
8879
|
+
return value;
|
|
8880
|
+
}
|
|
8881
|
+
function finiteResult(value, name) {
|
|
8882
|
+
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
8883
|
+
return value;
|
|
8884
|
+
}
|
|
8885
|
+
function allFiniteNumbers(args, name) {
|
|
8886
|
+
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
8887
|
+
}
|
|
8888
|
+
var INF = Number.POSITIVE_INFINITY;
|
|
8889
|
+
var table = {
|
|
8890
|
+
min: {
|
|
8891
|
+
minArgs: 1,
|
|
8892
|
+
maxArgs: INF,
|
|
8893
|
+
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
8894
|
+
},
|
|
8895
|
+
max: {
|
|
8896
|
+
minArgs: 1,
|
|
8897
|
+
maxArgs: INF,
|
|
8898
|
+
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
8899
|
+
},
|
|
8900
|
+
abs: {
|
|
8901
|
+
minArgs: 1,
|
|
8902
|
+
maxArgs: 1,
|
|
8903
|
+
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
8904
|
+
},
|
|
8905
|
+
floor: {
|
|
8906
|
+
minArgs: 1,
|
|
8907
|
+
maxArgs: 1,
|
|
8908
|
+
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
8909
|
+
},
|
|
8910
|
+
ceil: {
|
|
8911
|
+
minArgs: 1,
|
|
8912
|
+
maxArgs: 1,
|
|
8913
|
+
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
8914
|
+
},
|
|
8915
|
+
sqrt: {
|
|
8916
|
+
minArgs: 1,
|
|
8917
|
+
maxArgs: 1,
|
|
8918
|
+
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
8919
|
+
},
|
|
8920
|
+
round: {
|
|
8921
|
+
minArgs: 1,
|
|
8922
|
+
maxArgs: 2,
|
|
8923
|
+
apply: (args) => {
|
|
8924
|
+
const x = asFiniteNumber(args[0], "round", 0);
|
|
8925
|
+
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
8926
|
+
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
8927
|
+
const factor = 10 ** digits;
|
|
8928
|
+
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
8929
|
+
}
|
|
8930
|
+
},
|
|
8931
|
+
pow: {
|
|
8932
|
+
minArgs: 2,
|
|
8933
|
+
maxArgs: 2,
|
|
8934
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
8935
|
+
},
|
|
8936
|
+
clamp: {
|
|
8937
|
+
minArgs: 3,
|
|
8938
|
+
maxArgs: 3,
|
|
8939
|
+
apply: (args) => {
|
|
8940
|
+
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
8941
|
+
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
8942
|
+
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
8943
|
+
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
8944
|
+
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
8945
|
+
}
|
|
8946
|
+
},
|
|
8947
|
+
avg: {
|
|
8948
|
+
minArgs: 1,
|
|
8949
|
+
maxArgs: INF,
|
|
8950
|
+
apply: (args) => {
|
|
8951
|
+
const nums = allFiniteNumbers(args, "avg");
|
|
8952
|
+
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
8953
|
+
}
|
|
8954
|
+
},
|
|
8955
|
+
sum: {
|
|
8956
|
+
minArgs: 1,
|
|
8957
|
+
maxArgs: INF,
|
|
8958
|
+
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
8959
|
+
},
|
|
8960
|
+
coalesce: {
|
|
8961
|
+
minArgs: 1,
|
|
8962
|
+
maxArgs: INF,
|
|
8963
|
+
apply: (args) => {
|
|
8964
|
+
for (const a of args) if (a !== null) return a;
|
|
8965
|
+
return null;
|
|
8966
|
+
}
|
|
8967
|
+
},
|
|
8968
|
+
age: {
|
|
8969
|
+
minArgs: 2,
|
|
8970
|
+
maxArgs: 2,
|
|
8971
|
+
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
8972
|
+
},
|
|
8973
|
+
convert: {
|
|
8974
|
+
minArgs: 3,
|
|
8975
|
+
maxArgs: 3,
|
|
8976
|
+
apply: (args, hooks) => {
|
|
8977
|
+
const x = asFiniteNumber(args[0], "convert", 0);
|
|
8978
|
+
const from = asString$1(args[1], "convert", 1).trim();
|
|
8979
|
+
const to = asString$1(args[2], "convert", 2).trim();
|
|
8980
|
+
if (hooks.convert) {
|
|
8981
|
+
const out = hooks.convert(x, from, to);
|
|
8982
|
+
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
8983
|
+
return finiteResult(out, "convert");
|
|
8984
|
+
}
|
|
8985
|
+
if (from === to) return x;
|
|
8986
|
+
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
8987
|
+
}
|
|
8988
|
+
}
|
|
8989
|
+
};
|
|
8990
|
+
Object.freeze(Object.assign(Object.create(null), table));
|
|
8991
|
+
/** The set of valid builtin names — used by the parser to reject unknown
|
|
8992
|
+
* callees at parse time (immediate author feedback). */
|
|
8993
|
+
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
8767
8994
|
/**
|
|
8768
|
-
*
|
|
8769
|
-
* declarations. Every addon that contributes a UI page registers a
|
|
8770
|
-
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
8771
|
-
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
8772
|
-
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
8773
|
-
* that admin-ui consumes.
|
|
8995
|
+
* Resource-bound constants for the safe expression engine.
|
|
8774
8996
|
*
|
|
8775
|
-
*
|
|
8776
|
-
*
|
|
8777
|
-
*
|
|
8997
|
+
* Every bound is defense-in-depth: the grammar is non-Turing-complete (no
|
|
8998
|
+
* loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
|
|
8999
|
+
* O(nodeCount) by construction. These caps merely put a hard ceiling on the
|
|
9000
|
+
* work a single author-supplied expression can request, so a hostile or
|
|
9001
|
+
* accidental pathological string can never spend unbounded CPU/memory.
|
|
8778
9002
|
*/
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
remoteName: string(),
|
|
8792
|
-
/**
|
|
8793
|
-
* Bundle filename inside the addon's `dist/` dir served at
|
|
8794
|
-
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
8795
|
-
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
8796
|
-
* the static-file route can compute an mtime-based cache-buster URL
|
|
8797
|
-
* without a separate filesystem stat.
|
|
8798
|
-
*/
|
|
8799
|
-
bundle: string(),
|
|
8800
|
-
/**
|
|
8801
|
-
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
8802
|
-
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
8803
|
-
* Any OTHER string creates (or joins) a custom section rendered after
|
|
8804
|
-
* the built-in groups; its label comes from `sectionLabel` (first
|
|
8805
|
-
* declaration wins), falling back to the id. Absent → the legacy
|
|
8806
|
-
* "Addon Pages" group.
|
|
8807
|
-
*/
|
|
8808
|
-
section: string().optional(),
|
|
8809
|
-
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
8810
|
-
sectionLabel: string().optional()
|
|
8811
|
-
});
|
|
8812
|
-
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
8813
|
-
var AddonHttpRouteSchema = object({
|
|
8814
|
-
method: _enum([
|
|
8815
|
-
"GET",
|
|
8816
|
-
"POST",
|
|
8817
|
-
"PUT",
|
|
8818
|
-
"DELETE",
|
|
8819
|
-
"PATCH"
|
|
8820
|
-
]),
|
|
8821
|
-
path: string(),
|
|
8822
|
-
access: _enum([
|
|
8823
|
-
"public",
|
|
8824
|
-
"authenticated",
|
|
8825
|
-
"admin"
|
|
8826
|
-
]).optional(),
|
|
8827
|
-
description: string().optional()
|
|
8828
|
-
});
|
|
8829
|
-
/**
|
|
8830
|
-
* Cross-process route invocation envelope. The hub captures the
|
|
8831
|
-
* request as plain data, ships it to the worker via Moleculer, and
|
|
8832
|
-
* the worker runs the local handler against a capturing reply. The
|
|
8833
|
-
* envelope returned describes what the handler intended (status,
|
|
8834
|
-
* headers, body, or a redirect) so the hub can translate it back to
|
|
8835
|
-
* the Fastify reply that's actually wired to the socket.
|
|
8836
|
-
*/
|
|
8837
|
-
var InvokeRequestSchema = object({
|
|
8838
|
-
method: string(),
|
|
8839
|
-
path: string(),
|
|
8840
|
-
params: record(string(), string()),
|
|
8841
|
-
query: record(string(), string()),
|
|
8842
|
-
body: unknown(),
|
|
8843
|
-
headers: record(string(), string()),
|
|
8844
|
-
user: object({
|
|
8845
|
-
id: string(),
|
|
8846
|
-
username: string(),
|
|
8847
|
-
isAdmin: boolean()
|
|
8848
|
-
}).optional(),
|
|
8849
|
-
scopedToken: unknown().optional()
|
|
8850
|
-
});
|
|
8851
|
-
var InvokeReplyEnvelopeSchema = object({
|
|
8852
|
-
status: number().int(),
|
|
8853
|
-
headers: record(string(), string()),
|
|
8854
|
-
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
8855
|
-
* sending `body`. Status defaults to 302 when this is set unless
|
|
8856
|
-
* the handler called `reply.code(...)` explicitly. */
|
|
8857
|
-
redirectUrl: string().nullable(),
|
|
8858
|
-
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
8859
|
-
body: unknown().optional(),
|
|
8860
|
-
/** Set when the handler called `reply.type(mime)`. */
|
|
8861
|
-
contentType: string().optional()
|
|
8862
|
-
});
|
|
8863
|
-
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
8864
|
-
var ConfigTabDeclarationSchema = object({
|
|
8865
|
-
id: string(),
|
|
8866
|
-
label: string(),
|
|
8867
|
-
icon: string(),
|
|
8868
|
-
order: number().optional()
|
|
8869
|
-
});
|
|
8870
|
-
var ConfigSectionWithValuesSchema = object({
|
|
8871
|
-
id: string(),
|
|
8872
|
-
title: string(),
|
|
8873
|
-
description: string().optional(),
|
|
8874
|
-
style: _enum(["card", "accordion"]).optional(),
|
|
8875
|
-
defaultCollapsed: boolean().optional(),
|
|
8876
|
-
columns: union([
|
|
8877
|
-
literal(1),
|
|
8878
|
-
literal(2),
|
|
8879
|
-
literal(3),
|
|
8880
|
-
literal(4)
|
|
8881
|
-
]).optional(),
|
|
8882
|
-
tab: string().optional(),
|
|
8883
|
-
location: _enum(["settings", "top-tab"]).optional(),
|
|
8884
|
-
order: number().optional(),
|
|
8885
|
-
fields: array(any())
|
|
8886
|
-
});
|
|
8887
|
-
var SettingsSchemaWithValuesSchema = object({
|
|
8888
|
-
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
8889
|
-
sections: array(ConfigSectionWithValuesSchema)
|
|
8890
|
-
});
|
|
8891
|
-
/** Patch object — keys are field names, values are the new field values. */
|
|
8892
|
-
var SettingsPatchSchema = record(string(), unknown());
|
|
8893
|
-
/** Standard success response for update operations. */
|
|
8894
|
-
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
8895
|
-
method(object({
|
|
8896
|
-
addonId: string(),
|
|
8897
|
-
nodeId: string().optional(),
|
|
8898
|
-
overlay: record(string(), unknown()).optional(),
|
|
8899
|
-
cap: string().optional()
|
|
8900
|
-
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8901
|
-
addonId: string(),
|
|
8902
|
-
nodeId: string().optional(),
|
|
8903
|
-
patch: SettingsPatchSchema
|
|
8904
|
-
}), SettingsUpdateResultSchema, {
|
|
8905
|
-
kind: "mutation",
|
|
8906
|
-
auth: "admin"
|
|
8907
|
-
}), method(object({
|
|
8908
|
-
addonId: string(),
|
|
8909
|
-
deviceId: number(),
|
|
8910
|
-
nodeId: string().optional()
|
|
8911
|
-
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
8912
|
-
addonId: string(),
|
|
8913
|
-
deviceId: number(),
|
|
8914
|
-
nodeId: string().optional(),
|
|
8915
|
-
patch: SettingsPatchSchema
|
|
8916
|
-
}), SettingsUpdateResultSchema, {
|
|
8917
|
-
kind: "mutation",
|
|
8918
|
-
auth: "admin"
|
|
8919
|
-
});
|
|
8920
|
-
/**
|
|
8921
|
-
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
8922
|
-
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
8923
|
-
* widgets registers a provider on this collection cap; the hub-local
|
|
8924
|
-
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
8925
|
-
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
8926
|
-
* exposes the public listing surface that admin-ui consumes.
|
|
8927
|
-
*
|
|
8928
|
-
* The split exists because the public listing has a different output
|
|
8929
|
-
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
8930
|
-
* per-provider raw declarations. Both ends flow through codegen.
|
|
8931
|
-
*
|
|
8932
|
-
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
8933
|
-
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
8934
|
-
* same `ContributionRenderer` / Module-Federation path as every other
|
|
8935
|
-
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
8936
|
-
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
8937
|
-
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
8938
|
-
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
8939
|
-
*/
|
|
8940
|
-
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
8941
|
-
var WidgetHostEnum = _enum([
|
|
8942
|
-
"device-tab",
|
|
8943
|
-
"dashboard",
|
|
8944
|
-
"integration-detail"
|
|
8945
|
-
]);
|
|
8946
|
-
var WidgetSizeEnum = _enum([
|
|
8947
|
-
"xs",
|
|
8948
|
-
"sm",
|
|
8949
|
-
"md",
|
|
8950
|
-
"lg",
|
|
8951
|
-
"xl"
|
|
9003
|
+
/** Max source length (chars) — checked BEFORE tokenizing so a huge string is
|
|
9004
|
+
* rejected without allocation. */
|
|
9005
|
+
var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
|
|
9006
|
+
/** A legal binding / identifier name. */
|
|
9007
|
+
var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9008
|
+
/** Binding names an author may NOT use: `now` is auto-injected; the literal
|
|
9009
|
+
* keywords lex as values, not identifiers, so binding to them is meaningless. */
|
|
9010
|
+
var RESERVED_BINDING_NAMES = new Set([
|
|
9011
|
+
"now",
|
|
9012
|
+
"true",
|
|
9013
|
+
"false",
|
|
9014
|
+
"null"
|
|
8952
9015
|
]);
|
|
8953
9016
|
/**
|
|
8954
|
-
*
|
|
8955
|
-
*
|
|
8956
|
-
*
|
|
8957
|
-
*
|
|
8958
|
-
* `
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
remoteName: string(),
|
|
8962
|
-
exposedModule: string(),
|
|
8963
|
-
componentKey: string().optional()
|
|
8964
|
-
});
|
|
8965
|
-
/**
|
|
8966
|
-
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
8967
|
-
* widget-only metadata. The `UiContribution` core fields:
|
|
8968
|
-
*
|
|
8969
|
-
* - `tab` — where the widget hosts. A widget that runs on the
|
|
8970
|
-
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
8971
|
-
* widget declares the target device-detail tab id.
|
|
8972
|
-
* - `subTab` — optional sub-tab within `tab`.
|
|
8973
|
-
* - `label` — operator-facing label.
|
|
8974
|
-
* - `order` — ordering within `(tab, subTab)`.
|
|
8975
|
-
* - `kind` — always `'remote'` for widgets.
|
|
8976
|
-
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
8977
|
-
*
|
|
8978
|
-
* Widget-only fields retained alongside the contribution core:
|
|
8979
|
-
*
|
|
8980
|
-
* - `stableId` — stable identity within the addon (the MF
|
|
8981
|
-
* `componentKey`; kept top-level so consumers have
|
|
8982
|
-
* a stable key without reaching into `remote`).
|
|
8983
|
-
* - `description` / `icon` — picker metadata.
|
|
8984
|
-
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
8985
|
-
* aggregator stamps a versioned `bundleUrl` from it.
|
|
8986
|
-
* - `hosts` — every host the widget supports (a widget can run
|
|
8987
|
-
* both on the dashboard and a device tab). `tab`
|
|
8988
|
-
* is the PRIMARY host; `hosts` is the full set the
|
|
8989
|
-
* picker filters on.
|
|
8990
|
-
* - `requires` — host-context requirements validated at mount.
|
|
8991
|
-
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
8992
|
-
* — dashboard placement hints.
|
|
9017
|
+
* Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
|
|
9018
|
+
* zero-dependency. The grammar is deliberately boring: decimal numbers,
|
|
9019
|
+
* single/double-quoted strings with a tiny escape set, identifiers, the three
|
|
9020
|
+
* value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
|
|
9021
|
+
* outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
|
|
9022
|
+
* is a parse error with a source position, so member access / assignment /
|
|
9023
|
+
* template literals are lexically impossible.
|
|
8993
9024
|
*/
|
|
8994
|
-
var
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
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
|
-
|
|
9025
|
+
var KEYWORDS = new Set([
|
|
9026
|
+
"true",
|
|
9027
|
+
"false",
|
|
9028
|
+
"null"
|
|
9029
|
+
]);
|
|
9030
|
+
function isDigit(ch) {
|
|
9031
|
+
return ch >= "0" && ch <= "9";
|
|
9032
|
+
}
|
|
9033
|
+
function isIdentStart(ch) {
|
|
9034
|
+
return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
|
|
9035
|
+
}
|
|
9036
|
+
function isIdentPart(ch) {
|
|
9037
|
+
return isIdentStart(ch) || isDigit(ch);
|
|
9038
|
+
}
|
|
9039
|
+
function isWhitespace(ch) {
|
|
9040
|
+
return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
|
|
9041
|
+
}
|
|
9042
|
+
/** Tokenize `source` into a flat token list ending with a single `eof` token.
|
|
9043
|
+
* Throws `ExpressionParseError` on any illegal character or unterminated
|
|
9044
|
+
* string. */
|
|
9045
|
+
function tokenize(source) {
|
|
9046
|
+
if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
|
|
9047
|
+
const tokens = [];
|
|
9048
|
+
let i = 0;
|
|
9049
|
+
const n = source.length;
|
|
9050
|
+
while (i < n) {
|
|
9051
|
+
const ch = source[i];
|
|
9052
|
+
if (isWhitespace(ch)) {
|
|
9053
|
+
i += 1;
|
|
9054
|
+
continue;
|
|
9055
|
+
}
|
|
9056
|
+
if (isDigit(ch)) {
|
|
9057
|
+
const start = i;
|
|
9058
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
9059
|
+
if (i < n && source[i] === ".") {
|
|
9060
|
+
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
9061
|
+
i += 1;
|
|
9062
|
+
while (i < n && isDigit(source[i])) i += 1;
|
|
9063
|
+
}
|
|
9064
|
+
const text = source.slice(start, i);
|
|
9065
|
+
const value = Number(text);
|
|
9066
|
+
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
9067
|
+
tokens.push({
|
|
9068
|
+
type: "number",
|
|
9069
|
+
value,
|
|
9070
|
+
pos: start
|
|
9071
|
+
});
|
|
9072
|
+
continue;
|
|
9073
|
+
}
|
|
9074
|
+
if (ch === "'" || ch === "\"") {
|
|
9075
|
+
const quote = ch;
|
|
9076
|
+
const start = i;
|
|
9077
|
+
i += 1;
|
|
9078
|
+
let out = "";
|
|
9079
|
+
let closed = false;
|
|
9080
|
+
while (i < n) {
|
|
9081
|
+
const c = source[i];
|
|
9082
|
+
if (c === "\\") {
|
|
9083
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
9084
|
+
if (next === "\\" || next === "'" || next === "\"") {
|
|
9085
|
+
out += next;
|
|
9086
|
+
i += 2;
|
|
9087
|
+
continue;
|
|
9088
|
+
}
|
|
9089
|
+
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
9090
|
+
}
|
|
9091
|
+
if (c === quote) {
|
|
9092
|
+
closed = true;
|
|
9093
|
+
i += 1;
|
|
9094
|
+
break;
|
|
9095
|
+
}
|
|
9096
|
+
out += c;
|
|
9097
|
+
i += 1;
|
|
9098
|
+
}
|
|
9099
|
+
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
9100
|
+
tokens.push({
|
|
9101
|
+
type: "string",
|
|
9102
|
+
value: out,
|
|
9103
|
+
pos: start
|
|
9104
|
+
});
|
|
9105
|
+
continue;
|
|
9106
|
+
}
|
|
9107
|
+
if (isIdentStart(ch)) {
|
|
9108
|
+
const start = i;
|
|
9109
|
+
while (i < n && isIdentPart(source[i])) i += 1;
|
|
9110
|
+
const text = source.slice(start, i);
|
|
9111
|
+
if (KEYWORDS.has(text)) tokens.push({
|
|
9112
|
+
type: "keyword",
|
|
9113
|
+
keyword: keywordOf(text),
|
|
9114
|
+
pos: start
|
|
9115
|
+
});
|
|
9116
|
+
else tokens.push({
|
|
9117
|
+
type: "identifier",
|
|
9118
|
+
name: text,
|
|
9119
|
+
pos: start
|
|
9120
|
+
});
|
|
9121
|
+
continue;
|
|
9122
|
+
}
|
|
9123
|
+
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
9124
|
+
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
9125
|
+
tokens.push({
|
|
9126
|
+
type: "punct",
|
|
9127
|
+
punct: two,
|
|
9128
|
+
pos: i
|
|
9129
|
+
});
|
|
9130
|
+
i += 2;
|
|
9131
|
+
continue;
|
|
9132
|
+
}
|
|
9133
|
+
if (isSinglePunct(ch)) {
|
|
9134
|
+
tokens.push({
|
|
9135
|
+
type: "punct",
|
|
9136
|
+
punct: ch,
|
|
9137
|
+
pos: i
|
|
9138
|
+
});
|
|
9139
|
+
i += 1;
|
|
9140
|
+
continue;
|
|
9141
|
+
}
|
|
9142
|
+
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
9143
|
+
}
|
|
9144
|
+
tokens.push({
|
|
9145
|
+
type: "eof",
|
|
9146
|
+
pos: n
|
|
9147
|
+
});
|
|
9148
|
+
return tokens;
|
|
9149
|
+
}
|
|
9150
|
+
function keywordOf(text) {
|
|
9151
|
+
if (text === "true") return "true";
|
|
9152
|
+
if (text === "false") return "false";
|
|
9153
|
+
return "null";
|
|
9154
|
+
}
|
|
9155
|
+
function isSinglePunct(ch) {
|
|
9156
|
+
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
9157
|
+
}
|
|
9054
9158
|
/**
|
|
9055
|
-
*
|
|
9056
|
-
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9057
|
-
*
|
|
9058
|
-
* The provider iterates every `addon-widgets-source` (collection)
|
|
9059
|
-
* provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
|
|
9060
|
-
* `bundleUrl` strings pointing at
|
|
9061
|
-
* `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
|
|
9062
|
-
* `mtime` cache-buster lets the browser pick up addon rebuilds without
|
|
9063
|
-
* manual reload — same scheme used by `addon-pages`.
|
|
9159
|
+
* Pratt (precedence-climbing) parser for the safe expression mini-language.
|
|
9064
9160
|
*
|
|
9065
|
-
*
|
|
9066
|
-
*
|
|
9067
|
-
*
|
|
9068
|
-
*
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
addonId: string(),
|
|
9072
|
-
bundleUrl: string()
|
|
9073
|
-
});
|
|
9074
|
-
method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
|
|
9075
|
-
/**
|
|
9076
|
-
* Alerts capability — collection-based internal alert system.
|
|
9161
|
+
* Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
|
|
9162
|
+
* → relational → additive → multiplicative → unary `! -` → call / primary.
|
|
9163
|
+
* Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
|
|
9164
|
+
* string validated against the builtin table at parse time, so an unknown
|
|
9165
|
+
* function is rejected immediately (author feedback) and a persisted expression
|
|
9166
|
+
* that references a since-removed builtin degrades at read.
|
|
9077
9167
|
*
|
|
9078
|
-
*
|
|
9079
|
-
*
|
|
9080
|
-
* in the DB and serves them to the admin UI.
|
|
9081
|
-
*/
|
|
9082
|
-
var AlertSeveritySchema = _enum([
|
|
9083
|
-
"info",
|
|
9084
|
-
"success",
|
|
9085
|
-
"warning",
|
|
9086
|
-
"error"
|
|
9087
|
-
]);
|
|
9088
|
-
var AlertStatusSchema = _enum([
|
|
9089
|
-
"active",
|
|
9090
|
-
"in-progress",
|
|
9091
|
-
"completed",
|
|
9092
|
-
"failed",
|
|
9093
|
-
"dismissed"
|
|
9094
|
-
]);
|
|
9095
|
-
var AlertSourceSchema = object({
|
|
9096
|
-
type: string(),
|
|
9097
|
-
id: string()
|
|
9098
|
-
});
|
|
9099
|
-
var AlertSchema = object({
|
|
9100
|
-
id: string(),
|
|
9101
|
-
category: string(),
|
|
9102
|
-
severity: AlertSeveritySchema,
|
|
9103
|
-
title: string(),
|
|
9104
|
-
message: string(),
|
|
9105
|
-
status: AlertStatusSchema,
|
|
9106
|
-
progress: number().optional(),
|
|
9107
|
-
read: boolean(),
|
|
9108
|
-
createdAt: number(),
|
|
9109
|
-
updatedAt: number(),
|
|
9110
|
-
source: AlertSourceSchema.optional(),
|
|
9111
|
-
metadata: record(string(), unknown()).optional()
|
|
9112
|
-
});
|
|
9113
|
-
method(AlertSchema, _void(), { kind: "mutation" }), method(object({
|
|
9114
|
-
alertId: string(),
|
|
9115
|
-
patch: AlertSchema.partial()
|
|
9116
|
-
}), _void(), { kind: "mutation" }), method(object({
|
|
9117
|
-
unreadOnly: boolean().optional(),
|
|
9118
|
-
limit: number().optional()
|
|
9119
|
-
}).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" });
|
|
9120
|
-
DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
|
|
9121
|
-
deviceId: number(),
|
|
9122
|
-
rms: number(),
|
|
9123
|
-
dbfs: number()
|
|
9124
|
-
});
|
|
9125
|
-
/** Shared Zod schemas used across detection capabilities. */
|
|
9126
|
-
/**
|
|
9127
|
-
* Canonical frame-format enum mirrored on `FrameFormat` in
|
|
9128
|
-
* `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
|
|
9129
|
-
* Zod runtime schema and TypeScript type stay in sync at the call site
|
|
9130
|
-
* — adding a new format requires changing both this enum and the
|
|
9131
|
-
* `FrameFormat` type alias together.
|
|
9168
|
+
* A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
|
|
9169
|
+
* arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
|
|
9132
9170
|
*/
|
|
9133
|
-
|
|
9134
|
-
|
|
9135
|
-
"
|
|
9136
|
-
"
|
|
9137
|
-
"
|
|
9138
|
-
"
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
+
/** Binary/logical operator precedence (higher binds tighter). */
|
|
9172
|
+
var BINARY_PRECEDENCE = {
|
|
9173
|
+
"||": 1,
|
|
9174
|
+
"&&": 2,
|
|
9175
|
+
"==": 3,
|
|
9176
|
+
"!=": 3,
|
|
9177
|
+
"<": 4,
|
|
9178
|
+
"<=": 4,
|
|
9179
|
+
">": 4,
|
|
9180
|
+
">=": 4,
|
|
9181
|
+
"+": 5,
|
|
9182
|
+
"-": 5,
|
|
9183
|
+
"*": 6,
|
|
9184
|
+
"/": 6,
|
|
9185
|
+
"%": 6
|
|
9186
|
+
};
|
|
9187
|
+
function isLogicalOp(op) {
|
|
9188
|
+
return op === "&&" || op === "||";
|
|
9189
|
+
}
|
|
9190
|
+
function isBinaryOp(op) {
|
|
9191
|
+
return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
9192
|
+
}
|
|
9193
|
+
var Parser = class {
|
|
9194
|
+
tokens;
|
|
9195
|
+
pos = 0;
|
|
9196
|
+
nodeCount = 0;
|
|
9197
|
+
identifiers = /* @__PURE__ */ new Set();
|
|
9198
|
+
callees = /* @__PURE__ */ new Set();
|
|
9199
|
+
constructor(tokens) {
|
|
9200
|
+
this.tokens = tokens;
|
|
9201
|
+
}
|
|
9202
|
+
parse() {
|
|
9203
|
+
const ast = this.parseTernary();
|
|
9204
|
+
const tok = this.peek();
|
|
9205
|
+
if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
|
|
9206
|
+
return {
|
|
9207
|
+
ast,
|
|
9208
|
+
identifiers: this.identifiers,
|
|
9209
|
+
callees: this.callees,
|
|
9210
|
+
nodeCount: this.nodeCount
|
|
9211
|
+
};
|
|
9212
|
+
}
|
|
9213
|
+
peek() {
|
|
9214
|
+
return this.tokens[this.pos];
|
|
9215
|
+
}
|
|
9216
|
+
next() {
|
|
9217
|
+
return this.tokens[this.pos++];
|
|
9218
|
+
}
|
|
9219
|
+
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
9220
|
+
expectPunct(punct) {
|
|
9221
|
+
const tok = this.peek();
|
|
9222
|
+
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
9223
|
+
this.pos += 1;
|
|
9224
|
+
}
|
|
9225
|
+
matchPunct(punct) {
|
|
9226
|
+
const tok = this.peek();
|
|
9227
|
+
if (tok.type === "punct" && tok.punct === punct) {
|
|
9228
|
+
this.pos += 1;
|
|
9229
|
+
return true;
|
|
9230
|
+
}
|
|
9231
|
+
return false;
|
|
9232
|
+
}
|
|
9233
|
+
countNode() {
|
|
9234
|
+
this.nodeCount += 1;
|
|
9235
|
+
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
9236
|
+
}
|
|
9237
|
+
parseTernary() {
|
|
9238
|
+
const test = this.parseBinary(1);
|
|
9239
|
+
if (this.matchPunct("?")) {
|
|
9240
|
+
const consequent = this.parseTernary();
|
|
9241
|
+
this.expectPunct(":");
|
|
9242
|
+
const alternate = this.parseTernary();
|
|
9243
|
+
this.countNode();
|
|
9244
|
+
return {
|
|
9245
|
+
kind: "conditional",
|
|
9246
|
+
test,
|
|
9247
|
+
consequent,
|
|
9248
|
+
alternate
|
|
9249
|
+
};
|
|
9250
|
+
}
|
|
9251
|
+
return test;
|
|
9252
|
+
}
|
|
9253
|
+
parseBinary(minPrec) {
|
|
9254
|
+
let left = this.parseUnary();
|
|
9255
|
+
for (;;) {
|
|
9256
|
+
const tok = this.peek();
|
|
9257
|
+
if (tok.type !== "punct") break;
|
|
9258
|
+
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
9259
|
+
if (prec === void 0 || prec < minPrec) break;
|
|
9260
|
+
const op = tok.punct;
|
|
9261
|
+
this.pos += 1;
|
|
9262
|
+
const right = this.parseBinary(prec + 1);
|
|
9263
|
+
this.countNode();
|
|
9264
|
+
if (isLogicalOp(op)) left = {
|
|
9265
|
+
kind: "logical",
|
|
9266
|
+
op,
|
|
9267
|
+
left,
|
|
9268
|
+
right
|
|
9269
|
+
};
|
|
9270
|
+
else if (isBinaryOp(op)) left = {
|
|
9271
|
+
kind: "binary",
|
|
9272
|
+
op,
|
|
9273
|
+
left,
|
|
9274
|
+
right
|
|
9275
|
+
};
|
|
9276
|
+
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
9277
|
+
}
|
|
9278
|
+
return left;
|
|
9279
|
+
}
|
|
9280
|
+
parseUnary() {
|
|
9281
|
+
const tok = this.peek();
|
|
9282
|
+
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
9283
|
+
const op = tok.punct;
|
|
9284
|
+
this.pos += 1;
|
|
9285
|
+
const operand = this.parseUnary();
|
|
9286
|
+
this.countNode();
|
|
9287
|
+
return {
|
|
9288
|
+
kind: "unary",
|
|
9289
|
+
op,
|
|
9290
|
+
operand
|
|
9291
|
+
};
|
|
9292
|
+
}
|
|
9293
|
+
return this.parsePrimary();
|
|
9294
|
+
}
|
|
9295
|
+
parsePrimary() {
|
|
9296
|
+
const tok = this.next();
|
|
9297
|
+
switch (tok.type) {
|
|
9298
|
+
case "number":
|
|
9299
|
+
this.countNode();
|
|
9300
|
+
return {
|
|
9301
|
+
kind: "literal",
|
|
9302
|
+
value: tok.value
|
|
9303
|
+
};
|
|
9304
|
+
case "string":
|
|
9305
|
+
this.countNode();
|
|
9306
|
+
return {
|
|
9307
|
+
kind: "literal",
|
|
9308
|
+
value: tok.value
|
|
9309
|
+
};
|
|
9310
|
+
case "keyword":
|
|
9311
|
+
this.countNode();
|
|
9312
|
+
return {
|
|
9313
|
+
kind: "literal",
|
|
9314
|
+
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
9315
|
+
};
|
|
9316
|
+
case "identifier": {
|
|
9317
|
+
const nextTok = this.peek();
|
|
9318
|
+
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
9319
|
+
this.identifiers.add(tok.name);
|
|
9320
|
+
this.countNode();
|
|
9321
|
+
return {
|
|
9322
|
+
kind: "identifier",
|
|
9323
|
+
name: tok.name
|
|
9324
|
+
};
|
|
9325
|
+
}
|
|
9326
|
+
case "punct":
|
|
9327
|
+
if (tok.punct === "(") {
|
|
9328
|
+
const inner = this.parseTernary();
|
|
9329
|
+
this.expectPunct(")");
|
|
9330
|
+
return inner;
|
|
9331
|
+
}
|
|
9332
|
+
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
9333
|
+
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
9334
|
+
}
|
|
9335
|
+
}
|
|
9336
|
+
parseCall(callee, pos) {
|
|
9337
|
+
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
9338
|
+
this.expectPunct("(");
|
|
9339
|
+
const args = [];
|
|
9340
|
+
if (!this.matchPunct(")")) for (;;) {
|
|
9341
|
+
args.push(this.parseTernary());
|
|
9342
|
+
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
9343
|
+
if (this.matchPunct(",")) continue;
|
|
9344
|
+
this.expectPunct(")");
|
|
9345
|
+
break;
|
|
9346
|
+
}
|
|
9347
|
+
this.callees.add(callee);
|
|
9348
|
+
this.countNode();
|
|
9349
|
+
return {
|
|
9350
|
+
kind: "call",
|
|
9351
|
+
callee,
|
|
9352
|
+
args
|
|
9353
|
+
};
|
|
9354
|
+
}
|
|
9355
|
+
};
|
|
9356
|
+
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
9357
|
+
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
9358
|
+
function parseExpression(source) {
|
|
9359
|
+
return new Parser(tokenize(source)).parse();
|
|
9360
|
+
}
|
|
9361
|
+
/**
|
|
9362
|
+
* LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
|
|
9363
|
+
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
9364
|
+
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
9365
|
+
* one per read on a hot resolve path.
|
|
9366
|
+
*
|
|
9367
|
+
* The cache is a module-level singleton: entries are pure, content-addressed
|
|
9368
|
+
* ASTs keyed by the raw source string, so sharing one instance across all
|
|
9369
|
+
* callers is safe and maximises hit rate.
|
|
9370
|
+
*/
|
|
9371
|
+
var cache = /* @__PURE__ */ new Map();
|
|
9372
|
+
function getCached(source) {
|
|
9373
|
+
const hit = cache.get(source);
|
|
9374
|
+
if (hit !== void 0) {
|
|
9375
|
+
cache.delete(source);
|
|
9376
|
+
cache.set(source, hit);
|
|
9377
|
+
return hit;
|
|
9378
|
+
}
|
|
9379
|
+
let result;
|
|
9380
|
+
try {
|
|
9381
|
+
result = {
|
|
9382
|
+
ok: true,
|
|
9383
|
+
parsed: parseExpression(source)
|
|
9384
|
+
};
|
|
9385
|
+
} catch (err) {
|
|
9386
|
+
result = {
|
|
9387
|
+
ok: false,
|
|
9388
|
+
error: err instanceof ExpressionParseError ? err.message : String(err)
|
|
9389
|
+
};
|
|
9390
|
+
}
|
|
9391
|
+
cache.set(source, result);
|
|
9392
|
+
if (cache.size > 256) {
|
|
9393
|
+
const oldest = cache.keys().next().value;
|
|
9394
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
9395
|
+
}
|
|
9396
|
+
return result;
|
|
9397
|
+
}
|
|
9398
|
+
/** Compile `source`, returning a discriminated result instead of throwing.
|
|
9399
|
+
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
9400
|
+
function compileExpressionSafe(source) {
|
|
9401
|
+
return getCached(source);
|
|
9402
|
+
}
|
|
9403
|
+
Object.freeze({});
|
|
9404
|
+
/**
|
|
9405
|
+
* Author-time validation. Returns `null` when the source is valid, else a
|
|
9406
|
+
* human-readable error message. Checks: the expression compiles; binding count
|
|
9407
|
+
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
9408
|
+
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
9409
|
+
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
9410
|
+
*/
|
|
9411
|
+
function validateExpressionSource(src) {
|
|
9412
|
+
const names = Object.keys(src.bindings);
|
|
9413
|
+
if (names.length > 32) return `too many bindings (${names.length} > 32)`;
|
|
9414
|
+
for (const name of names) {
|
|
9415
|
+
if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
|
|
9416
|
+
if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
|
|
9417
|
+
if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
|
|
9418
|
+
}
|
|
9419
|
+
const compiled = compileExpressionSafe(src.expr);
|
|
9420
|
+
if (!compiled.ok) return compiled.error;
|
|
9421
|
+
const bound = new Set(names);
|
|
9422
|
+
for (const id of compiled.parsed.identifiers) {
|
|
9423
|
+
if (id === "now") continue;
|
|
9424
|
+
if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
|
|
9425
|
+
}
|
|
9426
|
+
return null;
|
|
9427
|
+
}
|
|
9428
|
+
var ExpressionBindingSourceSchema = union([
|
|
9429
|
+
object({
|
|
9430
|
+
kind: literal("field").optional(),
|
|
9431
|
+
sourceKey: string(),
|
|
9432
|
+
cap: string(),
|
|
9433
|
+
fieldPath: string()
|
|
9434
|
+
}),
|
|
9435
|
+
object({
|
|
9436
|
+
kind: literal("literal"),
|
|
9437
|
+
value: union([
|
|
9438
|
+
string(),
|
|
9439
|
+
number(),
|
|
9440
|
+
boolean(),
|
|
9441
|
+
_null()
|
|
9442
|
+
])
|
|
9443
|
+
}),
|
|
9444
|
+
object({
|
|
9445
|
+
kind: literal("global"),
|
|
9446
|
+
sourceStableId: string(),
|
|
9447
|
+
cap: string(),
|
|
9448
|
+
fieldPath: string()
|
|
9449
|
+
})
|
|
9450
|
+
]);
|
|
9451
|
+
object({
|
|
9452
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
9453
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
9454
|
+
}).superRefine((src, ctx) => {
|
|
9455
|
+
const err = validateExpressionSource(src);
|
|
9456
|
+
if (err !== null) ctx.addIssue({
|
|
9457
|
+
code: "custom",
|
|
9458
|
+
message: err,
|
|
9459
|
+
path: ["expr"]
|
|
9460
|
+
});
|
|
9461
|
+
});
|
|
9462
|
+
/** How a leaf compares a device field to a value. Derived from the field's
|
|
9463
|
+
* `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
|
|
9464
|
+
var AutomationConditionOperatorSchema = _enum([
|
|
9465
|
+
"eq",
|
|
9466
|
+
"ne",
|
|
9467
|
+
"gt",
|
|
9468
|
+
"gte",
|
|
9469
|
+
"lt",
|
|
9470
|
+
"lte",
|
|
9471
|
+
"contains",
|
|
9472
|
+
"in"
|
|
9473
|
+
]);
|
|
9474
|
+
var AutomationConditionLeafSchema = object({
|
|
9475
|
+
kind: literal("condition"),
|
|
9476
|
+
deviceId: number().int().nonnegative(),
|
|
9477
|
+
cap: string().min(1),
|
|
9478
|
+
fieldPath: string().min(1),
|
|
9479
|
+
operator: AutomationConditionOperatorSchema,
|
|
9480
|
+
value: union([
|
|
9481
|
+
string(),
|
|
9482
|
+
number(),
|
|
9483
|
+
boolean(),
|
|
9484
|
+
array(union([string(), number()]))
|
|
9485
|
+
])
|
|
9486
|
+
});
|
|
9487
|
+
/**
|
|
9488
|
+
* The expression leaf, declared as a plain object rather than an intersection
|
|
9489
|
+
* with {@link ExpressionSourceSchema}: a discriminated union has to be able to
|
|
9490
|
+
* read `kind` off each option, and an intersection hides it. The author-time
|
|
9491
|
+
* validation is the SAME function `ExpressionSourceSchema` runs, so the two
|
|
9492
|
+
* cannot drift — an expression that one accepts, the other accepts.
|
|
9493
|
+
*/
|
|
9494
|
+
var AutomationConditionExpressionSchema = object({
|
|
9495
|
+
kind: literal("expression"),
|
|
9496
|
+
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
9497
|
+
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
9498
|
+
}).superRefine((src, ctx) => {
|
|
9499
|
+
const err = validateExpressionSource(src);
|
|
9500
|
+
if (err !== null) ctx.addIssue({
|
|
9501
|
+
code: "custom",
|
|
9502
|
+
message: err,
|
|
9503
|
+
path: ["expr"]
|
|
9504
|
+
});
|
|
9505
|
+
});
|
|
9506
|
+
var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
|
|
9507
|
+
object({
|
|
9508
|
+
kind: literal("all"),
|
|
9509
|
+
children: array(AutomationConditionSchema)
|
|
9510
|
+
}),
|
|
9511
|
+
object({
|
|
9512
|
+
kind: literal("any"),
|
|
9513
|
+
children: array(AutomationConditionSchema)
|
|
9514
|
+
}),
|
|
9515
|
+
object({
|
|
9516
|
+
kind: literal("not"),
|
|
9517
|
+
child: AutomationConditionSchema
|
|
9518
|
+
}),
|
|
9519
|
+
AutomationConditionLeafSchema,
|
|
9520
|
+
AutomationConditionExpressionSchema
|
|
9521
|
+
]));
|
|
9522
|
+
/**
|
|
9523
|
+
* What starts a run.
|
|
9524
|
+
*
|
|
9525
|
+
* D8 compliance, and it is the reason `device-state` is not merely an event
|
|
9526
|
+
* subscription: the trigger evaluates against the **state mirror**, which is
|
|
9527
|
+
* reconciled, and an event only WAKES the evaluation. A dropped event therefore
|
|
9528
|
+
* DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
|
|
9529
|
+
* already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
|
|
9530
|
+
* not.
|
|
9531
|
+
*/
|
|
9532
|
+
var AutomationTriggerSchema = discriminatedUnion("kind", [
|
|
9533
|
+
object({
|
|
9534
|
+
kind: literal("device-state"),
|
|
9535
|
+
deviceId: number().int().nonnegative(),
|
|
9536
|
+
cap: string().min(1),
|
|
9537
|
+
fieldPath: string().min(1),
|
|
9538
|
+
/** Fire when the field takes this value. Omit to fire on any change. */
|
|
9539
|
+
becomes: union([
|
|
9540
|
+
string(),
|
|
9541
|
+
number(),
|
|
9542
|
+
boolean()
|
|
9543
|
+
]).optional(),
|
|
9544
|
+
/** Only on a CHANGE of value, not on every re-report. */
|
|
9545
|
+
edge: boolean().optional(),
|
|
9546
|
+
/** The condition must hold this long before the run starts. */
|
|
9547
|
+
forMs: number().int().min(0).max(864e5).optional(),
|
|
9548
|
+
/** Collapse a burst into one run. */
|
|
9549
|
+
debounceMs: number().int().min(0).max(6e5).optional()
|
|
9550
|
+
}),
|
|
9551
|
+
object({
|
|
9552
|
+
kind: literal("device-event"),
|
|
9553
|
+
/** An `EventCategory` value. */
|
|
9554
|
+
category: string().min(1),
|
|
9555
|
+
deviceId: number().int().nonnegative().optional()
|
|
9556
|
+
}),
|
|
9557
|
+
object({
|
|
9558
|
+
kind: literal("schedule"),
|
|
9559
|
+
cron: string().min(1).max(120)
|
|
9560
|
+
}),
|
|
9561
|
+
object({ kind: literal("manual") })
|
|
9562
|
+
]);
|
|
9563
|
+
/**
|
|
9564
|
+
* One action step.
|
|
9565
|
+
*
|
|
9566
|
+
* `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
|
|
9567
|
+
* identical so `NcRuleActionRunner` runs them unchanged — its device-scope
|
|
9568
|
+
* check, stop-at-first-failure and per-sequence throttle are the whole reason
|
|
9569
|
+
* to reuse it, and none of them are re-implemented here.
|
|
9570
|
+
*
|
|
9571
|
+
* **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
|
|
9572
|
+
* a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
|
|
9573
|
+
* documents its subject device as *"for the log tag, never for routing"*. So an
|
|
9574
|
+
* NC action can never target the device that triggered it — which is fine for
|
|
9575
|
+
* the NC (its rules already scope to a device) and fatal for an automation
|
|
9576
|
+
* ("sound the siren of the camera that saw the person"). `deviceId` therefore
|
|
9577
|
+
* also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
|
|
9578
|
+
* is called. The runner still receives a number and is untouched; the
|
|
9579
|
+
* resolution is the recipe's job, not the runner's.
|
|
9580
|
+
*/
|
|
9581
|
+
var AutomationActionSchema = discriminatedUnion("kind", [
|
|
9582
|
+
object({
|
|
9583
|
+
kind: literal("wait"),
|
|
9584
|
+
seconds: number().min(0).max(300)
|
|
9585
|
+
}),
|
|
9586
|
+
object({
|
|
9587
|
+
kind: literal("cap"),
|
|
9588
|
+
deviceId: union([number().int(), object({ $var: string().min(1) })]),
|
|
9589
|
+
cap: string().min(1),
|
|
9590
|
+
method: string().min(1),
|
|
9591
|
+
/** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
|
|
9592
|
+
* evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
|
|
9593
|
+
args: record(string(), unknown()).optional()
|
|
9594
|
+
}),
|
|
9595
|
+
object({
|
|
9596
|
+
kind: literal("code"),
|
|
9597
|
+
/** Compiled into the automation's OWN block by esbuild — not a third
|
|
9598
|
+
* runtime, not a `vm`, and not dynamically evaluated. */
|
|
9599
|
+
code: string().min(1).max(2e4)
|
|
9600
|
+
})
|
|
9601
|
+
]);
|
|
9602
|
+
object({
|
|
9603
|
+
triggers: array(AutomationTriggerSchema),
|
|
9604
|
+
conditions: AutomationConditionSchema.optional(),
|
|
9605
|
+
actions: array(AutomationActionSchema)
|
|
9606
|
+
});
|
|
9607
|
+
/**
|
|
9608
|
+
* `addon-pages` — system-scoped singleton aggregator cap. Public-facing
|
|
9609
|
+
* surface that admin-ui consumes through `useAddonPagesListPages()`.
|
|
9610
|
+
*
|
|
9611
|
+
* The provider iterates every `addon-pages-source` (collection) provider
|
|
9612
|
+
* and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
|
|
9613
|
+
* pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
|
|
9614
|
+
* filesystem `mtime` cache-buster lets the browser pick up addon
|
|
9615
|
+
* rebuilds without manual reload.
|
|
9616
|
+
*
|
|
9617
|
+
* The hub-local builtin `addon-pages-aggregator` (see
|
|
9618
|
+
* `@camstack/system/builtins/addon-pages-aggregator`) registers the
|
|
9619
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9620
|
+
* keeps both ends in codegen — there's no hand-written
|
|
9621
|
+
* `addon-pages.router.ts` wrapper anymore.
|
|
9622
|
+
*/
|
|
9623
|
+
var AddonPageDeclarationSchema$1 = object({
|
|
9624
|
+
id: string(),
|
|
9625
|
+
label: string(),
|
|
9626
|
+
icon: string(),
|
|
9627
|
+
path: string(),
|
|
9628
|
+
remoteName: string(),
|
|
9629
|
+
bundle: string(),
|
|
9630
|
+
section: string().optional(),
|
|
9631
|
+
sectionLabel: string().optional()
|
|
9632
|
+
});
|
|
9633
|
+
var AddonPageInfoSchema = object({
|
|
9634
|
+
addonId: string(),
|
|
9635
|
+
page: AddonPageDeclarationSchema$1,
|
|
9636
|
+
bundleUrl: string()
|
|
9637
|
+
});
|
|
9638
|
+
method(_void(), array(AddonPageInfoSchema).readonly());
|
|
9639
|
+
/**
|
|
9640
|
+
* `addon-pages-source` — collection cap exposing per-provider raw page
|
|
9641
|
+
* declarations. Every addon that contributes a UI page registers a
|
|
9642
|
+
* provider here. The hub-side singleton aggregator (`addon-pages` cap,
|
|
9643
|
+
* see `addon-pages.cap.ts`) walks this collection, stamps versioned
|
|
9644
|
+
* `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
|
|
9645
|
+
* that admin-ui consumes.
|
|
9646
|
+
*
|
|
9647
|
+
* The split exists because the public listing has a different output
|
|
9648
|
+
* shape than the per-provider raw declarations, and we want both ends
|
|
9649
|
+
* to flow through codegen instead of relying on a hand-written wrapper.
|
|
9650
|
+
*/
|
|
9651
|
+
var AddonPageDeclarationSchema = object({
|
|
9652
|
+
id: string(),
|
|
9653
|
+
label: string(),
|
|
9654
|
+
icon: string(),
|
|
9655
|
+
path: string(),
|
|
9656
|
+
/**
|
|
9657
|
+
* Module Federation remote name — must match the `name` field on the
|
|
9658
|
+
* page addon's `federation()` plugin config. Used by admin-ui's
|
|
9659
|
+
* `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
|
|
9660
|
+
* Conventionally `addon_<id>_page` (snake_case; MF names cannot
|
|
9661
|
+
* contain hyphens).
|
|
9662
|
+
*/
|
|
9663
|
+
remoteName: string(),
|
|
9664
|
+
/**
|
|
9665
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9666
|
+
* `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
|
|
9667
|
+
* is always `'remoteEntry.js'`; the value is kept on the metadata so
|
|
9668
|
+
* the static-file route can compute an mtime-based cache-buster URL
|
|
9669
|
+
* without a separate filesystem stat.
|
|
9670
|
+
*/
|
|
9671
|
+
bundle: string(),
|
|
9672
|
+
/**
|
|
9673
|
+
* Sidebar section this page docks into. Well-known ids: `'detection'`,
|
|
9674
|
+
* `'cluster'`, `'administration'` — the page renders inside that group.
|
|
9675
|
+
* Any OTHER string creates (or joins) a custom section rendered after
|
|
9676
|
+
* the built-in groups; its label comes from `sectionLabel` (first
|
|
9677
|
+
* declaration wins), falling back to the id. Absent → the legacy
|
|
9678
|
+
* "Addon Pages" group.
|
|
9679
|
+
*/
|
|
9680
|
+
section: string().optional(),
|
|
9681
|
+
/** Display label for a CUSTOM `section` id (ignored for well-known ids). */
|
|
9682
|
+
sectionLabel: string().optional()
|
|
9683
|
+
});
|
|
9684
|
+
method(_void(), array(AddonPageDeclarationSchema).readonly());
|
|
9685
|
+
var AddonHttpRouteSchema = object({
|
|
9686
|
+
method: _enum([
|
|
9687
|
+
"GET",
|
|
9688
|
+
"POST",
|
|
9689
|
+
"PUT",
|
|
9690
|
+
"DELETE",
|
|
9691
|
+
"PATCH"
|
|
9692
|
+
]),
|
|
9693
|
+
path: string(),
|
|
9694
|
+
access: _enum([
|
|
9695
|
+
"public",
|
|
9696
|
+
"authenticated",
|
|
9697
|
+
"admin"
|
|
9698
|
+
]).optional(),
|
|
9699
|
+
description: string().optional()
|
|
9700
|
+
});
|
|
9701
|
+
/**
|
|
9702
|
+
* Cross-process route invocation envelope. The hub captures the
|
|
9703
|
+
* request as plain data, ships it to the worker via Moleculer, and
|
|
9704
|
+
* the worker runs the local handler against a capturing reply. The
|
|
9705
|
+
* envelope returned describes what the handler intended (status,
|
|
9706
|
+
* headers, body, or a redirect) so the hub can translate it back to
|
|
9707
|
+
* the Fastify reply that's actually wired to the socket.
|
|
9708
|
+
*/
|
|
9709
|
+
var InvokeRequestSchema = object({
|
|
9710
|
+
method: string(),
|
|
9711
|
+
path: string(),
|
|
9712
|
+
params: record(string(), string()),
|
|
9713
|
+
query: record(string(), string()),
|
|
9714
|
+
body: unknown(),
|
|
9715
|
+
headers: record(string(), string()),
|
|
9716
|
+
user: object({
|
|
9717
|
+
id: string(),
|
|
9718
|
+
username: string(),
|
|
9719
|
+
isAdmin: boolean()
|
|
9720
|
+
}).optional(),
|
|
9721
|
+
scopedToken: unknown().optional()
|
|
9722
|
+
});
|
|
9723
|
+
var InvokeReplyEnvelopeSchema = object({
|
|
9724
|
+
status: number().int(),
|
|
9725
|
+
headers: record(string(), string()),
|
|
9726
|
+
/** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
|
|
9727
|
+
* sending `body`. Status defaults to 302 when this is set unless
|
|
9728
|
+
* the handler called `reply.code(...)` explicitly. */
|
|
9729
|
+
redirectUrl: string().nullable(),
|
|
9730
|
+
/** JSON-serializable body. `undefined` is treated as "no body". */
|
|
9731
|
+
body: unknown().optional(),
|
|
9732
|
+
/** Set when the handler called `reply.type(mime)`. */
|
|
9733
|
+
contentType: string().optional()
|
|
9734
|
+
});
|
|
9735
|
+
method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
|
|
9736
|
+
var ConfigTabDeclarationSchema = object({
|
|
9737
|
+
id: string(),
|
|
9738
|
+
label: string(),
|
|
9739
|
+
icon: string(),
|
|
9740
|
+
order: number().optional()
|
|
9741
|
+
});
|
|
9742
|
+
var ConfigSectionWithValuesSchema = object({
|
|
9743
|
+
id: string(),
|
|
9744
|
+
title: string(),
|
|
9745
|
+
description: string().optional(),
|
|
9746
|
+
style: _enum(["card", "accordion"]).optional(),
|
|
9747
|
+
defaultCollapsed: boolean().optional(),
|
|
9748
|
+
columns: union([
|
|
9749
|
+
literal(1),
|
|
9750
|
+
literal(2),
|
|
9751
|
+
literal(3),
|
|
9752
|
+
literal(4)
|
|
9753
|
+
]).optional(),
|
|
9754
|
+
tab: string().optional(),
|
|
9755
|
+
location: _enum(["settings", "top-tab"]).optional(),
|
|
9756
|
+
order: number().optional(),
|
|
9757
|
+
fields: array(any())
|
|
9758
|
+
});
|
|
9759
|
+
var SettingsSchemaWithValuesSchema = object({
|
|
9760
|
+
tabs: array(ConfigTabDeclarationSchema).optional(),
|
|
9761
|
+
sections: array(ConfigSectionWithValuesSchema)
|
|
9762
|
+
});
|
|
9763
|
+
/** Patch object — keys are field names, values are the new field values. */
|
|
9764
|
+
var SettingsPatchSchema = record(string(), unknown());
|
|
9765
|
+
/** Standard success response for update operations. */
|
|
9766
|
+
var SettingsUpdateResultSchema = object({ success: literal(true) });
|
|
9767
|
+
method(object({
|
|
9768
|
+
addonId: string(),
|
|
9769
|
+
nodeId: string().optional(),
|
|
9770
|
+
overlay: record(string(), unknown()).optional(),
|
|
9771
|
+
cap: string().optional()
|
|
9772
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9773
|
+
addonId: string(),
|
|
9774
|
+
nodeId: string().optional(),
|
|
9775
|
+
patch: SettingsPatchSchema
|
|
9776
|
+
}), SettingsUpdateResultSchema, {
|
|
9777
|
+
kind: "mutation",
|
|
9778
|
+
auth: "admin"
|
|
9779
|
+
}), method(object({
|
|
9780
|
+
addonId: string(),
|
|
9781
|
+
deviceId: number(),
|
|
9782
|
+
nodeId: string().optional()
|
|
9783
|
+
}), SettingsSchemaWithValuesSchema.nullable()), method(object({
|
|
9784
|
+
addonId: string(),
|
|
9785
|
+
deviceId: number(),
|
|
9786
|
+
nodeId: string().optional(),
|
|
9787
|
+
patch: SettingsPatchSchema
|
|
9788
|
+
}), SettingsUpdateResultSchema, {
|
|
9789
|
+
kind: "mutation",
|
|
9790
|
+
auth: "admin"
|
|
9791
|
+
});
|
|
9792
|
+
/**
|
|
9793
|
+
* `addon-widgets-source` — collection cap exposing per-addon raw widget
|
|
9794
|
+
* declarations. Mirrors the addon-pages split: every addon shipping
|
|
9795
|
+
* widgets registers a provider on this collection cap; the hub-local
|
|
9796
|
+
* aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
|
|
9797
|
+
* collection, stamps versioned `bundleUrl`s onto each declaration, and
|
|
9798
|
+
* exposes the public listing surface that admin-ui consumes.
|
|
9799
|
+
*
|
|
9800
|
+
* The split exists because the public listing has a different output
|
|
9801
|
+
* shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
|
|
9802
|
+
* per-provider raw declarations. Both ends flow through codegen.
|
|
9803
|
+
*
|
|
9804
|
+
* Unified UI-contribution model (Task 10): a widget descriptor IS a
|
|
9805
|
+
* `UiContribution` with `kind:'remote'`. The host renders it through the
|
|
9806
|
+
* same `ContributionRenderer` / Module-Federation path as every other
|
|
9807
|
+
* contributed UI surface — no bespoke widget-rendering path. The widget-
|
|
9808
|
+
* only metadata (sizing hints, `requires`) lives as extra fields on the
|
|
9809
|
+
* descriptor; the `UiContribution` core (`tab` / `label` / `order` /
|
|
9810
|
+
* `kind` / `remote`) carries identity + placement + the MF remote.
|
|
9811
|
+
*/
|
|
9812
|
+
/** Where the widget makes sense to render — maps to a contribution `tab`. */
|
|
9813
|
+
var WidgetHostEnum = _enum([
|
|
9814
|
+
"device-tab",
|
|
9815
|
+
"dashboard",
|
|
9816
|
+
"integration-detail"
|
|
9817
|
+
]);
|
|
9818
|
+
var WidgetSizeEnum = _enum([
|
|
9819
|
+
"xs",
|
|
9820
|
+
"sm",
|
|
9821
|
+
"md",
|
|
9822
|
+
"lg",
|
|
9823
|
+
"xl"
|
|
9824
|
+
]);
|
|
9825
|
+
/**
|
|
9826
|
+
* MF remote descriptor — mirrors `UiContributionRemote` from
|
|
9827
|
+
* `capability-definition.ts`. Widget remotes expose a single
|
|
9828
|
+
* `'./widgets'` module whose default export is a
|
|
9829
|
+
* `Record<componentKey, Component>` map; `componentKey` (the widget
|
|
9830
|
+
* `stableId`) picks the entry the host mounts.
|
|
9831
|
+
*/
|
|
9832
|
+
var WidgetRemoteSchema = object({
|
|
9833
|
+
remoteName: string(),
|
|
9834
|
+
exposedModule: string(),
|
|
9835
|
+
componentKey: string().optional()
|
|
9836
|
+
});
|
|
9837
|
+
/**
|
|
9838
|
+
* One widget declaration — a `UiContribution` (`kind:'remote'`) plus
|
|
9839
|
+
* widget-only metadata. The `UiContribution` core fields:
|
|
9840
|
+
*
|
|
9841
|
+
* - `tab` — where the widget hosts. A widget that runs on the
|
|
9842
|
+
* dashboard declares `tab:'dashboard'`; a device-tab
|
|
9843
|
+
* widget declares the target device-detail tab id.
|
|
9844
|
+
* - `subTab` — optional sub-tab within `tab`.
|
|
9845
|
+
* - `label` — operator-facing label.
|
|
9846
|
+
* - `order` — ordering within `(tab, subTab)`.
|
|
9847
|
+
* - `kind` — always `'remote'` for widgets.
|
|
9848
|
+
* - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
|
|
9849
|
+
*
|
|
9850
|
+
* Widget-only fields retained alongside the contribution core:
|
|
9851
|
+
*
|
|
9852
|
+
* - `stableId` — stable identity within the addon (the MF
|
|
9853
|
+
* `componentKey`; kept top-level so consumers have
|
|
9854
|
+
* a stable key without reaching into `remote`).
|
|
9855
|
+
* - `description` / `icon` — picker metadata.
|
|
9856
|
+
* - `bundle` — entry filename inside the addon `dist/` dir; the
|
|
9857
|
+
* aggregator stamps a versioned `bundleUrl` from it.
|
|
9858
|
+
* - `hosts` — every host the widget supports (a widget can run
|
|
9859
|
+
* both on the dashboard and a device tab). `tab`
|
|
9860
|
+
* is the PRIMARY host; `hosts` is the full set the
|
|
9861
|
+
* picker filters on.
|
|
9862
|
+
* - `requires` — host-context requirements validated at mount.
|
|
9863
|
+
* - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
|
|
9864
|
+
* — dashboard placement hints.
|
|
9865
|
+
*/
|
|
9866
|
+
var WidgetMetadataSchema = object({
|
|
9867
|
+
/** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
|
|
9868
|
+
tab: string(),
|
|
9869
|
+
/** Optional sub-tab within `tab`. */
|
|
9870
|
+
subTab: string().optional(),
|
|
9871
|
+
/** Operator-facing label. */
|
|
9872
|
+
label: string(),
|
|
9873
|
+
/** Ordering within `(tab, subTab)`, ascending. */
|
|
9874
|
+
order: number().optional(),
|
|
9875
|
+
/** Always `'remote'` — a widget is a Module Federation remote. */
|
|
9876
|
+
kind: literal("remote"),
|
|
9877
|
+
/** MF remote descriptor. */
|
|
9878
|
+
remote: WidgetRemoteSchema,
|
|
9879
|
+
/** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
|
|
9880
|
+
stableId: string(),
|
|
9881
|
+
description: string().optional(),
|
|
9882
|
+
icon: string().optional(),
|
|
9883
|
+
/**
|
|
9884
|
+
* Bundle filename inside the addon's `dist/` dir served at
|
|
9885
|
+
* `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
|
|
9886
|
+
* this is always `'remoteEntry.js'` — the value is kept on the
|
|
9887
|
+
* metadata so the static-file route can compute an mtime-based
|
|
9888
|
+
* cache-buster URL without a separate filesystem stat.
|
|
9889
|
+
*/
|
|
9890
|
+
bundle: string(),
|
|
9891
|
+
/** Every host the widget supports. The picker filters on this set. */
|
|
9892
|
+
hosts: array(WidgetHostEnum).readonly(),
|
|
9893
|
+
/** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
|
|
9894
|
+
requires: object({
|
|
9895
|
+
deviceContext: boolean().default(false),
|
|
9896
|
+
integrationContext: boolean().default(false)
|
|
9897
|
+
}),
|
|
9898
|
+
/**
|
|
9899
|
+
* Loadable BEFORE authentication. The normal widget registry listing
|
|
9900
|
+
* (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
|
|
9901
|
+
* (the login page) cannot discover a widget through it. A widget that
|
|
9902
|
+
* declares `preAuth: true` marks itself as safe to mount on a pre-auth
|
|
9903
|
+
* screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
|
|
9904
|
+
* login-method contribution channel (see `login-method.cap.ts`) rather
|
|
9905
|
+
* than the authenticated registry, and its bundle is served by the
|
|
9906
|
+
* public `/api/addon-widgets/:addonId/*` static route. Defaults false.
|
|
9907
|
+
*/
|
|
9908
|
+
preAuth: boolean().optional().default(false),
|
|
9909
|
+
/** Dashboard placement HINTS (operator can override per instance). */
|
|
9910
|
+
defaultSize: WidgetSizeEnum.default("md"),
|
|
9911
|
+
allowedSizes: array(WidgetSizeEnum).readonly().default([
|
|
9912
|
+
"sm",
|
|
9913
|
+
"md",
|
|
9914
|
+
"lg"
|
|
9915
|
+
]),
|
|
9916
|
+
defaultColumns: number().int().min(1).max(12).default(6),
|
|
9917
|
+
defaultRows: number().int().min(1).max(12).default(1)
|
|
9918
|
+
});
|
|
9919
|
+
var addonWidgetsSourceCapability = {
|
|
9920
|
+
name: "addon-widgets-source",
|
|
9921
|
+
scope: "system",
|
|
9922
|
+
mode: "collection",
|
|
9923
|
+
internal: true,
|
|
9924
|
+
methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
|
|
9925
|
+
};
|
|
9926
|
+
/**
|
|
9927
|
+
* `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
|
|
9928
|
+
* surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
|
|
9929
|
+
*
|
|
9930
|
+
* The provider iterates every `addon-widgets-source` (collection)
|
|
9931
|
+
* provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
|
|
9932
|
+
* `bundleUrl` strings pointing at
|
|
9933
|
+
* `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
|
|
9934
|
+
* `mtime` cache-buster lets the browser pick up addon rebuilds without
|
|
9935
|
+
* manual reload — same scheme used by `addon-pages`.
|
|
9936
|
+
*
|
|
9937
|
+
* The hub-local builtin `addon-widgets-aggregator` (see
|
|
9938
|
+
* `@camstack/system/builtins/addon-widgets-aggregator`) registers the
|
|
9939
|
+
* provider. Splitting the public aggregator from the raw collection
|
|
9940
|
+
* keeps both ends in codegen — there's no hand-written wrapper.
|
|
9941
|
+
*/
|
|
9942
|
+
var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
|
|
9943
|
+
addonId: string(),
|
|
9944
|
+
bundleUrl: string()
|
|
9945
|
+
});
|
|
9946
|
+
method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
|
|
9947
|
+
/**
|
|
9948
|
+
* Alerts capability — collection-based internal alert system.
|
|
9949
|
+
*
|
|
9950
|
+
* Multiple providers can register. Each provider filters by EventBus category
|
|
9951
|
+
* and creates/updates alerts. The built-in Alert Center addon persists alerts
|
|
9952
|
+
* in the DB and serves them to the admin UI.
|
|
9953
|
+
*/
|
|
9954
|
+
var AlertSeveritySchema = _enum([
|
|
9955
|
+
"info",
|
|
9956
|
+
"success",
|
|
9957
|
+
"warning",
|
|
9958
|
+
"error"
|
|
9959
|
+
]);
|
|
9960
|
+
var AlertStatusSchema = _enum([
|
|
9961
|
+
"active",
|
|
9962
|
+
"in-progress",
|
|
9963
|
+
"completed",
|
|
9964
|
+
"failed",
|
|
9965
|
+
"dismissed"
|
|
9966
|
+
]);
|
|
9967
|
+
var AlertSourceSchema = object({
|
|
9968
|
+
type: string(),
|
|
9969
|
+
id: string()
|
|
9970
|
+
});
|
|
9971
|
+
var AlertSchema = object({
|
|
9972
|
+
id: string(),
|
|
9973
|
+
category: string(),
|
|
9974
|
+
severity: AlertSeveritySchema,
|
|
9975
|
+
title: string(),
|
|
9976
|
+
message: string(),
|
|
9977
|
+
status: AlertStatusSchema,
|
|
9978
|
+
progress: number().optional(),
|
|
9979
|
+
read: boolean(),
|
|
9980
|
+
createdAt: number(),
|
|
9981
|
+
updatedAt: number(),
|
|
9982
|
+
source: AlertSourceSchema.optional(),
|
|
9983
|
+
metadata: record(string(), unknown()).optional()
|
|
9984
|
+
});
|
|
9985
|
+
method(AlertSchema, _void(), { kind: "mutation" }), method(object({
|
|
9986
|
+
alertId: string(),
|
|
9987
|
+
patch: AlertSchema.partial()
|
|
9988
|
+
}), _void(), { kind: "mutation" }), method(object({
|
|
9989
|
+
unreadOnly: boolean().optional(),
|
|
9990
|
+
limit: number().optional()
|
|
9991
|
+
}).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" });
|
|
9992
|
+
DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
|
|
9993
|
+
deviceId: number(),
|
|
9994
|
+
rms: number(),
|
|
9995
|
+
dbfs: number()
|
|
9996
|
+
});
|
|
9997
|
+
/** Shared Zod schemas used across detection capabilities. */
|
|
9998
|
+
/**
|
|
9999
|
+
* Canonical frame-format enum mirrored on `FrameFormat` in
|
|
10000
|
+
* `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
|
|
10001
|
+
* Zod runtime schema and TypeScript type stay in sync at the call site
|
|
10002
|
+
* — adding a new format requires changing both this enum and the
|
|
10003
|
+
* `FrameFormat` type alias together.
|
|
10004
|
+
*/
|
|
10005
|
+
var FrameFormatSchema = _enum([
|
|
10006
|
+
"jpeg",
|
|
10007
|
+
"rgb",
|
|
10008
|
+
"bgr",
|
|
10009
|
+
"yuv420",
|
|
10010
|
+
"gray"
|
|
10011
|
+
]);
|
|
10012
|
+
var FrameInputSchema = object({
|
|
10013
|
+
data: custom(),
|
|
10014
|
+
format: FrameFormatSchema,
|
|
10015
|
+
width: number(),
|
|
10016
|
+
height: number(),
|
|
10017
|
+
timestamp: number()
|
|
10018
|
+
});
|
|
10019
|
+
var BoundingBoxSchema = object({
|
|
10020
|
+
x: number(),
|
|
10021
|
+
y: number(),
|
|
10022
|
+
w: number(),
|
|
10023
|
+
h: number()
|
|
10024
|
+
});
|
|
10025
|
+
object({
|
|
10026
|
+
class: string(),
|
|
10027
|
+
originalClass: string(),
|
|
10028
|
+
score: number(),
|
|
10029
|
+
bbox: BoundingBoxSchema
|
|
10030
|
+
});
|
|
10031
|
+
/**
|
|
10032
|
+
* `data` carries the raw f32le bytes of the PCM samples (4 bytes per
|
|
10033
|
+
* sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
|
|
10034
|
+
* because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
|
|
10035
|
+
* round-trips losslessly over the UDS transport. `Float32Array` is NOT
|
|
10036
|
+
* preserved — the encoder serialises it as `bin` (its raw bytes) but
|
|
10037
|
+
* the decoder returns `Uint8Array`, so treating a `Float32Array` as the
|
|
10038
|
+
* wire type causes receivers to read bytes as sample values, producing
|
|
10039
|
+
* wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
|
|
10040
|
+
*
|
|
10041
|
+
* Callers that need float arithmetic reconstruct the view with:
|
|
10042
|
+
* `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
|
|
9171
10043
|
*/
|
|
9172
10044
|
var AudioChunkInputSchema = object({
|
|
9173
10045
|
data: _instanceof(Uint8Array),
|
|
@@ -11872,6 +12744,15 @@ method(object({
|
|
|
11872
12744
|
}), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
|
|
11873
12745
|
kind: "mutation",
|
|
11874
12746
|
auth: "admin"
|
|
12747
|
+
}), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
|
|
12748
|
+
kind: "mutation",
|
|
12749
|
+
auth: "admin"
|
|
12750
|
+
}), method(object({
|
|
12751
|
+
addonId: string(),
|
|
12752
|
+
integrationId: string().optional()
|
|
12753
|
+
}), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
|
|
12754
|
+
kind: "mutation",
|
|
12755
|
+
auth: "admin"
|
|
11875
12756
|
}), method(ResyncInputSchema, ResyncResultSchema, {
|
|
11876
12757
|
kind: "mutation",
|
|
11877
12758
|
auth: "admin"
|
|
@@ -12594,7 +13475,7 @@ DeviceType.Camera, method(object({
|
|
|
12594
13475
|
* Why: pub/sub routing over the system event-bus loses fidelity
|
|
12595
13476
|
* (callback shape, QoS guarantees, will/retain semantics) and adds
|
|
12596
13477
|
* refcount bookkeeping that addons would rather own themselves. The
|
|
12597
|
-
* canonical consumer
|
|
13478
|
+
* canonical consumer needs raw `mqtt.js`
|
|
12598
13479
|
* features anyway — give it the connection config, get out of the way.
|
|
12599
13480
|
*
|
|
12600
13481
|
* Consumer flow:
|
|
@@ -14751,6 +15632,65 @@ object({
|
|
|
14751
15632
|
* Each provider returns a static descriptor; the core enumerates them
|
|
14752
15633
|
* to validate the `integration=` query param and resolve the consent
|
|
14753
15634
|
* label + the scopes baked into the issued token.
|
|
15635
|
+
*
|
|
15636
|
+
* ## Declaring one
|
|
15637
|
+
*
|
|
15638
|
+
* An OAuth client is integration-specific knowledge — who the client is, what
|
|
15639
|
+
* it may ask for, where it may be sent — so it is declared by the ADDON that
|
|
15640
|
+
* owns the integration, never by the kernel and never as a branch inside
|
|
15641
|
+
* `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
|
|
15642
|
+
* steps, no others:
|
|
15643
|
+
*
|
|
15644
|
+
* 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
|
|
15645
|
+
* manifest entry. This is also what tells the hub, at addon-LOAD time, that
|
|
15646
|
+
* a descriptor is owed — see "the boot window" below.
|
|
15647
|
+
* 2. Return a provider from `onInitialize()`:
|
|
15648
|
+
*
|
|
15649
|
+
* ```ts
|
|
15650
|
+
* const provider: IOauthIntegrationProvider = {
|
|
15651
|
+
* getDescriptor: async () => ({
|
|
15652
|
+
* integrationId: 'my-thing', // the `integration=` query param
|
|
15653
|
+
* displayName: 'My Thing',
|
|
15654
|
+
* requestedScopes: [ … ], // see below
|
|
15655
|
+
* allowedRedirectPrefixes: ['https://callback.example/'],
|
|
15656
|
+
* }),
|
|
15657
|
+
* }
|
|
15658
|
+
* return [{ capability: oauthIntegrationCapability, provider }]
|
|
15659
|
+
* ```
|
|
15660
|
+
*
|
|
15661
|
+
* The descriptor must be **static** — it is read on the authorize path, so
|
|
15662
|
+
* never put an await on network or disk behind it, and never register it
|
|
15663
|
+
* behind one either (a provider is registered only once `onInitialize`
|
|
15664
|
+
* RETURNS, so anything awaited before the return delays linking).
|
|
15665
|
+
* 3. Nothing else. There is no allow-list to join, no id to register with the
|
|
15666
|
+
* core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
|
|
15667
|
+
* `/api/oauth2/integrations` are built from this collection alone.
|
|
15668
|
+
*
|
|
15669
|
+
* **Scopes.** `requestedScopes` is baked into every token this integration is
|
|
15670
|
+
* ever issued and the operator consents to it once. Derive it from the tRPC
|
|
15671
|
+
* paths the client calls **with that token**, against `METHOD_ACCESS_MAP`, and
|
|
15672
|
+
* prefer a narrow `capability:` scope to a `category:` one unless the client
|
|
15673
|
+
* genuinely needs a whole family. A category scope grants every future member
|
|
15674
|
+
* of that category too. `category:system [create]` has been rejected once and
|
|
15675
|
+
* should stay rejected: it hands `addons.installPackage` to an integration.
|
|
15676
|
+
*
|
|
15677
|
+
* What it does NOT cover: calls the ADDON makes over `ctx.api`, which run as
|
|
15678
|
+
* the addon and are not scope-checked. Alexa's descriptor is narrower than
|
|
15679
|
+
* Home Assistant's for exactly that reason — its Lambda posts directives and
|
|
15680
|
+
* the addon does the work, while the Home Assistant component calls tRPC
|
|
15681
|
+
* directly with the token. So `requestedScopes` describes the blast radius of
|
|
15682
|
+
* the GRANT, not the reach of the integration; do not widen one to describe the
|
|
15683
|
+
* other.
|
|
15684
|
+
*
|
|
15685
|
+
* **The boot window.** An addon registers its provider after its runner forks
|
|
15686
|
+
* and initialises, so between hub start and that moment this collection is
|
|
15687
|
+
* incomplete and an `integrationId` can be legitimately absent. The core does
|
|
15688
|
+
* not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
|
|
15689
|
+
* it compares the manifest declarers against the registered providers and
|
|
15690
|
+
* answers `503 temporarily_unavailable` (with `Retry-After` and the pending
|
|
15691
|
+
* addon ids) instead of `400 unknown integration`, and reports
|
|
15692
|
+
* `complete: false` on `GET /api/oauth2/integrations`. A client should retry
|
|
15693
|
+
* while the list is incomplete rather than conclude the hub cannot do OAuth.
|
|
14754
15694
|
*/
|
|
14755
15695
|
var OauthIntegrationDescriptorSchema = object({
|
|
14756
15696
|
/** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
|
|
@@ -15134,8 +16074,26 @@ var TrackSchema = object({
|
|
|
15134
16074
|
/** Periodic snapshots at snapshotIntervalMs cadence (subject to
|
|
15135
16075
|
* saveThumbnails policy). */
|
|
15136
16076
|
snapshots: array(TrackSnapshotSchema).readonly(),
|
|
15137
|
-
/** Deduplicated zones the track has entered at least once. */
|
|
16077
|
+
/** Deduplicated zones the track has entered at least once. Zone IDS. */
|
|
15138
16078
|
zonesVisited: array(string()).readonly(),
|
|
16079
|
+
/**
|
|
16080
|
+
* Human NAMES for {@link zonesVisited}, resolved at READ time against the
|
|
16081
|
+
* `zones` capability.
|
|
16082
|
+
*
|
|
16083
|
+
* `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
|
|
16084
|
+
* and no card can render — so every free-text search surface was structurally
|
|
16085
|
+
* unable to answer "show me the tracks in Uscio", and did not fail loudly, it
|
|
16086
|
+
* just returned nothing. Resolving here rather than in each client keeps ONE
|
|
16087
|
+
* derivation and costs the clients no extra call (the `zones` cap is
|
|
16088
|
+
* per-device, so a client-side resolve would be a per-camera fan-out on a
|
|
16089
|
+
* surface built to avoid exactly that).
|
|
16090
|
+
*
|
|
16091
|
+
* Resolved, never invented: a zone deleted since the track was written has no
|
|
16092
|
+
* name and is DROPPED, so this array can be shorter than `zonesVisited` — the
|
|
16093
|
+
* two are not positionally aligned. Absent when the track visited no zone, or
|
|
16094
|
+
* when the zone catalogue could not be read.
|
|
16095
|
+
*/
|
|
16096
|
+
zoneNames: array(string()).readonly().optional(),
|
|
15139
16097
|
/** Deduplicated set of detector classes observed for this track over its
|
|
15140
16098
|
* life (a track may be reclassified, e.g. person→vehicle). Absent on
|
|
15141
16099
|
* legacy rows written before class accumulation shipped. */
|
|
@@ -17529,6 +18487,23 @@ var CameraRecordingStatusSchema = object({
|
|
|
17529
18487
|
active: boolean(),
|
|
17530
18488
|
storageBytes: number()
|
|
17531
18489
|
});
|
|
18490
|
+
/** One stage of the fan-out that could NOT be read, and how long it cost. */
|
|
18491
|
+
var CameraStatusDegradationSchema = object({
|
|
18492
|
+
stage: _enum([
|
|
18493
|
+
"source",
|
|
18494
|
+
"broker",
|
|
18495
|
+
"detection",
|
|
18496
|
+
"recording",
|
|
18497
|
+
"switches"
|
|
18498
|
+
]),
|
|
18499
|
+
reason: _enum([
|
|
18500
|
+
"timeout",
|
|
18501
|
+
"error",
|
|
18502
|
+
"partial"
|
|
18503
|
+
]),
|
|
18504
|
+
/** Wall-clock ms spent on the stage before it was abandoned. */
|
|
18505
|
+
elapsedMs: number()
|
|
18506
|
+
});
|
|
17532
18507
|
/**
|
|
17533
18508
|
* Aggregated per-camera pipeline status — server-composed, single call.
|
|
17534
18509
|
*
|
|
@@ -17559,9 +18534,28 @@ var CameraStatusSchema = object({
|
|
|
17559
18534
|
* differently — a quiet camera that looks identical to a dead one is the
|
|
17560
18535
|
* silence-reads-as-never-happened trap this repo keeps paying for.
|
|
17561
18536
|
*
|
|
17562
|
-
* Empty when nothing is off
|
|
18537
|
+
* Empty when nothing is off, and never contains a switch no provider offers
|
|
18538
|
+
* — but an empty list is only a POSITIVE claim when `degraded` does not name
|
|
18539
|
+
* `'switches'`. When it does, the switch set could not be read and nothing
|
|
18540
|
+
* here may be rendered as "the operator turned nothing off": that is the
|
|
18541
|
+
* D62 failure (a camera we could not read painted as broken) in the very
|
|
18542
|
+
* field that exists to prevent it.
|
|
17563
18543
|
*/
|
|
17564
18544
|
switchedOff: array(CameraSwitchIdSchema).readonly(),
|
|
18545
|
+
/**
|
|
18546
|
+
* Stages of the bounded fan-out that were CUT SHORT — a timeout or a
|
|
18547
|
+
* rejection — and whose block is therefore `null` because we could not
|
|
18548
|
+
* READ it, not because there is nothing there.
|
|
18549
|
+
*
|
|
18550
|
+
* Without this, three different facts arrive as the same `null`: "the stage
|
|
18551
|
+
* timed out", "the stage failed", and "this camera legitimately has no
|
|
18552
|
+
* decoder / no recording". Every surface that draws a conclusion from a null
|
|
18553
|
+
* block (or from an empty `switchedOff`) must consult this first; a stage
|
|
18554
|
+
* named here supports no conclusion at all, only "unknown".
|
|
18555
|
+
*
|
|
18556
|
+
* Empty on a clean read — the overwhelmingly common case.
|
|
18557
|
+
*/
|
|
18558
|
+
degraded: array(CameraStatusDegradationSchema).readonly(),
|
|
17565
18559
|
/** Unix timestamp (ms) when this snapshot was composed server-side. */
|
|
17566
18560
|
fetchedAt: number()
|
|
17567
18561
|
});
|
|
@@ -18371,6 +19365,37 @@ DeviceType.Camera, method(object({
|
|
|
18371
19365
|
lastCapturedAt: number().nullable(),
|
|
18372
19366
|
cacheAgeMs: number().nullable(),
|
|
18373
19367
|
etag: string().nullable()
|
|
19368
|
+
}))), systemMethod(object({
|
|
19369
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
19370
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
19371
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
19372
|
+
targets: array(object({
|
|
19373
|
+
deviceId: number(),
|
|
19374
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
19375
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
19376
|
+
width: number().int().positive().optional()
|
|
19377
|
+
})).min(1).max(200) }), array(object({
|
|
19378
|
+
deviceId: number(),
|
|
19379
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
19380
|
+
* served (no data-plane facility). Present even for a device that has
|
|
19381
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
19382
|
+
url: string().nullable(),
|
|
19383
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
19384
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
19385
|
+
capturedAt: number().nullable(),
|
|
19386
|
+
/** Age of that frame at the moment the answer was built. */
|
|
19387
|
+
ageMs: number().nullable(),
|
|
19388
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
19389
|
+
expiresAt: number().nullable(),
|
|
19390
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
19391
|
+
width: number().nullable(),
|
|
19392
|
+
/** The device has never produced a frame. An empty state, not a
|
|
19393
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
19394
|
+
neverCaptured: boolean(),
|
|
19395
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
19396
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
19397
|
+
* present it as current. */
|
|
19398
|
+
sleeping: boolean()
|
|
18374
19399
|
})));
|
|
18375
19400
|
/**
|
|
18376
19401
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -20121,6 +21146,37 @@ DeviceType.Light, method(object({
|
|
|
20121
21146
|
mireds: number().int().optional(),
|
|
20122
21147
|
lastChangedAt: number()
|
|
20123
21148
|
});
|
|
21149
|
+
var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
|
|
21150
|
+
object({
|
|
21151
|
+
outcome: literal("validated"),
|
|
21152
|
+
/** Round-trip of the sign-in, when the provider measured it. */
|
|
21153
|
+
latencyMs: number().nonnegative().optional(),
|
|
21154
|
+
/** Optional human detail worth showing next to the tick
|
|
21155
|
+
* ("3 devices visible on this account"). */
|
|
21156
|
+
detail: string().optional()
|
|
21157
|
+
}).strict(),
|
|
21158
|
+
object({
|
|
21159
|
+
outcome: literal("rejected"),
|
|
21160
|
+
error: string()
|
|
21161
|
+
}).strict(),
|
|
21162
|
+
object({
|
|
21163
|
+
outcome: literal("inconclusive"),
|
|
21164
|
+
error: string()
|
|
21165
|
+
}).strict()
|
|
21166
|
+
]);
|
|
21167
|
+
var ConnectionTestInputSchema = object({
|
|
21168
|
+
/** Candidate integration settings, exactly as the create form collected them. */
|
|
21169
|
+
settings: record(string(), unknown()) });
|
|
21170
|
+
/**
|
|
21171
|
+
* What the provider's test actually DOES, so the UI can say it in words before
|
|
21172
|
+
* the operator presses the button ("Signs in to the Dreo cloud"). Purely
|
|
21173
|
+
* descriptive — it never changes routing.
|
|
21174
|
+
*/
|
|
21175
|
+
var ConnectionTestDescriptorSchema = object({ label: string() });
|
|
21176
|
+
method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
|
|
21177
|
+
kind: "mutation",
|
|
21178
|
+
auth: "admin"
|
|
21179
|
+
}), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
|
|
20124
21180
|
object({
|
|
20125
21181
|
/** True when the upstream system considers the entity connected. */
|
|
20126
21182
|
connected: boolean(),
|
|
@@ -21022,15 +22078,57 @@ var AvailableIntegrationTypeSchema = object({
|
|
|
21022
22078
|
* flow can import (e.g. HA areas). Drives the adopt modal's "import
|
|
21023
22079
|
* locations" checkbox. Provider-declared in the addon manifest. */
|
|
21024
22080
|
supportsLocationImport: boolean(),
|
|
22081
|
+
/**
|
|
22082
|
+
* True when this integration DECLARES a pre-creation test (the
|
|
22083
|
+
* `connection-test` cap, or a broker whose settings it stores). Drives the
|
|
22084
|
+
* Test button: an integration that cannot be tested must say so up front
|
|
22085
|
+
* rather than offering a button that always answers the same nonsense.
|
|
22086
|
+
*/
|
|
22087
|
+
canTest: boolean(),
|
|
21025
22088
|
existingInstances: array(object({
|
|
21026
22089
|
id: string(),
|
|
21027
22090
|
name: string()
|
|
21028
22091
|
})),
|
|
21029
22092
|
canAdd: boolean()
|
|
21030
22093
|
});
|
|
22094
|
+
/**
|
|
22095
|
+
* Why a test could not be answered as a plain boolean.
|
|
22096
|
+
*
|
|
22097
|
+
* `success` alone collapsed four different situations into one red box, and the
|
|
22098
|
+
* one that mattered most — "nobody ever asked the remote anything" — looked
|
|
22099
|
+
* exactly like "the remote said no". The status is the discriminator:
|
|
22100
|
+
*
|
|
22101
|
+
* - `validated` — a provider-declared test ran and the remote ACCEPTED.
|
|
22102
|
+
* - `rejected` — a provider-declared test ran and the remote REFUSED.
|
|
22103
|
+
* The only status that blocks `integrations.create`.
|
|
22104
|
+
* - `inconclusive` — a test IS declared but could not complete (timeout,
|
|
22105
|
+
* DNS, 5xx). Nothing was observed; not a failure.
|
|
22106
|
+
* - `unsupported` — this integration declares NO test. Nothing was
|
|
22107
|
+
* observed either; not a failure, and not a pass.
|
|
22108
|
+
*
|
|
22109
|
+
* `unsupported` and `inconclusive` both carry `success: false` so an older
|
|
22110
|
+
* client can never read them as a green tick, and both carry an `error` string
|
|
22111
|
+
* that SAYS the test did not run rather than inventing a failure.
|
|
22112
|
+
*/
|
|
22113
|
+
var TestConnectionStatusEnum = _enum([
|
|
22114
|
+
"validated",
|
|
22115
|
+
"rejected",
|
|
22116
|
+
"inconclusive",
|
|
22117
|
+
"unsupported"
|
|
22118
|
+
]);
|
|
21031
22119
|
var TestConnectionResultSchema$1 = object({
|
|
22120
|
+
/** True ONLY for `validated`. Never true for a test that did not run. */
|
|
21032
22121
|
success: boolean(),
|
|
21033
|
-
error: string().optional()
|
|
22122
|
+
error: string().optional(),
|
|
22123
|
+
/** Optional for wire back-compat with clients built before the tri-state;
|
|
22124
|
+
* the server always sets it. */
|
|
22125
|
+
status: TestConnectionStatusEnum.optional(),
|
|
22126
|
+
/** Addon id whose declared test answered — `null` when none did. Lets the UI
|
|
22127
|
+
* attribute a result instead of blaming "the integration". */
|
|
22128
|
+
testedBy: string().nullable().optional(),
|
|
22129
|
+
latencyMs: number().nonnegative().optional(),
|
|
22130
|
+
/** Human detail from a `validated` result ("3 devices on this account"). */
|
|
22131
|
+
detail: string().optional()
|
|
21034
22132
|
});
|
|
21035
22133
|
var CreateIntegrationInputSchema = object({
|
|
21036
22134
|
addonId: string(),
|
|
@@ -24031,1182 +25129,553 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
|
|
|
24031
25129
|
}), method(object({
|
|
24032
25130
|
username: string(),
|
|
24033
25131
|
password: string()
|
|
24034
|
-
}), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
|
|
24035
|
-
kind: "mutation",
|
|
24036
|
-
access: "view"
|
|
24037
|
-
}), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
|
|
24038
|
-
kind: "mutation",
|
|
24039
|
-
auth: "admin",
|
|
24040
|
-
access: "create"
|
|
24041
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24042
|
-
kind: "mutation",
|
|
24043
|
-
auth: "admin",
|
|
24044
|
-
access: "delete"
|
|
24045
|
-
}), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
|
|
24046
|
-
kind: "mutation",
|
|
24047
|
-
access: "view"
|
|
24048
|
-
}), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
|
|
24049
|
-
kind: "mutation",
|
|
24050
|
-
auth: "admin",
|
|
24051
|
-
access: "create"
|
|
24052
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24053
|
-
kind: "mutation",
|
|
24054
|
-
auth: "admin",
|
|
24055
|
-
access: "delete"
|
|
24056
|
-
}), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
|
|
24057
|
-
kind: "mutation",
|
|
24058
|
-
auth: "admin",
|
|
24059
|
-
access: "create"
|
|
24060
|
-
}), method(object({
|
|
24061
|
-
userId: string(),
|
|
24062
|
-
code: string()
|
|
24063
|
-
}), object({ success: literal(true) }), {
|
|
24064
|
-
kind: "mutation",
|
|
24065
|
-
auth: "admin",
|
|
24066
|
-
access: "create"
|
|
24067
|
-
}), method(object({ userId: string() }), object({ success: literal(true) }), {
|
|
24068
|
-
kind: "mutation",
|
|
24069
|
-
auth: "admin",
|
|
24070
|
-
access: "delete"
|
|
24071
|
-
}), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
|
|
24072
|
-
userId: string(),
|
|
24073
|
-
code: string()
|
|
24074
|
-
}), object({ valid: boolean() }), {
|
|
24075
|
-
kind: "mutation",
|
|
24076
|
-
access: "view"
|
|
24077
|
-
}), method(object({
|
|
24078
|
-
integrationId: string(),
|
|
24079
|
-
userId: string(),
|
|
24080
|
-
username: string(),
|
|
24081
|
-
scopes: array(TokenScopeSchema),
|
|
24082
|
-
redirectUri: string(),
|
|
24083
|
-
hubUrl: string(),
|
|
24084
|
-
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
24085
|
-
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
24086
|
-
codeChallenge: string().optional()
|
|
24087
|
-
}), object({ code: string() }), {
|
|
24088
|
-
kind: "mutation",
|
|
24089
|
-
access: "create"
|
|
24090
|
-
}), method(object({
|
|
24091
|
-
code: string(),
|
|
24092
|
-
redirectUri: string(),
|
|
24093
|
-
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
24094
|
-
codeVerifier: string().optional()
|
|
24095
|
-
}), object({
|
|
24096
|
-
accessToken: string(),
|
|
24097
|
-
refreshToken: string(),
|
|
24098
|
-
expiresIn: number()
|
|
24099
|
-
}).nullable(), {
|
|
24100
|
-
kind: "mutation",
|
|
24101
|
-
access: "view"
|
|
24102
|
-
}), method(object({ refreshToken: string() }), object({
|
|
24103
|
-
accessToken: string(),
|
|
24104
|
-
refreshToken: string(),
|
|
24105
|
-
expiresIn: number()
|
|
24106
|
-
}).nullable(), {
|
|
24107
|
-
kind: "mutation",
|
|
24108
|
-
access: "view"
|
|
24109
|
-
}), method(object({ token: string() }), object({
|
|
24110
|
-
userId: string(),
|
|
24111
|
-
username: string(),
|
|
24112
|
-
scopes: array(TokenScopeSchema)
|
|
24113
|
-
}).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
24114
|
-
kind: "mutation",
|
|
24115
|
-
auth: "admin",
|
|
24116
|
-
access: "delete"
|
|
24117
|
-
});
|
|
24118
|
-
/**
|
|
24119
|
-
* Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
|
|
24120
|
-
* cleaning lifecycle plus a return-to-base / locate surface and an
|
|
24121
|
-
* optional fan-speed selector.
|
|
24122
|
-
*
|
|
24123
|
-
* State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
|
|
24124
|
-
* `paused` / `returning` / `docked` / `error`. `batteryLevel`
|
|
24125
|
-
* (0..100) is nullable — some vacuums don't report a battery
|
|
24126
|
-
* percentage. `fanSpeed` is the current speed token (provider-verbatim,
|
|
24127
|
-
* e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
|
|
24128
|
-
* tokens the hardware accepts so the UI renders only supported choices.
|
|
24129
|
-
*
|
|
24130
|
-
* The `setFanSpeed` method takes the bare `speed` token — the provider
|
|
24131
|
-
* validates it against the vacuum's own list. `locate` triggers the
|
|
24132
|
-
* find-me chirp; `returnToBase` sends it home.
|
|
24133
|
-
*
|
|
24134
|
-
* Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
|
|
24135
|
-
* `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
|
|
24136
|
-
* 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
|
|
24137
|
-
* SHOULD populate whichever tanks the hardware has — leave a field `null`
|
|
24138
|
-
* only when the device has no such tank at all, and use a `TankStatus`
|
|
24139
|
-
* with both inner fields `null` when the tank exists but its level is
|
|
24140
|
-
* currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
|
|
24141
|
-
* so the HA provider leaves all four `null`.
|
|
24142
|
-
*/
|
|
24143
|
-
var VacuumStateSchema = _enum([
|
|
24144
|
-
"idle",
|
|
24145
|
-
"cleaning",
|
|
24146
|
-
"paused",
|
|
24147
|
-
"returning",
|
|
24148
|
-
"docked",
|
|
24149
|
-
"drying",
|
|
24150
|
-
"error"
|
|
24151
|
-
]);
|
|
24152
|
-
/**
|
|
24153
|
-
* One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
|
|
24154
|
-
* detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
|
|
24155
|
-
* a discrete `status` (binary-style hardware), or both. Both `null` means the
|
|
24156
|
-
* level is currently unknown; the OWNING field being `null` means the
|
|
24157
|
-
* hardware has no such tank at all.
|
|
24158
|
-
*/
|
|
24159
|
-
var TankStatusSchema = object({
|
|
24160
|
-
/** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
|
|
24161
|
-
level: number().min(0).max(100).nullable(),
|
|
24162
|
-
/** Discrete state when the hardware is binary-mode; null otherwise. */
|
|
24163
|
-
status: _enum([
|
|
24164
|
-
"ok",
|
|
24165
|
-
"low",
|
|
24166
|
-
"full"
|
|
24167
|
-
]).nullable()
|
|
24168
|
-
});
|
|
24169
|
-
object({
|
|
24170
|
-
/** Lifecycle state of the vacuum. */
|
|
24171
|
-
state: VacuumStateSchema,
|
|
24172
|
-
/** 0..100 battery percentage. Null when the device has no battery
|
|
24173
|
-
* reading. */
|
|
24174
|
-
batteryLevel: number().min(0).max(100).nullable(),
|
|
24175
|
-
/** Current fan-speed token (provider-verbatim). Null when unknown or
|
|
24176
|
-
* the vacuum has no speed control. */
|
|
24177
|
-
fanSpeed: string().nullable(),
|
|
24178
|
-
/** Speed tokens the hardware accepts — drives the UI selector. */
|
|
24179
|
-
availableFanSpeeds: array(string()),
|
|
24180
|
-
/** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
|
|
24181
|
-
cleanWater: TankStatusSchema.nullable(),
|
|
24182
|
-
/** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
|
|
24183
|
-
dirtyWater: TankStatusSchema.nullable(),
|
|
24184
|
-
/** Detergent tank. Null when the hardware has no detergent tank. */
|
|
24185
|
-
detergent: TankStatusSchema.nullable(),
|
|
24186
|
-
/** Dust bin. Null when the hardware has no dust bin. */
|
|
24187
|
-
dustBin: TankStatusSchema.nullable(),
|
|
24188
|
-
/** 0..100 cleaning-completion percentage of the current task, or null. */
|
|
24189
|
-
progressPercent: number().min(0).max(100).nullable(),
|
|
24190
|
-
/** Current error code (0 / null = no error). */
|
|
24191
|
-
errorCode: number().nullable(),
|
|
24192
|
-
/** Human label for {@link errorCode}, or null when none / undecodable. */
|
|
24193
|
-
errorLabel: string().nullable(),
|
|
24194
|
-
/** Ms epoch when the slice was last updated. */
|
|
24195
|
-
lastChangedAt: number()
|
|
24196
|
-
});
|
|
24197
|
-
DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24198
|
-
kind: "mutation",
|
|
24199
|
-
auth: "admin"
|
|
24200
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24201
|
-
kind: "mutation",
|
|
24202
|
-
auth: "admin"
|
|
24203
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24204
|
-
kind: "mutation",
|
|
24205
|
-
auth: "admin"
|
|
24206
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24207
|
-
kind: "mutation",
|
|
24208
|
-
auth: "admin"
|
|
24209
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24210
|
-
kind: "mutation",
|
|
24211
|
-
auth: "admin"
|
|
24212
|
-
}), method(object({
|
|
24213
|
-
deviceId: number().int().nonnegative(),
|
|
24214
|
-
speed: string().min(1)
|
|
24215
|
-
}), _void(), {
|
|
24216
|
-
kind: "mutation",
|
|
24217
|
-
auth: "admin"
|
|
24218
|
-
});
|
|
24219
|
-
object({
|
|
24220
|
-
/** Lifecycle state of the valve. */
|
|
24221
|
-
state: _enum([
|
|
24222
|
-
"open",
|
|
24223
|
-
"opening",
|
|
24224
|
-
"closing",
|
|
24225
|
-
"closed",
|
|
24226
|
-
"stopped"
|
|
24227
|
-
]),
|
|
24228
|
-
/** 0 = fully closed, 100 = fully open. Null when the device has no
|
|
24229
|
-
* intermediate position surface. */
|
|
24230
|
-
position: number().min(0).max(100).nullable(),
|
|
24231
|
-
/** Ms epoch when the slice was last updated. */
|
|
24232
|
-
lastChangedAt: number()
|
|
24233
|
-
});
|
|
24234
|
-
DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25132
|
+
}), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
|
|
24235
25133
|
kind: "mutation",
|
|
24236
|
-
|
|
24237
|
-
}), method(
|
|
25134
|
+
access: "view"
|
|
25135
|
+
}), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
|
|
24238
25136
|
kind: "mutation",
|
|
24239
|
-
auth: "admin"
|
|
24240
|
-
|
|
25137
|
+
auth: "admin",
|
|
25138
|
+
access: "create"
|
|
25139
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24241
25140
|
kind: "mutation",
|
|
24242
|
-
auth: "admin"
|
|
24243
|
-
|
|
24244
|
-
|
|
24245
|
-
position: number().min(0).max(100)
|
|
24246
|
-
}), _void(), {
|
|
25141
|
+
auth: "admin",
|
|
25142
|
+
access: "delete"
|
|
25143
|
+
}), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
|
|
24247
25144
|
kind: "mutation",
|
|
24248
|
-
|
|
24249
|
-
})
|
|
24250
|
-
object({
|
|
24251
|
-
detected: boolean(),
|
|
24252
|
-
/** Ms epoch of the last transition. 0 if never observed. */
|
|
24253
|
-
lastChangedAt: number()
|
|
24254
|
-
});
|
|
24255
|
-
DeviceType.Sensor;
|
|
24256
|
-
object({
|
|
24257
|
-
/** Current measured temperature. Null when not reported. */
|
|
24258
|
-
currentTemp: number().nullable(),
|
|
24259
|
-
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
24260
|
-
targetTemp: number().nullable(),
|
|
24261
|
-
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
24262
|
-
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
24263
|
-
* device reports an unknown state. */
|
|
24264
|
-
operationMode: string().nullable(),
|
|
24265
|
-
/** Available operation modes = HA `operation_list`. */
|
|
24266
|
-
availableModes: array(string()),
|
|
24267
|
-
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
24268
|
-
* has no away surface. */
|
|
24269
|
-
away: boolean().nullable(),
|
|
24270
|
-
/** HA `min_temp` attribute. Null when not reported. */
|
|
24271
|
-
minTemp: number().nullable(),
|
|
24272
|
-
/** HA `max_temp` attribute. Null when not reported. */
|
|
24273
|
-
maxTemp: number().nullable(),
|
|
24274
|
-
/** Ms epoch when the slice was last updated. */
|
|
24275
|
-
lastChangedAt: number()
|
|
24276
|
-
});
|
|
24277
|
-
DeviceType.WaterHeater, method(object({
|
|
24278
|
-
deviceId: number().int().nonnegative(),
|
|
24279
|
-
temp: number().finite()
|
|
24280
|
-
}), _void(), {
|
|
25145
|
+
access: "view"
|
|
25146
|
+
}), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
|
|
24281
25147
|
kind: "mutation",
|
|
24282
|
-
auth: "admin"
|
|
25148
|
+
auth: "admin",
|
|
25149
|
+
access: "create"
|
|
25150
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
25151
|
+
kind: "mutation",
|
|
25152
|
+
auth: "admin",
|
|
25153
|
+
access: "delete"
|
|
25154
|
+
}), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
|
|
25155
|
+
kind: "mutation",
|
|
25156
|
+
auth: "admin",
|
|
25157
|
+
access: "create"
|
|
24283
25158
|
}), method(object({
|
|
24284
|
-
|
|
24285
|
-
|
|
24286
|
-
}),
|
|
25159
|
+
userId: string(),
|
|
25160
|
+
code: string()
|
|
25161
|
+
}), object({ success: literal(true) }), {
|
|
24287
25162
|
kind: "mutation",
|
|
24288
|
-
auth: "admin"
|
|
25163
|
+
auth: "admin",
|
|
25164
|
+
access: "create"
|
|
25165
|
+
}), method(object({ userId: string() }), object({ success: literal(true) }), {
|
|
25166
|
+
kind: "mutation",
|
|
25167
|
+
auth: "admin",
|
|
25168
|
+
access: "delete"
|
|
25169
|
+
}), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
|
|
25170
|
+
userId: string(),
|
|
25171
|
+
code: string()
|
|
25172
|
+
}), object({ valid: boolean() }), {
|
|
25173
|
+
kind: "mutation",
|
|
25174
|
+
access: "view"
|
|
24289
25175
|
}), method(object({
|
|
24290
|
-
|
|
24291
|
-
|
|
24292
|
-
|
|
25176
|
+
integrationId: string(),
|
|
25177
|
+
userId: string(),
|
|
25178
|
+
username: string(),
|
|
25179
|
+
scopes: array(TokenScopeSchema),
|
|
25180
|
+
redirectUri: string(),
|
|
25181
|
+
hubUrl: string(),
|
|
25182
|
+
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
25183
|
+
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
25184
|
+
codeChallenge: string().optional()
|
|
25185
|
+
}), object({ code: string() }), {
|
|
24293
25186
|
kind: "mutation",
|
|
24294
|
-
|
|
24295
|
-
})
|
|
24296
|
-
|
|
24297
|
-
|
|
24298
|
-
|
|
24299
|
-
|
|
24300
|
-
|
|
24301
|
-
|
|
24302
|
-
|
|
24303
|
-
|
|
24304
|
-
|
|
24305
|
-
|
|
24306
|
-
|
|
24307
|
-
|
|
24308
|
-
|
|
24309
|
-
|
|
24310
|
-
|
|
24311
|
-
|
|
24312
|
-
|
|
24313
|
-
|
|
24314
|
-
|
|
24315
|
-
|
|
24316
|
-
|
|
24317
|
-
|
|
24318
|
-
})
|
|
24319
|
-
|
|
24320
|
-
|
|
24321
|
-
|
|
24322
|
-
|
|
24323
|
-
* matrix the operator UI needs: total objects everywhere, total
|
|
24324
|
-
* objects per zone, single class everywhere, single class per zone,
|
|
24325
|
-
* objects outside any zone.
|
|
24326
|
-
*
|
|
24327
|
-
* Counts are derived from the analytics tracker (tracked detections
|
|
24328
|
-
* with stable trackIds), not raw detector hits — this filters out
|
|
24329
|
-
* one-off detector flickers and gives counts that match what the user
|
|
24330
|
-
* sees on the live overlay.
|
|
24331
|
-
*
|
|
24332
|
-
* Overlap policy: a detection that intersects two zones counts in
|
|
24333
|
-
* BOTH zones' `byClass` and `totalObjects` (count-in-each). The
|
|
24334
|
-
* `frame` aggregate de-duplicates trivially since it's frame-wide.
|
|
24335
|
-
* `unzoned` counts only detections that landed in zero zones.
|
|
24336
|
-
*/
|
|
24337
|
-
var PerScopeBreakdownSchema = object({
|
|
24338
|
-
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
24339
|
-
totalObjects: number().int().nonnegative(),
|
|
24340
|
-
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
24341
|
-
byClass: record(string(), number().int().nonnegative())
|
|
24342
|
-
});
|
|
24343
|
-
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
24344
|
-
zoneId: string(),
|
|
24345
|
-
zoneName: string(),
|
|
24346
|
-
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
24347
|
-
* with the per-track detail panel and live overlay. */
|
|
24348
|
-
trackIds: array(string()).readonly()
|
|
24349
|
-
});
|
|
24350
|
-
/**
|
|
24351
|
-
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
24352
|
-
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
24353
|
-
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
24354
|
-
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
24355
|
-
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
24356
|
-
* of flooding the live event feed.
|
|
24357
|
-
*/
|
|
24358
|
-
var StationaryObjectSchema = object({
|
|
24359
|
-
id: string(),
|
|
24360
|
-
className: string(),
|
|
24361
|
-
bbox: object({
|
|
24362
|
-
x: number(),
|
|
24363
|
-
y: number(),
|
|
24364
|
-
w: number(),
|
|
24365
|
-
h: number()
|
|
24366
|
-
}),
|
|
24367
|
-
frameWidth: number().int().nonnegative(),
|
|
24368
|
-
frameHeight: number().int().nonnegative(),
|
|
24369
|
-
/** When the source track was first seen. */
|
|
24370
|
-
firstSeenAt: number().int(),
|
|
24371
|
-
/** When the object was recognised as parked (promotion time). */
|
|
24372
|
-
becameStationaryAt: number().int(),
|
|
24373
|
-
/** Last frame a detection confirmed the object is still there. */
|
|
24374
|
-
lastConfirmedAt: number().int(),
|
|
24375
|
-
/** Enrichment label carried from the source track (identity / plate). */
|
|
24376
|
-
label: string().optional(),
|
|
24377
|
-
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
24378
|
-
keyFrameMediaKey: string().optional()
|
|
24379
|
-
});
|
|
24380
|
-
var CameraOccupancySnapshotSchema = object({
|
|
24381
|
-
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
24382
|
-
ts: number().int(),
|
|
24383
|
-
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
24384
|
-
frameWidth: number().int().nonnegative(),
|
|
24385
|
-
frameHeight: number().int().nonnegative(),
|
|
24386
|
-
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
24387
|
-
zones: array(ZoneScopeBreakdownSchema).readonly(),
|
|
24388
|
-
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
24389
|
-
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
24390
|
-
frame: PerScopeBreakdownSchema,
|
|
24391
|
-
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
24392
|
-
unzoned: PerScopeBreakdownSchema,
|
|
24393
|
-
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
24394
|
-
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
24395
|
-
* than as repeated tracks/events. */
|
|
24396
|
-
stationaryObjects: array(StationaryObjectSchema).readonly().optional()
|
|
24397
|
-
});
|
|
24398
|
-
/**
|
|
24399
|
-
* Time-series resolution. The history methods return one bucket per
|
|
24400
|
-
* step over the requested range. Smaller resolutions cost more
|
|
24401
|
-
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
24402
|
-
* for arbitrary fractional buckets.
|
|
24403
|
-
*/
|
|
24404
|
-
var HistoryResolutionEnum = _enum([
|
|
24405
|
-
"minute",
|
|
24406
|
-
"5min",
|
|
24407
|
-
"hour"
|
|
24408
|
-
]);
|
|
24409
|
-
var HistoryRangeSchema = object({
|
|
24410
|
-
/** Range start (epoch ms, inclusive). */
|
|
24411
|
-
from: number().int(),
|
|
24412
|
-
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
24413
|
-
to: number().int(),
|
|
24414
|
-
resolution: HistoryResolutionEnum
|
|
24415
|
-
});
|
|
24416
|
-
var HistoryPointSchema = object({
|
|
24417
|
-
/** Bucket midpoint (epoch ms). */
|
|
24418
|
-
ts: number().int(),
|
|
24419
|
-
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
24420
|
-
count: number().int().nonnegative()
|
|
24421
|
-
});
|
|
24422
|
-
DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
|
|
24423
|
-
deviceId: number(),
|
|
24424
|
-
zoneId: string(),
|
|
24425
|
-
className: string().optional()
|
|
24426
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
24427
|
-
deviceId: number(),
|
|
24428
|
-
className: string().optional()
|
|
24429
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
24430
|
-
deviceId: number(),
|
|
24431
|
-
className: string().optional()
|
|
24432
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
|
|
24433
|
-
/**
|
|
24434
|
-
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
24435
|
-
* cap so a single CRUD surface backs every consumer; each stage has
|
|
24436
|
-
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
24437
|
-
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
24438
|
-
*
|
|
24439
|
-
* Extend the enum here when a new gating consumer comes online (audio
|
|
24440
|
-
* gating, alert filtering, …) — no other surface needs to change.
|
|
24441
|
-
*/
|
|
24442
|
-
var ZoneRuleStageEnum = _enum([
|
|
24443
|
-
"motion",
|
|
24444
|
-
"detection",
|
|
24445
|
-
"package"
|
|
24446
|
-
]);
|
|
25187
|
+
access: "create"
|
|
25188
|
+
}), method(object({
|
|
25189
|
+
code: string(),
|
|
25190
|
+
redirectUri: string(),
|
|
25191
|
+
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
25192
|
+
codeVerifier: string().optional()
|
|
25193
|
+
}), object({
|
|
25194
|
+
accessToken: string(),
|
|
25195
|
+
refreshToken: string(),
|
|
25196
|
+
expiresIn: number()
|
|
25197
|
+
}).nullable(), {
|
|
25198
|
+
kind: "mutation",
|
|
25199
|
+
access: "view"
|
|
25200
|
+
}), method(object({ refreshToken: string() }), object({
|
|
25201
|
+
accessToken: string(),
|
|
25202
|
+
refreshToken: string(),
|
|
25203
|
+
expiresIn: number()
|
|
25204
|
+
}).nullable(), {
|
|
25205
|
+
kind: "mutation",
|
|
25206
|
+
access: "view"
|
|
25207
|
+
}), method(object({ token: string() }), object({
|
|
25208
|
+
userId: string(),
|
|
25209
|
+
username: string(),
|
|
25210
|
+
scopes: array(TokenScopeSchema)
|
|
25211
|
+
}).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
25212
|
+
kind: "mutation",
|
|
25213
|
+
auth: "admin",
|
|
25214
|
+
access: "delete"
|
|
25215
|
+
});
|
|
24447
25216
|
/**
|
|
24448
|
-
*
|
|
24449
|
-
*
|
|
24450
|
-
*
|
|
24451
|
-
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
24452
|
-
* so the operator has a single hub-side source of truth for both
|
|
24453
|
-
* geometry and behaviour. Per-stage rules are stored under the
|
|
24454
|
-
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
24455
|
-
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
24456
|
-
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
24457
|
-
* subscribe to that slice and refresh their gating without
|
|
24458
|
-
* round-tripping the cap.
|
|
25217
|
+
* Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
|
|
25218
|
+
* cleaning lifecycle plus a return-to-base / locate surface and an
|
|
25219
|
+
* optional fan-speed selector.
|
|
24459
25220
|
*
|
|
24460
|
-
*
|
|
24461
|
-
*
|
|
24462
|
-
*
|
|
24463
|
-
|
|
24464
|
-
|
|
24465
|
-
|
|
24466
|
-
scope: "device",
|
|
24467
|
-
mode: "singleton",
|
|
24468
|
-
deviceTypes: [DeviceType.Camera],
|
|
24469
|
-
methods: {
|
|
24470
|
-
/** Read the full rule list for a given stage (empty when no rules
|
|
24471
|
-
* are defined yet). */
|
|
24472
|
-
listRules: method(object({
|
|
24473
|
-
deviceId: number(),
|
|
24474
|
-
stage: ZoneRuleStageEnum
|
|
24475
|
-
}), array(ZoneRuleSchema).readonly()),
|
|
24476
|
-
/** Bulk-replace the rule list for one stage. The provider validates
|
|
24477
|
-
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
24478
|
-
* thresholds in range) and rejects the whole patch if any entry
|
|
24479
|
-
* is invalid — partial writes are a configuration footgun. */
|
|
24480
|
-
setRules: method(object({
|
|
24481
|
-
deviceId: number(),
|
|
24482
|
-
stage: ZoneRuleStageEnum,
|
|
24483
|
-
rules: array(ZoneRuleSchema).readonly()
|
|
24484
|
-
}), _void(), {
|
|
24485
|
-
kind: "mutation",
|
|
24486
|
-
auth: "admin"
|
|
24487
|
-
})
|
|
24488
|
-
},
|
|
24489
|
-
/**
|
|
24490
|
-
* Runtime-state slice — every stage mirrored together so consumers
|
|
24491
|
-
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
24492
|
-
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
24493
|
-
* `{motion, detection, package}` shape, so subscribers always get the
|
|
24494
|
-
* complete current set. Consumers that only care about one stage
|
|
24495
|
-
* just read the matching property.
|
|
24496
|
-
*
|
|
24497
|
-
* `package` backs the package-drop detector — a package zone is a
|
|
24498
|
-
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
24499
|
-
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
24500
|
-
* The orchestrator provider writes this stage as a first-class slice
|
|
24501
|
-
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
24502
|
-
* package}` shape, so consumers read the current package rules directly
|
|
24503
|
-
* off `device.state.zoneRules.value.package`.
|
|
24504
|
-
*/
|
|
24505
|
-
runtimeState: object({
|
|
24506
|
-
motion: array(ZoneRuleSchema).readonly(),
|
|
24507
|
-
detection: array(ZoneRuleSchema).readonly(),
|
|
24508
|
-
package: array(ZoneRuleSchema).readonly()
|
|
24509
|
-
})
|
|
24510
|
-
};
|
|
24511
|
-
/**
|
|
24512
|
-
* Accessory device helpers — shared across drivers.
|
|
25221
|
+
* State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
|
|
25222
|
+
* `paused` / `returning` / `docked` / `error`. `batteryLevel`
|
|
25223
|
+
* (0..100) is nullable — some vacuums don't report a battery
|
|
25224
|
+
* percentage. `fanSpeed` is the current speed token (provider-verbatim,
|
|
25225
|
+
* e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
|
|
25226
|
+
* tokens the hardware accepts so the UI renders only supported choices.
|
|
24513
25227
|
*
|
|
24514
|
-
*
|
|
24515
|
-
*
|
|
24516
|
-
*
|
|
24517
|
-
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
24518
|
-
* spawning, builds a name derived from the parent, and produces a
|
|
24519
|
-
* stableId tied to the parent so boot-restore can reconstruct the
|
|
24520
|
-
* relationship.
|
|
25228
|
+
* The `setFanSpeed` method takes the bare `speed` token — the provider
|
|
25229
|
+
* validates it against the vacuum's own list. `locate` triggers the
|
|
25230
|
+
* find-me chirp; `returnToBase` sends it home.
|
|
24521
25231
|
*
|
|
24522
|
-
*
|
|
24523
|
-
*
|
|
24524
|
-
*
|
|
24525
|
-
*
|
|
24526
|
-
*
|
|
24527
|
-
*
|
|
24528
|
-
*
|
|
24529
|
-
|
|
24530
|
-
/**
|
|
24531
|
-
* Subset of `DeviceRole` values that drivers register as child
|
|
24532
|
-
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
24533
|
-
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
24534
|
-
* children, so the call site reads as
|
|
24535
|
-
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
24536
|
-
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
24537
|
-
* any role works, including non-accessory ones like Doorbell).
|
|
24538
|
-
*/
|
|
24539
|
-
var AccessoryKind = {
|
|
24540
|
-
Siren: DeviceRole.Siren,
|
|
24541
|
-
Floodlight: DeviceRole.Floodlight,
|
|
24542
|
-
Spotlight: DeviceRole.Spotlight,
|
|
24543
|
-
PirSensor: DeviceRole.PirSensor,
|
|
24544
|
-
Chime: DeviceRole.Chime,
|
|
24545
|
-
Autotrack: DeviceRole.Autotrack,
|
|
24546
|
-
Nightvision: DeviceRole.Nightvision,
|
|
24547
|
-
PrivacyMask: DeviceRole.PrivacyMask
|
|
24548
|
-
};
|
|
24549
|
-
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
24550
|
-
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;
|
|
24551
|
-
new Set(Object.values(DeviceType));
|
|
24552
|
-
/**
|
|
24553
|
-
* Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
|
|
24554
|
-
*/
|
|
24555
|
-
function deviceMatchesProfile(features, profile) {
|
|
24556
|
-
if (!features || features.length === 0) return false;
|
|
24557
|
-
return features.includes(profile.when.hasFeature);
|
|
24558
|
-
}
|
|
24559
|
-
/**
|
|
24560
|
-
* Profile registry — order matters when multiple profiles match the
|
|
24561
|
-
* same device (first match wins). Today there's only one entry.
|
|
24562
|
-
*/
|
|
24563
|
-
var DEVICE_PROFILES = [{
|
|
24564
|
-
id: "battery",
|
|
24565
|
-
label: "Battery-operated camera",
|
|
24566
|
-
when: { hasFeature: DeviceFeature.BatteryOperated },
|
|
24567
|
-
defaults: {
|
|
24568
|
-
audioMode: "disabled",
|
|
24569
|
-
detectionMode: "on-motion"
|
|
24570
|
-
},
|
|
24571
|
-
settings: {}
|
|
24572
|
-
}];
|
|
24573
|
-
/**
|
|
24574
|
-
* Resolve the profile that matches a device's features, or `null` when
|
|
24575
|
-
* no profile matches. First-match-wins.
|
|
25232
|
+
* Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
|
|
25233
|
+
* `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
|
|
25234
|
+
* 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
|
|
25235
|
+
* SHOULD populate whichever tanks the hardware has — leave a field `null`
|
|
25236
|
+
* only when the device has no such tank at all, and use a `TankStatus`
|
|
25237
|
+
* with both inner fields `null` when the tank exists but its level is
|
|
25238
|
+
* currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
|
|
25239
|
+
* so the HA provider leaves all four `null`.
|
|
24576
25240
|
*/
|
|
24577
|
-
|
|
24578
|
-
|
|
24579
|
-
|
|
24580
|
-
|
|
25241
|
+
var VacuumStateSchema = _enum([
|
|
25242
|
+
"idle",
|
|
25243
|
+
"cleaning",
|
|
25244
|
+
"paused",
|
|
25245
|
+
"returning",
|
|
25246
|
+
"docked",
|
|
25247
|
+
"drying",
|
|
25248
|
+
"error"
|
|
25249
|
+
]);
|
|
24581
25250
|
/**
|
|
24582
|
-
*
|
|
24583
|
-
*
|
|
24584
|
-
*
|
|
25251
|
+
* One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
|
|
25252
|
+
* detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
|
|
25253
|
+
* a discrete `status` (binary-style hardware), or both. Both `null` means the
|
|
25254
|
+
* level is currently unknown; the OWNING field being `null` means the
|
|
25255
|
+
* hardware has no such tank at all.
|
|
24585
25256
|
*/
|
|
24586
|
-
|
|
24587
|
-
|
|
24588
|
-
|
|
24589
|
-
|
|
24590
|
-
|
|
24591
|
-
|
|
24592
|
-
|
|
24593
|
-
|
|
24594
|
-
|
|
24595
|
-
};
|
|
24596
|
-
|
|
24597
|
-
|
|
24598
|
-
|
|
24599
|
-
|
|
24600
|
-
|
|
24601
|
-
|
|
24602
|
-
|
|
24603
|
-
|
|
25257
|
+
var TankStatusSchema = object({
|
|
25258
|
+
/** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
|
|
25259
|
+
level: number().min(0).max(100).nullable(),
|
|
25260
|
+
/** Discrete state when the hardware is binary-mode; null otherwise. */
|
|
25261
|
+
status: _enum([
|
|
25262
|
+
"ok",
|
|
25263
|
+
"low",
|
|
25264
|
+
"full"
|
|
25265
|
+
]).nullable()
|
|
25266
|
+
});
|
|
25267
|
+
object({
|
|
25268
|
+
/** Lifecycle state of the vacuum. */
|
|
25269
|
+
state: VacuumStateSchema,
|
|
25270
|
+
/** 0..100 battery percentage. Null when the device has no battery
|
|
25271
|
+
* reading. */
|
|
25272
|
+
batteryLevel: number().min(0).max(100).nullable(),
|
|
25273
|
+
/** Current fan-speed token (provider-verbatim). Null when unknown or
|
|
25274
|
+
* the vacuum has no speed control. */
|
|
25275
|
+
fanSpeed: string().nullable(),
|
|
25276
|
+
/** Speed tokens the hardware accepts — drives the UI selector. */
|
|
25277
|
+
availableFanSpeeds: array(string()),
|
|
25278
|
+
/** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
|
|
25279
|
+
cleanWater: TankStatusSchema.nullable(),
|
|
25280
|
+
/** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
|
|
25281
|
+
dirtyWater: TankStatusSchema.nullable(),
|
|
25282
|
+
/** Detergent tank. Null when the hardware has no detergent tank. */
|
|
25283
|
+
detergent: TankStatusSchema.nullable(),
|
|
25284
|
+
/** Dust bin. Null when the hardware has no dust bin. */
|
|
25285
|
+
dustBin: TankStatusSchema.nullable(),
|
|
25286
|
+
/** 0..100 cleaning-completion percentage of the current task, or null. */
|
|
25287
|
+
progressPercent: number().min(0).max(100).nullable(),
|
|
25288
|
+
/** Current error code (0 / null = no error). */
|
|
25289
|
+
errorCode: number().nullable(),
|
|
25290
|
+
/** Human label for {@link errorCode}, or null when none / undecodable. */
|
|
25291
|
+
errorLabel: string().nullable(),
|
|
25292
|
+
/** Ms epoch when the slice was last updated. */
|
|
25293
|
+
lastChangedAt: number()
|
|
25294
|
+
});
|
|
25295
|
+
DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25296
|
+
kind: "mutation",
|
|
25297
|
+
auth: "admin"
|
|
25298
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25299
|
+
kind: "mutation",
|
|
25300
|
+
auth: "admin"
|
|
25301
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25302
|
+
kind: "mutation",
|
|
25303
|
+
auth: "admin"
|
|
25304
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25305
|
+
kind: "mutation",
|
|
25306
|
+
auth: "admin"
|
|
25307
|
+
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25308
|
+
kind: "mutation",
|
|
25309
|
+
auth: "admin"
|
|
25310
|
+
}), method(object({
|
|
25311
|
+
deviceId: number().int().nonnegative(),
|
|
25312
|
+
speed: string().min(1)
|
|
25313
|
+
}), _void(), {
|
|
25314
|
+
kind: "mutation",
|
|
25315
|
+
auth: "admin"
|
|
25316
|
+
});
|
|
25317
|
+
object({
|
|
25318
|
+
/** Lifecycle state of the valve. */
|
|
25319
|
+
state: _enum([
|
|
25320
|
+
"open",
|
|
25321
|
+
"opening",
|
|
25322
|
+
"closing",
|
|
25323
|
+
"closed",
|
|
25324
|
+
"stopped"
|
|
25325
|
+
]),
|
|
25326
|
+
/** 0 = fully closed, 100 = fully open. Null when the device has no
|
|
25327
|
+
* intermediate position surface. */
|
|
25328
|
+
position: number().min(0).max(100).nullable(),
|
|
25329
|
+
/** Ms epoch when the slice was last updated. */
|
|
25330
|
+
lastChangedAt: number()
|
|
25331
|
+
});
|
|
25332
|
+
DeviceType.Valve, 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({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25339
|
+
kind: "mutation",
|
|
25340
|
+
auth: "admin"
|
|
25341
|
+
}), method(object({
|
|
25342
|
+
deviceId: number().int().nonnegative(),
|
|
25343
|
+
position: number().min(0).max(100)
|
|
25344
|
+
}), _void(), {
|
|
25345
|
+
kind: "mutation",
|
|
25346
|
+
auth: "admin"
|
|
25347
|
+
});
|
|
25348
|
+
object({
|
|
25349
|
+
detected: boolean(),
|
|
25350
|
+
/** Ms epoch of the last transition. 0 if never observed. */
|
|
25351
|
+
lastChangedAt: number()
|
|
25352
|
+
});
|
|
25353
|
+
DeviceType.Sensor;
|
|
25354
|
+
object({
|
|
25355
|
+
/** Current measured temperature. Null when not reported. */
|
|
25356
|
+
currentTemp: number().nullable(),
|
|
25357
|
+
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
25358
|
+
targetTemp: number().nullable(),
|
|
25359
|
+
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
25360
|
+
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
25361
|
+
* device reports an unknown state. */
|
|
25362
|
+
operationMode: string().nullable(),
|
|
25363
|
+
/** Available operation modes = HA `operation_list`. */
|
|
25364
|
+
availableModes: array(string()),
|
|
25365
|
+
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
25366
|
+
* has no away surface. */
|
|
25367
|
+
away: boolean().nullable(),
|
|
25368
|
+
/** HA `min_temp` attribute. Null when not reported. */
|
|
25369
|
+
minTemp: number().nullable(),
|
|
25370
|
+
/** HA `max_temp` attribute. Null when not reported. */
|
|
25371
|
+
maxTemp: number().nullable(),
|
|
25372
|
+
/** Ms epoch when the slice was last updated. */
|
|
25373
|
+
lastChangedAt: number()
|
|
25374
|
+
});
|
|
25375
|
+
DeviceType.WaterHeater, method(object({
|
|
25376
|
+
deviceId: number().int().nonnegative(),
|
|
25377
|
+
temp: number().finite()
|
|
25378
|
+
}), _void(), {
|
|
25379
|
+
kind: "mutation",
|
|
25380
|
+
auth: "admin"
|
|
25381
|
+
}), method(object({
|
|
25382
|
+
deviceId: number().int().nonnegative(),
|
|
25383
|
+
mode: string().min(1)
|
|
25384
|
+
}), _void(), {
|
|
25385
|
+
kind: "mutation",
|
|
25386
|
+
auth: "admin"
|
|
25387
|
+
}), method(object({
|
|
25388
|
+
deviceId: number().int().nonnegative(),
|
|
25389
|
+
on: boolean()
|
|
25390
|
+
}), _void(), {
|
|
25391
|
+
kind: "mutation",
|
|
25392
|
+
auth: "admin"
|
|
25393
|
+
});
|
|
25394
|
+
object({
|
|
25395
|
+
/** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
|
|
25396
|
+
* when no condition has been reported yet. */
|
|
25397
|
+
condition: string().nullable(),
|
|
25398
|
+
/** Current temperature in the reported unit. Null when not provided. */
|
|
25399
|
+
temperature: number().nullable(),
|
|
25400
|
+
/** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
|
|
25401
|
+
temperatureUnit: string().nullable(),
|
|
25402
|
+
/** Relative humidity (0..100). Null when not provided. */
|
|
25403
|
+
humidity: number().min(0).max(100).nullable(),
|
|
25404
|
+
/** Barometric pressure in the reported unit. Null when not provided. */
|
|
25405
|
+
pressure: number().nullable(),
|
|
25406
|
+
/** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
|
|
25407
|
+
pressureUnit: string().nullable(),
|
|
25408
|
+
/** Wind speed in the reported unit. Null when not provided. */
|
|
25409
|
+
windSpeed: number().nullable(),
|
|
25410
|
+
/** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
|
|
25411
|
+
windSpeedUnit: string().nullable(),
|
|
25412
|
+
/** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
|
|
25413
|
+
windBearing: number().nullable(),
|
|
25414
|
+
/** Ms epoch when the slice was last updated. */
|
|
25415
|
+
lastFetchedAt: number()
|
|
25416
|
+
});
|
|
25417
|
+
DeviceType.Weather;
|
|
24604
25418
|
/**
|
|
24605
|
-
*
|
|
24606
|
-
*
|
|
24607
|
-
*
|
|
24608
|
-
*
|
|
24609
|
-
*
|
|
24610
|
-
* Because the object has a NULL prototype AND is `Object.freeze`d:
|
|
24611
|
-
* - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
|
|
24612
|
-
* - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
|
|
24613
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
24614
|
-
* callable — they are simply "unknown function" at parse time.
|
|
25419
|
+
* Per-zone occupancy aggregation produced by the analytics frame
|
|
25420
|
+
* processor on every inference result. Covers the full combinatorial
|
|
25421
|
+
* matrix the operator UI needs: total objects everywhere, total
|
|
25422
|
+
* objects per zone, single class everywhere, single class per zone,
|
|
25423
|
+
* objects outside any zone.
|
|
24615
25424
|
*
|
|
24616
|
-
*
|
|
24617
|
-
*
|
|
24618
|
-
*
|
|
24619
|
-
*
|
|
24620
|
-
*/
|
|
24621
|
-
function asFiniteNumber(value, name, index) {
|
|
24622
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
|
|
24623
|
-
return value;
|
|
24624
|
-
}
|
|
24625
|
-
function asString$1(value, name, index) {
|
|
24626
|
-
if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
|
|
24627
|
-
return value;
|
|
24628
|
-
}
|
|
24629
|
-
function finiteResult(value, name) {
|
|
24630
|
-
if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
|
|
24631
|
-
return value;
|
|
24632
|
-
}
|
|
24633
|
-
function allFiniteNumbers(args, name) {
|
|
24634
|
-
return args.map((a, idx) => asFiniteNumber(a, name, idx));
|
|
24635
|
-
}
|
|
24636
|
-
var INF = Number.POSITIVE_INFINITY;
|
|
24637
|
-
var table = {
|
|
24638
|
-
min: {
|
|
24639
|
-
minArgs: 1,
|
|
24640
|
-
maxArgs: INF,
|
|
24641
|
-
apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
|
|
24642
|
-
},
|
|
24643
|
-
max: {
|
|
24644
|
-
minArgs: 1,
|
|
24645
|
-
maxArgs: INF,
|
|
24646
|
-
apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
|
|
24647
|
-
},
|
|
24648
|
-
abs: {
|
|
24649
|
-
minArgs: 1,
|
|
24650
|
-
maxArgs: 1,
|
|
24651
|
-
apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
|
|
24652
|
-
},
|
|
24653
|
-
floor: {
|
|
24654
|
-
minArgs: 1,
|
|
24655
|
-
maxArgs: 1,
|
|
24656
|
-
apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
|
|
24657
|
-
},
|
|
24658
|
-
ceil: {
|
|
24659
|
-
minArgs: 1,
|
|
24660
|
-
maxArgs: 1,
|
|
24661
|
-
apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
|
|
24662
|
-
},
|
|
24663
|
-
sqrt: {
|
|
24664
|
-
minArgs: 1,
|
|
24665
|
-
maxArgs: 1,
|
|
24666
|
-
apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
|
|
24667
|
-
},
|
|
24668
|
-
round: {
|
|
24669
|
-
minArgs: 1,
|
|
24670
|
-
maxArgs: 2,
|
|
24671
|
-
apply: (args) => {
|
|
24672
|
-
const x = asFiniteNumber(args[0], "round", 0);
|
|
24673
|
-
const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
|
|
24674
|
-
if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
|
|
24675
|
-
const factor = 10 ** digits;
|
|
24676
|
-
return finiteResult(Math.round(x * factor) / factor, "round");
|
|
24677
|
-
}
|
|
24678
|
-
},
|
|
24679
|
-
pow: {
|
|
24680
|
-
minArgs: 2,
|
|
24681
|
-
maxArgs: 2,
|
|
24682
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
|
|
24683
|
-
},
|
|
24684
|
-
clamp: {
|
|
24685
|
-
minArgs: 3,
|
|
24686
|
-
maxArgs: 3,
|
|
24687
|
-
apply: (args) => {
|
|
24688
|
-
const x = asFiniteNumber(args[0], "clamp", 0);
|
|
24689
|
-
const lo = asFiniteNumber(args[1], "clamp", 1);
|
|
24690
|
-
const hi = asFiniteNumber(args[2], "clamp", 2);
|
|
24691
|
-
if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
|
|
24692
|
-
return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
|
|
24693
|
-
}
|
|
24694
|
-
},
|
|
24695
|
-
avg: {
|
|
24696
|
-
minArgs: 1,
|
|
24697
|
-
maxArgs: INF,
|
|
24698
|
-
apply: (args) => {
|
|
24699
|
-
const nums = allFiniteNumbers(args, "avg");
|
|
24700
|
-
return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
|
|
24701
|
-
}
|
|
24702
|
-
},
|
|
24703
|
-
sum: {
|
|
24704
|
-
minArgs: 1,
|
|
24705
|
-
maxArgs: INF,
|
|
24706
|
-
apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
|
|
24707
|
-
},
|
|
24708
|
-
coalesce: {
|
|
24709
|
-
minArgs: 1,
|
|
24710
|
-
maxArgs: INF,
|
|
24711
|
-
apply: (args) => {
|
|
24712
|
-
for (const a of args) if (a !== null) return a;
|
|
24713
|
-
return null;
|
|
24714
|
-
}
|
|
24715
|
-
},
|
|
24716
|
-
age: {
|
|
24717
|
-
minArgs: 2,
|
|
24718
|
-
maxArgs: 2,
|
|
24719
|
-
apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
|
|
24720
|
-
},
|
|
24721
|
-
convert: {
|
|
24722
|
-
minArgs: 3,
|
|
24723
|
-
maxArgs: 3,
|
|
24724
|
-
apply: (args, hooks) => {
|
|
24725
|
-
const x = asFiniteNumber(args[0], "convert", 0);
|
|
24726
|
-
const from = asString$1(args[1], "convert", 1).trim();
|
|
24727
|
-
const to = asString$1(args[2], "convert", 2).trim();
|
|
24728
|
-
if (hooks.convert) {
|
|
24729
|
-
const out = hooks.convert(x, from, to);
|
|
24730
|
-
if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
|
|
24731
|
-
return finiteResult(out, "convert");
|
|
24732
|
-
}
|
|
24733
|
-
if (from === to) return x;
|
|
24734
|
-
throw new ExpressionEvalError("convert: unit conversion table not installed");
|
|
24735
|
-
}
|
|
24736
|
-
}
|
|
24737
|
-
};
|
|
24738
|
-
Object.freeze(Object.assign(Object.create(null), table));
|
|
24739
|
-
/** The set of valid builtin names — used by the parser to reject unknown
|
|
24740
|
-
* callees at parse time (immediate author feedback). */
|
|
24741
|
-
var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
|
|
24742
|
-
/**
|
|
24743
|
-
* Resource-bound constants for the safe expression engine.
|
|
25425
|
+
* Counts are derived from the analytics tracker (tracked detections
|
|
25426
|
+
* with stable trackIds), not raw detector hits — this filters out
|
|
25427
|
+
* one-off detector flickers and gives counts that match what the user
|
|
25428
|
+
* sees on the live overlay.
|
|
24744
25429
|
*
|
|
24745
|
-
*
|
|
24746
|
-
*
|
|
24747
|
-
*
|
|
24748
|
-
*
|
|
24749
|
-
* accidental pathological string can never spend unbounded CPU/memory.
|
|
25430
|
+
* Overlap policy: a detection that intersects two zones counts in
|
|
25431
|
+
* BOTH zones' `byClass` and `totalObjects` (count-in-each). The
|
|
25432
|
+
* `frame` aggregate de-duplicates trivially since it's frame-wide.
|
|
25433
|
+
* `unzoned` counts only detections that landed in zero zones.
|
|
24750
25434
|
*/
|
|
24751
|
-
|
|
24752
|
-
|
|
24753
|
-
|
|
24754
|
-
/**
|
|
24755
|
-
|
|
24756
|
-
|
|
24757
|
-
|
|
24758
|
-
|
|
24759
|
-
|
|
24760
|
-
|
|
24761
|
-
|
|
24762
|
-
|
|
24763
|
-
|
|
25435
|
+
var PerScopeBreakdownSchema = object({
|
|
25436
|
+
/** Total tracked objects in this scope (frame / zone / unzoned). */
|
|
25437
|
+
totalObjects: number().int().nonnegative(),
|
|
25438
|
+
/** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
|
|
25439
|
+
byClass: record(string(), number().int().nonnegative())
|
|
25440
|
+
});
|
|
25441
|
+
var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
|
|
25442
|
+
zoneId: string(),
|
|
25443
|
+
zoneName: string(),
|
|
25444
|
+
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
25445
|
+
* with the per-track detail panel and live overlay. */
|
|
25446
|
+
trackIds: array(string()).readonly()
|
|
25447
|
+
});
|
|
24764
25448
|
/**
|
|
24765
|
-
*
|
|
24766
|
-
*
|
|
24767
|
-
*
|
|
24768
|
-
*
|
|
24769
|
-
*
|
|
24770
|
-
*
|
|
24771
|
-
* template literals are lexically impossible.
|
|
25449
|
+
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
25450
|
+
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
25451
|
+
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
25452
|
+
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
25453
|
+
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
25454
|
+
* of flooding the live event feed.
|
|
24772
25455
|
*/
|
|
24773
|
-
var
|
|
24774
|
-
|
|
24775
|
-
|
|
24776
|
-
|
|
25456
|
+
var StationaryObjectSchema = object({
|
|
25457
|
+
id: string(),
|
|
25458
|
+
className: string(),
|
|
25459
|
+
bbox: object({
|
|
25460
|
+
x: number(),
|
|
25461
|
+
y: number(),
|
|
25462
|
+
w: number(),
|
|
25463
|
+
h: number()
|
|
25464
|
+
}),
|
|
25465
|
+
frameWidth: number().int().nonnegative(),
|
|
25466
|
+
frameHeight: number().int().nonnegative(),
|
|
25467
|
+
/** When the source track was first seen. */
|
|
25468
|
+
firstSeenAt: number().int(),
|
|
25469
|
+
/** When the object was recognised as parked (promotion time). */
|
|
25470
|
+
becameStationaryAt: number().int(),
|
|
25471
|
+
/** Last frame a detection confirmed the object is still there. */
|
|
25472
|
+
lastConfirmedAt: number().int(),
|
|
25473
|
+
/** Enrichment label carried from the source track (identity / plate). */
|
|
25474
|
+
label: string().optional(),
|
|
25475
|
+
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
25476
|
+
keyFrameMediaKey: string().optional()
|
|
25477
|
+
});
|
|
25478
|
+
var CameraOccupancySnapshotSchema = object({
|
|
25479
|
+
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
25480
|
+
ts: number().int(),
|
|
25481
|
+
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
25482
|
+
frameWidth: number().int().nonnegative(),
|
|
25483
|
+
frameHeight: number().int().nonnegative(),
|
|
25484
|
+
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
25485
|
+
zones: array(ZoneScopeBreakdownSchema).readonly(),
|
|
25486
|
+
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
25487
|
+
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
25488
|
+
frame: PerScopeBreakdownSchema,
|
|
25489
|
+
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
25490
|
+
unzoned: PerScopeBreakdownSchema,
|
|
25491
|
+
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
25492
|
+
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
25493
|
+
* than as repeated tracks/events. */
|
|
25494
|
+
stationaryObjects: array(StationaryObjectSchema).readonly().optional()
|
|
25495
|
+
});
|
|
25496
|
+
/**
|
|
25497
|
+
* Time-series resolution. The history methods return one bucket per
|
|
25498
|
+
* step over the requested range. Smaller resolutions cost more
|
|
25499
|
+
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
25500
|
+
* for arbitrary fractional buckets.
|
|
25501
|
+
*/
|
|
25502
|
+
var HistoryResolutionEnum = _enum([
|
|
25503
|
+
"minute",
|
|
25504
|
+
"5min",
|
|
25505
|
+
"hour"
|
|
24777
25506
|
]);
|
|
24778
|
-
|
|
24779
|
-
|
|
24780
|
-
|
|
24781
|
-
|
|
24782
|
-
|
|
24783
|
-
|
|
24784
|
-
|
|
24785
|
-
|
|
24786
|
-
|
|
24787
|
-
|
|
24788
|
-
|
|
24789
|
-
|
|
24790
|
-
|
|
24791
|
-
|
|
24792
|
-
|
|
24793
|
-
|
|
24794
|
-
|
|
24795
|
-
|
|
24796
|
-
|
|
24797
|
-
|
|
24798
|
-
|
|
24799
|
-
|
|
24800
|
-
|
|
24801
|
-
|
|
24802
|
-
continue;
|
|
24803
|
-
}
|
|
24804
|
-
if (isDigit(ch)) {
|
|
24805
|
-
const start = i;
|
|
24806
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
24807
|
-
if (i < n && source[i] === ".") {
|
|
24808
|
-
if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
|
|
24809
|
-
i += 1;
|
|
24810
|
-
while (i < n && isDigit(source[i])) i += 1;
|
|
24811
|
-
}
|
|
24812
|
-
const text = source.slice(start, i);
|
|
24813
|
-
const value = Number(text);
|
|
24814
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
24815
|
-
tokens.push({
|
|
24816
|
-
type: "number",
|
|
24817
|
-
value,
|
|
24818
|
-
pos: start
|
|
24819
|
-
});
|
|
24820
|
-
continue;
|
|
24821
|
-
}
|
|
24822
|
-
if (ch === "'" || ch === "\"") {
|
|
24823
|
-
const quote = ch;
|
|
24824
|
-
const start = i;
|
|
24825
|
-
i += 1;
|
|
24826
|
-
let out = "";
|
|
24827
|
-
let closed = false;
|
|
24828
|
-
while (i < n) {
|
|
24829
|
-
const c = source[i];
|
|
24830
|
-
if (c === "\\") {
|
|
24831
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
24832
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
24833
|
-
out += next;
|
|
24834
|
-
i += 2;
|
|
24835
|
-
continue;
|
|
24836
|
-
}
|
|
24837
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
24838
|
-
}
|
|
24839
|
-
if (c === quote) {
|
|
24840
|
-
closed = true;
|
|
24841
|
-
i += 1;
|
|
24842
|
-
break;
|
|
24843
|
-
}
|
|
24844
|
-
out += c;
|
|
24845
|
-
i += 1;
|
|
24846
|
-
}
|
|
24847
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
24848
|
-
tokens.push({
|
|
24849
|
-
type: "string",
|
|
24850
|
-
value: out,
|
|
24851
|
-
pos: start
|
|
24852
|
-
});
|
|
24853
|
-
continue;
|
|
24854
|
-
}
|
|
24855
|
-
if (isIdentStart(ch)) {
|
|
24856
|
-
const start = i;
|
|
24857
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
24858
|
-
const text = source.slice(start, i);
|
|
24859
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
24860
|
-
type: "keyword",
|
|
24861
|
-
keyword: keywordOf(text),
|
|
24862
|
-
pos: start
|
|
24863
|
-
});
|
|
24864
|
-
else tokens.push({
|
|
24865
|
-
type: "identifier",
|
|
24866
|
-
name: text,
|
|
24867
|
-
pos: start
|
|
24868
|
-
});
|
|
24869
|
-
continue;
|
|
24870
|
-
}
|
|
24871
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
24872
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
24873
|
-
tokens.push({
|
|
24874
|
-
type: "punct",
|
|
24875
|
-
punct: two,
|
|
24876
|
-
pos: i
|
|
24877
|
-
});
|
|
24878
|
-
i += 2;
|
|
24879
|
-
continue;
|
|
24880
|
-
}
|
|
24881
|
-
if (isSinglePunct(ch)) {
|
|
24882
|
-
tokens.push({
|
|
24883
|
-
type: "punct",
|
|
24884
|
-
punct: ch,
|
|
24885
|
-
pos: i
|
|
24886
|
-
});
|
|
24887
|
-
i += 1;
|
|
24888
|
-
continue;
|
|
24889
|
-
}
|
|
24890
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
24891
|
-
}
|
|
24892
|
-
tokens.push({
|
|
24893
|
-
type: "eof",
|
|
24894
|
-
pos: n
|
|
24895
|
-
});
|
|
24896
|
-
return tokens;
|
|
24897
|
-
}
|
|
24898
|
-
function keywordOf(text) {
|
|
24899
|
-
if (text === "true") return "true";
|
|
24900
|
-
if (text === "false") return "false";
|
|
24901
|
-
return "null";
|
|
24902
|
-
}
|
|
24903
|
-
function isSinglePunct(ch) {
|
|
24904
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
24905
|
-
}
|
|
25507
|
+
var HistoryRangeSchema = object({
|
|
25508
|
+
/** Range start (epoch ms, inclusive). */
|
|
25509
|
+
from: number().int(),
|
|
25510
|
+
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
25511
|
+
to: number().int(),
|
|
25512
|
+
resolution: HistoryResolutionEnum
|
|
25513
|
+
});
|
|
25514
|
+
var HistoryPointSchema = object({
|
|
25515
|
+
/** Bucket midpoint (epoch ms). */
|
|
25516
|
+
ts: number().int(),
|
|
25517
|
+
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
25518
|
+
count: number().int().nonnegative()
|
|
25519
|
+
});
|
|
25520
|
+
DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
|
|
25521
|
+
deviceId: number(),
|
|
25522
|
+
zoneId: string(),
|
|
25523
|
+
className: string().optional()
|
|
25524
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
25525
|
+
deviceId: number(),
|
|
25526
|
+
className: string().optional()
|
|
25527
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
25528
|
+
deviceId: number(),
|
|
25529
|
+
className: string().optional()
|
|
25530
|
+
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
|
|
24906
25531
|
/**
|
|
24907
|
-
*
|
|
25532
|
+
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
25533
|
+
* cap so a single CRUD surface backs every consumer; each stage has
|
|
25534
|
+
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
25535
|
+
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
24908
25536
|
*
|
|
24909
|
-
*
|
|
24910
|
-
*
|
|
24911
|
-
|
|
24912
|
-
|
|
24913
|
-
|
|
24914
|
-
|
|
25537
|
+
* Extend the enum here when a new gating consumer comes online (audio
|
|
25538
|
+
* gating, alert filtering, …) — no other surface needs to change.
|
|
25539
|
+
*/
|
|
25540
|
+
var ZoneRuleStageEnum = _enum([
|
|
25541
|
+
"motion",
|
|
25542
|
+
"detection",
|
|
25543
|
+
"package"
|
|
25544
|
+
]);
|
|
25545
|
+
/**
|
|
25546
|
+
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
25547
|
+
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
24915
25548
|
*
|
|
24916
|
-
*
|
|
24917
|
-
*
|
|
25549
|
+
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
25550
|
+
* so the operator has a single hub-side source of truth for both
|
|
25551
|
+
* geometry and behaviour. Per-stage rules are stored under the
|
|
25552
|
+
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
25553
|
+
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
25554
|
+
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
25555
|
+
* subscribe to that slice and refresh their gating without
|
|
25556
|
+
* round-tripping the cap.
|
|
25557
|
+
*
|
|
25558
|
+
* Sets are bulk-replace — the operator UI sends the new rule list
|
|
25559
|
+
* wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
|
|
25560
|
+
* naturally in the rule editor without per-rule mutation chatter.
|
|
24918
25561
|
*/
|
|
24919
|
-
|
|
24920
|
-
|
|
24921
|
-
"
|
|
24922
|
-
"
|
|
24923
|
-
|
|
24924
|
-
|
|
24925
|
-
|
|
24926
|
-
|
|
24927
|
-
|
|
24928
|
-
|
|
24929
|
-
|
|
24930
|
-
|
|
24931
|
-
|
|
24932
|
-
|
|
24933
|
-
|
|
24934
|
-
|
|
24935
|
-
|
|
24936
|
-
|
|
24937
|
-
|
|
24938
|
-
|
|
24939
|
-
|
|
24940
|
-
|
|
24941
|
-
|
|
24942
|
-
|
|
24943
|
-
|
|
24944
|
-
|
|
24945
|
-
|
|
24946
|
-
|
|
24947
|
-
|
|
24948
|
-
|
|
24949
|
-
|
|
24950
|
-
|
|
24951
|
-
|
|
24952
|
-
|
|
24953
|
-
|
|
24954
|
-
|
|
24955
|
-
|
|
24956
|
-
|
|
24957
|
-
|
|
24958
|
-
|
|
24959
|
-
|
|
24960
|
-
|
|
24961
|
-
|
|
24962
|
-
|
|
24963
|
-
|
|
24964
|
-
|
|
24965
|
-
return this.tokens[this.pos++];
|
|
24966
|
-
}
|
|
24967
|
-
/** Consume a punctuator token, erroring if the next token isn't it. */
|
|
24968
|
-
expectPunct(punct) {
|
|
24969
|
-
const tok = this.peek();
|
|
24970
|
-
if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
|
|
24971
|
-
this.pos += 1;
|
|
24972
|
-
}
|
|
24973
|
-
matchPunct(punct) {
|
|
24974
|
-
const tok = this.peek();
|
|
24975
|
-
if (tok.type === "punct" && tok.punct === punct) {
|
|
24976
|
-
this.pos += 1;
|
|
24977
|
-
return true;
|
|
24978
|
-
}
|
|
24979
|
-
return false;
|
|
24980
|
-
}
|
|
24981
|
-
countNode() {
|
|
24982
|
-
this.nodeCount += 1;
|
|
24983
|
-
if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
|
|
24984
|
-
}
|
|
24985
|
-
parseTernary() {
|
|
24986
|
-
const test = this.parseBinary(1);
|
|
24987
|
-
if (this.matchPunct("?")) {
|
|
24988
|
-
const consequent = this.parseTernary();
|
|
24989
|
-
this.expectPunct(":");
|
|
24990
|
-
const alternate = this.parseTernary();
|
|
24991
|
-
this.countNode();
|
|
24992
|
-
return {
|
|
24993
|
-
kind: "conditional",
|
|
24994
|
-
test,
|
|
24995
|
-
consequent,
|
|
24996
|
-
alternate
|
|
24997
|
-
};
|
|
24998
|
-
}
|
|
24999
|
-
return test;
|
|
25000
|
-
}
|
|
25001
|
-
parseBinary(minPrec) {
|
|
25002
|
-
let left = this.parseUnary();
|
|
25003
|
-
for (;;) {
|
|
25004
|
-
const tok = this.peek();
|
|
25005
|
-
if (tok.type !== "punct") break;
|
|
25006
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
25007
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
25008
|
-
const op = tok.punct;
|
|
25009
|
-
this.pos += 1;
|
|
25010
|
-
const right = this.parseBinary(prec + 1);
|
|
25011
|
-
this.countNode();
|
|
25012
|
-
if (isLogicalOp(op)) left = {
|
|
25013
|
-
kind: "logical",
|
|
25014
|
-
op,
|
|
25015
|
-
left,
|
|
25016
|
-
right
|
|
25017
|
-
};
|
|
25018
|
-
else if (isBinaryOp(op)) left = {
|
|
25019
|
-
kind: "binary",
|
|
25020
|
-
op,
|
|
25021
|
-
left,
|
|
25022
|
-
right
|
|
25023
|
-
};
|
|
25024
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
25025
|
-
}
|
|
25026
|
-
return left;
|
|
25027
|
-
}
|
|
25028
|
-
parseUnary() {
|
|
25029
|
-
const tok = this.peek();
|
|
25030
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
25031
|
-
const op = tok.punct;
|
|
25032
|
-
this.pos += 1;
|
|
25033
|
-
const operand = this.parseUnary();
|
|
25034
|
-
this.countNode();
|
|
25035
|
-
return {
|
|
25036
|
-
kind: "unary",
|
|
25037
|
-
op,
|
|
25038
|
-
operand
|
|
25039
|
-
};
|
|
25040
|
-
}
|
|
25041
|
-
return this.parsePrimary();
|
|
25042
|
-
}
|
|
25043
|
-
parsePrimary() {
|
|
25044
|
-
const tok = this.next();
|
|
25045
|
-
switch (tok.type) {
|
|
25046
|
-
case "number":
|
|
25047
|
-
this.countNode();
|
|
25048
|
-
return {
|
|
25049
|
-
kind: "literal",
|
|
25050
|
-
value: tok.value
|
|
25051
|
-
};
|
|
25052
|
-
case "string":
|
|
25053
|
-
this.countNode();
|
|
25054
|
-
return {
|
|
25055
|
-
kind: "literal",
|
|
25056
|
-
value: tok.value
|
|
25057
|
-
};
|
|
25058
|
-
case "keyword":
|
|
25059
|
-
this.countNode();
|
|
25060
|
-
return {
|
|
25061
|
-
kind: "literal",
|
|
25062
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
25063
|
-
};
|
|
25064
|
-
case "identifier": {
|
|
25065
|
-
const nextTok = this.peek();
|
|
25066
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
25067
|
-
this.identifiers.add(tok.name);
|
|
25068
|
-
this.countNode();
|
|
25069
|
-
return {
|
|
25070
|
-
kind: "identifier",
|
|
25071
|
-
name: tok.name
|
|
25072
|
-
};
|
|
25073
|
-
}
|
|
25074
|
-
case "punct":
|
|
25075
|
-
if (tok.punct === "(") {
|
|
25076
|
-
const inner = this.parseTernary();
|
|
25077
|
-
this.expectPunct(")");
|
|
25078
|
-
return inner;
|
|
25079
|
-
}
|
|
25080
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
25081
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
25082
|
-
}
|
|
25083
|
-
}
|
|
25084
|
-
parseCall(callee, pos) {
|
|
25085
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
25086
|
-
this.expectPunct("(");
|
|
25087
|
-
const args = [];
|
|
25088
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
25089
|
-
args.push(this.parseTernary());
|
|
25090
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
25091
|
-
if (this.matchPunct(",")) continue;
|
|
25092
|
-
this.expectPunct(")");
|
|
25093
|
-
break;
|
|
25094
|
-
}
|
|
25095
|
-
this.callees.add(callee);
|
|
25096
|
-
this.countNode();
|
|
25097
|
-
return {
|
|
25098
|
-
kind: "call",
|
|
25099
|
-
callee,
|
|
25100
|
-
args
|
|
25101
|
-
};
|
|
25102
|
-
}
|
|
25562
|
+
var zoneRulesCapability = {
|
|
25563
|
+
name: "zone-rules",
|
|
25564
|
+
scope: "device",
|
|
25565
|
+
mode: "singleton",
|
|
25566
|
+
deviceTypes: [DeviceType.Camera],
|
|
25567
|
+
methods: {
|
|
25568
|
+
/** Read the full rule list for a given stage (empty when no rules
|
|
25569
|
+
* are defined yet). */
|
|
25570
|
+
listRules: method(object({
|
|
25571
|
+
deviceId: number(),
|
|
25572
|
+
stage: ZoneRuleStageEnum
|
|
25573
|
+
}), array(ZoneRuleSchema).readonly()),
|
|
25574
|
+
/** Bulk-replace the rule list for one stage. The provider validates
|
|
25575
|
+
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
25576
|
+
* thresholds in range) and rejects the whole patch if any entry
|
|
25577
|
+
* is invalid — partial writes are a configuration footgun. */
|
|
25578
|
+
setRules: method(object({
|
|
25579
|
+
deviceId: number(),
|
|
25580
|
+
stage: ZoneRuleStageEnum,
|
|
25581
|
+
rules: array(ZoneRuleSchema).readonly()
|
|
25582
|
+
}), _void(), {
|
|
25583
|
+
kind: "mutation",
|
|
25584
|
+
auth: "admin"
|
|
25585
|
+
})
|
|
25586
|
+
},
|
|
25587
|
+
/**
|
|
25588
|
+
* Runtime-state slice — every stage mirrored together so consumers
|
|
25589
|
+
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
25590
|
+
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
25591
|
+
* `{motion, detection, package}` shape, so subscribers always get the
|
|
25592
|
+
* complete current set. Consumers that only care about one stage
|
|
25593
|
+
* just read the matching property.
|
|
25594
|
+
*
|
|
25595
|
+
* `package` backs the package-drop detector — a package zone is a
|
|
25596
|
+
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
25597
|
+
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
25598
|
+
* The orchestrator provider writes this stage as a first-class slice
|
|
25599
|
+
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
25600
|
+
* package}` shape, so consumers read the current package rules directly
|
|
25601
|
+
* off `device.state.zoneRules.value.package`.
|
|
25602
|
+
*/
|
|
25603
|
+
runtimeState: object({
|
|
25604
|
+
motion: array(ZoneRuleSchema).readonly(),
|
|
25605
|
+
detection: array(ZoneRuleSchema).readonly(),
|
|
25606
|
+
package: array(ZoneRuleSchema).readonly()
|
|
25607
|
+
})
|
|
25103
25608
|
};
|
|
25104
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
25105
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
25106
|
-
function parseExpression(source) {
|
|
25107
|
-
return new Parser(tokenize(source)).parse();
|
|
25108
|
-
}
|
|
25109
25609
|
/**
|
|
25110
|
-
*
|
|
25111
|
-
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
25112
|
-
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
25113
|
-
* one per read on a hot resolve path.
|
|
25610
|
+
* Accessory device helpers — shared across drivers.
|
|
25114
25611
|
*
|
|
25115
|
-
*
|
|
25116
|
-
*
|
|
25117
|
-
*
|
|
25612
|
+
* Many vendor-specific drivers register accessory child devices on
|
|
25613
|
+
* top of a parent (Reolink: siren / floodlight / PIR / autotrack /
|
|
25614
|
+
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
25615
|
+
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
25616
|
+
* spawning, builds a name derived from the parent, and produces a
|
|
25617
|
+
* stableId tied to the parent so boot-restore can reconstruct the
|
|
25618
|
+
* relationship.
|
|
25619
|
+
*
|
|
25620
|
+
* Centralised `(kind → DeviceType)` mapping was dropped on purpose:
|
|
25621
|
+
* drivers may reasonably disagree on the right type for an accessory
|
|
25622
|
+
* (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
|
|
25623
|
+
* read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
|
|
25624
|
+
* one canonical mapping was over-prescriptive and added a layer of
|
|
25625
|
+
* indirection without saving meaningful code at call sites — the
|
|
25626
|
+
* driver knows its own hardware best.
|
|
25118
25627
|
*/
|
|
25119
|
-
|
|
25120
|
-
|
|
25121
|
-
|
|
25122
|
-
|
|
25123
|
-
|
|
25124
|
-
|
|
25125
|
-
|
|
25126
|
-
|
|
25127
|
-
|
|
25128
|
-
|
|
25129
|
-
|
|
25130
|
-
|
|
25131
|
-
|
|
25132
|
-
|
|
25133
|
-
|
|
25134
|
-
|
|
25135
|
-
|
|
25136
|
-
|
|
25137
|
-
|
|
25138
|
-
|
|
25139
|
-
|
|
25140
|
-
|
|
25141
|
-
|
|
25142
|
-
|
|
25143
|
-
|
|
25144
|
-
|
|
25145
|
-
|
|
25146
|
-
|
|
25147
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
25148
|
-
function compileExpressionSafe(source) {
|
|
25149
|
-
return getCached(source);
|
|
25628
|
+
/**
|
|
25629
|
+
* Subset of `DeviceRole` values that drivers register as child
|
|
25630
|
+
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
25631
|
+
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
25632
|
+
* children, so the call site reads as
|
|
25633
|
+
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
25634
|
+
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
25635
|
+
* any role works, including non-accessory ones like Doorbell).
|
|
25636
|
+
*/
|
|
25637
|
+
var AccessoryKind = {
|
|
25638
|
+
Siren: DeviceRole.Siren,
|
|
25639
|
+
Floodlight: DeviceRole.Floodlight,
|
|
25640
|
+
Spotlight: DeviceRole.Spotlight,
|
|
25641
|
+
PirSensor: DeviceRole.PirSensor,
|
|
25642
|
+
Chime: DeviceRole.Chime,
|
|
25643
|
+
Autotrack: DeviceRole.Autotrack,
|
|
25644
|
+
Nightvision: DeviceRole.Nightvision,
|
|
25645
|
+
PrivacyMask: DeviceRole.PrivacyMask
|
|
25646
|
+
};
|
|
25647
|
+
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
25648
|
+
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;
|
|
25649
|
+
new Set(Object.values(DeviceType));
|
|
25650
|
+
/**
|
|
25651
|
+
* Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
|
|
25652
|
+
*/
|
|
25653
|
+
function deviceMatchesProfile(features, profile) {
|
|
25654
|
+
if (!features || features.length === 0) return false;
|
|
25655
|
+
return features.includes(profile.when.hasFeature);
|
|
25150
25656
|
}
|
|
25151
|
-
Object.freeze({});
|
|
25152
25657
|
/**
|
|
25153
|
-
*
|
|
25154
|
-
*
|
|
25155
|
-
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
25156
|
-
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
25157
|
-
* FREE identifier of the AST is covered by a binding or the injected `now`.
|
|
25658
|
+
* Profile registry — order matters when multiple profiles match the
|
|
25659
|
+
* same device (first match wins). Today there's only one entry.
|
|
25158
25660
|
*/
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
}
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
25661
|
+
var DEVICE_PROFILES = [{
|
|
25662
|
+
id: "battery",
|
|
25663
|
+
label: "Battery-operated camera",
|
|
25664
|
+
when: { hasFeature: DeviceFeature.BatteryOperated },
|
|
25665
|
+
defaults: {
|
|
25666
|
+
audioMode: "disabled",
|
|
25667
|
+
detectionMode: "on-motion"
|
|
25668
|
+
},
|
|
25669
|
+
settings: {}
|
|
25670
|
+
}];
|
|
25671
|
+
/**
|
|
25672
|
+
* Resolve the profile that matches a device's features, or `null` when
|
|
25673
|
+
* no profile matches. First-match-wins.
|
|
25674
|
+
*/
|
|
25675
|
+
function resolveDeviceProfile(features) {
|
|
25676
|
+
for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
|
|
25174
25677
|
return null;
|
|
25175
25678
|
}
|
|
25176
|
-
var ExpressionBindingSourceSchema = union([
|
|
25177
|
-
object({
|
|
25178
|
-
kind: literal("field").optional(),
|
|
25179
|
-
sourceKey: string(),
|
|
25180
|
-
cap: string(),
|
|
25181
|
-
fieldPath: string()
|
|
25182
|
-
}),
|
|
25183
|
-
object({
|
|
25184
|
-
kind: literal("literal"),
|
|
25185
|
-
value: union([
|
|
25186
|
-
string(),
|
|
25187
|
-
number(),
|
|
25188
|
-
boolean(),
|
|
25189
|
-
_null()
|
|
25190
|
-
])
|
|
25191
|
-
}),
|
|
25192
|
-
object({
|
|
25193
|
-
kind: literal("global"),
|
|
25194
|
-
sourceStableId: string(),
|
|
25195
|
-
cap: string(),
|
|
25196
|
-
fieldPath: string()
|
|
25197
|
-
})
|
|
25198
|
-
]);
|
|
25199
|
-
object({
|
|
25200
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
25201
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
25202
|
-
}).superRefine((src, ctx) => {
|
|
25203
|
-
const err = validateExpressionSource(src);
|
|
25204
|
-
if (err !== null) ctx.addIssue({
|
|
25205
|
-
code: "custom",
|
|
25206
|
-
message: err,
|
|
25207
|
-
path: ["expr"]
|
|
25208
|
-
});
|
|
25209
|
-
});
|
|
25210
25679
|
Object.freeze({
|
|
25211
25680
|
"accessories.setChildHidden": {
|
|
25212
25681
|
capName: "accessories",
|
|
@@ -25982,6 +26451,18 @@ Object.freeze({
|
|
|
25982
26451
|
addonId: null,
|
|
25983
26452
|
access: "create"
|
|
25984
26453
|
},
|
|
26454
|
+
"connectionTest.describeTest": {
|
|
26455
|
+
capName: "connection-test",
|
|
26456
|
+
capScope: "system",
|
|
26457
|
+
addonId: null,
|
|
26458
|
+
access: "view"
|
|
26459
|
+
},
|
|
26460
|
+
"connectionTest.testSettings": {
|
|
26461
|
+
capName: "connection-test",
|
|
26462
|
+
capScope: "system",
|
|
26463
|
+
addonId: null,
|
|
26464
|
+
access: "create"
|
|
26465
|
+
},
|
|
25985
26466
|
"consumables.reset": {
|
|
25986
26467
|
capName: "consumables",
|
|
25987
26468
|
capScope: "device",
|
|
@@ -26372,6 +26853,12 @@ Object.freeze({
|
|
|
26372
26853
|
addonId: null,
|
|
26373
26854
|
access: "create"
|
|
26374
26855
|
},
|
|
26856
|
+
"deviceManager.adoptionCancelJob": {
|
|
26857
|
+
capName: "device-manager",
|
|
26858
|
+
capScope: "system",
|
|
26859
|
+
addonId: null,
|
|
26860
|
+
access: "create"
|
|
26861
|
+
},
|
|
26375
26862
|
"deviceManager.adoptionListCandidateFilters": {
|
|
26376
26863
|
capName: "device-manager",
|
|
26377
26864
|
capScope: "system",
|
|
@@ -26384,6 +26871,12 @@ Object.freeze({
|
|
|
26384
26871
|
addonId: null,
|
|
26385
26872
|
access: "view"
|
|
26386
26873
|
},
|
|
26874
|
+
"deviceManager.adoptionListJobs": {
|
|
26875
|
+
capName: "device-manager",
|
|
26876
|
+
capScope: "system",
|
|
26877
|
+
addonId: null,
|
|
26878
|
+
access: "view"
|
|
26879
|
+
},
|
|
26387
26880
|
"deviceManager.adoptionRefresh": {
|
|
26388
26881
|
capName: "device-manager",
|
|
26389
26882
|
capScope: "system",
|
|
@@ -26402,6 +26895,12 @@ Object.freeze({
|
|
|
26402
26895
|
addonId: null,
|
|
26403
26896
|
access: "create"
|
|
26404
26897
|
},
|
|
26898
|
+
"deviceManager.adoptionStartJob": {
|
|
26899
|
+
capName: "device-manager",
|
|
26900
|
+
capScope: "system",
|
|
26901
|
+
addonId: null,
|
|
26902
|
+
access: "create"
|
|
26903
|
+
},
|
|
26405
26904
|
"deviceManager.allocateDeviceId": {
|
|
26406
26905
|
capName: "device-manager",
|
|
26407
26906
|
capScope: "system",
|
|
@@ -29540,6 +30039,12 @@ Object.freeze({
|
|
|
29540
30039
|
addonId: null,
|
|
29541
30040
|
access: "view"
|
|
29542
30041
|
},
|
|
30042
|
+
"snapshot.getSnapshotLinks": {
|
|
30043
|
+
capName: "snapshot",
|
|
30044
|
+
capScope: "device",
|
|
30045
|
+
addonId: null,
|
|
30046
|
+
access: "view"
|
|
30047
|
+
},
|
|
29543
30048
|
"snapshot.getSnapshotOverview": {
|
|
29544
30049
|
capName: "snapshot",
|
|
29545
30050
|
capScope: "device",
|
|
@@ -32905,6 +33410,7 @@ function composeCameraStatus(input) {
|
|
|
32905
33410
|
audio: mapAudio(input.audioResult),
|
|
32906
33411
|
recording: mapRecording(input.recordingResult),
|
|
32907
33412
|
switchedOff: input.switchedOff,
|
|
33413
|
+
degraded: input.degraded,
|
|
32908
33414
|
fetchedAt: input.fetchedAt
|
|
32909
33415
|
};
|
|
32910
33416
|
}
|
|
@@ -32956,24 +33462,74 @@ var AUDIO_SILENCING_SWITCH_IDS = [
|
|
|
32956
33462
|
"broker-audio",
|
|
32957
33463
|
"audio-analysis"
|
|
32958
33464
|
];
|
|
33465
|
+
/**
|
|
33466
|
+
* Sentinel resolved by the per-stage timer. A unique symbol rather than `null`
|
|
33467
|
+
* so the race result can be narrowed WITHOUT a cast — and, more to the point,
|
|
33468
|
+
* so a stage that legitimately resolves `null` is never mistaken for one that
|
|
33469
|
+
* ran out of time.
|
|
33470
|
+
*/
|
|
33471
|
+
var STAGE_TIMED_OUT = Symbol("camera-status-stage-timeout");
|
|
32959
33472
|
var CameraStatusService = class {
|
|
32960
33473
|
deps;
|
|
32961
33474
|
constructor(deps) {
|
|
32962
33475
|
this.deps = deps;
|
|
32963
33476
|
}
|
|
32964
33477
|
/**
|
|
32965
|
-
*
|
|
33478
|
+
* Record — and ANNOUNCE — a stage whose value could not be trusted.
|
|
33479
|
+
*
|
|
33480
|
+
* Both halves matter. The entry is what a client reads to tell a `null` that
|
|
33481
|
+
* means "we could not look" from a `null` that means "there is nothing
|
|
33482
|
+
* there"; the `warn` is what makes the next hang findable at all. The line
|
|
33483
|
+
* carries `tags.deviceId` with the numeric id because the question is always
|
|
33484
|
+
* per-camera ("why is 614 worse than 615?") and a line without the tag
|
|
33485
|
+
* cannot be grouped to answer it.
|
|
33486
|
+
*/
|
|
33487
|
+
recordDegraded(sink, deviceId, stage, reason, elapsedMs, extraMeta = {}) {
|
|
33488
|
+
sink.entries.push({
|
|
33489
|
+
stage,
|
|
33490
|
+
reason,
|
|
33491
|
+
elapsedMs
|
|
33492
|
+
});
|
|
33493
|
+
this.deps.logger.warn("camera status stage could not be read", {
|
|
33494
|
+
tags: { deviceId },
|
|
33495
|
+
meta: {
|
|
33496
|
+
stage,
|
|
33497
|
+
reason,
|
|
33498
|
+
elapsedMs,
|
|
33499
|
+
...extraMeta
|
|
33500
|
+
}
|
|
33501
|
+
});
|
|
33502
|
+
}
|
|
33503
|
+
/**
|
|
33504
|
+
* Races a promise against a timeout. Returns `null` on timeout OR rejection —
|
|
33505
|
+
* but the two are no longer the same event: whichever happened is pushed to
|
|
33506
|
+
* `sink` (and logged) under `stage`, so the `null` that reaches the payload
|
|
33507
|
+
* arrives with its cause attached.
|
|
33508
|
+
*
|
|
32966
33509
|
* Never throws — individual stage failures become `null` in the aggregate.
|
|
32967
33510
|
*
|
|
32968
|
-
* @param p
|
|
32969
|
-
* @param ms
|
|
33511
|
+
* @param p The stage fetch promise.
|
|
33512
|
+
* @param ms Timeout in milliseconds.
|
|
33513
|
+
* @param stage Which stage this is, as it appears in `CameraStatus.degraded`.
|
|
33514
|
+
* @param deviceId The camera — every line about it carries the tag.
|
|
33515
|
+
* @param sink Per-call collector for stages that could not be read.
|
|
32970
33516
|
*/
|
|
32971
|
-
boundedStage(p, ms) {
|
|
33517
|
+
boundedStage(p, ms, stage, deviceId, sink) {
|
|
33518
|
+
const startedAt = Date.now();
|
|
32972
33519
|
let timer;
|
|
32973
33520
|
const timeout = new Promise((resolve) => {
|
|
32974
|
-
timer = setTimeout(() => resolve(
|
|
33521
|
+
timer = setTimeout(() => resolve(STAGE_TIMED_OUT), ms);
|
|
32975
33522
|
});
|
|
32976
|
-
return Promise.race([p, timeout]).
|
|
33523
|
+
return Promise.race([p, timeout]).then((value) => {
|
|
33524
|
+
if (value === STAGE_TIMED_OUT) {
|
|
33525
|
+
this.recordDegraded(sink, deviceId, stage, "timeout", Date.now() - startedAt, { timeoutMs: ms });
|
|
33526
|
+
return null;
|
|
33527
|
+
}
|
|
33528
|
+
return value;
|
|
33529
|
+
}).catch((err) => {
|
|
33530
|
+
this.recordDegraded(sink, deviceId, stage, "error", Date.now() - startedAt, { error: err instanceof Error ? err.message : String(err) });
|
|
33531
|
+
return null;
|
|
33532
|
+
}).finally(() => {
|
|
32977
33533
|
if (timer !== void 0) clearTimeout(timer);
|
|
32978
33534
|
});
|
|
32979
33535
|
}
|
|
@@ -32999,7 +33555,7 @@ var CameraStatusService = class {
|
|
|
32999
33555
|
decoder: false,
|
|
33000
33556
|
audio: audioPinned
|
|
33001
33557
|
},
|
|
33002
|
-
detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : void 0,
|
|
33558
|
+
detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
|
|
33003
33559
|
audioNodeId,
|
|
33004
33560
|
audioPinned
|
|
33005
33561
|
};
|
|
@@ -33010,7 +33566,7 @@ var CameraStatusService = class {
|
|
|
33010
33566
|
* deviceId). `allSlotsFetch` is shared with the broker stage — fetched
|
|
33011
33567
|
* ONCE by the caller — so this stage never issues its own round-trip.
|
|
33012
33568
|
*/
|
|
33013
|
-
buildSourceStage(api, allSlotsFetch, deviceId) {
|
|
33569
|
+
buildSourceStage(api, allSlotsFetch, deviceId, sink) {
|
|
33014
33570
|
if (!api || !allSlotsFetch) return Promise.resolve(null);
|
|
33015
33571
|
return this.boundedStage(allSlotsFetch.then((slots) => {
|
|
33016
33572
|
return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
|
|
@@ -33021,7 +33577,7 @@ var CameraStatusService = class {
|
|
|
33021
33577
|
fps: 0,
|
|
33022
33578
|
kind: s.profile
|
|
33023
33579
|
})) };
|
|
33024
|
-
}), STAGE_TIMEOUT_MS);
|
|
33580
|
+
}), STAGE_TIMEOUT_MS, "source", deviceId, sink);
|
|
33025
33581
|
}
|
|
33026
33582
|
/**
|
|
33027
33583
|
* Broker stage (per-profile slot stats + client counts). Reuses
|
|
@@ -33032,7 +33588,7 @@ var CameraStatusService = class {
|
|
|
33032
33588
|
* broker's actual decode-session node into `liveDecoder` (T6) — the first
|
|
33033
33589
|
* slot that reports one wins.
|
|
33034
33590
|
*/
|
|
33035
|
-
buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder) {
|
|
33591
|
+
buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, sink) {
|
|
33036
33592
|
if (!api || !allSlotsFetch) return Promise.resolve(null);
|
|
33037
33593
|
return this.boundedStage(allSlotsFetch.then(async (slots) => {
|
|
33038
33594
|
const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
|
|
@@ -33069,7 +33625,7 @@ var CameraStatusService = class {
|
|
|
33069
33625
|
return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
|
|
33070
33626
|
}) ?? false
|
|
33071
33627
|
};
|
|
33072
|
-
}), STAGE_TIMEOUT_MS);
|
|
33628
|
+
}), STAGE_TIMEOUT_MS, "broker", deviceId, sink);
|
|
33073
33629
|
}
|
|
33074
33630
|
/**
|
|
33075
33631
|
* Decoder stage. decoder cap methods (listActiveSessions/getInfo/getShmStats)
|
|
@@ -33108,7 +33664,7 @@ var CameraStatusService = class {
|
|
|
33108
33664
|
};
|
|
33109
33665
|
}
|
|
33110
33666
|
/** Detection stage (pipeline-executor + runner metrics). */
|
|
33111
|
-
buildDetectionStage(api, detectionNodeId, deviceId) {
|
|
33667
|
+
buildDetectionStage(api, detectionNodeId, deviceId, sink) {
|
|
33112
33668
|
if (!api || !detectionNodeId) return Promise.resolve(null);
|
|
33113
33669
|
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]) => {
|
|
33114
33670
|
const metrics = await api.pipelineRunner.getCameraMetrics.query({
|
|
@@ -33138,7 +33694,7 @@ var CameraStatusService = class {
|
|
|
33138
33694
|
...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
|
|
33139
33695
|
}
|
|
33140
33696
|
};
|
|
33141
|
-
}), STAGE_TIMEOUT_MS);
|
|
33697
|
+
}), STAGE_TIMEOUT_MS, "detection", deviceId, sink);
|
|
33142
33698
|
}
|
|
33143
33699
|
/**
|
|
33144
33700
|
* Audio stage. `nodeId` is orchestrator-local (the cached assignment);
|
|
@@ -33157,6 +33713,12 @@ var CameraStatusService = class {
|
|
|
33157
33713
|
* analyzer with nothing, and reporting `enabled: true` for the other two
|
|
33158
33714
|
* would reintroduce the exact hardcoded lie the previous paragraph is about
|
|
33159
33715
|
* — one row further down the group.
|
|
33716
|
+
*
|
|
33717
|
+
* `enabled` is a boolean and cannot say "unknown", so when the switch read
|
|
33718
|
+
* was cut short or partial this block is OPTIMISTIC by construction. That is
|
|
33719
|
+
* survivable only because `CameraStatus.degraded` names `'switches'` in
|
|
33720
|
+
* exactly those cases — a surface drawing "audio is running" from this field
|
|
33721
|
+
* must consult it first.
|
|
33160
33722
|
*/
|
|
33161
33723
|
buildAudioStage(audioNodeId, switchedOff) {
|
|
33162
33724
|
if (audioNodeId === null) return null;
|
|
@@ -33171,8 +33733,13 @@ var CameraStatusService = class {
|
|
|
33171
33733
|
* `{ deviceId }` and returns `RecordingStatus`, typed `unknown` in the
|
|
33172
33734
|
* generated router (DEVICE_STATUS_METHOD) — narrowed via
|
|
33173
33735
|
* {@link isRecordingStatus} instead of an unsafe cast.
|
|
33736
|
+
*
|
|
33737
|
+
* There is deliberately NO `.catch(() => null)` on the query any more: it
|
|
33738
|
+
* swallowed the rejection before `boundedStage` could see it, so a recording
|
|
33739
|
+
* source that was refusing every call produced the same anonymous `null` as
|
|
33740
|
+
* a camera that simply records nothing.
|
|
33174
33741
|
*/
|
|
33175
|
-
buildRecordingStage(api, deviceId) {
|
|
33742
|
+
buildRecordingStage(api, deviceId, sink) {
|
|
33176
33743
|
if (!api) return Promise.resolve(null);
|
|
33177
33744
|
return this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
|
|
33178
33745
|
if (!isRecordingStatus(rawStatus)) return null;
|
|
@@ -33181,7 +33748,40 @@ var CameraStatusService = class {
|
|
|
33181
33748
|
active: rawStatus.enabled && rawStatus.activeMode !== "off",
|
|
33182
33749
|
storageBytes: rawStatus.storageBytes
|
|
33183
33750
|
};
|
|
33184
|
-
})
|
|
33751
|
+
}), STAGE_TIMEOUT_MS, "recording", deviceId, sink);
|
|
33752
|
+
}
|
|
33753
|
+
/**
|
|
33754
|
+
* The operator's function switches (D61/D62), bounded like every other stage.
|
|
33755
|
+
*
|
|
33756
|
+
* Three outcomes, and they must not collapse into one:
|
|
33757
|
+
*
|
|
33758
|
+
* - READ — the group came back whole. The derived id list is a total, and an
|
|
33759
|
+
* empty one really does mean the operator turned nothing off.
|
|
33760
|
+
* - PARTIAL — the group came back with at least one switch marked
|
|
33761
|
+
* `source-unreachable`. `CameraSwitchService` bounds each source
|
|
33762
|
+
* individually, so a slow source drops ITS switch and leaves the rest;
|
|
33763
|
+
* the derived list is then a FLOOR. Marked `'partial'`.
|
|
33764
|
+
* - NOT READ — the whole group timed out or rejected. Marked by
|
|
33765
|
+
* `boundedStage` itself.
|
|
33766
|
+
*
|
|
33767
|
+
* In the last two the returned list is `[]`, and `[]` was previously handed
|
|
33768
|
+
* to the payload as the positive claim "the operator has switched nothing
|
|
33769
|
+
* off". Measured on the live hub 2026-08-08: `getCameraStatus(614)` said
|
|
33770
|
+
* `switchedOff: []` at the very moment `getCameraSwitches(614)` reported
|
|
33771
|
+
* `stream-broker` AND `recording` switched off by the operator — the camera
|
|
33772
|
+
* read as BROKEN because it was switched OFF, which is exactly the
|
|
33773
|
+
* inversion D62 exists to forbid. The list stays empty (a guess would put a
|
|
33774
|
+
* permanent "off" badge on a working camera); what changed is that the
|
|
33775
|
+
* emptiness now travels with the reason it is empty.
|
|
33776
|
+
*/
|
|
33777
|
+
buildSwitchStage(deviceId, sink) {
|
|
33778
|
+
const startedAt = Date.now();
|
|
33779
|
+
return this.boundedStage(this.deps.cameraSwitchesFor(deviceId), STAGE_TIMEOUT_MS, "switches", deviceId, sink).then((group) => {
|
|
33780
|
+
if (group === null) return [];
|
|
33781
|
+
const unreadable = group.switches.filter((s) => s.unavailableReason === "source-unreachable");
|
|
33782
|
+
if (unreadable.length > 0) this.recordDegraded(sink, deviceId, "switches", "partial", Date.now() - startedAt, { unreadableSwitches: unreadable.map((s) => s.id) });
|
|
33783
|
+
return switchedOffIds(group.switches);
|
|
33784
|
+
});
|
|
33185
33785
|
}
|
|
33186
33786
|
/**
|
|
33187
33787
|
* Server-composed aggregated status for a single camera.
|
|
@@ -33189,20 +33789,23 @@ var CameraStatusService = class {
|
|
|
33189
33789
|
* Fans out in parallel (bounded, per-stage graceful degradation) to
|
|
33190
33790
|
* broker / decoder / motion / detection / audio / recording source caps
|
|
33191
33791
|
* via `ctx.api`. A stage whose source errors or times out becomes `null`
|
|
33192
|
-
* in the returned payload — one slow agent never breaks the whole call
|
|
33792
|
+
* in the returned payload — one slow agent never breaks the whole call —
|
|
33793
|
+
* and is NAMED in `degraded`, so a consumer can tell that `null` from the
|
|
33794
|
+
* `null` of a camera that legitimately has no such stage.
|
|
33193
33795
|
*/
|
|
33194
33796
|
async getCameraStatus(deviceId) {
|
|
33195
33797
|
const api = this.deps.api();
|
|
33798
|
+
const degradations = { entries: [] };
|
|
33196
33799
|
const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
|
|
33197
33800
|
const liveDecoder = { nodeId: null };
|
|
33198
33801
|
const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
|
|
33199
|
-
const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId);
|
|
33200
|
-
const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder);
|
|
33802
|
+
const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
|
|
33803
|
+
const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, degradations);
|
|
33201
33804
|
const decoderFetch = this.buildDecoderStage(detectionNodeId);
|
|
33202
33805
|
const motionResult = this.buildMotionStage(deviceId);
|
|
33203
|
-
const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId);
|
|
33204
|
-
const recordingFetch = this.buildRecordingStage(api, deviceId);
|
|
33205
|
-
const switchesFetch = this.
|
|
33806
|
+
const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, degradations);
|
|
33807
|
+
const recordingFetch = this.buildRecordingStage(api, deviceId, degradations);
|
|
33808
|
+
const switchesFetch = this.buildSwitchStage(deviceId, degradations);
|
|
33206
33809
|
const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
|
|
33207
33810
|
sourceFetch,
|
|
33208
33811
|
brokerFetch,
|
|
@@ -33235,7 +33838,8 @@ var CameraStatusService = class {
|
|
|
33235
33838
|
detectionResult,
|
|
33236
33839
|
audioResult,
|
|
33237
33840
|
recordingResult,
|
|
33238
|
-
switchedOff
|
|
33841
|
+
switchedOff,
|
|
33842
|
+
degraded: [...degradations.entries]
|
|
33239
33843
|
});
|
|
33240
33844
|
}
|
|
33241
33845
|
/**
|
|
@@ -33303,6 +33907,25 @@ function readPrivacyMaskFacts(v) {
|
|
|
33303
33907
|
enabled: typeof enabled === "boolean" ? enabled : null
|
|
33304
33908
|
};
|
|
33305
33909
|
}
|
|
33910
|
+
/**
|
|
33911
|
+
* How long ONE source of the gather may take before it is treated as
|
|
33912
|
+
* unreachable.
|
|
33913
|
+
*
|
|
33914
|
+
* The gather runs in two sequential stages, so an operator's worst case is
|
|
33915
|
+
* twice this — 2.4s — which has to stay under the 3s `STAGE_TIMEOUT_MS` that
|
|
33916
|
+
* `CameraStatusService` allows the whole switch read. That ordering is the
|
|
33917
|
+
* whole point of the number: if the gather can outlast its caller's budget,
|
|
33918
|
+
* the caller degrades to "nothing switched off" and a camera the operator
|
|
33919
|
+
* DISABLED renders as broken.
|
|
33920
|
+
*
|
|
33921
|
+
* Measured on the live hub, 2026-08-08: seven of the eight sources answered in
|
|
33922
|
+
* 5–15ms on every camera. The eighth, `privacyMask.getStatus`, probes the
|
|
33923
|
+
* camera itself and took 3.1–3.6s on device 614 on every single call (18s
|
|
33924
|
+
* under load) — so a bound generous enough to make the fast sources safe still
|
|
33925
|
+
* has to be willing to drop the slow one, because waiting for it costs the
|
|
33926
|
+
* other six switches.
|
|
33927
|
+
*/
|
|
33928
|
+
var SOURCE_TIMEOUT_MS = 1200;
|
|
33306
33929
|
var CameraSwitchService = class {
|
|
33307
33930
|
deps;
|
|
33308
33931
|
constructor(deps) {
|
|
@@ -33439,11 +34062,16 @@ var CameraSwitchService = class {
|
|
|
33439
34062
|
wrapperAddonIdByCap: /* @__PURE__ */ new Map(),
|
|
33440
34063
|
recordingConfig: null
|
|
33441
34064
|
};
|
|
33442
|
-
const devicePromise = api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
|
|
34065
|
+
const devicePromise = this.bounded(deviceId, "deviceManager.getDevice", api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
|
|
33443
34066
|
this.warn(deviceId, "getDevice", err);
|
|
33444
34067
|
return null;
|
|
33445
|
-
});
|
|
33446
|
-
const
|
|
34068
|
+
}), null);
|
|
34069
|
+
const unknownBindings = {
|
|
34070
|
+
activeWrapperCapNames: null,
|
|
34071
|
+
providerAddonIdByCap: /* @__PURE__ */ new Map(),
|
|
34072
|
+
allCapNames: null
|
|
34073
|
+
};
|
|
34074
|
+
const bindingsPromise = this.bounded(deviceId, "deviceManager.getBindings", api.deviceManager.getBindings.query({ deviceId }).then((b) => {
|
|
33447
34075
|
const active = [];
|
|
33448
34076
|
const all = [];
|
|
33449
34077
|
const providers = /* @__PURE__ */ new Map();
|
|
@@ -33460,24 +34088,20 @@ var CameraSwitchService = class {
|
|
|
33460
34088
|
};
|
|
33461
34089
|
}).catch((err) => {
|
|
33462
34090
|
this.warn(deviceId, "getBindings", err);
|
|
33463
|
-
return
|
|
33464
|
-
|
|
33465
|
-
|
|
33466
|
-
allCapNames: null
|
|
33467
|
-
};
|
|
33468
|
-
});
|
|
33469
|
-
const recordingPromise = api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
|
|
34091
|
+
return unknownBindings;
|
|
34092
|
+
}), unknownBindings);
|
|
34093
|
+
const recordingPromise = this.bounded(deviceId, "recording.getDeviceConfig", api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
|
|
33470
34094
|
this.warn(deviceId, "recording.getDeviceConfig", err);
|
|
33471
34095
|
return null;
|
|
33472
|
-
});
|
|
33473
|
-
const mutesPromise = api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
|
|
34096
|
+
}), null);
|
|
34097
|
+
const mutesPromise = this.bounded(deviceId, "notificationRules.listDeviceMutes", api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
|
|
33474
34098
|
this.warn(deviceId, "notificationRules.listDeviceMutes", err);
|
|
33475
34099
|
return null;
|
|
33476
|
-
});
|
|
33477
|
-
const brokerAudioPromise = api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
|
|
34100
|
+
}), null);
|
|
34101
|
+
const brokerAudioPromise = this.bounded(deviceId, "streamBroker.getDeviceAudioMute", api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
|
|
33478
34102
|
this.warn(deviceId, "streamBroker.getDeviceAudioMute", err);
|
|
33479
34103
|
return null;
|
|
33480
|
-
});
|
|
34104
|
+
}), null);
|
|
33481
34105
|
const [device, bindings, recordingConfig, mutedDeviceIds, brokerAudio] = await Promise.all([
|
|
33482
34106
|
devicePromise,
|
|
33483
34107
|
bindingsPromise,
|
|
@@ -33485,10 +34109,10 @@ var CameraSwitchService = class {
|
|
|
33485
34109
|
mutesPromise,
|
|
33486
34110
|
brokerAudioPromise
|
|
33487
34111
|
]);
|
|
33488
|
-
const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
|
|
34112
|
+
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) => {
|
|
33489
34113
|
this.warn(deviceId, "listBindableCapsForDeviceType", err);
|
|
33490
34114
|
return null;
|
|
33491
|
-
}), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
|
|
34115
|
+
}), null), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
|
|
33492
34116
|
const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
|
|
33493
34117
|
for (const entry of bindable ?? []) {
|
|
33494
34118
|
const first = entry.wrappers[0];
|
|
@@ -33544,13 +34168,13 @@ var CameraSwitchService = class {
|
|
|
33544
34168
|
enabled: null
|
|
33545
34169
|
}
|
|
33546
34170
|
};
|
|
33547
|
-
const [options, status] = await Promise.all([api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
|
|
34171
|
+
const [options, status] = await Promise.all([this.bounded(deviceId, "privacyMask.getOptions", api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
|
|
33548
34172
|
this.warn(deviceId, "privacyMask.getOptions", err);
|
|
33549
34173
|
return null;
|
|
33550
|
-
}), api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
|
|
34174
|
+
}), null), this.bounded(deviceId, "privacyMask.getStatus", api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
|
|
33551
34175
|
this.warn(deviceId, "privacyMask.getStatus", err);
|
|
33552
34176
|
return null;
|
|
33553
|
-
})]);
|
|
34177
|
+
}), null)]);
|
|
33554
34178
|
if (options === null) return {
|
|
33555
34179
|
deviceAudio: null,
|
|
33556
34180
|
privacyMask: null
|
|
@@ -33574,6 +34198,41 @@ var CameraSwitchService = class {
|
|
|
33574
34198
|
}
|
|
33575
34199
|
};
|
|
33576
34200
|
}
|
|
34201
|
+
/**
|
|
34202
|
+
* Bound ONE source. A source that has not answered within
|
|
34203
|
+
* {@link SOURCE_TIMEOUT_MS} yields `fallback` — the same value its `.catch`
|
|
34204
|
+
* yields — so a slow source and a broken one produce the identical group:
|
|
34205
|
+
* that switch is `available: false`, and every other switch is untouched.
|
|
34206
|
+
*
|
|
34207
|
+
* The bound is per SOURCE rather than around the whole gather on purpose.
|
|
34208
|
+
* One deadline over the fan-out would let the slowest camera probe consume
|
|
34209
|
+
* the budget of the seven sources that already answered, which is precisely
|
|
34210
|
+
* how `getCameraStatus` came to report `switchedOff: []` for a camera whose
|
|
34211
|
+
* `stream-broker` and `recording` switches the operator had turned off.
|
|
34212
|
+
*
|
|
34213
|
+
* @param deviceId The camera — every line about it carries the tag.
|
|
34214
|
+
* @param source The cap method, as named in the warn line.
|
|
34215
|
+
* @param p The source's promise. Already `.catch`-ed; never rejects.
|
|
34216
|
+
* @param fallback What the caller reads when the source did not answer.
|
|
34217
|
+
*/
|
|
34218
|
+
bounded(deviceId, source, p, fallback) {
|
|
34219
|
+
let timer;
|
|
34220
|
+
const timeout = new Promise((resolve) => {
|
|
34221
|
+
timer = setTimeout(() => {
|
|
34222
|
+
this.deps.logger.warn("camera switch source TIMED OUT — its switch is not offered", {
|
|
34223
|
+
tags: { deviceId },
|
|
34224
|
+
meta: {
|
|
34225
|
+
source,
|
|
34226
|
+
timeoutMs: SOURCE_TIMEOUT_MS
|
|
34227
|
+
}
|
|
34228
|
+
});
|
|
34229
|
+
resolve(fallback);
|
|
34230
|
+
}, SOURCE_TIMEOUT_MS);
|
|
34231
|
+
});
|
|
34232
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
34233
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
34234
|
+
});
|
|
34235
|
+
}
|
|
33577
34236
|
warn(deviceId, source, err) {
|
|
33578
34237
|
this.deps.logger.warn("camera switch source unreachable — its switch is not offered", {
|
|
33579
34238
|
tags: { deviceId },
|
|
@@ -39290,6 +39949,7 @@ async function buildOrchestratorControllers(deps) {
|
|
|
39290
39949
|
});
|
|
39291
39950
|
const cameraStatusService = new CameraStatusService({
|
|
39292
39951
|
api: () => deps.ctx().api,
|
|
39952
|
+
logger: deps.ctx().logger,
|
|
39293
39953
|
getAssignment: (deviceId) => ledger.getAssignment(deviceId),
|
|
39294
39954
|
getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
|
|
39295
39955
|
getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
|
|
@@ -39297,7 +39957,8 @@ async function buildOrchestratorControllers(deps) {
|
|
|
39297
39957
|
getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
|
|
39298
39958
|
assignSource: (deviceId) => topology.assignSource(deviceId),
|
|
39299
39959
|
listAssignedDeviceIds: () => ledger.listAssignedDeviceIds(),
|
|
39300
|
-
|
|
39960
|
+
isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
|
|
39961
|
+
cameraSwitchesFor: (deviceId) => cameraSwitchService.getCameraSwitches(deviceId)
|
|
39301
39962
|
});
|
|
39302
39963
|
const reconcile = new ReconcileController({
|
|
39303
39964
|
api: () => deps.ctx().api ?? null,
|