@switchbot/openapi-cli 2.1.0 → 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 +55 -3
- package/dist/commands/capabilities.js +30 -20
- package/dist/commands/devices.js +206 -66
- package/dist/commands/events.js +114 -9
- package/dist/commands/expand.js +20 -3
- package/dist/commands/mcp.js +35 -0
- package/dist/commands/plan.js +10 -2
- package/dist/commands/scenes.js +1 -1
- package/dist/commands/watch.js +13 -2
- package/dist/index.js +2 -1
- package/dist/lib/devices.js +16 -1
- package/dist/mcp/device-history.js +66 -0
- package/dist/mcp/events-subscription.js +10 -3
- package/dist/mqtt/client.js +8 -0
- package/dist/mqtt/credential.js +3 -2
- 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 +1 -1
package/dist/commands/events.js
CHANGED
|
@@ -3,6 +3,15 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
|
|
|
3
3
|
import { SwitchBotMqttClient } from '../mqtt/client.js';
|
|
4
4
|
import { fetchMqttCredential } from '../mqtt/credential.js';
|
|
5
5
|
import { tryLoadConfig } from '../config.js';
|
|
6
|
+
import { SinkDispatcher } from '../sinks/dispatcher.js';
|
|
7
|
+
import { StdoutSink } from '../sinks/stdout.js';
|
|
8
|
+
import { FileSink } from '../sinks/file.js';
|
|
9
|
+
import { WebhookSink } from '../sinks/webhook.js';
|
|
10
|
+
import { OpenClawSink } from '../sinks/openclaw.js';
|
|
11
|
+
import { TelegramSink } from '../sinks/telegram.js';
|
|
12
|
+
import { HomeAssistantSink } from '../sinks/homeassistant.js';
|
|
13
|
+
import { parseSinkEvent } from '../sinks/format.js';
|
|
14
|
+
import { deviceHistoryStore } from '../mcp/device-history.js';
|
|
6
15
|
const DEFAULT_PORT = 3000;
|
|
7
16
|
const DEFAULT_PATH = '/';
|
|
8
17
|
const MAX_BODY_BYTES = 1_000_000;
|
|
@@ -192,6 +201,18 @@ Examples:
|
|
|
192
201
|
.description('Subscribe to SwitchBot MQTT shadow events and stream them as JSONL')
|
|
193
202
|
.option('--topic <pattern>', 'MQTT topic filter (default: SwitchBot shadow topic from credential)')
|
|
194
203
|
.option('--max <n>', 'Stop after N events (default: run until Ctrl-C)')
|
|
204
|
+
.option('--sink <type>', 'Output sink: stdout (default), file, webhook, openclaw, telegram, homeassistant (repeatable)', (val, prev) => [...prev, val], [])
|
|
205
|
+
.option('--sink-file <path>', 'File path for file sink')
|
|
206
|
+
.option('--webhook-url <url>', 'Webhook URL for webhook sink')
|
|
207
|
+
.option('--openclaw-url <url>', 'OpenClaw gateway URL (default: http://localhost:18789)')
|
|
208
|
+
.option('--openclaw-token <token>', 'Bearer token for OpenClaw (or env OPENCLAW_TOKEN)')
|
|
209
|
+
.option('--openclaw-model <id>', 'OpenClaw agent model ID to route events to')
|
|
210
|
+
.option('--telegram-token <token>', 'Telegram bot token (or env TELEGRAM_TOKEN)')
|
|
211
|
+
.option('--telegram-chat <id>', 'Telegram chat/channel ID to send messages to')
|
|
212
|
+
.option('--ha-url <url>', 'Home Assistant base URL (e.g. http://homeassistant.local:8123)')
|
|
213
|
+
.option('--ha-token <token>', 'HA long-lived access token (for REST event API)')
|
|
214
|
+
.option('--ha-webhook-id <id>', 'HA webhook ID (no auth; takes priority over --ha-token)')
|
|
215
|
+
.option('--ha-event-type <type>', 'HA event type for REST API (default: switchbot_event)')
|
|
195
216
|
.addHelpText('after', `
|
|
196
217
|
Connects to the SwitchBot MQTT service using your existing credentials
|
|
197
218
|
(SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json).
|
|
@@ -200,10 +221,25 @@ No additional MQTT configuration required.
|
|
|
200
221
|
Output (JSONL, one event per line):
|
|
201
222
|
{ "t": "<ISO>", "topic": "<mqtt-topic>", "payload": <parsed JSON or raw string> }
|
|
202
223
|
|
|
224
|
+
Sink types (--sink, repeatable):
|
|
225
|
+
stdout Print JSONL to stdout (default when no --sink given)
|
|
226
|
+
file Append JSONL to --sink-file <path>
|
|
227
|
+
webhook HTTP POST to --webhook-url <url>
|
|
228
|
+
openclaw POST to OpenClaw via --openclaw-url / --openclaw-token / --openclaw-model
|
|
229
|
+
telegram Send to Telegram via --telegram-token / --telegram-chat
|
|
230
|
+
homeassistant POST to HA via --ha-url + --ha-webhook-id (or --ha-token)
|
|
231
|
+
|
|
232
|
+
Device state is also persisted to ~/.switchbot/device-history/<deviceId>.json
|
|
233
|
+
regardless of sink configuration.
|
|
234
|
+
|
|
203
235
|
Examples:
|
|
204
236
|
$ switchbot events mqtt-tail
|
|
205
|
-
$ switchbot events mqtt-tail --topic 'switchbot/#'
|
|
206
237
|
$ switchbot events mqtt-tail --max 10 --json
|
|
238
|
+
$ switchbot events mqtt-tail --sink file --sink-file ~/.switchbot/events.jsonl
|
|
239
|
+
$ switchbot events mqtt-tail --sink openclaw --openclaw-token abc --openclaw-model home-agent
|
|
240
|
+
$ switchbot events mqtt-tail --sink telegram --telegram-token <token> --telegram-chat <chatId>
|
|
241
|
+
$ switchbot events mqtt-tail --sink homeassistant --ha-url http://ha.local:8123 --ha-webhook-id switchbot
|
|
242
|
+
$ switchbot events mqtt-tail --sink stdout --sink openclaw --openclaw-token abc --openclaw-model home
|
|
207
243
|
`)
|
|
208
244
|
.action(async (options) => {
|
|
209
245
|
try {
|
|
@@ -211,20 +247,70 @@ Examples:
|
|
|
211
247
|
if (maxEvents !== null && (!Number.isInteger(maxEvents) || maxEvents < 1)) {
|
|
212
248
|
throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`);
|
|
213
249
|
}
|
|
214
|
-
let creds;
|
|
215
250
|
const loaded = tryLoadConfig();
|
|
216
251
|
if (!loaded) {
|
|
217
252
|
throw new UsageError('No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.');
|
|
218
253
|
}
|
|
219
|
-
|
|
254
|
+
const sinkTypes = options.sink;
|
|
255
|
+
let dispatcher = null;
|
|
256
|
+
if (sinkTypes.length > 0) {
|
|
257
|
+
const sinks = sinkTypes.map((type) => {
|
|
258
|
+
switch (type) {
|
|
259
|
+
case 'stdout':
|
|
260
|
+
return new StdoutSink();
|
|
261
|
+
case 'file': {
|
|
262
|
+
if (!options.sinkFile)
|
|
263
|
+
throw new UsageError('--sink file requires --sink-file <path>');
|
|
264
|
+
return new FileSink(options.sinkFile);
|
|
265
|
+
}
|
|
266
|
+
case 'webhook': {
|
|
267
|
+
if (!options.webhookUrl)
|
|
268
|
+
throw new UsageError('--sink webhook requires --webhook-url <url>');
|
|
269
|
+
return new WebhookSink(options.webhookUrl);
|
|
270
|
+
}
|
|
271
|
+
case 'openclaw': {
|
|
272
|
+
const token = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
|
|
273
|
+
if (!token)
|
|
274
|
+
throw new UsageError('--sink openclaw requires --openclaw-token or env OPENCLAW_TOKEN');
|
|
275
|
+
if (!options.openclawModel)
|
|
276
|
+
throw new UsageError('--sink openclaw requires --openclaw-model <id>');
|
|
277
|
+
return new OpenClawSink({ url: options.openclawUrl, token, model: options.openclawModel });
|
|
278
|
+
}
|
|
279
|
+
case 'telegram': {
|
|
280
|
+
const token = options.telegramToken ?? process.env.TELEGRAM_TOKEN;
|
|
281
|
+
if (!token)
|
|
282
|
+
throw new UsageError('--sink telegram requires --telegram-token or env TELEGRAM_TOKEN');
|
|
283
|
+
if (!options.telegramChat)
|
|
284
|
+
throw new UsageError('--sink telegram requires --telegram-chat <id>');
|
|
285
|
+
return new TelegramSink({ token, chatId: options.telegramChat });
|
|
286
|
+
}
|
|
287
|
+
case 'homeassistant': {
|
|
288
|
+
if (!options.haUrl)
|
|
289
|
+
throw new UsageError('--sink homeassistant requires --ha-url <url>');
|
|
290
|
+
if (!options.haWebhookId && !options.haToken) {
|
|
291
|
+
throw new UsageError('--sink homeassistant requires --ha-webhook-id or --ha-token');
|
|
292
|
+
}
|
|
293
|
+
return new HomeAssistantSink({
|
|
294
|
+
url: options.haUrl,
|
|
295
|
+
token: options.haToken,
|
|
296
|
+
webhookId: options.haWebhookId,
|
|
297
|
+
eventType: options.haEventType,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
default:
|
|
301
|
+
throw new UsageError(`Unknown --sink type "${type}". Supported: stdout, file, webhook, openclaw, telegram, homeassistant`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
dispatcher = new SinkDispatcher(sinks);
|
|
305
|
+
}
|
|
220
306
|
if (!isJsonMode()) {
|
|
221
307
|
console.error('Fetching MQTT credentials from SwitchBot service…');
|
|
222
308
|
}
|
|
223
|
-
const credential = await fetchMqttCredential(
|
|
309
|
+
const credential = await fetchMqttCredential(loaded.token, loaded.secret);
|
|
224
310
|
const topic = options.topic ?? credential.topics.status;
|
|
225
311
|
let eventCount = 0;
|
|
226
312
|
const ac = new AbortController();
|
|
227
|
-
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(
|
|
313
|
+
const client = new SwitchBotMqttClient(credential, () => fetchMqttCredential(loaded.token, loaded.secret));
|
|
228
314
|
const unsub = client.onMessage((msgTopic, payload) => {
|
|
229
315
|
let parsed;
|
|
230
316
|
try {
|
|
@@ -233,18 +319,35 @@ Examples:
|
|
|
233
319
|
catch {
|
|
234
320
|
parsed = payload.toString('utf-8');
|
|
235
321
|
}
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
|
|
322
|
+
const t = new Date().toISOString();
|
|
323
|
+
if (dispatcher) {
|
|
324
|
+
const { deviceId, deviceType, text } = parseSinkEvent(parsed);
|
|
325
|
+
const sinkEvent = { t, topic: msgTopic, deviceId, deviceType, payload: parsed, text };
|
|
326
|
+
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
327
|
+
dispatcher.dispatch(sinkEvent).catch(() => { });
|
|
239
328
|
}
|
|
240
329
|
else {
|
|
241
|
-
|
|
330
|
+
// Default behavior: record history + print to stdout
|
|
331
|
+
const { deviceId, deviceType } = parseSinkEvent(parsed);
|
|
332
|
+
deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t);
|
|
333
|
+
const record = { t, topic: msgTopic, payload: parsed };
|
|
334
|
+
if (isJsonMode()) {
|
|
335
|
+
printJson(record);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
console.log(JSON.stringify(record));
|
|
339
|
+
}
|
|
242
340
|
}
|
|
243
341
|
eventCount++;
|
|
244
342
|
if (maxEvents !== null && eventCount >= maxEvents) {
|
|
245
343
|
ac.abort();
|
|
246
344
|
}
|
|
247
345
|
});
|
|
346
|
+
const unsubState = client.onStateChange((state) => {
|
|
347
|
+
if (!isJsonMode()) {
|
|
348
|
+
console.error(`[${new Date().toLocaleTimeString()}] MQTT state: ${state}`);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
248
351
|
await client.connect();
|
|
249
352
|
client.subscribe(topic);
|
|
250
353
|
if (!isJsonMode()) {
|
|
@@ -255,6 +358,8 @@ Examples:
|
|
|
255
358
|
process.removeListener('SIGINT', cleanup);
|
|
256
359
|
process.removeListener('SIGTERM', cleanup);
|
|
257
360
|
unsub();
|
|
361
|
+
unsubState();
|
|
362
|
+
dispatcher?.close().catch(() => { });
|
|
258
363
|
client.disconnect().then(resolve).catch(resolve);
|
|
259
364
|
};
|
|
260
365
|
process.once('SIGINT', cleanup);
|
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,6 +8,7 @@ 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';
|
|
@@ -110,6 +111,40 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
|
|
|
110
111
|
structuredContent: { status: body },
|
|
111
112
|
};
|
|
112
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
|
+
});
|
|
113
148
|
// ---- send_command ---------------------------------------------------------
|
|
114
149
|
server.registerTool('send_command', {
|
|
115
150
|
title: 'Send a control command to a device',
|
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/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);
|
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();
|
|
@@ -5,6 +5,7 @@ import { fetchDeviceList } from '../lib/devices.js';
|
|
|
5
5
|
import { getCachedDevice } from '../devices/cache.js';
|
|
6
6
|
import { createClient } from '../api/client.js';
|
|
7
7
|
import { log } from '../logger.js';
|
|
8
|
+
import { deviceHistoryStore } from './device-history.js';
|
|
8
9
|
export class EventSubscriptionManager {
|
|
9
10
|
mqttClient = null;
|
|
10
11
|
subscribers = new Map();
|
|
@@ -35,12 +36,18 @@ export class EventSubscriptionManager {
|
|
|
35
36
|
client.onMessage((topic, payload) => {
|
|
36
37
|
try {
|
|
37
38
|
const data = JSON.parse(payload.toString());
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
40
47
|
this.addEvent({
|
|
41
48
|
kind: 'shadow.updated',
|
|
42
49
|
deviceId,
|
|
43
|
-
payload:
|
|
50
|
+
payload: payloadData,
|
|
44
51
|
timestamp: Date.now(),
|
|
45
52
|
});
|
|
46
53
|
}
|
package/dist/mqtt/client.js
CHANGED
|
@@ -17,6 +17,14 @@ export class SwitchBotMqttClient {
|
|
|
17
17
|
async connect() {
|
|
18
18
|
if (this.client && this.state === 'connected')
|
|
19
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;
|
|
27
|
+
}
|
|
20
28
|
this.setState('connecting');
|
|
21
29
|
this.credentialExpired = false;
|
|
22
30
|
this.reconnectAttempts = 0;
|
package/dist/mqtt/credential.js
CHANGED
|
@@ -2,8 +2,9 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { buildAuthHeaders } from '../auth.js';
|
|
3
3
|
const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
|
|
4
4
|
export async function fetchMqttCredential(token, secret) {
|
|
5
|
-
//
|
|
6
|
-
|
|
5
|
+
// Use a random instanceId so each CLI session gets its own clientId, avoiding
|
|
6
|
+
// conflicts with the SwitchBot cloud service that shares the same account credentials.
|
|
7
|
+
const instanceId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
7
8
|
const headers = buildAuthHeaders(token, secret);
|
|
8
9
|
const res = await fetch(CREDENTIAL_ENDPOINT, {
|
|
9
10
|
method: 'POST',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class SinkDispatcher {
|
|
2
|
+
sinks;
|
|
3
|
+
constructor(sinks) {
|
|
4
|
+
this.sinks = sinks;
|
|
5
|
+
}
|
|
6
|
+
async dispatch(event) {
|
|
7
|
+
await Promise.allSettled(this.sinks.map((s) => s.write(event)));
|
|
8
|
+
}
|
|
9
|
+
async close() {
|
|
10
|
+
await Promise.allSettled(this.sinks.map((s) => s.close?.()));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export class FileSink {
|
|
4
|
+
filePath;
|
|
5
|
+
constructor(filePath) {
|
|
6
|
+
this.filePath = path.resolve(filePath);
|
|
7
|
+
const dir = path.dirname(this.filePath);
|
|
8
|
+
if (!fs.existsSync(dir))
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
async write(event) {
|
|
12
|
+
try {
|
|
13
|
+
fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n', { encoding: 'utf-8' });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// best-effort
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const ICONS = {
|
|
2
|
+
'Bot': '🤖',
|
|
3
|
+
'Curtain': '🪟',
|
|
4
|
+
'Hub': '📡',
|
|
5
|
+
'Hub 2': '📡',
|
|
6
|
+
'Hub 3': '📡',
|
|
7
|
+
'Hub Mini': '📡',
|
|
8
|
+
'Smart Lock': '🔒',
|
|
9
|
+
'Smart Lock Pro': '🔒',
|
|
10
|
+
'Plug': '🔌',
|
|
11
|
+
'Plug Mini (US)': '🔌',
|
|
12
|
+
'Plug Mini (JP)': '🔌',
|
|
13
|
+
'Color Bulb': '💡',
|
|
14
|
+
'Strip Light': '💡',
|
|
15
|
+
'Contact Sensor': '🚪',
|
|
16
|
+
'Motion Sensor': '👁',
|
|
17
|
+
'Meter': '🌡',
|
|
18
|
+
'MeterPro': '🌡',
|
|
19
|
+
'Climate Panel': '🌡',
|
|
20
|
+
'WoMeter': '🌡',
|
|
21
|
+
'WoIOSensor': '🌡',
|
|
22
|
+
};
|
|
23
|
+
function icon(deviceType) {
|
|
24
|
+
return ICONS[deviceType] ?? '📱';
|
|
25
|
+
}
|
|
26
|
+
export function formatEventText(context) {
|
|
27
|
+
const type = context.deviceType ?? 'Unknown';
|
|
28
|
+
const pfx = `${icon(type)} ${type}`;
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (context.temperature !== undefined)
|
|
31
|
+
parts.push(`${context.temperature}°C`);
|
|
32
|
+
if (context.humidity !== undefined)
|
|
33
|
+
parts.push(`${context.humidity}%`);
|
|
34
|
+
if (parts.length)
|
|
35
|
+
return `${pfx}: ${parts.join(' / ')}`;
|
|
36
|
+
if (context.power !== undefined)
|
|
37
|
+
return `${pfx}: ${context.power}`;
|
|
38
|
+
if (context.lockState !== undefined)
|
|
39
|
+
return `${pfx}: ${context.lockState}`;
|
|
40
|
+
if (context.openState !== undefined)
|
|
41
|
+
return `${pfx}: ${context.openState}`;
|
|
42
|
+
if (context.detectionState !== undefined)
|
|
43
|
+
return `${pfx}: ${context.detectionState}`;
|
|
44
|
+
if (context.brightness !== undefined)
|
|
45
|
+
return `${pfx}: ${context.brightness}`;
|
|
46
|
+
return `${pfx}: state change`;
|
|
47
|
+
}
|
|
48
|
+
export function parseSinkEvent(payload) {
|
|
49
|
+
const p = payload;
|
|
50
|
+
const context = (p?.context ?? {});
|
|
51
|
+
return {
|
|
52
|
+
deviceId: String(context.deviceMac ?? 'unknown'),
|
|
53
|
+
deviceType: String(context.deviceType ?? 'Unknown'),
|
|
54
|
+
text: formatEventText(context),
|
|
55
|
+
};
|
|
56
|
+
}
|