@switchbot/openapi-cli 2.0.1 → 2.2.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 +265 -39
- package/dist/commands/capabilities.js +37 -20
- package/dist/commands/devices.js +206 -66
- package/dist/commands/doctor.js +33 -0
- package/dist/commands/events.js +188 -1
- package/dist/commands/expand.js +20 -3
- package/dist/commands/mcp.js +59 -12
- package/dist/commands/plan.js +10 -2
- package/dist/commands/scenes.js +1 -1
- package/dist/commands/watch.js +13 -2
- package/dist/config.js +23 -0
- package/dist/index.js +5 -4
- package/dist/lib/devices.js +16 -1
- package/dist/mcp/device-history.js +66 -0
- package/dist/mcp/events-subscription.js +15 -12
- package/dist/mqtt/client.js +46 -50
- package/dist/mqtt/credential.js +29 -11
- package/dist/sinks/dispatcher.js +12 -0
- package/dist/sinks/file.js +19 -0
- package/dist/sinks/format.js +56 -0
- package/dist/sinks/homeassistant.js +44 -0
- package/dist/sinks/openclaw.js +33 -0
- package/dist/sinks/stdout.js +5 -0
- package/dist/sinks/telegram.js +28 -0
- package/dist/sinks/types.js +1 -0
- package/dist/sinks/webhook.js +22 -0
- package/dist/utils/flags.js +13 -12
- package/dist/utils/format.js +6 -5
- package/package.json +2 -2
package/dist/commands/expand.js
CHANGED
|
@@ -2,6 +2,7 @@ import { handleError, isJsonMode, printJson, UsageError } from '../utils/output.
|
|
|
2
2
|
import { getCachedDevice } from '../devices/cache.js';
|
|
3
3
|
import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
|
|
4
4
|
import { isDryRun } from '../utils/flags.js';
|
|
5
|
+
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
5
6
|
import { DryRunSignal } from '../api/client.js';
|
|
6
7
|
// ---- Mapping tables --------------------------------------------------------
|
|
7
8
|
const AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
@@ -85,8 +86,9 @@ export function registerExpandCommand(devices) {
|
|
|
85
86
|
devices
|
|
86
87
|
.command('expand')
|
|
87
88
|
.description('Send a command with semantic flags instead of raw positional parameters')
|
|
88
|
-
.argument('
|
|
89
|
-
.argument('
|
|
89
|
+
.argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
|
|
90
|
+
.argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
|
|
91
|
+
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId')
|
|
90
92
|
.option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)')
|
|
91
93
|
.option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary')
|
|
92
94
|
.option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high')
|
|
@@ -123,9 +125,24 @@ Examples:
|
|
|
123
125
|
$ switchbot devices expand <blindId> setPosition --direction up --angle 50
|
|
124
126
|
$ switchbot devices expand <relayId> setMode --channel 1 --mode edge
|
|
125
127
|
$ switchbot devices expand <acId> setAll --temp 22 --mode heat --fan auto --power on --dry-run
|
|
128
|
+
$ switchbot devices expand --name "客厅空调" setAll --temp 26 --mode cool --fan low --power on
|
|
126
129
|
`)
|
|
127
|
-
.action(async (
|
|
130
|
+
.action(async (deviceIdArg, commandArg, options) => {
|
|
131
|
+
let deviceId = '';
|
|
132
|
+
let command = '';
|
|
128
133
|
try {
|
|
134
|
+
// When --name is provided, Commander assigns the first positional to deviceIdArg
|
|
135
|
+
// and leaves commandArg undefined. Detect and shift.
|
|
136
|
+
let effectiveDeviceIdArg = deviceIdArg;
|
|
137
|
+
let effectiveCommand = commandArg;
|
|
138
|
+
if (options.name && deviceIdArg && !commandArg) {
|
|
139
|
+
effectiveCommand = deviceIdArg;
|
|
140
|
+
effectiveDeviceIdArg = undefined;
|
|
141
|
+
}
|
|
142
|
+
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
|
|
143
|
+
if (!effectiveCommand)
|
|
144
|
+
throw new UsageError('A command argument is required (setAll, setPosition, setMode).');
|
|
145
|
+
command = effectiveCommand;
|
|
129
146
|
const cached = getCachedDevice(deviceId);
|
|
130
147
|
const deviceType = cached?.type ?? '';
|
|
131
148
|
let parameter;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -8,11 +8,11 @@ import { fetchScenes, executeScene } from '../lib/scenes.js';
|
|
|
8
8
|
import { findCatalogEntry } from '../devices/catalog.js';
|
|
9
9
|
import { getCachedDevice } from '../devices/cache.js';
|
|
10
10
|
import { EventSubscriptionManager } from '../mcp/events-subscription.js';
|
|
11
|
+
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
11
12
|
import { todayUsage } from '../utils/quota.js';
|
|
12
13
|
import { describeCache } from '../devices/cache.js';
|
|
13
14
|
import { withRequestContext } from '../lib/request-context.js';
|
|
14
|
-
import { profileFilePath } from '../config.js';
|
|
15
|
-
import { getMqttConfig } from '../mqtt/credential.js';
|
|
15
|
+
import { profileFilePath, tryLoadConfig } from '../config.js';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
function mcpError(kind, code, message, options) {
|
|
18
18
|
const obj = { code, kind, message };
|
|
@@ -111,6 +111,40 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
111
111
|
structuredContent: { status: body },
|
|
112
112
|
};
|
|
113
113
|
});
|
|
114
|
+
// ---- get_device_history ----------------------------------------------------
|
|
115
|
+
server.registerTool('get_device_history', {
|
|
116
|
+
title: 'Get locally-persisted device state history',
|
|
117
|
+
description: 'Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). ' +
|
|
118
|
+
'No API call — zero quota cost. Use when you need recent historical readings or want to avoid a live API call. ' +
|
|
119
|
+
'Omit deviceId to list all devices with stored history.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
deviceId: z.string().optional().describe('Device MAC address (deviceId). Omit to list all devices with history.'),
|
|
122
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max history entries to return (default 20, max 100)'),
|
|
123
|
+
},
|
|
124
|
+
outputSchema: {
|
|
125
|
+
deviceId: z.string().optional(),
|
|
126
|
+
latest: z.unknown().optional(),
|
|
127
|
+
history: z.array(z.unknown()).optional(),
|
|
128
|
+
devices: z.array(z.object({ deviceId: z.string(), latest: z.unknown() })).optional(),
|
|
129
|
+
},
|
|
130
|
+
}, async ({ deviceId, limit }) => {
|
|
131
|
+
if (deviceId) {
|
|
132
|
+
const latest = deviceHistoryStore.getLatest(deviceId);
|
|
133
|
+
const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
|
|
134
|
+
const result = { deviceId, latest, history };
|
|
135
|
+
return {
|
|
136
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
137
|
+
structuredContent: result,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const ids = deviceHistoryStore.listDevices();
|
|
141
|
+
const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
|
|
142
|
+
const result = { devices };
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
145
|
+
structuredContent: result,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
114
148
|
// ---- send_command ---------------------------------------------------------
|
|
115
149
|
server.registerTool('send_command', {
|
|
116
150
|
title: 'Send a control command to a device',
|
|
@@ -350,7 +384,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
350
384
|
mqtt: z.object({
|
|
351
385
|
state: z.string(),
|
|
352
386
|
subscribers: z.number(),
|
|
353
|
-
}).optional().describe('MQTT connection state (
|
|
387
|
+
}).optional().describe('MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)'),
|
|
354
388
|
},
|
|
355
389
|
}, async () => {
|
|
356
390
|
const deviceList = await fetchDeviceList();
|
|
@@ -398,7 +432,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
398
432
|
server.registerResource('events', 'switchbot://events', {
|
|
399
433
|
title: 'SwitchBot real-time shadow events',
|
|
400
434
|
description: 'Recent device shadow-update events received via MQTT. Returns a JSON snapshot of the ring buffer. ' +
|
|
401
|
-
'State is "disabled" when
|
|
435
|
+
'State is "disabled" when REST credentials (SWITCHBOT_TOKEN + SWITCHBOT_SECRET) are not configured.',
|
|
402
436
|
mimeType: 'application/json',
|
|
403
437
|
}, (_uri) => {
|
|
404
438
|
const state = eventManager.getState();
|
|
@@ -419,7 +453,7 @@ export function registerMcpCommand(program) {
|
|
|
419
453
|
.command('mcp')
|
|
420
454
|
.description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools')
|
|
421
455
|
.addHelpText('after', `
|
|
422
|
-
The MCP server exposes
|
|
456
|
+
The MCP server exposes eight tools:
|
|
423
457
|
- list_devices fetch all physical + IR devices
|
|
424
458
|
- get_device_status live status for a physical device
|
|
425
459
|
- send_command control a device (destructive commands need confirm:true)
|
|
@@ -427,6 +461,12 @@ The MCP server exposes seven tools over stdio:
|
|
|
427
461
|
- run_scene execute a manual scene
|
|
428
462
|
- search_catalog offline catalog search by type/alias
|
|
429
463
|
- describe_device metadata + commands + (optionally) live status for one device
|
|
464
|
+
- account_overview single cold-start snapshot: devices + scenes + quota + cache + MQTT state
|
|
465
|
+
|
|
466
|
+
Resource (read-only):
|
|
467
|
+
- switchbot://events snapshot of recent MQTT shadow events from the ring buffer
|
|
468
|
+
Auto-provisioned from SWITCHBOT_TOKEN + SWITCHBOT_SECRET;
|
|
469
|
+
returns {state:"disabled"} when credentials are not configured.
|
|
430
470
|
|
|
431
471
|
Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
|
|
432
472
|
|
|
@@ -487,17 +527,17 @@ Inspect locally:
|
|
|
487
527
|
const { createServer } = await import('node:http');
|
|
488
528
|
const rateLimitMap = new Map();
|
|
489
529
|
// Initialize shared EventSubscriptionManager for event streaming.
|
|
490
|
-
//
|
|
491
|
-
//
|
|
530
|
+
// Credentials are auto-provisioned from the SwitchBot API using the
|
|
531
|
+
// account's token+secret — no extra MQTT env vars needed.
|
|
492
532
|
const eventManager = new EventSubscriptionManager();
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
eventManager.initialize(
|
|
533
|
+
const mqttCreds = tryLoadConfig();
|
|
534
|
+
if (mqttCreds) {
|
|
535
|
+
eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
|
|
496
536
|
console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
|
|
497
537
|
});
|
|
498
538
|
}
|
|
499
539
|
else {
|
|
500
|
-
console.error('MQTT disabled:
|
|
540
|
+
console.error('MQTT disabled: credentials not configured.');
|
|
501
541
|
}
|
|
502
542
|
// Helper: constant-time token comparison
|
|
503
543
|
const tokenMatch = (provided) => {
|
|
@@ -691,7 +731,14 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
691
731
|
});
|
|
692
732
|
return;
|
|
693
733
|
}
|
|
694
|
-
const
|
|
734
|
+
const eventManager = new EventSubscriptionManager();
|
|
735
|
+
const mqttCreds = tryLoadConfig();
|
|
736
|
+
if (mqttCreds) {
|
|
737
|
+
eventManager.initialize(mqttCreds.token, mqttCreds.secret).catch((err) => {
|
|
738
|
+
console.error('MQTT initialization failed:', err instanceof Error ? err.message : String(err));
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const server = createSwitchBotMcpServer({ eventManager });
|
|
695
742
|
const transport = new StdioServerTransport();
|
|
696
743
|
await server.connect(transport);
|
|
697
744
|
}
|
package/dist/commands/plan.js
CHANGED
|
@@ -210,10 +210,18 @@ Workflow:
|
|
|
210
210
|
process.exit(2);
|
|
211
211
|
}
|
|
212
212
|
if (isJsonMode()) {
|
|
213
|
-
|
|
213
|
+
const out = { valid: true, steps: result.plan.steps.length };
|
|
214
|
+
if (result.plan.steps.length === 0)
|
|
215
|
+
out.warning = 'plan has no steps — nothing will execute';
|
|
216
|
+
printJson(out);
|
|
214
217
|
}
|
|
215
218
|
else {
|
|
216
|
-
|
|
219
|
+
if (result.plan.steps.length === 0) {
|
|
220
|
+
console.log('✓ plan valid — but 0 steps: nothing will execute');
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`);
|
|
224
|
+
}
|
|
217
225
|
}
|
|
218
226
|
});
|
|
219
227
|
plan
|
package/dist/commands/scenes.js
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
printJson(scenes);
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
|
-
renderRows(['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields());
|
|
30
|
+
renderRows(['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields(), { id: 'sceneId', name: 'sceneName' });
|
|
31
31
|
if (fmt === 'table' && scenes.length === 0) {
|
|
32
32
|
console.log('No scenes found');
|
|
33
33
|
}
|
package/dist/commands/watch.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fetchDeviceStatus } from '../lib/devices.js';
|
|
|
3
3
|
import { getCachedDevice } from '../devices/cache.js';
|
|
4
4
|
import { parseDurationToMs, getFields } from '../utils/flags.js';
|
|
5
5
|
import { createClient } from '../api/client.js';
|
|
6
|
+
import { resolveDeviceId } from '../utils/name-resolver.js';
|
|
6
7
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
7
8
|
const MIN_INTERVAL_MS = 1_000;
|
|
8
9
|
function diff(prev, next, fields) {
|
|
@@ -55,7 +56,8 @@ export function registerWatchCommand(devices) {
|
|
|
55
56
|
devices
|
|
56
57
|
.command('watch')
|
|
57
58
|
.description('Poll device status on an interval and emit field-level changes (JSONL)')
|
|
58
|
-
.argument('
|
|
59
|
+
.argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)')
|
|
60
|
+
.option('--name <query>', 'Resolve one device by fuzzy name (combined with any positional IDs)')
|
|
59
61
|
.option('--interval <dur>', `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, '30s')
|
|
60
62
|
.option('--max <n>', 'Stop after N ticks (default: run until Ctrl-C)')
|
|
61
63
|
.option('--include-unchanged', 'Emit a tick even when no field changed')
|
|
@@ -71,9 +73,18 @@ Examples:
|
|
|
71
73
|
$ switchbot devices watch ABC123 --fields battery,power --interval 1m
|
|
72
74
|
$ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
|
|
73
75
|
$ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
|
|
76
|
+
$ switchbot devices watch --name "客厅空调" --interval 10s
|
|
74
77
|
`)
|
|
75
78
|
.action(async (deviceIds, options) => {
|
|
76
79
|
try {
|
|
80
|
+
const allIds = [...deviceIds];
|
|
81
|
+
if (options.name) {
|
|
82
|
+
const resolved = resolveDeviceId(undefined, options.name);
|
|
83
|
+
if (!allIds.includes(resolved))
|
|
84
|
+
allIds.push(resolved);
|
|
85
|
+
}
|
|
86
|
+
if (allIds.length === 0)
|
|
87
|
+
throw new UsageError('Provide at least one deviceId argument or --name.');
|
|
77
88
|
const parsed = parseDurationToMs(options.interval);
|
|
78
89
|
if (parsed === null || parsed < MIN_INTERVAL_MS) {
|
|
79
90
|
throw new UsageError(`Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`);
|
|
@@ -101,7 +112,7 @@ Examples:
|
|
|
101
112
|
const t = new Date().toISOString();
|
|
102
113
|
// Poll all devices in parallel; one failure per device doesn't stop
|
|
103
114
|
// the others.
|
|
104
|
-
await Promise.all(
|
|
115
|
+
await Promise.all(allIds.map(async (id) => {
|
|
105
116
|
const cached = getCachedDevice(id);
|
|
106
117
|
try {
|
|
107
118
|
const body = await fetchDeviceStatus(id, client);
|
package/dist/config.js
CHANGED
|
@@ -62,6 +62,29 @@ export function loadConfig() {
|
|
|
62
62
|
process.exit(1);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Like loadConfig but returns null instead of exiting. Use this in code paths
|
|
67
|
+
* that want graceful degradation (e.g. optional MQTT init in `mcp serve`).
|
|
68
|
+
*/
|
|
69
|
+
export function tryLoadConfig() {
|
|
70
|
+
const envToken = process.env.SWITCHBOT_TOKEN;
|
|
71
|
+
const envSecret = process.env.SWITCHBOT_SECRET;
|
|
72
|
+
if (envToken && envSecret)
|
|
73
|
+
return { token: envToken, secret: envSecret };
|
|
74
|
+
const file = configFilePath();
|
|
75
|
+
if (!fs.existsSync(file))
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
79
|
+
const cfg = JSON.parse(raw);
|
|
80
|
+
if (!cfg.token || !cfg.secret)
|
|
81
|
+
return null;
|
|
82
|
+
return cfg;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
65
88
|
export function saveConfig(token, secret) {
|
|
66
89
|
const file = configFilePath();
|
|
67
90
|
const dir = path.dirname(file);
|
package/dist/index.js
CHANGED
|
@@ -37,7 +37,8 @@ program
|
|
|
37
37
|
.option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
|
|
38
38
|
.option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
|
|
39
39
|
.option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
|
|
40
|
-
.option('--audit-log
|
|
40
|
+
.option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
|
|
41
|
+
.option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log')
|
|
41
42
|
.showHelpAfterError('(run with --help to see usage)')
|
|
42
43
|
.showSuggestionAfterError();
|
|
43
44
|
registerConfigCommand(program);
|
|
@@ -68,9 +69,9 @@ Exit codes:
|
|
|
68
69
|
2 usage error (bad flag, unknown subcommand, invalid argument, unknown device type)
|
|
69
70
|
|
|
70
71
|
Environment:
|
|
71
|
-
SWITCHBOT_TOKEN
|
|
72
|
-
SWITCHBOT_SECRET
|
|
73
|
-
NO_COLOR
|
|
72
|
+
SWITCHBOT_TOKEN credential token (takes priority over config file)
|
|
73
|
+
SWITCHBOT_SECRET credential secret (takes priority over config file)
|
|
74
|
+
NO_COLOR disable ANSI colors (auto-respected via chalk)
|
|
74
75
|
|
|
75
76
|
Examples:
|
|
76
77
|
$ switchbot config set-token <token> <secret>
|
package/dist/lib/devices.js
CHANGED
|
@@ -147,9 +147,13 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
147
147
|
const spec = builtinCommands.find((c) => c.command === cmd);
|
|
148
148
|
if (!spec) {
|
|
149
149
|
const unique = [...new Set(builtinCommands.map((c) => c.command))];
|
|
150
|
+
const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase());
|
|
151
|
+
const hint = caseMatch
|
|
152
|
+
? `Did you mean "${caseMatch}"? Supported commands: ${unique.join(', ')}`
|
|
153
|
+
: `Supported commands: ${unique.join(', ')}`;
|
|
150
154
|
return {
|
|
151
155
|
ok: false,
|
|
152
|
-
error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command',
|
|
156
|
+
error: new CommandValidationError(`"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, 'unknown-command', hint),
|
|
153
157
|
};
|
|
154
158
|
}
|
|
155
159
|
const noParamExpected = spec.parameter === '—';
|
|
@@ -160,6 +164,17 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
|
|
|
160
164
|
error: new CommandValidationError(`"${cmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', `Try: switchbot devices command ${deviceId} ${cmd}`),
|
|
161
165
|
};
|
|
162
166
|
}
|
|
167
|
+
// Warn when a parameter is required but the user omitted it
|
|
168
|
+
const paramRequired = !noParamExpected && spec.parameter !== 'default';
|
|
169
|
+
if (paramRequired && !userProvidedParam) {
|
|
170
|
+
const example = spec.exampleParams?.[0];
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: new CommandValidationError(`"${cmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example
|
|
174
|
+
? `Example: switchbot devices command <deviceId> ${cmd} "${example}"`
|
|
175
|
+
: `See: switchbot devices commands ${cached.type}`),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
163
178
|
return { ok: true };
|
|
164
179
|
}
|
|
165
180
|
/**
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
const MAX_HISTORY = 100;
|
|
5
|
+
function historyDir() {
|
|
6
|
+
return path.join(os.homedir(), '.switchbot', 'device-history');
|
|
7
|
+
}
|
|
8
|
+
export class DeviceHistoryStore {
|
|
9
|
+
dir;
|
|
10
|
+
constructor() {
|
|
11
|
+
this.dir = historyDir();
|
|
12
|
+
}
|
|
13
|
+
record(deviceId, topic, deviceType, payload, t) {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(this.dir))
|
|
16
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
17
|
+
const file = path.join(this.dir, `${deviceId}.json`);
|
|
18
|
+
const existing = fs.existsSync(file)
|
|
19
|
+
? JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
20
|
+
: { latest: null, history: [] };
|
|
21
|
+
const entry = { t: t ?? new Date().toISOString(), topic, deviceType, payload };
|
|
22
|
+
existing.latest = entry;
|
|
23
|
+
existing.history = [entry, ...existing.history].slice(0, MAX_HISTORY);
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// best-effort — history loss is non-fatal
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
getLatest(deviceId) {
|
|
31
|
+
try {
|
|
32
|
+
const file = path.join(this.dir, `${deviceId}.json`);
|
|
33
|
+
if (!fs.existsSync(file))
|
|
34
|
+
return null;
|
|
35
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8')).latest;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
getHistory(deviceId, limit = 20) {
|
|
42
|
+
try {
|
|
43
|
+
const file = path.join(this.dir, `${deviceId}.json`);
|
|
44
|
+
if (!fs.existsSync(file))
|
|
45
|
+
return [];
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
47
|
+
return data.history.slice(0, Math.min(limit, MAX_HISTORY));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
listDevices() {
|
|
54
|
+
try {
|
|
55
|
+
if (!fs.existsSync(this.dir))
|
|
56
|
+
return [];
|
|
57
|
+
return fs.readdirSync(this.dir)
|
|
58
|
+
.filter((f) => f.endsWith('.json'))
|
|
59
|
+
.map((f) => f.slice(0, -5));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export const deviceHistoryStore = new DeviceHistoryStore();
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { SwitchBotMqttClient } from '../mqtt/client.js';
|
|
2
|
+
import { fetchMqttCredential } from '../mqtt/credential.js';
|
|
2
3
|
import { parseFilter, applyFilter } from '../utils/filter.js';
|
|
3
4
|
import { fetchDeviceList } from '../lib/devices.js';
|
|
4
5
|
import { getCachedDevice } from '../devices/cache.js';
|
|
5
6
|
import { createClient } from '../api/client.js';
|
|
6
7
|
import { log } from '../logger.js';
|
|
8
|
+
import { deviceHistoryStore } from './device-history.js';
|
|
7
9
|
export class EventSubscriptionManager {
|
|
8
10
|
mqttClient = null;
|
|
9
11
|
subscribers = new Map();
|
|
@@ -18,33 +20,34 @@ export class EventSubscriptionManager {
|
|
|
18
20
|
this.mqttClient = mqttClient || null;
|
|
19
21
|
this.getClient = getClient;
|
|
20
22
|
}
|
|
21
|
-
async initialize(
|
|
23
|
+
async initialize(token, secret) {
|
|
22
24
|
if (!this.mqttClient) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
username: mqttConfig.username,
|
|
27
|
-
password: mqttConfig.password,
|
|
28
|
-
};
|
|
29
|
-
});
|
|
25
|
+
const credential = await fetchMqttCredential(token, secret);
|
|
26
|
+
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(token, secret));
|
|
30
27
|
client.onStateChange((state) => {
|
|
31
28
|
if (state === 'connected') {
|
|
32
29
|
this.emit({
|
|
33
30
|
kind: 'events.reconnected',
|
|
34
31
|
timestamp: Date.now(),
|
|
35
32
|
});
|
|
36
|
-
client.subscribe(
|
|
33
|
+
client.subscribe(credential.topics.status);
|
|
37
34
|
}
|
|
38
35
|
});
|
|
39
36
|
client.onMessage((topic, payload) => {
|
|
40
37
|
try {
|
|
41
38
|
const data = JSON.parse(payload.toString());
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
// Support SwitchBot direct format: { eventType, context: { deviceMac, deviceType, ... } }
|
|
40
|
+
// and AWS IoT shadow format: $aws/things/<id>/shadow/... with data.state
|
|
41
|
+
const context = data.context;
|
|
42
|
+
const deviceId = context?.deviceMac ?? this.extractDeviceId(topic);
|
|
43
|
+
const payloadData = context ?? data.state;
|
|
44
|
+
const deviceType = String(context?.deviceType ?? 'Unknown');
|
|
45
|
+
if (deviceId && payloadData) {
|
|
46
|
+
deviceHistoryStore.record(deviceId, topic, deviceType, payloadData);
|
|
44
47
|
this.addEvent({
|
|
45
48
|
kind: 'shadow.updated',
|
|
46
49
|
deviceId,
|
|
47
|
-
payload:
|
|
50
|
+
payload: payloadData,
|
|
48
51
|
timestamp: Date.now(),
|
|
49
52
|
});
|
|
50
53
|
}
|
package/dist/mqtt/client.js
CHANGED
|
@@ -1,41 +1,53 @@
|
|
|
1
1
|
import { connect } from 'mqtt';
|
|
2
2
|
export class SwitchBotMqttClient {
|
|
3
3
|
client = null;
|
|
4
|
-
|
|
4
|
+
credential;
|
|
5
5
|
state = 'connecting';
|
|
6
|
-
|
|
6
|
+
credentialExpired = false;
|
|
7
7
|
reconnectAttempts = 0;
|
|
8
8
|
maxReconnectAttempts = 10;
|
|
9
|
+
disconnecting = false;
|
|
9
10
|
handlers = new Set();
|
|
10
11
|
messageHandlers = new Set();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
this.config = config;
|
|
16
|
-
this.authRefreshCallback = onAuthRefreshNeeded;
|
|
12
|
+
credentialRefreshCallback;
|
|
13
|
+
constructor(credential, onCredentialExpired) {
|
|
14
|
+
this.credential = credential;
|
|
15
|
+
this.credentialRefreshCallback = onCredentialExpired;
|
|
17
16
|
}
|
|
18
17
|
async connect() {
|
|
19
|
-
if (this.client && this.state === 'connected')
|
|
18
|
+
if (this.client && this.state === 'connected')
|
|
20
19
|
return;
|
|
20
|
+
// Remove stale listeners before replacing the client instance, otherwise
|
|
21
|
+
// the old client's close event fires after the new connection is established
|
|
22
|
+
// (AWS IoT drops the old session), triggering a spurious reconnect loop.
|
|
23
|
+
if (this.client) {
|
|
24
|
+
this.client.removeAllListeners();
|
|
25
|
+
this.client.end(true);
|
|
26
|
+
this.client = null;
|
|
21
27
|
}
|
|
22
28
|
this.setState('connecting');
|
|
23
|
-
this.
|
|
29
|
+
this.credentialExpired = false;
|
|
24
30
|
this.reconnectAttempts = 0;
|
|
25
31
|
try {
|
|
32
|
+
const { tls, brokerUrl, clientId } = this.credential;
|
|
33
|
+
// tls.ca/cert/keyBase64 are PEM strings despite the misleading field name
|
|
26
34
|
const options = {
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
clientId,
|
|
36
|
+
ca: tls.caBase64,
|
|
37
|
+
cert: tls.certBase64,
|
|
38
|
+
key: tls.keyBase64,
|
|
39
|
+
rejectUnauthorized: true,
|
|
29
40
|
clean: true,
|
|
30
|
-
reconnectPeriod: 0,
|
|
31
|
-
connectTimeout:
|
|
32
|
-
|
|
41
|
+
reconnectPeriod: 0,
|
|
42
|
+
connectTimeout: 30000,
|
|
43
|
+
keepalive: 60,
|
|
44
|
+
reschedulePings: true,
|
|
33
45
|
};
|
|
34
|
-
this.client = connect(
|
|
46
|
+
this.client = connect(brokerUrl, options);
|
|
35
47
|
this.client.on('connect', () => {
|
|
36
48
|
this.reconnectAttempts = 0;
|
|
37
49
|
this.setState('connected');
|
|
38
|
-
this.
|
|
50
|
+
this.credentialExpired = false;
|
|
39
51
|
});
|
|
40
52
|
this.client.on('message', (topic, payload) => {
|
|
41
53
|
for (const handler of this.messageHandlers) {
|
|
@@ -43,18 +55,17 @@ export class SwitchBotMqttClient {
|
|
|
43
55
|
}
|
|
44
56
|
});
|
|
45
57
|
this.client.on('error', (err) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
err.message.includes('
|
|
50
|
-
|
|
51
|
-
err.code === 'EACCES') {
|
|
52
|
-
this.authRefreshNeeded = true;
|
|
58
|
+
if (err instanceof Error &&
|
|
59
|
+
(err.message.includes('certificate') ||
|
|
60
|
+
err.message.includes('ECONNRESET') ||
|
|
61
|
+
err.message.includes('handshake'))) {
|
|
62
|
+
this.credentialExpired = true;
|
|
53
63
|
}
|
|
54
64
|
});
|
|
55
65
|
this.client.on('close', () => {
|
|
56
|
-
this.
|
|
57
|
-
|
|
66
|
+
if (this.disconnecting)
|
|
67
|
+
return;
|
|
68
|
+
if (this.credentialExpired) {
|
|
58
69
|
this.setState('failed');
|
|
59
70
|
}
|
|
60
71
|
else if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
@@ -64,7 +75,6 @@ export class SwitchBotMqttClient {
|
|
|
64
75
|
this.setState('failed');
|
|
65
76
|
}
|
|
66
77
|
});
|
|
67
|
-
// Wait for connection with timeout
|
|
68
78
|
await new Promise((resolve, reject) => {
|
|
69
79
|
const timeout = setTimeout(() => {
|
|
70
80
|
reject(new Error('MQTT connection timeout'));
|
|
@@ -97,26 +107,22 @@ export class SwitchBotMqttClient {
|
|
|
97
107
|
async attemptReconnect() {
|
|
98
108
|
this.reconnectAttempts++;
|
|
99
109
|
this.setState('reconnecting');
|
|
100
|
-
if (this.
|
|
110
|
+
if (this.credentialExpired && this.credentialRefreshCallback) {
|
|
101
111
|
try {
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
this.config.password = refreshed.password;
|
|
105
|
-
this.authRefreshNeeded = false;
|
|
112
|
+
this.credential = await this.credentialRefreshCallback();
|
|
113
|
+
this.credentialExpired = false;
|
|
106
114
|
}
|
|
107
|
-
catch
|
|
108
|
-
// Auth refresh failed, mark as failed
|
|
115
|
+
catch {
|
|
109
116
|
this.setState('failed');
|
|
110
117
|
return;
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
|
-
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s...
|
|
114
120
|
const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts - 1));
|
|
115
121
|
await new Promise((r) => setTimeout(r, delay));
|
|
116
122
|
try {
|
|
117
123
|
await this.connect();
|
|
118
124
|
}
|
|
119
|
-
catch
|
|
125
|
+
catch {
|
|
120
126
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
121
127
|
await this.attemptReconnect();
|
|
122
128
|
}
|
|
@@ -133,12 +139,6 @@ export class SwitchBotMqttClient {
|
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
}
|
|
136
|
-
clearStableTimer() {
|
|
137
|
-
if (this.stableTimer) {
|
|
138
|
-
clearTimeout(this.stableTimer);
|
|
139
|
-
this.stableTimer = null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
142
|
subscribe(topic) {
|
|
143
143
|
if (this.client && this.state === 'connected') {
|
|
144
144
|
this.client.subscribe(topic, (err) => {
|
|
@@ -167,18 +167,14 @@ export class SwitchBotMqttClient {
|
|
|
167
167
|
return this.state === 'connected' && this.client?.connected === true;
|
|
168
168
|
}
|
|
169
169
|
async disconnect() {
|
|
170
|
-
this.
|
|
170
|
+
this.disconnecting = true;
|
|
171
171
|
if (this.client) {
|
|
172
172
|
await new Promise((resolve) => {
|
|
173
|
-
this.client?.end(false, () =>
|
|
174
|
-
resolve();
|
|
175
|
-
});
|
|
173
|
+
this.client?.end(false, () => resolve());
|
|
176
174
|
});
|
|
177
175
|
this.client = null;
|
|
178
|
-
this.setState('failed');
|
|
179
176
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
this.authRefreshCallback = callback;
|
|
177
|
+
this.disconnecting = false;
|
|
178
|
+
this.setState('failed');
|
|
183
179
|
}
|
|
184
180
|
}
|