@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/mqtt/credential.js
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { buildAuthHeaders } from '../auth.js';
|
|
3
|
+
const CREDENTIAL_ENDPOINT = 'https://api.switchbot.net/v1.1/iot/credential';
|
|
4
|
+
export async function fetchMqttCredential(token, secret) {
|
|
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);
|
|
8
|
+
const headers = buildAuthHeaders(token, secret);
|
|
9
|
+
const res = await fetch(CREDENTIAL_ENDPOINT, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers,
|
|
12
|
+
body: JSON.stringify({ instanceId }),
|
|
13
|
+
signal: AbortSignal.timeout(15000),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
throw new Error(`MQTT credential request failed: HTTP ${res.status} ${res.statusText}`);
|
|
17
|
+
}
|
|
18
|
+
const json = (await res.json());
|
|
19
|
+
if (json.statusCode !== 100) {
|
|
20
|
+
throw new Error(`MQTT credential API error: statusCode ${json.statusCode}`);
|
|
21
|
+
}
|
|
22
|
+
// Response shape: { statusCode, body: { body: { channels: { mqtt: ... } } } }
|
|
23
|
+
const outer = json.body;
|
|
24
|
+
const inner = (outer.body ?? outer);
|
|
25
|
+
const channels = inner.channels;
|
|
26
|
+
if (!channels?.mqtt) {
|
|
27
|
+
throw new Error('Unexpected MQTT credential response — channels.mqtt missing');
|
|
28
|
+
}
|
|
29
|
+
return channels.mqtt;
|
|
12
30
|
}
|
|
@@ -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
|
+
}
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchbot/openapi-cli",
|
|
3
|
-
"version": "2.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.2.0",
|
|
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",
|
|
7
7
|
"cli",
|