@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
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() {
|
package/dist/utils/audit.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { getAuditLog } from './flags.js';
|
|
5
|
+
export const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
4
6
|
/**
|
|
5
7
|
* Bump when breaking changes to the audit line shape land.
|
|
6
8
|
*
|
|
@@ -20,7 +22,9 @@ function resolveAuditPath() {
|
|
|
20
22
|
return path.resolve(flag);
|
|
21
23
|
}
|
|
22
24
|
export function writeAudit(entry) {
|
|
23
|
-
|
|
25
|
+
let file = resolveAuditPath();
|
|
26
|
+
if (!file && entry.planId)
|
|
27
|
+
file = DEFAULT_AUDIT_PATH;
|
|
24
28
|
if (!file)
|
|
25
29
|
return;
|
|
26
30
|
const dir = path.dirname(file);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health report utilities — collects process, quota, audit, and circuit
|
|
3
|
+
* breaker state into a single snapshot suitable for /health-style checks
|
|
4
|
+
* and Prometheus-compatible metrics export.
|
|
5
|
+
*
|
|
6
|
+
* No side effects: reading is safe to call from any context.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { todayUsage, DAILY_QUOTA } from './quota.js';
|
|
12
|
+
import { readAudit } from './audit.js';
|
|
13
|
+
import { apiCircuitBreaker } from '../api/client.js';
|
|
14
|
+
const DEFAULT_AUDIT_PATH = path.join(os.homedir(), '.switchbot', 'audit.log');
|
|
15
|
+
const AUDIT_ERROR_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h
|
|
16
|
+
export function getHealthReport(auditPath = DEFAULT_AUDIT_PATH) {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
// Process info
|
|
19
|
+
const procHealth = {
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
22
|
+
platform: process.platform,
|
|
23
|
+
nodeVersion: process.version,
|
|
24
|
+
memoryMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
|
25
|
+
};
|
|
26
|
+
// Quota
|
|
27
|
+
const { total: used } = todayUsage(now);
|
|
28
|
+
const pct = Math.round((used / DAILY_QUOTA) * 100);
|
|
29
|
+
const quotaHealth = {
|
|
30
|
+
used,
|
|
31
|
+
limit: DAILY_QUOTA,
|
|
32
|
+
percentUsed: pct,
|
|
33
|
+
remaining: Math.max(0, DAILY_QUOTA - used),
|
|
34
|
+
status: pct >= 90 ? 'critical' : pct >= 70 ? 'warn' : 'ok',
|
|
35
|
+
};
|
|
36
|
+
// Audit error rate (last 24h)
|
|
37
|
+
let auditHealth;
|
|
38
|
+
if (!fs.existsSync(auditPath)) {
|
|
39
|
+
auditHealth = { present: false, recentErrors: 0, recentTotal: 0, errorRatePercent: 0, status: 'ok' };
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const entries = readAudit(auditPath);
|
|
43
|
+
const windowStart = now.getTime() - AUDIT_ERROR_WINDOW_MS;
|
|
44
|
+
const recent = entries.filter((e) => new Date(e.t).getTime() >= windowStart);
|
|
45
|
+
const errors = recent.filter((e) => e.result === 'error').length;
|
|
46
|
+
const total = recent.length;
|
|
47
|
+
const errorRate = total > 0 ? Math.round((errors / total) * 100) : 0;
|
|
48
|
+
auditHealth = {
|
|
49
|
+
present: true,
|
|
50
|
+
recentErrors: errors,
|
|
51
|
+
recentTotal: total,
|
|
52
|
+
errorRatePercent: errorRate,
|
|
53
|
+
status: errorRate >= 30 ? 'warn' : 'ok',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Circuit breaker
|
|
57
|
+
const cbStats = apiCircuitBreaker.getStats();
|
|
58
|
+
const circuitHealth = {
|
|
59
|
+
name: apiCircuitBreaker.name,
|
|
60
|
+
state: cbStats.state,
|
|
61
|
+
failures: cbStats.failures,
|
|
62
|
+
status: cbStats.state === 'open' ? 'open' : 'ok',
|
|
63
|
+
};
|
|
64
|
+
// Overall
|
|
65
|
+
const degraded = quotaHealth.status !== 'ok' ||
|
|
66
|
+
auditHealth.status !== 'ok' ||
|
|
67
|
+
circuitHealth.status !== 'ok';
|
|
68
|
+
const down = circuitHealth.status === 'open';
|
|
69
|
+
const overall = down ? 'down' : degraded ? 'degraded' : 'ok';
|
|
70
|
+
return {
|
|
71
|
+
generatedAt: now.toISOString(),
|
|
72
|
+
overall,
|
|
73
|
+
process: procHealth,
|
|
74
|
+
quota: quotaHealth,
|
|
75
|
+
audit: auditHealth,
|
|
76
|
+
circuit: circuitHealth,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Render a minimal Prometheus-compatible text metrics export.
|
|
81
|
+
* Only includes the most actionable gauges.
|
|
82
|
+
*/
|
|
83
|
+
export function toPrometheusText(report) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
const push = (name, value, help) => {
|
|
86
|
+
if (help)
|
|
87
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
88
|
+
lines.push(`# TYPE ${name} gauge`);
|
|
89
|
+
lines.push(`${name} ${value}`);
|
|
90
|
+
};
|
|
91
|
+
push('switchbot_quota_used_total', report.quota.used, 'SwitchBot API requests used today');
|
|
92
|
+
push('switchbot_quota_remaining', report.quota.remaining, 'SwitchBot API quota remaining today');
|
|
93
|
+
push('switchbot_quota_percent_used', report.quota.percentUsed, 'SwitchBot API quota percent used today');
|
|
94
|
+
push('switchbot_audit_recent_errors', report.audit.recentErrors, 'Audit log errors in the last 24h');
|
|
95
|
+
push('switchbot_audit_error_rate_percent', report.audit.errorRatePercent, 'Audit error rate percent (last 24h)');
|
|
96
|
+
push('switchbot_circuit_open', report.circuit.state === 'open' ? 1 : 0, 'API circuit breaker open (1=open, 0=closed/half-open)');
|
|
97
|
+
push('switchbot_circuit_failures', report.circuit.failures, 'Consecutive API failures recorded by circuit breaker');
|
|
98
|
+
push('switchbot_process_uptime_seconds', report.process.uptimeSeconds, 'Process uptime in seconds');
|
|
99
|
+
push('switchbot_process_memory_mb', report.process.memoryMb, 'Process RSS memory usage in MB');
|
|
100
|
+
return lines.join('\n') + '\n';
|
|
101
|
+
}
|
package/dist/utils/output.js
CHANGED
|
@@ -196,10 +196,40 @@ export function buildErrorPayload(error) {
|
|
|
196
196
|
kind: 'usage',
|
|
197
197
|
message: error.message,
|
|
198
198
|
errorClass: 'usage',
|
|
199
|
-
transient: false
|
|
199
|
+
transient: false,
|
|
200
200
|
};
|
|
201
|
-
if (error.context)
|
|
202
|
-
|
|
201
|
+
if (error.context) {
|
|
202
|
+
const ctx = error.context;
|
|
203
|
+
const { error: errorType, candidates, hint } = ctx;
|
|
204
|
+
if (errorType === 'ambiguous_name_match') {
|
|
205
|
+
payload.subKind = 'ambiguous-name-match';
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(candidates) && candidates.length > 0) {
|
|
208
|
+
const normalized = candidates
|
|
209
|
+
.map((c) => {
|
|
210
|
+
if (typeof c !== 'object' || c === null)
|
|
211
|
+
return null;
|
|
212
|
+
const o = c;
|
|
213
|
+
const name = typeof o.name === 'string' ? o.name
|
|
214
|
+
: typeof o.sceneName === 'string' ? o.sceneName
|
|
215
|
+
: '';
|
|
216
|
+
const match = { name };
|
|
217
|
+
if (typeof o.deviceId === 'string')
|
|
218
|
+
match.deviceId = o.deviceId;
|
|
219
|
+
if (typeof o.sceneId === 'string')
|
|
220
|
+
match.sceneId = o.sceneId;
|
|
221
|
+
return match;
|
|
222
|
+
})
|
|
223
|
+
.filter((c) => c !== null && c.name.length > 0);
|
|
224
|
+
if (normalized.length > 0)
|
|
225
|
+
payload.candidateMatches = normalized;
|
|
226
|
+
}
|
|
227
|
+
if (typeof hint === 'string') {
|
|
228
|
+
payload.resolutionHint = hint;
|
|
229
|
+
}
|
|
230
|
+
// Preserve full context for backward compatibility (including candidates / hint).
|
|
231
|
+
payload.context = ctx;
|
|
232
|
+
}
|
|
203
233
|
return payload;
|
|
204
234
|
}
|
|
205
235
|
if (error instanceof UsageError) {
|
|
@@ -286,31 +316,50 @@ export function handleError(error) {
|
|
|
286
316
|
}
|
|
287
317
|
if (payload.kind === 'usage') {
|
|
288
318
|
console.error(payload.message);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const names = ctx.candidates
|
|
319
|
+
if (Array.isArray(payload.candidateMatches) && payload.candidateMatches.length > 0) {
|
|
320
|
+
const names = payload.candidateMatches
|
|
292
321
|
.map((c) => {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const name = typeof o.name === 'string'
|
|
298
|
-
? o.name
|
|
299
|
-
: typeof o.sceneName === 'string' ? o.sceneName : undefined;
|
|
300
|
-
const id = typeof o.deviceId === 'string'
|
|
301
|
-
? o.deviceId
|
|
302
|
-
: typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
|
|
303
|
-
if (name && id)
|
|
304
|
-
return `${name} (${id})`;
|
|
305
|
-
return name ?? id ?? JSON.stringify(c);
|
|
306
|
-
}
|
|
307
|
-
return String(c);
|
|
322
|
+
const id = c.deviceId ?? c.sceneId;
|
|
323
|
+
if (c.name && id)
|
|
324
|
+
return `${c.name} (${id})`;
|
|
325
|
+
return c.name ?? id ?? JSON.stringify(c);
|
|
308
326
|
})
|
|
309
327
|
.slice(0, 6);
|
|
310
328
|
console.error(`Did you mean: ${names.join(', ')}?`);
|
|
311
329
|
}
|
|
312
|
-
|
|
313
|
-
|
|
330
|
+
else {
|
|
331
|
+
const ctx = payload.context;
|
|
332
|
+
if (ctx && Array.isArray(ctx.candidates) && ctx.candidates.length > 0) {
|
|
333
|
+
const names = ctx.candidates
|
|
334
|
+
.map((c) => {
|
|
335
|
+
if (typeof c === 'string')
|
|
336
|
+
return c;
|
|
337
|
+
if (c && typeof c === 'object') {
|
|
338
|
+
const o = c;
|
|
339
|
+
const name = typeof o.name === 'string'
|
|
340
|
+
? o.name
|
|
341
|
+
: typeof o.sceneName === 'string' ? o.sceneName : undefined;
|
|
342
|
+
const id = typeof o.deviceId === 'string'
|
|
343
|
+
? o.deviceId
|
|
344
|
+
: typeof o.sceneId === 'string' ? o.sceneId : typeof o.id === 'string' ? o.id : undefined;
|
|
345
|
+
if (name && id)
|
|
346
|
+
return `${name} (${id})`;
|
|
347
|
+
return name ?? id ?? JSON.stringify(c);
|
|
348
|
+
}
|
|
349
|
+
return String(c);
|
|
350
|
+
})
|
|
351
|
+
.slice(0, 6);
|
|
352
|
+
console.error(`Did you mean: ${names.join(', ')}?`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (payload.resolutionHint) {
|
|
356
|
+
console.error(payload.resolutionHint);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const ctx = payload.context;
|
|
360
|
+
if (ctx && typeof ctx.hint === 'string') {
|
|
361
|
+
console.error(ctx.hint);
|
|
362
|
+
}
|
|
314
363
|
}
|
|
315
364
|
process.exit(2);
|
|
316
365
|
}
|
package/dist/utils/retry.js
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* If the server returns a `Retry-After` header we always prefer it over our
|
|
10
10
|
* own backoff — the API explicitly told us when to come back.
|
|
11
|
+
*
|
|
12
|
+
* Circuit breaker:
|
|
13
|
+
* Prevents hammering a consistently-failing endpoint. Tracks consecutive
|
|
14
|
+
* failures; when `failureThreshold` is exceeded the circuit opens and
|
|
15
|
+
* subsequent calls fail immediately (with CircuitOpenError). After
|
|
16
|
+
* `resetTimeoutMs` the circuit enters half-open state: the next call is
|
|
17
|
+
* allowed as a probe — success closes it, failure re-opens it.
|
|
11
18
|
*/
|
|
12
19
|
const BASE_MS = 1_000;
|
|
13
20
|
const MAX_MS = 30_000;
|
|
@@ -57,3 +64,77 @@ export function nextRetryDelayMs(attempt, strategy, retryAfterHeader, now = Date
|
|
|
57
64
|
export function sleep(ms) {
|
|
58
65
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Thrown when a call is blocked because the circuit is open.
|
|
69
|
+
*/
|
|
70
|
+
export class CircuitOpenError extends Error {
|
|
71
|
+
circuitName;
|
|
72
|
+
nextAttemptMs;
|
|
73
|
+
constructor(circuitName, nextAttemptMs) {
|
|
74
|
+
super(`Circuit "${circuitName}" is open — too many recent failures. Next probe allowed in ${Math.ceil((nextAttemptMs - Date.now()) / 1000)}s.`);
|
|
75
|
+
this.circuitName = circuitName;
|
|
76
|
+
this.nextAttemptMs = nextAttemptMs;
|
|
77
|
+
this.name = 'CircuitOpenError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class CircuitBreaker {
|
|
81
|
+
name;
|
|
82
|
+
state = 'closed';
|
|
83
|
+
failures = 0;
|
|
84
|
+
lastOpenedAt = 0;
|
|
85
|
+
failureThreshold;
|
|
86
|
+
resetTimeoutMs;
|
|
87
|
+
constructor(name, opts = {}) {
|
|
88
|
+
this.name = name;
|
|
89
|
+
this.failureThreshold = opts.failureThreshold ?? 5;
|
|
90
|
+
this.resetTimeoutMs = opts.resetTimeoutMs ?? 60_000;
|
|
91
|
+
}
|
|
92
|
+
getState() {
|
|
93
|
+
this._maybeHalfOpen();
|
|
94
|
+
return this.state;
|
|
95
|
+
}
|
|
96
|
+
getStats() {
|
|
97
|
+
this._maybeHalfOpen();
|
|
98
|
+
return {
|
|
99
|
+
state: this.state,
|
|
100
|
+
failures: this.failures,
|
|
101
|
+
lastOpenedAt: this.lastOpenedAt,
|
|
102
|
+
nextProbeMs: this.state === 'open' ? this.lastOpenedAt + this.resetTimeoutMs : 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Check if a call is allowed. Throws `CircuitOpenError` when the circuit
|
|
107
|
+
* is open and the reset timeout hasn't elapsed. Call `recordSuccess()` or
|
|
108
|
+
* `recordFailure()` after the operation completes.
|
|
109
|
+
*/
|
|
110
|
+
checkAndAllow() {
|
|
111
|
+
this._maybeHalfOpen();
|
|
112
|
+
if (this.state === 'open') {
|
|
113
|
+
throw new CircuitOpenError(this.name, this.lastOpenedAt + this.resetTimeoutMs);
|
|
114
|
+
}
|
|
115
|
+
// closed or half-open: allow the call
|
|
116
|
+
}
|
|
117
|
+
recordSuccess() {
|
|
118
|
+
this.failures = 0;
|
|
119
|
+
this.state = 'closed';
|
|
120
|
+
}
|
|
121
|
+
recordFailure() {
|
|
122
|
+
this.failures++;
|
|
123
|
+
if (this.state === 'half-open' || this.failures >= this.failureThreshold) {
|
|
124
|
+
this.state = 'open';
|
|
125
|
+
this.lastOpenedAt = Date.now();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Reset to closed — useful for testing or manual recovery. */
|
|
129
|
+
reset() {
|
|
130
|
+
this.state = 'closed';
|
|
131
|
+
this.failures = 0;
|
|
132
|
+
this.lastOpenedAt = 0;
|
|
133
|
+
}
|
|
134
|
+
_maybeHalfOpen() {
|
|
135
|
+
if (this.state === 'open' &&
|
|
136
|
+
Date.now() >= this.lastOpenedAt + this.resetTimeoutMs) {
|
|
137
|
+
this.state = 'half-open';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"switchbot",
|