camstack 1.2.210 → 1.2.211
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/CHANGELOG.md +8 -0
- package/dist/{chunk-EMTOU2RA.js → chunk-NAUJQNCX.js} +1470 -0
- package/dist/cli.js +6 -6
- package/dist/{launcher-FMNINS4U.js → launcher-UXJ4WU3U.js} +60 -0
- package/dist/server-OYJZ5RCY.js +7407 -0
- package/package.json +1 -1
- package/dist/server-LHBD6ATG.js +0 -2243
|
@@ -1724,6 +1724,9 @@ var READING_QUANTITIES = [
|
|
|
1724
1724
|
];
|
|
1725
1725
|
var ReadingQuantitySchema = z2.enum(READING_QUANTITIES);
|
|
1726
1726
|
var QUANTITY_SET = new Set(READING_QUANTITIES);
|
|
1727
|
+
function isReadingQuantity(value) {
|
|
1728
|
+
return typeof value === "string" && QUANTITY_SET.has(value);
|
|
1729
|
+
}
|
|
1727
1730
|
var QUANTITY_BASE = {
|
|
1728
1731
|
temperature: "\xB0C",
|
|
1729
1732
|
pressure: "hPa",
|
|
@@ -6747,6 +6750,63 @@ var ExpressionSourceSchema = z4.object({
|
|
|
6747
6750
|
path: ["expr"]
|
|
6748
6751
|
});
|
|
6749
6752
|
});
|
|
6753
|
+
function unwrapSchema(schema) {
|
|
6754
|
+
let s = schema;
|
|
6755
|
+
while (s instanceof z4.ZodNullable || s instanceof z4.ZodOptional || s instanceof z4.ZodDefault) s = s.unwrap();
|
|
6756
|
+
return s;
|
|
6757
|
+
}
|
|
6758
|
+
function isMetaBag(value) {
|
|
6759
|
+
return typeof value === "object" && value !== null;
|
|
6760
|
+
}
|
|
6761
|
+
function metaQuantity(schema) {
|
|
6762
|
+
const bag = z4.globalRegistry.get(schema);
|
|
6763
|
+
if (isMetaBag(bag) && isReadingQuantity(bag.quantity)) return bag.quantity;
|
|
6764
|
+
}
|
|
6765
|
+
function quantityOfField(schema) {
|
|
6766
|
+
return metaQuantity(schema) ?? metaQuantity(unwrapSchema(schema));
|
|
6767
|
+
}
|
|
6768
|
+
function leafKind(schema) {
|
|
6769
|
+
const s = unwrapSchema(schema);
|
|
6770
|
+
if (s instanceof z4.ZodEnum) return {
|
|
6771
|
+
kind: "enum",
|
|
6772
|
+
enumValues: s.options
|
|
6773
|
+
};
|
|
6774
|
+
if (s instanceof z4.ZodNumber) return { kind: "number" };
|
|
6775
|
+
if (s instanceof z4.ZodString) return { kind: "string" };
|
|
6776
|
+
if (s instanceof z4.ZodBoolean) return { kind: "boolean" };
|
|
6777
|
+
return null;
|
|
6778
|
+
}
|
|
6779
|
+
function enumerateSchemaFields(schema, prefix = "") {
|
|
6780
|
+
const root = unwrapSchema(schema);
|
|
6781
|
+
if (!(root instanceof z4.ZodObject)) return [];
|
|
6782
|
+
const out = [];
|
|
6783
|
+
const shape = root.shape;
|
|
6784
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
6785
|
+
const path2 = prefix ? `${prefix}.${key}` : key;
|
|
6786
|
+
const inner = unwrapSchema(field);
|
|
6787
|
+
if (inner instanceof z4.ZodObject) {
|
|
6788
|
+
out.push(...enumerateSchemaFields(inner, path2));
|
|
6789
|
+
continue;
|
|
6790
|
+
}
|
|
6791
|
+
const leaf = leafKind(field);
|
|
6792
|
+
if (leaf) {
|
|
6793
|
+
const quantity = quantityOfField(field);
|
|
6794
|
+
out.push({
|
|
6795
|
+
path: path2,
|
|
6796
|
+
kind: leaf.kind,
|
|
6797
|
+
...leaf.enumValues ? { enumValues: leaf.enumValues } : {},
|
|
6798
|
+
...quantity !== void 0 ? { quantity } : {}
|
|
6799
|
+
});
|
|
6800
|
+
}
|
|
6801
|
+
}
|
|
6802
|
+
return out;
|
|
6803
|
+
}
|
|
6804
|
+
function enumerateItemArrayFields(itemArray) {
|
|
6805
|
+
return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
|
|
6806
|
+
...f,
|
|
6807
|
+
item: true
|
|
6808
|
+
}));
|
|
6809
|
+
}
|
|
6750
6810
|
var SourceInfoSchema = z4.object({
|
|
6751
6811
|
/** Live dispatch key — mutable when the source system allows rename. */
|
|
6752
6812
|
id: z4.string(),
|
|
@@ -6768,6 +6828,69 @@ var SourceInfoSchema = z4.object({
|
|
|
6768
6828
|
* attribute blobs (would explode the meta JSON column). */
|
|
6769
6829
|
raw: z4.record(z4.string(), z4.unknown()).optional()
|
|
6770
6830
|
});
|
|
6831
|
+
function normalizeUnit(raw) {
|
|
6832
|
+
if (raw == null) return void 0;
|
|
6833
|
+
const trimmed = raw.trim();
|
|
6834
|
+
if (trimmed === "") return void 0;
|
|
6835
|
+
const lower = trimmed.toLowerCase();
|
|
6836
|
+
if (lower === "\xB0c" || lower === "c" || lower === "celsius") return "\xB0C";
|
|
6837
|
+
if (lower === "\xB0f" || lower === "f" || lower === "fahrenheit") return "\xB0F";
|
|
6838
|
+
if (lower === "k" || lower === "kelvin") return "K";
|
|
6839
|
+
if (lower === "%" || lower === "percent" || lower === "percentage") return "%";
|
|
6840
|
+
if (lower === "lx" || lower === "lux") return "lx";
|
|
6841
|
+
if (lower === "hpa" || lower === "hectopascal") return "hPa";
|
|
6842
|
+
if (lower === "kpa") return "kPa";
|
|
6843
|
+
if (lower === "pa") return "Pa";
|
|
6844
|
+
if (lower === "mbar" || lower === "millibar") return "mbar";
|
|
6845
|
+
if (lower === "w" || lower === "watt") return "W";
|
|
6846
|
+
if (lower === "kw" || lower === "kilowatt") return "kW";
|
|
6847
|
+
if (lower === "wh" || lower === "watt-hour") return "Wh";
|
|
6848
|
+
if (lower === "kwh" || lower === "kilowatt-hour") return "kWh";
|
|
6849
|
+
if (lower === "v" || lower === "volt") return "V";
|
|
6850
|
+
if (lower === "a" || lower === "amp" || lower === "ampere") return "A";
|
|
6851
|
+
if (lower === "ma" || lower === "milliampere") return "mA";
|
|
6852
|
+
if (lower === "va") return "VA";
|
|
6853
|
+
if (lower === "ppm") return "ppm";
|
|
6854
|
+
if (lower === "ppb") return "ppb";
|
|
6855
|
+
if (lower === "\xB5g/m\xB3" || lower === "ug/m3" || lower === "\xB5g/m3") return "\xB5g/m\xB3";
|
|
6856
|
+
if (lower === "km/h" || lower === "kph") return "km/h";
|
|
6857
|
+
if (lower === "mph") return "mph";
|
|
6858
|
+
if (lower === "m/s") return "m/s";
|
|
6859
|
+
if (lower === "m") return "m";
|
|
6860
|
+
if (lower === "m\xB2" || lower === "m2") return "m\xB2";
|
|
6861
|
+
if (lower === "mm") return "mm";
|
|
6862
|
+
if (lower === "cm") return "cm";
|
|
6863
|
+
if (lower === "km") return "km";
|
|
6864
|
+
if (lower === "in" || lower === "inch" || lower === "inches") return "in";
|
|
6865
|
+
if (lower === "ft" || lower === "foot" || lower === "feet") return "ft";
|
|
6866
|
+
if (lower === "kg") return "kg";
|
|
6867
|
+
if (lower === "g") return "g";
|
|
6868
|
+
if (lower === "l" || lower === "liter" || lower === "litre" || lower === "liters" || lower === "litres") return "L";
|
|
6869
|
+
if (lower === "ml") return "mL";
|
|
6870
|
+
if (lower === "gal" || lower === "gallon" || lower === "gallons") return "gal";
|
|
6871
|
+
if (lower === "s" || lower === "sec" || lower === "second" || lower === "seconds") return "s";
|
|
6872
|
+
if (lower === "ms") return "ms";
|
|
6873
|
+
if (lower === "min" || lower === "minute" || lower === "minutes") return "min";
|
|
6874
|
+
if (lower === "h" || lower === "hr" || lower === "hour" || lower === "hours") return "h";
|
|
6875
|
+
if (lower === "d" || lower === "day" || lower === "days") return "d";
|
|
6876
|
+
if (lower === "b" || lower === "byte" || lower === "bytes") return "B";
|
|
6877
|
+
if (lower === "kib") return "KiB";
|
|
6878
|
+
if (lower === "mib") return "MiB";
|
|
6879
|
+
if (lower === "gib") return "GiB";
|
|
6880
|
+
if (lower === "tib") return "TiB";
|
|
6881
|
+
if (lower === "kb") return "kB";
|
|
6882
|
+
if (lower === "mb") return "MB";
|
|
6883
|
+
if (lower === "gb") return "GB";
|
|
6884
|
+
if (lower === "dbm") return "dBm";
|
|
6885
|
+
if (lower === "dbfs") return "dBFS";
|
|
6886
|
+
if (lower === "inhg") return "inHg";
|
|
6887
|
+
if (lower === "mm/h") return "mm/h";
|
|
6888
|
+
if (lower === "w/m\xB2" || lower === "w/m2") return "W/m\xB2";
|
|
6889
|
+
if (lower === "\xB0" || lower === "deg" || lower === "degree" || lower === "degrees") return "\xB0";
|
|
6890
|
+
if (lower === "kbps") return "kbps";
|
|
6891
|
+
if (lower === "fps") return "fps";
|
|
6892
|
+
return trimmed;
|
|
6893
|
+
}
|
|
6771
6894
|
var UNIT_TABLE = {
|
|
6772
6895
|
"\xB0C": {
|
|
6773
6896
|
dimension: "temperature",
|
|
@@ -7118,6 +7241,9 @@ var UNIT_TABLE = {
|
|
|
7118
7241
|
function specFor(unit) {
|
|
7119
7242
|
return Object.hasOwn(UNIT_TABLE, unit) ? UNIT_TABLE[unit] : void 0;
|
|
7120
7243
|
}
|
|
7244
|
+
function unitDimension(unit) {
|
|
7245
|
+
return specFor(unit)?.dimension;
|
|
7246
|
+
}
|
|
7121
7247
|
function toCanonical(value, spec) {
|
|
7122
7248
|
return value * spec.factor + spec.offset;
|
|
7123
7249
|
}
|
|
@@ -7133,6 +7259,11 @@ function tryConvertUnit(value, from, to) {
|
|
|
7133
7259
|
if (fromSpec.dimension !== toSpec.dimension) return void 0;
|
|
7134
7260
|
return fromCanonical(toCanonical(value, fromSpec), toSpec);
|
|
7135
7261
|
}
|
|
7262
|
+
function unitsForDimension(dimension) {
|
|
7263
|
+
const out = [];
|
|
7264
|
+
for (const [unit, spec] of Object.entries(UNIT_TABLE)) if (spec.dimension === dimension) out.push(unit);
|
|
7265
|
+
return out;
|
|
7266
|
+
}
|
|
7136
7267
|
var QuantityUnitError = class extends Error {
|
|
7137
7268
|
code;
|
|
7138
7269
|
constructor(code, message) {
|
|
@@ -7145,6 +7276,17 @@ var QuantityValueSchema = z4.object({
|
|
|
7145
7276
|
value: z4.number(),
|
|
7146
7277
|
unit: z4.string().min(1)
|
|
7147
7278
|
});
|
|
7279
|
+
function isQuantityValue(candidate) {
|
|
7280
|
+
return QuantityValueSchema.safeParse(candidate).success;
|
|
7281
|
+
}
|
|
7282
|
+
function convertQuantityInput(input) {
|
|
7283
|
+
if (!Number.isFinite(input.value)) throw new QuantityUnitError("non-finite", `${String(input.value)} is not a finite number`);
|
|
7284
|
+
const unit = normalizeUnit(input.unit);
|
|
7285
|
+
if (unit === void 0 || unitDimension(unit) === void 0) throw new QuantityUnitError("unknown-unit", `unknown unit "${input.unit}"`);
|
|
7286
|
+
const converted = tryConvertUnit(input.value, unit, input.target);
|
|
7287
|
+
if (converted === void 0) throw new QuantityUnitError("cross-dimension", `"${input.unit}" is not a unit of ${input.target}'s quantity`);
|
|
7288
|
+
return Number(converted.toPrecision(12));
|
|
7289
|
+
}
|
|
7148
7290
|
function paramMeta(declared) {
|
|
7149
7291
|
const base = baseUnitOf(declared.quantity);
|
|
7150
7292
|
if (base === void 0) throw new QuantityUnitError("unknown-unit", `quantity "${declared.quantity}" has no unit`);
|
|
@@ -7158,6 +7300,13 @@ var ParamMetaBagSchema = z4.object({
|
|
|
7158
7300
|
quantity: ReadingQuantitySchema,
|
|
7159
7301
|
unit: z4.string().min(1)
|
|
7160
7302
|
});
|
|
7303
|
+
function metaOf(schema) {
|
|
7304
|
+
const parsed = ParamMetaBagSchema.safeParse(z4.globalRegistry.get(schema));
|
|
7305
|
+
return parsed.success ? parsed.data : void 0;
|
|
7306
|
+
}
|
|
7307
|
+
function paramMetaOf(field) {
|
|
7308
|
+
return metaOf(field) ?? metaOf(unwrapSchema(field));
|
|
7309
|
+
}
|
|
7161
7310
|
var AutomationConditionOperatorSchema = z4.enum([
|
|
7162
7311
|
"eq",
|
|
7163
7312
|
"ne",
|
|
@@ -7353,6 +7502,770 @@ var AutomationRecipeSchema = z4.object({
|
|
|
7353
7502
|
/** How many run traces the automation keeps (default {@link DEFAULT_TRACE_KEEP}). */
|
|
7354
7503
|
keepTraces: z4.number().int().min(1).max(100).optional()
|
|
7355
7504
|
});
|
|
7505
|
+
function isLeaf(cond) {
|
|
7506
|
+
return cond.kind === "condition" || cond.kind === "expression" || cond.kind === "stationary-present";
|
|
7507
|
+
}
|
|
7508
|
+
function conditionDepth(cond) {
|
|
7509
|
+
if (isLeaf(cond)) return 1;
|
|
7510
|
+
if (cond.kind === "not") return 1 + conditionDepth(cond.child);
|
|
7511
|
+
const children = cond.children;
|
|
7512
|
+
if (children.length === 0) return 1;
|
|
7513
|
+
let deepest = 0;
|
|
7514
|
+
for (const child of children) {
|
|
7515
|
+
const d = conditionDepth(child);
|
|
7516
|
+
if (d > deepest) deepest = d;
|
|
7517
|
+
}
|
|
7518
|
+
return 1 + deepest;
|
|
7519
|
+
}
|
|
7520
|
+
function countConditionLeaves(cond) {
|
|
7521
|
+
if (isLeaf(cond)) return 1;
|
|
7522
|
+
if (cond.kind === "not") return countConditionLeaves(cond.child);
|
|
7523
|
+
let total = 0;
|
|
7524
|
+
for (const child of cond.children) total += countConditionLeaves(child);
|
|
7525
|
+
return total;
|
|
7526
|
+
}
|
|
7527
|
+
function findEmptyContainer(cond) {
|
|
7528
|
+
if (isLeaf(cond)) return false;
|
|
7529
|
+
if (cond.kind === "not") return findEmptyContainer(cond.child);
|
|
7530
|
+
if (cond.children.length === 0) return true;
|
|
7531
|
+
return cond.children.some(findEmptyContainer);
|
|
7532
|
+
}
|
|
7533
|
+
function validateRecipeBounds(recipe) {
|
|
7534
|
+
if (recipe.triggers.length === 0) return "an automation needs at least one trigger \u2014 without one nothing can start a run";
|
|
7535
|
+
if (recipe.actions.length === 0) return "an automation needs at least one action \u2014 a rule that does nothing is not a rule";
|
|
7536
|
+
const cond = recipe.conditions;
|
|
7537
|
+
if (cond !== void 0) {
|
|
7538
|
+
if (findEmptyContainer(cond)) return "a condition group is empty \u2014 group existing rows instead of leaving a container to fill";
|
|
7539
|
+
const depth = conditionDepth(cond);
|
|
7540
|
+
if (depth > 3) return `conditions nest ${depth} levels deep \u2014 the limit is 3. "not (any of \u2026)" is the same as "all of (not \u2026)", which is one level shallower`;
|
|
7541
|
+
const leaves = countConditionLeaves(cond);
|
|
7542
|
+
if (leaves > 32) return `conditions use ${leaves} leaves \u2014 the limit is 32`;
|
|
7543
|
+
}
|
|
7544
|
+
return null;
|
|
7545
|
+
}
|
|
7546
|
+
var RECONCILE_MS = 15e3;
|
|
7547
|
+
function stepMetaOf(action) {
|
|
7548
|
+
switch (action.kind) {
|
|
7549
|
+
case "wait":
|
|
7550
|
+
return { kind: "wait" };
|
|
7551
|
+
case "code":
|
|
7552
|
+
return { kind: "code" };
|
|
7553
|
+
case "action":
|
|
7554
|
+
return { kind: "action" };
|
|
7555
|
+
case "cap":
|
|
7556
|
+
return {
|
|
7557
|
+
kind: "cap",
|
|
7558
|
+
cap: action.cap,
|
|
7559
|
+
method: action.method,
|
|
7560
|
+
...typeof action.deviceId === "number" ? { deviceId: action.deviceId } : {}
|
|
7561
|
+
};
|
|
7562
|
+
}
|
|
7563
|
+
}
|
|
7564
|
+
function j(value) {
|
|
7565
|
+
return JSON.stringify(value);
|
|
7566
|
+
}
|
|
7567
|
+
function emitTrigger(trigger, index) {
|
|
7568
|
+
switch (trigger.kind) {
|
|
7569
|
+
case "schedule":
|
|
7570
|
+
return ` // trigger ${index}: schedule \u2014 armed to the next minute boundary and
|
|
7571
|
+
// RE-ARMED from the wall clock every tick, so it cannot drift the way a
|
|
7572
|
+
// setInterval(24h) does. No cron library: a block cannot resolve one.
|
|
7573
|
+
{
|
|
7574
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
7575
|
+
const arm = (): void => {
|
|
7576
|
+
timer = setTimeout(() => {
|
|
7577
|
+
if (cronMatches(${j(trigger.cron)}, new Date())) {
|
|
7578
|
+
void run({ kind: 'schedule', at: Date.now() })
|
|
7579
|
+
}
|
|
7580
|
+
arm()
|
|
7581
|
+
}, nextMinuteBoundary())
|
|
7582
|
+
}
|
|
7583
|
+
arm()
|
|
7584
|
+
disposers.push(() => { if (timer !== undefined) clearTimeout(timer) })
|
|
7585
|
+
}`;
|
|
7586
|
+
case "device-event":
|
|
7587
|
+
return ` // trigger ${index}: device event
|
|
7588
|
+
disposers.push(
|
|
7589
|
+
ctx.eventBus.subscribe(
|
|
7590
|
+
{ category: ${j(trigger.category)}${trigger.deviceId === void 0 ? "" : `, deviceId: ${j(trigger.deviceId)}`} },
|
|
7591
|
+
(event) => {
|
|
7592
|
+
void run({
|
|
7593
|
+
kind: 'device-event',
|
|
7594
|
+
at: Date.now(),
|
|
7595
|
+
deviceId: event.deviceId,
|
|
7596
|
+
category: ${j(trigger.category)},
|
|
7597
|
+
})
|
|
7598
|
+
},
|
|
7599
|
+
),
|
|
7600
|
+
)`;
|
|
7601
|
+
case "device-state": {
|
|
7602
|
+
const becomes = trigger.becomes === void 0 ? "undefined" : j(trigger.becomes);
|
|
7603
|
+
return ` // trigger ${index}: device state \u2014 the mirror is the authority, the
|
|
7604
|
+
// event only WAKES the evaluation, so a dropped event delays and never loses.
|
|
7605
|
+
{
|
|
7606
|
+
let previous: unknown = undefined
|
|
7607
|
+
let debounce: ReturnType<typeof setTimeout> | undefined
|
|
7608
|
+
let heldSince: number | undefined
|
|
7609
|
+
const check = async (): Promise<void> => {
|
|
7610
|
+
const value = await readField(${j(trigger.deviceId)}, ${j(trigger.cap)}, ${j(trigger.fieldPath)})
|
|
7611
|
+
const want = ${becomes}
|
|
7612
|
+
const matches = want === undefined ? value !== previous : value === want
|
|
7613
|
+
const changed = value !== previous
|
|
7614
|
+
previous = value
|
|
7615
|
+
if (!matches) { heldSince = undefined; return }
|
|
7616
|
+
if (${trigger.edge === true ? "true" : "false"} && !changed) return
|
|
7617
|
+
const forMs = ${j(trigger.forMs ?? 0)}
|
|
7618
|
+
if (forMs > 0) {
|
|
7619
|
+
if (heldSince === undefined) { heldSince = Date.now(); return }
|
|
7620
|
+
if (Date.now() - heldSince < forMs) return
|
|
7621
|
+
}
|
|
7622
|
+
heldSince = undefined
|
|
7623
|
+
void run({
|
|
7624
|
+
kind: 'device-state',
|
|
7625
|
+
at: Date.now(),
|
|
7626
|
+
deviceId: ${j(trigger.deviceId)},
|
|
7627
|
+
cap: ${j(trigger.cap)},
|
|
7628
|
+
field: ${j(trigger.fieldPath)},
|
|
7629
|
+
to: value,
|
|
7630
|
+
})
|
|
7631
|
+
}
|
|
7632
|
+
const wake = (): void => {
|
|
7633
|
+
if (debounce !== undefined) clearTimeout(debounce)
|
|
7634
|
+
debounce = setTimeout(() => { void check() }, ${j(trigger.debounceMs ?? 0)})
|
|
7635
|
+
}
|
|
7636
|
+
disposers.push(
|
|
7637
|
+
ctx.eventBus.subscribe({ category: 'device.state-changed', deviceId: ${j(trigger.deviceId)} }, wake),
|
|
7638
|
+
)
|
|
7639
|
+
const reconcile = setInterval(() => { void check() }, RECONCILE_MS)
|
|
7640
|
+
disposers.push(() => clearInterval(reconcile))
|
|
7641
|
+
}`;
|
|
7642
|
+
}
|
|
7643
|
+
case "manual":
|
|
7644
|
+
return ` // trigger ${index}: manual \u2014 fired by automation-control.trigger
|
|
7645
|
+
// ("Run actions"), which runs the THEN half with an EMPTY vars bag. A manual
|
|
7646
|
+
// run bypasses only the cooldown wait, never the single/queued limits.`;
|
|
7647
|
+
case "interval":
|
|
7648
|
+
return ` // trigger ${index}: interval \u2014 every ${trigger.minutes} min, first run jittered so
|
|
7649
|
+
// blocks restarted together do not all fire on the same second.
|
|
7650
|
+
{
|
|
7651
|
+
let interval: ReturnType<typeof setInterval> | undefined
|
|
7652
|
+
const first = setTimeout(() => {
|
|
7653
|
+
void run({ kind: 'interval', at: Date.now() })
|
|
7654
|
+
interval = setInterval(() => { void run({ kind: 'interval', at: Date.now() }) }, ${j(trigger.minutes * 6e4)})
|
|
7655
|
+
}, Math.floor(Math.random() * Math.min(${j(trigger.minutes * 6e4)}, 30_000)) + 1)
|
|
7656
|
+
disposers.push(() => {
|
|
7657
|
+
clearTimeout(first)
|
|
7658
|
+
if (interval !== undefined) clearInterval(interval)
|
|
7659
|
+
})
|
|
7660
|
+
}`;
|
|
7661
|
+
case "detection":
|
|
7662
|
+
return ` // trigger ${index}: detection \u2014 ONE fire per (device, track); a rejected event is
|
|
7663
|
+
// traced with its reason and never fires.
|
|
7664
|
+
{
|
|
7665
|
+
const filter = new DetectionTriggerFilter(${j(trigger)})
|
|
7666
|
+
disposers.push(
|
|
7667
|
+
ctx.eventBus.subscribe(
|
|
7668
|
+
{ category: EventCategory.PipelineAnalyticsTrackLifecycle },
|
|
7669
|
+
(event) => {
|
|
7670
|
+
const decision = filter.evaluate(event.data, Date.now())
|
|
7671
|
+
if (decision.kind === 'ignore') return
|
|
7672
|
+
if (decision.kind === 'reject') {
|
|
7673
|
+
ctx.logger.debug('detection trigger rejected an event', {
|
|
7674
|
+
tags: { deviceId: decision.vars.deviceId },
|
|
7675
|
+
reason: decision.reason,
|
|
7676
|
+
})
|
|
7677
|
+
}
|
|
7678
|
+
void run(
|
|
7679
|
+
{ kind: 'detection', at: Date.now(), ...decision.vars },
|
|
7680
|
+
decision.reason,
|
|
7681
|
+
decision.kind === 'fire',
|
|
7682
|
+
)
|
|
7683
|
+
},
|
|
7684
|
+
),
|
|
7685
|
+
)
|
|
7686
|
+
}`;
|
|
7687
|
+
case "stationary":
|
|
7688
|
+
return ` // trigger ${index}: stationary \u2014 a parked object appeared / departed. Stateless:
|
|
7689
|
+
// the registry emits one event per transition. A dropped event only misses a fire;
|
|
7690
|
+
// the stationary-present CONDITION reads the registry, not this event.
|
|
7691
|
+
{
|
|
7692
|
+
const trigger = ${j(trigger)} as const
|
|
7693
|
+
disposers.push(
|
|
7694
|
+
ctx.eventBus.subscribe(
|
|
7695
|
+
{ category: EventCategory.PipelineAnalyticsStationaryChanged },
|
|
7696
|
+
(event) => {
|
|
7697
|
+
const decision = evaluateStationaryTrigger(trigger, event.data)
|
|
7698
|
+
if (decision.kind === 'ignore') return
|
|
7699
|
+
if (decision.kind === 'reject') {
|
|
7700
|
+
ctx.logger.debug('stationary trigger rejected an event', {
|
|
7701
|
+
tags: { deviceId: decision.vars.deviceId },
|
|
7702
|
+
reason: decision.reason,
|
|
7703
|
+
})
|
|
7704
|
+
}
|
|
7705
|
+
void run(
|
|
7706
|
+
{ kind: 'stationary', at: Date.now(), ...decision.vars },
|
|
7707
|
+
decision.reason,
|
|
7708
|
+
decision.kind === 'fire',
|
|
7709
|
+
)
|
|
7710
|
+
},
|
|
7711
|
+
),
|
|
7712
|
+
)
|
|
7713
|
+
}`;
|
|
7714
|
+
}
|
|
7715
|
+
}
|
|
7716
|
+
function emitAction(action, index) {
|
|
7717
|
+
const step = (body) => ` {
|
|
7718
|
+
meta: STEP_META[${index}]!,
|
|
7719
|
+
run: async (scope) => {
|
|
7720
|
+
${body}
|
|
7721
|
+
},
|
|
7722
|
+
},`;
|
|
7723
|
+
switch (action.kind) {
|
|
7724
|
+
case "wait":
|
|
7725
|
+
return step(` await sleep(${j(action.seconds * 1e3)})`);
|
|
7726
|
+
case "cap":
|
|
7727
|
+
if (Array.isArray(action.deviceId)) return step(` for (const deviceId of ${j(action.deviceId)}) {
|
|
7728
|
+
scope.setDeviceId(deviceId)
|
|
7729
|
+
await actuate(deviceId, ${j(action.cap)}, ${j(action.method)}, ${j(action.args ?? {})}, vars)
|
|
7730
|
+
}`);
|
|
7731
|
+
return step(` const deviceId = ${typeof action.deviceId === "number" ? j(action.deviceId) : `resolveTarget(vars, ${j(action.deviceId.$var)})`}
|
|
7732
|
+
scope.setDeviceId(deviceId)
|
|
7733
|
+
await actuate(deviceId, ${j(action.cap)}, ${j(action.method)}, ${j(action.args ?? {})}, vars)`);
|
|
7734
|
+
case "action":
|
|
7735
|
+
return step(` // an Action run, awaited: the step takes THAT run's outcome
|
|
7736
|
+
const result = await ctx.api.actions.run.mutate({
|
|
7737
|
+
actionId: ${j(action.actionId)},
|
|
7738
|
+
variables: substituteVariables(${j(action.variables ?? {})}, vars),
|
|
7739
|
+
wait: true,
|
|
7740
|
+
origin: { blockId: BLOCK_ID ?? undefined },
|
|
7741
|
+
})
|
|
7742
|
+
if (result.outcome !== 'ran') {
|
|
7743
|
+
throw new Error('action ' + ${j(action.actionId)} + ' ' + result.outcome + (result.message === undefined ? '' : ': ' + result.message))
|
|
7744
|
+
}`);
|
|
7745
|
+
case "code":
|
|
7746
|
+
return step(` // inline code action \u2014 a fragment of THIS block, bundled by esbuild
|
|
7747
|
+
await (async () => {
|
|
7748
|
+
${action.code.split("\n").map((line) => ` ${line}`).join("\n")}
|
|
7749
|
+
})()`);
|
|
7750
|
+
}
|
|
7751
|
+
}
|
|
7752
|
+
function generateAutomationBlock(input) {
|
|
7753
|
+
const boundsError = validateRecipeBounds(input.recipe);
|
|
7754
|
+
if (boundsError !== null) throw new Error(`cannot generate an automation from an invalid recipe: ${boundsError}`);
|
|
7755
|
+
const { name, recipe } = input;
|
|
7756
|
+
const runMode = recipe.runMode ?? { mode: "single" };
|
|
7757
|
+
const stepMeta = recipe.actions.map(stepMetaOf);
|
|
7758
|
+
return `import type {
|
|
7759
|
+
AddonContext,
|
|
7760
|
+
AutomationConditionTraceNode,
|
|
7761
|
+
AutomationRunTrace,
|
|
7762
|
+
AutomationRunVerdict,
|
|
7763
|
+
AutomationStepTrace,
|
|
7764
|
+
RunnableStep,
|
|
7765
|
+
} from '@camstack/types'
|
|
7766
|
+
import {
|
|
7767
|
+
ConditionMirror,
|
|
7768
|
+
DetectionTriggerFilter,
|
|
7769
|
+
EventCategory,
|
|
7770
|
+
RunGate,
|
|
7771
|
+
buildTriggerTrace,
|
|
7772
|
+
collectMirrorSources,
|
|
7773
|
+
coreBlockIdFromAddonId,
|
|
7774
|
+
evaluateConditionTree,
|
|
7775
|
+
evaluateStationaryTrigger,
|
|
7776
|
+
isSkippedTrace,
|
|
7777
|
+
mergeSkipTraces,
|
|
7778
|
+
resolveDeviceTarget,
|
|
7779
|
+
resolveStepArgs,
|
|
7780
|
+
runSteps,
|
|
7781
|
+
} from '@camstack/types'
|
|
7782
|
+
|
|
7783
|
+
/**
|
|
7784
|
+
* ${name}
|
|
7785
|
+
*
|
|
7786
|
+
* GENERATED FROM A RECIPE \u2014 do not edit by hand. Editing this file makes it a
|
|
7787
|
+
* script: the visual editor reads AUTOMATION_RECIPE below, and hand edits are
|
|
7788
|
+
* lost the next time the recipe is saved. Use "Eject to code" to take ownership
|
|
7789
|
+
* of it permanently (one way).
|
|
7790
|
+
*/
|
|
7791
|
+
|
|
7792
|
+
const AUTOMATION_RECIPE = ${JSON.stringify(recipe, null, 2)} as const
|
|
7793
|
+
|
|
7794
|
+
const RECONCILE_MS = ${RECONCILE_MS}
|
|
7795
|
+
|
|
7796
|
+
/** How triggers that arrive while busy (or cooling down) are handled. */
|
|
7797
|
+
const RUN_MODE = ${j(runMode)} as const
|
|
7798
|
+
|
|
7799
|
+
/** Run traces kept (a ring, held in the hub's runs ledger). */
|
|
7800
|
+
const KEEP_TRACES = ${recipe.keepTraces ?? 20}
|
|
7801
|
+
|
|
7802
|
+
const CONDITIONS = ${recipe.conditions === void 0 ? "undefined" : "AUTOMATION_RECIPE.conditions"}
|
|
7803
|
+
|
|
7804
|
+
/** Skips that repeat are folded into one trace; it goes to the hub after this. */
|
|
7805
|
+
const SKIP_FLUSH_MS = 5_000
|
|
7806
|
+
|
|
7807
|
+
/** Static facts about each action step, for its trace. */
|
|
7808
|
+
const STEP_META: ReadonlyArray<{ kind: string; cap?: string; method?: string; deviceId?: number }> = ${j(stepMeta)}
|
|
7809
|
+
|
|
7810
|
+
/** A cap is named \`alarm-panel\`; \`ctx.api\` is keyed by the ROUTER name,
|
|
7811
|
+
* \`alarmPanel\`. Indexing ctx.api with the cap name yields undefined and every
|
|
7812
|
+
* action throws, so the conversion is not cosmetic. */
|
|
7813
|
+
function capToRouterKey(cap: string): string {
|
|
7814
|
+
return cap.replace(/-([a-z0-9])/g, (_m, c: string) => c.toUpperCase())
|
|
7815
|
+
}
|
|
7816
|
+
|
|
7817
|
+
/** Ms until the top of the next minute. Recomputed every tick so a long-lived
|
|
7818
|
+
* schedule cannot accumulate drift. */
|
|
7819
|
+
function nextMinuteBoundary(): number {
|
|
7820
|
+
const now = Date.now()
|
|
7821
|
+
return 60_000 - (now % 60_000)
|
|
7822
|
+
}
|
|
7823
|
+
|
|
7824
|
+
/** Does \`when\` satisfy a 5-field cron expression (min hour dom mon dow)?
|
|
7825
|
+
* Supports wildcards, step syntax, ranges, comma lists and plain numbers \u2014
|
|
7826
|
+
* the subset an operator can author, and nothing that needs a library. */
|
|
7827
|
+
function cronMatches(expr: string, when: Date): boolean {
|
|
7828
|
+
const fields = expr.trim().split(/\\s+/)
|
|
7829
|
+
if (fields.length !== 5) return false
|
|
7830
|
+
const actual = [
|
|
7831
|
+
when.getMinutes(),
|
|
7832
|
+
when.getHours(),
|
|
7833
|
+
when.getDate(),
|
|
7834
|
+
when.getMonth() + 1,
|
|
7835
|
+
when.getDay(),
|
|
7836
|
+
]
|
|
7837
|
+
return fields.every((field, i) => matchField(field, actual[i]!))
|
|
7838
|
+
}
|
|
7839
|
+
|
|
7840
|
+
function matchField(field: string, value: number): boolean {
|
|
7841
|
+
for (const part of field.split(',')) {
|
|
7842
|
+
const [range, stepRaw] = part.split('/')
|
|
7843
|
+
const step = stepRaw === undefined ? 1 : Number.parseInt(stepRaw, 10)
|
|
7844
|
+
if (!Number.isFinite(step) || step < 1) continue
|
|
7845
|
+
if (range === '*') {
|
|
7846
|
+
if (value % step === 0) return true
|
|
7847
|
+
continue
|
|
7848
|
+
}
|
|
7849
|
+
const bounds = range!.split('-')
|
|
7850
|
+
const lo = Number.parseInt(bounds[0]!, 10)
|
|
7851
|
+
const hi = bounds[1] === undefined ? lo : Number.parseInt(bounds[1], 10)
|
|
7852
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue
|
|
7853
|
+
if (value >= lo && value <= hi && (value - lo) % step === 0) return true
|
|
7854
|
+
}
|
|
7855
|
+
return false
|
|
7856
|
+
}
|
|
7857
|
+
|
|
7858
|
+
export default function block(ctx: AddonContext) {
|
|
7859
|
+
const disposers: Array<() => void> = []
|
|
7860
|
+
const gate = new RunGate(RUN_MODE)
|
|
7861
|
+
const mirror = new ConditionMirror(collectMirrorSources(CONDITIONS))
|
|
7862
|
+
const BLOCK_ID = coreBlockIdFromAddonId(ctx.id)
|
|
7863
|
+
/** The cooldown stamp: memory is the authority at fire time (D49); the hub ledger is read once at start. */
|
|
7864
|
+
let lastRunStartedAt: number | null = null
|
|
7865
|
+
/** True when the stamp could not be read at start \u2014 the first run is let through and says so. */
|
|
7866
|
+
let stampUnknown = false
|
|
7867
|
+
let sendChain: Promise<void> = Promise.resolve()
|
|
7868
|
+
let pendingSkip: AutomationRunTrace | undefined
|
|
7869
|
+
let skipTimer: ReturnType<typeof setTimeout> | undefined
|
|
7870
|
+
let stopped = false
|
|
7871
|
+
let runCounter = 0
|
|
7872
|
+
const runningTokens = new Set<RunToken>()
|
|
7873
|
+
const queue: QueuedRun[] = []
|
|
7874
|
+
|
|
7875
|
+
interface RunToken {
|
|
7876
|
+
aborted: boolean
|
|
7877
|
+
wake?: () => void
|
|
7878
|
+
}
|
|
7879
|
+
interface QueuedRun {
|
|
7880
|
+
readonly trigger: Record<string, unknown>
|
|
7881
|
+
readonly reason: string
|
|
7882
|
+
readonly queuedAt: number
|
|
7883
|
+
}
|
|
7884
|
+
|
|
7885
|
+
/** Sleep that a restart can cut short. */
|
|
7886
|
+
const abortableSleep = (ms: number, token: RunToken): Promise<void> =>
|
|
7887
|
+
new Promise((resolve) => {
|
|
7888
|
+
const timer = setTimeout(resolve, ms)
|
|
7889
|
+
token.wake = () => {
|
|
7890
|
+
clearTimeout(timer)
|
|
7891
|
+
resolve()
|
|
7892
|
+
}
|
|
7893
|
+
})
|
|
7894
|
+
|
|
7895
|
+
// \u2500\u2500 the hub's runs ledger: the stamp is read ONCE here, the ring is written through RPC \u2500\u2500
|
|
7896
|
+
async function loadState(): Promise<void> {
|
|
7897
|
+
if (BLOCK_ID === null) {
|
|
7898
|
+
ctx.logger.warn('this is not a core-block addon id \u2014 no run ledger, cooldown and traces will not survive a restart', { addonId: ctx.id })
|
|
7899
|
+
return
|
|
7900
|
+
}
|
|
7901
|
+
try {
|
|
7902
|
+
const doc = await ctx.api.coreBlocks.getAutomationRunState.query({ blockId: BLOCK_ID })
|
|
7903
|
+
lastRunStartedAt = doc.lastRunStartedAt
|
|
7904
|
+
} catch (err: unknown) {
|
|
7905
|
+
stampUnknown = true
|
|
7906
|
+
ctx.logger.warn('could not read the stored cooldown stamp \u2014 the first run is let through', {
|
|
7907
|
+
blockId: BLOCK_ID,
|
|
7908
|
+
error: err instanceof Error ? err.message : String(err),
|
|
7909
|
+
})
|
|
7910
|
+
}
|
|
7911
|
+
}
|
|
7912
|
+
|
|
7913
|
+
function send(payload: { trace?: AutomationRunTrace; runStartedAt?: number }): Promise<void> {
|
|
7914
|
+
if (BLOCK_ID === null) return Promise.resolve()
|
|
7915
|
+
const blockId = BLOCK_ID
|
|
7916
|
+
sendChain = sendChain.then(async () => {
|
|
7917
|
+
try {
|
|
7918
|
+
await ctx.api.coreBlocks.reportAutomationRun.mutate({ blockId, keepTraces: KEEP_TRACES, ...payload })
|
|
7919
|
+
} catch (err: unknown) {
|
|
7920
|
+
ctx.logger.warn('could not report the run to the hub ledger', {
|
|
7921
|
+
blockId,
|
|
7922
|
+
error: err instanceof Error ? err.message : String(err),
|
|
7923
|
+
})
|
|
7924
|
+
}
|
|
7925
|
+
})
|
|
7926
|
+
return sendChain
|
|
7927
|
+
}
|
|
7928
|
+
|
|
7929
|
+
function flushSkip(): void {
|
|
7930
|
+
if (skipTimer !== undefined) {
|
|
7931
|
+
clearTimeout(skipTimer)
|
|
7932
|
+
skipTimer = undefined
|
|
7933
|
+
}
|
|
7934
|
+
if (pendingSkip === undefined) return
|
|
7935
|
+
const trace = pendingSkip
|
|
7936
|
+
pendingSkip = undefined
|
|
7937
|
+
void send({ trace })
|
|
7938
|
+
}
|
|
7939
|
+
|
|
7940
|
+
/** A skip identical to the one waiting is COUNTED on it; anything else flushes it first. */
|
|
7941
|
+
function record(trace: AutomationRunTrace): void {
|
|
7942
|
+
if (isSkippedTrace(trace)) {
|
|
7943
|
+
if (pendingSkip !== undefined) {
|
|
7944
|
+
const merged = mergeSkipTraces(pendingSkip, trace)
|
|
7945
|
+
if (merged !== null) {
|
|
7946
|
+
pendingSkip = merged
|
|
7947
|
+
return
|
|
7948
|
+
}
|
|
7949
|
+
flushSkip()
|
|
7950
|
+
}
|
|
7951
|
+
pendingSkip = trace
|
|
7952
|
+
skipTimer = setTimeout(flushSkip, SKIP_FLUSH_MS)
|
|
7953
|
+
return
|
|
7954
|
+
}
|
|
7955
|
+
flushSkip()
|
|
7956
|
+
void send({ trace })
|
|
7957
|
+
}
|
|
7958
|
+
|
|
7959
|
+
// \u2500\u2500 the condition mirror (D49): memory only at fire time \u2500\u2500
|
|
7960
|
+
async function refreshMirror(): Promise<void> {
|
|
7961
|
+
const report = await mirror.refresh({
|
|
7962
|
+
fetchSlice: (deviceId, cap) => ctx.api.deviceState.getCapSlice.query({ deviceId, capName: cap }),
|
|
7963
|
+
fetchStableIds: async () => {
|
|
7964
|
+
const all = await ctx.api.deviceManager.listAll.query({})
|
|
7965
|
+
return new Map(all.devices.flatMap((d: { stableId?: string; id: number }) =>
|
|
7966
|
+
d.stableId === undefined ? [] : [[d.stableId, d.id] as [string, number]]))
|
|
7967
|
+
},
|
|
7968
|
+
})
|
|
7969
|
+
if (report.kept > 0) {
|
|
7970
|
+
ctx.logger.debug('condition mirror kept stale values for sources it could not read', report)
|
|
7971
|
+
}
|
|
7972
|
+
}
|
|
7973
|
+
|
|
7974
|
+
/** Read one cap field off the state mirror. Returns undefined when the device,
|
|
7975
|
+
* the cap or the field cannot be resolved \u2014 the caller decides what that
|
|
7976
|
+
* means, and it always logs. (Used by the device-state TRIGGER, which the
|
|
7977
|
+
* reconcile backs; conditions read the ConditionMirror instead.) */
|
|
7978
|
+
async function readField(
|
|
7979
|
+
deviceId: number,
|
|
7980
|
+
cap: string,
|
|
7981
|
+
fieldPath: string,
|
|
7982
|
+
): Promise<unknown> {
|
|
7983
|
+
try {
|
|
7984
|
+
const slice = await ctx.api.deviceState.getCapSlice.query({ deviceId, capName: cap })
|
|
7985
|
+
let cursor: unknown = slice
|
|
7986
|
+
for (const part of fieldPath.split('.')) {
|
|
7987
|
+
if (cursor === null || typeof cursor !== 'object') return undefined
|
|
7988
|
+
cursor = (cursor as Record<string, unknown>)[part]
|
|
7989
|
+
}
|
|
7990
|
+
return cursor
|
|
7991
|
+
} catch {
|
|
7992
|
+
return undefined
|
|
7993
|
+
}
|
|
7994
|
+
}
|
|
7995
|
+
|
|
7996
|
+
/** A {$var} action target, resolved from the run's bag BEFORE dispatch \u2014 the
|
|
7997
|
+
* action runner only ever sees a number. */
|
|
7998
|
+
function resolveTarget(vars: Record<string, unknown>, name: string): number {
|
|
7999
|
+
return resolveDeviceTarget(vars, name)
|
|
8000
|
+
}
|
|
8001
|
+
|
|
8002
|
+
function warnMissingVar(name: string): void {
|
|
8003
|
+
ctx.logger.warn('template referenced a var that was never set', { var: name })
|
|
8004
|
+
}
|
|
8005
|
+
|
|
8006
|
+
/** Substitute {{vars.x}} slots and { $var } values. Substitution only \u2014 never evaluation. */
|
|
8007
|
+
function substitute(value: unknown, vars: Record<string, unknown>): unknown {
|
|
8008
|
+
return resolveStepArgs(value, vars, warnMissingVar)
|
|
8009
|
+
}
|
|
8010
|
+
|
|
8011
|
+
/** The variables handed to an Action step, substituted and kept to primitives. */
|
|
8012
|
+
function substituteVariables(
|
|
8013
|
+
raw: Record<string, string | number | boolean>,
|
|
8014
|
+
vars: Record<string, unknown>,
|
|
8015
|
+
): Record<string, string | number | boolean> {
|
|
8016
|
+
const out: Record<string, string | number | boolean> = {}
|
|
8017
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
8018
|
+
const resolved = substitute(value, vars)
|
|
8019
|
+
if (typeof resolved === 'string' || typeof resolved === 'number' || typeof resolved === 'boolean') {
|
|
8020
|
+
out[key] = resolved
|
|
8021
|
+
}
|
|
8022
|
+
}
|
|
8023
|
+
return out
|
|
8024
|
+
}
|
|
8025
|
+
|
|
8026
|
+
async function actuate(
|
|
8027
|
+
deviceId: number,
|
|
8028
|
+
cap: string,
|
|
8029
|
+
method: string,
|
|
8030
|
+
args: Record<string, unknown>,
|
|
8031
|
+
vars: Record<string, unknown>,
|
|
8032
|
+
): Promise<void> {
|
|
8033
|
+
const substituted = substitute(args, vars)
|
|
8034
|
+
const resolved: Record<string, unknown> =
|
|
8035
|
+
substituted !== null && typeof substituted === 'object' ? { ...substituted } : {}
|
|
8036
|
+
// Forked-addon ctx.api namespaces are tRPC Proxies over a noop function
|
|
8037
|
+
// (typeof === 'function'), not plain objects \u2014 reject only null/undefined.
|
|
8038
|
+
const client: unknown = Reflect.get(ctx.api, capToRouterKey(cap))
|
|
8039
|
+
const proc: unknown =
|
|
8040
|
+
client !== null && client !== undefined ? Reflect.get(client as object, method) : undefined
|
|
8041
|
+
const mutate: unknown =
|
|
8042
|
+
proc !== null && proc !== undefined ? Reflect.get(proc as object, 'mutate') : undefined
|
|
8043
|
+
if (typeof mutate !== 'function') {
|
|
8044
|
+
throw new Error('no such device method: ' + cap + '.' + method)
|
|
8045
|
+
}
|
|
8046
|
+
await mutate({ ...resolved, deviceId })
|
|
8047
|
+
}
|
|
8048
|
+
|
|
8049
|
+
/** The THEN half, through the ONE shared step runner: stops at the first
|
|
8050
|
+
* failure (continuing past a failed step executes an intent the operator
|
|
8051
|
+
* never expressed); every step is traced, the ones after a failure (or an
|
|
8052
|
+
* abort) are marked not-run. */
|
|
8053
|
+
async function runActions(
|
|
8054
|
+
vars: Record<string, unknown>,
|
|
8055
|
+
token: RunToken,
|
|
8056
|
+
): Promise<{ steps: readonly AutomationStepTrace[]; failed: boolean }> {
|
|
8057
|
+
const sleep = (ms: number): Promise<void> => abortableSleep(ms, token)
|
|
8058
|
+
const steps: RunnableStep[] = [
|
|
8059
|
+
${recipe.actions.map((a, i) => emitAction(a, i)).join("\n")}
|
|
8060
|
+
]
|
|
8061
|
+
return runSteps(steps, {
|
|
8062
|
+
pathPrefix: 'actions',
|
|
8063
|
+
isAborted: () => token.aborted,
|
|
8064
|
+
abortReason: 'aborted by a restart',
|
|
8065
|
+
now: () => Date.now(),
|
|
8066
|
+
onFailure: (failure) => {
|
|
8067
|
+
ctx.logger.warn('action failed \u2014 stopping the sequence', {
|
|
8068
|
+
...(failure.deviceId === undefined ? {} : { tags: { deviceId: failure.deviceId } }),
|
|
8069
|
+
step: failure.index,
|
|
8070
|
+
error: failure.message,
|
|
8071
|
+
})
|
|
8072
|
+
},
|
|
8073
|
+
})
|
|
8074
|
+
}
|
|
8075
|
+
|
|
8076
|
+
function deviceTags(trigger: Record<string, unknown>): { tags?: { deviceId: number } } {
|
|
8077
|
+
const deviceId = trigger['deviceId']
|
|
8078
|
+
return typeof deviceId === 'number' ? { tags: { deviceId } } : {}
|
|
8079
|
+
}
|
|
8080
|
+
|
|
8081
|
+
function newRunId(at: number): string {
|
|
8082
|
+
runCounter += 1
|
|
8083
|
+
return at.toString(36) + '-' + runCounter.toString(36)
|
|
8084
|
+
}
|
|
8085
|
+
|
|
8086
|
+
/** A trigger that never became a run (rejected, or dropped by the run mode). */
|
|
8087
|
+
function skipTrace(
|
|
8088
|
+
trigger: Record<string, unknown>,
|
|
8089
|
+
reason: string,
|
|
8090
|
+
outcome: AutomationRunTrace['outcome'],
|
|
8091
|
+
verdict: AutomationRunVerdict | undefined,
|
|
8092
|
+
accepted: boolean,
|
|
8093
|
+
): AutomationRunTrace {
|
|
8094
|
+
const now = Date.now()
|
|
8095
|
+
return {
|
|
8096
|
+
runId: newRunId(now),
|
|
8097
|
+
startedAt: now,
|
|
8098
|
+
endedAt: now,
|
|
8099
|
+
trigger: buildTriggerTrace(trigger, accepted ? 'pass' : 'fail', reason),
|
|
8100
|
+
conditions: [],
|
|
8101
|
+
...(verdict === undefined ? {} : { verdict }),
|
|
8102
|
+
steps: [],
|
|
8103
|
+
outcome,
|
|
8104
|
+
repeat: 1,
|
|
8105
|
+
lastAt: now,
|
|
8106
|
+
}
|
|
8107
|
+
}
|
|
8108
|
+
|
|
8109
|
+
/** One admitted run: conditions (memory only), the cooldown, the actions. */
|
|
8110
|
+
async function execute(
|
|
8111
|
+
trigger: Record<string, unknown>,
|
|
8112
|
+
reason: string,
|
|
8113
|
+
admission: AutomationRunVerdict,
|
|
8114
|
+
): Promise<void> {
|
|
8115
|
+
const token: RunToken = { aborted: false }
|
|
8116
|
+
runningTokens.add(token)
|
|
8117
|
+
const startedAt = Date.now()
|
|
8118
|
+
const runId = newRunId(startedAt)
|
|
8119
|
+
let verdict = admission
|
|
8120
|
+
let outcome: AutomationRunTrace['outcome'] = 'ran'
|
|
8121
|
+
let nodes: AutomationConditionTraceNode[] = []
|
|
8122
|
+
let steps: AutomationStepTrace[] = []
|
|
8123
|
+
try {
|
|
8124
|
+
const evaluation = evaluateConditionTree(CONDITIONS, mirror, Date.now())
|
|
8125
|
+
nodes = [...evaluation.nodes]
|
|
8126
|
+
for (const node of nodes) {
|
|
8127
|
+
if (node.kind === 'condition' && node.observed === null && node.deviceId !== undefined) {
|
|
8128
|
+
ctx.logger.warn('condition leaf value unknown \u2014 treated as false', {
|
|
8129
|
+
tags: { deviceId: node.deviceId },
|
|
8130
|
+
path: node.path,
|
|
8131
|
+
cap: node.cap,
|
|
8132
|
+
fieldPath: node.fieldPath,
|
|
8133
|
+
})
|
|
8134
|
+
}
|
|
8135
|
+
}
|
|
8136
|
+
if (!evaluation.pass) {
|
|
8137
|
+
outcome = 'skipped-conditions'
|
|
8138
|
+
ctx.logger.debug('automation did not run \u2014 conditions were false', { trigger, ...deviceTags(trigger) })
|
|
8139
|
+
return
|
|
8140
|
+
}
|
|
8141
|
+
const letThrough = stampUnknown && RUN_MODE.mode === 'cooldown'
|
|
8142
|
+
const cooldown = letThrough
|
|
8143
|
+
? { ok: true, bypassed: true, reason: 'cooldown stamp unreadable at start \u2014 run let through' }
|
|
8144
|
+
: gate.cooldown({
|
|
8145
|
+
manual: trigger['kind'] === 'manual',
|
|
8146
|
+
now: Date.now(),
|
|
8147
|
+
lastRunStartedAt,
|
|
8148
|
+
})
|
|
8149
|
+
if (!cooldown.ok) {
|
|
8150
|
+
outcome = 'skipped-cooldown'
|
|
8151
|
+
verdict = { mode: RUN_MODE, decision: 'cooldown', reason: cooldown.reason }
|
|
8152
|
+
ctx.logger.info('automation did not run \u2014 ' + cooldown.reason, deviceTags(trigger))
|
|
8153
|
+
return
|
|
8154
|
+
}
|
|
8155
|
+
if (cooldown.bypassed) verdict = { mode: RUN_MODE, decision: 'bypass', reason: cooldown.reason }
|
|
8156
|
+
// Stamped when the actions START, and reported to the hub before they run,
|
|
8157
|
+
// so a restart mid-run cannot fire again inside the window.
|
|
8158
|
+
lastRunStartedAt = startedAt
|
|
8159
|
+
stampUnknown = false
|
|
8160
|
+
await send({ runStartedAt: startedAt })
|
|
8161
|
+
ctx.logger.info('automation running', { trigger, ...deviceTags(trigger) })
|
|
8162
|
+
const vars: Record<string, unknown> = { ...trigger }
|
|
8163
|
+
const result = await runActions(vars, token)
|
|
8164
|
+
steps = result.steps
|
|
8165
|
+
outcome = token.aborted ? 'aborted' : result.failed ? 'failed' : 'ran'
|
|
8166
|
+
} catch (err: unknown) {
|
|
8167
|
+
outcome = 'failed'
|
|
8168
|
+
ctx.logger.warn('automation run failed', { error: err instanceof Error ? err.message : String(err), ...deviceTags(trigger) })
|
|
8169
|
+
} finally {
|
|
8170
|
+
runningTokens.delete(token)
|
|
8171
|
+
const endedAt = Date.now()
|
|
8172
|
+
record({
|
|
8173
|
+
runId,
|
|
8174
|
+
startedAt,
|
|
8175
|
+
endedAt,
|
|
8176
|
+
trigger: buildTriggerTrace(trigger, 'pass', reason),
|
|
8177
|
+
conditions: nodes,
|
|
8178
|
+
verdict,
|
|
8179
|
+
steps,
|
|
8180
|
+
outcome,
|
|
8181
|
+
repeat: 1,
|
|
8182
|
+
lastAt: endedAt,
|
|
8183
|
+
})
|
|
8184
|
+
if (gate.release()) {
|
|
8185
|
+
const next = queue.shift()
|
|
8186
|
+
if (next !== undefined && !stopped) {
|
|
8187
|
+
void execute(next.trigger, next.reason, {
|
|
8188
|
+
mode: RUN_MODE,
|
|
8189
|
+
decision: 'queued',
|
|
8190
|
+
reason: 'queued, then ran after waiting ' + (Date.now() - next.queuedAt) + ' ms',
|
|
8191
|
+
})
|
|
8192
|
+
}
|
|
8193
|
+
}
|
|
8194
|
+
}
|
|
8195
|
+
}
|
|
8196
|
+
|
|
8197
|
+
/** Every trigger lands here. \`accepted\` = the trigger itself was satisfied
|
|
8198
|
+
* (a rejected detection event is only traced). */
|
|
8199
|
+
async function run(
|
|
8200
|
+
trigger: Record<string, unknown>,
|
|
8201
|
+
reason = 'fired',
|
|
8202
|
+
accepted = true,
|
|
8203
|
+
): Promise<void> {
|
|
8204
|
+
await ready
|
|
8205
|
+
if (stopped) return
|
|
8206
|
+
if (!accepted) {
|
|
8207
|
+
record(skipTrace(trigger, reason, 'skipped-trigger', undefined, false))
|
|
8208
|
+
return
|
|
8209
|
+
}
|
|
8210
|
+
const admission = gate.admit()
|
|
8211
|
+
const verdict: AutomationRunVerdict = { mode: RUN_MODE, decision: admission.decision, reason: admission.reason }
|
|
8212
|
+
switch (admission.decision) {
|
|
8213
|
+
case 'drop':
|
|
8214
|
+
ctx.logger.info('automation trigger dropped \u2014 ' + admission.reason, deviceTags(trigger))
|
|
8215
|
+
record(skipTrace(trigger, reason, 'skipped-overlap', verdict, true))
|
|
8216
|
+
return
|
|
8217
|
+
case 'queued':
|
|
8218
|
+
ctx.logger.info('automation trigger queued \u2014 ' + admission.reason, deviceTags(trigger))
|
|
8219
|
+
queue.push({ trigger, reason, queuedAt: Date.now() })
|
|
8220
|
+
return
|
|
8221
|
+
case 'restart':
|
|
8222
|
+
ctx.logger.info('automation restarting \u2014 ' + admission.reason, deviceTags(trigger))
|
|
8223
|
+
for (const running of runningTokens) {
|
|
8224
|
+
running.aborted = true
|
|
8225
|
+
running.wake?.()
|
|
8226
|
+
}
|
|
8227
|
+
break
|
|
8228
|
+
case 'run':
|
|
8229
|
+
break
|
|
8230
|
+
}
|
|
8231
|
+
await execute(trigger, reason, verdict)
|
|
8232
|
+
}
|
|
8233
|
+
|
|
8234
|
+
const ready: Promise<void> = (async () => {
|
|
8235
|
+
await loadState()
|
|
8236
|
+
await refreshMirror()
|
|
8237
|
+
})()
|
|
8238
|
+
|
|
8239
|
+
if (CONDITIONS !== undefined) {
|
|
8240
|
+
disposers.push(
|
|
8241
|
+
ctx.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
8242
|
+
const data = event.data
|
|
8243
|
+
const deviceId = data['deviceId']
|
|
8244
|
+
const capName = data['capName']
|
|
8245
|
+
if (typeof deviceId === 'number' && typeof capName === 'string') {
|
|
8246
|
+
mirror.apply(deviceId, capName, data['slice'])
|
|
8247
|
+
}
|
|
8248
|
+
}),
|
|
8249
|
+
)
|
|
8250
|
+
const mirrorReconcile = setInterval(() => { void refreshMirror() }, RECONCILE_MS)
|
|
8251
|
+
disposers.push(() => clearInterval(mirrorReconcile))
|
|
8252
|
+
}
|
|
8253
|
+
|
|
8254
|
+
${recipe.triggers.map((t2, i) => emitTrigger(t2, i)).join("\n\n")}
|
|
8255
|
+
|
|
8256
|
+
return () => {
|
|
8257
|
+
stopped = true
|
|
8258
|
+
queue.length = 0
|
|
8259
|
+
flushSkip()
|
|
8260
|
+
for (const running of runningTokens) {
|
|
8261
|
+
running.aborted = true
|
|
8262
|
+
running.wake?.()
|
|
8263
|
+
}
|
|
8264
|
+
for (const dispose of disposers) dispose()
|
|
8265
|
+
}
|
|
8266
|
+
}
|
|
8267
|
+
`;
|
|
8268
|
+
}
|
|
7356
8269
|
var MACRO_LABELS = [
|
|
7357
8270
|
{
|
|
7358
8271
|
id: "person",
|
|
@@ -7628,6 +8541,16 @@ var ActionRunChangedSchema = z4.object({
|
|
|
7628
8541
|
lastRunAt: z4.number().nullable(),
|
|
7629
8542
|
lastError: z4.string().nullable()
|
|
7630
8543
|
});
|
|
8544
|
+
function baseUnitForQuantity(quantity) {
|
|
8545
|
+
return baseUnitOf(quantity);
|
|
8546
|
+
}
|
|
8547
|
+
function unitsForQuantity(quantity) {
|
|
8548
|
+
const base = baseUnitForQuantity(quantity);
|
|
8549
|
+
if (base === void 0) return [];
|
|
8550
|
+
const dimension = unitDimension(base);
|
|
8551
|
+
if (dimension === void 0) return [base];
|
|
8552
|
+
return [base, ...unitsForDimension(dimension).filter((unit) => unit !== base)];
|
|
8553
|
+
}
|
|
7631
8554
|
var UnitWarningSchema = z4.object({
|
|
7632
8555
|
/** Where in the saved record, e.g. `conditions.children.0` or `steps.2.args.grams`. */
|
|
7633
8556
|
path: z4.string(),
|
|
@@ -7637,6 +8560,260 @@ var UnitWarningSchema = z4.object({
|
|
|
7637
8560
|
assumedUnit: z4.string(),
|
|
7638
8561
|
message: z4.string()
|
|
7639
8562
|
});
|
|
8563
|
+
function objectInput(schema) {
|
|
8564
|
+
const inner = unwrapSchema(schema);
|
|
8565
|
+
return inner instanceof z4.ZodObject ? inner : void 0;
|
|
8566
|
+
}
|
|
8567
|
+
function capUnitResolver(defs) {
|
|
8568
|
+
const byName = new Map(defs.map((def) => [def.name, def]));
|
|
8569
|
+
return {
|
|
8570
|
+
fieldQuantity(cap, fieldPath) {
|
|
8571
|
+
const status = byName.get(cap)?.status;
|
|
8572
|
+
if (status === void 0) return void 0;
|
|
8573
|
+
return [...enumerateSchemaFields(status.schema), ...status.itemArray === void 0 ? [] : enumerateItemArrayFields(status.itemArray)].find((f) => f.path === fieldPath)?.quantity;
|
|
8574
|
+
},
|
|
8575
|
+
paramMeta(cap, method2, arg) {
|
|
8576
|
+
const input = byName.get(cap)?.methods[method2]?.input;
|
|
8577
|
+
if (input === void 0) return void 0;
|
|
8578
|
+
const field = objectInput(input)?.shape[arg];
|
|
8579
|
+
return field === void 0 ? void 0 : paramMetaOf(field);
|
|
8580
|
+
}
|
|
8581
|
+
};
|
|
8582
|
+
}
|
|
8583
|
+
function pickedUnit(raw) {
|
|
8584
|
+
return normalizeUnit(raw) ?? raw;
|
|
8585
|
+
}
|
|
8586
|
+
function hasUnitChoice(quantity) {
|
|
8587
|
+
return unitsForQuantity(quantity).length > 1;
|
|
8588
|
+
}
|
|
8589
|
+
function bareWarning(input) {
|
|
8590
|
+
return {
|
|
8591
|
+
...input,
|
|
8592
|
+
message: `${input.field}: the bare number ${input.value} was taken as ${input.assumedUnit} \u2014 send { value, unit } to state the unit`
|
|
8593
|
+
};
|
|
8594
|
+
}
|
|
8595
|
+
function checkPickedUnit(unit, target) {
|
|
8596
|
+
convertQuantityInput({
|
|
8597
|
+
value: 1,
|
|
8598
|
+
unit,
|
|
8599
|
+
target
|
|
8600
|
+
});
|
|
8601
|
+
}
|
|
8602
|
+
function normalizeLeaf(leaf, resolver, path2) {
|
|
8603
|
+
const quantity = resolver.fieldQuantity(leaf.cap, leaf.fieldPath);
|
|
8604
|
+
const base = quantity === void 0 ? void 0 : baseUnitForQuantity(quantity);
|
|
8605
|
+
const wantsUnit = isQuantityValue(leaf.value) || leaf.unit !== void 0;
|
|
8606
|
+
if (base === void 0 || quantity === void 0) {
|
|
8607
|
+
if (wantsUnit) throw new QuantityUnitError("no-quantity", `${leaf.cap}.${leaf.fieldPath} is not a quantity \u2014 it takes no unit`);
|
|
8608
|
+
return {
|
|
8609
|
+
leaf,
|
|
8610
|
+
warnings: []
|
|
8611
|
+
};
|
|
8612
|
+
}
|
|
8613
|
+
if (isQuantityValue(leaf.value)) {
|
|
8614
|
+
const converted = convertQuantityInput({
|
|
8615
|
+
value: leaf.value.value,
|
|
8616
|
+
unit: leaf.value.unit,
|
|
8617
|
+
target: base
|
|
8618
|
+
});
|
|
8619
|
+
return {
|
|
8620
|
+
leaf: {
|
|
8621
|
+
...leaf,
|
|
8622
|
+
value: converted,
|
|
8623
|
+
unit: pickedUnit(leaf.value.unit)
|
|
8624
|
+
},
|
|
8625
|
+
warnings: []
|
|
8626
|
+
};
|
|
8627
|
+
}
|
|
8628
|
+
if (typeof leaf.value !== "number") return {
|
|
8629
|
+
leaf,
|
|
8630
|
+
warnings: []
|
|
8631
|
+
};
|
|
8632
|
+
if (leaf.unit !== void 0) {
|
|
8633
|
+
checkPickedUnit(leaf.unit, base);
|
|
8634
|
+
return {
|
|
8635
|
+
leaf,
|
|
8636
|
+
warnings: []
|
|
8637
|
+
};
|
|
8638
|
+
}
|
|
8639
|
+
if (!hasUnitChoice(quantity)) return {
|
|
8640
|
+
leaf,
|
|
8641
|
+
warnings: []
|
|
8642
|
+
};
|
|
8643
|
+
return {
|
|
8644
|
+
leaf,
|
|
8645
|
+
warnings: [bareWarning({
|
|
8646
|
+
path: path2,
|
|
8647
|
+
deviceId: leaf.deviceId,
|
|
8648
|
+
field: leaf.fieldPath,
|
|
8649
|
+
value: leaf.value,
|
|
8650
|
+
assumedUnit: base
|
|
8651
|
+
})]
|
|
8652
|
+
};
|
|
8653
|
+
}
|
|
8654
|
+
function normalizeConditionUnits(condition, resolver, path2 = "conditions") {
|
|
8655
|
+
switch (condition.kind) {
|
|
8656
|
+
case "condition": {
|
|
8657
|
+
const { leaf, warnings } = normalizeLeaf(condition, resolver, path2);
|
|
8658
|
+
return {
|
|
8659
|
+
value: leaf,
|
|
8660
|
+
warnings
|
|
8661
|
+
};
|
|
8662
|
+
}
|
|
8663
|
+
case "not": {
|
|
8664
|
+
const inner = normalizeConditionUnits(condition.child, resolver, `${path2}.child`);
|
|
8665
|
+
return {
|
|
8666
|
+
value: {
|
|
8667
|
+
...condition,
|
|
8668
|
+
child: inner.value
|
|
8669
|
+
},
|
|
8670
|
+
warnings: inner.warnings
|
|
8671
|
+
};
|
|
8672
|
+
}
|
|
8673
|
+
case "all":
|
|
8674
|
+
case "any": {
|
|
8675
|
+
const children = condition.children.map((child, i) => normalizeConditionUnits(child, resolver, `${path2}.children.${i}`));
|
|
8676
|
+
return {
|
|
8677
|
+
value: {
|
|
8678
|
+
...condition,
|
|
8679
|
+
children: children.map((c) => c.value)
|
|
8680
|
+
},
|
|
8681
|
+
warnings: children.flatMap((c) => c.warnings)
|
|
8682
|
+
};
|
|
8683
|
+
}
|
|
8684
|
+
default:
|
|
8685
|
+
return {
|
|
8686
|
+
value: condition,
|
|
8687
|
+
warnings: []
|
|
8688
|
+
};
|
|
8689
|
+
}
|
|
8690
|
+
}
|
|
8691
|
+
function normalizeCapStep(step, resolver, path2) {
|
|
8692
|
+
const deviceId = typeof step.deviceId === "number" ? step.deviceId : void 0;
|
|
8693
|
+
const args = { ...step.args };
|
|
8694
|
+
const argUnits = { ...step.argUnits };
|
|
8695
|
+
const warnings = [];
|
|
8696
|
+
for (const [arg, picked] of Object.entries(step.argUnits ?? {})) {
|
|
8697
|
+
const meta = resolver.paramMeta(step.cap, step.method, arg);
|
|
8698
|
+
if (meta === void 0) throw new QuantityUnitError("no-quantity", `${step.cap}.${step.method}(${arg}) is not a quantity \u2014 it takes no unit`);
|
|
8699
|
+
checkPickedUnit(picked, meta.unit);
|
|
8700
|
+
}
|
|
8701
|
+
for (const [arg, given] of Object.entries(step.args ?? {})) {
|
|
8702
|
+
const meta = resolver.paramMeta(step.cap, step.method, arg);
|
|
8703
|
+
if (meta === void 0) continue;
|
|
8704
|
+
if (isQuantityValue(given)) {
|
|
8705
|
+
args[arg] = convertQuantityInput({
|
|
8706
|
+
value: given.value,
|
|
8707
|
+
unit: given.unit,
|
|
8708
|
+
target: meta.unit
|
|
8709
|
+
});
|
|
8710
|
+
argUnits[arg] = pickedUnit(given.unit);
|
|
8711
|
+
} else if (typeof given === "number" && argUnits[arg] === void 0 && hasUnitChoice(meta.quantity)) warnings.push(bareWarning({
|
|
8712
|
+
path: `${path2}.args.${arg}`,
|
|
8713
|
+
...deviceId === void 0 ? {} : { deviceId },
|
|
8714
|
+
field: arg,
|
|
8715
|
+
value: given,
|
|
8716
|
+
assumedUnit: meta.unit
|
|
8717
|
+
}));
|
|
8718
|
+
}
|
|
8719
|
+
const { argUnits: _dropped, ...rest } = step;
|
|
8720
|
+
return {
|
|
8721
|
+
value: {
|
|
8722
|
+
...rest,
|
|
8723
|
+
...step.args === void 0 ? {} : { args },
|
|
8724
|
+
...Object.keys(argUnits).length > 0 ? { argUnits } : {}
|
|
8725
|
+
},
|
|
8726
|
+
warnings
|
|
8727
|
+
};
|
|
8728
|
+
}
|
|
8729
|
+
function normalizeActionUnits(actions, resolver, path2 = "steps") {
|
|
8730
|
+
const out = [];
|
|
8731
|
+
const warnings = [];
|
|
8732
|
+
actions.forEach((action, i) => {
|
|
8733
|
+
if (action.kind !== "cap") {
|
|
8734
|
+
out.push(action);
|
|
8735
|
+
return;
|
|
8736
|
+
}
|
|
8737
|
+
const normalized = normalizeCapStep(action, resolver, `${path2}.${i}`);
|
|
8738
|
+
out.push(normalized.value);
|
|
8739
|
+
warnings.push(...normalized.warnings);
|
|
8740
|
+
});
|
|
8741
|
+
return {
|
|
8742
|
+
value: out,
|
|
8743
|
+
warnings
|
|
8744
|
+
};
|
|
8745
|
+
}
|
|
8746
|
+
function normalizeRecipeUnits(recipe, resolver) {
|
|
8747
|
+
const conditions = recipe.conditions === void 0 ? void 0 : normalizeConditionUnits(recipe.conditions, resolver, "conditions");
|
|
8748
|
+
const actions = normalizeActionUnits(recipe.actions, resolver, "actions");
|
|
8749
|
+
return {
|
|
8750
|
+
value: {
|
|
8751
|
+
...recipe,
|
|
8752
|
+
...conditions === void 0 ? {} : { conditions: conditions.value },
|
|
8753
|
+
actions: actions.value
|
|
8754
|
+
},
|
|
8755
|
+
warnings: [...conditions?.warnings ?? [], ...actions.warnings]
|
|
8756
|
+
};
|
|
8757
|
+
}
|
|
8758
|
+
function stampCondition(condition, resolver) {
|
|
8759
|
+
switch (condition.kind) {
|
|
8760
|
+
case "condition": {
|
|
8761
|
+
if (typeof condition.value !== "number" || condition.unit !== void 0) return condition;
|
|
8762
|
+
const quantity = resolver.fieldQuantity(condition.cap, condition.fieldPath);
|
|
8763
|
+
if (quantity === void 0 || unitsForQuantity(quantity).length < 2) return condition;
|
|
8764
|
+
const base = baseUnitForQuantity(quantity);
|
|
8765
|
+
return base === void 0 ? condition : {
|
|
8766
|
+
...condition,
|
|
8767
|
+
unit: base
|
|
8768
|
+
};
|
|
8769
|
+
}
|
|
8770
|
+
case "not":
|
|
8771
|
+
return {
|
|
8772
|
+
...condition,
|
|
8773
|
+
child: stampCondition(condition.child, resolver)
|
|
8774
|
+
};
|
|
8775
|
+
case "all":
|
|
8776
|
+
case "any":
|
|
8777
|
+
return {
|
|
8778
|
+
...condition,
|
|
8779
|
+
children: condition.children.map((child) => stampCondition(child, resolver))
|
|
8780
|
+
};
|
|
8781
|
+
default:
|
|
8782
|
+
return condition;
|
|
8783
|
+
}
|
|
8784
|
+
}
|
|
8785
|
+
function stampAction(action, resolver) {
|
|
8786
|
+
if (action.kind !== "cap" || action.args === void 0) return action;
|
|
8787
|
+
const argUnits = { ...action.argUnits };
|
|
8788
|
+
for (const [arg, value] of Object.entries(action.args)) {
|
|
8789
|
+
if (typeof value !== "number" || argUnits[arg] !== void 0) continue;
|
|
8790
|
+
const meta = resolver.paramMeta(action.cap, action.method, arg);
|
|
8791
|
+
if (meta !== void 0 && unitsForQuantity(meta.quantity).length > 1) argUnits[arg] = meta.unit;
|
|
8792
|
+
}
|
|
8793
|
+
return Object.keys(argUnits).length === 0 ? action : {
|
|
8794
|
+
...action,
|
|
8795
|
+
argUnits
|
|
8796
|
+
};
|
|
8797
|
+
}
|
|
8798
|
+
function prepareRecipeUnits(recipe, resolver) {
|
|
8799
|
+
const stamped = {
|
|
8800
|
+
...recipe,
|
|
8801
|
+
...recipe.conditions === void 0 ? {} : { conditions: stampCondition(recipe.conditions, resolver) },
|
|
8802
|
+
actions: recipe.actions.map((action) => stampAction(action, resolver))
|
|
8803
|
+
};
|
|
8804
|
+
try {
|
|
8805
|
+
return {
|
|
8806
|
+
ok: true,
|
|
8807
|
+
recipe: normalizeRecipeUnits(stamped, resolver).value
|
|
8808
|
+
};
|
|
8809
|
+
} catch (error) {
|
|
8810
|
+
if (error instanceof QuantityUnitError) return {
|
|
8811
|
+
ok: false,
|
|
8812
|
+
error: error.message
|
|
8813
|
+
};
|
|
8814
|
+
throw error;
|
|
8815
|
+
}
|
|
8816
|
+
}
|
|
7640
8817
|
var AttachmentMediaTypeSchema = z4.enum([
|
|
7641
8818
|
"image",
|
|
7642
8819
|
"video",
|
|
@@ -58990,6 +60167,71 @@ var powerMeterCustomize = {
|
|
|
58990
60167
|
powerTarget("current", "A")
|
|
58991
60168
|
]
|
|
58992
60169
|
};
|
|
60170
|
+
var powerMeterCapability = {
|
|
60171
|
+
name: "power-meter",
|
|
60172
|
+
scope: "device",
|
|
60173
|
+
deviceNative: true,
|
|
60174
|
+
mode: "singleton",
|
|
60175
|
+
deviceTypes: POWER_METER_DEVICE_TYPES,
|
|
60176
|
+
methods: {},
|
|
60177
|
+
status: {
|
|
60178
|
+
schema: PowerMeterStatusSchema,
|
|
60179
|
+
kind: "push"
|
|
60180
|
+
},
|
|
60181
|
+
runtimeState: PowerMeterStatusSchema,
|
|
60182
|
+
/**
|
|
60183
|
+
* Runtime-state durability: **restored** — as `numeric-sensor`; `energy` is an accumulator whose restored value is the baseline.
|
|
60184
|
+
*
|
|
60185
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
60186
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
60187
|
+
*/
|
|
60188
|
+
durability: "restored",
|
|
60189
|
+
/**
|
|
60190
|
+
* History (D850): recorded series. Gauges are floored at 30 s; `energy` is the one monotonic meter (counter).
|
|
60191
|
+
*/
|
|
60192
|
+
history: { fields: {
|
|
60193
|
+
power: {
|
|
60194
|
+
kind: "gauge",
|
|
60195
|
+
unit: "W",
|
|
60196
|
+
deadband: 1,
|
|
60197
|
+
minIntervalMs: 3e4
|
|
60198
|
+
},
|
|
60199
|
+
energy: {
|
|
60200
|
+
kind: "counter",
|
|
60201
|
+
unit: "kWh",
|
|
60202
|
+
deadband: 0.01,
|
|
60203
|
+
minIntervalMs: 6e4
|
|
60204
|
+
},
|
|
60205
|
+
voltage: {
|
|
60206
|
+
kind: "gauge",
|
|
60207
|
+
unit: "V",
|
|
60208
|
+
deadband: 1,
|
|
60209
|
+
minIntervalMs: 3e4
|
|
60210
|
+
},
|
|
60211
|
+
current: {
|
|
60212
|
+
kind: "gauge",
|
|
60213
|
+
unit: "A",
|
|
60214
|
+
deadband: 0.05,
|
|
60215
|
+
minIntervalMs: 3e4
|
|
60216
|
+
},
|
|
60217
|
+
apparentPower: {
|
|
60218
|
+
kind: "gauge",
|
|
60219
|
+
unit: "VA",
|
|
60220
|
+
deadband: 1,
|
|
60221
|
+
minIntervalMs: 3e4
|
|
60222
|
+
},
|
|
60223
|
+
powerFactor: {
|
|
60224
|
+
kind: "gauge",
|
|
60225
|
+
unit: "ratio",
|
|
60226
|
+
deadband: 0.01,
|
|
60227
|
+
minIntervalMs: 6e4
|
|
60228
|
+
}
|
|
60229
|
+
} },
|
|
60230
|
+
/** Clock fields: written, but excluded from the compare that decides
|
|
60231
|
+
* whether persisting is worth a SQLite commit. */
|
|
60232
|
+
volatileStateFields: ["lastFetchedAt"],
|
|
60233
|
+
customize: powerMeterCustomize
|
|
60234
|
+
};
|
|
58993
60235
|
var GpsLocationSchema = z4.object({
|
|
58994
60236
|
/** Latitude in decimal degrees, -90..90. */
|
|
58995
60237
|
latitude: z4.number().min(-90).max(90).meta(readingMeta("angle")),
|
|
@@ -59653,6 +60895,27 @@ var runningCustomize = {
|
|
|
59653
60895
|
advancedExpression: true
|
|
59654
60896
|
}]
|
|
59655
60897
|
};
|
|
60898
|
+
var runningStateCapability = {
|
|
60899
|
+
name: "running-state",
|
|
60900
|
+
scope: "device",
|
|
60901
|
+
deviceNative: true,
|
|
60902
|
+
mode: "singleton",
|
|
60903
|
+
deviceTypes: RUNNING_DEVICE_TYPES,
|
|
60904
|
+
methods: {},
|
|
60905
|
+
events: {
|
|
60906
|
+
onStarted: { data: RunningFlipEventDataSchema },
|
|
60907
|
+
onFinished: { data: RunningFlipEventDataSchema }
|
|
60908
|
+
},
|
|
60909
|
+
status: {
|
|
60910
|
+
schema: RunningStateStatusSchema,
|
|
60911
|
+
kind: "push"
|
|
60912
|
+
},
|
|
60913
|
+
runtimeState: RunningStateStatusSchema,
|
|
60914
|
+
durability: "restored",
|
|
60915
|
+
history: { fields: { running: { kind: "boolean" } } },
|
|
60916
|
+
volatileStateFields: ["lastFetchedAt"],
|
|
60917
|
+
customize: runningCustomize
|
|
60918
|
+
};
|
|
59656
60919
|
var rebootCapability = {
|
|
59657
60920
|
name: "reboot",
|
|
59658
60921
|
scope: "device",
|
|
@@ -78817,6 +80080,201 @@ var APPLIANCE_EXPORT = {
|
|
|
78817
80080
|
matter: withheld(MATTER_REASON)
|
|
78818
80081
|
};
|
|
78819
80082
|
var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
|
|
80083
|
+
var ALL_CAPABILITY_DEFINITIONS = [
|
|
80084
|
+
accessoriesCapability,
|
|
80085
|
+
actionSourceCapability,
|
|
80086
|
+
actionsCapability,
|
|
80087
|
+
addonPagesCapability,
|
|
80088
|
+
addonPagesSourceCapability,
|
|
80089
|
+
addonRoutesCapability,
|
|
80090
|
+
addonSettingsCapability,
|
|
80091
|
+
addonWidgetsCapability,
|
|
80092
|
+
addonWidgetsSourceCapability,
|
|
80093
|
+
addonsCapability,
|
|
80094
|
+
adminUiCapability,
|
|
80095
|
+
airQualitySensorCapability,
|
|
80096
|
+
alarmPanelCapability,
|
|
80097
|
+
alertsCapability,
|
|
80098
|
+
ambientLightSensorCapability,
|
|
80099
|
+
audioAnalysisCapability,
|
|
80100
|
+
audioAnalyzerCapability,
|
|
80101
|
+
audioCodecCapability,
|
|
80102
|
+
audioMetricsCapability,
|
|
80103
|
+
authProviderCapability,
|
|
80104
|
+
automationControlCapability,
|
|
80105
|
+
backupCapability,
|
|
80106
|
+
batteryCapability,
|
|
80107
|
+
binaryCapability,
|
|
80108
|
+
brightnessCapability,
|
|
80109
|
+
brokerCapability,
|
|
80110
|
+
buttonCapability,
|
|
80111
|
+
calendarSourceCapability,
|
|
80112
|
+
calendarsCapability,
|
|
80113
|
+
cameraCredentialsCapability,
|
|
80114
|
+
cameraGridLayoutCapability,
|
|
80115
|
+
cameraPipelineConfigCapability,
|
|
80116
|
+
cameraStreamsCapability,
|
|
80117
|
+
carbonMonoxideCapability,
|
|
80118
|
+
climateControlCapability,
|
|
80119
|
+
colorCapability,
|
|
80120
|
+
composerCapability,
|
|
80121
|
+
connectionTestCapability,
|
|
80122
|
+
connectivityCapability,
|
|
80123
|
+
consumablesCapability,
|
|
80124
|
+
contactCapability,
|
|
80125
|
+
controlCapability,
|
|
80126
|
+
coreBlocksCapability,
|
|
80127
|
+
coverCapability,
|
|
80128
|
+
customModelRegistryCapability,
|
|
80129
|
+
dataStoreProviderCapability,
|
|
80130
|
+
databaseSnapshotCapability,
|
|
80131
|
+
dayNightCapability,
|
|
80132
|
+
decoderCapability,
|
|
80133
|
+
detectionPipelineCapability,
|
|
80134
|
+
deviceAdminLinkCapability,
|
|
80135
|
+
deviceAdoptionCapability,
|
|
80136
|
+
deviceCredentialsCapability,
|
|
80137
|
+
deviceDiscoveryCapability,
|
|
80138
|
+
deviceExportCapability,
|
|
80139
|
+
deviceExtensionCapability,
|
|
80140
|
+
deviceHistoryCapability,
|
|
80141
|
+
deviceManagerCapability,
|
|
80142
|
+
deviceOpsCapability,
|
|
80143
|
+
deviceProviderCapability,
|
|
80144
|
+
deviceRemovalHookCapability,
|
|
80145
|
+
deviceStateCapability,
|
|
80146
|
+
deviceStatusCapability,
|
|
80147
|
+
dishwasherCapability,
|
|
80148
|
+
displayPaletteCapability,
|
|
80149
|
+
doorbellCapability,
|
|
80150
|
+
embeddingEncoderCapability,
|
|
80151
|
+
enumSensorCapability,
|
|
80152
|
+
eventEmitterCapability,
|
|
80153
|
+
faceGalleryCapability,
|
|
80154
|
+
failureContributionCapability,
|
|
80155
|
+
fanControlCapability,
|
|
80156
|
+
featureProbeCapability,
|
|
80157
|
+
filesystemBrowseCapability,
|
|
80158
|
+
floodCapability,
|
|
80159
|
+
gasCapability,
|
|
80160
|
+
groupActiveCapability,
|
|
80161
|
+
groupCommandsCapability,
|
|
80162
|
+
groupStatusCapability,
|
|
80163
|
+
heatingValveCapability,
|
|
80164
|
+
humidifierCapability,
|
|
80165
|
+
humiditySensorCapability,
|
|
80166
|
+
imageCapability,
|
|
80167
|
+
imageSettingsCapability,
|
|
80168
|
+
integrationsCapability,
|
|
80169
|
+
intercomCapability,
|
|
80170
|
+
irTransmitterCapability,
|
|
80171
|
+
keychainCapability,
|
|
80172
|
+
keychainAdminCapability,
|
|
80173
|
+
lawnMowerControlCapability,
|
|
80174
|
+
llmCapability,
|
|
80175
|
+
llmRuntimeCapability,
|
|
80176
|
+
loadContributionCapability,
|
|
80177
|
+
localNetworkCapability,
|
|
80178
|
+
lockControlCapability,
|
|
80179
|
+
logChannelsCapability,
|
|
80180
|
+
logDestinationCapability,
|
|
80181
|
+
loginMethodCapability,
|
|
80182
|
+
mediaPlayerCapability,
|
|
80183
|
+
meshNetworkCapability,
|
|
80184
|
+
meterCapability,
|
|
80185
|
+
metricsProviderCapability,
|
|
80186
|
+
modelConvertCapability,
|
|
80187
|
+
modelDistributorCapability,
|
|
80188
|
+
motionCapability,
|
|
80189
|
+
motionDetectionCapability,
|
|
80190
|
+
motionTriggerCapability,
|
|
80191
|
+
motionZonesCapability,
|
|
80192
|
+
mqttBrokerCapability,
|
|
80193
|
+
nativeObjectDetectionCapability,
|
|
80194
|
+
navigationCapability,
|
|
80195
|
+
networkAccessCapability,
|
|
80196
|
+
networkLinkCapability,
|
|
80197
|
+
networkQualityCapability,
|
|
80198
|
+
nodeCertificatesCapability,
|
|
80199
|
+
nodeRemovalHookCapability,
|
|
80200
|
+
nodesCapability,
|
|
80201
|
+
notificationOutputCapability,
|
|
80202
|
+
notificationRulesCapability,
|
|
80203
|
+
notifierCapability,
|
|
80204
|
+
numericSensorCapability,
|
|
80205
|
+
oauthIntegrationCapability,
|
|
80206
|
+
oidcConnectionsCapability,
|
|
80207
|
+
osdCapability,
|
|
80208
|
+
osdManagerCapability,
|
|
80209
|
+
petFeederCapability,
|
|
80210
|
+
petFountainCapability,
|
|
80211
|
+
pipelineAnalyticsCapability,
|
|
80212
|
+
pipelineExecutorCapability,
|
|
80213
|
+
pipelineOrchestratorCapability,
|
|
80214
|
+
pipelineRunnerCapability,
|
|
80215
|
+
plateGalleryCapability,
|
|
80216
|
+
platformProbeCapability,
|
|
80217
|
+
powerMeterCapability,
|
|
80218
|
+
presenceCapability,
|
|
80219
|
+
pressureSensorCapability,
|
|
80220
|
+
privacyMaskCapability,
|
|
80221
|
+
ptzCapability,
|
|
80222
|
+
ptzAutotrackCapability,
|
|
80223
|
+
rebootCapability,
|
|
80224
|
+
recordEntriesCapability,
|
|
80225
|
+
recordingCapability,
|
|
80226
|
+
recordingArchiveCapability,
|
|
80227
|
+
recordingExportCapability,
|
|
80228
|
+
recordingOnboardCapability,
|
|
80229
|
+
recordingSignalCapability,
|
|
80230
|
+
recordsCapability,
|
|
80231
|
+
remoteCapability,
|
|
80232
|
+
remoteLayoutComponentsCapability,
|
|
80233
|
+
runningStateCapability,
|
|
80234
|
+
sceneMonitorCapability,
|
|
80235
|
+
scriptRunnerCapability,
|
|
80236
|
+
serverManagementCapability,
|
|
80237
|
+
settingsStoreCapability,
|
|
80238
|
+
smokeCapability,
|
|
80239
|
+
smtpProviderCapability,
|
|
80240
|
+
snapshotCapability,
|
|
80241
|
+
ssoBridgeCapability,
|
|
80242
|
+
storageCapability,
|
|
80243
|
+
storageAdminCapability,
|
|
80244
|
+
storageEvictableCapability,
|
|
80245
|
+
storageMigrationCapability,
|
|
80246
|
+
storageOccupancyCapability,
|
|
80247
|
+
storageProviderCapability,
|
|
80248
|
+
streamBrokerCapability,
|
|
80249
|
+
streamCatalogCapability,
|
|
80250
|
+
streamParamsCapability,
|
|
80251
|
+
streamSignalsCapability,
|
|
80252
|
+
switchCapability,
|
|
80253
|
+
systemCapability,
|
|
80254
|
+
tamperCapability,
|
|
80255
|
+
temperatureSensorCapability,
|
|
80256
|
+
templateCapability,
|
|
80257
|
+
terminalSessionCapability,
|
|
80258
|
+
toastCapability,
|
|
80259
|
+
trainingCapability,
|
|
80260
|
+
trainingProviderCapability,
|
|
80261
|
+
turnProviderCapability,
|
|
80262
|
+
updateCapability,
|
|
80263
|
+
userManagementCapability,
|
|
80264
|
+
userPasskeysCapability,
|
|
80265
|
+
vacuumControlCapability,
|
|
80266
|
+
valveCapability,
|
|
80267
|
+
vectorStoreCapability,
|
|
80268
|
+
vibrationCapability,
|
|
80269
|
+
videoclipsCapability,
|
|
80270
|
+
viewerUiCapability,
|
|
80271
|
+
waterHeaterCapability,
|
|
80272
|
+
weatherCapability,
|
|
80273
|
+
webrtcSessionCapability,
|
|
80274
|
+
zoneAnalyticsCapability,
|
|
80275
|
+
zoneRulesCapability,
|
|
80276
|
+
zonesCapability
|
|
80277
|
+
];
|
|
78820
80278
|
var METHOD_DEVICE_SELECTORS = Object.freeze({
|
|
78821
80279
|
"accessories.getStatus": [{
|
|
78822
80280
|
name: "deviceId",
|
|
@@ -83863,9 +85321,21 @@ function describeHub(hub) {
|
|
|
83863
85321
|
}
|
|
83864
85322
|
|
|
83865
85323
|
export {
|
|
85324
|
+
isReadingQuantity,
|
|
83866
85325
|
FFMPEG_PATH_ENV,
|
|
85326
|
+
AutomationRecipeSchema,
|
|
85327
|
+
validateRecipeBounds,
|
|
85328
|
+
generateAutomationBlock,
|
|
85329
|
+
MACRO_LABELS,
|
|
85330
|
+
unitsForQuantity,
|
|
85331
|
+
capUnitResolver,
|
|
85332
|
+
prepareRecipeUnits,
|
|
83867
85333
|
buildViewerLink,
|
|
85334
|
+
AlarmArmModeSchema,
|
|
85335
|
+
NcRuleInputSchema,
|
|
85336
|
+
ZoneKindEnum,
|
|
83868
85337
|
DETECTOR_PERF,
|
|
85338
|
+
ALL_CAPABILITY_DEFINITIONS,
|
|
83869
85339
|
classifyInferenceHardware,
|
|
83870
85340
|
acceleratorKindOf,
|
|
83871
85341
|
validateManualHubUrl,
|