@switchbot/openapi-cli 3.0.0 → 3.1.0
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 +108 -12
- 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 +367 -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 +88 -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/conflict-analyzer.js +203 -0
- package/dist/rules/engine.js +195 -5
- package/dist/rules/throttle.js +42 -4
- 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
|
@@ -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,
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
/** Known opposing command pairs (order-independent). */
|
|
29
|
+
const OPPOSING_PAIRS = [
|
|
30
|
+
['turnOn', 'turnOff'],
|
|
31
|
+
['lock', 'unlock'],
|
|
32
|
+
['open', 'close'],
|
|
33
|
+
['openDoor', 'closeDoor'],
|
|
34
|
+
['openCurtain', 'closeCurtain'],
|
|
35
|
+
['turnOn', 'standby'],
|
|
36
|
+
['brightnessUp', 'brightnessDown'],
|
|
37
|
+
['volumeUp', 'volumeDown'],
|
|
38
|
+
['fanSpeedUp', 'fanSpeedDown'],
|
|
39
|
+
];
|
|
40
|
+
function commandsAreOpposing(a, b) {
|
|
41
|
+
for (const [x, y] of OPPOSING_PAIRS) {
|
|
42
|
+
if ((a === x && b === y) || (a === y && b === x))
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function extractDeviceFromAction(action) {
|
|
48
|
+
return action.device ?? null;
|
|
49
|
+
}
|
|
50
|
+
function extractCommandVerb(command) {
|
|
51
|
+
// command strings are like "devices command <id> turnOn" — extract last token
|
|
52
|
+
const parts = command.trim().split(/\s+/);
|
|
53
|
+
return parts[parts.length - 1] ?? command;
|
|
54
|
+
}
|
|
55
|
+
function effectiveCooldownMs(rule) {
|
|
56
|
+
if (rule.cooldown) {
|
|
57
|
+
try {
|
|
58
|
+
return parseMaxPerMs(rule.cooldown);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (rule.throttle?.max_per) {
|
|
65
|
+
try {
|
|
66
|
+
return parseMaxPerMs(rule.throttle.max_per);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function hasTimeBetweenGuard(conditions) {
|
|
75
|
+
if (!conditions)
|
|
76
|
+
return false;
|
|
77
|
+
for (const c of conditions) {
|
|
78
|
+
if (isTimeBetween(c))
|
|
79
|
+
return true;
|
|
80
|
+
if (isAllCondition(c) && hasTimeBetweenGuard(c.all))
|
|
81
|
+
return true;
|
|
82
|
+
if (isAnyCondition(c) && hasTimeBetweenGuard(c.any))
|
|
83
|
+
return true;
|
|
84
|
+
if (isNotCondition(c) && hasTimeBetweenGuard([c.not]))
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
export function analyzeConflicts(rules, quietHours) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
const active = rules.filter((r) => r.enabled !== false);
|
|
92
|
+
// 1. Opposing-action pairs on the same device
|
|
93
|
+
for (let i = 0; i < active.length; i++) {
|
|
94
|
+
for (let j = i + 1; j < active.length; j++) {
|
|
95
|
+
const a = active[i];
|
|
96
|
+
const b = active[j];
|
|
97
|
+
// Only flag when they share the same trigger source (otherwise they
|
|
98
|
+
// can't race each other in normal operation).
|
|
99
|
+
if (a.when.source !== b.when.source)
|
|
100
|
+
continue;
|
|
101
|
+
const cooldownA = effectiveCooldownMs(a);
|
|
102
|
+
const cooldownB = effectiveCooldownMs(b);
|
|
103
|
+
// If both rules have meaningful cooldowns (≥ 5 minutes), the risk is
|
|
104
|
+
// low — skip.
|
|
105
|
+
const bothThrottled = cooldownA !== null && cooldownA >= 5 * 60_000 &&
|
|
106
|
+
cooldownB !== null && cooldownB >= 5 * 60_000;
|
|
107
|
+
if (bothThrottled)
|
|
108
|
+
continue;
|
|
109
|
+
for (const actionA of a.then) {
|
|
110
|
+
for (const actionB of b.then) {
|
|
111
|
+
const deviceA = extractDeviceFromAction(actionA);
|
|
112
|
+
const deviceB = extractDeviceFromAction(actionB);
|
|
113
|
+
// Skip if devices can't be compared.
|
|
114
|
+
if (!deviceA || !deviceB || deviceA !== deviceB)
|
|
115
|
+
continue;
|
|
116
|
+
const verbA = extractCommandVerb(actionA.command);
|
|
117
|
+
const verbB = extractCommandVerb(actionB.command);
|
|
118
|
+
if (commandsAreOpposing(verbA, verbB)) {
|
|
119
|
+
const noThrottle = cooldownA === null || cooldownB === null;
|
|
120
|
+
findings.push({
|
|
121
|
+
severity: noThrottle ? 'warning' : 'info',
|
|
122
|
+
code: 'opposing-actions',
|
|
123
|
+
message: `Rules "${a.name}" and "${b.name}" issue opposing commands (${verbA} / ${verbB}) on device "${deviceA}" via the same trigger source.`,
|
|
124
|
+
rules: [a.name, b.name],
|
|
125
|
+
hint: noThrottle
|
|
126
|
+
? 'Add a "cooldown" or "throttle.max_per" to both rules to prevent rapid state oscillation.'
|
|
127
|
+
: undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// 2. High-frequency MQTT catch-all rules without throttle
|
|
135
|
+
for (const rule of active) {
|
|
136
|
+
if (rule.when.source !== 'mqtt')
|
|
137
|
+
continue;
|
|
138
|
+
const event = rule.when.event;
|
|
139
|
+
const isHighFreq = event === 'device.shadow' || event === '*';
|
|
140
|
+
if (!isHighFreq)
|
|
141
|
+
continue;
|
|
142
|
+
const cooldown = effectiveCooldownMs(rule);
|
|
143
|
+
if (cooldown === null) {
|
|
144
|
+
findings.push({
|
|
145
|
+
severity: 'warning',
|
|
146
|
+
code: 'high-frequency-no-throttle',
|
|
147
|
+
message: `Rule "${rule.name}" listens on "${event}" (high-frequency catch-all) with no throttle/cooldown. This can rapidly exhaust the daily API quota.`,
|
|
148
|
+
rules: [rule.name],
|
|
149
|
+
hint: 'Add "cooldown: 1m" or "throttle: { max_per: 1m }" to rate-limit this rule.',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else if (cooldown < 30_000) {
|
|
153
|
+
findings.push({
|
|
154
|
+
severity: 'info',
|
|
155
|
+
code: 'high-frequency-low-throttle',
|
|
156
|
+
message: `Rule "${rule.name}" listens on "${event}" with a throttle under 30 s. Consider increasing to at least 1 m to protect API quota.`,
|
|
157
|
+
rules: [rule.name],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 3. Destructive actions in rules (engine blocks these at runtime, but
|
|
162
|
+
// surface early with clear guidance).
|
|
163
|
+
for (const rule of active) {
|
|
164
|
+
for (let i = 0; i < rule.then.length; i++) {
|
|
165
|
+
const verb = extractCommandVerb(rule.then[i].command);
|
|
166
|
+
if (isDestructiveCommand(verb)) {
|
|
167
|
+
findings.push({
|
|
168
|
+
severity: 'error',
|
|
169
|
+
code: 'destructive-action-in-rule',
|
|
170
|
+
message: `Rule "${rule.name}" then[${i}] contains destructive command "${verb}". The engine blocks this at runtime.`,
|
|
171
|
+
rules: [rule.name],
|
|
172
|
+
hint: 'Remove the destructive command or replace it with a non-destructive alternative.',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 4. Event-driven rules with no time_between guard when quiet_hours is defined.
|
|
178
|
+
// Cron rules fire on an explicit schedule so their overlap with quiet hours
|
|
179
|
+
// requires schedule analysis — flag those separately when needed.
|
|
180
|
+
if (quietHours?.start && quietHours?.end) {
|
|
181
|
+
for (const rule of active) {
|
|
182
|
+
if (rule.when.source === 'cron')
|
|
183
|
+
continue;
|
|
184
|
+
if (!hasTimeBetweenGuard(rule.conditions)) {
|
|
185
|
+
findings.push({
|
|
186
|
+
severity: 'warning',
|
|
187
|
+
code: 'no-quiet-hours-guard',
|
|
188
|
+
message: `Rule "${rule.name}" (${rule.when.source} trigger) has no time_between condition and may fire during quiet hours (${quietHours.start}–${quietHours.end}).`,
|
|
189
|
+
rules: [rule.name],
|
|
190
|
+
hint: `Add a conditions entry "{ time_between: ['${quietHours.end}', '${quietHours.start}'] }" to block this rule during quiet hours.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
196
|
+
for (const f of findings)
|
|
197
|
+
counts[f.severity]++;
|
|
198
|
+
return {
|
|
199
|
+
findings,
|
|
200
|
+
counts,
|
|
201
|
+
clean: counts.error === 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
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/throttle.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Semantics:
|
|
5
5
|
* - `max_per: "10m"` → a rule may fire at most once every 10 minutes
|
|
6
6
|
* per (rule, deviceId) pair.
|
|
7
|
+
* - `dedupe_window: "5s"` → suppress fires whose key already fired
|
|
8
|
+
* within the window (collapses rapid sensor bursts into one action).
|
|
7
9
|
* - Fires that would violate the window are **suppressed** (not
|
|
8
10
|
* queued) and surface as `{ allowed: false, reason: 'throttled' }`.
|
|
9
11
|
* - When a rule has no `throttle` block, `ThrottleGate.check` returns
|
|
@@ -26,6 +28,8 @@ export function parseMaxPerMs(expr) {
|
|
|
26
28
|
}
|
|
27
29
|
export class ThrottleGate {
|
|
28
30
|
lastFireAt = new Map();
|
|
31
|
+
/** Sliding-window fire-time log for count-based maxFiringsPerHour. */
|
|
32
|
+
fireTimes = new Map();
|
|
29
33
|
keyOf(ruleName, deviceId) {
|
|
30
34
|
return deviceId ? `${ruleName}::${deviceId}` : ruleName;
|
|
31
35
|
}
|
|
@@ -34,21 +38,45 @@ export class ThrottleGate {
|
|
|
34
38
|
* actually runs so that dry-run / throttled paths don't bump the
|
|
35
39
|
* window.
|
|
36
40
|
*/
|
|
37
|
-
check(ruleName, windowMs, now, deviceId) {
|
|
38
|
-
if (windowMs === null || windowMs <= 0)
|
|
39
|
-
return { allowed: true };
|
|
41
|
+
check(ruleName, windowMs, now, deviceId, dedupeWindowMs) {
|
|
40
42
|
const key = this.keyOf(ruleName, deviceId);
|
|
41
43
|
const last = this.lastFireAt.get(key);
|
|
44
|
+
// dedupe_window check: suppress if last fire was within this (typically smaller) window
|
|
45
|
+
if (dedupeWindowMs !== null && dedupeWindowMs !== undefined && dedupeWindowMs > 0) {
|
|
46
|
+
if (last !== undefined) {
|
|
47
|
+
const dedupeEnd = last + dedupeWindowMs;
|
|
48
|
+
if (now < dedupeEnd) {
|
|
49
|
+
return { allowed: false, lastFiredAt: last, nextAllowedAt: dedupeEnd, dedupedBy: 'dedupe_window' };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// max_per / cooldown check
|
|
54
|
+
if (windowMs === null || windowMs <= 0)
|
|
55
|
+
return { allowed: true, lastFiredAt: last };
|
|
42
56
|
if (last === undefined)
|
|
43
57
|
return { allowed: true };
|
|
44
58
|
const earliest = last + windowMs;
|
|
45
59
|
if (now >= earliest)
|
|
46
60
|
return { allowed: true, lastFiredAt: last };
|
|
47
|
-
return { allowed: false, lastFiredAt: last, nextAllowedAt: earliest };
|
|
61
|
+
return { allowed: false, lastFiredAt: last, nextAllowedAt: earliest, dedupedBy: windowMs > 0 ? 'max_per' : undefined };
|
|
48
62
|
}
|
|
49
63
|
record(ruleName, now, deviceId) {
|
|
50
64
|
this.lastFireAt.set(this.keyOf(ruleName, deviceId), now);
|
|
51
65
|
}
|
|
66
|
+
/** Count-based check: has the rule fired >= maxCount times in the last windowMs? */
|
|
67
|
+
checkMaxFirings(ruleName, maxCount, windowMs, now, deviceId) {
|
|
68
|
+
const key = this.keyOf(ruleName, deviceId);
|
|
69
|
+
const times = (this.fireTimes.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
70
|
+
this.fireTimes.set(key, times);
|
|
71
|
+
return { allowed: times.length < maxCount, count: times.length, max: maxCount };
|
|
72
|
+
}
|
|
73
|
+
/** Record a count-based fire (call alongside record()). */
|
|
74
|
+
recordFire(ruleName, now, deviceId) {
|
|
75
|
+
const key = this.keyOf(ruleName, deviceId);
|
|
76
|
+
const times = this.fireTimes.get(key) ?? [];
|
|
77
|
+
times.push(now);
|
|
78
|
+
this.fireTimes.set(key, times);
|
|
79
|
+
}
|
|
52
80
|
/** Drop everything — used by engine.reload when a rule is removed. */
|
|
53
81
|
forget(ruleName) {
|
|
54
82
|
const prefix = `${ruleName}::`;
|
|
@@ -56,6 +84,10 @@ export class ThrottleGate {
|
|
|
56
84
|
if (k === ruleName || k.startsWith(prefix))
|
|
57
85
|
this.lastFireAt.delete(k);
|
|
58
86
|
}
|
|
87
|
+
for (const k of this.fireTimes.keys()) {
|
|
88
|
+
if (k === ruleName || k.startsWith(prefix))
|
|
89
|
+
this.fireTimes.delete(k);
|
|
90
|
+
}
|
|
59
91
|
}
|
|
60
92
|
/**
|
|
61
93
|
* Drop every window whose rule name isn't in the given set — used by
|
|
@@ -70,6 +102,12 @@ export class ThrottleGate {
|
|
|
70
102
|
if (!ruleNames.has(ruleName))
|
|
71
103
|
this.lastFireAt.delete(k);
|
|
72
104
|
}
|
|
105
|
+
for (const k of this.fireTimes.keys()) {
|
|
106
|
+
const sep = k.indexOf('::');
|
|
107
|
+
const ruleName = sep === -1 ? k : k.slice(0, sep);
|
|
108
|
+
if (!ruleNames.has(ruleName))
|
|
109
|
+
this.fireTimes.delete(k);
|
|
110
|
+
}
|
|
73
111
|
}
|
|
74
112
|
/** Test helper — exposes the underlying size. */
|
|
75
113
|
size() {
|