@switchbot/openapi-cli 2.1.0 → 2.2.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 +58 -3
- package/dist/commands/batch.js +7 -5
- package/dist/commands/cache.js +3 -1
- package/dist/commands/capabilities.js +30 -20
- package/dist/commands/catalog.js +3 -1
- package/dist/commands/completion.js +139 -12
- package/dist/commands/config.js +4 -3
- package/dist/commands/device-meta.js +3 -2
- package/dist/commands/devices.js +217 -71
- package/dist/commands/events.js +133 -15
- package/dist/commands/expand.js +29 -11
- package/dist/commands/history.js +4 -3
- package/dist/commands/mcp.js +41 -5
- package/dist/commands/plan.js +10 -2
- package/dist/commands/scenes.js +1 -1
- package/dist/commands/schema.js +6 -3
- package/dist/commands/watch.js +16 -4
- package/dist/commands/webhook.js +2 -1
- package/dist/config.js +7 -2
- package/dist/index.js +49 -19
- 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/arg-parsers.js +62 -0
- package/dist/utils/flags.js +13 -12
- package/dist/utils/format.js +6 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command, CommanderError } from 'commander';
|
|
2
|
+
import { Command, CommanderError, InvalidArgumentError } from 'commander';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
+
import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
|
|
5
|
+
import { parseDurationToMs } from './utils/flags.js';
|
|
4
6
|
import { registerConfigCommand } from './commands/config.js';
|
|
5
7
|
import { registerDevicesCommand } from './commands/devices.js';
|
|
6
8
|
import { registerScenesCommand } from './commands/scenes.js';
|
|
@@ -19,25 +21,44 @@ import { registerCapabilitiesCommand } from './commands/capabilities.js';
|
|
|
19
21
|
const require = createRequire(import.meta.url);
|
|
20
22
|
const { version: pkgVersion } = require('../package.json');
|
|
21
23
|
const program = new Command();
|
|
24
|
+
// Top-level subcommand names. Used by stringArg to produce clearer errors when
|
|
25
|
+
// a value is omitted and the next argv token turns out to be a subcommand name.
|
|
26
|
+
const TOP_LEVEL_COMMANDS = [
|
|
27
|
+
'config', 'devices', 'scenes', 'webhook', 'completion', 'mcp',
|
|
28
|
+
'quota', 'catalog', 'cache', 'events', 'doctor', 'schema',
|
|
29
|
+
'history', 'plan', 'capabilities',
|
|
30
|
+
];
|
|
31
|
+
const cacheModeArg = (value) => {
|
|
32
|
+
if (value.startsWith('-')) {
|
|
33
|
+
throw new InvalidArgumentError(`--cache requires a mode value, got "${value}". ` +
|
|
34
|
+
`Valid: "off", "auto", or a duration like "5m", "1h". Use --cache=<mode> if needed.`);
|
|
35
|
+
}
|
|
36
|
+
if (value === 'off' || value === 'auto')
|
|
37
|
+
return value;
|
|
38
|
+
if (parseDurationToMs(value) !== null)
|
|
39
|
+
return value;
|
|
40
|
+
throw new InvalidArgumentError(`--cache must be "off", "auto", or a duration like "30s"/"5m"/"1h" (got "${value}")`);
|
|
41
|
+
};
|
|
22
42
|
program
|
|
23
43
|
.name('switchbot')
|
|
24
44
|
.description('Command-line tool for SwitchBot API v1.1')
|
|
25
45
|
.version(pkgVersion)
|
|
26
46
|
.option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
|
|
27
|
-
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id')
|
|
28
|
-
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)')
|
|
47
|
+
.option('--format <type>', 'Output format: table (default), json, jsonl, tsv, yaml, id', enumArg('--format', ['table', 'json', 'jsonl', 'tsv', 'yaml', 'id']))
|
|
48
|
+
.option('--fields <csv>', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)', stringArg('--fields', { disallow: TOP_LEVEL_COMMANDS }))
|
|
29
49
|
.option('-v, --verbose', 'Log HTTP request/response details to stderr')
|
|
30
50
|
.option('--dry-run', 'Print mutating requests without sending them (GETs still execute)')
|
|
31
|
-
.option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)')
|
|
32
|
-
.option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)')
|
|
33
|
-
.option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)')
|
|
51
|
+
.option('--timeout <ms>', 'HTTP request timeout in milliseconds (default: 30000)', intArg('--timeout', { min: 1 }))
|
|
52
|
+
.option('--retry-on-429 <n>', 'Max 429 retries before surfacing the error (default: 3)', intArg('--retry-on-429', { min: 0 }))
|
|
53
|
+
.option('--backoff <strategy>', 'Backoff strategy for retries: "linear" or "exponential" (default)', enumArg('--backoff', ['linear', 'exponential']))
|
|
34
54
|
.option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)')
|
|
35
55
|
.option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run')
|
|
36
|
-
.option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)')
|
|
56
|
+
.option('--cache <mode>', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)', cacheModeArg)
|
|
37
57
|
.option('--no-cache', 'Disable cache reads (equivalent to --cache off)')
|
|
38
|
-
.option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)')
|
|
39
|
-
.option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json')
|
|
40
|
-
.option('--audit-log
|
|
58
|
+
.option('--config <path>', 'Override credential file location (default: ~/.switchbot/config.json)', stringArg('--config', { disallow: TOP_LEVEL_COMMANDS }))
|
|
59
|
+
.option('--profile <name>', 'Use a named profile: ~/.switchbot/profiles/<name>.json', stringArg('--profile', { disallow: TOP_LEVEL_COMMANDS }))
|
|
60
|
+
.option('--audit-log', 'Append every mutating command to JSONL audit log (default path: ~/.switchbot/audit.log)')
|
|
61
|
+
.option('--audit-log-path <path>', 'Custom audit log file path; use together with --audit-log', stringArg('--audit-log-path', { disallow: TOP_LEVEL_COMMANDS }))
|
|
41
62
|
.showHelpAfterError('(run with --help to see usage)')
|
|
42
63
|
.showSuggestionAfterError();
|
|
43
64
|
registerConfigCommand(program);
|
|
@@ -94,24 +115,33 @@ Discovery:
|
|
|
94
115
|
|
|
95
116
|
Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
|
|
96
117
|
`);
|
|
97
|
-
// Map commander usage errors (unknown option, missing argument,
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
// Map commander usage errors (unknown option, missing argument, argParser
|
|
119
|
+
// InvalidArgumentError, etc.) to exit code 2. Commander's exitOverride is
|
|
120
|
+
// per-command: subcommand errors won't bubble to the root override, so walk
|
|
121
|
+
// every registered command and apply the same handler.
|
|
122
|
+
const usageExitHandler = (err) => {
|
|
100
123
|
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
101
124
|
process.exit(0);
|
|
102
125
|
}
|
|
103
|
-
// Everything else from commander (unknown option, missing argument,
|
|
104
|
-
// invalid choice, conflicting options, unknown command) is a usage error.
|
|
105
126
|
process.exit(2);
|
|
106
|
-
}
|
|
127
|
+
};
|
|
128
|
+
function applyExitOverride(cmd) {
|
|
129
|
+
cmd.exitOverride(usageExitHandler);
|
|
130
|
+
cmd.commands.forEach(applyExitOverride);
|
|
131
|
+
}
|
|
132
|
+
applyExitOverride(program);
|
|
107
133
|
try {
|
|
108
134
|
await program.parseAsync();
|
|
109
135
|
}
|
|
110
136
|
catch (err) {
|
|
111
|
-
//
|
|
112
|
-
//
|
|
137
|
+
// Subcommand-level CommanderErrors (e.g. InvalidArgumentError from an
|
|
138
|
+
// argParser on a subcommand option) don't always hit the root exitOverride.
|
|
139
|
+
// Mirror the root mapping so all usage errors surface as exit 2.
|
|
113
140
|
if (err instanceof CommanderError) {
|
|
114
|
-
|
|
141
|
+
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
process.exit(2);
|
|
115
145
|
}
|
|
116
146
|
throw err;
|
|
117
147
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export class HomeAssistantSink {
|
|
2
|
+
url;
|
|
3
|
+
token;
|
|
4
|
+
webhookId;
|
|
5
|
+
eventType;
|
|
6
|
+
constructor(opts) {
|
|
7
|
+
this.url = opts.url.replace(/\/$/, '');
|
|
8
|
+
this.token = opts.token;
|
|
9
|
+
this.webhookId = opts.webhookId;
|
|
10
|
+
this.eventType = opts.eventType ?? 'switchbot_event';
|
|
11
|
+
}
|
|
12
|
+
async write(event) {
|
|
13
|
+
try {
|
|
14
|
+
let endpoint;
|
|
15
|
+
const headers = { 'content-type': 'application/json' };
|
|
16
|
+
if (this.webhookId) {
|
|
17
|
+
// Webhook mode: no auth needed, HA triggers automations directly
|
|
18
|
+
endpoint = `${this.url}/api/webhook/${this.webhookId}`;
|
|
19
|
+
}
|
|
20
|
+
else if (this.token) {
|
|
21
|
+
// REST event API: fires a custom event on the HA event bus
|
|
22
|
+
endpoint = `${this.url}/api/events/${this.eventType}`;
|
|
23
|
+
headers['authorization'] = `Bearer ${this.token}`;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.error('[homeassistant] requires --ha-webhook-id or --ha-token');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const res = await fetch(endpoint, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers,
|
|
32
|
+
body: JSON.stringify(event),
|
|
33
|
+
signal: AbortSignal.timeout(10000),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const body = await res.text().catch(() => '');
|
|
37
|
+
console.error(`[homeassistant] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(`[homeassistant] error: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class OpenClawSink {
|
|
2
|
+
url;
|
|
3
|
+
token;
|
|
4
|
+
model;
|
|
5
|
+
constructor(opts) {
|
|
6
|
+
this.url = (opts.url ?? 'http://localhost:18789').replace(/\/$/, '');
|
|
7
|
+
this.token = opts.token;
|
|
8
|
+
this.model = opts.model;
|
|
9
|
+
}
|
|
10
|
+
async write(event) {
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(`${this.url}/v1/chat/completions`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
'authorization': `Bearer ${this.token}`,
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify({
|
|
19
|
+
model: this.model,
|
|
20
|
+
messages: [{ role: 'user', content: event.text }],
|
|
21
|
+
}),
|
|
22
|
+
signal: AbortSignal.timeout(10000),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const body = await res.text().catch(() => '');
|
|
26
|
+
console.error(`[openclaw] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.error(`[openclaw] error: ${err instanceof Error ? err.message : String(err)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class TelegramSink {
|
|
2
|
+
token;
|
|
3
|
+
chatId;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
this.token = opts.token;
|
|
6
|
+
this.chatId = opts.chatId;
|
|
7
|
+
}
|
|
8
|
+
async write(event) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
chat_id: this.chatId,
|
|
15
|
+
text: event.text,
|
|
16
|
+
}),
|
|
17
|
+
signal: AbortSignal.timeout(10000),
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.text().catch(() => '');
|
|
21
|
+
console.error(`[telegram] POST failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error(`[telegram] error: ${err instanceof Error ? err.message : String(err)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class WebhookSink {
|
|
2
|
+
url;
|
|
3
|
+
constructor(url) {
|
|
4
|
+
this.url = url;
|
|
5
|
+
}
|
|
6
|
+
async write(event) {
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(this.url, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
body: JSON.stringify(event),
|
|
12
|
+
signal: AbortSignal.timeout(10000),
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
console.error(`[webhook] POST failed: HTTP ${res.status}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.error(`[webhook] error: ${err instanceof Error ? err.message : String(err)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { InvalidArgumentError } from 'commander';
|
|
2
|
+
import { parseDurationToMs } from './flags.js';
|
|
3
|
+
/**
|
|
4
|
+
* Commander argParser callbacks that fail fast when a required-value flag
|
|
5
|
+
* swallows the next token (another flag, a subcommand name, etc.) — the
|
|
6
|
+
* default Commander behavior is to take the next argv token verbatim.
|
|
7
|
+
*
|
|
8
|
+
* Use `--flag=<val>` form to pass values that legitimately start with `--`.
|
|
9
|
+
*/
|
|
10
|
+
export function intArg(flagName, opts) {
|
|
11
|
+
return (value) => {
|
|
12
|
+
if (value.startsWith('-')) {
|
|
13
|
+
throw new InvalidArgumentError(`${flagName} requires a numeric value, got "${value}". ` +
|
|
14
|
+
`Did you forget a value? Use ${flagName}=<n> if the value really starts with "-".`);
|
|
15
|
+
}
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
if (!Number.isInteger(n)) {
|
|
18
|
+
throw new InvalidArgumentError(`${flagName} must be an integer (got "${value}")`);
|
|
19
|
+
}
|
|
20
|
+
if (opts?.min !== undefined && n < opts.min) {
|
|
21
|
+
throw new InvalidArgumentError(`${flagName} must be >= ${opts.min} (got "${value}")`);
|
|
22
|
+
}
|
|
23
|
+
if (opts?.max !== undefined && n > opts.max) {
|
|
24
|
+
throw new InvalidArgumentError(`${flagName} must be <= ${opts.max} (got "${value}")`);
|
|
25
|
+
}
|
|
26
|
+
return String(n);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function durationArg(flagName) {
|
|
30
|
+
return (value) => {
|
|
31
|
+
if (value.startsWith('-')) {
|
|
32
|
+
throw new InvalidArgumentError(`${flagName} requires a duration value, got "${value}". ` +
|
|
33
|
+
`Use ${flagName}=<dur> if the value really starts with "-".`);
|
|
34
|
+
}
|
|
35
|
+
const ms = parseDurationToMs(value);
|
|
36
|
+
if (ms === null) {
|
|
37
|
+
throw new InvalidArgumentError(`${flagName} must look like "30s", "1m", "500ms", "1h" (got "${value}")`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function stringArg(flagName, opts) {
|
|
43
|
+
return (value) => {
|
|
44
|
+
if (value.startsWith('--')) {
|
|
45
|
+
throw new InvalidArgumentError(`${flagName} requires a value. "${value}" looks like another option — ` +
|
|
46
|
+
`did you forget the value? Use ${flagName}=<val> if your value really starts with "--".`);
|
|
47
|
+
}
|
|
48
|
+
if (opts?.disallow?.includes(value)) {
|
|
49
|
+
throw new InvalidArgumentError(`${flagName} requires a value but got "${value}", which is a subcommand name. ` +
|
|
50
|
+
`Did you forget the value? Use ${flagName}=<val> or put ${flagName} after the subcommand.`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function enumArg(flagName, allowed) {
|
|
56
|
+
return (value) => {
|
|
57
|
+
if (!allowed.includes(value)) {
|
|
58
|
+
throw new InvalidArgumentError(`${flagName} must be one of: ${allowed.join(', ')} (got "${value}")`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
};
|
|
62
|
+
}
|
package/dist/utils/flags.js
CHANGED
|
@@ -17,7 +17,7 @@ export function isVerbose() {
|
|
|
17
17
|
export function isDryRun() {
|
|
18
18
|
return process.argv.includes('--dry-run');
|
|
19
19
|
}
|
|
20
|
-
/** HTTP request timeout in milliseconds. Default 30s. */
|
|
20
|
+
/** HTTP request timeout in milliseconds. Default 30s. Minimum 100ms (values below 100ms are ignored). */
|
|
21
21
|
export function getTimeout() {
|
|
22
22
|
const v = getFlagValue('--timeout');
|
|
23
23
|
if (!v)
|
|
@@ -25,6 +25,10 @@ export function getTimeout() {
|
|
|
25
25
|
const n = Number(v);
|
|
26
26
|
if (!Number.isFinite(n) || n <= 0)
|
|
27
27
|
return 30_000;
|
|
28
|
+
if (n < 100) {
|
|
29
|
+
process.stderr.write(`Warning: --timeout ${n}ms is too low to complete any request; using 100ms minimum.\n`);
|
|
30
|
+
return 100;
|
|
31
|
+
}
|
|
28
32
|
return n;
|
|
29
33
|
}
|
|
30
34
|
/** Override for the credentials file path. */
|
|
@@ -36,20 +40,17 @@ export function getProfile() {
|
|
|
36
40
|
return getFlagValue('--profile');
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
|
-
* Audit log path. `--audit-log
|
|
40
|
-
*
|
|
41
|
-
*
|
|
43
|
+
* Audit log path. `--audit-log` enables JSONL append on every mutating command.
|
|
44
|
+
* Use `--audit-log-path <path>` to specify a custom file; otherwise defaults to
|
|
45
|
+
* ~/.switchbot/audit.log. Returns null when --audit-log is absent.
|
|
42
46
|
*/
|
|
43
47
|
export function getAuditLog() {
|
|
44
|
-
|
|
45
|
-
if (idx === -1)
|
|
48
|
+
if (!process.argv.includes('--audit-log'))
|
|
46
49
|
return null;
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
return next;
|
|
50
|
+
const customPath = getFlagValue('--audit-log-path');
|
|
51
|
+
if (customPath)
|
|
52
|
+
return customPath;
|
|
53
|
+
return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`;
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
55
56
|
* Max 429 retries before surfacing the error. Default 3. `--no-retry`
|
package/dist/utils/format.js
CHANGED
|
@@ -32,15 +32,16 @@ export function resolveFormat() {
|
|
|
32
32
|
export function resolveFields() {
|
|
33
33
|
return getFields();
|
|
34
34
|
}
|
|
35
|
-
export function filterFields(headers, rows, fields) {
|
|
35
|
+
export function filterFields(headers, rows, fields, aliases) {
|
|
36
36
|
if (!fields || fields.length === 0)
|
|
37
37
|
return { headers, rows };
|
|
38
|
-
const
|
|
38
|
+
const resolved = aliases ? fields.map((f) => aliases[f] ?? f) : fields;
|
|
39
|
+
const unknown = fields.filter((_, i) => !headers.includes(resolved[i]));
|
|
39
40
|
if (unknown.length > 0) {
|
|
40
41
|
throw new UsageError(`Unknown field(s): ${unknown.map((f) => `"${f}"`).join(', ')}. ` +
|
|
41
42
|
`Allowed: ${headers.map((f) => `"${f}"`).join(', ')}.`);
|
|
42
43
|
}
|
|
43
|
-
const indices =
|
|
44
|
+
const indices = resolved.map((f) => headers.indexOf(f));
|
|
44
45
|
return {
|
|
45
46
|
headers: indices.map((i) => headers[i]),
|
|
46
47
|
rows: rows.map((row) => indices.map((i) => row[i])),
|
|
@@ -62,8 +63,8 @@ function rowToObject(headers, row) {
|
|
|
62
63
|
}
|
|
63
64
|
return obj;
|
|
64
65
|
}
|
|
65
|
-
export function renderRows(headers, rows, format, fields) {
|
|
66
|
-
const filtered = filterFields(headers, rows, fields);
|
|
66
|
+
export function renderRows(headers, rows, format, fields, aliases) {
|
|
67
|
+
const filtered = filterFields(headers, rows, fields, aliases);
|
|
67
68
|
const h = filtered.headers;
|
|
68
69
|
const r = filtered.rows;
|
|
69
70
|
switch (format) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.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",
|