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.
@@ -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,