@switchbot/openapi-cli 3.0.0 → 3.1.1
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/README.md +138 -50
- package/dist/api/client.js +23 -1
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +79 -0
- package/dist/commands/daemon.js +410 -0
- package/dist/commands/devices.js +62 -10
- package/dist/commands/doctor.js +233 -1
- package/dist/commands/health.js +113 -0
- package/dist/commands/mcp.js +93 -5
- package/dist/commands/plan.js +310 -130
- package/dist/commands/policy.js +120 -3
- package/dist/commands/rules.js +220 -2
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/upgrade-check.js +107 -0
- package/dist/index.js +7 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -0
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/schema/v0.2.json +29 -0
- package/dist/rules/action.js +11 -0
- package/dist/rules/conflict-analyzer.js +214 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/suggest.js +1 -1
- package/dist/rules/throttle.js +42 -4
- package/dist/utils/audit.js +5 -1
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
export const PLANS_DIR = path.join(os.homedir(), '.switchbot', 'plans');
|
|
6
|
+
function ensurePlansDir() {
|
|
7
|
+
fs.mkdirSync(PLANS_DIR, { recursive: true, mode: 0o700 });
|
|
8
|
+
}
|
|
9
|
+
function planPath(planId) {
|
|
10
|
+
return path.join(PLANS_DIR, `${planId}.json`);
|
|
11
|
+
}
|
|
12
|
+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
13
|
+
function assertValidPlanId(planId) {
|
|
14
|
+
if (!UUID_V4_RE.test(planId)) {
|
|
15
|
+
throw new Error(`invalid planId: ${planId}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function savePlanRecord(plan) {
|
|
19
|
+
ensurePlansDir();
|
|
20
|
+
const record = {
|
|
21
|
+
planId: randomUUID(),
|
|
22
|
+
createdAt: new Date().toISOString(),
|
|
23
|
+
status: 'pending',
|
|
24
|
+
plan,
|
|
25
|
+
};
|
|
26
|
+
fs.writeFileSync(planPath(record.planId), JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
export function loadPlanRecord(planId) {
|
|
30
|
+
assertValidPlanId(planId);
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(planPath(planId), 'utf-8');
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function updatePlanRecord(planId, updates) {
|
|
40
|
+
assertValidPlanId(planId);
|
|
41
|
+
const record = loadPlanRecord(planId);
|
|
42
|
+
if (!record)
|
|
43
|
+
throw new Error(`Plan ${planId} not found in ${PLANS_DIR}`);
|
|
44
|
+
const updated = { ...record, ...updates };
|
|
45
|
+
fs.writeFileSync(planPath(planId), JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
46
|
+
return updated;
|
|
47
|
+
}
|
|
48
|
+
export function listPlanRecords() {
|
|
49
|
+
try {
|
|
50
|
+
if (!fs.existsSync(PLANS_DIR))
|
|
51
|
+
return [];
|
|
52
|
+
return fs
|
|
53
|
+
.readdirSync(PLANS_DIR)
|
|
54
|
+
.filter((f) => f.endsWith('.json'))
|
|
55
|
+
.flatMap((f) => {
|
|
56
|
+
try {
|
|
57
|
+
return [JSON.parse(fs.readFileSync(path.join(PLANS_DIR, f), 'utf-8'))];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -130,10 +130,39 @@
|
|
|
130
130
|
"type": "string",
|
|
131
131
|
"pattern": "^\\d+[smh]$",
|
|
132
132
|
"description": "Minimum spacing between fires, e.g. \"10m\". Later triggers inside the window are suppressed and audited."
|
|
133
|
+
},
|
|
134
|
+
"dedupe_window": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"pattern": "^\\d+[smh]$",
|
|
137
|
+
"description": "Deduplicate identical events arriving within this window after the last fire. Complements max_per: use when rapid sensor bursts should collapse into one action."
|
|
133
138
|
}
|
|
134
139
|
},
|
|
135
140
|
"required": ["max_per"]
|
|
136
141
|
},
|
|
142
|
+
"cooldown": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"pattern": "^\\d+[smh]$",
|
|
145
|
+
"description": "Shorthand cooldown — minimum pause after a rule fires before it can fire again. Equivalent to throttle.max_per but at rule level. Takes precedence over throttle.max_per when both are set."
|
|
146
|
+
},
|
|
147
|
+
"requires_stable_for": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"pattern": "^\\d+[smh]$",
|
|
150
|
+
"description": "Hysteresis guard — the triggering device state must remain stable (unchanged) for this duration before the rule fires. Prevents action storms from transient sensor flicker. Validated at lint; enforcement is best-effort in the engine."
|
|
151
|
+
},
|
|
152
|
+
"hysteresis": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"pattern": "^\\d+[smh]$",
|
|
155
|
+
"description": "Alias for requires_stable_for with clearer semantics. Takes precedence when both are set. The triggering state must be continuously observed for this duration before the rule fires."
|
|
156
|
+
},
|
|
157
|
+
"maxFiringsPerHour": {
|
|
158
|
+
"type": "integer",
|
|
159
|
+
"minimum": 1,
|
|
160
|
+
"description": "Maximum number of times this rule may fire in any rolling 60-minute window. Applied after cooldown/throttle checks. Useful as an absolute safety cap for high-frequency MQTT triggers."
|
|
161
|
+
},
|
|
162
|
+
"suppressIfAlreadyDesired": {
|
|
163
|
+
"type": "boolean",
|
|
164
|
+
"description": "When true, skip the rule's actions if the target device's last-known cached state already matches the expected outcome of the command (e.g. skip turnOn if powerState is already 'on'). Best-effort: requires a warm device cache."
|
|
165
|
+
},
|
|
137
166
|
"dry_run": {
|
|
138
167
|
"type": "boolean",
|
|
139
168
|
"default": true,
|
package/dist/rules/action.js
CHANGED
|
@@ -203,3 +203,14 @@ export async function executeRuleAction(action, ctx) {
|
|
|
203
203
|
return { ok: false, error: msg, deviceId, verb: parsed.verb };
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Extract the raw deviceId from an action object without alias resolution.
|
|
208
|
+
* Prefers `action.device` over the deviceId embedded in the command string.
|
|
209
|
+
* Use resolveActionDevice() when alias resolution is needed.
|
|
210
|
+
*/
|
|
211
|
+
export function extractDeviceIdFromAction(action) {
|
|
212
|
+
if (action.device)
|
|
213
|
+
return action.device;
|
|
214
|
+
const m = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? '');
|
|
215
|
+
return m ? m[1] : null;
|
|
216
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static conflict analysis for automation rules.
|
|
3
|
+
*
|
|
4
|
+
* Detects patterns that are technically valid but likely to cause
|
|
5
|
+
* operational problems:
|
|
6
|
+
*
|
|
7
|
+
* 1. Opposing-action pairs — same device, opposite commands (e.g.
|
|
8
|
+
* turnOn / turnOff), triggered by the same source within a short
|
|
9
|
+
* window and with no throttle on either rule.
|
|
10
|
+
*
|
|
11
|
+
* 2. High-frequency MQTT rules without throttle — rules that listen
|
|
12
|
+
* on `device.shadow` (catch-all) with no throttle can fire on
|
|
13
|
+
* every shadow push (up to once per second) and exhaust the daily
|
|
14
|
+
* API quota quickly.
|
|
15
|
+
*
|
|
16
|
+
* 3. Potentially-destructive action without quiet-hours protection —
|
|
17
|
+
* a rule that targets a destructive verb is technically blocked by
|
|
18
|
+
* the engine, but we can still flag it early so users don't get a
|
|
19
|
+
* surprise at runtime.
|
|
20
|
+
*
|
|
21
|
+
* Results are designed to be consumed by `rules doctor --json` and
|
|
22
|
+
* by CI pipelines. Each finding carries a `severity` so callers can
|
|
23
|
+
* decide how to gate on them.
|
|
24
|
+
*/
|
|
25
|
+
import { isTimeBetween, isAllCondition, isAnyCondition, isNotCondition } from './types.js';
|
|
26
|
+
import { parseMaxPerMs } from './throttle.js';
|
|
27
|
+
import { isDestructiveCommand } from './destructive.js';
|
|
28
|
+
import { extractDeviceIdFromAction } from './action.js';
|
|
29
|
+
/** Known opposing command pairs (order-independent). */
|
|
30
|
+
const OPPOSING_PAIRS = [
|
|
31
|
+
['turnOn', 'turnOff'],
|
|
32
|
+
['lock', 'unlock'],
|
|
33
|
+
['open', 'close'],
|
|
34
|
+
['openDoor', 'closeDoor'],
|
|
35
|
+
['openCurtain', 'closeCurtain'],
|
|
36
|
+
['turnOn', 'standby'],
|
|
37
|
+
['brightnessUp', 'brightnessDown'],
|
|
38
|
+
['volumeUp', 'volumeDown'],
|
|
39
|
+
['fanSpeedUp', 'fanSpeedDown'],
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* MQTT events that fire on every device state push and can rapidly exhaust
|
|
43
|
+
* the daily API quota when a rule has no throttle.
|
|
44
|
+
*
|
|
45
|
+
* Conditional events like `motion.detected` are intentionally excluded —
|
|
46
|
+
* they fire discretely, not at continuous high frequency. Extend this set
|
|
47
|
+
* when new catch-all or near-continuous event types are added to the classifier.
|
|
48
|
+
*/
|
|
49
|
+
export const HIGH_FREQ_EVENTS = ['device.shadow', '*'];
|
|
50
|
+
/** Returns true when an MQTT event is known to fire at high frequency. */
|
|
51
|
+
export function isHighFreqEvent(event) {
|
|
52
|
+
return HIGH_FREQ_EVENTS.includes(event);
|
|
53
|
+
}
|
|
54
|
+
function commandsAreOpposing(a, b) {
|
|
55
|
+
for (const [x, y] of OPPOSING_PAIRS) {
|
|
56
|
+
if ((a === x && b === y) || (a === y && b === x))
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
function extractCommandVerb(command) {
|
|
62
|
+
// command strings are like "devices command <id> turnOn" — extract last token
|
|
63
|
+
const parts = command.trim().split(/\s+/);
|
|
64
|
+
return parts[parts.length - 1] ?? command;
|
|
65
|
+
}
|
|
66
|
+
function effectiveCooldownMs(rule) {
|
|
67
|
+
if (rule.cooldown) {
|
|
68
|
+
try {
|
|
69
|
+
return parseMaxPerMs(rule.cooldown);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (rule.throttle?.max_per) {
|
|
76
|
+
try {
|
|
77
|
+
return parseMaxPerMs(rule.throttle.max_per);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function hasTimeBetweenGuard(conditions) {
|
|
86
|
+
if (!conditions)
|
|
87
|
+
return false;
|
|
88
|
+
for (const c of conditions) {
|
|
89
|
+
if (isTimeBetween(c))
|
|
90
|
+
return true;
|
|
91
|
+
if (isAllCondition(c) && hasTimeBetweenGuard(c.all))
|
|
92
|
+
return true;
|
|
93
|
+
if (isAnyCondition(c) && hasTimeBetweenGuard(c.any))
|
|
94
|
+
return true;
|
|
95
|
+
if (isNotCondition(c) && hasTimeBetweenGuard([c.not]))
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
export function analyzeConflicts(rules, quietHours) {
|
|
101
|
+
const findings = [];
|
|
102
|
+
const active = rules.filter((r) => r.enabled !== false);
|
|
103
|
+
// 1. Opposing-action pairs on the same device
|
|
104
|
+
for (let i = 0; i < active.length; i++) {
|
|
105
|
+
for (let j = i + 1; j < active.length; j++) {
|
|
106
|
+
const a = active[i];
|
|
107
|
+
const b = active[j];
|
|
108
|
+
// Only flag when they share the same trigger source (otherwise they
|
|
109
|
+
// can't race each other in normal operation).
|
|
110
|
+
if (a.when.source !== b.when.source)
|
|
111
|
+
continue;
|
|
112
|
+
const cooldownA = effectiveCooldownMs(a);
|
|
113
|
+
const cooldownB = effectiveCooldownMs(b);
|
|
114
|
+
// If both rules have meaningful cooldowns (≥ 5 minutes), the risk is
|
|
115
|
+
// low — skip.
|
|
116
|
+
const bothThrottled = cooldownA !== null && cooldownA >= 5 * 60_000 &&
|
|
117
|
+
cooldownB !== null && cooldownB >= 5 * 60_000;
|
|
118
|
+
if (bothThrottled)
|
|
119
|
+
continue;
|
|
120
|
+
for (const actionA of a.then) {
|
|
121
|
+
for (const actionB of b.then) {
|
|
122
|
+
const deviceA = extractDeviceIdFromAction(actionA);
|
|
123
|
+
const deviceB = extractDeviceIdFromAction(actionB);
|
|
124
|
+
// Skip if devices can't be compared.
|
|
125
|
+
if (!deviceA || !deviceB || deviceA !== deviceB)
|
|
126
|
+
continue;
|
|
127
|
+
const verbA = extractCommandVerb(actionA.command);
|
|
128
|
+
const verbB = extractCommandVerb(actionB.command);
|
|
129
|
+
if (commandsAreOpposing(verbA, verbB)) {
|
|
130
|
+
const noThrottle = cooldownA === null || cooldownB === null;
|
|
131
|
+
findings.push({
|
|
132
|
+
severity: noThrottle ? 'warning' : 'info',
|
|
133
|
+
code: 'opposing-actions',
|
|
134
|
+
message: `Rules "${a.name}" and "${b.name}" issue opposing commands (${verbA} / ${verbB}) on device "${deviceA}" via the same trigger source.`,
|
|
135
|
+
rules: [a.name, b.name],
|
|
136
|
+
hint: noThrottle
|
|
137
|
+
? 'Add a "cooldown" or "throttle.max_per" to both rules to prevent rapid state oscillation.'
|
|
138
|
+
: undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 2. High-frequency MQTT catch-all rules without throttle
|
|
146
|
+
for (const rule of active) {
|
|
147
|
+
if (rule.when.source !== 'mqtt')
|
|
148
|
+
continue;
|
|
149
|
+
const event = rule.when.event;
|
|
150
|
+
const isHighFreq = isHighFreqEvent(event);
|
|
151
|
+
if (!isHighFreq)
|
|
152
|
+
continue;
|
|
153
|
+
const cooldown = effectiveCooldownMs(rule);
|
|
154
|
+
if (cooldown === null) {
|
|
155
|
+
findings.push({
|
|
156
|
+
severity: 'warning',
|
|
157
|
+
code: 'high-frequency-no-throttle',
|
|
158
|
+
message: `Rule "${rule.name}" listens on "${event}" (high-frequency catch-all) with no throttle/cooldown. This can rapidly exhaust the daily API quota.`,
|
|
159
|
+
rules: [rule.name],
|
|
160
|
+
hint: 'Add "cooldown: 1m" or "throttle: { max_per: 1m }" to rate-limit this rule.',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else if (cooldown < 30_000) {
|
|
164
|
+
findings.push({
|
|
165
|
+
severity: 'info',
|
|
166
|
+
code: 'high-frequency-low-throttle',
|
|
167
|
+
message: `Rule "${rule.name}" listens on "${event}" with a throttle under 30 s. Consider increasing to at least 1 m to protect API quota.`,
|
|
168
|
+
rules: [rule.name],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// 3. Destructive actions in rules (engine blocks these at runtime, but
|
|
173
|
+
// surface early with clear guidance).
|
|
174
|
+
for (const rule of active) {
|
|
175
|
+
for (let i = 0; i < rule.then.length; i++) {
|
|
176
|
+
const verb = extractCommandVerb(rule.then[i].command);
|
|
177
|
+
if (isDestructiveCommand(verb)) {
|
|
178
|
+
findings.push({
|
|
179
|
+
severity: 'error',
|
|
180
|
+
code: 'destructive-action-in-rule',
|
|
181
|
+
message: `Rule "${rule.name}" then[${i}] contains destructive command "${verb}". The engine blocks this at runtime.`,
|
|
182
|
+
rules: [rule.name],
|
|
183
|
+
hint: 'Remove the destructive command or replace it with a non-destructive alternative.',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// 4. Event-driven rules with no time_between guard when quiet_hours is defined.
|
|
189
|
+
// Cron rules fire on an explicit schedule so their overlap with quiet hours
|
|
190
|
+
// requires schedule analysis — flag those separately when needed.
|
|
191
|
+
if (quietHours?.start && quietHours?.end) {
|
|
192
|
+
for (const rule of active) {
|
|
193
|
+
if (rule.when.source === 'cron')
|
|
194
|
+
continue;
|
|
195
|
+
if (!hasTimeBetweenGuard(rule.conditions)) {
|
|
196
|
+
findings.push({
|
|
197
|
+
severity: 'warning',
|
|
198
|
+
code: 'no-quiet-hours-guard',
|
|
199
|
+
message: `Rule "${rule.name}" (${rule.when.source} trigger) has no time_between condition and may fire during quiet hours (${quietHours.start}–${quietHours.end}).`,
|
|
200
|
+
rules: [rule.name],
|
|
201
|
+
hint: `Add a conditions entry "{ time_between: ['${quietHours.end}', '${quietHours.start}'] }" to block this rule during quiet hours.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
207
|
+
for (const f of findings)
|
|
208
|
+
counts[f.severity]++;
|
|
209
|
+
return {
|
|
210
|
+
findings,
|
|
211
|
+
counts,
|
|
212
|
+
clean: counts.error === 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/dist/rules/engine.js
CHANGED
|
@@ -22,7 +22,7 @@ import { fetchDeviceStatus } from '../lib/devices.js';
|
|
|
22
22
|
import { isDestructiveCommand } from './destructive.js';
|
|
23
23
|
import { classifyMqttPayload, evaluateConditions, matchesMqttTrigger, } from './matcher.js';
|
|
24
24
|
import { ThrottleGate, parseMaxPerMs } from './throttle.js';
|
|
25
|
-
import { executeRuleAction } from './action.js';
|
|
25
|
+
import { executeRuleAction, parseRuleCommand } from './action.js';
|
|
26
26
|
import { CronScheduler } from './cron-scheduler.js';
|
|
27
27
|
import { WebhookListener, DEFAULT_WEBHOOK_PORT } from './webhook-listener.js';
|
|
28
28
|
import { isCronTrigger, isMqttTrigger, isWebhookTrigger, } from './types.js';
|
|
@@ -102,6 +102,88 @@ export function lintRules(automation) {
|
|
|
102
102
|
message: `throttle.max_per "${r.throttle.max_per}" is not a valid duration.`,
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
+
if (r.throttle.dedupe_window) {
|
|
106
|
+
try {
|
|
107
|
+
parseMaxPerMs(r.throttle.dedupe_window);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
issues.push({
|
|
111
|
+
rule: r.name,
|
|
112
|
+
severity: 'error',
|
|
113
|
+
code: 'invalid-dedupe-window',
|
|
114
|
+
message: `throttle.dedupe_window "${r.throttle.dedupe_window}" is not a valid duration.`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// cooldown field validation
|
|
120
|
+
if (r.cooldown) {
|
|
121
|
+
try {
|
|
122
|
+
parseMaxPerMs(r.cooldown);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
issues.push({
|
|
126
|
+
rule: r.name,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
code: 'invalid-cooldown',
|
|
129
|
+
message: `cooldown "${r.cooldown}" is not a valid duration (expected e.g. "10m", "1h").`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (r.throttle?.max_per) {
|
|
133
|
+
issues.push({
|
|
134
|
+
rule: r.name,
|
|
135
|
+
severity: 'warning',
|
|
136
|
+
code: 'cooldown-throttle-overlap',
|
|
137
|
+
message: `Both "cooldown" and "throttle.max_per" are set. "cooldown" takes precedence.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// requires_stable_for field validation
|
|
142
|
+
if (r.requires_stable_for) {
|
|
143
|
+
try {
|
|
144
|
+
parseMaxPerMs(r.requires_stable_for);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
issues.push({
|
|
148
|
+
rule: r.name,
|
|
149
|
+
severity: 'error',
|
|
150
|
+
code: 'invalid-requires-stable-for',
|
|
151
|
+
message: `requires_stable_for "${r.requires_stable_for}" is not a valid duration.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// hysteresis field validation
|
|
156
|
+
if (r.hysteresis) {
|
|
157
|
+
try {
|
|
158
|
+
parseMaxPerMs(r.hysteresis);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
issues.push({
|
|
162
|
+
rule: r.name,
|
|
163
|
+
severity: 'error',
|
|
164
|
+
code: 'invalid-hysteresis',
|
|
165
|
+
message: `hysteresis "${r.hysteresis}" is not a valid duration (expected e.g. "10m", "1h").`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (r.requires_stable_for) {
|
|
169
|
+
issues.push({
|
|
170
|
+
rule: r.name,
|
|
171
|
+
severity: 'warning',
|
|
172
|
+
code: 'hysteresis-requires-stable-overlap',
|
|
173
|
+
message: `Both "hysteresis" and "requires_stable_for" are set. "hysteresis" takes precedence.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// maxFiringsPerHour field validation
|
|
178
|
+
if (r.maxFiringsPerHour !== undefined) {
|
|
179
|
+
if (!Number.isInteger(r.maxFiringsPerHour) || r.maxFiringsPerHour < 1) {
|
|
180
|
+
issues.push({
|
|
181
|
+
rule: r.name,
|
|
182
|
+
severity: 'error',
|
|
183
|
+
code: 'invalid-maxFiringsPerHour',
|
|
184
|
+
message: `maxFiringsPerHour must be a positive integer (got ${r.maxFiringsPerHour}).`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
105
187
|
}
|
|
106
188
|
const enabled = r.enabled !== false;
|
|
107
189
|
const hasError = issues.some((i) => i.severity === 'error');
|
|
@@ -126,6 +208,8 @@ export class RulesEngine {
|
|
|
126
208
|
rules;
|
|
127
209
|
aliases;
|
|
128
210
|
throttle = new ThrottleGate();
|
|
211
|
+
/** hysteresis / requires_stable_for: tracks when each (rule::device) trigger was first seen. */
|
|
212
|
+
hysteresisFirstSeen = new Map();
|
|
129
213
|
unsubscribeMessage = null;
|
|
130
214
|
unsubscribeState = null;
|
|
131
215
|
cronScheduler = null;
|
|
@@ -341,6 +425,24 @@ export class RulesEngine {
|
|
|
341
425
|
async ingestMqttForTest(payload) {
|
|
342
426
|
await this.enqueue(() => this.onMqttMessage(payload, { preParsed: true }));
|
|
343
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Dispatch a pre-built EngineEvent through all matching MQTT rules.
|
|
430
|
+
* Used by tests that need full control over the event timestamp (e.g.
|
|
431
|
+
* hysteresis tests that advance time manually).
|
|
432
|
+
*/
|
|
433
|
+
async ingestEventForTest(event) {
|
|
434
|
+
for (const rule of this.rules) {
|
|
435
|
+
if (!isMqttTrigger(rule.when))
|
|
436
|
+
continue;
|
|
437
|
+
const resolvedFilter = rule.when.device
|
|
438
|
+
? this.aliases[rule.when.device] ?? rule.when.device
|
|
439
|
+
: undefined;
|
|
440
|
+
if (!matchesMqttTrigger(rule.when, event, resolvedFilter))
|
|
441
|
+
continue;
|
|
442
|
+
this.stats.eventsProcessed++;
|
|
443
|
+
await this.enqueue(() => this.dispatchRule(rule, event));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
344
446
|
/**
|
|
345
447
|
* Fire a cron rule directly without needing the scheduler/timers.
|
|
346
448
|
* Used by tests that want to exercise the dispatch pipeline without
|
|
@@ -478,6 +580,13 @@ export class RulesEngine {
|
|
|
478
580
|
fetchStatus,
|
|
479
581
|
});
|
|
480
582
|
if (!cond.matched) {
|
|
583
|
+
// If conditions are not met, the trigger is no longer "continuously stable" —
|
|
584
|
+
// reset the hysteresis clock so intermittent satisfaction never accumulates.
|
|
585
|
+
const hasHysteresis = rule.hysteresis ?? rule.requires_stable_for;
|
|
586
|
+
if (hasHysteresis) {
|
|
587
|
+
const hysteresisKey = `${rule.name}::${event.deviceId ?? ''}`;
|
|
588
|
+
this.hysteresisFirstSeen.delete(hysteresisKey);
|
|
589
|
+
}
|
|
481
590
|
if (cond.unsupported.length > 0) {
|
|
482
591
|
writeAudit({
|
|
483
592
|
t: event.t.toISOString(),
|
|
@@ -504,9 +613,13 @@ export class RulesEngine {
|
|
|
504
613
|
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'conditions-failed', deviceId: event.deviceId, reason: cond.failures.join('; ') });
|
|
505
614
|
return;
|
|
506
615
|
}
|
|
507
|
-
|
|
616
|
+
// cooldown takes precedence over throttle.max_per when both are set.
|
|
617
|
+
const effectiveMaxPerMs = rule.cooldown
|
|
618
|
+
? parseMaxPerMs(rule.cooldown)
|
|
619
|
+
: rule.throttle ? parseMaxPerMs(rule.throttle.max_per) : null;
|
|
620
|
+
const dedupeWindowMs = rule.throttle?.dedupe_window ? parseMaxPerMs(rule.throttle.dedupe_window) : null;
|
|
508
621
|
const throttleKey = event.deviceId;
|
|
509
|
-
const check = this.throttle.check(rule.name,
|
|
622
|
+
const check = this.throttle.check(rule.name, effectiveMaxPerMs, event.t.getTime(), throttleKey, dedupeWindowMs);
|
|
510
623
|
if (!check.allowed) {
|
|
511
624
|
this.stats.throttled++;
|
|
512
625
|
writeAudit({
|
|
@@ -524,13 +637,87 @@ export class RulesEngine {
|
|
|
524
637
|
matchedDevice: event.deviceId,
|
|
525
638
|
fireId,
|
|
526
639
|
reason: check.nextAllowedAt
|
|
527
|
-
?
|
|
528
|
-
: 'throttled',
|
|
640
|
+
? `${check.dedupedBy ?? 'throttled'} — next allowed at ${new Date(check.nextAllowedAt).toISOString()}`
|
|
641
|
+
: check.dedupedBy ?? 'throttled',
|
|
529
642
|
},
|
|
530
643
|
});
|
|
531
644
|
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId });
|
|
532
645
|
return;
|
|
533
646
|
}
|
|
647
|
+
// hysteresis / requires_stable_for: require the trigger to have been continuously
|
|
648
|
+
// observed for the specified duration before firing.
|
|
649
|
+
const hysteresisMs = rule.hysteresis
|
|
650
|
+
? parseMaxPerMs(rule.hysteresis)
|
|
651
|
+
: rule.requires_stable_for ? parseMaxPerMs(rule.requires_stable_for) : null;
|
|
652
|
+
if (hysteresisMs !== null) {
|
|
653
|
+
const hysteresisKey = `${rule.name}::${event.deviceId ?? ''}`;
|
|
654
|
+
const firstSeen = this.hysteresisFirstSeen.get(hysteresisKey);
|
|
655
|
+
const now = event.t.getTime();
|
|
656
|
+
if (firstSeen === undefined) {
|
|
657
|
+
this.hysteresisFirstSeen.set(hysteresisKey, now);
|
|
658
|
+
writeAudit({
|
|
659
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
660
|
+
deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '',
|
|
661
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
662
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `hysteresis — first observation, waiting ${hysteresisMs}ms for stability` },
|
|
663
|
+
});
|
|
664
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'hysteresis-first-seen' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (now - firstSeen < hysteresisMs) {
|
|
668
|
+
writeAudit({
|
|
669
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
670
|
+
deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '',
|
|
671
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
672
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `hysteresis — stable for ${now - firstSeen}ms of required ${hysteresisMs}ms` },
|
|
673
|
+
});
|
|
674
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'hysteresis-not-stable' });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// Stable long enough — clear so the next trigger starts fresh.
|
|
678
|
+
this.hysteresisFirstSeen.delete(hysteresisKey);
|
|
679
|
+
}
|
|
680
|
+
// maxFiringsPerHour: count-based rate cap over a rolling 1-hour window.
|
|
681
|
+
if (rule.maxFiringsPerHour !== undefined) {
|
|
682
|
+
const countCheck = this.throttle.checkMaxFirings(rule.name, rule.maxFiringsPerHour, 3_600_000, event.t.getTime(), event.deviceId);
|
|
683
|
+
if (!countCheck.allowed) {
|
|
684
|
+
this.stats.throttled++;
|
|
685
|
+
writeAudit({
|
|
686
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
687
|
+
deviceId: event.deviceId ?? 'unknown', command: rule.then[0]?.command ?? '',
|
|
688
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
689
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `maxFiringsPerHour — ${countCheck.count}/${countCheck.max} in last hour` },
|
|
690
|
+
});
|
|
691
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'maxFiringsPerHour' });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// suppressIfAlreadyDesired: skip if device's live state already matches the command outcome.
|
|
696
|
+
if (rule.suppressIfAlreadyDesired) {
|
|
697
|
+
const firstAction = rule.then[0];
|
|
698
|
+
const parsed = firstAction ? parseRuleCommand(firstAction.command) : null;
|
|
699
|
+
const verb = parsed?.verb ?? firstAction?.command;
|
|
700
|
+
if ((verb === 'turnOn' || verb === 'turnOff') && (firstAction?.device || event.deviceId)) {
|
|
701
|
+
const targetId = firstAction?.device ? (this.aliases[firstAction.device] ?? firstAction.device) : event.deviceId;
|
|
702
|
+
try {
|
|
703
|
+
const deviceStatus = await fetchStatus(targetId);
|
|
704
|
+
const powerState = deviceStatus['powerState'];
|
|
705
|
+
if ((verb === 'turnOn' && powerState === 'on') || (verb === 'turnOff' && powerState === 'off')) {
|
|
706
|
+
writeAudit({
|
|
707
|
+
t: event.t.toISOString(), kind: 'rule-throttled',
|
|
708
|
+
deviceId: targetId, command: verb ?? '',
|
|
709
|
+
parameter: null, commandType: 'command', dryRun: true, result: 'ok',
|
|
710
|
+
rule: { name: rule.name, triggerSource: rule.when.source, matchedDevice: event.deviceId, fireId, reason: `suppressIfAlreadyDesired — powerState already "${powerState}"` },
|
|
711
|
+
});
|
|
712
|
+
this.opts.onFire?.({ ruleName: rule.name, fireId, status: 'throttled', deviceId: event.deviceId, reason: 'already-desired' });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// best-effort — if status fetch fails, proceed with the action
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
534
721
|
let fired = false;
|
|
535
722
|
let allDry = true;
|
|
536
723
|
for (const action of rule.then) {
|
|
@@ -561,6 +748,9 @@ export class RulesEngine {
|
|
|
561
748
|
else
|
|
562
749
|
this.stats.fires++;
|
|
563
750
|
this.throttle.record(rule.name, event.t.getTime(), throttleKey);
|
|
751
|
+
if (rule.maxFiringsPerHour !== undefined) {
|
|
752
|
+
this.throttle.recordFire(rule.name, event.t.getTime(), throttleKey);
|
|
753
|
+
}
|
|
564
754
|
this.opts.onFire?.({ ruleName: rule.name, fireId, status: allDry ? 'dry' : 'fired', deviceId: event.deviceId });
|
|
565
755
|
}
|
|
566
756
|
}
|
package/dist/rules/suggest.js
CHANGED
|
@@ -80,7 +80,7 @@ export function suggestRule(opts) {
|
|
|
80
80
|
const then = actionDevices.length > 0
|
|
81
81
|
? actionDevices.map((d) => ({
|
|
82
82
|
command: `devices command <id> ${command}`,
|
|
83
|
-
device: d.
|
|
83
|
+
device: d.id,
|
|
84
84
|
}))
|
|
85
85
|
: [{ command: `devices command <id> ${command}` }];
|
|
86
86
|
const rule = {
|