@camstack/addon-pipeline-orchestrator 1.2.33 → 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-B9MEuh4w.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-BHQUwZb4.mjs → hostInit-Cslwtzcn.mjs} +3 -3
- package/dist/index.js +2059 -1590
- package/dist/index.mjs +2059 -1590
- 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-RHxrNfOD.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. */
|
|
@@ -18407,6 +19365,37 @@ DeviceType.Camera, method(object({
|
|
|
18407
19365
|
lastCapturedAt: number().nullable(),
|
|
18408
19366
|
cacheAgeMs: number().nullable(),
|
|
18409
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()
|
|
18410
19399
|
})));
|
|
18411
19400
|
/**
|
|
18412
19401
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -20157,6 +21146,37 @@ DeviceType.Light, method(object({
|
|
|
20157
21146
|
mireds: number().int().optional(),
|
|
20158
21147
|
lastChangedAt: number()
|
|
20159
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" });
|
|
20160
21180
|
object({
|
|
20161
21181
|
/** True when the upstream system considers the entity connected. */
|
|
20162
21182
|
connected: boolean(),
|
|
@@ -21058,15 +22078,57 @@ var AvailableIntegrationTypeSchema = object({
|
|
|
21058
22078
|
* flow can import (e.g. HA areas). Drives the adopt modal's "import
|
|
21059
22079
|
* locations" checkbox. Provider-declared in the addon manifest. */
|
|
21060
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(),
|
|
21061
22088
|
existingInstances: array(object({
|
|
21062
22089
|
id: string(),
|
|
21063
22090
|
name: string()
|
|
21064
22091
|
})),
|
|
21065
22092
|
canAdd: boolean()
|
|
21066
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
|
+
]);
|
|
21067
22119
|
var TestConnectionResultSchema$1 = object({
|
|
22120
|
+
/** True ONLY for `validated`. Never true for a test that did not run. */
|
|
21068
22121
|
success: boolean(),
|
|
21069
|
-
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()
|
|
21070
22132
|
});
|
|
21071
22133
|
var CreateIntegrationInputSchema = object({
|
|
21072
22134
|
addonId: string(),
|
|
@@ -24041,1208 +25103,579 @@ var TotpStatusSchema = object({
|
|
|
24041
25103
|
method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserInputSchema, UserSummarySchema, {
|
|
24042
25104
|
kind: "mutation",
|
|
24043
25105
|
auth: "admin",
|
|
24044
|
-
access: "create"
|
|
24045
|
-
}), method(UpdateUserInputSchema, object({ success: literal(true) }), {
|
|
24046
|
-
kind: "mutation",
|
|
24047
|
-
auth: "admin",
|
|
24048
|
-
access: "create"
|
|
24049
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24050
|
-
kind: "mutation",
|
|
24051
|
-
auth: "admin",
|
|
24052
|
-
access: "delete"
|
|
24053
|
-
}), method(object({
|
|
24054
|
-
id: string(),
|
|
24055
|
-
newPassword: string().min(6)
|
|
24056
|
-
}), object({ success: literal(true) }), {
|
|
24057
|
-
kind: "mutation",
|
|
24058
|
-
auth: "admin",
|
|
24059
|
-
access: "create"
|
|
24060
|
-
}), method(object({
|
|
24061
|
-
userId: string(),
|
|
24062
|
-
scopes: array(TokenScopeSchema)
|
|
24063
|
-
}), object({ success: literal(true) }), {
|
|
24064
|
-
kind: "mutation",
|
|
24065
|
-
auth: "admin",
|
|
24066
|
-
access: "create"
|
|
24067
|
-
}), method(object({
|
|
24068
|
-
username: string(),
|
|
24069
|
-
password: string()
|
|
24070
|
-
}), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
|
|
24071
|
-
kind: "mutation",
|
|
24072
|
-
access: "view"
|
|
24073
|
-
}), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
|
|
24074
|
-
kind: "mutation",
|
|
24075
|
-
auth: "admin",
|
|
24076
|
-
access: "create"
|
|
24077
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24078
|
-
kind: "mutation",
|
|
24079
|
-
auth: "admin",
|
|
24080
|
-
access: "delete"
|
|
24081
|
-
}), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
|
|
24082
|
-
kind: "mutation",
|
|
24083
|
-
access: "view"
|
|
24084
|
-
}), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
|
|
24085
|
-
kind: "mutation",
|
|
24086
|
-
auth: "admin",
|
|
24087
|
-
access: "create"
|
|
24088
|
-
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24089
|
-
kind: "mutation",
|
|
24090
|
-
auth: "admin",
|
|
24091
|
-
access: "delete"
|
|
24092
|
-
}), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
|
|
24093
|
-
kind: "mutation",
|
|
24094
|
-
auth: "admin",
|
|
24095
|
-
access: "create"
|
|
24096
|
-
}), method(object({
|
|
24097
|
-
userId: string(),
|
|
24098
|
-
code: string()
|
|
24099
|
-
}), object({ success: literal(true) }), {
|
|
24100
|
-
kind: "mutation",
|
|
24101
|
-
auth: "admin",
|
|
24102
|
-
access: "create"
|
|
24103
|
-
}), method(object({ userId: string() }), object({ success: literal(true) }), {
|
|
24104
|
-
kind: "mutation",
|
|
24105
|
-
auth: "admin",
|
|
24106
|
-
access: "delete"
|
|
24107
|
-
}), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
|
|
24108
|
-
userId: string(),
|
|
24109
|
-
code: string()
|
|
24110
|
-
}), object({ valid: boolean() }), {
|
|
24111
|
-
kind: "mutation",
|
|
24112
|
-
access: "view"
|
|
24113
|
-
}), method(object({
|
|
24114
|
-
integrationId: string(),
|
|
24115
|
-
userId: string(),
|
|
24116
|
-
username: string(),
|
|
24117
|
-
scopes: array(TokenScopeSchema),
|
|
24118
|
-
redirectUri: string(),
|
|
24119
|
-
hubUrl: string(),
|
|
24120
|
-
/** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
|
|
24121
|
-
* that carries one can ONLY be exchanged with the matching verifier. */
|
|
24122
|
-
codeChallenge: string().optional()
|
|
24123
|
-
}), object({ code: string() }), {
|
|
24124
|
-
kind: "mutation",
|
|
24125
|
-
access: "create"
|
|
24126
|
-
}), method(object({
|
|
24127
|
-
code: string(),
|
|
24128
|
-
redirectUri: string(),
|
|
24129
|
-
/** PKCE verifier. REQUIRED when the code carries a challenge. */
|
|
24130
|
-
codeVerifier: string().optional()
|
|
24131
|
-
}), object({
|
|
24132
|
-
accessToken: string(),
|
|
24133
|
-
refreshToken: string(),
|
|
24134
|
-
expiresIn: number()
|
|
24135
|
-
}).nullable(), {
|
|
24136
|
-
kind: "mutation",
|
|
24137
|
-
access: "view"
|
|
24138
|
-
}), method(object({ refreshToken: string() }), object({
|
|
24139
|
-
accessToken: string(),
|
|
24140
|
-
refreshToken: string(),
|
|
24141
|
-
expiresIn: number()
|
|
24142
|
-
}).nullable(), {
|
|
24143
|
-
kind: "mutation",
|
|
24144
|
-
access: "view"
|
|
24145
|
-
}), method(object({ token: string() }), object({
|
|
24146
|
-
userId: string(),
|
|
24147
|
-
username: string(),
|
|
24148
|
-
scopes: array(TokenScopeSchema)
|
|
24149
|
-
}).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
|
|
24150
|
-
kind: "mutation",
|
|
24151
|
-
auth: "admin",
|
|
24152
|
-
access: "delete"
|
|
24153
|
-
});
|
|
24154
|
-
/**
|
|
24155
|
-
* Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
|
|
24156
|
-
* cleaning lifecycle plus a return-to-base / locate surface and an
|
|
24157
|
-
* optional fan-speed selector.
|
|
24158
|
-
*
|
|
24159
|
-
* State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
|
|
24160
|
-
* `paused` / `returning` / `docked` / `error`. `batteryLevel`
|
|
24161
|
-
* (0..100) is nullable — some vacuums don't report a battery
|
|
24162
|
-
* percentage. `fanSpeed` is the current speed token (provider-verbatim,
|
|
24163
|
-
* e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
|
|
24164
|
-
* tokens the hardware accepts so the UI renders only supported choices.
|
|
24165
|
-
*
|
|
24166
|
-
* The `setFanSpeed` method takes the bare `speed` token — the provider
|
|
24167
|
-
* validates it against the vacuum's own list. `locate` triggers the
|
|
24168
|
-
* find-me chirp; `returnToBase` sends it home.
|
|
24169
|
-
*
|
|
24170
|
-
* Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
|
|
24171
|
-
* `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
|
|
24172
|
-
* 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
|
|
24173
|
-
* SHOULD populate whichever tanks the hardware has — leave a field `null`
|
|
24174
|
-
* only when the device has no such tank at all, and use a `TankStatus`
|
|
24175
|
-
* with both inner fields `null` when the tank exists but its level is
|
|
24176
|
-
* currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
|
|
24177
|
-
* so the HA provider leaves all four `null`.
|
|
24178
|
-
*/
|
|
24179
|
-
var VacuumStateSchema = _enum([
|
|
24180
|
-
"idle",
|
|
24181
|
-
"cleaning",
|
|
24182
|
-
"paused",
|
|
24183
|
-
"returning",
|
|
24184
|
-
"docked",
|
|
24185
|
-
"drying",
|
|
24186
|
-
"error"
|
|
24187
|
-
]);
|
|
24188
|
-
/**
|
|
24189
|
-
* One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
|
|
24190
|
-
* detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
|
|
24191
|
-
* a discrete `status` (binary-style hardware), or both. Both `null` means the
|
|
24192
|
-
* level is currently unknown; the OWNING field being `null` means the
|
|
24193
|
-
* hardware has no such tank at all.
|
|
24194
|
-
*/
|
|
24195
|
-
var TankStatusSchema = object({
|
|
24196
|
-
/** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
|
|
24197
|
-
level: number().min(0).max(100).nullable(),
|
|
24198
|
-
/** Discrete state when the hardware is binary-mode; null otherwise. */
|
|
24199
|
-
status: _enum([
|
|
24200
|
-
"ok",
|
|
24201
|
-
"low",
|
|
24202
|
-
"full"
|
|
24203
|
-
]).nullable()
|
|
24204
|
-
});
|
|
24205
|
-
object({
|
|
24206
|
-
/** Lifecycle state of the vacuum. */
|
|
24207
|
-
state: VacuumStateSchema,
|
|
24208
|
-
/** 0..100 battery percentage. Null when the device has no battery
|
|
24209
|
-
* reading. */
|
|
24210
|
-
batteryLevel: number().min(0).max(100).nullable(),
|
|
24211
|
-
/** Current fan-speed token (provider-verbatim). Null when unknown or
|
|
24212
|
-
* the vacuum has no speed control. */
|
|
24213
|
-
fanSpeed: string().nullable(),
|
|
24214
|
-
/** Speed tokens the hardware accepts — drives the UI selector. */
|
|
24215
|
-
availableFanSpeeds: array(string()),
|
|
24216
|
-
/** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
|
|
24217
|
-
cleanWater: TankStatusSchema.nullable(),
|
|
24218
|
-
/** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
|
|
24219
|
-
dirtyWater: TankStatusSchema.nullable(),
|
|
24220
|
-
/** Detergent tank. Null when the hardware has no detergent tank. */
|
|
24221
|
-
detergent: TankStatusSchema.nullable(),
|
|
24222
|
-
/** Dust bin. Null when the hardware has no dust bin. */
|
|
24223
|
-
dustBin: TankStatusSchema.nullable(),
|
|
24224
|
-
/** 0..100 cleaning-completion percentage of the current task, or null. */
|
|
24225
|
-
progressPercent: number().min(0).max(100).nullable(),
|
|
24226
|
-
/** Current error code (0 / null = no error). */
|
|
24227
|
-
errorCode: number().nullable(),
|
|
24228
|
-
/** Human label for {@link errorCode}, or null when none / undecodable. */
|
|
24229
|
-
errorLabel: string().nullable(),
|
|
24230
|
-
/** Ms epoch when the slice was last updated. */
|
|
24231
|
-
lastChangedAt: number()
|
|
24232
|
-
});
|
|
24233
|
-
DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
24234
|
-
kind: "mutation",
|
|
24235
|
-
auth: "admin"
|
|
24236
|
-
}), method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25106
|
+
access: "create"
|
|
25107
|
+
}), method(UpdateUserInputSchema, object({ success: literal(true) }), {
|
|
24237
25108
|
kind: "mutation",
|
|
24238
|
-
auth: "admin"
|
|
24239
|
-
|
|
25109
|
+
auth: "admin",
|
|
25110
|
+
access: "create"
|
|
25111
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24240
25112
|
kind: "mutation",
|
|
24241
|
-
auth: "admin"
|
|
24242
|
-
|
|
25113
|
+
auth: "admin",
|
|
25114
|
+
access: "delete"
|
|
25115
|
+
}), method(object({
|
|
25116
|
+
id: string(),
|
|
25117
|
+
newPassword: string().min(6)
|
|
25118
|
+
}), object({ success: literal(true) }), {
|
|
24243
25119
|
kind: "mutation",
|
|
24244
|
-
auth: "admin"
|
|
24245
|
-
|
|
25120
|
+
auth: "admin",
|
|
25121
|
+
access: "create"
|
|
25122
|
+
}), method(object({
|
|
25123
|
+
userId: string(),
|
|
25124
|
+
scopes: array(TokenScopeSchema)
|
|
25125
|
+
}), object({ success: literal(true) }), {
|
|
24246
25126
|
kind: "mutation",
|
|
24247
|
-
auth: "admin"
|
|
25127
|
+
auth: "admin",
|
|
25128
|
+
access: "create"
|
|
24248
25129
|
}), method(object({
|
|
24249
|
-
|
|
24250
|
-
|
|
24251
|
-
}),
|
|
25130
|
+
username: string(),
|
|
25131
|
+
password: string()
|
|
25132
|
+
}), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
|
|
24252
25133
|
kind: "mutation",
|
|
24253
|
-
|
|
24254
|
-
})
|
|
24255
|
-
object({
|
|
24256
|
-
/** Lifecycle state of the valve. */
|
|
24257
|
-
state: _enum([
|
|
24258
|
-
"open",
|
|
24259
|
-
"opening",
|
|
24260
|
-
"closing",
|
|
24261
|
-
"closed",
|
|
24262
|
-
"stopped"
|
|
24263
|
-
]),
|
|
24264
|
-
/** 0 = fully closed, 100 = fully open. Null when the device has no
|
|
24265
|
-
* intermediate position surface. */
|
|
24266
|
-
position: number().min(0).max(100).nullable(),
|
|
24267
|
-
/** Ms epoch when the slice was last updated. */
|
|
24268
|
-
lastChangedAt: number()
|
|
24269
|
-
});
|
|
24270
|
-
DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
|
|
25134
|
+
access: "view"
|
|
25135
|
+
}), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
|
|
24271
25136
|
kind: "mutation",
|
|
24272
|
-
auth: "admin"
|
|
24273
|
-
|
|
25137
|
+
auth: "admin",
|
|
25138
|
+
access: "create"
|
|
25139
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24274
25140
|
kind: "mutation",
|
|
24275
|
-
auth: "admin"
|
|
24276
|
-
|
|
25141
|
+
auth: "admin",
|
|
25142
|
+
access: "delete"
|
|
25143
|
+
}), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
|
|
24277
25144
|
kind: "mutation",
|
|
24278
|
-
|
|
24279
|
-
}), method(
|
|
24280
|
-
deviceId: number().int().nonnegative(),
|
|
24281
|
-
position: number().min(0).max(100)
|
|
24282
|
-
}), _void(), {
|
|
25145
|
+
access: "view"
|
|
25146
|
+
}), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
|
|
24283
25147
|
kind: "mutation",
|
|
24284
|
-
auth: "admin"
|
|
24285
|
-
|
|
24286
|
-
object({
|
|
24287
|
-
detected: boolean(),
|
|
24288
|
-
/** Ms epoch of the last transition. 0 if never observed. */
|
|
24289
|
-
lastChangedAt: number()
|
|
24290
|
-
});
|
|
24291
|
-
DeviceType.Sensor;
|
|
24292
|
-
object({
|
|
24293
|
-
/** Current measured temperature. Null when not reported. */
|
|
24294
|
-
currentTemp: number().nullable(),
|
|
24295
|
-
/** Target temperature setpoint. Null when no setpoint surface. */
|
|
24296
|
-
targetTemp: number().nullable(),
|
|
24297
|
-
/** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
|
|
24298
|
-
* `heat_pump` / `high_demand` / `performance` / `off`). Null when the
|
|
24299
|
-
* device reports an unknown state. */
|
|
24300
|
-
operationMode: string().nullable(),
|
|
24301
|
-
/** Available operation modes = HA `operation_list`. */
|
|
24302
|
-
availableModes: array(string()),
|
|
24303
|
-
/** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
|
|
24304
|
-
* has no away surface. */
|
|
24305
|
-
away: boolean().nullable(),
|
|
24306
|
-
/** HA `min_temp` attribute. Null when not reported. */
|
|
24307
|
-
minTemp: number().nullable(),
|
|
24308
|
-
/** HA `max_temp` attribute. Null when not reported. */
|
|
24309
|
-
maxTemp: number().nullable(),
|
|
24310
|
-
/** Ms epoch when the slice was last updated. */
|
|
24311
|
-
lastChangedAt: number()
|
|
24312
|
-
});
|
|
24313
|
-
DeviceType.WaterHeater, method(object({
|
|
24314
|
-
deviceId: number().int().nonnegative(),
|
|
24315
|
-
temp: number().finite()
|
|
24316
|
-
}), _void(), {
|
|
25148
|
+
auth: "admin",
|
|
25149
|
+
access: "create"
|
|
25150
|
+
}), method(object({ id: string() }), object({ success: literal(true) }), {
|
|
24317
25151
|
kind: "mutation",
|
|
24318
|
-
auth: "admin"
|
|
24319
|
-
|
|
24320
|
-
|
|
24321
|
-
mode: string().min(1)
|
|
24322
|
-
}), _void(), {
|
|
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, {
|
|
24323
25155
|
kind: "mutation",
|
|
24324
|
-
auth: "admin"
|
|
25156
|
+
auth: "admin",
|
|
25157
|
+
access: "create"
|
|
24325
25158
|
}), method(object({
|
|
24326
|
-
|
|
24327
|
-
|
|
24328
|
-
}),
|
|
25159
|
+
userId: string(),
|
|
25160
|
+
code: string()
|
|
25161
|
+
}), object({ success: literal(true) }), {
|
|
24329
25162
|
kind: "mutation",
|
|
24330
|
-
auth: "admin"
|
|
24331
|
-
|
|
24332
|
-
object({
|
|
24333
|
-
|
|
24334
|
-
|
|
24335
|
-
|
|
24336
|
-
|
|
24337
|
-
|
|
24338
|
-
|
|
24339
|
-
|
|
24340
|
-
|
|
24341
|
-
|
|
24342
|
-
|
|
24343
|
-
|
|
24344
|
-
|
|
24345
|
-
|
|
24346
|
-
|
|
24347
|
-
|
|
24348
|
-
|
|
24349
|
-
|
|
24350
|
-
|
|
24351
|
-
|
|
24352
|
-
|
|
24353
|
-
|
|
24354
|
-
|
|
24355
|
-
|
|
24356
|
-
|
|
24357
|
-
|
|
24358
|
-
|
|
24359
|
-
|
|
24360
|
-
|
|
24361
|
-
|
|
24362
|
-
|
|
24363
|
-
|
|
24364
|
-
|
|
24365
|
-
|
|
24366
|
-
|
|
24367
|
-
|
|
24368
|
-
|
|
24369
|
-
|
|
24370
|
-
|
|
24371
|
-
|
|
24372
|
-
|
|
24373
|
-
|
|
24374
|
-
|
|
24375
|
-
|
|
24376
|
-
|
|
24377
|
-
|
|
24378
|
-
})
|
|
24379
|
-
|
|
24380
|
-
|
|
24381
|
-
|
|
24382
|
-
/** TrackIds of objects currently inside this zone — for cross-reference
|
|
24383
|
-
* with the per-track detail panel and live overlay. */
|
|
24384
|
-
trackIds: array(string()).readonly()
|
|
24385
|
-
});
|
|
24386
|
-
/**
|
|
24387
|
-
* A parked ("stationary") object surfaced alongside occupancy — an object that
|
|
24388
|
-
* settled and stopped moving. It is NO LONGER a tracked object (the tracker was
|
|
24389
|
-
* told to forget it so it stops re-spawning tracks/events), but it IS still
|
|
24390
|
-
* physically present, so it keeps counting toward `frame` occupancy and is
|
|
24391
|
-
* listed here so the UI can show it in a dedicated "Stationary" section instead
|
|
24392
|
-
* of flooding the live event feed.
|
|
24393
|
-
*/
|
|
24394
|
-
var StationaryObjectSchema = object({
|
|
24395
|
-
id: string(),
|
|
24396
|
-
className: string(),
|
|
24397
|
-
bbox: object({
|
|
24398
|
-
x: number(),
|
|
24399
|
-
y: number(),
|
|
24400
|
-
w: number(),
|
|
24401
|
-
h: number()
|
|
24402
|
-
}),
|
|
24403
|
-
frameWidth: number().int().nonnegative(),
|
|
24404
|
-
frameHeight: number().int().nonnegative(),
|
|
24405
|
-
/** When the source track was first seen. */
|
|
24406
|
-
firstSeenAt: number().int(),
|
|
24407
|
-
/** When the object was recognised as parked (promotion time). */
|
|
24408
|
-
becameStationaryAt: number().int(),
|
|
24409
|
-
/** Last frame a detection confirmed the object is still there. */
|
|
24410
|
-
lastConfirmedAt: number().int(),
|
|
24411
|
-
/** Enrichment label carried from the source track (identity / plate). */
|
|
24412
|
-
label: string().optional(),
|
|
24413
|
-
/** Native-resolution key-frame media key for the parked object's best image. */
|
|
24414
|
-
keyFrameMediaKey: string().optional()
|
|
24415
|
-
});
|
|
24416
|
-
var CameraOccupancySnapshotSchema = object({
|
|
24417
|
-
/** Frame timestamp of the inference result that produced this snapshot. */
|
|
24418
|
-
ts: number().int(),
|
|
24419
|
-
/** Frame width/height in pixels — let the UI normalize bbox coords. */
|
|
24420
|
-
frameWidth: number().int().nonnegative(),
|
|
24421
|
-
frameHeight: number().int().nonnegative(),
|
|
24422
|
-
/** Per-zone breakdown — one entry per defined zone (user + onboard). */
|
|
24423
|
-
zones: array(ZoneScopeBreakdownSchema).readonly(),
|
|
24424
|
-
/** Frame-wide aggregate (everywhere, regardless of zone membership).
|
|
24425
|
-
* INCLUDES currently-confirmed stationary objects (they are still present). */
|
|
24426
|
-
frame: PerScopeBreakdownSchema,
|
|
24427
|
-
/** Detections that landed outside every zone. Empty when no zones defined. */
|
|
24428
|
-
unzoned: PerScopeBreakdownSchema,
|
|
24429
|
-
/** Parked objects on this camera (additive — absent on legacy snapshots).
|
|
24430
|
-
* Surfaced separately so the UI shows them in a dedicated section rather
|
|
24431
|
-
* than as repeated tracks/events. */
|
|
24432
|
-
stationaryObjects: array(StationaryObjectSchema).readonly().optional()
|
|
24433
|
-
});
|
|
24434
|
-
/**
|
|
24435
|
-
* Time-series resolution. The history methods return one bucket per
|
|
24436
|
-
* step over the requested range. Smaller resolutions cost more
|
|
24437
|
-
* memory + bandwidth; bound to discrete steps so caller cannot ask
|
|
24438
|
-
* for arbitrary fractional buckets.
|
|
24439
|
-
*/
|
|
24440
|
-
var HistoryResolutionEnum = _enum([
|
|
24441
|
-
"minute",
|
|
24442
|
-
"5min",
|
|
24443
|
-
"hour"
|
|
24444
|
-
]);
|
|
24445
|
-
var HistoryRangeSchema = object({
|
|
24446
|
-
/** Range start (epoch ms, inclusive). */
|
|
24447
|
-
from: number().int(),
|
|
24448
|
-
/** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
|
|
24449
|
-
to: number().int(),
|
|
24450
|
-
resolution: HistoryResolutionEnum
|
|
24451
|
-
});
|
|
24452
|
-
var HistoryPointSchema = object({
|
|
24453
|
-
/** Bucket midpoint (epoch ms). */
|
|
24454
|
-
ts: number().int(),
|
|
24455
|
-
/** Object count averaged over the bucket (rounded to nearest integer). */
|
|
24456
|
-
count: number().int().nonnegative()
|
|
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"
|
|
25175
|
+
}), method(object({
|
|
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() }), {
|
|
25186
|
+
kind: "mutation",
|
|
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"
|
|
24457
25215
|
});
|
|
24458
|
-
DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
|
|
24459
|
-
deviceId: number(),
|
|
24460
|
-
zoneId: string(),
|
|
24461
|
-
className: string().optional()
|
|
24462
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
24463
|
-
deviceId: number(),
|
|
24464
|
-
className: string().optional()
|
|
24465
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
|
|
24466
|
-
deviceId: number(),
|
|
24467
|
-
className: string().optional()
|
|
24468
|
-
}).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
|
|
24469
|
-
/**
|
|
24470
|
-
* Stages a {@link ZoneRule} can apply to. Discriminator on the rules
|
|
24471
|
-
* cap so a single CRUD surface backs every consumer; each stage has
|
|
24472
|
-
* its own dev-state mirror slice (`motion-zone-rules`,
|
|
24473
|
-
* `detection-zone-rules`, …) so consumer addons subscribe independently.
|
|
24474
|
-
*
|
|
24475
|
-
* Extend the enum here when a new gating consumer comes online (audio
|
|
24476
|
-
* gating, alert filtering, …) — no other surface needs to change.
|
|
24477
|
-
*/
|
|
24478
|
-
var ZoneRuleStageEnum = _enum([
|
|
24479
|
-
"motion",
|
|
24480
|
-
"detection",
|
|
24481
|
-
"package"
|
|
24482
|
-
]);
|
|
24483
|
-
/**
|
|
24484
|
-
* Zone rules capability — per-camera CRUD over the {@link ZoneRule}
|
|
24485
|
-
* arrays that decide how each pipeline stage uses the polygon zones.
|
|
24486
|
-
*
|
|
24487
|
-
* Hosted by `addon-pipeline-orchestrator` alongside the zones provider
|
|
24488
|
-
* so the operator has a single hub-side source of truth for both
|
|
24489
|
-
* geometry and behaviour. Per-stage rules are stored under the
|
|
24490
|
-
* `zoneRules.<stage>` key in the orchestrator's per-device store and
|
|
24491
|
-
* mirrored to the device-state slice `<stage>-zone-rules` on every
|
|
24492
|
-
* mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
|
|
24493
|
-
* subscribe to that slice and refresh their gating without
|
|
24494
|
-
* round-tripping the cap.
|
|
24495
|
-
*
|
|
24496
|
-
* Sets are bulk-replace — the operator UI sends the new rule list
|
|
24497
|
-
* wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
|
|
24498
|
-
* naturally in the rule editor without per-rule mutation chatter.
|
|
24499
|
-
*/
|
|
24500
|
-
var zoneRulesCapability = {
|
|
24501
|
-
name: "zone-rules",
|
|
24502
|
-
scope: "device",
|
|
24503
|
-
mode: "singleton",
|
|
24504
|
-
deviceTypes: [DeviceType.Camera],
|
|
24505
|
-
methods: {
|
|
24506
|
-
/** Read the full rule list for a given stage (empty when no rules
|
|
24507
|
-
* are defined yet). */
|
|
24508
|
-
listRules: method(object({
|
|
24509
|
-
deviceId: number(),
|
|
24510
|
-
stage: ZoneRuleStageEnum
|
|
24511
|
-
}), array(ZoneRuleSchema).readonly()),
|
|
24512
|
-
/** Bulk-replace the rule list for one stage. The provider validates
|
|
24513
|
-
* each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
|
|
24514
|
-
* thresholds in range) and rejects the whole patch if any entry
|
|
24515
|
-
* is invalid — partial writes are a configuration footgun. */
|
|
24516
|
-
setRules: method(object({
|
|
24517
|
-
deviceId: number(),
|
|
24518
|
-
stage: ZoneRuleStageEnum,
|
|
24519
|
-
rules: array(ZoneRuleSchema).readonly()
|
|
24520
|
-
}), _void(), {
|
|
24521
|
-
kind: "mutation",
|
|
24522
|
-
auth: "admin"
|
|
24523
|
-
})
|
|
24524
|
-
},
|
|
24525
|
-
/**
|
|
24526
|
-
* Runtime-state slice — every stage mirrored together so consumers
|
|
24527
|
-
* see one reactive handle (`device.state.zoneRules.value`) instead
|
|
24528
|
-
* of one per stage. Bulk-replace mutations on any stage write the full
|
|
24529
|
-
* `{motion, detection, package}` shape, so subscribers always get the
|
|
24530
|
-
* complete current set. Consumers that only care about one stage
|
|
24531
|
-
* just read the matching property.
|
|
24532
|
-
*
|
|
24533
|
-
* `package` backs the package-drop detector — a package zone is a
|
|
24534
|
-
* `ZoneRule` on the `'package'` stage referencing drawn polygons
|
|
24535
|
-
* (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
|
|
24536
|
-
* The orchestrator provider writes this stage as a first-class slice
|
|
24537
|
-
* (Phase 4): every mutation mirrors the full `{motion, detection,
|
|
24538
|
-
* package}` shape, so consumers read the current package rules directly
|
|
24539
|
-
* off `device.state.zoneRules.value.package`.
|
|
24540
|
-
*/
|
|
24541
|
-
runtimeState: object({
|
|
24542
|
-
motion: array(ZoneRuleSchema).readonly(),
|
|
24543
|
-
detection: array(ZoneRuleSchema).readonly(),
|
|
24544
|
-
package: array(ZoneRuleSchema).readonly()
|
|
24545
|
-
})
|
|
24546
|
-
};
|
|
24547
25216
|
/**
|
|
24548
|
-
*
|
|
24549
|
-
*
|
|
24550
|
-
*
|
|
24551
|
-
* top of a parent (Reolink: siren / floodlight / PIR / autotrack /
|
|
24552
|
-
* chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
|
|
24553
|
-
* driver picks the right `DeviceType` + `DeviceRole` explicitly when
|
|
24554
|
-
* spawning, builds a name derived from the parent, and produces a
|
|
24555
|
-
* stableId tied to the parent so boot-restore can reconstruct the
|
|
24556
|
-
* relationship.
|
|
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.
|
|
24557
25220
|
*
|
|
24558
|
-
*
|
|
24559
|
-
*
|
|
24560
|
-
* (
|
|
24561
|
-
*
|
|
24562
|
-
*
|
|
24563
|
-
*
|
|
24564
|
-
* driver knows its own hardware best.
|
|
24565
|
-
*/
|
|
24566
|
-
/**
|
|
24567
|
-
* Subset of `DeviceRole` values that drivers register as child
|
|
24568
|
-
* accessories of a parent device. Sourced verbatim from `DeviceRole`
|
|
24569
|
-
* — `AccessoryKind` is the alias drivers use when building accessory
|
|
24570
|
-
* children, so the call site reads as
|
|
24571
|
-
* `accessoryStableId(parent, AccessoryKind.Siren)` rather than
|
|
24572
|
-
* `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
|
|
24573
|
-
* any role works, including non-accessory ones like Doorbell).
|
|
24574
|
-
*/
|
|
24575
|
-
var AccessoryKind = {
|
|
24576
|
-
Siren: DeviceRole.Siren,
|
|
24577
|
-
Floodlight: DeviceRole.Floodlight,
|
|
24578
|
-
Spotlight: DeviceRole.Spotlight,
|
|
24579
|
-
PirSensor: DeviceRole.PirSensor,
|
|
24580
|
-
Chime: DeviceRole.Chime,
|
|
24581
|
-
Autotrack: DeviceRole.Autotrack,
|
|
24582
|
-
Nightvision: DeviceRole.Nightvision,
|
|
24583
|
-
PrivacyMask: DeviceRole.PrivacyMask
|
|
24584
|
-
};
|
|
24585
|
-
AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
|
|
24586
|
-
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;
|
|
24587
|
-
new Set(Object.values(DeviceType));
|
|
24588
|
-
/**
|
|
24589
|
-
* Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
|
|
24590
|
-
*/
|
|
24591
|
-
function deviceMatchesProfile(features, profile) {
|
|
24592
|
-
if (!features || features.length === 0) return false;
|
|
24593
|
-
return features.includes(profile.when.hasFeature);
|
|
24594
|
-
}
|
|
24595
|
-
/**
|
|
24596
|
-
* Profile registry — order matters when multiple profiles match the
|
|
24597
|
-
* same device (first match wins). Today there's only one entry.
|
|
24598
|
-
*/
|
|
24599
|
-
var DEVICE_PROFILES = [{
|
|
24600
|
-
id: "battery",
|
|
24601
|
-
label: "Battery-operated camera",
|
|
24602
|
-
when: { hasFeature: DeviceFeature.BatteryOperated },
|
|
24603
|
-
defaults: {
|
|
24604
|
-
audioMode: "disabled",
|
|
24605
|
-
detectionMode: "on-motion"
|
|
24606
|
-
},
|
|
24607
|
-
settings: {}
|
|
24608
|
-
}];
|
|
24609
|
-
/**
|
|
24610
|
-
* Resolve the profile that matches a device's features, or `null` when
|
|
24611
|
-
* no profile matches. First-match-wins.
|
|
24612
|
-
*/
|
|
24613
|
-
function resolveDeviceProfile(features) {
|
|
24614
|
-
for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
|
|
24615
|
-
return null;
|
|
24616
|
-
}
|
|
24617
|
-
/**
|
|
24618
|
-
* Error types for the safe expression engine. Two distinct classes so callers
|
|
24619
|
-
* can tell a compile-time (grammar) failure from a runtime (evaluation)
|
|
24620
|
-
* failure — both are non-fatal to the host: read paths degrade to "skip link".
|
|
24621
|
-
*/
|
|
24622
|
-
/** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
|
|
24623
|
-
* the failure is anchored to a character (author-facing inline feedback). */
|
|
24624
|
-
var ExpressionParseError = class extends Error {
|
|
24625
|
-
position;
|
|
24626
|
-
constructor(message, position) {
|
|
24627
|
-
super(message);
|
|
24628
|
-
this.name = "ExpressionParseError";
|
|
24629
|
-
this.position = position;
|
|
24630
|
-
}
|
|
24631
|
-
};
|
|
24632
|
-
/** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
|
|
24633
|
-
* result, unknown builtin, step-budget exceeded). */
|
|
24634
|
-
var ExpressionEvalError = class extends Error {
|
|
24635
|
-
constructor(message) {
|
|
24636
|
-
super(message);
|
|
24637
|
-
this.name = "ExpressionEvalError";
|
|
24638
|
-
}
|
|
24639
|
-
};
|
|
24640
|
-
/**
|
|
24641
|
-
* Frozen, null-prototype builtin function table for the expression engine
|
|
24642
|
-
* (spec §4 rule 4). The table is the SOLE surface of callable functions: the
|
|
24643
|
-
* parser rejects any callee not in it, and the evaluator gates each call on an
|
|
24644
|
-
* own-property check against it.
|
|
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.
|
|
24645
25227
|
*
|
|
24646
|
-
*
|
|
24647
|
-
*
|
|
24648
|
-
*
|
|
24649
|
-
* (there is no `Object.prototype` in the chain), so those names are not
|
|
24650
|
-
* callable — they are simply "unknown function" at parse time.
|
|
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.
|
|
24651
25231
|
*
|
|
24652
|
-
*
|
|
24653
|
-
*
|
|
24654
|
-
*
|
|
24655
|
-
*
|
|
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`.
|
|
24656
25240
|
*/
|
|
24657
|
-
|
|
24658
|
-
|
|
24659
|
-
|
|
24660
|
-
|
|
24661
|
-
|
|
24662
|
-
|
|
24663
|
-
|
|
24664
|
-
|
|
24665
|
-
|
|
24666
|
-
|
|
24667
|
-
|
|
24668
|
-
|
|
24669
|
-
|
|
24670
|
-
|
|
24671
|
-
|
|
24672
|
-
|
|
24673
|
-
var
|
|
24674
|
-
|
|
24675
|
-
|
|
24676
|
-
|
|
24677
|
-
|
|
24678
|
-
|
|
24679
|
-
|
|
24680
|
-
|
|
24681
|
-
|
|
24682
|
-
|
|
24683
|
-
|
|
24684
|
-
|
|
24685
|
-
|
|
24686
|
-
|
|
24687
|
-
|
|
24688
|
-
|
|
24689
|
-
|
|
24690
|
-
|
|
24691
|
-
|
|
24692
|
-
|
|
24693
|
-
|
|
24694
|
-
|
|
24695
|
-
|
|
24696
|
-
|
|
24697
|
-
|
|
24698
|
-
|
|
24699
|
-
|
|
24700
|
-
|
|
24701
|
-
|
|
24702
|
-
|
|
24703
|
-
|
|
24704
|
-
|
|
24705
|
-
|
|
24706
|
-
|
|
24707
|
-
|
|
24708
|
-
|
|
24709
|
-
|
|
24710
|
-
|
|
24711
|
-
|
|
24712
|
-
|
|
24713
|
-
|
|
24714
|
-
|
|
24715
|
-
|
|
24716
|
-
|
|
24717
|
-
|
|
24718
|
-
|
|
24719
|
-
|
|
24720
|
-
|
|
24721
|
-
|
|
24722
|
-
|
|
24723
|
-
|
|
24724
|
-
|
|
24725
|
-
|
|
24726
|
-
|
|
24727
|
-
|
|
24728
|
-
|
|
24729
|
-
|
|
24730
|
-
|
|
24731
|
-
|
|
24732
|
-
|
|
24733
|
-
|
|
24734
|
-
|
|
24735
|
-
|
|
24736
|
-
|
|
24737
|
-
|
|
24738
|
-
|
|
24739
|
-
|
|
24740
|
-
|
|
24741
|
-
|
|
24742
|
-
|
|
24743
|
-
|
|
24744
|
-
|
|
24745
|
-
|
|
24746
|
-
|
|
24747
|
-
|
|
24748
|
-
|
|
24749
|
-
|
|
24750
|
-
|
|
24751
|
-
|
|
24752
|
-
|
|
24753
|
-
|
|
24754
|
-
|
|
24755
|
-
|
|
24756
|
-
|
|
24757
|
-
|
|
24758
|
-
|
|
24759
|
-
|
|
24760
|
-
|
|
24761
|
-
|
|
24762
|
-
|
|
24763
|
-
|
|
24764
|
-
|
|
24765
|
-
|
|
24766
|
-
|
|
24767
|
-
|
|
24768
|
-
|
|
24769
|
-
|
|
24770
|
-
|
|
24771
|
-
|
|
24772
|
-
|
|
24773
|
-
|
|
24774
|
-
|
|
24775
|
-
/**
|
|
24776
|
-
*
|
|
24777
|
-
|
|
25241
|
+
var VacuumStateSchema = _enum([
|
|
25242
|
+
"idle",
|
|
25243
|
+
"cleaning",
|
|
25244
|
+
"paused",
|
|
25245
|
+
"returning",
|
|
25246
|
+
"docked",
|
|
25247
|
+
"drying",
|
|
25248
|
+
"error"
|
|
25249
|
+
]);
|
|
25250
|
+
/**
|
|
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.
|
|
25256
|
+
*/
|
|
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;
|
|
24778
25418
|
/**
|
|
24779
|
-
*
|
|
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.
|
|
24780
25424
|
*
|
|
24781
|
-
*
|
|
24782
|
-
*
|
|
24783
|
-
*
|
|
24784
|
-
*
|
|
24785
|
-
*
|
|
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.
|
|
25429
|
+
*
|
|
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.
|
|
24786
25434
|
*/
|
|
24787
|
-
|
|
24788
|
-
|
|
24789
|
-
|
|
24790
|
-
/**
|
|
24791
|
-
|
|
24792
|
-
|
|
24793
|
-
|
|
24794
|
-
|
|
24795
|
-
|
|
24796
|
-
|
|
24797
|
-
|
|
24798
|
-
|
|
24799
|
-
|
|
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
|
+
});
|
|
24800
25448
|
/**
|
|
24801
|
-
*
|
|
24802
|
-
*
|
|
24803
|
-
*
|
|
24804
|
-
*
|
|
24805
|
-
*
|
|
24806
|
-
*
|
|
24807
|
-
* 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.
|
|
24808
25455
|
*/
|
|
24809
|
-
var
|
|
24810
|
-
|
|
24811
|
-
|
|
24812
|
-
|
|
24813
|
-
|
|
24814
|
-
|
|
24815
|
-
|
|
24816
|
-
|
|
24817
|
-
|
|
24818
|
-
|
|
24819
|
-
|
|
24820
|
-
|
|
24821
|
-
|
|
24822
|
-
|
|
24823
|
-
|
|
24824
|
-
|
|
24825
|
-
|
|
24826
|
-
/**
|
|
24827
|
-
|
|
24828
|
-
|
|
24829
|
-
|
|
24830
|
-
|
|
24831
|
-
|
|
24832
|
-
|
|
24833
|
-
|
|
24834
|
-
|
|
24835
|
-
|
|
24836
|
-
|
|
24837
|
-
|
|
24838
|
-
|
|
24839
|
-
|
|
24840
|
-
|
|
24841
|
-
|
|
24842
|
-
|
|
24843
|
-
|
|
24844
|
-
|
|
24845
|
-
|
|
24846
|
-
|
|
24847
|
-
|
|
24848
|
-
|
|
24849
|
-
const value = Number(text);
|
|
24850
|
-
if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
|
|
24851
|
-
tokens.push({
|
|
24852
|
-
type: "number",
|
|
24853
|
-
value,
|
|
24854
|
-
pos: start
|
|
24855
|
-
});
|
|
24856
|
-
continue;
|
|
24857
|
-
}
|
|
24858
|
-
if (ch === "'" || ch === "\"") {
|
|
24859
|
-
const quote = ch;
|
|
24860
|
-
const start = i;
|
|
24861
|
-
i += 1;
|
|
24862
|
-
let out = "";
|
|
24863
|
-
let closed = false;
|
|
24864
|
-
while (i < n) {
|
|
24865
|
-
const c = source[i];
|
|
24866
|
-
if (c === "\\") {
|
|
24867
|
-
const next = i + 1 < n ? source[i + 1] : "";
|
|
24868
|
-
if (next === "\\" || next === "'" || next === "\"") {
|
|
24869
|
-
out += next;
|
|
24870
|
-
i += 2;
|
|
24871
|
-
continue;
|
|
24872
|
-
}
|
|
24873
|
-
throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
|
|
24874
|
-
}
|
|
24875
|
-
if (c === quote) {
|
|
24876
|
-
closed = true;
|
|
24877
|
-
i += 1;
|
|
24878
|
-
break;
|
|
24879
|
-
}
|
|
24880
|
-
out += c;
|
|
24881
|
-
i += 1;
|
|
24882
|
-
}
|
|
24883
|
-
if (!closed) throw new ExpressionParseError("unterminated string literal", start);
|
|
24884
|
-
tokens.push({
|
|
24885
|
-
type: "string",
|
|
24886
|
-
value: out,
|
|
24887
|
-
pos: start
|
|
24888
|
-
});
|
|
24889
|
-
continue;
|
|
24890
|
-
}
|
|
24891
|
-
if (isIdentStart(ch)) {
|
|
24892
|
-
const start = i;
|
|
24893
|
-
while (i < n && isIdentPart(source[i])) i += 1;
|
|
24894
|
-
const text = source.slice(start, i);
|
|
24895
|
-
if (KEYWORDS.has(text)) tokens.push({
|
|
24896
|
-
type: "keyword",
|
|
24897
|
-
keyword: keywordOf(text),
|
|
24898
|
-
pos: start
|
|
24899
|
-
});
|
|
24900
|
-
else tokens.push({
|
|
24901
|
-
type: "identifier",
|
|
24902
|
-
name: text,
|
|
24903
|
-
pos: start
|
|
24904
|
-
});
|
|
24905
|
-
continue;
|
|
24906
|
-
}
|
|
24907
|
-
const two = i + 1 < n ? source.slice(i, i + 2) : "";
|
|
24908
|
-
if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
|
|
24909
|
-
tokens.push({
|
|
24910
|
-
type: "punct",
|
|
24911
|
-
punct: two,
|
|
24912
|
-
pos: i
|
|
24913
|
-
});
|
|
24914
|
-
i += 2;
|
|
24915
|
-
continue;
|
|
24916
|
-
}
|
|
24917
|
-
if (isSinglePunct(ch)) {
|
|
24918
|
-
tokens.push({
|
|
24919
|
-
type: "punct",
|
|
24920
|
-
punct: ch,
|
|
24921
|
-
pos: i
|
|
24922
|
-
});
|
|
24923
|
-
i += 1;
|
|
24924
|
-
continue;
|
|
24925
|
-
}
|
|
24926
|
-
throw new ExpressionParseError(`unexpected character '${ch}'`, i);
|
|
24927
|
-
}
|
|
24928
|
-
tokens.push({
|
|
24929
|
-
type: "eof",
|
|
24930
|
-
pos: n
|
|
24931
|
-
});
|
|
24932
|
-
return tokens;
|
|
24933
|
-
}
|
|
24934
|
-
function keywordOf(text) {
|
|
24935
|
-
if (text === "true") return "true";
|
|
24936
|
-
if (text === "false") return "false";
|
|
24937
|
-
return "null";
|
|
24938
|
-
}
|
|
24939
|
-
function isSinglePunct(ch) {
|
|
24940
|
-
return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
|
|
24941
|
-
}
|
|
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
|
+
});
|
|
24942
25496
|
/**
|
|
24943
|
-
*
|
|
24944
|
-
*
|
|
24945
|
-
*
|
|
24946
|
-
*
|
|
24947
|
-
|
|
24948
|
-
|
|
24949
|
-
|
|
24950
|
-
|
|
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"
|
|
25506
|
+
]);
|
|
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());
|
|
25531
|
+
/**
|
|
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.
|
|
24951
25536
|
*
|
|
24952
|
-
*
|
|
24953
|
-
*
|
|
25537
|
+
* Extend the enum here when a new gating consumer comes online (audio
|
|
25538
|
+
* gating, alert filtering, …) — no other surface needs to change.
|
|
24954
25539
|
*/
|
|
24955
|
-
|
|
24956
|
-
|
|
24957
|
-
"
|
|
24958
|
-
"
|
|
24959
|
-
|
|
24960
|
-
|
|
24961
|
-
|
|
24962
|
-
|
|
24963
|
-
|
|
24964
|
-
|
|
24965
|
-
|
|
24966
|
-
|
|
24967
|
-
|
|
24968
|
-
|
|
24969
|
-
|
|
24970
|
-
|
|
24971
|
-
|
|
24972
|
-
|
|
24973
|
-
|
|
24974
|
-
|
|
24975
|
-
|
|
24976
|
-
|
|
24977
|
-
var
|
|
24978
|
-
|
|
24979
|
-
|
|
24980
|
-
|
|
24981
|
-
|
|
24982
|
-
|
|
24983
|
-
|
|
24984
|
-
|
|
24985
|
-
|
|
24986
|
-
|
|
24987
|
-
|
|
24988
|
-
|
|
24989
|
-
|
|
24990
|
-
|
|
24991
|
-
|
|
24992
|
-
|
|
24993
|
-
|
|
24994
|
-
|
|
24995
|
-
|
|
24996
|
-
|
|
24997
|
-
|
|
24998
|
-
|
|
24999
|
-
|
|
25000
|
-
|
|
25001
|
-
|
|
25002
|
-
|
|
25003
|
-
|
|
25004
|
-
|
|
25005
|
-
|
|
25006
|
-
|
|
25007
|
-
|
|
25008
|
-
|
|
25009
|
-
|
|
25010
|
-
|
|
25011
|
-
|
|
25012
|
-
|
|
25013
|
-
|
|
25014
|
-
|
|
25015
|
-
|
|
25016
|
-
|
|
25017
|
-
|
|
25018
|
-
|
|
25019
|
-
|
|
25020
|
-
|
|
25021
|
-
|
|
25022
|
-
|
|
25023
|
-
if (this.matchPunct("?")) {
|
|
25024
|
-
const consequent = this.parseTernary();
|
|
25025
|
-
this.expectPunct(":");
|
|
25026
|
-
const alternate = this.parseTernary();
|
|
25027
|
-
this.countNode();
|
|
25028
|
-
return {
|
|
25029
|
-
kind: "conditional",
|
|
25030
|
-
test,
|
|
25031
|
-
consequent,
|
|
25032
|
-
alternate
|
|
25033
|
-
};
|
|
25034
|
-
}
|
|
25035
|
-
return test;
|
|
25036
|
-
}
|
|
25037
|
-
parseBinary(minPrec) {
|
|
25038
|
-
let left = this.parseUnary();
|
|
25039
|
-
for (;;) {
|
|
25040
|
-
const tok = this.peek();
|
|
25041
|
-
if (tok.type !== "punct") break;
|
|
25042
|
-
const prec = BINARY_PRECEDENCE[tok.punct];
|
|
25043
|
-
if (prec === void 0 || prec < minPrec) break;
|
|
25044
|
-
const op = tok.punct;
|
|
25045
|
-
this.pos += 1;
|
|
25046
|
-
const right = this.parseBinary(prec + 1);
|
|
25047
|
-
this.countNode();
|
|
25048
|
-
if (isLogicalOp(op)) left = {
|
|
25049
|
-
kind: "logical",
|
|
25050
|
-
op,
|
|
25051
|
-
left,
|
|
25052
|
-
right
|
|
25053
|
-
};
|
|
25054
|
-
else if (isBinaryOp(op)) left = {
|
|
25055
|
-
kind: "binary",
|
|
25056
|
-
op,
|
|
25057
|
-
left,
|
|
25058
|
-
right
|
|
25059
|
-
};
|
|
25060
|
-
else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
|
|
25061
|
-
}
|
|
25062
|
-
return left;
|
|
25063
|
-
}
|
|
25064
|
-
parseUnary() {
|
|
25065
|
-
const tok = this.peek();
|
|
25066
|
-
if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
|
|
25067
|
-
const op = tok.punct;
|
|
25068
|
-
this.pos += 1;
|
|
25069
|
-
const operand = this.parseUnary();
|
|
25070
|
-
this.countNode();
|
|
25071
|
-
return {
|
|
25072
|
-
kind: "unary",
|
|
25073
|
-
op,
|
|
25074
|
-
operand
|
|
25075
|
-
};
|
|
25076
|
-
}
|
|
25077
|
-
return this.parsePrimary();
|
|
25078
|
-
}
|
|
25079
|
-
parsePrimary() {
|
|
25080
|
-
const tok = this.next();
|
|
25081
|
-
switch (tok.type) {
|
|
25082
|
-
case "number":
|
|
25083
|
-
this.countNode();
|
|
25084
|
-
return {
|
|
25085
|
-
kind: "literal",
|
|
25086
|
-
value: tok.value
|
|
25087
|
-
};
|
|
25088
|
-
case "string":
|
|
25089
|
-
this.countNode();
|
|
25090
|
-
return {
|
|
25091
|
-
kind: "literal",
|
|
25092
|
-
value: tok.value
|
|
25093
|
-
};
|
|
25094
|
-
case "keyword":
|
|
25095
|
-
this.countNode();
|
|
25096
|
-
return {
|
|
25097
|
-
kind: "literal",
|
|
25098
|
-
value: tok.keyword === "null" ? null : tok.keyword === "true"
|
|
25099
|
-
};
|
|
25100
|
-
case "identifier": {
|
|
25101
|
-
const nextTok = this.peek();
|
|
25102
|
-
if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
|
|
25103
|
-
this.identifiers.add(tok.name);
|
|
25104
|
-
this.countNode();
|
|
25105
|
-
return {
|
|
25106
|
-
kind: "identifier",
|
|
25107
|
-
name: tok.name
|
|
25108
|
-
};
|
|
25109
|
-
}
|
|
25110
|
-
case "punct":
|
|
25111
|
-
if (tok.punct === "(") {
|
|
25112
|
-
const inner = this.parseTernary();
|
|
25113
|
-
this.expectPunct(")");
|
|
25114
|
-
return inner;
|
|
25115
|
-
}
|
|
25116
|
-
throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
|
|
25117
|
-
case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
|
|
25118
|
-
}
|
|
25119
|
-
}
|
|
25120
|
-
parseCall(callee, pos) {
|
|
25121
|
-
if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
|
|
25122
|
-
this.expectPunct("(");
|
|
25123
|
-
const args = [];
|
|
25124
|
-
if (!this.matchPunct(")")) for (;;) {
|
|
25125
|
-
args.push(this.parseTernary());
|
|
25126
|
-
if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
|
|
25127
|
-
if (this.matchPunct(",")) continue;
|
|
25128
|
-
this.expectPunct(")");
|
|
25129
|
-
break;
|
|
25130
|
-
}
|
|
25131
|
-
this.callees.add(callee);
|
|
25132
|
-
this.countNode();
|
|
25133
|
-
return {
|
|
25134
|
-
kind: "call",
|
|
25135
|
-
callee,
|
|
25136
|
-
args
|
|
25137
|
-
};
|
|
25138
|
-
}
|
|
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.
|
|
25548
|
+
*
|
|
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.
|
|
25561
|
+
*/
|
|
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
|
+
})
|
|
25139
25608
|
};
|
|
25140
|
-
/** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
|
|
25141
|
-
* `ExpressionParseError` on any lexical or grammatical failure. */
|
|
25142
|
-
function parseExpression(source) {
|
|
25143
|
-
return new Parser(tokenize(source)).parse();
|
|
25144
|
-
}
|
|
25145
25609
|
/**
|
|
25146
|
-
*
|
|
25147
|
-
* by expr"). The cache stores BOTH successes and failures (negative caching),
|
|
25148
|
-
* so a corrupt persisted string costs exactly one tokenize+parse total — not
|
|
25149
|
-
* one per read on a hot resolve path.
|
|
25610
|
+
* Accessory device helpers — shared across drivers.
|
|
25150
25611
|
*
|
|
25151
|
-
*
|
|
25152
|
-
*
|
|
25153
|
-
*
|
|
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.
|
|
25154
25627
|
*/
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
-
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
25174
|
-
|
|
25175
|
-
|
|
25176
|
-
|
|
25177
|
-
|
|
25178
|
-
|
|
25179
|
-
|
|
25180
|
-
|
|
25181
|
-
|
|
25182
|
-
|
|
25183
|
-
* Used by read paths that must degrade rather than raise. LRU/negative-cached. */
|
|
25184
|
-
function compileExpressionSafe(source) {
|
|
25185
|
-
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);
|
|
25186
25656
|
}
|
|
25187
|
-
Object.freeze({});
|
|
25188
25657
|
/**
|
|
25189
|
-
*
|
|
25190
|
-
*
|
|
25191
|
-
* is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
|
|
25192
|
-
* is not reserved (`now`/keywords) and does not shadow a builtin; and every
|
|
25193
|
-
* 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.
|
|
25194
25660
|
*/
|
|
25195
|
-
|
|
25196
|
-
|
|
25197
|
-
|
|
25198
|
-
|
|
25199
|
-
|
|
25200
|
-
|
|
25201
|
-
|
|
25202
|
-
}
|
|
25203
|
-
|
|
25204
|
-
|
|
25205
|
-
|
|
25206
|
-
|
|
25207
|
-
|
|
25208
|
-
|
|
25209
|
-
|
|
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;
|
|
25210
25677
|
return null;
|
|
25211
25678
|
}
|
|
25212
|
-
var ExpressionBindingSourceSchema = union([
|
|
25213
|
-
object({
|
|
25214
|
-
kind: literal("field").optional(),
|
|
25215
|
-
sourceKey: string(),
|
|
25216
|
-
cap: string(),
|
|
25217
|
-
fieldPath: string()
|
|
25218
|
-
}),
|
|
25219
|
-
object({
|
|
25220
|
-
kind: literal("literal"),
|
|
25221
|
-
value: union([
|
|
25222
|
-
string(),
|
|
25223
|
-
number(),
|
|
25224
|
-
boolean(),
|
|
25225
|
-
_null()
|
|
25226
|
-
])
|
|
25227
|
-
}),
|
|
25228
|
-
object({
|
|
25229
|
-
kind: literal("global"),
|
|
25230
|
-
sourceStableId: string(),
|
|
25231
|
-
cap: string(),
|
|
25232
|
-
fieldPath: string()
|
|
25233
|
-
})
|
|
25234
|
-
]);
|
|
25235
|
-
object({
|
|
25236
|
-
expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
|
|
25237
|
-
bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
|
|
25238
|
-
}).superRefine((src, ctx) => {
|
|
25239
|
-
const err = validateExpressionSource(src);
|
|
25240
|
-
if (err !== null) ctx.addIssue({
|
|
25241
|
-
code: "custom",
|
|
25242
|
-
message: err,
|
|
25243
|
-
path: ["expr"]
|
|
25244
|
-
});
|
|
25245
|
-
});
|
|
25246
25679
|
Object.freeze({
|
|
25247
25680
|
"accessories.setChildHidden": {
|
|
25248
25681
|
capName: "accessories",
|
|
@@ -26018,6 +26451,18 @@ Object.freeze({
|
|
|
26018
26451
|
addonId: null,
|
|
26019
26452
|
access: "create"
|
|
26020
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
|
+
},
|
|
26021
26466
|
"consumables.reset": {
|
|
26022
26467
|
capName: "consumables",
|
|
26023
26468
|
capScope: "device",
|
|
@@ -26408,6 +26853,12 @@ Object.freeze({
|
|
|
26408
26853
|
addonId: null,
|
|
26409
26854
|
access: "create"
|
|
26410
26855
|
},
|
|
26856
|
+
"deviceManager.adoptionCancelJob": {
|
|
26857
|
+
capName: "device-manager",
|
|
26858
|
+
capScope: "system",
|
|
26859
|
+
addonId: null,
|
|
26860
|
+
access: "create"
|
|
26861
|
+
},
|
|
26411
26862
|
"deviceManager.adoptionListCandidateFilters": {
|
|
26412
26863
|
capName: "device-manager",
|
|
26413
26864
|
capScope: "system",
|
|
@@ -26420,6 +26871,12 @@ Object.freeze({
|
|
|
26420
26871
|
addonId: null,
|
|
26421
26872
|
access: "view"
|
|
26422
26873
|
},
|
|
26874
|
+
"deviceManager.adoptionListJobs": {
|
|
26875
|
+
capName: "device-manager",
|
|
26876
|
+
capScope: "system",
|
|
26877
|
+
addonId: null,
|
|
26878
|
+
access: "view"
|
|
26879
|
+
},
|
|
26423
26880
|
"deviceManager.adoptionRefresh": {
|
|
26424
26881
|
capName: "device-manager",
|
|
26425
26882
|
capScope: "system",
|
|
@@ -26438,6 +26895,12 @@ Object.freeze({
|
|
|
26438
26895
|
addonId: null,
|
|
26439
26896
|
access: "create"
|
|
26440
26897
|
},
|
|
26898
|
+
"deviceManager.adoptionStartJob": {
|
|
26899
|
+
capName: "device-manager",
|
|
26900
|
+
capScope: "system",
|
|
26901
|
+
addonId: null,
|
|
26902
|
+
access: "create"
|
|
26903
|
+
},
|
|
26441
26904
|
"deviceManager.allocateDeviceId": {
|
|
26442
26905
|
capName: "device-manager",
|
|
26443
26906
|
capScope: "system",
|
|
@@ -29576,6 +30039,12 @@ Object.freeze({
|
|
|
29576
30039
|
addonId: null,
|
|
29577
30040
|
access: "view"
|
|
29578
30041
|
},
|
|
30042
|
+
"snapshot.getSnapshotLinks": {
|
|
30043
|
+
capName: "snapshot",
|
|
30044
|
+
capScope: "device",
|
|
30045
|
+
addonId: null,
|
|
30046
|
+
access: "view"
|
|
30047
|
+
},
|
|
29579
30048
|
"snapshot.getSnapshotOverview": {
|
|
29580
30049
|
capName: "snapshot",
|
|
29581
30050
|
capScope: "device",
|