@switchbot/openapi-cli 3.3.0 → 3.3.2
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 +1 -1
- package/dist/index.js +1254 -448
- package/dist/policy/schema/v0.2.json +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -6396,6 +6396,7 @@ var init_prime = __esm({
|
|
|
6396
6396
|
import fs2 from "node:fs";
|
|
6397
6397
|
import path2 from "node:path";
|
|
6398
6398
|
import os3 from "node:os";
|
|
6399
|
+
import { createHash } from "node:crypto";
|
|
6399
6400
|
function sanitizeOptionalString(v2) {
|
|
6400
6401
|
if (typeof v2 !== "string") return void 0;
|
|
6401
6402
|
const trimmed = v2.trim();
|
|
@@ -6586,12 +6587,13 @@ function getConfigSummary() {
|
|
|
6586
6587
|
}
|
|
6587
6588
|
}
|
|
6588
6589
|
function maskCredential(token) {
|
|
6589
|
-
|
|
6590
|
-
return token.slice(0, 4) + "*".repeat(token.length - 8) + token.slice(-4);
|
|
6590
|
+
return `**** [sha256:${fingerprintCredential(token)}]`;
|
|
6591
6591
|
}
|
|
6592
6592
|
function maskSecret(secret) {
|
|
6593
|
-
|
|
6594
|
-
|
|
6593
|
+
return `**** [sha256:${fingerprintCredential(secret)}]`;
|
|
6594
|
+
}
|
|
6595
|
+
function fingerprintCredential(value) {
|
|
6596
|
+
return createHash("sha256").update(value).digest("hex").slice(-8);
|
|
6595
6597
|
}
|
|
6596
6598
|
var init_config = __esm({
|
|
6597
6599
|
"src/config.ts"() {
|
|
@@ -7219,7 +7221,7 @@ function emitJsonError(errorPayload) {
|
|
|
7219
7221
|
function emitStreamHeader(opts) {
|
|
7220
7222
|
console.log(
|
|
7221
7223
|
JSON.stringify({
|
|
7222
|
-
schemaVersion:
|
|
7224
|
+
schemaVersion: SCHEMA_VERSION,
|
|
7223
7225
|
stream: true,
|
|
7224
7226
|
eventKind: opts.eventKind,
|
|
7225
7227
|
cadence: opts.cadence
|
|
@@ -8125,9 +8127,9 @@ var init_catalog = __esm({
|
|
|
8125
8127
|
description: "Battery-powered temperature and humidity sensor; read-only, no control commands.",
|
|
8126
8128
|
role: "sensor",
|
|
8127
8129
|
readOnly: true,
|
|
8128
|
-
aliases: ["Meter Plus", "MeterPro", "MeterPro(CO2)", "WoIOSensor"
|
|
8130
|
+
aliases: ["Meter Plus", "MeterPro", "MeterPro(CO2)", "WoIOSensor"],
|
|
8129
8131
|
commands: [],
|
|
8130
|
-
statusFields: ["temperature", "humidity", "
|
|
8132
|
+
statusFields: ["temperature", "humidity", "battery", "version"]
|
|
8131
8133
|
},
|
|
8132
8134
|
{
|
|
8133
8135
|
type: "Motion Sensor",
|
|
@@ -8157,6 +8159,15 @@ var init_catalog = __esm({
|
|
|
8157
8159
|
statusFields: ["battery", "version", "status"]
|
|
8158
8160
|
},
|
|
8159
8161
|
// Status-only hub-class devices (no control commands)
|
|
8162
|
+
{
|
|
8163
|
+
type: "Hub 2",
|
|
8164
|
+
category: "physical",
|
|
8165
|
+
description: "Wi-Fi hub with built-in temperature, humidity, and light sensors; bridges BLE devices to the cloud.",
|
|
8166
|
+
role: "hub",
|
|
8167
|
+
readOnly: true,
|
|
8168
|
+
commands: [],
|
|
8169
|
+
statusFields: ["version", "temperature", "humidity", "lightLevel"]
|
|
8170
|
+
},
|
|
8160
8171
|
{
|
|
8161
8172
|
type: "Hub Mini",
|
|
8162
8173
|
category: "physical",
|
|
@@ -8192,7 +8203,7 @@ var init_catalog = __esm({
|
|
|
8192
8203
|
role: "climate",
|
|
8193
8204
|
readOnly: true,
|
|
8194
8205
|
commands: [],
|
|
8195
|
-
statusFields: ["
|
|
8206
|
+
statusFields: ["battery", "brightness", "moveDetected", "humidity", "temperature", "version"]
|
|
8196
8207
|
},
|
|
8197
8208
|
{
|
|
8198
8209
|
type: "Wallet Finder Card",
|
|
@@ -8301,11 +8312,11 @@ var init_catalog = __esm({
|
|
|
8301
8312
|
import fs6 from "node:fs";
|
|
8302
8313
|
import path6 from "node:path";
|
|
8303
8314
|
import os7 from "node:os";
|
|
8304
|
-
import { createHash } from "node:crypto";
|
|
8315
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
8305
8316
|
function scopedCacheDir(baseDir) {
|
|
8306
8317
|
const profile = getActiveProfile();
|
|
8307
8318
|
if (profile === void 0) return baseDir;
|
|
8308
|
-
const hash2 =
|
|
8319
|
+
const hash2 = createHash2("sha256").update(profile).digest("hex").slice(0, 8);
|
|
8309
8320
|
const dir = path6.join(baseDir, "cache", hash2);
|
|
8310
8321
|
if (!fs6.existsSync(dir)) fs6.mkdirSync(dir, { recursive: true });
|
|
8311
8322
|
return dir;
|
|
@@ -8367,9 +8378,11 @@ function getCachedTypeMap(deviceIds) {
|
|
|
8367
8378
|
function updateCacheFromDeviceList(body) {
|
|
8368
8379
|
const devices = {};
|
|
8369
8380
|
for (const d of body.deviceList) {
|
|
8370
|
-
if (!d.deviceId
|
|
8381
|
+
if (!d.deviceId) continue;
|
|
8371
8382
|
devices[d.deviceId] = {
|
|
8372
|
-
|
|
8383
|
+
// Some real devices omit deviceType entirely (for example AI accessories).
|
|
8384
|
+
// Keep them in cache with an empty type string rather than dropping the row.
|
|
8385
|
+
type: d.deviceType ?? "",
|
|
8373
8386
|
name: d.deviceName,
|
|
8374
8387
|
category: "physical",
|
|
8375
8388
|
hubDeviceId: d.hubDeviceId,
|
|
@@ -23786,6 +23799,8 @@ var init_capabilities = __esm({
|
|
|
23786
23799
|
"history aggregate": READ_LOCAL,
|
|
23787
23800
|
"install": ACTION_LOCAL,
|
|
23788
23801
|
"mcp serve": READ_LOCAL,
|
|
23802
|
+
"mcp tools": READ_LOCAL,
|
|
23803
|
+
"mcp list-tools": READ_LOCAL,
|
|
23789
23804
|
"plan schema": READ_LOCAL,
|
|
23790
23805
|
"plan validate": READ_LOCAL,
|
|
23791
23806
|
"plan suggest": READ_LOCAL,
|
|
@@ -27172,6 +27187,9 @@ var IdempotencyConflictError = class extends Error {
|
|
|
27172
27187
|
function hashKey(key) {
|
|
27173
27188
|
return crypto2.createHash("sha256").update(key).digest("hex");
|
|
27174
27189
|
}
|
|
27190
|
+
function fingerprintIdempotencyKey(key) {
|
|
27191
|
+
return hashKey(key).slice(0, 12);
|
|
27192
|
+
}
|
|
27175
27193
|
function shapeSignature(command, parameter) {
|
|
27176
27194
|
let parm;
|
|
27177
27195
|
try {
|
|
@@ -27371,9 +27389,23 @@ var CommandValidationError = class extends Error {
|
|
|
27371
27389
|
kind;
|
|
27372
27390
|
hint;
|
|
27373
27391
|
};
|
|
27374
|
-
|
|
27392
|
+
function hasDanglingHubReference(device, isPhysical, deviceList) {
|
|
27393
|
+
if (!isPhysical) return false;
|
|
27394
|
+
const hubDeviceId = device.hubDeviceId;
|
|
27395
|
+
if (!hubDeviceId || hubDeviceId === "000000000000" || hubDeviceId === device.deviceId) return false;
|
|
27396
|
+
return !deviceList.some((d) => d.deviceId === hubDeviceId);
|
|
27397
|
+
}
|
|
27398
|
+
function describeCatalogNote(deviceId, typeName, isPhysical) {
|
|
27399
|
+
if (isPhysical) {
|
|
27400
|
+
const label2 = typeName || "this device type";
|
|
27401
|
+
return `No built-in catalog entry for ${label2}; raw device metadata is shown. Try \`switchbot devices status ${deviceId}\` for live raw status.`;
|
|
27402
|
+
}
|
|
27403
|
+
const label = typeName || "this IR remote type";
|
|
27404
|
+
return `No built-in catalog entry for ${label}; raw device metadata is shown. Use \`switchbot devices command ${deviceId} "<buttonName>" --type customize\` for custom IR buttons.`;
|
|
27405
|
+
}
|
|
27406
|
+
async function fetchDeviceList(client, options = {}) {
|
|
27375
27407
|
const mode = getCacheMode();
|
|
27376
|
-
if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
|
|
27408
|
+
if (!options.bypassCache && mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
|
|
27377
27409
|
const cached2 = loadCache();
|
|
27378
27410
|
if (cached2) {
|
|
27379
27411
|
const deviceList = [];
|
|
@@ -27383,7 +27415,7 @@ async function fetchDeviceList(client) {
|
|
|
27383
27415
|
deviceList.push({
|
|
27384
27416
|
deviceId,
|
|
27385
27417
|
deviceName: entry.name,
|
|
27386
|
-
deviceType: entry.type,
|
|
27418
|
+
...entry.type ? { deviceType: entry.type } : {},
|
|
27387
27419
|
enableCloudService: entry.enableCloudService ?? true,
|
|
27388
27420
|
hubDeviceId: entry.hubDeviceId ?? "",
|
|
27389
27421
|
roomID: entry.roomID,
|
|
@@ -27439,6 +27471,7 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
27439
27471
|
parameter,
|
|
27440
27472
|
commandType,
|
|
27441
27473
|
dryRun: isDryRun(),
|
|
27474
|
+
...options?.idempotencyKey ? { idempotencyKeyFingerprint: fingerprintIdempotencyKey(options.idempotencyKey) } : {},
|
|
27442
27475
|
...options?.planId ? { planId: options.planId } : {}
|
|
27443
27476
|
};
|
|
27444
27477
|
const execute = async () => {
|
|
@@ -27451,7 +27484,7 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
27451
27484
|
return res.data.body;
|
|
27452
27485
|
} catch (err) {
|
|
27453
27486
|
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
27454
|
-
writeAudit({ ...baseAudit, result: "
|
|
27487
|
+
writeAudit({ ...baseAudit, result: "dry-run" });
|
|
27455
27488
|
} else {
|
|
27456
27489
|
writeAudit({
|
|
27457
27490
|
...baseAudit,
|
|
@@ -27468,6 +27501,12 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
27468
27501
|
{ command: cmd, parameter }
|
|
27469
27502
|
);
|
|
27470
27503
|
if (!replayed) return result;
|
|
27504
|
+
writeAudit({
|
|
27505
|
+
...baseAudit,
|
|
27506
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
27507
|
+
result: "ok",
|
|
27508
|
+
replayed: true
|
|
27509
|
+
});
|
|
27471
27510
|
if (result && typeof result === "object") {
|
|
27472
27511
|
return { ...result, replayed: true };
|
|
27473
27512
|
}
|
|
@@ -27557,10 +27596,19 @@ function getDestructiveReason(deviceType, cmd, commandType) {
|
|
|
27557
27596
|
return spec ? getCommandSafetyReason(spec) : null;
|
|
27558
27597
|
}
|
|
27559
27598
|
async function describeDevice(deviceId, options = {}, client) {
|
|
27560
|
-
const
|
|
27561
|
-
const
|
|
27562
|
-
|
|
27563
|
-
|
|
27599
|
+
const mode = getCacheMode();
|
|
27600
|
+
const hadFreshListCache = mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs) && loadCache() !== null;
|
|
27601
|
+
let body = await fetchDeviceList(client);
|
|
27602
|
+
let { deviceList, infraredRemoteList } = body;
|
|
27603
|
+
let physical = deviceList.find((d) => d.deviceId === deviceId);
|
|
27604
|
+
let ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
27605
|
+
if (!physical && !ir && hadFreshListCache) {
|
|
27606
|
+
body = await fetchDeviceList(client, { bypassCache: true });
|
|
27607
|
+
deviceList = body.deviceList;
|
|
27608
|
+
infraredRemoteList = body.infraredRemoteList;
|
|
27609
|
+
physical = deviceList.find((d) => d.deviceId === deviceId);
|
|
27610
|
+
ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
27611
|
+
}
|
|
27564
27612
|
if (!physical && !ir) throw new DeviceNotFoundError(deviceId);
|
|
27565
27613
|
const typeName = physical ? physical.deviceType ?? "" : ir.remoteType;
|
|
27566
27614
|
const match = typeName ? findCatalogEntry(typeName) : null;
|
|
@@ -27589,8 +27637,13 @@ async function describeDevice(deviceId, options = {}, client) {
|
|
|
27589
27637
|
statusFields: catalogEntry.statusFields ?? [],
|
|
27590
27638
|
...liveStatus !== void 0 ? { liveStatus } : {}
|
|
27591
27639
|
} : liveStatus !== void 0 ? { liveStatus } : null;
|
|
27640
|
+
const warnings = [];
|
|
27641
|
+
const selectedDevice = physical ?? ir;
|
|
27642
|
+
if (hasDanglingHubReference(selectedDevice, Boolean(physical), deviceList)) {
|
|
27643
|
+
warnings.push(`hubDeviceId ${selectedDevice.hubDeviceId} is not present in the current inventory`);
|
|
27644
|
+
}
|
|
27592
27645
|
return {
|
|
27593
|
-
device:
|
|
27646
|
+
device: selectedDevice,
|
|
27594
27647
|
isPhysical: Boolean(physical),
|
|
27595
27648
|
typeName,
|
|
27596
27649
|
controlType: physical?.controlType ?? ir?.controlType ?? null,
|
|
@@ -27598,6 +27651,8 @@ async function describeDevice(deviceId, options = {}, client) {
|
|
|
27598
27651
|
capabilities,
|
|
27599
27652
|
source,
|
|
27600
27653
|
suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [],
|
|
27654
|
+
...catalogEntry ? {} : { catalogNote: describeCatalogNote(deviceId, typeName, Boolean(physical)) },
|
|
27655
|
+
...warnings.length > 0 ? { warnings } : {},
|
|
27601
27656
|
inheritedLocation: ir ? buildHubLocationMap(deviceList).get(ir.hubDeviceId) : void 0
|
|
27602
27657
|
};
|
|
27603
27658
|
}
|
|
@@ -27648,6 +27703,8 @@ function toMcpDescribeShape(r) {
|
|
|
27648
27703
|
source: r.source,
|
|
27649
27704
|
capabilities: r.capabilities,
|
|
27650
27705
|
suggestedActions: r.suggestedActions,
|
|
27706
|
+
...r.catalogNote !== void 0 ? { catalogNote: r.catalogNote } : {},
|
|
27707
|
+
...r.warnings !== void 0 ? { warnings: r.warnings } : {},
|
|
27651
27708
|
...r.inheritedLocation !== void 0 ? { inheritedLocation: { family: r.inheritedLocation.family, room: r.inheritedLocation.room } } : {}
|
|
27652
27709
|
};
|
|
27653
27710
|
}
|
|
@@ -28468,7 +28525,6 @@ Examples:
|
|
|
28468
28525
|
idempotencyKey: options.idempotencyKeyPrefix ? `${options.idempotencyKeyPrefix}-${id}` : void 0
|
|
28469
28526
|
}));
|
|
28470
28527
|
const planDoc = {
|
|
28471
|
-
schemaVersion: "1.1",
|
|
28472
28528
|
dryRun: true,
|
|
28473
28529
|
plan: {
|
|
28474
28530
|
command: cmd,
|
|
@@ -28577,7 +28633,6 @@ Examples:
|
|
|
28577
28633
|
skipped: dryRunned.length + preSkipped.length,
|
|
28578
28634
|
durationMs: Date.now() - startedAt,
|
|
28579
28635
|
unverifiableCount: succeeded.filter((s2) => getCachedDevice(s2.deviceId)?.category === "ir").length,
|
|
28580
|
-
schemaVersion: "1.1",
|
|
28581
28636
|
maxConcurrent: concurrency,
|
|
28582
28637
|
staggerMs,
|
|
28583
28638
|
...dryRun ? { dryRun: true } : {}
|
|
@@ -28725,6 +28780,7 @@ function listAllCanonical() {
|
|
|
28725
28780
|
|
|
28726
28781
|
// src/commands/watch.ts
|
|
28727
28782
|
var MIN_INTERVAL_MS = 1e3;
|
|
28783
|
+
var INITIAL_MODES = ["snapshot", "emit", "skip"];
|
|
28728
28784
|
function diff(prev, next, fields) {
|
|
28729
28785
|
const out = {};
|
|
28730
28786
|
const keys = fields ?? Object.keys(next);
|
|
@@ -28741,6 +28797,10 @@ function formatHumanLine(ev) {
|
|
|
28741
28797
|
const when = new Date(ev.t).toLocaleTimeString();
|
|
28742
28798
|
const head = `[${when}] ${ev.deviceId}${ev.type ? ` (${ev.type})` : ""}`;
|
|
28743
28799
|
if (ev.error) return `${head}: error \u2014 ${ev.error}`;
|
|
28800
|
+
if (ev.snapshot) {
|
|
28801
|
+
const pairs3 = Object.entries(ev.snapshot).map(([k2, v2]) => `${k2}=${JSON.stringify(v2)}`).join(", ");
|
|
28802
|
+
return `${head}: snapshot ${pairs3}`;
|
|
28803
|
+
}
|
|
28744
28804
|
const keys = Object.keys(ev.changed);
|
|
28745
28805
|
if (keys.length === 0) return `${head}: no changes`;
|
|
28746
28806
|
const pairs2 = keys.map((k2) => {
|
|
@@ -28771,15 +28831,19 @@ function registerWatchCommand(devices) {
|
|
|
28771
28831
|
`Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1e3}s)`,
|
|
28772
28832
|
durationArg("--interval"),
|
|
28773
28833
|
"30s"
|
|
28774
|
-
).option("--max <n>", "Stop after N ticks (default: run until Ctrl-C)", intArg("--max", { min: 1 })).option("--for <dur>", 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg("--for")).option("--include-unchanged", "Emit a tick even when no field changed").addHelpText(
|
|
28834
|
+
).option("--max <n>", "Stop after N ticks (default: run until Ctrl-C)", intArg("--max", { min: 1 })).option("--for <dur>", 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg("--for")).option("--include-unchanged", "Emit a tick even when no field changed").option("--initial <mode>", "How to handle the first poll: snapshot | emit | skip (default: snapshot)", enumArg("--initial", INITIAL_MODES), "snapshot").addHelpText(
|
|
28775
28835
|
"after",
|
|
28776
28836
|
`
|
|
28777
28837
|
Default output is a human-readable table of field changes per tick; add --json
|
|
28778
28838
|
to get one JSON-Lines record per deviceId per tick (the agent-friendly form).
|
|
28779
28839
|
|
|
28780
|
-
The
|
|
28781
|
-
|
|
28782
|
-
|
|
28840
|
+
The first poll is configurable:
|
|
28841
|
+
--initial=snapshot emit one baseline snapshot event, then only diffs
|
|
28842
|
+
--initial=emit treat the first poll as null -> value changes
|
|
28843
|
+
--initial=skip record the baseline silently, then only diffs
|
|
28844
|
+
|
|
28845
|
+
Subsequent ticks only include fields whose value changed (unless
|
|
28846
|
+
--include-unchanged is passed).
|
|
28783
28847
|
|
|
28784
28848
|
Each --json line has the shape:
|
|
28785
28849
|
{ "t": "<ISO>", "tick": <n>, "deviceId": "ID", "type": "Bot",
|
|
@@ -28838,7 +28902,32 @@ Examples:
|
|
|
28838
28902
|
const cached2 = getCachedDevice(id);
|
|
28839
28903
|
try {
|
|
28840
28904
|
const body = await fetchDeviceStatus(id, client);
|
|
28841
|
-
const
|
|
28905
|
+
const previous = prev.get(id);
|
|
28906
|
+
const baseline = fields ? Object.fromEntries(fields.map((f2) => [f2, body[f2] ?? null])) : body;
|
|
28907
|
+
if (!prev.has(id)) {
|
|
28908
|
+
if (options.initial === "skip") {
|
|
28909
|
+
prev.set(id, body);
|
|
28910
|
+
return;
|
|
28911
|
+
}
|
|
28912
|
+
if (options.initial === "snapshot") {
|
|
28913
|
+
prev.set(id, body);
|
|
28914
|
+
const ev2 = {
|
|
28915
|
+
t,
|
|
28916
|
+
tick,
|
|
28917
|
+
deviceId: id,
|
|
28918
|
+
type: cached2?.type,
|
|
28919
|
+
changed: {},
|
|
28920
|
+
snapshot: baseline
|
|
28921
|
+
};
|
|
28922
|
+
if (isJsonMode()) {
|
|
28923
|
+
printJson(ev2);
|
|
28924
|
+
} else {
|
|
28925
|
+
console.log(formatHumanLine(ev2));
|
|
28926
|
+
}
|
|
28927
|
+
return;
|
|
28928
|
+
}
|
|
28929
|
+
}
|
|
28930
|
+
const changed = diff(previous, body, fields);
|
|
28842
28931
|
prev.set(id, body);
|
|
28843
28932
|
if (Object.keys(changed).length === 0 && !options.includeUnchanged) {
|
|
28844
28933
|
return;
|
|
@@ -28913,7 +29002,7 @@ Examples:
|
|
|
28913
29002
|
try {
|
|
28914
29003
|
const wantLive = options.live !== false;
|
|
28915
29004
|
const desc = await describeDevice(deviceId, { live: wantLive });
|
|
28916
|
-
const warnings = [];
|
|
29005
|
+
const warnings = [...desc.warnings ?? []];
|
|
28917
29006
|
if (desc.isPhysical && !desc.device.enableCloudService) {
|
|
28918
29007
|
warnings.push("Cloud service disabled on this device \u2014 commands will fail.");
|
|
28919
29008
|
}
|
|
@@ -28948,6 +29037,7 @@ Examples:
|
|
|
28948
29037
|
name: deviceName(desc.device),
|
|
28949
29038
|
role: desc.catalog?.role ?? null,
|
|
28950
29039
|
readOnly: desc.catalog?.readOnly ?? false,
|
|
29040
|
+
...desc.catalogNote ? { catalogNote: desc.catalogNote } : {},
|
|
28951
29041
|
location,
|
|
28952
29042
|
liveStatus,
|
|
28953
29043
|
commands,
|
|
@@ -28973,6 +29063,9 @@ function printHuman(r) {
|
|
|
28973
29063
|
const loc = [r.location?.family, r.location?.room].filter(Boolean).join(" / ");
|
|
28974
29064
|
console.log(`location: ${loc}`);
|
|
28975
29065
|
}
|
|
29066
|
+
if (r.catalogNote) {
|
|
29067
|
+
console.log(`catalog: ${r.catalogNote}`);
|
|
29068
|
+
}
|
|
28976
29069
|
if (r.warnings.length) {
|
|
28977
29070
|
console.log("warnings:");
|
|
28978
29071
|
for (const w2 of r.warnings) console.log(` ! ${w2}`);
|
|
@@ -29019,7 +29112,7 @@ init_cache();
|
|
|
29019
29112
|
init_flags();
|
|
29020
29113
|
init_client();
|
|
29021
29114
|
function registerExpandCommand(devices) {
|
|
29022
|
-
devices.command("expand").description("Send a command with semantic flags instead of raw positional parameters").argument("[deviceId]", 'Target device ID from "devices list" (or use --name)').argument("[command]", "Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).option("--temp <celsius>", "AC setAll: temperature in Celsius (16-30)", intArg("--temp", { min: 16, max: 30 })).option("--mode <mode>", "AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary", stringArg("--mode")).option("--fan <speed>", "AC setAll: fan speed auto|low|mid|high", stringArg("--fan")).option("--power <state>", "AC setAll: on|off", stringArg("--power")).option("--position <percent>", "Curtain setPosition: 0-100 (0=open, 100=closed)", intArg("--position", { min: 0, max: 100 })).option("--direction <dir>", "Blind Tilt setPosition: up|down", stringArg("--direction")).option("--angle <percent>", "Blind Tilt setPosition: 0-100 (0=closed, 100=open)", intArg("--angle", { min: 0, max: 100 })).option("--channel <n>", "Relay Switch 2 setMode: channel 1 or 2", intArg("--channel", { min: 1, max: 2 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
|
|
29115
|
+
devices.command("expand").description("Send a command with semantic flags instead of raw positional parameters").argument("[deviceId]", 'Target device ID from "devices list" (or use --name)').argument("[command]", "Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)").option("--name <query>", "Resolve device by fuzzy name instead of deviceId", stringArg("--name")).option("--name-strategy <s>", `Name match strategy: ${ALL_STRATEGIES.join("|")} (default: require-unique)`, stringArg("--name-strategy")).option("--name-type <type>", 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg("--name-type")).option("--name-category <cat>", "Narrow --name by category: physical|ir", enumArg("--name-category", ["physical", "ir"])).option("--name-room <room>", "Narrow --name by room name (substring match)", stringArg("--name-room")).option("--temp <celsius>", "AC setAll: temperature in Celsius (16-30)", intArg("--temp", { min: 16, max: 30 })).option("--mode <mode>", "AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary", stringArg("--mode")).option("--fan <speed>", "AC setAll: fan speed auto|low|mid|high", stringArg("--fan")).option("--power <state>", "AC setAll: on|off", stringArg("--power")).option("--position <percent>", "Curtain setPosition: 0-100 (0=open, 100=closed)", intArg("--position", { min: 0, max: 100 })).option("--direction <dir>", "Blind Tilt setPosition: up|down", stringArg("--direction")).option("--angle <percent>", "Blind Tilt setPosition: 0-100 (0=closed, 100=open)", intArg("--angle", { min: 0, max: 100 })).option("--channel <n>", "Relay Switch 2 setMode: channel 1 or 2", intArg("--channel", { min: 1, max: 2 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
|
|
29023
29116
|
Translates semantic flags into the wire parameter format, then sends the command.
|
|
29024
29117
|
|
|
29025
29118
|
Supported expansions:
|
|
@@ -29057,7 +29150,12 @@ Examples:
|
|
|
29057
29150
|
effectiveCommand = deviceIdArg;
|
|
29058
29151
|
effectiveDeviceIdArg = void 0;
|
|
29059
29152
|
}
|
|
29060
|
-
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name
|
|
29153
|
+
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
|
|
29154
|
+
strategy: options.nameStrategy ?? "require-unique",
|
|
29155
|
+
type: options.nameType,
|
|
29156
|
+
category: options.nameCategory,
|
|
29157
|
+
room: options.nameRoom
|
|
29158
|
+
});
|
|
29061
29159
|
if (!effectiveCommand) throw new UsageError("A command argument is required (setAll, setPosition, setMode).");
|
|
29062
29160
|
command = effectiveCommand;
|
|
29063
29161
|
const cached2 = getCachedDevice(deviceId);
|
|
@@ -29240,6 +29338,21 @@ var EXPAND_HINTS = {
|
|
|
29240
29338
|
"Blind Tilt": { command: "setPosition", flags: "--direction up --angle 50" },
|
|
29241
29339
|
"Relay Switch 2PM": { command: "setMode", flags: "--channel 1 --mode edge" }
|
|
29242
29340
|
};
|
|
29341
|
+
function annotateStatusPayload(deviceId, body) {
|
|
29342
|
+
const annotated = { ...body };
|
|
29343
|
+
if (Object.keys(body).length === 0) {
|
|
29344
|
+
annotated.supported = false;
|
|
29345
|
+
annotated.note = "this device does not expose cloud status";
|
|
29346
|
+
return annotated;
|
|
29347
|
+
}
|
|
29348
|
+
const cached2 = getCachedDevice(deviceId);
|
|
29349
|
+
const looksLikeMeter = cached2?.type?.toLowerCase().includes("meter") ?? false;
|
|
29350
|
+
const staleZeroReading = looksLikeMeter && !Object.prototype.hasOwnProperty.call(body, "onlineStatus") && body.battery === 0 && body.temperature === 0 && body.humidity === 0;
|
|
29351
|
+
if (staleZeroReading) {
|
|
29352
|
+
annotated.hint = "readings look stale; check batteries or hub connectivity";
|
|
29353
|
+
}
|
|
29354
|
+
return annotated;
|
|
29355
|
+
}
|
|
29243
29356
|
function registerDevicesCommand(program3) {
|
|
29244
29357
|
const COMMAND_TYPES2 = ["command", "customize"];
|
|
29245
29358
|
const devices = program3.command("devices").description("Manage and control SwitchBot devices").addHelpText("after", `
|
|
@@ -29475,7 +29588,7 @@ Examples:
|
|
|
29475
29588
|
const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
|
|
29476
29589
|
const fetchedAt2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
29477
29590
|
const batch = results.map(
|
|
29478
|
-
(r, i) => r.status === "fulfilled" ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt2, ...r.value } : { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) }
|
|
29591
|
+
(r, i) => r.status === "fulfilled" ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt2, ...annotateStatusPayload(ids[i], r.value) } : { deviceId: ids[i], ok: false, error: r.reason?.message ?? String(r.reason) }
|
|
29479
29592
|
);
|
|
29480
29593
|
const batchFmt = resolveFormat();
|
|
29481
29594
|
if (isJsonMode() || batchFmt === "json") {
|
|
@@ -29509,7 +29622,7 @@ Examples:
|
|
|
29509
29622
|
category: options.nameCategory,
|
|
29510
29623
|
room: options.nameRoom
|
|
29511
29624
|
});
|
|
29512
|
-
const body = await fetchDeviceStatus(deviceId);
|
|
29625
|
+
const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId));
|
|
29513
29626
|
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
29514
29627
|
const fmt = resolveFormat();
|
|
29515
29628
|
if (fmt === "json" && process.argv.includes("--json")) {
|
|
@@ -29787,7 +29900,7 @@ ${extra}` : extra;
|
|
|
29787
29900
|
if (isJsonMode()) {
|
|
29788
29901
|
printJson({ dryRun: true, wouldSend });
|
|
29789
29902
|
} else {
|
|
29790
|
-
console.log(
|
|
29903
|
+
console.log(`\u25E6 dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`);
|
|
29791
29904
|
}
|
|
29792
29905
|
return;
|
|
29793
29906
|
}
|
|
@@ -29932,6 +30045,8 @@ Examples:
|
|
|
29932
30045
|
capabilities,
|
|
29933
30046
|
source,
|
|
29934
30047
|
suggestedActions: picks,
|
|
30048
|
+
...result.catalogNote ? { catalogNote: result.catalogNote } : {},
|
|
30049
|
+
...result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {},
|
|
29935
30050
|
...expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}
|
|
29936
30051
|
});
|
|
29937
30052
|
return;
|
|
@@ -29965,8 +30080,17 @@ Examples:
|
|
|
29965
30080
|
}
|
|
29966
30081
|
const liveStatus = capabilities && "liveStatus" in capabilities ? capabilities.liveStatus : void 0;
|
|
29967
30082
|
console.log("");
|
|
30083
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
30084
|
+
for (const warning of result.warnings) {
|
|
30085
|
+
console.log(`Warning: ${warning}`);
|
|
30086
|
+
}
|
|
30087
|
+
console.log("");
|
|
30088
|
+
}
|
|
29968
30089
|
if (!catalog) {
|
|
29969
30090
|
console.log(`(Type "${typeName}" is not in the built-in catalog \u2014 no command reference available.)`);
|
|
30091
|
+
if (result.catalogNote) {
|
|
30092
|
+
console.log(result.catalogNote);
|
|
30093
|
+
}
|
|
29970
30094
|
if (isPhysical) {
|
|
29971
30095
|
console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`);
|
|
29972
30096
|
} else {
|
|
@@ -30050,6 +30174,7 @@ function renderCatalogEntry(entry) {
|
|
|
30050
30174
|
if (entry.statusFields && entry.statusFields.length > 0) {
|
|
30051
30175
|
console.log('\nStatus fields (from "devices status"):');
|
|
30052
30176
|
console.log(" " + entry.statusFields.join(", "));
|
|
30177
|
+
console.log(" Note: statusFields are advisory; actual fields can vary by firmware and device variant.");
|
|
30053
30178
|
}
|
|
30054
30179
|
const expandHint = EXPAND_HINTS[entry.type];
|
|
30055
30180
|
if (expandHint) {
|
|
@@ -45423,6 +45548,12 @@ function isSupportedPolicySchemaVersion(v2) {
|
|
|
45423
45548
|
return typeof v2 === "string" && SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(v2);
|
|
45424
45549
|
}
|
|
45425
45550
|
|
|
45551
|
+
// src/policy/validate.ts
|
|
45552
|
+
init_catalog();
|
|
45553
|
+
|
|
45554
|
+
// src/rules/action.ts
|
|
45555
|
+
init_cjs_shim();
|
|
45556
|
+
|
|
45426
45557
|
// src/rules/destructive.ts
|
|
45427
45558
|
init_cjs_shim();
|
|
45428
45559
|
var DESTRUCTIVE_COMMANDS = [
|
|
@@ -45456,9 +45587,195 @@ function destructiveVerbOf(cmd) {
|
|
|
45456
45587
|
return null;
|
|
45457
45588
|
}
|
|
45458
45589
|
|
|
45590
|
+
// src/rules/action.ts
|
|
45591
|
+
var DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
|
|
45592
|
+
function parseRuleCommand(cmd) {
|
|
45593
|
+
const m2 = DEVICES_COMMAND_RE.exec(cmd.trim());
|
|
45594
|
+
if (!m2) return null;
|
|
45595
|
+
const deviceIdSlot = m2[1];
|
|
45596
|
+
const verb = m2[2];
|
|
45597
|
+
const rest = (m2[3] ?? "").trim();
|
|
45598
|
+
return {
|
|
45599
|
+
deviceIdSlot,
|
|
45600
|
+
verb,
|
|
45601
|
+
parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/)
|
|
45602
|
+
};
|
|
45603
|
+
}
|
|
45604
|
+
function resolveActionDevice(explicit, slot, aliases) {
|
|
45605
|
+
const candidate = explicit ?? (slot && slot !== "<id>" ? slot : null);
|
|
45606
|
+
if (!candidate) return null;
|
|
45607
|
+
if (aliases[candidate]) return aliases[candidate];
|
|
45608
|
+
return candidate;
|
|
45609
|
+
}
|
|
45610
|
+
function renderParameter(tokens) {
|
|
45611
|
+
if (tokens.length === 0) return void 0;
|
|
45612
|
+
if (tokens.length === 1) return tokens[0];
|
|
45613
|
+
return tokens.join(":");
|
|
45614
|
+
}
|
|
45615
|
+
async function executeRuleAction(action, ctx) {
|
|
45616
|
+
const parsed = parseRuleCommand(action.command);
|
|
45617
|
+
if (!parsed) {
|
|
45618
|
+
writeAudit({
|
|
45619
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45620
|
+
kind: "rule-fire",
|
|
45621
|
+
deviceId: "unknown",
|
|
45622
|
+
command: action.command,
|
|
45623
|
+
parameter: null,
|
|
45624
|
+
commandType: "command",
|
|
45625
|
+
dryRun: true,
|
|
45626
|
+
result: "error",
|
|
45627
|
+
error: "unparseable-command",
|
|
45628
|
+
rule: {
|
|
45629
|
+
name: ctx.rule.name,
|
|
45630
|
+
triggerSource: ctx.rule.when.source,
|
|
45631
|
+
fireId: ctx.fireId,
|
|
45632
|
+
reason: "unparseable-command"
|
|
45633
|
+
}
|
|
45634
|
+
});
|
|
45635
|
+
return { ok: false, error: "unparseable-command", blocked: true };
|
|
45636
|
+
}
|
|
45637
|
+
if (isDestructiveCommand2(action.command)) {
|
|
45638
|
+
writeAudit({
|
|
45639
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45640
|
+
kind: "rule-fire",
|
|
45641
|
+
deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? "unknown",
|
|
45642
|
+
command: action.command,
|
|
45643
|
+
parameter: null,
|
|
45644
|
+
commandType: "command",
|
|
45645
|
+
dryRun: true,
|
|
45646
|
+
result: "error",
|
|
45647
|
+
error: `destructive-verb:${parsed.verb}`,
|
|
45648
|
+
rule: {
|
|
45649
|
+
name: ctx.rule.name,
|
|
45650
|
+
triggerSource: ctx.rule.when.source,
|
|
45651
|
+
fireId: ctx.fireId,
|
|
45652
|
+
reason: `destructive verb "${parsed.verb}" refused at runtime`
|
|
45653
|
+
}
|
|
45654
|
+
});
|
|
45655
|
+
return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
|
|
45656
|
+
}
|
|
45657
|
+
const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
|
|
45658
|
+
if (!deviceId || deviceId === "<id>") {
|
|
45659
|
+
writeAudit({
|
|
45660
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45661
|
+
kind: "rule-fire",
|
|
45662
|
+
deviceId: "unknown",
|
|
45663
|
+
command: action.command,
|
|
45664
|
+
parameter: null,
|
|
45665
|
+
commandType: "command",
|
|
45666
|
+
dryRun: true,
|
|
45667
|
+
result: "error",
|
|
45668
|
+
error: "missing-device",
|
|
45669
|
+
rule: {
|
|
45670
|
+
name: ctx.rule.name,
|
|
45671
|
+
triggerSource: ctx.rule.when.source,
|
|
45672
|
+
fireId: ctx.fireId,
|
|
45673
|
+
reason: "action omitted `device` and command used `<id>` placeholder"
|
|
45674
|
+
}
|
|
45675
|
+
});
|
|
45676
|
+
return { ok: false, error: "missing-device", verb: parsed.verb };
|
|
45677
|
+
}
|
|
45678
|
+
const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
|
|
45679
|
+
const parameter = renderParameter(parsed.parameterTokens);
|
|
45680
|
+
if (dryRun) {
|
|
45681
|
+
writeAudit({
|
|
45682
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45683
|
+
kind: "rule-fire-dry",
|
|
45684
|
+
deviceId,
|
|
45685
|
+
command: parsed.verb,
|
|
45686
|
+
parameter: parameter ?? "default",
|
|
45687
|
+
commandType: "command",
|
|
45688
|
+
dryRun: true,
|
|
45689
|
+
result: "ok",
|
|
45690
|
+
rule: {
|
|
45691
|
+
name: ctx.rule.name,
|
|
45692
|
+
triggerSource: ctx.rule.when.source,
|
|
45693
|
+
matchedDevice: deviceId,
|
|
45694
|
+
fireId: ctx.fireId
|
|
45695
|
+
}
|
|
45696
|
+
});
|
|
45697
|
+
return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
|
|
45698
|
+
}
|
|
45699
|
+
if (ctx.skipApiCall) {
|
|
45700
|
+
writeAudit({
|
|
45701
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45702
|
+
kind: "rule-fire",
|
|
45703
|
+
deviceId,
|
|
45704
|
+
command: parsed.verb,
|
|
45705
|
+
parameter: parameter ?? "default",
|
|
45706
|
+
commandType: "command",
|
|
45707
|
+
dryRun: false,
|
|
45708
|
+
result: "ok",
|
|
45709
|
+
rule: {
|
|
45710
|
+
name: ctx.rule.name,
|
|
45711
|
+
triggerSource: ctx.rule.when.source,
|
|
45712
|
+
matchedDevice: deviceId,
|
|
45713
|
+
fireId: ctx.fireId,
|
|
45714
|
+
reason: "api-skipped"
|
|
45715
|
+
}
|
|
45716
|
+
});
|
|
45717
|
+
return { ok: true, deviceId, verb: parsed.verb };
|
|
45718
|
+
}
|
|
45719
|
+
try {
|
|
45720
|
+
await executeCommand(deviceId, parsed.verb, parameter, "command", ctx.httpClient);
|
|
45721
|
+
writeAudit({
|
|
45722
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45723
|
+
kind: "rule-fire",
|
|
45724
|
+
deviceId,
|
|
45725
|
+
command: parsed.verb,
|
|
45726
|
+
parameter: parameter ?? "default",
|
|
45727
|
+
commandType: "command",
|
|
45728
|
+
dryRun: false,
|
|
45729
|
+
result: "ok",
|
|
45730
|
+
rule: {
|
|
45731
|
+
name: ctx.rule.name,
|
|
45732
|
+
triggerSource: ctx.rule.when.source,
|
|
45733
|
+
matchedDevice: deviceId,
|
|
45734
|
+
fireId: ctx.fireId
|
|
45735
|
+
}
|
|
45736
|
+
});
|
|
45737
|
+
return { ok: true, deviceId, verb: parsed.verb };
|
|
45738
|
+
} catch (err) {
|
|
45739
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45740
|
+
writeAudit({
|
|
45741
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45742
|
+
kind: "rule-fire",
|
|
45743
|
+
deviceId,
|
|
45744
|
+
command: parsed.verb,
|
|
45745
|
+
parameter: parameter ?? "default",
|
|
45746
|
+
commandType: "command",
|
|
45747
|
+
dryRun: false,
|
|
45748
|
+
result: "error",
|
|
45749
|
+
error: msg,
|
|
45750
|
+
rule: {
|
|
45751
|
+
name: ctx.rule.name,
|
|
45752
|
+
triggerSource: ctx.rule.when.source,
|
|
45753
|
+
matchedDevice: deviceId,
|
|
45754
|
+
fireId: ctx.fireId
|
|
45755
|
+
}
|
|
45756
|
+
});
|
|
45757
|
+
return { ok: false, error: msg, deviceId, verb: parsed.verb };
|
|
45758
|
+
}
|
|
45759
|
+
}
|
|
45760
|
+
function extractDeviceIdFromAction(action) {
|
|
45761
|
+
if (action.device) return action.device;
|
|
45762
|
+
const m2 = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? "");
|
|
45763
|
+
return m2 ? m2[1] : null;
|
|
45764
|
+
}
|
|
45765
|
+
|
|
45459
45766
|
// src/policy/validate.ts
|
|
45460
45767
|
var require4 = createRequire3(import.meta.url);
|
|
45461
45768
|
var addFormats = require4("ajv-formats");
|
|
45769
|
+
var POLICY_VALIDATION_LIMITATIONS = [
|
|
45770
|
+
"Does not resolve aliases against the live device inventory.",
|
|
45771
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
45772
|
+
];
|
|
45773
|
+
var POLICY_VALIDATION_LIVE_LIMITATIONS = [
|
|
45774
|
+
"Live inventory checks reflect a point-in-time device list snapshot.",
|
|
45775
|
+
"Does not verify commands against live capabilities or current firmware."
|
|
45776
|
+
];
|
|
45777
|
+
var HEX_MAC_DEVICE_ID_RE = /^[A-Fa-f0-9]{12}(?:-[A-Za-z0-9]{2,16})?$/;
|
|
45778
|
+
var HYPHENATED_DEVICE_ID_RE = /^[A-Za-z0-9]{2,32}(?:-[A-Za-z0-9]{2,32}){1,4}$/;
|
|
45462
45779
|
var validators = /* @__PURE__ */ new Map();
|
|
45463
45780
|
function getValidator(version2) {
|
|
45464
45781
|
const cached2 = validators.get(version2);
|
|
@@ -45502,6 +45819,13 @@ function getKeyNodeAt(doc, parentSegments, key) {
|
|
|
45502
45819
|
const pair = parent.items.find((p2) => (0, import_yaml2.isScalar)(p2.key) && String(p2.key.value) === key);
|
|
45503
45820
|
return pair?.key ?? null;
|
|
45504
45821
|
}
|
|
45822
|
+
function locateInstancePath(doc, lineCounter, instancePath) {
|
|
45823
|
+
const node = getNodeAt(doc, instancePathToSegments(instancePath));
|
|
45824
|
+
const range = node?.range;
|
|
45825
|
+
if (!range) return {};
|
|
45826
|
+
const pos = lineCounter.linePos(range[0]);
|
|
45827
|
+
return { line: pos.line, col: pos.col };
|
|
45828
|
+
}
|
|
45505
45829
|
function locateError(doc, lineCounter, err) {
|
|
45506
45830
|
const segments = instancePathToSegments(err.instancePath);
|
|
45507
45831
|
if (err.keyword === "additionalProperties") {
|
|
@@ -45587,6 +45911,8 @@ function unsupportedVersionResult(loaded, declared) {
|
|
|
45587
45911
|
return {
|
|
45588
45912
|
policyPath: loaded.path,
|
|
45589
45913
|
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
45914
|
+
validationScope: "schema+offline-semantics",
|
|
45915
|
+
limitations: [...POLICY_VALIDATION_LIMITATIONS],
|
|
45590
45916
|
valid: false,
|
|
45591
45917
|
errors: [
|
|
45592
45918
|
{
|
|
@@ -45601,6 +45927,58 @@ function unsupportedVersionResult(loaded, declared) {
|
|
|
45601
45927
|
]
|
|
45602
45928
|
};
|
|
45603
45929
|
}
|
|
45930
|
+
function escapeJsonPointerSegment(segment) {
|
|
45931
|
+
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
45932
|
+
}
|
|
45933
|
+
function isPlausibleDeviceId(value) {
|
|
45934
|
+
return HEX_MAC_DEVICE_ID_RE.test(value) || HYPHENATED_DEVICE_ID_RE.test(value);
|
|
45935
|
+
}
|
|
45936
|
+
function hasErrorAtPath(errors, path26) {
|
|
45937
|
+
return errors.some((err) => err.path === path26);
|
|
45938
|
+
}
|
|
45939
|
+
function resolvePolicyDeviceRef(raw, aliases) {
|
|
45940
|
+
if (!raw) return { ok: false, reason: "missing-device" };
|
|
45941
|
+
if (raw === "<id>") return { ok: false, reason: "missing-device" };
|
|
45942
|
+
if (Object.hasOwn(aliases, raw)) return { ok: true };
|
|
45943
|
+
if (isPlausibleDeviceId(raw)) return { ok: true };
|
|
45944
|
+
return { ok: false, reason: "unknown-device-ref" };
|
|
45945
|
+
}
|
|
45946
|
+
function collectAliasMap(data) {
|
|
45947
|
+
const aliases = data?.aliases;
|
|
45948
|
+
if (!aliases || typeof aliases !== "object") return {};
|
|
45949
|
+
return Object.fromEntries(
|
|
45950
|
+
Object.entries(aliases).filter(
|
|
45951
|
+
(entry) => typeof entry[0] === "string" && typeof entry[1] === "string"
|
|
45952
|
+
)
|
|
45953
|
+
);
|
|
45954
|
+
}
|
|
45955
|
+
function isDeviceStateConditionLike(value) {
|
|
45956
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
45957
|
+
const candidate = value;
|
|
45958
|
+
return typeof candidate.device === "string" && typeof candidate.field === "string" && typeof candidate.op === "string";
|
|
45959
|
+
}
|
|
45960
|
+
function collectConditionDeviceRefs(condition, path26) {
|
|
45961
|
+
if (!condition || typeof condition !== "object" || Array.isArray(condition)) return [];
|
|
45962
|
+
const out = [];
|
|
45963
|
+
if (isDeviceStateConditionLike(condition)) {
|
|
45964
|
+
out.push({ path: `${path26}/device`, ref: condition.device });
|
|
45965
|
+
}
|
|
45966
|
+
const candidate = condition;
|
|
45967
|
+
if (Array.isArray(candidate.all)) {
|
|
45968
|
+
for (let i = 0; i < candidate.all.length; i++) {
|
|
45969
|
+
out.push(...collectConditionDeviceRefs(candidate.all[i], `${path26}/all/${i}`));
|
|
45970
|
+
}
|
|
45971
|
+
}
|
|
45972
|
+
if (Array.isArray(candidate.any)) {
|
|
45973
|
+
for (let i = 0; i < candidate.any.length; i++) {
|
|
45974
|
+
out.push(...collectConditionDeviceRefs(candidate.any[i], `${path26}/any/${i}`));
|
|
45975
|
+
}
|
|
45976
|
+
}
|
|
45977
|
+
if (candidate.not !== void 0) {
|
|
45978
|
+
out.push(...collectConditionDeviceRefs(candidate.not, `${path26}/not`));
|
|
45979
|
+
}
|
|
45980
|
+
return out;
|
|
45981
|
+
}
|
|
45604
45982
|
function collectDestructiveRuleErrors(loaded) {
|
|
45605
45983
|
const data = loaded.data;
|
|
45606
45984
|
const rules = data?.automation?.rules;
|
|
@@ -45639,6 +46017,258 @@ function collectDestructiveRuleErrors(loaded) {
|
|
|
45639
46017
|
}
|
|
45640
46018
|
return out;
|
|
45641
46019
|
}
|
|
46020
|
+
function collectOfflineSemanticErrors(loaded, existingErrors) {
|
|
46021
|
+
const data = loaded.data;
|
|
46022
|
+
const out = [];
|
|
46023
|
+
const aliases = collectAliasMap(data);
|
|
46024
|
+
for (const [aliasName, deviceId] of Object.entries(aliases)) {
|
|
46025
|
+
const path26 = `/aliases/${escapeJsonPointerSegment(aliasName)}`;
|
|
46026
|
+
if (hasErrorAtPath(existingErrors, path26)) continue;
|
|
46027
|
+
if (isPlausibleDeviceId(deviceId)) continue;
|
|
46028
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
|
|
46029
|
+
out.push({
|
|
46030
|
+
path: path26,
|
|
46031
|
+
line,
|
|
46032
|
+
col,
|
|
46033
|
+
keyword: "alias-device-id",
|
|
46034
|
+
message: `alias "${aliasName}" does not point to a plausible SwitchBot deviceId`,
|
|
46035
|
+
hint: "use a deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212 or 28372F4C9C4A",
|
|
46036
|
+
schemaPath: "#/properties/aliases"
|
|
46037
|
+
});
|
|
46038
|
+
}
|
|
46039
|
+
const knownDeviceCommands = new Set(
|
|
46040
|
+
getEffectiveCatalog().flatMap((entry) => entry.commands).filter((spec) => spec.commandType !== "customize").map((spec) => spec.command)
|
|
46041
|
+
);
|
|
46042
|
+
const rules = data?.automation?.rules;
|
|
46043
|
+
if (!Array.isArray(rules)) return out;
|
|
46044
|
+
for (let ri = 0; ri < rules.length; ri++) {
|
|
46045
|
+
const rule = rules[ri];
|
|
46046
|
+
const ruleName = typeof rule?.name === "string" ? rule.name : `#${ri}`;
|
|
46047
|
+
if (typeof rule?.when?.device === "string") {
|
|
46048
|
+
const whenDevicePath = `/automation/rules/${ri}/when/device`;
|
|
46049
|
+
const resolved = resolvePolicyDeviceRef(rule.when.device, aliases);
|
|
46050
|
+
if (!resolved.ok) {
|
|
46051
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath);
|
|
46052
|
+
out.push({
|
|
46053
|
+
path: whenDevicePath,
|
|
46054
|
+
line,
|
|
46055
|
+
col,
|
|
46056
|
+
keyword: resolved.reason ?? "unknown-device-ref",
|
|
46057
|
+
message: `rule "${ruleName}" trigger references unknown device "${rule.when.device}"`,
|
|
46058
|
+
hint: "set `when.device` to a declared alias or a plausible deviceId",
|
|
46059
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/when/properties/device"
|
|
46060
|
+
});
|
|
46061
|
+
}
|
|
46062
|
+
}
|
|
46063
|
+
const conditions = Array.isArray(rule?.conditions) ? rule.conditions : [];
|
|
46064
|
+
for (let ci = 0; ci < conditions.length; ci++) {
|
|
46065
|
+
for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) {
|
|
46066
|
+
const resolved = resolvePolicyDeviceRef(ref.ref, aliases);
|
|
46067
|
+
if (!resolved.ok) {
|
|
46068
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path);
|
|
46069
|
+
out.push({
|
|
46070
|
+
path: ref.path,
|
|
46071
|
+
line,
|
|
46072
|
+
col,
|
|
46073
|
+
keyword: resolved.reason ?? "unknown-device-ref",
|
|
46074
|
+
message: `rule "${ruleName}" condition references unknown device "${ref.ref}"`,
|
|
46075
|
+
hint: "set condition `device` to a declared alias or a plausible deviceId",
|
|
46076
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/conditions"
|
|
46077
|
+
});
|
|
46078
|
+
}
|
|
46079
|
+
}
|
|
46080
|
+
}
|
|
46081
|
+
const actions = Array.isArray(rule?.then) ? rule.then : [];
|
|
46082
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
46083
|
+
const action = actions[ai];
|
|
46084
|
+
const cmd = action?.command;
|
|
46085
|
+
if (typeof cmd !== "string") continue;
|
|
46086
|
+
const commandPath = `/automation/rules/${ri}/then/${ai}/command`;
|
|
46087
|
+
const devicePath = `/automation/rules/${ri}/then/${ai}/device`;
|
|
46088
|
+
const parsed = parseRuleCommand(cmd);
|
|
46089
|
+
if (!parsed) {
|
|
46090
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46091
|
+
out.push({
|
|
46092
|
+
path: commandPath,
|
|
46093
|
+
line,
|
|
46094
|
+
col,
|
|
46095
|
+
keyword: "rule-unparseable-command",
|
|
46096
|
+
message: `rule "${ruleName}" action #${ai} must use \`devices command <id> <verb> [parameter...]\``,
|
|
46097
|
+
hint: "automation rules currently support only `devices command ...` actions; scenes/webhooks/other subcommands are not executable here",
|
|
46098
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46099
|
+
});
|
|
46100
|
+
continue;
|
|
46101
|
+
}
|
|
46102
|
+
if (!knownDeviceCommands.has(parsed.verb)) {
|
|
46103
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46104
|
+
out.push({
|
|
46105
|
+
path: commandPath,
|
|
46106
|
+
line,
|
|
46107
|
+
col,
|
|
46108
|
+
keyword: "rule-unknown-command",
|
|
46109
|
+
message: `rule "${ruleName}" action #${ai} uses unknown device command "${parsed.verb}"`,
|
|
46110
|
+
hint: "check `switchbot devices commands <type>` for valid verbs; this validator only checks offline catalog verbs, not the real target device",
|
|
46111
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46112
|
+
});
|
|
46113
|
+
}
|
|
46114
|
+
if (typeof action?.device === "string") {
|
|
46115
|
+
const resolved2 = resolvePolicyDeviceRef(action.device, aliases);
|
|
46116
|
+
if (!resolved2.ok) {
|
|
46117
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, devicePath);
|
|
46118
|
+
out.push({
|
|
46119
|
+
path: devicePath,
|
|
46120
|
+
line,
|
|
46121
|
+
col,
|
|
46122
|
+
keyword: resolved2.reason ?? "unknown-device-ref",
|
|
46123
|
+
message: `rule "${ruleName}" action #${ai} references unknown device "${action.device}"`,
|
|
46124
|
+
hint: "set `device:` to a declared alias or a plausible deviceId",
|
|
46125
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/device"
|
|
46126
|
+
});
|
|
46127
|
+
}
|
|
46128
|
+
continue;
|
|
46129
|
+
}
|
|
46130
|
+
const resolved = resolvePolicyDeviceRef(parsed.deviceIdSlot ?? void 0, aliases);
|
|
46131
|
+
if (!resolved.ok) {
|
|
46132
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46133
|
+
out.push({
|
|
46134
|
+
path: commandPath,
|
|
46135
|
+
line,
|
|
46136
|
+
col,
|
|
46137
|
+
keyword: resolved.reason ?? "missing-device",
|
|
46138
|
+
message: resolved.reason === "missing-device" ? `rule "${ruleName}" action #${ai} uses \`<id>\` but does not provide \`device:\`` : `rule "${ruleName}" action #${ai} references unknown device "${parsed.deviceIdSlot}"`,
|
|
46139
|
+
hint: resolved.reason === "missing-device" ? "either replace `<id>` with a deviceId/alias or add `device: <alias-or-deviceId>` to the action" : "use a declared alias or a plausible deviceId in the command slot",
|
|
46140
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46141
|
+
});
|
|
46142
|
+
}
|
|
46143
|
+
}
|
|
46144
|
+
}
|
|
46145
|
+
return out;
|
|
46146
|
+
}
|
|
46147
|
+
function resolveInventoryDeviceId(raw, aliases) {
|
|
46148
|
+
if (!raw || raw === "<id>") return null;
|
|
46149
|
+
if (Object.hasOwn(aliases, raw)) return aliases[raw];
|
|
46150
|
+
return raw;
|
|
46151
|
+
}
|
|
46152
|
+
function validateLoadedPolicyAgainstInventory(loaded, inventory) {
|
|
46153
|
+
const base = validateLoadedPolicy(loaded);
|
|
46154
|
+
const errors = [...base.errors];
|
|
46155
|
+
const aliases = collectAliasMap(loaded.data);
|
|
46156
|
+
const inventoryById = /* @__PURE__ */ new Map();
|
|
46157
|
+
for (const device of inventory.deviceList) {
|
|
46158
|
+
inventoryById.set(device.deviceId, { typeName: device.deviceType ?? "" });
|
|
46159
|
+
}
|
|
46160
|
+
for (const remote of inventory.infraredRemoteList) {
|
|
46161
|
+
inventoryById.set(remote.deviceId, { typeName: remote.remoteType });
|
|
46162
|
+
}
|
|
46163
|
+
for (const [aliasName, deviceId] of Object.entries(aliases)) {
|
|
46164
|
+
const path26 = `/aliases/${escapeJsonPointerSegment(aliasName)}`;
|
|
46165
|
+
if (hasErrorAtPath(errors, path26)) continue;
|
|
46166
|
+
if (!inventoryById.has(deviceId)) {
|
|
46167
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
|
|
46168
|
+
errors.push({
|
|
46169
|
+
path: path26,
|
|
46170
|
+
line,
|
|
46171
|
+
col,
|
|
46172
|
+
keyword: "alias-live-device-not-found",
|
|
46173
|
+
message: `alias "${aliasName}" points to deviceId "${deviceId}" which is not present in the current inventory`,
|
|
46174
|
+
hint: "refresh with `switchbot devices list` and confirm the alias target still exists on this account",
|
|
46175
|
+
schemaPath: "#/properties/aliases"
|
|
46176
|
+
});
|
|
46177
|
+
}
|
|
46178
|
+
}
|
|
46179
|
+
const data = loaded.data;
|
|
46180
|
+
const rules = data?.automation?.rules;
|
|
46181
|
+
if (Array.isArray(rules)) {
|
|
46182
|
+
for (let ri = 0; ri < rules.length; ri++) {
|
|
46183
|
+
const rule = rules[ri];
|
|
46184
|
+
const ruleName = typeof rule?.name === "string" ? rule.name : `#${ri}`;
|
|
46185
|
+
if (typeof rule?.when?.device === "string") {
|
|
46186
|
+
const whenDevicePath = `/automation/rules/${ri}/when/device`;
|
|
46187
|
+
const effectiveDeviceId = resolveInventoryDeviceId(rule.when.device, aliases);
|
|
46188
|
+
if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) {
|
|
46189
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath);
|
|
46190
|
+
errors.push({
|
|
46191
|
+
path: whenDevicePath,
|
|
46192
|
+
line,
|
|
46193
|
+
col,
|
|
46194
|
+
keyword: "rule-live-device-not-found",
|
|
46195
|
+
message: `rule "${ruleName}" trigger resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
|
|
46196
|
+
hint: "confirm `when.device` against `switchbot devices list` before relying on this policy",
|
|
46197
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/when/properties/device"
|
|
46198
|
+
});
|
|
46199
|
+
}
|
|
46200
|
+
}
|
|
46201
|
+
const conditions = Array.isArray(rule?.conditions) ? rule.conditions : [];
|
|
46202
|
+
for (let ci = 0; ci < conditions.length; ci++) {
|
|
46203
|
+
for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) {
|
|
46204
|
+
const effectiveDeviceId = resolveInventoryDeviceId(ref.ref, aliases);
|
|
46205
|
+
if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) {
|
|
46206
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path);
|
|
46207
|
+
errors.push({
|
|
46208
|
+
path: ref.path,
|
|
46209
|
+
line,
|
|
46210
|
+
col,
|
|
46211
|
+
keyword: "rule-live-device-not-found",
|
|
46212
|
+
message: `rule "${ruleName}" condition resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
|
|
46213
|
+
hint: "confirm the condition device against `switchbot devices list` before relying on this policy",
|
|
46214
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/conditions"
|
|
46215
|
+
});
|
|
46216
|
+
}
|
|
46217
|
+
}
|
|
46218
|
+
}
|
|
46219
|
+
const actions = Array.isArray(rule?.then) ? rule.then : [];
|
|
46220
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
46221
|
+
const action = actions[ai];
|
|
46222
|
+
const cmd = action?.command;
|
|
46223
|
+
if (typeof cmd !== "string") continue;
|
|
46224
|
+
const parsed = parseRuleCommand(cmd);
|
|
46225
|
+
if (!parsed) continue;
|
|
46226
|
+
const commandPath = `/automation/rules/${ri}/then/${ai}/command`;
|
|
46227
|
+
const devicePath = `/automation/rules/${ri}/then/${ai}/device`;
|
|
46228
|
+
const effectiveRef = typeof action?.device === "string" ? action.device : parsed.deviceIdSlot ?? void 0;
|
|
46229
|
+
const effectiveDeviceId = resolveInventoryDeviceId(effectiveRef, aliases);
|
|
46230
|
+
if (!effectiveDeviceId) continue;
|
|
46231
|
+
const target = inventoryById.get(effectiveDeviceId);
|
|
46232
|
+
if (!target) {
|
|
46233
|
+
const path26 = typeof action?.device === "string" ? devicePath : commandPath;
|
|
46234
|
+
const { line: line2, col: col2 } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
|
|
46235
|
+
errors.push({
|
|
46236
|
+
path: path26,
|
|
46237
|
+
line: line2,
|
|
46238
|
+
col: col2,
|
|
46239
|
+
keyword: "rule-live-device-not-found",
|
|
46240
|
+
message: `rule "${ruleName}" action #${ai} resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
|
|
46241
|
+
hint: "confirm the alias/deviceId against `switchbot devices list` before relying on this policy",
|
|
46242
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/device"
|
|
46243
|
+
});
|
|
46244
|
+
continue;
|
|
46245
|
+
}
|
|
46246
|
+
const match = target.typeName ? findCatalogEntry(target.typeName) : null;
|
|
46247
|
+
const entry = !match || Array.isArray(match) ? null : match;
|
|
46248
|
+
if (!entry) continue;
|
|
46249
|
+
const supported = entry.commands.filter((spec) => spec.commandType !== "customize").some((spec) => spec.command === parsed.verb);
|
|
46250
|
+
if (supported) continue;
|
|
46251
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46252
|
+
errors.push({
|
|
46253
|
+
path: commandPath,
|
|
46254
|
+
line,
|
|
46255
|
+
col,
|
|
46256
|
+
keyword: "rule-live-unsupported-command",
|
|
46257
|
+
message: `rule "${ruleName}" action #${ai} uses command "${parsed.verb}" but live target "${effectiveDeviceId}" is type "${target.typeName}"`,
|
|
46258
|
+
hint: `supported offline verbs for ${target.typeName}: ${entry.commands.filter((spec) => spec.commandType !== "customize").map((spec) => spec.command).join(", ")}`,
|
|
46259
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46260
|
+
});
|
|
46261
|
+
}
|
|
46262
|
+
}
|
|
46263
|
+
}
|
|
46264
|
+
return {
|
|
46265
|
+
...base,
|
|
46266
|
+
validationScope: "schema+offline-semantics+live-inventory",
|
|
46267
|
+
limitations: [...POLICY_VALIDATION_LIVE_LIMITATIONS],
|
|
46268
|
+
valid: errors.length === 0,
|
|
46269
|
+
errors
|
|
46270
|
+
};
|
|
46271
|
+
}
|
|
45642
46272
|
function validateLoadedPolicy(loaded) {
|
|
45643
46273
|
const declared = readDeclaredVersion(loaded.data);
|
|
45644
46274
|
if (declared !== void 0 && !isSupportedPolicySchemaVersion(declared)) {
|
|
@@ -45665,11 +46295,14 @@ function validateLoadedPolicy(loaded) {
|
|
|
45665
46295
|
if (version2 === "0.2") {
|
|
45666
46296
|
const ruleErrors = collectDestructiveRuleErrors(loaded);
|
|
45667
46297
|
errors.push(...ruleErrors);
|
|
46298
|
+
errors.push(...collectOfflineSemanticErrors(loaded, errors));
|
|
45668
46299
|
}
|
|
45669
46300
|
const valid = ok === true && errors.length === 0;
|
|
45670
46301
|
return {
|
|
45671
46302
|
policyPath: loaded.path,
|
|
45672
46303
|
schemaVersion: version2,
|
|
46304
|
+
validationScope: "schema+offline-semantics",
|
|
46305
|
+
limitations: [...POLICY_VALIDATION_LIMITATIONS],
|
|
45673
46306
|
valid,
|
|
45674
46307
|
errors
|
|
45675
46308
|
};
|
|
@@ -45751,6 +46384,15 @@ var COMMAND_KEYWORDS = [
|
|
|
45751
46384
|
{ pattern: /\bclose\b|\blower\b|\bdown\b/i, command: "close" },
|
|
45752
46385
|
{ pattern: /\bpause\b/i, command: "pause" }
|
|
45753
46386
|
];
|
|
46387
|
+
function inferCommandFromIntent(intent) {
|
|
46388
|
+
for (const k2 of COMMAND_KEYWORDS) {
|
|
46389
|
+
if (k2.pattern.test(intent)) return k2.command;
|
|
46390
|
+
}
|
|
46391
|
+
return void 0;
|
|
46392
|
+
}
|
|
46393
|
+
function containsCjk(intent) {
|
|
46394
|
+
return /[\u3400-\u9FFF]/u.test(intent);
|
|
46395
|
+
}
|
|
45754
46396
|
|
|
45755
46397
|
// src/lib/plan-store.ts
|
|
45756
46398
|
init_cjs_shim();
|
|
@@ -45956,14 +46598,13 @@ function validatePlan(raw) {
|
|
|
45956
46598
|
}
|
|
45957
46599
|
function suggestPlan(opts) {
|
|
45958
46600
|
const warnings = [];
|
|
45959
|
-
let command = "";
|
|
45960
|
-
for (const k2 of COMMAND_KEYWORDS) {
|
|
45961
|
-
if (k2.pattern.test(opts.intent)) {
|
|
45962
|
-
command = k2.command;
|
|
45963
|
-
break;
|
|
45964
|
-
}
|
|
45965
|
-
}
|
|
46601
|
+
let command = inferCommandFromIntent(opts.intent) ?? "";
|
|
45966
46602
|
if (!command) {
|
|
46603
|
+
if (containsCjk(opts.intent)) {
|
|
46604
|
+
throw new UsageError(
|
|
46605
|
+
`Intent "${opts.intent}" contains non-English command text that this heuristic cannot safely infer. Use explicit English command words (turnOn/turnOff/open/close/lock/unlock/press/pause) or author the plan manually.`
|
|
46606
|
+
);
|
|
46607
|
+
}
|
|
45967
46608
|
command = "turnOn";
|
|
45968
46609
|
warnings.push(
|
|
45969
46610
|
`Could not infer command from intent "${opts.intent}" \u2014 defaulted to "turnOn". Edit the generated plan to set the correct command.`
|
|
@@ -46012,7 +46653,7 @@ async function executePlanSteps(plan, planId, options) {
|
|
|
46012
46653
|
const out = {
|
|
46013
46654
|
plan,
|
|
46014
46655
|
results: [],
|
|
46015
|
-
summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0 }
|
|
46656
|
+
summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0, dryRun: 0 }
|
|
46016
46657
|
};
|
|
46017
46658
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
46018
46659
|
const step = plan.steps[i];
|
|
@@ -46071,8 +46712,8 @@ async function executePlanSteps(plan, planId, options) {
|
|
|
46071
46712
|
if (!isJsonMode()) console.log(` ${idx}. \u2713 ${step.command} on ${resolvedDeviceId}`);
|
|
46072
46713
|
} catch (err) {
|
|
46073
46714
|
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
46074
|
-
out.results.push({ step: idx, type: "command", deviceId: resolvedDeviceId, command: step.command, status: "
|
|
46075
|
-
out.summary.
|
|
46715
|
+
out.results.push({ step: idx, type: "command", deviceId: resolvedDeviceId, command: step.command, status: "dry-run" });
|
|
46716
|
+
out.summary.dryRun++;
|
|
46076
46717
|
if (!isJsonMode()) console.log(` ${idx}. \u25E6 dry-run ${step.command} on ${resolvedDeviceId}`);
|
|
46077
46718
|
continue;
|
|
46078
46719
|
}
|
|
@@ -46155,25 +46796,29 @@ against the live API without executing any mutations.
|
|
|
46155
46796
|
(v2, prev) => [...prev, v2],
|
|
46156
46797
|
[]
|
|
46157
46798
|
).option("--out <file>", "Write plan JSON to file instead of stdout").action((opts) => {
|
|
46158
|
-
|
|
46159
|
-
|
|
46160
|
-
|
|
46161
|
-
|
|
46162
|
-
|
|
46163
|
-
const
|
|
46164
|
-
|
|
46165
|
-
|
|
46166
|
-
|
|
46167
|
-
|
|
46799
|
+
try {
|
|
46800
|
+
if (opts.device.length === 0) {
|
|
46801
|
+
console.error("error: at least one --device is required");
|
|
46802
|
+
process.exit(1);
|
|
46803
|
+
}
|
|
46804
|
+
const devices = opts.device.map((ref) => {
|
|
46805
|
+
const cached2 = getCachedDevice(ref);
|
|
46806
|
+
return { id: ref, name: cached2?.name, type: cached2?.type };
|
|
46807
|
+
});
|
|
46808
|
+
const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
|
|
46809
|
+
for (const w2 of warnings) process.stderr.write(`warning: ${w2}
|
|
46168
46810
|
`);
|
|
46169
|
-
|
|
46170
|
-
|
|
46171
|
-
|
|
46172
|
-
|
|
46173
|
-
|
|
46174
|
-
|
|
46175
|
-
|
|
46176
|
-
|
|
46811
|
+
const json3 = JSON.stringify(suggested, null, 2);
|
|
46812
|
+
if (opts.out) {
|
|
46813
|
+
fs13.writeFileSync(opts.out, json3 + "\n", "utf8");
|
|
46814
|
+
if (!isJsonMode()) console.log(`\u2713 plan written to ${opts.out}`);
|
|
46815
|
+
} else if (isJsonMode()) {
|
|
46816
|
+
printJson({ plan: suggested, warnings });
|
|
46817
|
+
} else {
|
|
46818
|
+
console.log(json3);
|
|
46819
|
+
}
|
|
46820
|
+
} catch (err) {
|
|
46821
|
+
handleError(err);
|
|
46177
46822
|
}
|
|
46178
46823
|
});
|
|
46179
46824
|
plan.command("run").description("Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default").argument("[file]", 'Path to plan.json, or "-" / omit to read stdin').option("--yes", "Authorize destructive commands (e.g. Smart Lock unlock, Garage open)").option("--require-approval", "Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)").option("--continue-on-error", "Keep running after a failed step (default: stop at first error)").action(
|
|
@@ -46373,6 +47018,13 @@ summary: ok=${ok} error=${error48} skipped=${skipped} total=${out.summary.total}
|
|
|
46373
47018
|
// src/rules/suggest.ts
|
|
46374
47019
|
init_cjs_shim();
|
|
46375
47020
|
var import_yaml4 = __toESM(require_dist(), 1);
|
|
47021
|
+
init_output();
|
|
47022
|
+
function buildSuggestedAction(command, deviceId) {
|
|
47023
|
+
if (deviceId) {
|
|
47024
|
+
return { command: `devices command ${deviceId} ${command}` };
|
|
47025
|
+
}
|
|
47026
|
+
return { command: `devices command <id> ${command}` };
|
|
47027
|
+
}
|
|
46376
47028
|
var TRIGGER_KEYWORDS = [
|
|
46377
47029
|
{ pattern: /\bmotion\b|\bdetect/i, trigger: "mqtt", event: "motion.detected" },
|
|
46378
47030
|
{ pattern: /\bdoor\b|\bcontact\b|\bopen.*sensor/i, trigger: "mqtt", event: "contact.opened" },
|
|
@@ -46400,8 +47052,12 @@ function inferSchedule(intent, warnings) {
|
|
|
46400
47052
|
return "0 8 * * *";
|
|
46401
47053
|
}
|
|
46402
47054
|
function inferCommand(intent, warnings) {
|
|
46403
|
-
|
|
46404
|
-
|
|
47055
|
+
const command = inferCommandFromIntent(intent);
|
|
47056
|
+
if (command) return command;
|
|
47057
|
+
if (containsCjk(intent)) {
|
|
47058
|
+
throw new UsageError(
|
|
47059
|
+
`Intent "${intent}" contains non-English command text that this heuristic cannot safely infer. Use explicit English command words (turnOn/turnOff/open/close/lock/unlock/press/pause) or edit the generated rule manually.`
|
|
47060
|
+
);
|
|
46405
47061
|
}
|
|
46406
47062
|
warnings.push(
|
|
46407
47063
|
`Could not infer command from intent "${intent}" \u2014 defaulted to "turnOn". Edit the generated rule to set the correct command.`
|
|
@@ -46410,6 +47066,7 @@ function inferCommand(intent, warnings) {
|
|
|
46410
47066
|
}
|
|
46411
47067
|
function suggestRule(opts) {
|
|
46412
47068
|
const warnings = [];
|
|
47069
|
+
const cjkIntent = containsCjk(opts.intent);
|
|
46413
47070
|
let triggerSource = opts.trigger;
|
|
46414
47071
|
let inferredEvent;
|
|
46415
47072
|
if (!triggerSource) {
|
|
@@ -46417,6 +47074,11 @@ function suggestRule(opts) {
|
|
|
46417
47074
|
triggerSource = inferred.trigger;
|
|
46418
47075
|
inferredEvent = inferred.event;
|
|
46419
47076
|
if (inferredEvent === "device.shadow") {
|
|
47077
|
+
if (cjkIntent) {
|
|
47078
|
+
throw new UsageError(
|
|
47079
|
+
`Intent "${opts.intent}" contains non-English trigger text that this heuristic cannot safely infer. Re-run with --trigger and, for mqtt rules, --event explicitly.`
|
|
47080
|
+
);
|
|
47081
|
+
}
|
|
46420
47082
|
warnings.push(
|
|
46421
47083
|
`Could not infer trigger type from intent "${opts.intent}" \u2014 defaulted to mqtt/device.shadow. Set --trigger and --event explicitly.`
|
|
46422
47084
|
);
|
|
@@ -46432,6 +47094,11 @@ function suggestRule(opts) {
|
|
|
46432
47094
|
}
|
|
46433
47095
|
when = mqttTrigger;
|
|
46434
47096
|
} else if (triggerSource === "cron") {
|
|
47097
|
+
if (cjkIntent && !opts.schedule) {
|
|
47098
|
+
throw new UsageError(
|
|
47099
|
+
`Intent "${opts.intent}" contains non-English scheduling text that this heuristic cannot safely infer. Re-run with --schedule "<cron>" explicitly.`
|
|
47100
|
+
);
|
|
47101
|
+
}
|
|
46435
47102
|
const schedule = opts.schedule ?? inferSchedule(opts.intent, warnings);
|
|
46436
47103
|
const cronTrigger = { source: "cron", schedule };
|
|
46437
47104
|
if (opts.days && opts.days.length > 0) cronTrigger.days = opts.days;
|
|
@@ -46441,10 +47108,7 @@ function suggestRule(opts) {
|
|
|
46441
47108
|
}
|
|
46442
47109
|
const command = inferCommand(opts.intent, warnings);
|
|
46443
47110
|
const actionDevices = triggerSource === "mqtt" && opts.devices && opts.devices.length > 1 ? opts.devices.slice(1) : opts.devices ?? [];
|
|
46444
|
-
const then = actionDevices.length > 0 ? actionDevices.map((d) => (
|
|
46445
|
-
command: `devices command <id> ${command}`,
|
|
46446
|
-
device: d.id
|
|
46447
|
-
})) : [{ command: `devices command <id> ${command}` }];
|
|
47111
|
+
const then = actionDevices.length > 0 ? actionDevices.map((d) => buildSuggestedAction(command, d.id)) : [buildSuggestedAction(command)];
|
|
46448
47112
|
const rule = {
|
|
46449
47113
|
name: opts.intent,
|
|
46450
47114
|
when,
|
|
@@ -47514,14 +48178,17 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47514
48178
|
"policy_validate",
|
|
47515
48179
|
{
|
|
47516
48180
|
title: "Validate a policy.yaml file",
|
|
47517
|
-
description: "Check a policy file against the embedded JSON Schema
|
|
48181
|
+
description: "Check a policy file against the embedded JSON Schema, offline command/device semantics, and local safety guards. By default this stays offline; set live=true to resolve aliases and rule targets against the current account inventory. It still does not verify commands against live capabilities, current firmware, or other runtime-only device behavior. When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.",
|
|
47518
48182
|
_meta: { agentSafetyTier: "read" },
|
|
47519
48183
|
inputSchema: external_exports.object({
|
|
47520
|
-
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path")
|
|
48184
|
+
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
48185
|
+
live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
|
|
47521
48186
|
}).strict(),
|
|
47522
48187
|
outputSchema: {
|
|
47523
48188
|
policyPath: external_exports.string(),
|
|
47524
48189
|
schemaVersion: external_exports.string(),
|
|
48190
|
+
validationScope: external_exports.string(),
|
|
48191
|
+
limitations: external_exports.array(external_exports.string()),
|
|
47525
48192
|
present: external_exports.boolean().describe("false when the file does not exist"),
|
|
47526
48193
|
valid: external_exports.boolean().nullable().describe("null when present=false"),
|
|
47527
48194
|
errors: external_exports.array(external_exports.object({
|
|
@@ -47535,14 +48202,25 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47535
48202
|
})).describe("Empty when valid or when the file is missing")
|
|
47536
48203
|
}
|
|
47537
48204
|
},
|
|
47538
|
-
async ({ path: pathArg }) => {
|
|
48205
|
+
async ({ path: pathArg, live }) => {
|
|
47539
48206
|
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
47540
48207
|
try {
|
|
47541
48208
|
const loaded = loadPolicyFile(policyPath);
|
|
47542
|
-
|
|
48209
|
+
let result = validateLoadedPolicy(loaded);
|
|
48210
|
+
if (live) {
|
|
48211
|
+
if (!tryLoadConfig()) {
|
|
48212
|
+
return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
|
|
48213
|
+
hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
48214
|
+
});
|
|
48215
|
+
}
|
|
48216
|
+
const inventory = await fetchDeviceList(void 0, { bypassCache: true });
|
|
48217
|
+
result = validateLoadedPolicyAgainstInventory(loaded, inventory);
|
|
48218
|
+
}
|
|
47543
48219
|
const structured = {
|
|
47544
48220
|
policyPath: result.policyPath,
|
|
47545
48221
|
schemaVersion: result.schemaVersion,
|
|
48222
|
+
validationScope: result.validationScope,
|
|
48223
|
+
limitations: result.limitations,
|
|
47546
48224
|
present: true,
|
|
47547
48225
|
valid: result.valid,
|
|
47548
48226
|
errors: result.errors
|
|
@@ -47556,6 +48234,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47556
48234
|
const structured = {
|
|
47557
48235
|
policyPath,
|
|
47558
48236
|
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
48237
|
+
validationScope: "schema+offline-semantics",
|
|
48238
|
+
limitations: [
|
|
48239
|
+
"Does not resolve aliases against the live device inventory.",
|
|
48240
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
48241
|
+
],
|
|
47559
48242
|
present: false,
|
|
47560
48243
|
valid: null,
|
|
47561
48244
|
errors: []
|
|
@@ -47569,6 +48252,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47569
48252
|
const structured = {
|
|
47570
48253
|
policyPath,
|
|
47571
48254
|
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
48255
|
+
validationScope: "schema+offline-semantics",
|
|
48256
|
+
limitations: [
|
|
48257
|
+
"Does not resolve aliases against the live device inventory.",
|
|
48258
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
48259
|
+
],
|
|
47572
48260
|
present: true,
|
|
47573
48261
|
valid: false,
|
|
47574
48262
|
errors: err.yamlErrors.map((e) => ({
|
|
@@ -47634,7 +48322,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47634
48322
|
"policy_migrate",
|
|
47635
48323
|
{
|
|
47636
48324
|
title: "Migrate a policy file to the latest supported schema",
|
|
47637
|
-
description:
|
|
48325
|
+
description: 'Rewrites a policy file between schema versions this CLI still supports while preserving comments. Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten and the tool returns status="precheck-failed" with the list of errors. Pass dryRun=true to preview without touching the file. This release only supports v0.2, so legacy v0.1 files are reported as unsupported rather than migrated.',
|
|
47638
48326
|
_meta: { agentSafetyTier: "action" },
|
|
47639
48327
|
inputSchema: external_exports.object({
|
|
47640
48328
|
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
@@ -47704,10 +48392,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47704
48392
|
};
|
|
47705
48393
|
}
|
|
47706
48394
|
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
48395
|
+
const isLegacy = fileVersion === "0.1";
|
|
47707
48396
|
const structured2 = {
|
|
47708
48397
|
...base,
|
|
47709
48398
|
status: "unsupported",
|
|
47710
|
-
message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
|
|
48399
|
+
message: isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
|
|
47711
48400
|
};
|
|
47712
48401
|
return {
|
|
47713
48402
|
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
@@ -48060,7 +48749,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
48060
48749
|
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
48061
48750
|
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
48062
48751
|
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
48063
|
-
results: external_exports.array(external_exports.enum(["ok", "error"])).optional().describe("Filter by execution result."),
|
|
48752
|
+
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
48064
48753
|
limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
|
|
48065
48754
|
}).strict(),
|
|
48066
48755
|
outputSchema: {
|
|
@@ -48113,7 +48802,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
48113
48802
|
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
48114
48803
|
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
48115
48804
|
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
48116
|
-
results: external_exports.array(external_exports.enum(["ok", "error"])).optional().describe("Filter by execution result."),
|
|
48805
|
+
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
48117
48806
|
top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
|
|
48118
48807
|
}).strict(),
|
|
48119
48808
|
outputSchema: {
|
|
@@ -48261,6 +48950,29 @@ function listRegisteredTools(server) {
|
|
|
48261
48950
|
if (!internal._registeredTools) return [];
|
|
48262
48951
|
return Object.keys(internal._registeredTools).sort();
|
|
48263
48952
|
}
|
|
48953
|
+
function listRegisteredResources() {
|
|
48954
|
+
return ["switchbot://events"];
|
|
48955
|
+
}
|
|
48956
|
+
function printMcpToolDirectory() {
|
|
48957
|
+
const server = createSwitchBotMcpServer();
|
|
48958
|
+
const tools = listRegisteredTools(server).map((name) => ({ name }));
|
|
48959
|
+
const resources = listRegisteredResources().map((uri) => ({ uri }));
|
|
48960
|
+
if (isJsonMode()) {
|
|
48961
|
+
printJson({ tools, resources });
|
|
48962
|
+
return;
|
|
48963
|
+
}
|
|
48964
|
+
console.log("Tools:");
|
|
48965
|
+
for (const tool of tools) {
|
|
48966
|
+
console.log(` ${tool.name}`);
|
|
48967
|
+
}
|
|
48968
|
+
console.log("");
|
|
48969
|
+
console.log("Resources:");
|
|
48970
|
+
for (const resource of resources) {
|
|
48971
|
+
console.log(` ${resource.uri}`);
|
|
48972
|
+
}
|
|
48973
|
+
console.log(`
|
|
48974
|
+
Total: ${tools.length} tool(s), ${resources.length} resource(s)`);
|
|
48975
|
+
}
|
|
48264
48976
|
function registerMcpCommand(program3) {
|
|
48265
48977
|
const mcp = program3.command("mcp").description("Run as a Model Context Protocol server so AI agents can call SwitchBot tools").addHelpText("after", `
|
|
48266
48978
|
The MCP server exposes twenty-one tools:
|
|
@@ -48275,9 +48987,10 @@ function registerMcpCommand(program3) {
|
|
|
48275
48987
|
- get_device_history fetch raw JSONL history records for a device
|
|
48276
48988
|
- query_device_history filter + page history records with field/time predicates
|
|
48277
48989
|
- aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
|
|
48278
|
-
- policy_validate check policy.yaml against the embedded schema
|
|
48990
|
+
- policy_validate check policy.yaml against the embedded schema + offline semantics
|
|
48991
|
+
(set live=true to resolve aliases and rule targets against current inventory)
|
|
48279
48992
|
- policy_new scaffold a starter policy.yaml (action \u2014 confirm first)
|
|
48280
|
-
- policy_migrate
|
|
48993
|
+
- policy_migrate rewrite policy.yaml between currently supported schemas (action \u2014 preserves comments)
|
|
48281
48994
|
- policy_diff compare two policy files with structural + line diff output
|
|
48282
48995
|
- plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
|
|
48283
48996
|
- plan_run validate + execute a Plan JSON document
|
|
@@ -48309,6 +49022,8 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
|
|
|
48309
49022
|
Inspect locally:
|
|
48310
49023
|
$ npx @modelcontextprotocol/inspector switchbot mcp serve
|
|
48311
49024
|
`);
|
|
49025
|
+
mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() => printMcpToolDirectory());
|
|
49026
|
+
mcp.command("list-tools").description("Alias of `mcp tools`").action(() => printMcpToolDirectory());
|
|
48312
49027
|
mcp.command("serve").description("Start the MCP server on stdio (default) or HTTP (--port)").option("--port <n>", "Listen on HTTP instead of stdio (Streamable HTTP transport)", intArg("--port", { min: 1, max: 65535 })).option("--bind <host>", "IP address to bind (default 127.0.0.1; use 0.0.0.0 to accept external connections)", stringArg("--bind"), "127.0.0.1").option("--auth-token <token>", "Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)", stringArg("--auth-token")).option("--cors-origin <url>", "Allowed CORS origin(s) for HTTP (repeatable)", stringArg("--cors-origin")).option("--rate-limit <n>", "Max requests per minute per profile (default 60)", intArg("--rate-limit", { min: 1 }), "60").addHelpText("after", `
|
|
48313
49028
|
Examples:
|
|
48314
49029
|
$ switchbot mcp serve
|
|
@@ -48550,6 +49265,42 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
48550
49265
|
init_cjs_shim();
|
|
48551
49266
|
init_output();
|
|
48552
49267
|
init_quota();
|
|
49268
|
+
function runQuotaStatus() {
|
|
49269
|
+
const usage = todayUsage();
|
|
49270
|
+
const history = loadQuota();
|
|
49271
|
+
if (isJsonMode()) {
|
|
49272
|
+
printJson({
|
|
49273
|
+
today: {
|
|
49274
|
+
date: usage.date,
|
|
49275
|
+
total: usage.total,
|
|
49276
|
+
remaining: usage.remaining,
|
|
49277
|
+
dailyLimit: DAILY_QUOTA,
|
|
49278
|
+
endpoints: usage.endpoints
|
|
49279
|
+
},
|
|
49280
|
+
history: history.days
|
|
49281
|
+
});
|
|
49282
|
+
return;
|
|
49283
|
+
}
|
|
49284
|
+
console.log(`Today (${usage.date}):`);
|
|
49285
|
+
console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`);
|
|
49286
|
+
console.log(` Remaining budget: ${usage.remaining}`);
|
|
49287
|
+
if (Object.keys(usage.endpoints).length === 0) {
|
|
49288
|
+
console.log(" (no requests recorded yet)");
|
|
49289
|
+
} else {
|
|
49290
|
+
console.log(" Endpoint breakdown:");
|
|
49291
|
+
const entries = Object.entries(usage.endpoints).sort((a, b2) => b2[1] - a[1]);
|
|
49292
|
+
for (const [endpoint, count] of entries) {
|
|
49293
|
+
console.log(` ${endpoint.padEnd(48)} ${count}`);
|
|
49294
|
+
}
|
|
49295
|
+
}
|
|
49296
|
+
const otherDays = Object.entries(history.days).filter(([d]) => d !== usage.date).sort((a, b2) => b2[0].localeCompare(a[0]));
|
|
49297
|
+
if (otherDays.length > 0) {
|
|
49298
|
+
console.log("\nRecent history:");
|
|
49299
|
+
for (const [date5, bucket] of otherDays) {
|
|
49300
|
+
console.log(` ${date5} ${bucket.total}`);
|
|
49301
|
+
}
|
|
49302
|
+
}
|
|
49303
|
+
}
|
|
48553
49304
|
function registerQuotaCommand(program3) {
|
|
48554
49305
|
const quota = program3.command("quota").description("Inspect and manage the local SwitchBot API request counter").addHelpText("after", `
|
|
48555
49306
|
Every request the CLI makes is counted locally in ~/.switchbot/quota.json.
|
|
@@ -48567,41 +49318,11 @@ Examples:
|
|
|
48567
49318
|
$ switchbot quota status --json
|
|
48568
49319
|
$ switchbot quota reset
|
|
48569
49320
|
`);
|
|
49321
|
+
quota.action(() => {
|
|
49322
|
+
runQuotaStatus();
|
|
49323
|
+
});
|
|
48570
49324
|
quota.command("status").alias("show").description("Show today's usage and the last 7 days (alias: show)").action(() => {
|
|
48571
|
-
|
|
48572
|
-
const history = loadQuota();
|
|
48573
|
-
if (isJsonMode()) {
|
|
48574
|
-
printJson({
|
|
48575
|
-
today: {
|
|
48576
|
-
date: usage.date,
|
|
48577
|
-
total: usage.total,
|
|
48578
|
-
remaining: usage.remaining,
|
|
48579
|
-
dailyLimit: DAILY_QUOTA,
|
|
48580
|
-
endpoints: usage.endpoints
|
|
48581
|
-
},
|
|
48582
|
-
history: history.days
|
|
48583
|
-
});
|
|
48584
|
-
return;
|
|
48585
|
-
}
|
|
48586
|
-
console.log(`Today (${usage.date}):`);
|
|
48587
|
-
console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`);
|
|
48588
|
-
console.log(` Remaining budget: ${usage.remaining}`);
|
|
48589
|
-
if (Object.keys(usage.endpoints).length === 0) {
|
|
48590
|
-
console.log(" (no requests recorded yet)");
|
|
48591
|
-
} else {
|
|
48592
|
-
console.log(" Endpoint breakdown:");
|
|
48593
|
-
const entries = Object.entries(usage.endpoints).sort((a, b2) => b2[1] - a[1]);
|
|
48594
|
-
for (const [endpoint, count] of entries) {
|
|
48595
|
-
console.log(` ${endpoint.padEnd(48)} ${count}`);
|
|
48596
|
-
}
|
|
48597
|
-
}
|
|
48598
|
-
const otherDays = Object.entries(history.days).filter(([d]) => d !== usage.date).sort((a, b2) => b2[0].localeCompare(a[0]));
|
|
48599
|
-
if (otherDays.length > 0) {
|
|
48600
|
-
console.log("\nRecent history:");
|
|
48601
|
-
for (const [date5, bucket] of otherDays) {
|
|
48602
|
-
console.log(` ${date5} ${bucket.total}`);
|
|
48603
|
-
}
|
|
48604
|
-
}
|
|
49325
|
+
runQuotaStatus();
|
|
48605
49326
|
});
|
|
48606
49327
|
quota.command("reset").description("Delete the local quota counter file").action(() => {
|
|
48607
49328
|
resetQuota();
|
|
@@ -49306,6 +50027,13 @@ function extractDeviceId(parsed) {
|
|
|
49306
50027
|
if (typeof id === "string" && id.length > 0) return id;
|
|
49307
50028
|
return null;
|
|
49308
50029
|
}
|
|
50030
|
+
function emitJsonStreamRecord(record2) {
|
|
50031
|
+
const { schemaVersion, ...rest } = record2;
|
|
50032
|
+
printJson({
|
|
50033
|
+
payloadVersion: schemaVersion,
|
|
50034
|
+
...rest
|
|
50035
|
+
});
|
|
50036
|
+
}
|
|
49309
50037
|
function matchFilterDetail(body, clauses) {
|
|
49310
50038
|
if (!clauses || clauses.length === 0) return { matched: true, matchedKeys: [] };
|
|
49311
50039
|
if (!body || typeof body !== "object") return { matched: false, matchedKeys: [] };
|
|
@@ -49461,7 +50189,7 @@ Examples:
|
|
|
49461
50189
|
if (!ev.matched) return;
|
|
49462
50190
|
matchedCount++;
|
|
49463
50191
|
if (isJsonMode()) {
|
|
49464
|
-
|
|
50192
|
+
emitJsonStreamRecord(ev);
|
|
49465
50193
|
} else {
|
|
49466
50194
|
const when = new Date(ev.t).toLocaleTimeString();
|
|
49467
50195
|
console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`);
|
|
@@ -49608,7 +50336,7 @@ Examples:
|
|
|
49608
50336
|
if (isJsonMode()) emitStreamHeader({ eventKind: "event", cadence: "push" });
|
|
49609
50337
|
if (isJsonMode()) {
|
|
49610
50338
|
const sessionStartAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
49611
|
-
|
|
50339
|
+
emitJsonStreamRecord({
|
|
49612
50340
|
schemaVersion: EVENTS_SCHEMA_VERSION,
|
|
49613
50341
|
source: "mqtt",
|
|
49614
50342
|
kind: "control",
|
|
@@ -49660,7 +50388,7 @@ Examples:
|
|
|
49660
50388
|
payload: parsed
|
|
49661
50389
|
};
|
|
49662
50390
|
if (isJsonMode()) {
|
|
49663
|
-
|
|
50391
|
+
emitJsonStreamRecord(record2);
|
|
49664
50392
|
} else {
|
|
49665
50393
|
console.log(JSON.stringify(record2));
|
|
49666
50394
|
}
|
|
@@ -49692,7 +50420,7 @@ Examples:
|
|
|
49692
50420
|
at
|
|
49693
50421
|
};
|
|
49694
50422
|
if (isJsonMode()) {
|
|
49695
|
-
|
|
50423
|
+
emitJsonStreamRecord(ctl);
|
|
49696
50424
|
} else {
|
|
49697
50425
|
console.log(JSON.stringify(ctl));
|
|
49698
50426
|
}
|
|
@@ -49959,6 +50687,33 @@ Examples:
|
|
|
49959
50687
|
|
|
49960
50688
|
// src/commands/doctor.ts
|
|
49961
50689
|
init_catalog();
|
|
50690
|
+
|
|
50691
|
+
// src/version-notes.ts
|
|
50692
|
+
init_cjs_shim();
|
|
50693
|
+
var RELEASE_METADATA = [];
|
|
50694
|
+
function semverParts(v2) {
|
|
50695
|
+
const [maj, min, pat] = v2.replace(/-.*$/, "").split(".").map((n) => Number.parseInt(n, 10));
|
|
50696
|
+
return [maj ?? 0, min ?? 0, pat ?? 0];
|
|
50697
|
+
}
|
|
50698
|
+
function semverCompare(a, b2) {
|
|
50699
|
+
const [aMaj, aMin, aPat] = semverParts(a);
|
|
50700
|
+
const [bMaj, bMin, bPat] = semverParts(b2);
|
|
50701
|
+
if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
|
|
50702
|
+
if (aMin !== bMin) return aMin < bMin ? -1 : 1;
|
|
50703
|
+
if (aPat !== bPat) return aPat < bPat ? -1 : 1;
|
|
50704
|
+
const aPre = a.includes("-");
|
|
50705
|
+
const bPre = b2.includes("-");
|
|
50706
|
+
if (aPre === bPre) return 0;
|
|
50707
|
+
return aPre ? -1 : 1;
|
|
50708
|
+
}
|
|
50709
|
+
function findBreakingChangeBetween(current, latest) {
|
|
50710
|
+
return RELEASE_METADATA.filter((m2) => m2.breaking && semverCompare(m2.version, current) > 0 && semverCompare(m2.version, latest) <= 0).sort((a, b2) => semverCompare(a.version, b2.version))[0] ?? null;
|
|
50711
|
+
}
|
|
50712
|
+
function getReleaseMetadata(version2) {
|
|
50713
|
+
return RELEASE_METADATA.find((m2) => m2.version === version2) ?? null;
|
|
50714
|
+
}
|
|
50715
|
+
|
|
50716
|
+
// src/commands/doctor.ts
|
|
49962
50717
|
init_keychain();
|
|
49963
50718
|
init_request_context();
|
|
49964
50719
|
|
|
@@ -50344,6 +51099,42 @@ function checkCatalogSchema() {
|
|
|
50344
51099
|
}
|
|
50345
51100
|
};
|
|
50346
51101
|
}
|
|
51102
|
+
function checkInventoryConsistency() {
|
|
51103
|
+
const cache2 = loadCache();
|
|
51104
|
+
if (!cache2) {
|
|
51105
|
+
return {
|
|
51106
|
+
name: "inventory",
|
|
51107
|
+
status: "ok",
|
|
51108
|
+
detail: "no local inventory cache \u2014 run 'switchbot devices list' to enable hub-reference checks"
|
|
51109
|
+
};
|
|
51110
|
+
}
|
|
51111
|
+
const dangling = Object.entries(cache2.devices).filter(([, device]) => device.category === "physical").filter(([deviceId, device]) => {
|
|
51112
|
+
const hubDeviceId = device.hubDeviceId;
|
|
51113
|
+
return Boolean(
|
|
51114
|
+
hubDeviceId && hubDeviceId !== "000000000000" && hubDeviceId !== deviceId && !cache2.devices[hubDeviceId]
|
|
51115
|
+
);
|
|
51116
|
+
}).map(([deviceId, device]) => ({
|
|
51117
|
+
deviceId,
|
|
51118
|
+
deviceName: device.name,
|
|
51119
|
+
hubDeviceId: device.hubDeviceId,
|
|
51120
|
+
deviceType: device.type
|
|
51121
|
+
}));
|
|
51122
|
+
if (dangling.length === 0) {
|
|
51123
|
+
return {
|
|
51124
|
+
name: "inventory",
|
|
51125
|
+
status: "ok",
|
|
51126
|
+
detail: `inventory graph consistent across ${Object.keys(cache2.devices).length} cached devices`
|
|
51127
|
+
};
|
|
51128
|
+
}
|
|
51129
|
+
return {
|
|
51130
|
+
name: "inventory",
|
|
51131
|
+
status: "warn",
|
|
51132
|
+
detail: {
|
|
51133
|
+
message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`,
|
|
51134
|
+
dangling: dangling.slice(0, 10)
|
|
51135
|
+
}
|
|
51136
|
+
};
|
|
51137
|
+
}
|
|
50347
51138
|
function checkAudit() {
|
|
50348
51139
|
const p2 = path15.join(os16.homedir(), ".switchbot", "audit.log");
|
|
50349
51140
|
if (!fs19.existsSync(p2)) {
|
|
@@ -50782,6 +51573,26 @@ function checkMcp() {
|
|
|
50782
51573
|
};
|
|
50783
51574
|
}
|
|
50784
51575
|
}
|
|
51576
|
+
function checkReleaseNotes() {
|
|
51577
|
+
const meta4 = getReleaseMetadata(VERSION);
|
|
51578
|
+
if (!meta4 || !meta4.breaking) {
|
|
51579
|
+
return {
|
|
51580
|
+
name: "release-notes",
|
|
51581
|
+
status: "ok",
|
|
51582
|
+
detail: { version: VERSION, message: "no known breaking-change notice for the current release" }
|
|
51583
|
+
};
|
|
51584
|
+
}
|
|
51585
|
+
return {
|
|
51586
|
+
name: "release-notes",
|
|
51587
|
+
status: "warn",
|
|
51588
|
+
detail: {
|
|
51589
|
+
version: VERSION,
|
|
51590
|
+
breaking: true,
|
|
51591
|
+
message: meta4.summary,
|
|
51592
|
+
hint: "If you have scripts pinned to 3.2.x JSON output, update them before rolling this release wider."
|
|
51593
|
+
}
|
|
51594
|
+
};
|
|
51595
|
+
}
|
|
50785
51596
|
var CHECK_REGISTRY = [
|
|
50786
51597
|
{ name: "node", description: "Node.js version compatibility", run: () => checkNodeVersion() },
|
|
50787
51598
|
{ name: "path", description: "switchbot binary reachable on PATH", run: () => checkPathDiscoverability() },
|
|
@@ -50790,6 +51601,7 @@ var CHECK_REGISTRY = [
|
|
|
50790
51601
|
{ name: "profiles", description: "profile definitions valid", run: () => checkProfiles() },
|
|
50791
51602
|
{ name: "catalog", description: "catalog loads", run: () => checkCatalog() },
|
|
50792
51603
|
{ name: "catalog-schema", description: "catalog vs agent-bootstrap version aligned", run: () => checkCatalogSchema() },
|
|
51604
|
+
{ name: "inventory", description: "cached inventory graph consistency (hubDeviceId references)", run: () => checkInventoryConsistency() },
|
|
50793
51605
|
{ name: "cache", description: "device cache state", run: () => checkCache() },
|
|
50794
51606
|
{ name: "quota", description: "API quota headroom", run: () => checkQuotaFile() },
|
|
50795
51607
|
{ name: "clock", description: "system clock skew", run: () => checkClockSkew() },
|
|
@@ -50799,6 +51611,7 @@ var CHECK_REGISTRY = [
|
|
|
50799
51611
|
run: ({ probe }) => probe ? checkMqttProbe() : checkMqtt()
|
|
50800
51612
|
},
|
|
50801
51613
|
{ name: "mcp", description: "MCP server instantiable + tool count", run: () => checkMcp() },
|
|
51614
|
+
{ name: "release-notes", description: "current release breaking-change notice", run: () => checkReleaseNotes() },
|
|
50802
51615
|
{ name: "policy", description: "policy.yaml present + schema-valid (if configured)", run: () => checkPolicy() },
|
|
50803
51616
|
{ name: "audit", description: "recent command errors (last 24h)", run: () => checkAudit() },
|
|
50804
51617
|
{ name: "daemon", description: "daemon state file + runtime status", run: () => checkDaemon() },
|
|
@@ -51003,88 +51816,50 @@ function projectFields2(entry, fields) {
|
|
|
51003
51816
|
}
|
|
51004
51817
|
return out;
|
|
51005
51818
|
}
|
|
51006
|
-
function
|
|
51007
|
-
const
|
|
51008
|
-
|
|
51009
|
-
|
|
51010
|
-
|
|
51011
|
-
|
|
51012
|
-
|
|
51013
|
-
|
|
51014
|
-
|
|
51015
|
-
|
|
51016
|
-
|
|
51017
|
-
|
|
51018
|
-
|
|
51019
|
-
|
|
51020
|
-
|
|
51021
|
-
|
|
51022
|
-
|
|
51023
|
-
|
|
51024
|
-
|
|
51025
|
-
|
|
51026
|
-
|
|
51027
|
-
|
|
51028
|
-
|
|
51029
|
-
|
|
51030
|
-
|
|
51031
|
-
|
|
51032
|
-
|
|
51033
|
-
|
|
51034
|
-
$ switchbot schema export --role lighting | jq '[.data.types[].type]'
|
|
51035
|
-
$ switchbot schema export --role security --category physical
|
|
51036
|
-
$ switchbot schema export --project type,commands,statusFields
|
|
51037
|
-
`).action(async (options) => {
|
|
51038
|
-
const catalog = getEffectiveCatalog();
|
|
51039
|
-
let filtered = catalog;
|
|
51040
|
-
if (options.type) {
|
|
51041
|
-
const q = options.type.toLowerCase();
|
|
51042
|
-
filtered = filtered.filter(
|
|
51043
|
-
(e) => e.type.toLowerCase() === q || (e.aliases ?? []).some((a) => a.toLowerCase() === q)
|
|
51819
|
+
function runSchemaExport(options) {
|
|
51820
|
+
const catalog = getEffectiveCatalog();
|
|
51821
|
+
let filtered = catalog;
|
|
51822
|
+
if (options.type) {
|
|
51823
|
+
const q = options.type.toLowerCase();
|
|
51824
|
+
filtered = filtered.filter(
|
|
51825
|
+
(e) => e.type.toLowerCase() === q || (e.aliases ?? []).some((a) => a.toLowerCase() === q)
|
|
51826
|
+
);
|
|
51827
|
+
}
|
|
51828
|
+
if (options.types) {
|
|
51829
|
+
const set3 = new Set(options.types.split(",").map((s2) => s2.trim().toLowerCase()).filter(Boolean));
|
|
51830
|
+
filtered = filtered.filter(
|
|
51831
|
+
(e) => set3.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => set3.has(a.toLowerCase()))
|
|
51832
|
+
);
|
|
51833
|
+
}
|
|
51834
|
+
if (options.role) {
|
|
51835
|
+
const q = options.role.toLowerCase();
|
|
51836
|
+
filtered = filtered.filter((e) => (e.role ?? "other") === q);
|
|
51837
|
+
}
|
|
51838
|
+
if (options.category) {
|
|
51839
|
+
const q = options.category.toLowerCase();
|
|
51840
|
+
filtered = filtered.filter((e) => e.category === q);
|
|
51841
|
+
}
|
|
51842
|
+
if (options.used) {
|
|
51843
|
+
const cache2 = loadCache();
|
|
51844
|
+
if (cache2) {
|
|
51845
|
+
const usedTypes = new Set(
|
|
51846
|
+
Object.values(cache2.devices).map((d) => d.type.toLowerCase())
|
|
51044
51847
|
);
|
|
51045
|
-
}
|
|
51046
|
-
if (options.types) {
|
|
51047
|
-
const set3 = new Set(options.types.split(",").map((s2) => s2.trim().toLowerCase()).filter(Boolean));
|
|
51048
51848
|
filtered = filtered.filter(
|
|
51049
|
-
(e) =>
|
|
51849
|
+
(e) => usedTypes.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase()))
|
|
51050
51850
|
);
|
|
51851
|
+
} else {
|
|
51852
|
+
filtered = [];
|
|
51051
51853
|
}
|
|
51052
|
-
|
|
51053
|
-
|
|
51054
|
-
|
|
51055
|
-
|
|
51056
|
-
|
|
51057
|
-
|
|
51058
|
-
filtered = filtered.filter((e) => e.category === q);
|
|
51059
|
-
}
|
|
51060
|
-
if (options.used) {
|
|
51061
|
-
const cache2 = loadCache();
|
|
51062
|
-
if (cache2) {
|
|
51063
|
-
const usedTypes = new Set(
|
|
51064
|
-
Object.values(cache2.devices).map((d) => d.type.toLowerCase())
|
|
51065
|
-
);
|
|
51066
|
-
filtered = filtered.filter(
|
|
51067
|
-
(e) => usedTypes.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase()))
|
|
51068
|
-
);
|
|
51069
|
-
} else {
|
|
51070
|
-
filtered = [];
|
|
51071
|
-
}
|
|
51072
|
-
}
|
|
51073
|
-
const mapped = options.compact ? filtered.map(toCompactEntry) : filtered.map(toSchemaEntry);
|
|
51074
|
-
const projected = options.project ? mapped.map(
|
|
51075
|
-
(e) => projectFields2(e, options.project.split(",").map((s2) => s2.trim()).filter(Boolean))
|
|
51076
|
-
) : mapped;
|
|
51077
|
-
let finalTypes = projected;
|
|
51078
|
-
if (options.capabilities) {
|
|
51079
|
-
const { COMMAND_META: COMMAND_META2 } = await Promise.resolve().then(() => (init_capabilities(), capabilities_exports));
|
|
51080
|
-
const devicesMeta = Object.fromEntries(
|
|
51081
|
-
Object.entries(COMMAND_META2).filter(([k2]) => k2.startsWith("devices "))
|
|
51082
|
-
);
|
|
51083
|
-
finalTypes = finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta }));
|
|
51084
|
-
}
|
|
51854
|
+
}
|
|
51855
|
+
const mapped = options.compact ? filtered.map(toCompactEntry) : filtered.map(toSchemaEntry);
|
|
51856
|
+
const projected = options.project ? mapped.map(
|
|
51857
|
+
(e) => projectFields2(e, options.project.split(",").map((s2) => s2.trim()).filter(Boolean))
|
|
51858
|
+
) : mapped;
|
|
51859
|
+
const finish = (finalTypes2) => {
|
|
51085
51860
|
const payload = {
|
|
51086
51861
|
version: "1.0",
|
|
51087
|
-
types:
|
|
51862
|
+
types: finalTypes2
|
|
51088
51863
|
};
|
|
51089
51864
|
if (!options.compact) {
|
|
51090
51865
|
payload.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -51117,6 +51892,56 @@ Examples:
|
|
|
51117
51892
|
];
|
|
51118
51893
|
}
|
|
51119
51894
|
printJson(payload);
|
|
51895
|
+
};
|
|
51896
|
+
const finalTypes = projected;
|
|
51897
|
+
if (options.capabilities) {
|
|
51898
|
+
return Promise.resolve().then(() => (init_capabilities(), capabilities_exports)).then(({ COMMAND_META: COMMAND_META2 }) => {
|
|
51899
|
+
const devicesMeta = Object.fromEntries(
|
|
51900
|
+
Object.entries(COMMAND_META2).filter(([k2]) => k2.startsWith("devices "))
|
|
51901
|
+
);
|
|
51902
|
+
finish(finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta })));
|
|
51903
|
+
});
|
|
51904
|
+
}
|
|
51905
|
+
finish(finalTypes);
|
|
51906
|
+
}
|
|
51907
|
+
function registerSchemaCommand(program3) {
|
|
51908
|
+
const ROLES = ["lighting", "security", "sensor", "climate", "media", "cleaning", "curtain", "fan", "power", "hub", "other"];
|
|
51909
|
+
const CATEGORIES = ["physical", "ir"];
|
|
51910
|
+
const schema2 = program3.command("schema").description("Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)").option("--type <type>", 'Restrict to a single device type (e.g. "Strip Light")', stringArg("--type")).option("--types <csv>", "Restrict to multiple device types (comma-separated)", stringArg("--types")).option("--role <role>", "Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other", enumArg("--role", ROLES)).option("--category <cat>", 'Restrict to "physical" or "ir"', enumArg("--category", CATEGORIES)).option("--compact", "Drop descriptions/aliases/example params \u2014 emit ~60% smaller payload. Useful for agent prompts.").option("--used", 'Restrict to device types present in the local devices cache (run "devices list" first)').option("--project <csv>", "Project per-type fields (e.g. --project type,commands,statusFields)", stringArg("--project")).option("--capabilities", "Annotate each device type with CLI command safety metadata (agentSafetyTier, mutating, consumesQuota)");
|
|
51911
|
+
schema2.action(async (options) => {
|
|
51912
|
+
await runSchemaExport(options);
|
|
51913
|
+
});
|
|
51914
|
+
schema2.command("export").description("Print the catalog as structured JSON (one object per type)").addHelpText("after", `
|
|
51915
|
+
Output is always JSON (this command ignores --format). The output is a
|
|
51916
|
+
catalog export \u2014 not a formal JSON Schema standard document \u2014 suitable for
|
|
51917
|
+
pre-baking LLM prompts or regenerating docs when the catalog changes.
|
|
51918
|
+
\`statusFields\` are advisory offline hints; actual live status can differ by
|
|
51919
|
+
firmware and device variant.
|
|
51920
|
+
|
|
51921
|
+
Size tips:
|
|
51922
|
+
--compact --used Smallest realistic payload for a given account
|
|
51923
|
+
(< 15 KB on most accounts).
|
|
51924
|
+
--fields type,commands Strip statusFields / role / etc. when only
|
|
51925
|
+
commands are needed.
|
|
51926
|
+
--type + --compact Inspect one type with minimum footprint.
|
|
51927
|
+
|
|
51928
|
+
Common top-level fields:
|
|
51929
|
+
schemaVersion CLI schema version (stable for agent contracts)
|
|
51930
|
+
data.version Catalog schema version
|
|
51931
|
+
data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
|
|
51932
|
+
data._fetchedAt CLI-added; present on live-query responses ('devices status'),
|
|
51933
|
+
not on this offline export.
|
|
51934
|
+
|
|
51935
|
+
Examples:
|
|
51936
|
+
$ switchbot schema export > catalog.json
|
|
51937
|
+
$ switchbot schema export --compact --used | wc -c # small prompt-ready payload
|
|
51938
|
+
$ switchbot schema export --type Bot | jq '.data.types[0].commands'
|
|
51939
|
+
$ switchbot schema export --types "Bot,Curtain,Color Bulb"
|
|
51940
|
+
$ switchbot schema export --role lighting | jq '[.data.types[].type]'
|
|
51941
|
+
$ switchbot schema export --role security --category physical
|
|
51942
|
+
$ switchbot schema export --project type,commands,statusFields
|
|
51943
|
+
`).action(async (_options, cmd) => {
|
|
51944
|
+
await runSchemaExport(cmd.optsWithGlobals());
|
|
51120
51945
|
});
|
|
51121
51946
|
}
|
|
51122
51947
|
|
|
@@ -51442,6 +52267,7 @@ function formatValidationResult(result, source, opts = {}) {
|
|
|
51442
52267
|
}
|
|
51443
52268
|
|
|
51444
52269
|
// src/commands/policy.ts
|
|
52270
|
+
init_config();
|
|
51445
52271
|
var LATEST_SUPPORTED_VERSION2 = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
|
|
51446
52272
|
function readEmbeddedTemplate() {
|
|
51447
52273
|
return readPolicyExampleYaml();
|
|
@@ -51484,6 +52310,18 @@ function exitPolicyError(kind, message, extra = {}) {
|
|
|
51484
52310
|
}
|
|
51485
52311
|
process.exit(code);
|
|
51486
52312
|
}
|
|
52313
|
+
function validationScopeLine(scope) {
|
|
52314
|
+
if (scope === "schema+offline-semantics+live-inventory") {
|
|
52315
|
+
return "Validation scope: schema + offline semantics + live inventory checks + local safety guards.";
|
|
52316
|
+
}
|
|
52317
|
+
return "Validation scope: schema + offline semantics + local safety guards.";
|
|
52318
|
+
}
|
|
52319
|
+
function validationNotCheckedLine(scope) {
|
|
52320
|
+
if (scope === "schema+offline-semantics+live-inventory") {
|
|
52321
|
+
return "Not checked: live capabilities, current firmware, and runtime-only device behavior.";
|
|
52322
|
+
}
|
|
52323
|
+
return "Not checked: alias targets against live devices; command support on the real target device, live capabilities, or current firmware.";
|
|
52324
|
+
}
|
|
51487
52325
|
function summarizeChangeValue(v2) {
|
|
51488
52326
|
if (v2 === null) return "null";
|
|
51489
52327
|
if (v2 === void 0) return "undefined";
|
|
@@ -51502,10 +52340,11 @@ audit log path, and which actions always or never need confirmation.
|
|
|
51502
52340
|
Default location: ${DEFAULT_POLICY_PATH}
|
|
51503
52341
|
|
|
51504
52342
|
Subcommands:
|
|
51505
|
-
validate [path] Check a policy file against the embedded schema
|
|
52343
|
+
validate [path] Check a policy file against the embedded schema + offline semantics
|
|
52344
|
+
(add --live to resolve aliases and rule targets against current inventory)
|
|
51506
52345
|
new [path] Write a starter policy to the default location (or a given path)
|
|
51507
|
-
migrate [path]
|
|
51508
|
-
(v${CURRENT_POLICY_SCHEMA_VERSION}
|
|
52346
|
+
migrate [path] Rewrite a policy file between schema versions this CLI still supports
|
|
52347
|
+
(this build only supports v${CURRENT_POLICY_SCHEMA_VERSION}; legacy v0.1 files cannot be migrated here)
|
|
51509
52348
|
diff <left> <right>
|
|
51510
52349
|
Compare two policy files and print structural + line diff
|
|
51511
52350
|
add-rule Append a rule YAML (from stdin) into automation.rules[]
|
|
@@ -51534,7 +52373,9 @@ Examples:
|
|
|
51534
52373
|
$ switchbot policy diff ./policy.before.yaml ./policy.after.yaml
|
|
51535
52374
|
`
|
|
51536
52375
|
);
|
|
51537
|
-
policy.command("validate [path]").description(
|
|
52376
|
+
policy.command("validate [path]").description(
|
|
52377
|
+
`Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema, offline semantics, and local safety guards`
|
|
52378
|
+
).option("--live", "Also resolve aliases and rule target devices against the current account inventory (1 API call)").option("--no-color", "disable ANSI color in human output").option("--no-snippet", "omit the source-line + caret preview").action(async (pathArg, opts) => {
|
|
51538
52379
|
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
51539
52380
|
let loaded;
|
|
51540
52381
|
try {
|
|
@@ -51554,7 +52395,22 @@ Examples:
|
|
|
51554
52395
|
}
|
|
51555
52396
|
exitPolicyError("internal", `unexpected error loading policy: ${String(err)}`);
|
|
51556
52397
|
}
|
|
51557
|
-
|
|
52398
|
+
let result = validateLoadedPolicy(loaded);
|
|
52399
|
+
if (opts.live) {
|
|
52400
|
+
if (!tryLoadConfig()) {
|
|
52401
|
+
exitWithError({
|
|
52402
|
+
code: 1,
|
|
52403
|
+
kind: "runtime",
|
|
52404
|
+
message: "policy validate --live requires configured SwitchBot credentials.",
|
|
52405
|
+
extra: {
|
|
52406
|
+
hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
52407
|
+
}
|
|
52408
|
+
});
|
|
52409
|
+
return;
|
|
52410
|
+
}
|
|
52411
|
+
const inventory = await fetchDeviceList(void 0, { bypassCache: true });
|
|
52412
|
+
result = validateLoadedPolicyAgainstInventory(loaded, inventory);
|
|
52413
|
+
}
|
|
51558
52414
|
if (isJsonMode()) {
|
|
51559
52415
|
printJson(result);
|
|
51560
52416
|
process.exit(result.valid ? 0 : 1);
|
|
@@ -51565,10 +52421,13 @@ Examples:
|
|
|
51565
52421
|
noSnippet: opts.snippet === false
|
|
51566
52422
|
})
|
|
51567
52423
|
);
|
|
52424
|
+
console.log("");
|
|
52425
|
+
console.log(validationScopeLine(result.validationScope));
|
|
52426
|
+
console.log(validationNotCheckedLine(result.validationScope));
|
|
51568
52427
|
process.exit(result.valid ? 0 : 1);
|
|
51569
52428
|
});
|
|
51570
|
-
policy.command("new [path]").description("Write a starter policy.yaml (fails if the file exists unless --force)").option("-f, --force", "overwrite an existing policy file").action((pathArg, opts) => {
|
|
51571
|
-
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
52429
|
+
policy.command("new [path]").description("Write a starter policy.yaml (fails if the file exists unless --force)").option("-o, --output <path>", "write the starter policy to this path (alias of positional [path])").option("-f, --force", "overwrite an existing policy file").action((pathArg, opts) => {
|
|
52430
|
+
const policyPath = resolvePolicyPath({ flag: opts.output ?? pathArg });
|
|
51572
52431
|
const force = opts.force === true;
|
|
51573
52432
|
let result;
|
|
51574
52433
|
try {
|
|
@@ -51597,7 +52456,7 @@ Examples:
|
|
|
51597
52456
|
console.log(` 2. run \`switchbot policy validate\``);
|
|
51598
52457
|
}
|
|
51599
52458
|
});
|
|
51600
|
-
policy.command("migrate [path]").description(`
|
|
52459
|
+
policy.command("migrate [path]").description(`Rewrite a policy file between schema versions supported by this CLI (currently only v${LATEST_SUPPORTED_VERSION2})`).option("--dry-run", "show what would change without writing the file").option(
|
|
51601
52460
|
"--to <version>",
|
|
51602
52461
|
`target schema version (default: ${LATEST_SUPPORTED_VERSION2})`,
|
|
51603
52462
|
LATEST_SUPPORTED_VERSION2
|
|
@@ -51637,8 +52496,9 @@ Examples:
|
|
|
51637
52496
|
return;
|
|
51638
52497
|
}
|
|
51639
52498
|
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
51640
|
-
const
|
|
51641
|
-
const
|
|
52499
|
+
const isLegacy = fileVersion === "0.1";
|
|
52500
|
+
const message = isLegacy ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` : `policy schema v${fileVersion} is not supported by this CLI (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`;
|
|
52501
|
+
const hint = isLegacy ? "use @switchbot/openapi-cli <=2.15 to migrate v0.1 first, then upgrade back to this release" : "upgrade @switchbot/openapi-cli, or downgrade the policy file to a supported version";
|
|
51642
52502
|
if (isJsonMode())
|
|
51643
52503
|
emitJsonError({ code: 6, kind: "unsupported-version", ...basePayload, message, hint });
|
|
51644
52504
|
else {
|
|
@@ -52258,183 +53118,6 @@ var ThrottleGate = class {
|
|
|
52258
53118
|
}
|
|
52259
53119
|
};
|
|
52260
53120
|
|
|
52261
|
-
// src/rules/action.ts
|
|
52262
|
-
init_cjs_shim();
|
|
52263
|
-
var DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
|
|
52264
|
-
function parseRuleCommand(cmd) {
|
|
52265
|
-
const m2 = DEVICES_COMMAND_RE.exec(cmd.trim());
|
|
52266
|
-
if (!m2) return null;
|
|
52267
|
-
const deviceIdSlot = m2[1];
|
|
52268
|
-
const verb = m2[2];
|
|
52269
|
-
const rest = (m2[3] ?? "").trim();
|
|
52270
|
-
return {
|
|
52271
|
-
deviceIdSlot,
|
|
52272
|
-
verb,
|
|
52273
|
-
parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/)
|
|
52274
|
-
};
|
|
52275
|
-
}
|
|
52276
|
-
function resolveActionDevice(explicit, slot, aliases) {
|
|
52277
|
-
const candidate = explicit ?? (slot && slot !== "<id>" ? slot : null);
|
|
52278
|
-
if (!candidate) return null;
|
|
52279
|
-
if (aliases[candidate]) return aliases[candidate];
|
|
52280
|
-
return candidate;
|
|
52281
|
-
}
|
|
52282
|
-
function renderParameter(tokens) {
|
|
52283
|
-
if (tokens.length === 0) return void 0;
|
|
52284
|
-
if (tokens.length === 1) return tokens[0];
|
|
52285
|
-
return tokens.join(":");
|
|
52286
|
-
}
|
|
52287
|
-
async function executeRuleAction(action, ctx) {
|
|
52288
|
-
const parsed = parseRuleCommand(action.command);
|
|
52289
|
-
if (!parsed) {
|
|
52290
|
-
writeAudit({
|
|
52291
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52292
|
-
kind: "rule-fire",
|
|
52293
|
-
deviceId: "unknown",
|
|
52294
|
-
command: action.command,
|
|
52295
|
-
parameter: null,
|
|
52296
|
-
commandType: "command",
|
|
52297
|
-
dryRun: true,
|
|
52298
|
-
result: "error",
|
|
52299
|
-
error: "unparseable-command",
|
|
52300
|
-
rule: {
|
|
52301
|
-
name: ctx.rule.name,
|
|
52302
|
-
triggerSource: ctx.rule.when.source,
|
|
52303
|
-
fireId: ctx.fireId,
|
|
52304
|
-
reason: "unparseable-command"
|
|
52305
|
-
}
|
|
52306
|
-
});
|
|
52307
|
-
return { ok: false, error: "unparseable-command", blocked: true };
|
|
52308
|
-
}
|
|
52309
|
-
if (isDestructiveCommand2(action.command)) {
|
|
52310
|
-
writeAudit({
|
|
52311
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52312
|
-
kind: "rule-fire",
|
|
52313
|
-
deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? "unknown",
|
|
52314
|
-
command: action.command,
|
|
52315
|
-
parameter: null,
|
|
52316
|
-
commandType: "command",
|
|
52317
|
-
dryRun: true,
|
|
52318
|
-
result: "error",
|
|
52319
|
-
error: `destructive-verb:${parsed.verb}`,
|
|
52320
|
-
rule: {
|
|
52321
|
-
name: ctx.rule.name,
|
|
52322
|
-
triggerSource: ctx.rule.when.source,
|
|
52323
|
-
fireId: ctx.fireId,
|
|
52324
|
-
reason: `destructive verb "${parsed.verb}" refused at runtime`
|
|
52325
|
-
}
|
|
52326
|
-
});
|
|
52327
|
-
return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
|
|
52328
|
-
}
|
|
52329
|
-
const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
|
|
52330
|
-
if (!deviceId || deviceId === "<id>") {
|
|
52331
|
-
writeAudit({
|
|
52332
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52333
|
-
kind: "rule-fire",
|
|
52334
|
-
deviceId: "unknown",
|
|
52335
|
-
command: action.command,
|
|
52336
|
-
parameter: null,
|
|
52337
|
-
commandType: "command",
|
|
52338
|
-
dryRun: true,
|
|
52339
|
-
result: "error",
|
|
52340
|
-
error: "missing-device",
|
|
52341
|
-
rule: {
|
|
52342
|
-
name: ctx.rule.name,
|
|
52343
|
-
triggerSource: ctx.rule.when.source,
|
|
52344
|
-
fireId: ctx.fireId,
|
|
52345
|
-
reason: "action omitted `device` and command used `<id>` placeholder"
|
|
52346
|
-
}
|
|
52347
|
-
});
|
|
52348
|
-
return { ok: false, error: "missing-device", verb: parsed.verb };
|
|
52349
|
-
}
|
|
52350
|
-
const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
|
|
52351
|
-
const parameter = renderParameter(parsed.parameterTokens);
|
|
52352
|
-
if (dryRun) {
|
|
52353
|
-
writeAudit({
|
|
52354
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52355
|
-
kind: "rule-fire-dry",
|
|
52356
|
-
deviceId,
|
|
52357
|
-
command: parsed.verb,
|
|
52358
|
-
parameter: parameter ?? "default",
|
|
52359
|
-
commandType: "command",
|
|
52360
|
-
dryRun: true,
|
|
52361
|
-
result: "ok",
|
|
52362
|
-
rule: {
|
|
52363
|
-
name: ctx.rule.name,
|
|
52364
|
-
triggerSource: ctx.rule.when.source,
|
|
52365
|
-
matchedDevice: deviceId,
|
|
52366
|
-
fireId: ctx.fireId
|
|
52367
|
-
}
|
|
52368
|
-
});
|
|
52369
|
-
return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
|
|
52370
|
-
}
|
|
52371
|
-
if (ctx.skipApiCall) {
|
|
52372
|
-
writeAudit({
|
|
52373
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52374
|
-
kind: "rule-fire",
|
|
52375
|
-
deviceId,
|
|
52376
|
-
command: parsed.verb,
|
|
52377
|
-
parameter: parameter ?? "default",
|
|
52378
|
-
commandType: "command",
|
|
52379
|
-
dryRun: false,
|
|
52380
|
-
result: "ok",
|
|
52381
|
-
rule: {
|
|
52382
|
-
name: ctx.rule.name,
|
|
52383
|
-
triggerSource: ctx.rule.when.source,
|
|
52384
|
-
matchedDevice: deviceId,
|
|
52385
|
-
fireId: ctx.fireId,
|
|
52386
|
-
reason: "api-skipped"
|
|
52387
|
-
}
|
|
52388
|
-
});
|
|
52389
|
-
return { ok: true, deviceId, verb: parsed.verb };
|
|
52390
|
-
}
|
|
52391
|
-
try {
|
|
52392
|
-
await executeCommand(deviceId, parsed.verb, parameter, "command", ctx.httpClient);
|
|
52393
|
-
writeAudit({
|
|
52394
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52395
|
-
kind: "rule-fire",
|
|
52396
|
-
deviceId,
|
|
52397
|
-
command: parsed.verb,
|
|
52398
|
-
parameter: parameter ?? "default",
|
|
52399
|
-
commandType: "command",
|
|
52400
|
-
dryRun: false,
|
|
52401
|
-
result: "ok",
|
|
52402
|
-
rule: {
|
|
52403
|
-
name: ctx.rule.name,
|
|
52404
|
-
triggerSource: ctx.rule.when.source,
|
|
52405
|
-
matchedDevice: deviceId,
|
|
52406
|
-
fireId: ctx.fireId
|
|
52407
|
-
}
|
|
52408
|
-
});
|
|
52409
|
-
return { ok: true, deviceId, verb: parsed.verb };
|
|
52410
|
-
} catch (err) {
|
|
52411
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
52412
|
-
writeAudit({
|
|
52413
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52414
|
-
kind: "rule-fire",
|
|
52415
|
-
deviceId,
|
|
52416
|
-
command: parsed.verb,
|
|
52417
|
-
parameter: parameter ?? "default",
|
|
52418
|
-
commandType: "command",
|
|
52419
|
-
dryRun: false,
|
|
52420
|
-
result: "error",
|
|
52421
|
-
error: msg,
|
|
52422
|
-
rule: {
|
|
52423
|
-
name: ctx.rule.name,
|
|
52424
|
-
triggerSource: ctx.rule.when.source,
|
|
52425
|
-
matchedDevice: deviceId,
|
|
52426
|
-
fireId: ctx.fireId
|
|
52427
|
-
}
|
|
52428
|
-
});
|
|
52429
|
-
return { ok: false, error: msg, deviceId, verb: parsed.verb };
|
|
52430
|
-
}
|
|
52431
|
-
}
|
|
52432
|
-
function extractDeviceIdFromAction(action) {
|
|
52433
|
-
if (action.device) return action.device;
|
|
52434
|
-
const m2 = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? "");
|
|
52435
|
-
return m2 ? m2[1] : null;
|
|
52436
|
-
}
|
|
52437
|
-
|
|
52438
53121
|
// src/rules/cron-scheduler.ts
|
|
52439
53122
|
init_cjs_shim();
|
|
52440
53123
|
|
|
@@ -54874,30 +55557,34 @@ function registerSuggest(rules) {
|
|
|
54874
55557
|
[]
|
|
54875
55558
|
).option("--event <type>", "MQTT event name override (e.g. motion.detected)").option("--schedule <cron>", "5-field cron expression override").option("--days <days>", "Weekday filter, comma-separated (e.g. mon,tue,wed,thu,fri)").option("--webhook-path <path>", "Webhook path override (default: /action)").option("--out <file>", "Write YAML to file instead of stdout").action(
|
|
54876
55559
|
(opts) => {
|
|
54877
|
-
|
|
54878
|
-
|
|
54879
|
-
|
|
54880
|
-
const
|
|
54881
|
-
|
|
54882
|
-
|
|
54883
|
-
|
|
54884
|
-
|
|
54885
|
-
|
|
54886
|
-
|
|
54887
|
-
|
|
54888
|
-
|
|
54889
|
-
|
|
54890
|
-
|
|
54891
|
-
|
|
54892
|
-
|
|
55560
|
+
try {
|
|
55561
|
+
const trigger = opts.trigger;
|
|
55562
|
+
const days = opts.days ? opts.days.split(",").map((d) => d.trim()) : void 0;
|
|
55563
|
+
const devices = opts.device.map((ref) => {
|
|
55564
|
+
const cached2 = getCachedDevice(ref);
|
|
55565
|
+
return { id: ref, name: cached2?.name, type: cached2?.type };
|
|
55566
|
+
});
|
|
55567
|
+
const { rule, ruleYaml, warnings } = suggestRule({
|
|
55568
|
+
intent: opts.intent,
|
|
55569
|
+
trigger,
|
|
55570
|
+
devices,
|
|
55571
|
+
event: opts.event,
|
|
55572
|
+
schedule: opts.schedule,
|
|
55573
|
+
days,
|
|
55574
|
+
webhookPath: opts.webhookPath
|
|
55575
|
+
});
|
|
55576
|
+
for (const w2 of warnings) process.stderr.write(`warning: ${w2}
|
|
54893
55577
|
`);
|
|
54894
|
-
|
|
54895
|
-
|
|
54896
|
-
|
|
54897
|
-
|
|
54898
|
-
|
|
54899
|
-
|
|
54900
|
-
|
|
55578
|
+
if (opts.out) {
|
|
55579
|
+
fs21.writeFileSync(opts.out, ruleYaml, "utf8");
|
|
55580
|
+
if (!isJsonMode()) console.log(`\u2713 rule YAML written to ${opts.out}`);
|
|
55581
|
+
} else if (isJsonMode()) {
|
|
55582
|
+
printJson({ rule, rule_yaml: ruleYaml, warnings });
|
|
55583
|
+
} else {
|
|
55584
|
+
process.stdout.write(ruleYaml);
|
|
55585
|
+
}
|
|
55586
|
+
} catch (err) {
|
|
55587
|
+
handleError(err);
|
|
54901
55588
|
}
|
|
54902
55589
|
}
|
|
54903
55590
|
);
|
|
@@ -56297,13 +56984,105 @@ function resolveStatusSyncRuntime(options) {
|
|
|
56297
56984
|
].join("\n")
|
|
56298
56985
|
);
|
|
56299
56986
|
}
|
|
56987
|
+
const openclawUrl = options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL;
|
|
56988
|
+
let parsedUrl;
|
|
56989
|
+
try {
|
|
56990
|
+
parsedUrl = new URL(openclawUrl);
|
|
56991
|
+
} catch {
|
|
56992
|
+
throw new UsageError(
|
|
56993
|
+
[
|
|
56994
|
+
`OpenClaw URL is invalid: ${openclawUrl}`,
|
|
56995
|
+
"Provide a full http:// or https:// URL via one of:",
|
|
56996
|
+
" 1. --openclaw-url <url>",
|
|
56997
|
+
" 2. OPENCLAW_URL=<url> in the environment",
|
|
56998
|
+
"",
|
|
56999
|
+
"After fixing it, re-run the command and verify with `switchbot status-sync status`."
|
|
57000
|
+
].join("\n")
|
|
57001
|
+
);
|
|
57002
|
+
}
|
|
57003
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
57004
|
+
throw new UsageError(
|
|
57005
|
+
[
|
|
57006
|
+
`OpenClaw URL must use http:// or https:// (received ${parsedUrl.protocol})`,
|
|
57007
|
+
"Provide a full http:// or https:// URL via one of:",
|
|
57008
|
+
" 1. --openclaw-url <url>",
|
|
57009
|
+
" 2. OPENCLAW_URL=<url> in the environment"
|
|
57010
|
+
].join("\n")
|
|
57011
|
+
);
|
|
57012
|
+
}
|
|
56300
57013
|
return {
|
|
56301
|
-
openclawUrl
|
|
57014
|
+
openclawUrl,
|
|
56302
57015
|
openclawToken,
|
|
56303
57016
|
openclawModel,
|
|
56304
57017
|
...options.topic ? { topic: options.topic } : {}
|
|
56305
57018
|
};
|
|
56306
57019
|
}
|
|
57020
|
+
async function probeStatusSyncStart(options = {}) {
|
|
57021
|
+
const runtime = resolveStatusSyncRuntime(options);
|
|
57022
|
+
const config2 = tryLoadConfig();
|
|
57023
|
+
if (!config2) {
|
|
57024
|
+
throw new UsageError(
|
|
57025
|
+
"No credentials found. Run 'switchbot config set-token' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
57026
|
+
);
|
|
57027
|
+
}
|
|
57028
|
+
const { fetchMqttCredential: fetchMqttCredential2 } = await Promise.resolve().then(() => (init_credential(), credential_exports));
|
|
57029
|
+
let mqttBrokerUrl = "";
|
|
57030
|
+
let mqttRegion = "";
|
|
57031
|
+
try {
|
|
57032
|
+
const cred = await fetchMqttCredential2(config2.token, config2.secret);
|
|
57033
|
+
mqttBrokerUrl = cred.brokerUrl;
|
|
57034
|
+
mqttRegion = cred.region;
|
|
57035
|
+
} catch (err) {
|
|
57036
|
+
throw new UsageError(
|
|
57037
|
+
[
|
|
57038
|
+
"SwitchBot MQTT credential probe failed.",
|
|
57039
|
+
`Reason: ${err instanceof Error ? err.message : String(err)}`,
|
|
57040
|
+
"Verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET first, then re-run `switchbot status-sync start --probe`."
|
|
57041
|
+
].join("\n")
|
|
57042
|
+
);
|
|
57043
|
+
}
|
|
57044
|
+
const probeUrl = `${runtime.openclawUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
|
57045
|
+
let res;
|
|
57046
|
+
try {
|
|
57047
|
+
res = await fetch(probeUrl, {
|
|
57048
|
+
method: "POST",
|
|
57049
|
+
headers: {
|
|
57050
|
+
"content-type": "application/json",
|
|
57051
|
+
authorization: `Bearer ${runtime.openclawToken}`
|
|
57052
|
+
},
|
|
57053
|
+
body: JSON.stringify({
|
|
57054
|
+
model: runtime.openclawModel,
|
|
57055
|
+
messages: [{ role: "user", content: "status-sync probe" }]
|
|
57056
|
+
}),
|
|
57057
|
+
signal: AbortSignal.timeout(5e3)
|
|
57058
|
+
});
|
|
57059
|
+
} catch (err) {
|
|
57060
|
+
throw new UsageError(
|
|
57061
|
+
[
|
|
57062
|
+
`OpenClaw probe failed for ${probeUrl}.`,
|
|
57063
|
+
`Reason: ${err instanceof Error ? err.message : String(err)}`,
|
|
57064
|
+
"Check URL reachability, TLS/certificate trust, and whether the OpenClaw server is listening."
|
|
57065
|
+
].join("\n")
|
|
57066
|
+
);
|
|
57067
|
+
}
|
|
57068
|
+
if (!res.ok) {
|
|
57069
|
+
const body = await res.text().catch(() => "");
|
|
57070
|
+
const preview = body ? ` \u2014 ${body.slice(0, 200).replace(/\s+/g, " ").trim()}` : "";
|
|
57071
|
+
const hint = res.status === 401 || res.status === 403 ? "OpenClaw rejected the token. Verify --openclaw-token / OPENCLAW_TOKEN against the server admin." : res.status === 404 ? "OpenClaw returned 404 for /v1/chat/completions. Confirm the base URL does not already include that path, and that the server exposes an OpenAI-compatible endpoint." : res.status === 400 || res.status === 422 ? "OpenClaw rejected the request body. The model name may not be registered; verify --openclaw-model / OPENCLAW_MODEL." : res.status >= 500 ? "OpenClaw returned a server error. Retry, and if it persists check the server logs." : "Unexpected status; inspect the response body above for details.";
|
|
57072
|
+
throw new UsageError(
|
|
57073
|
+
[
|
|
57074
|
+
`OpenClaw probe failed for ${probeUrl}.`,
|
|
57075
|
+
`Reason: HTTP ${res.status} ${res.statusText}${preview}`,
|
|
57076
|
+
hint
|
|
57077
|
+
].join("\n")
|
|
57078
|
+
);
|
|
57079
|
+
}
|
|
57080
|
+
return {
|
|
57081
|
+
openclawUrl: runtime.openclawUrl,
|
|
57082
|
+
mqttBrokerUrl,
|
|
57083
|
+
mqttRegion
|
|
57084
|
+
};
|
|
57085
|
+
}
|
|
56307
57086
|
function resolveStatusSyncPaths(explicitStateDir) {
|
|
56308
57087
|
const stateDir = path23.resolve(
|
|
56309
57088
|
explicitStateDir ?? process.env.SWITCHBOT_STATUS_SYNC_HOME ?? path23.join(os23.homedir(), ".switchbot", "status-sync")
|
|
@@ -56577,12 +57356,21 @@ Examples:
|
|
|
56577
57356
|
handleError(error48);
|
|
56578
57357
|
}
|
|
56579
57358
|
});
|
|
56580
|
-
statusSync.command("start").description("Start the background status-sync bridge").option("--openclaw-url <url>", "OpenClaw gateway URL (default: http://localhost:18789)", stringArg("--openclaw-url")).option("--openclaw-token <token>", "Bearer token for OpenClaw (or env OPENCLAW_TOKEN)", stringArg("--openclaw-token")).option("--openclaw-model <id>", "OpenClaw agent model ID to route events to (or env OPENCLAW_MODEL)", stringArg("--openclaw-model")).option("--topic <pattern>", "MQTT topic filter (default: SwitchBot shadow topic from credential)", stringArg("--topic")).option("--state-dir <path>", "Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)", stringArg("--state-dir")).option("--force", "Stop any existing status-sync bridge before starting a new one").addHelpText(
|
|
57359
|
+
statusSync.command("start").description("Start the background status-sync bridge").option("--openclaw-url <url>", "OpenClaw gateway URL (default: http://localhost:18789)", stringArg("--openclaw-url")).option("--openclaw-token <token>", "Bearer token for OpenClaw (or env OPENCLAW_TOKEN)", stringArg("--openclaw-token")).option("--openclaw-model <id>", "OpenClaw agent model ID to route events to (or env OPENCLAW_MODEL)", stringArg("--openclaw-model")).option("--topic <pattern>", "MQTT topic filter (default: SwitchBot shadow topic from credential)", stringArg("--topic")).option("--state-dir <path>", "Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)", stringArg("--state-dir")).option("--force", "Stop any existing status-sync bridge before starting a new one").option("--probe", "Perform online preflight: fetch MQTT credentials and probe the OpenClaw URL before spawning").addHelpText(
|
|
56581
57360
|
"after",
|
|
56582
57361
|
`
|
|
56583
57362
|
Starts a detached child process that runs:
|
|
56584
57363
|
switchbot status-sync run ...
|
|
56585
57364
|
|
|
57365
|
+
Local preflight before spawning:
|
|
57366
|
+
- SwitchBot credentials must be configured
|
|
57367
|
+
- OpenClaw token + model must be present
|
|
57368
|
+
- OpenClaw URL must parse as http:// or https://
|
|
57369
|
+
|
|
57370
|
+
Optional online preflight with --probe:
|
|
57371
|
+
- fetch MQTT credentials from SwitchBot
|
|
57372
|
+
- perform a short HTTP probe against the OpenClaw URL
|
|
57373
|
+
|
|
56586
57374
|
State files:
|
|
56587
57375
|
state.json process metadata (pid, startedAt, command)
|
|
56588
57376
|
stdout.log redirected stdout from the child process
|
|
@@ -56593,8 +57381,11 @@ Examples:
|
|
|
56593
57381
|
$ OPENCLAW_TOKEN=abc OPENCLAW_MODEL=home-agent switchbot status-sync start
|
|
56594
57382
|
$ switchbot status-sync start --state-dir ~/.switchbot/custom-status-sync --force
|
|
56595
57383
|
`
|
|
56596
|
-
).action((options) => {
|
|
57384
|
+
).action(async (options) => {
|
|
56597
57385
|
try {
|
|
57386
|
+
if (options.probe) {
|
|
57387
|
+
await probeStatusSyncStart(options);
|
|
57388
|
+
}
|
|
56598
57389
|
const status = startStatusSync(options);
|
|
56599
57390
|
if (isJsonMode()) {
|
|
56600
57391
|
printJson(status);
|
|
@@ -56731,6 +57522,46 @@ function toPrometheusText(report) {
|
|
|
56731
57522
|
// src/commands/health.ts
|
|
56732
57523
|
init_arg_parsers();
|
|
56733
57524
|
var HEALTHZ_SCHEMA_VERSION = "1.1";
|
|
57525
|
+
function runHealthCheck(opts) {
|
|
57526
|
+
const report = getHealthReport(opts.auditLog);
|
|
57527
|
+
if (opts.prometheus) {
|
|
57528
|
+
process.stdout.write(toPrometheusText(report));
|
|
57529
|
+
return;
|
|
57530
|
+
}
|
|
57531
|
+
if (isJsonMode()) {
|
|
57532
|
+
printJson(report);
|
|
57533
|
+
return;
|
|
57534
|
+
}
|
|
57535
|
+
const statusEmoji = report.overall === "ok" ? "\u2713" : report.overall === "degraded" ? "\u26A0" : "\u2717";
|
|
57536
|
+
console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
|
|
57537
|
+
console.log("");
|
|
57538
|
+
printTable(
|
|
57539
|
+
["Component", "Status", "Detail"],
|
|
57540
|
+
[
|
|
57541
|
+
[
|
|
57542
|
+
"quota",
|
|
57543
|
+
report.quota.status,
|
|
57544
|
+
`${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`
|
|
57545
|
+
],
|
|
57546
|
+
[
|
|
57547
|
+
"audit",
|
|
57548
|
+
report.audit.status,
|
|
57549
|
+
report.audit.present ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` : "log not present"
|
|
57550
|
+
],
|
|
57551
|
+
[
|
|
57552
|
+
"circuit",
|
|
57553
|
+
report.circuit.status,
|
|
57554
|
+
`${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`
|
|
57555
|
+
],
|
|
57556
|
+
[
|
|
57557
|
+
"process",
|
|
57558
|
+
"ok",
|
|
57559
|
+
`pid ${report.process.pid} \xB7 uptime ${report.process.uptimeSeconds}s \xB7 mem ${report.process.memoryMb}MB`
|
|
57560
|
+
]
|
|
57561
|
+
]
|
|
57562
|
+
);
|
|
57563
|
+
if (report.overall !== "ok") process.exit(1);
|
|
57564
|
+
}
|
|
56734
57565
|
function createHealthHandler(auditLogPath) {
|
|
56735
57566
|
return (req, res) => {
|
|
56736
57567
|
const url2 = (req.url ?? "/").split("?")[0];
|
|
@@ -56750,48 +57581,14 @@ function createHealthHandler(auditLogPath) {
|
|
|
56750
57581
|
};
|
|
56751
57582
|
}
|
|
56752
57583
|
function registerHealthCommand(program3) {
|
|
56753
|
-
const health = program3.command("health").description("Report process health: quota, audit error rate, circuit breaker state.");
|
|
56754
|
-
health.
|
|
56755
|
-
|
|
56756
|
-
|
|
56757
|
-
|
|
56758
|
-
|
|
56759
|
-
}
|
|
56760
|
-
if (isJsonMode()) {
|
|
56761
|
-
printJson(report);
|
|
56762
|
-
return;
|
|
56763
|
-
}
|
|
56764
|
-
const statusEmoji = report.overall === "ok" ? "\u2713" : report.overall === "degraded" ? "\u26A0" : "\u2717";
|
|
56765
|
-
console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
|
|
56766
|
-
console.log("");
|
|
56767
|
-
printTable(
|
|
56768
|
-
["Component", "Status", "Detail"],
|
|
56769
|
-
[
|
|
56770
|
-
[
|
|
56771
|
-
"quota",
|
|
56772
|
-
report.quota.status,
|
|
56773
|
-
`${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`
|
|
56774
|
-
],
|
|
56775
|
-
[
|
|
56776
|
-
"audit",
|
|
56777
|
-
report.audit.status,
|
|
56778
|
-
report.audit.present ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` : "log not present"
|
|
56779
|
-
],
|
|
56780
|
-
[
|
|
56781
|
-
"circuit",
|
|
56782
|
-
report.circuit.status,
|
|
56783
|
-
`${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`
|
|
56784
|
-
],
|
|
56785
|
-
[
|
|
56786
|
-
"process",
|
|
56787
|
-
"ok",
|
|
56788
|
-
`pid ${report.process.pid} \xB7 uptime ${report.process.uptimeSeconds}s \xB7 mem ${report.process.memoryMb}MB`
|
|
56789
|
-
]
|
|
56790
|
-
]
|
|
56791
|
-
);
|
|
56792
|
-
if (report.overall !== "ok") process.exit(1);
|
|
57584
|
+
const health = program3.command("health").description("Report process health: quota, audit error rate, circuit breaker state.").option("--prometheus", "Emit Prometheus text format.").option("--audit-log <path>", "Audit log path (default: ~/.switchbot/audit.log).");
|
|
57585
|
+
health.action((opts) => {
|
|
57586
|
+
runHealthCheck(opts);
|
|
57587
|
+
});
|
|
57588
|
+
health.command("check").description("Print a one-shot health report.").action((_opts, cmd) => {
|
|
57589
|
+
runHealthCheck(cmd.optsWithGlobals());
|
|
56793
57590
|
});
|
|
56794
|
-
health.command("serve").description("Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).").option("--port <n>", "Port to listen on.", intArg("--port"), "3100").option("--host <host>", "Bind address.", "127.0.0.1").
|
|
57591
|
+
health.command("serve").description("Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).").option("--port <n>", "Port to listen on.", intArg("--port"), "3100").option("--host <host>", "Bind address.", "127.0.0.1").addHelpText("after", `
|
|
56795
57592
|
Endpoints:
|
|
56796
57593
|
GET /healthz JSON health report (HTTP 200 ok/degraded, 503 when circuit is open).
|
|
56797
57594
|
GET /metrics Prometheus text metrics.
|
|
@@ -56799,7 +57596,8 @@ Endpoints:
|
|
|
56799
57596
|
Example:
|
|
56800
57597
|
$ switchbot health serve --port 3100
|
|
56801
57598
|
$ curl http://127.0.0.1:3100/healthz
|
|
56802
|
-
`).action((
|
|
57599
|
+
`).action((_opts, cmd) => {
|
|
57600
|
+
const opts = cmd.optsWithGlobals();
|
|
56803
57601
|
const port = parseInt(opts.port, 10);
|
|
56804
57602
|
const handler = createHealthHandler(opts.auditLog);
|
|
56805
57603
|
const server = http3.createServer(handler);
|
|
@@ -56902,12 +57700,17 @@ function registerUpgradeCheckCommand(program3) {
|
|
|
56902
57700
|
}
|
|
56903
57701
|
return;
|
|
56904
57702
|
}
|
|
57703
|
+
const breakingNotice = findBreakingChangeBetween(VERSION, latestVersion);
|
|
56905
57704
|
const result = {
|
|
56906
57705
|
current: VERSION,
|
|
56907
57706
|
latest: latestVersion,
|
|
56908
57707
|
upToDate,
|
|
56909
57708
|
updateAvailable: !upToDate,
|
|
56910
|
-
breakingChange: latestMajor > currentMajor,
|
|
57709
|
+
breakingChange: latestMajor > currentMajor || breakingNotice !== null,
|
|
57710
|
+
...breakingNotice ? {
|
|
57711
|
+
breakingVersion: breakingNotice.version,
|
|
57712
|
+
breakingSummary: breakingNotice.summary
|
|
57713
|
+
} : {},
|
|
56911
57714
|
installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`
|
|
56912
57715
|
};
|
|
56913
57716
|
if (isJsonMode()) {
|
|
@@ -56918,6 +57721,9 @@ function registerUpgradeCheckCommand(program3) {
|
|
|
56918
57721
|
console.log(`${source_default.green("\u2713")} You are running the latest version (${VERSION}).`);
|
|
56919
57722
|
} else {
|
|
56920
57723
|
console.log(`${source_default.yellow("!")} Update available: ${source_default.bold(VERSION)} \u2192 ${source_default.bold(latestVersion)}`);
|
|
57724
|
+
if (breakingNotice) {
|
|
57725
|
+
console.log(source_default.red(` Breaking change in ${breakingNotice.version}: ${breakingNotice.summary}`));
|
|
57726
|
+
}
|
|
56921
57727
|
console.log(` Run: ${source_default.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`);
|
|
56922
57728
|
process.exit(1);
|
|
56923
57729
|
}
|