@switchbot/openapi-cli 3.2.2 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1360 -486
- 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,
|
|
@@ -27172,6 +27185,9 @@ var IdempotencyConflictError = class extends Error {
|
|
|
27172
27185
|
function hashKey(key) {
|
|
27173
27186
|
return crypto2.createHash("sha256").update(key).digest("hex");
|
|
27174
27187
|
}
|
|
27188
|
+
function fingerprintIdempotencyKey(key) {
|
|
27189
|
+
return hashKey(key).slice(0, 12);
|
|
27190
|
+
}
|
|
27175
27191
|
function shapeSignature(command, parameter) {
|
|
27176
27192
|
let parm;
|
|
27177
27193
|
try {
|
|
@@ -27371,9 +27387,23 @@ var CommandValidationError = class extends Error {
|
|
|
27371
27387
|
kind;
|
|
27372
27388
|
hint;
|
|
27373
27389
|
};
|
|
27374
|
-
|
|
27390
|
+
function hasDanglingHubReference(device, isPhysical, deviceList) {
|
|
27391
|
+
if (!isPhysical) return false;
|
|
27392
|
+
const hubDeviceId = device.hubDeviceId;
|
|
27393
|
+
if (!hubDeviceId || hubDeviceId === "000000000000" || hubDeviceId === device.deviceId) return false;
|
|
27394
|
+
return !deviceList.some((d) => d.deviceId === hubDeviceId);
|
|
27395
|
+
}
|
|
27396
|
+
function describeCatalogNote(deviceId, typeName, isPhysical) {
|
|
27397
|
+
if (isPhysical) {
|
|
27398
|
+
const label2 = typeName || "this device type";
|
|
27399
|
+
return `No built-in catalog entry for ${label2}; raw device metadata is shown. Try \`switchbot devices status ${deviceId}\` for live raw status.`;
|
|
27400
|
+
}
|
|
27401
|
+
const label = typeName || "this IR remote type";
|
|
27402
|
+
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.`;
|
|
27403
|
+
}
|
|
27404
|
+
async function fetchDeviceList(client, options = {}) {
|
|
27375
27405
|
const mode = getCacheMode();
|
|
27376
|
-
if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
|
|
27406
|
+
if (!options.bypassCache && mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) {
|
|
27377
27407
|
const cached2 = loadCache();
|
|
27378
27408
|
if (cached2) {
|
|
27379
27409
|
const deviceList = [];
|
|
@@ -27383,7 +27413,7 @@ async function fetchDeviceList(client) {
|
|
|
27383
27413
|
deviceList.push({
|
|
27384
27414
|
deviceId,
|
|
27385
27415
|
deviceName: entry.name,
|
|
27386
|
-
deviceType: entry.type,
|
|
27416
|
+
...entry.type ? { deviceType: entry.type } : {},
|
|
27387
27417
|
enableCloudService: entry.enableCloudService ?? true,
|
|
27388
27418
|
hubDeviceId: entry.hubDeviceId ?? "",
|
|
27389
27419
|
roomID: entry.roomID,
|
|
@@ -27439,6 +27469,7 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
27439
27469
|
parameter,
|
|
27440
27470
|
commandType,
|
|
27441
27471
|
dryRun: isDryRun(),
|
|
27472
|
+
...options?.idempotencyKey ? { idempotencyKeyFingerprint: fingerprintIdempotencyKey(options.idempotencyKey) } : {},
|
|
27442
27473
|
...options?.planId ? { planId: options.planId } : {}
|
|
27443
27474
|
};
|
|
27444
27475
|
const execute = async () => {
|
|
@@ -27451,7 +27482,7 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
27451
27482
|
return res.data.body;
|
|
27452
27483
|
} catch (err) {
|
|
27453
27484
|
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
27454
|
-
writeAudit({ ...baseAudit, result: "
|
|
27485
|
+
writeAudit({ ...baseAudit, result: "dry-run" });
|
|
27455
27486
|
} else {
|
|
27456
27487
|
writeAudit({
|
|
27457
27488
|
...baseAudit,
|
|
@@ -27468,6 +27499,12 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
27468
27499
|
{ command: cmd, parameter }
|
|
27469
27500
|
);
|
|
27470
27501
|
if (!replayed) return result;
|
|
27502
|
+
writeAudit({
|
|
27503
|
+
...baseAudit,
|
|
27504
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
27505
|
+
result: "ok",
|
|
27506
|
+
replayed: true
|
|
27507
|
+
});
|
|
27471
27508
|
if (result && typeof result === "object") {
|
|
27472
27509
|
return { ...result, replayed: true };
|
|
27473
27510
|
}
|
|
@@ -27557,10 +27594,19 @@ function getDestructiveReason(deviceType, cmd, commandType) {
|
|
|
27557
27594
|
return spec ? getCommandSafetyReason(spec) : null;
|
|
27558
27595
|
}
|
|
27559
27596
|
async function describeDevice(deviceId, options = {}, client) {
|
|
27560
|
-
const
|
|
27561
|
-
const
|
|
27562
|
-
|
|
27563
|
-
|
|
27597
|
+
const mode = getCacheMode();
|
|
27598
|
+
const hadFreshListCache = mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs) && loadCache() !== null;
|
|
27599
|
+
let body = await fetchDeviceList(client);
|
|
27600
|
+
let { deviceList, infraredRemoteList } = body;
|
|
27601
|
+
let physical = deviceList.find((d) => d.deviceId === deviceId);
|
|
27602
|
+
let ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
27603
|
+
if (!physical && !ir && hadFreshListCache) {
|
|
27604
|
+
body = await fetchDeviceList(client, { bypassCache: true });
|
|
27605
|
+
deviceList = body.deviceList;
|
|
27606
|
+
infraredRemoteList = body.infraredRemoteList;
|
|
27607
|
+
physical = deviceList.find((d) => d.deviceId === deviceId);
|
|
27608
|
+
ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
27609
|
+
}
|
|
27564
27610
|
if (!physical && !ir) throw new DeviceNotFoundError(deviceId);
|
|
27565
27611
|
const typeName = physical ? physical.deviceType ?? "" : ir.remoteType;
|
|
27566
27612
|
const match = typeName ? findCatalogEntry(typeName) : null;
|
|
@@ -27589,8 +27635,13 @@ async function describeDevice(deviceId, options = {}, client) {
|
|
|
27589
27635
|
statusFields: catalogEntry.statusFields ?? [],
|
|
27590
27636
|
...liveStatus !== void 0 ? { liveStatus } : {}
|
|
27591
27637
|
} : liveStatus !== void 0 ? { liveStatus } : null;
|
|
27638
|
+
const warnings = [];
|
|
27639
|
+
const selectedDevice = physical ?? ir;
|
|
27640
|
+
if (hasDanglingHubReference(selectedDevice, Boolean(physical), deviceList)) {
|
|
27641
|
+
warnings.push(`hubDeviceId ${selectedDevice.hubDeviceId} is not present in the current inventory`);
|
|
27642
|
+
}
|
|
27592
27643
|
return {
|
|
27593
|
-
device:
|
|
27644
|
+
device: selectedDevice,
|
|
27594
27645
|
isPhysical: Boolean(physical),
|
|
27595
27646
|
typeName,
|
|
27596
27647
|
controlType: physical?.controlType ?? ir?.controlType ?? null,
|
|
@@ -27598,6 +27649,8 @@ async function describeDevice(deviceId, options = {}, client) {
|
|
|
27598
27649
|
capabilities,
|
|
27599
27650
|
source,
|
|
27600
27651
|
suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [],
|
|
27652
|
+
...catalogEntry ? {} : { catalogNote: describeCatalogNote(deviceId, typeName, Boolean(physical)) },
|
|
27653
|
+
...warnings.length > 0 ? { warnings } : {},
|
|
27601
27654
|
inheritedLocation: ir ? buildHubLocationMap(deviceList).get(ir.hubDeviceId) : void 0
|
|
27602
27655
|
};
|
|
27603
27656
|
}
|
|
@@ -27648,6 +27701,8 @@ function toMcpDescribeShape(r) {
|
|
|
27648
27701
|
source: r.source,
|
|
27649
27702
|
capabilities: r.capabilities,
|
|
27650
27703
|
suggestedActions: r.suggestedActions,
|
|
27704
|
+
...r.catalogNote !== void 0 ? { catalogNote: r.catalogNote } : {},
|
|
27705
|
+
...r.warnings !== void 0 ? { warnings: r.warnings } : {},
|
|
27651
27706
|
...r.inheritedLocation !== void 0 ? { inheritedLocation: { family: r.inheritedLocation.family, room: r.inheritedLocation.room } } : {}
|
|
27652
27707
|
};
|
|
27653
27708
|
}
|
|
@@ -28468,7 +28523,6 @@ Examples:
|
|
|
28468
28523
|
idempotencyKey: options.idempotencyKeyPrefix ? `${options.idempotencyKeyPrefix}-${id}` : void 0
|
|
28469
28524
|
}));
|
|
28470
28525
|
const planDoc = {
|
|
28471
|
-
schemaVersion: "1.1",
|
|
28472
28526
|
dryRun: true,
|
|
28473
28527
|
plan: {
|
|
28474
28528
|
command: cmd,
|
|
@@ -28577,7 +28631,6 @@ Examples:
|
|
|
28577
28631
|
skipped: dryRunned.length + preSkipped.length,
|
|
28578
28632
|
durationMs: Date.now() - startedAt,
|
|
28579
28633
|
unverifiableCount: succeeded.filter((s2) => getCachedDevice(s2.deviceId)?.category === "ir").length,
|
|
28580
|
-
schemaVersion: "1.1",
|
|
28581
28634
|
maxConcurrent: concurrency,
|
|
28582
28635
|
staggerMs,
|
|
28583
28636
|
...dryRun ? { dryRun: true } : {}
|
|
@@ -28586,10 +28639,25 @@ Examples:
|
|
|
28586
28639
|
if (isJsonMode()) {
|
|
28587
28640
|
printJson(result);
|
|
28588
28641
|
} else {
|
|
28589
|
-
|
|
28590
|
-
`
|
|
28591
|
-
|
|
28592
|
-
|
|
28642
|
+
if (dryRunned.length > 0) {
|
|
28643
|
+
console.log(`
|
|
28644
|
+
Planned (dry-run): ${dryRunned.length} device(s)`);
|
|
28645
|
+
for (const d of dryRunned) console.log(` - ${d.deviceId}`);
|
|
28646
|
+
}
|
|
28647
|
+
if (preSkipped.length > 0) {
|
|
28648
|
+
console.log(`
|
|
28649
|
+
Skipped (offline): ${preSkipped.length} device(s)`);
|
|
28650
|
+
for (const d of preSkipped) console.log(` - ${d.deviceId}`);
|
|
28651
|
+
}
|
|
28652
|
+
const parts = [
|
|
28653
|
+
`${result.summary.ok} ok`,
|
|
28654
|
+
`${result.summary.failed} failed`
|
|
28655
|
+
];
|
|
28656
|
+
if (dryRunned.length > 0) parts.push(`${dryRunned.length} planned`);
|
|
28657
|
+
if (preSkipped.length > 0) parts.push(`${preSkipped.length} skipped_offline`);
|
|
28658
|
+
parts.push(`(${result.summary.durationMs}ms)`);
|
|
28659
|
+
console.log(`
|
|
28660
|
+
Summary: ${parts.join(", ")}`);
|
|
28593
28661
|
}
|
|
28594
28662
|
if (failed.length > 0) process.exit(1);
|
|
28595
28663
|
}
|
|
@@ -28710,6 +28778,7 @@ function listAllCanonical() {
|
|
|
28710
28778
|
|
|
28711
28779
|
// src/commands/watch.ts
|
|
28712
28780
|
var MIN_INTERVAL_MS = 1e3;
|
|
28781
|
+
var INITIAL_MODES = ["snapshot", "emit", "skip"];
|
|
28713
28782
|
function diff(prev, next, fields) {
|
|
28714
28783
|
const out = {};
|
|
28715
28784
|
const keys = fields ?? Object.keys(next);
|
|
@@ -28726,6 +28795,10 @@ function formatHumanLine(ev) {
|
|
|
28726
28795
|
const when = new Date(ev.t).toLocaleTimeString();
|
|
28727
28796
|
const head = `[${when}] ${ev.deviceId}${ev.type ? ` (${ev.type})` : ""}`;
|
|
28728
28797
|
if (ev.error) return `${head}: error \u2014 ${ev.error}`;
|
|
28798
|
+
if (ev.snapshot) {
|
|
28799
|
+
const pairs3 = Object.entries(ev.snapshot).map(([k2, v2]) => `${k2}=${JSON.stringify(v2)}`).join(", ");
|
|
28800
|
+
return `${head}: snapshot ${pairs3}`;
|
|
28801
|
+
}
|
|
28729
28802
|
const keys = Object.keys(ev.changed);
|
|
28730
28803
|
if (keys.length === 0) return `${head}: no changes`;
|
|
28731
28804
|
const pairs2 = keys.map((k2) => {
|
|
@@ -28751,24 +28824,34 @@ function sleep2(ms, signal) {
|
|
|
28751
28824
|
});
|
|
28752
28825
|
}
|
|
28753
28826
|
function registerWatchCommand(devices) {
|
|
28754
|
-
devices.command("watch").description("Poll device status on an interval and emit field-level changes (JSONL)").argument("[deviceId...]", "One or more deviceIds to watch (or use --name for one device)").option("--name <query>", "Resolve one device by fuzzy name (combined with any positional IDs)", stringArg("--name")).option(
|
|
28827
|
+
devices.command("watch").description("Poll device status on an interval and emit field-level changes (human table by default; JSONL with --json for agents)").argument("[deviceId...]", "One or more deviceIds to watch (or use --name for one device)").option("--name <query>", "Resolve one device by fuzzy name (combined with any positional IDs)", stringArg("--name")).option(
|
|
28755
28828
|
"--interval <dur>",
|
|
28756
28829
|
`Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1e3}s)`,
|
|
28757
28830
|
durationArg("--interval"),
|
|
28758
28831
|
"30s"
|
|
28759
|
-
).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(
|
|
28832
|
+
).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(
|
|
28760
28833
|
"after",
|
|
28761
28834
|
`
|
|
28762
|
-
|
|
28835
|
+
Default output is a human-readable table of field changes per tick; add --json
|
|
28836
|
+
to get one JSON-Lines record per deviceId per tick (the agent-friendly form).
|
|
28837
|
+
|
|
28838
|
+
The first poll is configurable:
|
|
28839
|
+
--initial=snapshot emit one baseline snapshot event, then only diffs
|
|
28840
|
+
--initial=emit treat the first poll as null -> value changes
|
|
28841
|
+
--initial=skip record the baseline silently, then only diffs
|
|
28842
|
+
|
|
28843
|
+
Subsequent ticks only include fields whose value changed (unless
|
|
28844
|
+
--include-unchanged is passed).
|
|
28845
|
+
|
|
28846
|
+
Each --json line has the shape:
|
|
28763
28847
|
{ "t": "<ISO>", "tick": <n>, "deviceId": "ID", "type": "Bot",
|
|
28764
28848
|
"changed": { "power": { "from": "off", "to": "on" } } }
|
|
28765
28849
|
|
|
28766
|
-
The very first poll has "from": null for every field (seed).
|
|
28767
|
-
|
|
28768
28850
|
Examples:
|
|
28769
28851
|
$ switchbot devices watch ABC123 --interval 10s
|
|
28770
28852
|
$ switchbot devices watch ABC123 --fields battery,power --interval 1m
|
|
28771
28853
|
$ switchbot devices watch ABC123 DEF456 --interval 30s --max 10
|
|
28854
|
+
# Agent-friendly: one JSONL record per tick, pipeable to jq
|
|
28772
28855
|
$ switchbot devices watch ABC123 --json | jq 'select(.changed.power)'
|
|
28773
28856
|
$ switchbot devices watch --name "Living Room AC" --interval 10s
|
|
28774
28857
|
`
|
|
@@ -28817,7 +28900,32 @@ Examples:
|
|
|
28817
28900
|
const cached2 = getCachedDevice(id);
|
|
28818
28901
|
try {
|
|
28819
28902
|
const body = await fetchDeviceStatus(id, client);
|
|
28820
|
-
const
|
|
28903
|
+
const previous = prev.get(id);
|
|
28904
|
+
const baseline = fields ? Object.fromEntries(fields.map((f2) => [f2, body[f2] ?? null])) : body;
|
|
28905
|
+
if (!prev.has(id)) {
|
|
28906
|
+
if (options.initial === "skip") {
|
|
28907
|
+
prev.set(id, body);
|
|
28908
|
+
return;
|
|
28909
|
+
}
|
|
28910
|
+
if (options.initial === "snapshot") {
|
|
28911
|
+
prev.set(id, body);
|
|
28912
|
+
const ev2 = {
|
|
28913
|
+
t,
|
|
28914
|
+
tick,
|
|
28915
|
+
deviceId: id,
|
|
28916
|
+
type: cached2?.type,
|
|
28917
|
+
changed: {},
|
|
28918
|
+
snapshot: baseline
|
|
28919
|
+
};
|
|
28920
|
+
if (isJsonMode()) {
|
|
28921
|
+
printJson(ev2);
|
|
28922
|
+
} else {
|
|
28923
|
+
console.log(formatHumanLine(ev2));
|
|
28924
|
+
}
|
|
28925
|
+
return;
|
|
28926
|
+
}
|
|
28927
|
+
}
|
|
28928
|
+
const changed = diff(previous, body, fields);
|
|
28821
28929
|
prev.set(id, body);
|
|
28822
28930
|
if (Object.keys(changed).length === 0 && !options.includeUnchanged) {
|
|
28823
28931
|
return;
|
|
@@ -28892,7 +29000,7 @@ Examples:
|
|
|
28892
29000
|
try {
|
|
28893
29001
|
const wantLive = options.live !== false;
|
|
28894
29002
|
const desc = await describeDevice(deviceId, { live: wantLive });
|
|
28895
|
-
const warnings = [];
|
|
29003
|
+
const warnings = [...desc.warnings ?? []];
|
|
28896
29004
|
if (desc.isPhysical && !desc.device.enableCloudService) {
|
|
28897
29005
|
warnings.push("Cloud service disabled on this device \u2014 commands will fail.");
|
|
28898
29006
|
}
|
|
@@ -28927,6 +29035,7 @@ Examples:
|
|
|
28927
29035
|
name: deviceName(desc.device),
|
|
28928
29036
|
role: desc.catalog?.role ?? null,
|
|
28929
29037
|
readOnly: desc.catalog?.readOnly ?? false,
|
|
29038
|
+
...desc.catalogNote ? { catalogNote: desc.catalogNote } : {},
|
|
28930
29039
|
location,
|
|
28931
29040
|
liveStatus,
|
|
28932
29041
|
commands,
|
|
@@ -28952,6 +29061,9 @@ function printHuman(r) {
|
|
|
28952
29061
|
const loc = [r.location?.family, r.location?.room].filter(Boolean).join(" / ");
|
|
28953
29062
|
console.log(`location: ${loc}`);
|
|
28954
29063
|
}
|
|
29064
|
+
if (r.catalogNote) {
|
|
29065
|
+
console.log(`catalog: ${r.catalogNote}`);
|
|
29066
|
+
}
|
|
28955
29067
|
if (r.warnings.length) {
|
|
28956
29068
|
console.log("warnings:");
|
|
28957
29069
|
for (const w2 of r.warnings) console.log(` ! ${w2}`);
|
|
@@ -28998,7 +29110,7 @@ init_cache();
|
|
|
28998
29110
|
init_flags();
|
|
28999
29111
|
init_client();
|
|
29000
29112
|
function registerExpandCommand(devices) {
|
|
29001
|
-
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", `
|
|
29113
|
+
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", `
|
|
29002
29114
|
Translates semantic flags into the wire parameter format, then sends the command.
|
|
29003
29115
|
|
|
29004
29116
|
Supported expansions:
|
|
@@ -29036,7 +29148,12 @@ Examples:
|
|
|
29036
29148
|
effectiveCommand = deviceIdArg;
|
|
29037
29149
|
effectiveDeviceIdArg = void 0;
|
|
29038
29150
|
}
|
|
29039
|
-
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name
|
|
29151
|
+
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
|
|
29152
|
+
strategy: options.nameStrategy ?? "require-unique",
|
|
29153
|
+
type: options.nameType,
|
|
29154
|
+
category: options.nameCategory,
|
|
29155
|
+
room: options.nameRoom
|
|
29156
|
+
});
|
|
29040
29157
|
if (!effectiveCommand) throw new UsageError("A command argument is required (setAll, setPosition, setMode).");
|
|
29041
29158
|
command = effectiveCommand;
|
|
29042
29159
|
const cached2 = getCachedDevice(deviceId);
|
|
@@ -29219,6 +29336,21 @@ var EXPAND_HINTS = {
|
|
|
29219
29336
|
"Blind Tilt": { command: "setPosition", flags: "--direction up --angle 50" },
|
|
29220
29337
|
"Relay Switch 2PM": { command: "setMode", flags: "--channel 1 --mode edge" }
|
|
29221
29338
|
};
|
|
29339
|
+
function annotateStatusPayload(deviceId, body) {
|
|
29340
|
+
const annotated = { ...body };
|
|
29341
|
+
if (Object.keys(body).length === 0) {
|
|
29342
|
+
annotated.supported = false;
|
|
29343
|
+
annotated.note = "this device does not expose cloud status";
|
|
29344
|
+
return annotated;
|
|
29345
|
+
}
|
|
29346
|
+
const cached2 = getCachedDevice(deviceId);
|
|
29347
|
+
const looksLikeMeter = cached2?.type?.toLowerCase().includes("meter") ?? false;
|
|
29348
|
+
const staleZeroReading = looksLikeMeter && !Object.prototype.hasOwnProperty.call(body, "onlineStatus") && body.battery === 0 && body.temperature === 0 && body.humidity === 0;
|
|
29349
|
+
if (staleZeroReading) {
|
|
29350
|
+
annotated.hint = "readings look stale; check batteries or hub connectivity";
|
|
29351
|
+
}
|
|
29352
|
+
return annotated;
|
|
29353
|
+
}
|
|
29222
29354
|
function registerDevicesCommand(program3) {
|
|
29223
29355
|
const COMMAND_TYPES2 = ["command", "customize"];
|
|
29224
29356
|
const devices = program3.command("devices").description("Manage and control SwitchBot devices").addHelpText("after", `
|
|
@@ -29454,7 +29586,7 @@ Examples:
|
|
|
29454
29586
|
const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
|
|
29455
29587
|
const fetchedAt2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
29456
29588
|
const batch = results.map(
|
|
29457
|
-
(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) }
|
|
29589
|
+
(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) }
|
|
29458
29590
|
);
|
|
29459
29591
|
const batchFmt = resolveFormat();
|
|
29460
29592
|
if (isJsonMode() || batchFmt === "json") {
|
|
@@ -29488,7 +29620,7 @@ Examples:
|
|
|
29488
29620
|
category: options.nameCategory,
|
|
29489
29621
|
room: options.nameRoom
|
|
29490
29622
|
});
|
|
29491
|
-
const body = await fetchDeviceStatus(deviceId);
|
|
29623
|
+
const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId));
|
|
29492
29624
|
const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
29493
29625
|
const fmt = resolveFormat();
|
|
29494
29626
|
if (fmt === "json" && process.argv.includes("--json")) {
|
|
@@ -29766,7 +29898,7 @@ ${extra}` : extra;
|
|
|
29766
29898
|
if (isJsonMode()) {
|
|
29767
29899
|
printJson({ dryRun: true, wouldSend });
|
|
29768
29900
|
} else {
|
|
29769
|
-
console.log(
|
|
29901
|
+
console.log(`\u25E6 dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`);
|
|
29770
29902
|
}
|
|
29771
29903
|
return;
|
|
29772
29904
|
}
|
|
@@ -29911,6 +30043,8 @@ Examples:
|
|
|
29911
30043
|
capabilities,
|
|
29912
30044
|
source,
|
|
29913
30045
|
suggestedActions: picks,
|
|
30046
|
+
...result.catalogNote ? { catalogNote: result.catalogNote } : {},
|
|
30047
|
+
...result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {},
|
|
29914
30048
|
...expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}
|
|
29915
30049
|
});
|
|
29916
30050
|
return;
|
|
@@ -29944,8 +30078,17 @@ Examples:
|
|
|
29944
30078
|
}
|
|
29945
30079
|
const liveStatus = capabilities && "liveStatus" in capabilities ? capabilities.liveStatus : void 0;
|
|
29946
30080
|
console.log("");
|
|
30081
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
30082
|
+
for (const warning of result.warnings) {
|
|
30083
|
+
console.log(`Warning: ${warning}`);
|
|
30084
|
+
}
|
|
30085
|
+
console.log("");
|
|
30086
|
+
}
|
|
29947
30087
|
if (!catalog) {
|
|
29948
30088
|
console.log(`(Type "${typeName}" is not in the built-in catalog \u2014 no command reference available.)`);
|
|
30089
|
+
if (result.catalogNote) {
|
|
30090
|
+
console.log(result.catalogNote);
|
|
30091
|
+
}
|
|
29949
30092
|
if (isPhysical) {
|
|
29950
30093
|
console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`);
|
|
29951
30094
|
} else {
|
|
@@ -30029,6 +30172,7 @@ function renderCatalogEntry(entry) {
|
|
|
30029
30172
|
if (entry.statusFields && entry.statusFields.length > 0) {
|
|
30030
30173
|
console.log('\nStatus fields (from "devices status"):');
|
|
30031
30174
|
console.log(" " + entry.statusFields.join(", "));
|
|
30175
|
+
console.log(" Note: statusFields are advisory; actual fields can vary by firmware and device variant.");
|
|
30032
30176
|
}
|
|
30033
30177
|
const expandHint = EXPAND_HINTS[entry.type];
|
|
30034
30178
|
if (expandHint) {
|
|
@@ -45370,16 +45514,30 @@ import { createRequire as createRequire3 } from "node:module";
|
|
|
45370
45514
|
|
|
45371
45515
|
// src/policy/schema.ts
|
|
45372
45516
|
init_cjs_shim();
|
|
45517
|
+
|
|
45518
|
+
// src/embedded-assets.ts
|
|
45519
|
+
init_cjs_shim();
|
|
45373
45520
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
45374
45521
|
import { fileURLToPath } from "node:url";
|
|
45522
|
+
function readAsset(relPath) {
|
|
45523
|
+
const resolved = fileURLToPath(new URL(relPath, import.meta.url));
|
|
45524
|
+
return readFileSync2(resolved, "utf-8");
|
|
45525
|
+
}
|
|
45526
|
+
function readPolicySchemaJson(version2) {
|
|
45527
|
+
return readAsset(`./policy/schema/v${version2}.json`);
|
|
45528
|
+
}
|
|
45529
|
+
function readPolicyExampleYaml() {
|
|
45530
|
+
return readAsset(`./policy/examples/policy.example.yaml`);
|
|
45531
|
+
}
|
|
45532
|
+
|
|
45533
|
+
// src/policy/schema.ts
|
|
45375
45534
|
var SUPPORTED_POLICY_SCHEMA_VERSIONS = ["0.2"];
|
|
45376
45535
|
var CURRENT_POLICY_SCHEMA_VERSION = "0.2";
|
|
45377
45536
|
var schemaCache = /* @__PURE__ */ new Map();
|
|
45378
45537
|
function loadPolicySchema(version2 = CURRENT_POLICY_SCHEMA_VERSION) {
|
|
45379
45538
|
const cached2 = schemaCache.get(version2);
|
|
45380
45539
|
if (cached2) return cached2;
|
|
45381
|
-
const
|
|
45382
|
-
const raw = readFileSync2(fileURLToPath(url2), "utf-8");
|
|
45540
|
+
const raw = readPolicySchemaJson(version2);
|
|
45383
45541
|
const parsed = JSON.parse(raw);
|
|
45384
45542
|
schemaCache.set(version2, parsed);
|
|
45385
45543
|
return parsed;
|
|
@@ -45388,6 +45546,12 @@ function isSupportedPolicySchemaVersion(v2) {
|
|
|
45388
45546
|
return typeof v2 === "string" && SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(v2);
|
|
45389
45547
|
}
|
|
45390
45548
|
|
|
45549
|
+
// src/policy/validate.ts
|
|
45550
|
+
init_catalog();
|
|
45551
|
+
|
|
45552
|
+
// src/rules/action.ts
|
|
45553
|
+
init_cjs_shim();
|
|
45554
|
+
|
|
45391
45555
|
// src/rules/destructive.ts
|
|
45392
45556
|
init_cjs_shim();
|
|
45393
45557
|
var DESTRUCTIVE_COMMANDS = [
|
|
@@ -45421,9 +45585,195 @@ function destructiveVerbOf(cmd) {
|
|
|
45421
45585
|
return null;
|
|
45422
45586
|
}
|
|
45423
45587
|
|
|
45588
|
+
// src/rules/action.ts
|
|
45589
|
+
var DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
|
|
45590
|
+
function parseRuleCommand(cmd) {
|
|
45591
|
+
const m2 = DEVICES_COMMAND_RE.exec(cmd.trim());
|
|
45592
|
+
if (!m2) return null;
|
|
45593
|
+
const deviceIdSlot = m2[1];
|
|
45594
|
+
const verb = m2[2];
|
|
45595
|
+
const rest = (m2[3] ?? "").trim();
|
|
45596
|
+
return {
|
|
45597
|
+
deviceIdSlot,
|
|
45598
|
+
verb,
|
|
45599
|
+
parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/)
|
|
45600
|
+
};
|
|
45601
|
+
}
|
|
45602
|
+
function resolveActionDevice(explicit, slot, aliases) {
|
|
45603
|
+
const candidate = explicit ?? (slot && slot !== "<id>" ? slot : null);
|
|
45604
|
+
if (!candidate) return null;
|
|
45605
|
+
if (aliases[candidate]) return aliases[candidate];
|
|
45606
|
+
return candidate;
|
|
45607
|
+
}
|
|
45608
|
+
function renderParameter(tokens) {
|
|
45609
|
+
if (tokens.length === 0) return void 0;
|
|
45610
|
+
if (tokens.length === 1) return tokens[0];
|
|
45611
|
+
return tokens.join(":");
|
|
45612
|
+
}
|
|
45613
|
+
async function executeRuleAction(action, ctx) {
|
|
45614
|
+
const parsed = parseRuleCommand(action.command);
|
|
45615
|
+
if (!parsed) {
|
|
45616
|
+
writeAudit({
|
|
45617
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45618
|
+
kind: "rule-fire",
|
|
45619
|
+
deviceId: "unknown",
|
|
45620
|
+
command: action.command,
|
|
45621
|
+
parameter: null,
|
|
45622
|
+
commandType: "command",
|
|
45623
|
+
dryRun: true,
|
|
45624
|
+
result: "error",
|
|
45625
|
+
error: "unparseable-command",
|
|
45626
|
+
rule: {
|
|
45627
|
+
name: ctx.rule.name,
|
|
45628
|
+
triggerSource: ctx.rule.when.source,
|
|
45629
|
+
fireId: ctx.fireId,
|
|
45630
|
+
reason: "unparseable-command"
|
|
45631
|
+
}
|
|
45632
|
+
});
|
|
45633
|
+
return { ok: false, error: "unparseable-command", blocked: true };
|
|
45634
|
+
}
|
|
45635
|
+
if (isDestructiveCommand2(action.command)) {
|
|
45636
|
+
writeAudit({
|
|
45637
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45638
|
+
kind: "rule-fire",
|
|
45639
|
+
deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? "unknown",
|
|
45640
|
+
command: action.command,
|
|
45641
|
+
parameter: null,
|
|
45642
|
+
commandType: "command",
|
|
45643
|
+
dryRun: true,
|
|
45644
|
+
result: "error",
|
|
45645
|
+
error: `destructive-verb:${parsed.verb}`,
|
|
45646
|
+
rule: {
|
|
45647
|
+
name: ctx.rule.name,
|
|
45648
|
+
triggerSource: ctx.rule.when.source,
|
|
45649
|
+
fireId: ctx.fireId,
|
|
45650
|
+
reason: `destructive verb "${parsed.verb}" refused at runtime`
|
|
45651
|
+
}
|
|
45652
|
+
});
|
|
45653
|
+
return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
|
|
45654
|
+
}
|
|
45655
|
+
const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
|
|
45656
|
+
if (!deviceId || deviceId === "<id>") {
|
|
45657
|
+
writeAudit({
|
|
45658
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45659
|
+
kind: "rule-fire",
|
|
45660
|
+
deviceId: "unknown",
|
|
45661
|
+
command: action.command,
|
|
45662
|
+
parameter: null,
|
|
45663
|
+
commandType: "command",
|
|
45664
|
+
dryRun: true,
|
|
45665
|
+
result: "error",
|
|
45666
|
+
error: "missing-device",
|
|
45667
|
+
rule: {
|
|
45668
|
+
name: ctx.rule.name,
|
|
45669
|
+
triggerSource: ctx.rule.when.source,
|
|
45670
|
+
fireId: ctx.fireId,
|
|
45671
|
+
reason: "action omitted `device` and command used `<id>` placeholder"
|
|
45672
|
+
}
|
|
45673
|
+
});
|
|
45674
|
+
return { ok: false, error: "missing-device", verb: parsed.verb };
|
|
45675
|
+
}
|
|
45676
|
+
const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
|
|
45677
|
+
const parameter = renderParameter(parsed.parameterTokens);
|
|
45678
|
+
if (dryRun) {
|
|
45679
|
+
writeAudit({
|
|
45680
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45681
|
+
kind: "rule-fire-dry",
|
|
45682
|
+
deviceId,
|
|
45683
|
+
command: parsed.verb,
|
|
45684
|
+
parameter: parameter ?? "default",
|
|
45685
|
+
commandType: "command",
|
|
45686
|
+
dryRun: true,
|
|
45687
|
+
result: "ok",
|
|
45688
|
+
rule: {
|
|
45689
|
+
name: ctx.rule.name,
|
|
45690
|
+
triggerSource: ctx.rule.when.source,
|
|
45691
|
+
matchedDevice: deviceId,
|
|
45692
|
+
fireId: ctx.fireId
|
|
45693
|
+
}
|
|
45694
|
+
});
|
|
45695
|
+
return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
|
|
45696
|
+
}
|
|
45697
|
+
if (ctx.skipApiCall) {
|
|
45698
|
+
writeAudit({
|
|
45699
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45700
|
+
kind: "rule-fire",
|
|
45701
|
+
deviceId,
|
|
45702
|
+
command: parsed.verb,
|
|
45703
|
+
parameter: parameter ?? "default",
|
|
45704
|
+
commandType: "command",
|
|
45705
|
+
dryRun: false,
|
|
45706
|
+
result: "ok",
|
|
45707
|
+
rule: {
|
|
45708
|
+
name: ctx.rule.name,
|
|
45709
|
+
triggerSource: ctx.rule.when.source,
|
|
45710
|
+
matchedDevice: deviceId,
|
|
45711
|
+
fireId: ctx.fireId,
|
|
45712
|
+
reason: "api-skipped"
|
|
45713
|
+
}
|
|
45714
|
+
});
|
|
45715
|
+
return { ok: true, deviceId, verb: parsed.verb };
|
|
45716
|
+
}
|
|
45717
|
+
try {
|
|
45718
|
+
await executeCommand(deviceId, parsed.verb, parameter, "command", ctx.httpClient);
|
|
45719
|
+
writeAudit({
|
|
45720
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45721
|
+
kind: "rule-fire",
|
|
45722
|
+
deviceId,
|
|
45723
|
+
command: parsed.verb,
|
|
45724
|
+
parameter: parameter ?? "default",
|
|
45725
|
+
commandType: "command",
|
|
45726
|
+
dryRun: false,
|
|
45727
|
+
result: "ok",
|
|
45728
|
+
rule: {
|
|
45729
|
+
name: ctx.rule.name,
|
|
45730
|
+
triggerSource: ctx.rule.when.source,
|
|
45731
|
+
matchedDevice: deviceId,
|
|
45732
|
+
fireId: ctx.fireId
|
|
45733
|
+
}
|
|
45734
|
+
});
|
|
45735
|
+
return { ok: true, deviceId, verb: parsed.verb };
|
|
45736
|
+
} catch (err) {
|
|
45737
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45738
|
+
writeAudit({
|
|
45739
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45740
|
+
kind: "rule-fire",
|
|
45741
|
+
deviceId,
|
|
45742
|
+
command: parsed.verb,
|
|
45743
|
+
parameter: parameter ?? "default",
|
|
45744
|
+
commandType: "command",
|
|
45745
|
+
dryRun: false,
|
|
45746
|
+
result: "error",
|
|
45747
|
+
error: msg,
|
|
45748
|
+
rule: {
|
|
45749
|
+
name: ctx.rule.name,
|
|
45750
|
+
triggerSource: ctx.rule.when.source,
|
|
45751
|
+
matchedDevice: deviceId,
|
|
45752
|
+
fireId: ctx.fireId
|
|
45753
|
+
}
|
|
45754
|
+
});
|
|
45755
|
+
return { ok: false, error: msg, deviceId, verb: parsed.verb };
|
|
45756
|
+
}
|
|
45757
|
+
}
|
|
45758
|
+
function extractDeviceIdFromAction(action) {
|
|
45759
|
+
if (action.device) return action.device;
|
|
45760
|
+
const m2 = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? "");
|
|
45761
|
+
return m2 ? m2[1] : null;
|
|
45762
|
+
}
|
|
45763
|
+
|
|
45424
45764
|
// src/policy/validate.ts
|
|
45425
45765
|
var require4 = createRequire3(import.meta.url);
|
|
45426
45766
|
var addFormats = require4("ajv-formats");
|
|
45767
|
+
var POLICY_VALIDATION_LIMITATIONS = [
|
|
45768
|
+
"Does not resolve aliases against the live device inventory.",
|
|
45769
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
45770
|
+
];
|
|
45771
|
+
var POLICY_VALIDATION_LIVE_LIMITATIONS = [
|
|
45772
|
+
"Live inventory checks reflect a point-in-time device list snapshot.",
|
|
45773
|
+
"Does not verify commands against live capabilities or current firmware."
|
|
45774
|
+
];
|
|
45775
|
+
var HEX_MAC_DEVICE_ID_RE = /^[A-Fa-f0-9]{12}(?:-[A-Za-z0-9]{2,16})?$/;
|
|
45776
|
+
var HYPHENATED_DEVICE_ID_RE = /^[A-Za-z0-9]{2,32}(?:-[A-Za-z0-9]{2,32}){1,4}$/;
|
|
45427
45777
|
var validators = /* @__PURE__ */ new Map();
|
|
45428
45778
|
function getValidator(version2) {
|
|
45429
45779
|
const cached2 = validators.get(version2);
|
|
@@ -45467,6 +45817,13 @@ function getKeyNodeAt(doc, parentSegments, key) {
|
|
|
45467
45817
|
const pair = parent.items.find((p2) => (0, import_yaml2.isScalar)(p2.key) && String(p2.key.value) === key);
|
|
45468
45818
|
return pair?.key ?? null;
|
|
45469
45819
|
}
|
|
45820
|
+
function locateInstancePath(doc, lineCounter, instancePath) {
|
|
45821
|
+
const node = getNodeAt(doc, instancePathToSegments(instancePath));
|
|
45822
|
+
const range = node?.range;
|
|
45823
|
+
if (!range) return {};
|
|
45824
|
+
const pos = lineCounter.linePos(range[0]);
|
|
45825
|
+
return { line: pos.line, col: pos.col };
|
|
45826
|
+
}
|
|
45470
45827
|
function locateError(doc, lineCounter, err) {
|
|
45471
45828
|
const segments = instancePathToSegments(err.instancePath);
|
|
45472
45829
|
if (err.keyword === "additionalProperties") {
|
|
@@ -45552,6 +45909,8 @@ function unsupportedVersionResult(loaded, declared) {
|
|
|
45552
45909
|
return {
|
|
45553
45910
|
policyPath: loaded.path,
|
|
45554
45911
|
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
45912
|
+
validationScope: "schema+offline-semantics",
|
|
45913
|
+
limitations: [...POLICY_VALIDATION_LIMITATIONS],
|
|
45555
45914
|
valid: false,
|
|
45556
45915
|
errors: [
|
|
45557
45916
|
{
|
|
@@ -45566,6 +45925,58 @@ function unsupportedVersionResult(loaded, declared) {
|
|
|
45566
45925
|
]
|
|
45567
45926
|
};
|
|
45568
45927
|
}
|
|
45928
|
+
function escapeJsonPointerSegment(segment) {
|
|
45929
|
+
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
45930
|
+
}
|
|
45931
|
+
function isPlausibleDeviceId(value) {
|
|
45932
|
+
return HEX_MAC_DEVICE_ID_RE.test(value) || HYPHENATED_DEVICE_ID_RE.test(value);
|
|
45933
|
+
}
|
|
45934
|
+
function hasErrorAtPath(errors, path26) {
|
|
45935
|
+
return errors.some((err) => err.path === path26);
|
|
45936
|
+
}
|
|
45937
|
+
function resolvePolicyDeviceRef(raw, aliases) {
|
|
45938
|
+
if (!raw) return { ok: false, reason: "missing-device" };
|
|
45939
|
+
if (raw === "<id>") return { ok: false, reason: "missing-device" };
|
|
45940
|
+
if (Object.hasOwn(aliases, raw)) return { ok: true };
|
|
45941
|
+
if (isPlausibleDeviceId(raw)) return { ok: true };
|
|
45942
|
+
return { ok: false, reason: "unknown-device-ref" };
|
|
45943
|
+
}
|
|
45944
|
+
function collectAliasMap(data) {
|
|
45945
|
+
const aliases = data?.aliases;
|
|
45946
|
+
if (!aliases || typeof aliases !== "object") return {};
|
|
45947
|
+
return Object.fromEntries(
|
|
45948
|
+
Object.entries(aliases).filter(
|
|
45949
|
+
(entry) => typeof entry[0] === "string" && typeof entry[1] === "string"
|
|
45950
|
+
)
|
|
45951
|
+
);
|
|
45952
|
+
}
|
|
45953
|
+
function isDeviceStateConditionLike(value) {
|
|
45954
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
45955
|
+
const candidate = value;
|
|
45956
|
+
return typeof candidate.device === "string" && typeof candidate.field === "string" && typeof candidate.op === "string";
|
|
45957
|
+
}
|
|
45958
|
+
function collectConditionDeviceRefs(condition, path26) {
|
|
45959
|
+
if (!condition || typeof condition !== "object" || Array.isArray(condition)) return [];
|
|
45960
|
+
const out = [];
|
|
45961
|
+
if (isDeviceStateConditionLike(condition)) {
|
|
45962
|
+
out.push({ path: `${path26}/device`, ref: condition.device });
|
|
45963
|
+
}
|
|
45964
|
+
const candidate = condition;
|
|
45965
|
+
if (Array.isArray(candidate.all)) {
|
|
45966
|
+
for (let i = 0; i < candidate.all.length; i++) {
|
|
45967
|
+
out.push(...collectConditionDeviceRefs(candidate.all[i], `${path26}/all/${i}`));
|
|
45968
|
+
}
|
|
45969
|
+
}
|
|
45970
|
+
if (Array.isArray(candidate.any)) {
|
|
45971
|
+
for (let i = 0; i < candidate.any.length; i++) {
|
|
45972
|
+
out.push(...collectConditionDeviceRefs(candidate.any[i], `${path26}/any/${i}`));
|
|
45973
|
+
}
|
|
45974
|
+
}
|
|
45975
|
+
if (candidate.not !== void 0) {
|
|
45976
|
+
out.push(...collectConditionDeviceRefs(candidate.not, `${path26}/not`));
|
|
45977
|
+
}
|
|
45978
|
+
return out;
|
|
45979
|
+
}
|
|
45569
45980
|
function collectDestructiveRuleErrors(loaded) {
|
|
45570
45981
|
const data = loaded.data;
|
|
45571
45982
|
const rules = data?.automation?.rules;
|
|
@@ -45604,6 +46015,258 @@ function collectDestructiveRuleErrors(loaded) {
|
|
|
45604
46015
|
}
|
|
45605
46016
|
return out;
|
|
45606
46017
|
}
|
|
46018
|
+
function collectOfflineSemanticErrors(loaded, existingErrors) {
|
|
46019
|
+
const data = loaded.data;
|
|
46020
|
+
const out = [];
|
|
46021
|
+
const aliases = collectAliasMap(data);
|
|
46022
|
+
for (const [aliasName, deviceId] of Object.entries(aliases)) {
|
|
46023
|
+
const path26 = `/aliases/${escapeJsonPointerSegment(aliasName)}`;
|
|
46024
|
+
if (hasErrorAtPath(existingErrors, path26)) continue;
|
|
46025
|
+
if (isPlausibleDeviceId(deviceId)) continue;
|
|
46026
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
|
|
46027
|
+
out.push({
|
|
46028
|
+
path: path26,
|
|
46029
|
+
line,
|
|
46030
|
+
col,
|
|
46031
|
+
keyword: "alias-device-id",
|
|
46032
|
+
message: `alias "${aliasName}" does not point to a plausible SwitchBot deviceId`,
|
|
46033
|
+
hint: "use a deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212 or 28372F4C9C4A",
|
|
46034
|
+
schemaPath: "#/properties/aliases"
|
|
46035
|
+
});
|
|
46036
|
+
}
|
|
46037
|
+
const knownDeviceCommands = new Set(
|
|
46038
|
+
getEffectiveCatalog().flatMap((entry) => entry.commands).filter((spec) => spec.commandType !== "customize").map((spec) => spec.command)
|
|
46039
|
+
);
|
|
46040
|
+
const rules = data?.automation?.rules;
|
|
46041
|
+
if (!Array.isArray(rules)) return out;
|
|
46042
|
+
for (let ri = 0; ri < rules.length; ri++) {
|
|
46043
|
+
const rule = rules[ri];
|
|
46044
|
+
const ruleName = typeof rule?.name === "string" ? rule.name : `#${ri}`;
|
|
46045
|
+
if (typeof rule?.when?.device === "string") {
|
|
46046
|
+
const whenDevicePath = `/automation/rules/${ri}/when/device`;
|
|
46047
|
+
const resolved = resolvePolicyDeviceRef(rule.when.device, aliases);
|
|
46048
|
+
if (!resolved.ok) {
|
|
46049
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath);
|
|
46050
|
+
out.push({
|
|
46051
|
+
path: whenDevicePath,
|
|
46052
|
+
line,
|
|
46053
|
+
col,
|
|
46054
|
+
keyword: resolved.reason ?? "unknown-device-ref",
|
|
46055
|
+
message: `rule "${ruleName}" trigger references unknown device "${rule.when.device}"`,
|
|
46056
|
+
hint: "set `when.device` to a declared alias or a plausible deviceId",
|
|
46057
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/when/properties/device"
|
|
46058
|
+
});
|
|
46059
|
+
}
|
|
46060
|
+
}
|
|
46061
|
+
const conditions = Array.isArray(rule?.conditions) ? rule.conditions : [];
|
|
46062
|
+
for (let ci = 0; ci < conditions.length; ci++) {
|
|
46063
|
+
for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) {
|
|
46064
|
+
const resolved = resolvePolicyDeviceRef(ref.ref, aliases);
|
|
46065
|
+
if (!resolved.ok) {
|
|
46066
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path);
|
|
46067
|
+
out.push({
|
|
46068
|
+
path: ref.path,
|
|
46069
|
+
line,
|
|
46070
|
+
col,
|
|
46071
|
+
keyword: resolved.reason ?? "unknown-device-ref",
|
|
46072
|
+
message: `rule "${ruleName}" condition references unknown device "${ref.ref}"`,
|
|
46073
|
+
hint: "set condition `device` to a declared alias or a plausible deviceId",
|
|
46074
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/conditions"
|
|
46075
|
+
});
|
|
46076
|
+
}
|
|
46077
|
+
}
|
|
46078
|
+
}
|
|
46079
|
+
const actions = Array.isArray(rule?.then) ? rule.then : [];
|
|
46080
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
46081
|
+
const action = actions[ai];
|
|
46082
|
+
const cmd = action?.command;
|
|
46083
|
+
if (typeof cmd !== "string") continue;
|
|
46084
|
+
const commandPath = `/automation/rules/${ri}/then/${ai}/command`;
|
|
46085
|
+
const devicePath = `/automation/rules/${ri}/then/${ai}/device`;
|
|
46086
|
+
const parsed = parseRuleCommand(cmd);
|
|
46087
|
+
if (!parsed) {
|
|
46088
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46089
|
+
out.push({
|
|
46090
|
+
path: commandPath,
|
|
46091
|
+
line,
|
|
46092
|
+
col,
|
|
46093
|
+
keyword: "rule-unparseable-command",
|
|
46094
|
+
message: `rule "${ruleName}" action #${ai} must use \`devices command <id> <verb> [parameter...]\``,
|
|
46095
|
+
hint: "automation rules currently support only `devices command ...` actions; scenes/webhooks/other subcommands are not executable here",
|
|
46096
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46097
|
+
});
|
|
46098
|
+
continue;
|
|
46099
|
+
}
|
|
46100
|
+
if (!knownDeviceCommands.has(parsed.verb)) {
|
|
46101
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46102
|
+
out.push({
|
|
46103
|
+
path: commandPath,
|
|
46104
|
+
line,
|
|
46105
|
+
col,
|
|
46106
|
+
keyword: "rule-unknown-command",
|
|
46107
|
+
message: `rule "${ruleName}" action #${ai} uses unknown device command "${parsed.verb}"`,
|
|
46108
|
+
hint: "check `switchbot devices commands <type>` for valid verbs; this validator only checks offline catalog verbs, not the real target device",
|
|
46109
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46110
|
+
});
|
|
46111
|
+
}
|
|
46112
|
+
if (typeof action?.device === "string") {
|
|
46113
|
+
const resolved2 = resolvePolicyDeviceRef(action.device, aliases);
|
|
46114
|
+
if (!resolved2.ok) {
|
|
46115
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, devicePath);
|
|
46116
|
+
out.push({
|
|
46117
|
+
path: devicePath,
|
|
46118
|
+
line,
|
|
46119
|
+
col,
|
|
46120
|
+
keyword: resolved2.reason ?? "unknown-device-ref",
|
|
46121
|
+
message: `rule "${ruleName}" action #${ai} references unknown device "${action.device}"`,
|
|
46122
|
+
hint: "set `device:` to a declared alias or a plausible deviceId",
|
|
46123
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/device"
|
|
46124
|
+
});
|
|
46125
|
+
}
|
|
46126
|
+
continue;
|
|
46127
|
+
}
|
|
46128
|
+
const resolved = resolvePolicyDeviceRef(parsed.deviceIdSlot ?? void 0, aliases);
|
|
46129
|
+
if (!resolved.ok) {
|
|
46130
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46131
|
+
out.push({
|
|
46132
|
+
path: commandPath,
|
|
46133
|
+
line,
|
|
46134
|
+
col,
|
|
46135
|
+
keyword: resolved.reason ?? "missing-device",
|
|
46136
|
+
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}"`,
|
|
46137
|
+
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",
|
|
46138
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46139
|
+
});
|
|
46140
|
+
}
|
|
46141
|
+
}
|
|
46142
|
+
}
|
|
46143
|
+
return out;
|
|
46144
|
+
}
|
|
46145
|
+
function resolveInventoryDeviceId(raw, aliases) {
|
|
46146
|
+
if (!raw || raw === "<id>") return null;
|
|
46147
|
+
if (Object.hasOwn(aliases, raw)) return aliases[raw];
|
|
46148
|
+
return raw;
|
|
46149
|
+
}
|
|
46150
|
+
function validateLoadedPolicyAgainstInventory(loaded, inventory) {
|
|
46151
|
+
const base = validateLoadedPolicy(loaded);
|
|
46152
|
+
const errors = [...base.errors];
|
|
46153
|
+
const aliases = collectAliasMap(loaded.data);
|
|
46154
|
+
const inventoryById = /* @__PURE__ */ new Map();
|
|
46155
|
+
for (const device of inventory.deviceList) {
|
|
46156
|
+
inventoryById.set(device.deviceId, { typeName: device.deviceType ?? "" });
|
|
46157
|
+
}
|
|
46158
|
+
for (const remote of inventory.infraredRemoteList) {
|
|
46159
|
+
inventoryById.set(remote.deviceId, { typeName: remote.remoteType });
|
|
46160
|
+
}
|
|
46161
|
+
for (const [aliasName, deviceId] of Object.entries(aliases)) {
|
|
46162
|
+
const path26 = `/aliases/${escapeJsonPointerSegment(aliasName)}`;
|
|
46163
|
+
if (hasErrorAtPath(errors, path26)) continue;
|
|
46164
|
+
if (!inventoryById.has(deviceId)) {
|
|
46165
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
|
|
46166
|
+
errors.push({
|
|
46167
|
+
path: path26,
|
|
46168
|
+
line,
|
|
46169
|
+
col,
|
|
46170
|
+
keyword: "alias-live-device-not-found",
|
|
46171
|
+
message: `alias "${aliasName}" points to deviceId "${deviceId}" which is not present in the current inventory`,
|
|
46172
|
+
hint: "refresh with `switchbot devices list` and confirm the alias target still exists on this account",
|
|
46173
|
+
schemaPath: "#/properties/aliases"
|
|
46174
|
+
});
|
|
46175
|
+
}
|
|
46176
|
+
}
|
|
46177
|
+
const data = loaded.data;
|
|
46178
|
+
const rules = data?.automation?.rules;
|
|
46179
|
+
if (Array.isArray(rules)) {
|
|
46180
|
+
for (let ri = 0; ri < rules.length; ri++) {
|
|
46181
|
+
const rule = rules[ri];
|
|
46182
|
+
const ruleName = typeof rule?.name === "string" ? rule.name : `#${ri}`;
|
|
46183
|
+
if (typeof rule?.when?.device === "string") {
|
|
46184
|
+
const whenDevicePath = `/automation/rules/${ri}/when/device`;
|
|
46185
|
+
const effectiveDeviceId = resolveInventoryDeviceId(rule.when.device, aliases);
|
|
46186
|
+
if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) {
|
|
46187
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath);
|
|
46188
|
+
errors.push({
|
|
46189
|
+
path: whenDevicePath,
|
|
46190
|
+
line,
|
|
46191
|
+
col,
|
|
46192
|
+
keyword: "rule-live-device-not-found",
|
|
46193
|
+
message: `rule "${ruleName}" trigger resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
|
|
46194
|
+
hint: "confirm `when.device` against `switchbot devices list` before relying on this policy",
|
|
46195
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/when/properties/device"
|
|
46196
|
+
});
|
|
46197
|
+
}
|
|
46198
|
+
}
|
|
46199
|
+
const conditions = Array.isArray(rule?.conditions) ? rule.conditions : [];
|
|
46200
|
+
for (let ci = 0; ci < conditions.length; ci++) {
|
|
46201
|
+
for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) {
|
|
46202
|
+
const effectiveDeviceId = resolveInventoryDeviceId(ref.ref, aliases);
|
|
46203
|
+
if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) {
|
|
46204
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path);
|
|
46205
|
+
errors.push({
|
|
46206
|
+
path: ref.path,
|
|
46207
|
+
line,
|
|
46208
|
+
col,
|
|
46209
|
+
keyword: "rule-live-device-not-found",
|
|
46210
|
+
message: `rule "${ruleName}" condition resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
|
|
46211
|
+
hint: "confirm the condition device against `switchbot devices list` before relying on this policy",
|
|
46212
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/conditions"
|
|
46213
|
+
});
|
|
46214
|
+
}
|
|
46215
|
+
}
|
|
46216
|
+
}
|
|
46217
|
+
const actions = Array.isArray(rule?.then) ? rule.then : [];
|
|
46218
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
46219
|
+
const action = actions[ai];
|
|
46220
|
+
const cmd = action?.command;
|
|
46221
|
+
if (typeof cmd !== "string") continue;
|
|
46222
|
+
const parsed = parseRuleCommand(cmd);
|
|
46223
|
+
if (!parsed) continue;
|
|
46224
|
+
const commandPath = `/automation/rules/${ri}/then/${ai}/command`;
|
|
46225
|
+
const devicePath = `/automation/rules/${ri}/then/${ai}/device`;
|
|
46226
|
+
const effectiveRef = typeof action?.device === "string" ? action.device : parsed.deviceIdSlot ?? void 0;
|
|
46227
|
+
const effectiveDeviceId = resolveInventoryDeviceId(effectiveRef, aliases);
|
|
46228
|
+
if (!effectiveDeviceId) continue;
|
|
46229
|
+
const target = inventoryById.get(effectiveDeviceId);
|
|
46230
|
+
if (!target) {
|
|
46231
|
+
const path26 = typeof action?.device === "string" ? devicePath : commandPath;
|
|
46232
|
+
const { line: line2, col: col2 } = locateInstancePath(loaded.doc, loaded.lineCounter, path26);
|
|
46233
|
+
errors.push({
|
|
46234
|
+
path: path26,
|
|
46235
|
+
line: line2,
|
|
46236
|
+
col: col2,
|
|
46237
|
+
keyword: "rule-live-device-not-found",
|
|
46238
|
+
message: `rule "${ruleName}" action #${ai} resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`,
|
|
46239
|
+
hint: "confirm the alias/deviceId against `switchbot devices list` before relying on this policy",
|
|
46240
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/device"
|
|
46241
|
+
});
|
|
46242
|
+
continue;
|
|
46243
|
+
}
|
|
46244
|
+
const match = target.typeName ? findCatalogEntry(target.typeName) : null;
|
|
46245
|
+
const entry = !match || Array.isArray(match) ? null : match;
|
|
46246
|
+
if (!entry) continue;
|
|
46247
|
+
const supported = entry.commands.filter((spec) => spec.commandType !== "customize").some((spec) => spec.command === parsed.verb);
|
|
46248
|
+
if (supported) continue;
|
|
46249
|
+
const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath);
|
|
46250
|
+
errors.push({
|
|
46251
|
+
path: commandPath,
|
|
46252
|
+
line,
|
|
46253
|
+
col,
|
|
46254
|
+
keyword: "rule-live-unsupported-command",
|
|
46255
|
+
message: `rule "${ruleName}" action #${ai} uses command "${parsed.verb}" but live target "${effectiveDeviceId}" is type "${target.typeName}"`,
|
|
46256
|
+
hint: `supported offline verbs for ${target.typeName}: ${entry.commands.filter((spec) => spec.commandType !== "customize").map((spec) => spec.command).join(", ")}`,
|
|
46257
|
+
schemaPath: "#/properties/automation/properties/rules/items/properties/then/items/properties/command"
|
|
46258
|
+
});
|
|
46259
|
+
}
|
|
46260
|
+
}
|
|
46261
|
+
}
|
|
46262
|
+
return {
|
|
46263
|
+
...base,
|
|
46264
|
+
validationScope: "schema+offline-semantics+live-inventory",
|
|
46265
|
+
limitations: [...POLICY_VALIDATION_LIVE_LIMITATIONS],
|
|
46266
|
+
valid: errors.length === 0,
|
|
46267
|
+
errors
|
|
46268
|
+
};
|
|
46269
|
+
}
|
|
45607
46270
|
function validateLoadedPolicy(loaded) {
|
|
45608
46271
|
const declared = readDeclaredVersion(loaded.data);
|
|
45609
46272
|
if (declared !== void 0 && !isSupportedPolicySchemaVersion(declared)) {
|
|
@@ -45630,11 +46293,14 @@ function validateLoadedPolicy(loaded) {
|
|
|
45630
46293
|
if (version2 === "0.2") {
|
|
45631
46294
|
const ruleErrors = collectDestructiveRuleErrors(loaded);
|
|
45632
46295
|
errors.push(...ruleErrors);
|
|
46296
|
+
errors.push(...collectOfflineSemanticErrors(loaded, errors));
|
|
45633
46297
|
}
|
|
45634
46298
|
const valid = ok === true && errors.length === 0;
|
|
45635
46299
|
return {
|
|
45636
46300
|
policyPath: loaded.path,
|
|
45637
46301
|
schemaVersion: version2,
|
|
46302
|
+
validationScope: "schema+offline-semantics",
|
|
46303
|
+
limitations: [...POLICY_VALIDATION_LIMITATIONS],
|
|
45638
46304
|
valid,
|
|
45639
46305
|
errors
|
|
45640
46306
|
};
|
|
@@ -45716,6 +46382,15 @@ var COMMAND_KEYWORDS = [
|
|
|
45716
46382
|
{ pattern: /\bclose\b|\blower\b|\bdown\b/i, command: "close" },
|
|
45717
46383
|
{ pattern: /\bpause\b/i, command: "pause" }
|
|
45718
46384
|
];
|
|
46385
|
+
function inferCommandFromIntent(intent) {
|
|
46386
|
+
for (const k2 of COMMAND_KEYWORDS) {
|
|
46387
|
+
if (k2.pattern.test(intent)) return k2.command;
|
|
46388
|
+
}
|
|
46389
|
+
return void 0;
|
|
46390
|
+
}
|
|
46391
|
+
function containsCjk(intent) {
|
|
46392
|
+
return /[\u3400-\u9FFF]/u.test(intent);
|
|
46393
|
+
}
|
|
45719
46394
|
|
|
45720
46395
|
// src/lib/plan-store.ts
|
|
45721
46396
|
init_cjs_shim();
|
|
@@ -45921,14 +46596,13 @@ function validatePlan(raw) {
|
|
|
45921
46596
|
}
|
|
45922
46597
|
function suggestPlan(opts) {
|
|
45923
46598
|
const warnings = [];
|
|
45924
|
-
let command = "";
|
|
45925
|
-
for (const k2 of COMMAND_KEYWORDS) {
|
|
45926
|
-
if (k2.pattern.test(opts.intent)) {
|
|
45927
|
-
command = k2.command;
|
|
45928
|
-
break;
|
|
45929
|
-
}
|
|
45930
|
-
}
|
|
46599
|
+
let command = inferCommandFromIntent(opts.intent) ?? "";
|
|
45931
46600
|
if (!command) {
|
|
46601
|
+
if (containsCjk(opts.intent)) {
|
|
46602
|
+
throw new UsageError(
|
|
46603
|
+
`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.`
|
|
46604
|
+
);
|
|
46605
|
+
}
|
|
45932
46606
|
command = "turnOn";
|
|
45933
46607
|
warnings.push(
|
|
45934
46608
|
`Could not infer command from intent "${opts.intent}" \u2014 defaulted to "turnOn". Edit the generated plan to set the correct command.`
|
|
@@ -45977,7 +46651,7 @@ async function executePlanSteps(plan, planId, options) {
|
|
|
45977
46651
|
const out = {
|
|
45978
46652
|
plan,
|
|
45979
46653
|
results: [],
|
|
45980
|
-
summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0 }
|
|
46654
|
+
summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0, dryRun: 0 }
|
|
45981
46655
|
};
|
|
45982
46656
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
45983
46657
|
const step = plan.steps[i];
|
|
@@ -46036,8 +46710,8 @@ async function executePlanSteps(plan, planId, options) {
|
|
|
46036
46710
|
if (!isJsonMode()) console.log(` ${idx}. \u2713 ${step.command} on ${resolvedDeviceId}`);
|
|
46037
46711
|
} catch (err) {
|
|
46038
46712
|
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
46039
|
-
out.results.push({ step: idx, type: "command", deviceId: resolvedDeviceId, command: step.command, status: "
|
|
46040
|
-
out.summary.
|
|
46713
|
+
out.results.push({ step: idx, type: "command", deviceId: resolvedDeviceId, command: step.command, status: "dry-run" });
|
|
46714
|
+
out.summary.dryRun++;
|
|
46041
46715
|
if (!isJsonMode()) console.log(` ${idx}. \u25E6 dry-run ${step.command} on ${resolvedDeviceId}`);
|
|
46042
46716
|
continue;
|
|
46043
46717
|
}
|
|
@@ -46120,25 +46794,29 @@ against the live API without executing any mutations.
|
|
|
46120
46794
|
(v2, prev) => [...prev, v2],
|
|
46121
46795
|
[]
|
|
46122
46796
|
).option("--out <file>", "Write plan JSON to file instead of stdout").action((opts) => {
|
|
46123
|
-
|
|
46124
|
-
|
|
46125
|
-
|
|
46126
|
-
|
|
46127
|
-
|
|
46128
|
-
const
|
|
46129
|
-
|
|
46130
|
-
|
|
46131
|
-
|
|
46132
|
-
|
|
46797
|
+
try {
|
|
46798
|
+
if (opts.device.length === 0) {
|
|
46799
|
+
console.error("error: at least one --device is required");
|
|
46800
|
+
process.exit(1);
|
|
46801
|
+
}
|
|
46802
|
+
const devices = opts.device.map((ref) => {
|
|
46803
|
+
const cached2 = getCachedDevice(ref);
|
|
46804
|
+
return { id: ref, name: cached2?.name, type: cached2?.type };
|
|
46805
|
+
});
|
|
46806
|
+
const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices });
|
|
46807
|
+
for (const w2 of warnings) process.stderr.write(`warning: ${w2}
|
|
46133
46808
|
`);
|
|
46134
|
-
|
|
46135
|
-
|
|
46136
|
-
|
|
46137
|
-
|
|
46138
|
-
|
|
46139
|
-
|
|
46140
|
-
|
|
46141
|
-
|
|
46809
|
+
const json3 = JSON.stringify(suggested, null, 2);
|
|
46810
|
+
if (opts.out) {
|
|
46811
|
+
fs13.writeFileSync(opts.out, json3 + "\n", "utf8");
|
|
46812
|
+
if (!isJsonMode()) console.log(`\u2713 plan written to ${opts.out}`);
|
|
46813
|
+
} else if (isJsonMode()) {
|
|
46814
|
+
printJson({ plan: suggested, warnings });
|
|
46815
|
+
} else {
|
|
46816
|
+
console.log(json3);
|
|
46817
|
+
}
|
|
46818
|
+
} catch (err) {
|
|
46819
|
+
handleError(err);
|
|
46142
46820
|
}
|
|
46143
46821
|
});
|
|
46144
46822
|
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(
|
|
@@ -46338,6 +47016,13 @@ summary: ok=${ok} error=${error48} skipped=${skipped} total=${out.summary.total}
|
|
|
46338
47016
|
// src/rules/suggest.ts
|
|
46339
47017
|
init_cjs_shim();
|
|
46340
47018
|
var import_yaml4 = __toESM(require_dist(), 1);
|
|
47019
|
+
init_output();
|
|
47020
|
+
function buildSuggestedAction(command, deviceId) {
|
|
47021
|
+
if (deviceId) {
|
|
47022
|
+
return { command: `devices command ${deviceId} ${command}` };
|
|
47023
|
+
}
|
|
47024
|
+
return { command: `devices command <id> ${command}` };
|
|
47025
|
+
}
|
|
46341
47026
|
var TRIGGER_KEYWORDS = [
|
|
46342
47027
|
{ pattern: /\bmotion\b|\bdetect/i, trigger: "mqtt", event: "motion.detected" },
|
|
46343
47028
|
{ pattern: /\bdoor\b|\bcontact\b|\bopen.*sensor/i, trigger: "mqtt", event: "contact.opened" },
|
|
@@ -46365,8 +47050,12 @@ function inferSchedule(intent, warnings) {
|
|
|
46365
47050
|
return "0 8 * * *";
|
|
46366
47051
|
}
|
|
46367
47052
|
function inferCommand(intent, warnings) {
|
|
46368
|
-
|
|
46369
|
-
|
|
47053
|
+
const command = inferCommandFromIntent(intent);
|
|
47054
|
+
if (command) return command;
|
|
47055
|
+
if (containsCjk(intent)) {
|
|
47056
|
+
throw new UsageError(
|
|
47057
|
+
`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.`
|
|
47058
|
+
);
|
|
46370
47059
|
}
|
|
46371
47060
|
warnings.push(
|
|
46372
47061
|
`Could not infer command from intent "${intent}" \u2014 defaulted to "turnOn". Edit the generated rule to set the correct command.`
|
|
@@ -46375,6 +47064,7 @@ function inferCommand(intent, warnings) {
|
|
|
46375
47064
|
}
|
|
46376
47065
|
function suggestRule(opts) {
|
|
46377
47066
|
const warnings = [];
|
|
47067
|
+
const cjkIntent = containsCjk(opts.intent);
|
|
46378
47068
|
let triggerSource = opts.trigger;
|
|
46379
47069
|
let inferredEvent;
|
|
46380
47070
|
if (!triggerSource) {
|
|
@@ -46382,6 +47072,11 @@ function suggestRule(opts) {
|
|
|
46382
47072
|
triggerSource = inferred.trigger;
|
|
46383
47073
|
inferredEvent = inferred.event;
|
|
46384
47074
|
if (inferredEvent === "device.shadow") {
|
|
47075
|
+
if (cjkIntent) {
|
|
47076
|
+
throw new UsageError(
|
|
47077
|
+
`Intent "${opts.intent}" contains non-English trigger text that this heuristic cannot safely infer. Re-run with --trigger and, for mqtt rules, --event explicitly.`
|
|
47078
|
+
);
|
|
47079
|
+
}
|
|
46385
47080
|
warnings.push(
|
|
46386
47081
|
`Could not infer trigger type from intent "${opts.intent}" \u2014 defaulted to mqtt/device.shadow. Set --trigger and --event explicitly.`
|
|
46387
47082
|
);
|
|
@@ -46397,6 +47092,11 @@ function suggestRule(opts) {
|
|
|
46397
47092
|
}
|
|
46398
47093
|
when = mqttTrigger;
|
|
46399
47094
|
} else if (triggerSource === "cron") {
|
|
47095
|
+
if (cjkIntent && !opts.schedule) {
|
|
47096
|
+
throw new UsageError(
|
|
47097
|
+
`Intent "${opts.intent}" contains non-English scheduling text that this heuristic cannot safely infer. Re-run with --schedule "<cron>" explicitly.`
|
|
47098
|
+
);
|
|
47099
|
+
}
|
|
46400
47100
|
const schedule = opts.schedule ?? inferSchedule(opts.intent, warnings);
|
|
46401
47101
|
const cronTrigger = { source: "cron", schedule };
|
|
46402
47102
|
if (opts.days && opts.days.length > 0) cronTrigger.days = opts.days;
|
|
@@ -46406,10 +47106,7 @@ function suggestRule(opts) {
|
|
|
46406
47106
|
}
|
|
46407
47107
|
const command = inferCommand(opts.intent, warnings);
|
|
46408
47108
|
const actionDevices = triggerSource === "mqtt" && opts.devices && opts.devices.length > 1 ? opts.devices.slice(1) : opts.devices ?? [];
|
|
46409
|
-
const then = actionDevices.length > 0 ? actionDevices.map((d) => (
|
|
46410
|
-
command: `devices command <id> ${command}`,
|
|
46411
|
-
device: d.id
|
|
46412
|
-
})) : [{ command: `devices command <id> ${command}` }];
|
|
47109
|
+
const then = actionDevices.length > 0 ? actionDevices.map((d) => buildSuggestedAction(command, d.id)) : [buildSuggestedAction(command)];
|
|
46413
47110
|
const rule = {
|
|
46414
47111
|
name: opts.intent,
|
|
46415
47112
|
when,
|
|
@@ -46638,7 +47335,6 @@ function diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource, maxChanges
|
|
|
46638
47335
|
}
|
|
46639
47336
|
|
|
46640
47337
|
// src/commands/mcp.ts
|
|
46641
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
46642
47338
|
import { dirname as pathDirname, join as pathJoin } from "node:path";
|
|
46643
47339
|
import os13 from "node:os";
|
|
46644
47340
|
import fs15 from "node:fs";
|
|
@@ -47480,14 +48176,17 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47480
48176
|
"policy_validate",
|
|
47481
48177
|
{
|
|
47482
48178
|
title: "Validate a policy.yaml file",
|
|
47483
|
-
description: "Check a policy file against the embedded JSON Schema
|
|
48179
|
+
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.",
|
|
47484
48180
|
_meta: { agentSafetyTier: "read" },
|
|
47485
48181
|
inputSchema: external_exports.object({
|
|
47486
|
-
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path")
|
|
48182
|
+
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
48183
|
+
live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
|
|
47487
48184
|
}).strict(),
|
|
47488
48185
|
outputSchema: {
|
|
47489
48186
|
policyPath: external_exports.string(),
|
|
47490
48187
|
schemaVersion: external_exports.string(),
|
|
48188
|
+
validationScope: external_exports.string(),
|
|
48189
|
+
limitations: external_exports.array(external_exports.string()),
|
|
47491
48190
|
present: external_exports.boolean().describe("false when the file does not exist"),
|
|
47492
48191
|
valid: external_exports.boolean().nullable().describe("null when present=false"),
|
|
47493
48192
|
errors: external_exports.array(external_exports.object({
|
|
@@ -47501,14 +48200,25 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47501
48200
|
})).describe("Empty when valid or when the file is missing")
|
|
47502
48201
|
}
|
|
47503
48202
|
},
|
|
47504
|
-
async ({ path: pathArg }) => {
|
|
48203
|
+
async ({ path: pathArg, live }) => {
|
|
47505
48204
|
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
47506
48205
|
try {
|
|
47507
48206
|
const loaded = loadPolicyFile(policyPath);
|
|
47508
|
-
|
|
48207
|
+
let result = validateLoadedPolicy(loaded);
|
|
48208
|
+
if (live) {
|
|
48209
|
+
if (!tryLoadConfig()) {
|
|
48210
|
+
return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
|
|
48211
|
+
hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
48212
|
+
});
|
|
48213
|
+
}
|
|
48214
|
+
const inventory = await fetchDeviceList(void 0, { bypassCache: true });
|
|
48215
|
+
result = validateLoadedPolicyAgainstInventory(loaded, inventory);
|
|
48216
|
+
}
|
|
47509
48217
|
const structured = {
|
|
47510
48218
|
policyPath: result.policyPath,
|
|
47511
48219
|
schemaVersion: result.schemaVersion,
|
|
48220
|
+
validationScope: result.validationScope,
|
|
48221
|
+
limitations: result.limitations,
|
|
47512
48222
|
present: true,
|
|
47513
48223
|
valid: result.valid,
|
|
47514
48224
|
errors: result.errors
|
|
@@ -47522,6 +48232,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47522
48232
|
const structured = {
|
|
47523
48233
|
policyPath,
|
|
47524
48234
|
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
48235
|
+
validationScope: "schema+offline-semantics",
|
|
48236
|
+
limitations: [
|
|
48237
|
+
"Does not resolve aliases against the live device inventory.",
|
|
48238
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
48239
|
+
],
|
|
47525
48240
|
present: false,
|
|
47526
48241
|
valid: null,
|
|
47527
48242
|
errors: []
|
|
@@ -47535,6 +48250,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47535
48250
|
const structured = {
|
|
47536
48251
|
policyPath,
|
|
47537
48252
|
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
48253
|
+
validationScope: "schema+offline-semantics",
|
|
48254
|
+
limitations: [
|
|
48255
|
+
"Does not resolve aliases against the live device inventory.",
|
|
48256
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
48257
|
+
],
|
|
47538
48258
|
present: true,
|
|
47539
48259
|
valid: false,
|
|
47540
48260
|
errors: err.yamlErrors.map((e) => ({
|
|
@@ -47581,8 +48301,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47581
48301
|
context: { policyPath }
|
|
47582
48302
|
});
|
|
47583
48303
|
}
|
|
47584
|
-
const
|
|
47585
|
-
const template = fs15.readFileSync(fileURLToPath2(templateUrl), "utf-8");
|
|
48304
|
+
const template = readPolicyExampleYaml();
|
|
47586
48305
|
fs15.mkdirSync(pathDirname(policyPath), { recursive: true });
|
|
47587
48306
|
fs15.writeFileSync(policyPath, template, { encoding: "utf-8" });
|
|
47588
48307
|
const structured = {
|
|
@@ -47601,7 +48320,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47601
48320
|
"policy_migrate",
|
|
47602
48321
|
{
|
|
47603
48322
|
title: "Migrate a policy file to the latest supported schema",
|
|
47604
|
-
description:
|
|
48323
|
+
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.',
|
|
47605
48324
|
_meta: { agentSafetyTier: "action" },
|
|
47606
48325
|
inputSchema: external_exports.object({
|
|
47607
48326
|
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
@@ -47671,10 +48390,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
47671
48390
|
};
|
|
47672
48391
|
}
|
|
47673
48392
|
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
48393
|
+
const isLegacy = fileVersion === "0.1";
|
|
47674
48394
|
const structured2 = {
|
|
47675
48395
|
...base,
|
|
47676
48396
|
status: "unsupported",
|
|
47677
|
-
message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(", ")})`
|
|
48397
|
+
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(", ")})`
|
|
47678
48398
|
};
|
|
47679
48399
|
return {
|
|
47680
48400
|
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
@@ -48027,7 +48747,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
48027
48747
|
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
48028
48748
|
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
48029
48749
|
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
48030
|
-
results: external_exports.array(external_exports.enum(["ok", "error"])).optional().describe("Filter by execution result."),
|
|
48750
|
+
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
48031
48751
|
limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
|
|
48032
48752
|
}).strict(),
|
|
48033
48753
|
outputSchema: {
|
|
@@ -48080,7 +48800,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
48080
48800
|
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
48081
48801
|
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
48082
48802
|
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
48083
|
-
results: external_exports.array(external_exports.enum(["ok", "error"])).optional().describe("Filter by execution result."),
|
|
48803
|
+
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
48084
48804
|
top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
|
|
48085
48805
|
}).strict(),
|
|
48086
48806
|
outputSchema: {
|
|
@@ -48228,6 +48948,29 @@ function listRegisteredTools(server) {
|
|
|
48228
48948
|
if (!internal._registeredTools) return [];
|
|
48229
48949
|
return Object.keys(internal._registeredTools).sort();
|
|
48230
48950
|
}
|
|
48951
|
+
function listRegisteredResources() {
|
|
48952
|
+
return ["switchbot://events"];
|
|
48953
|
+
}
|
|
48954
|
+
function printMcpToolDirectory() {
|
|
48955
|
+
const server = createSwitchBotMcpServer();
|
|
48956
|
+
const tools = listRegisteredTools(server).map((name) => ({ name }));
|
|
48957
|
+
const resources = listRegisteredResources().map((uri) => ({ uri }));
|
|
48958
|
+
if (isJsonMode()) {
|
|
48959
|
+
printJson({ tools, resources });
|
|
48960
|
+
return;
|
|
48961
|
+
}
|
|
48962
|
+
console.log("Tools:");
|
|
48963
|
+
for (const tool of tools) {
|
|
48964
|
+
console.log(` ${tool.name}`);
|
|
48965
|
+
}
|
|
48966
|
+
console.log("");
|
|
48967
|
+
console.log("Resources:");
|
|
48968
|
+
for (const resource of resources) {
|
|
48969
|
+
console.log(` ${resource.uri}`);
|
|
48970
|
+
}
|
|
48971
|
+
console.log(`
|
|
48972
|
+
Total: ${tools.length} tool(s), ${resources.length} resource(s)`);
|
|
48973
|
+
}
|
|
48231
48974
|
function registerMcpCommand(program3) {
|
|
48232
48975
|
const mcp = program3.command("mcp").description("Run as a Model Context Protocol server so AI agents can call SwitchBot tools").addHelpText("after", `
|
|
48233
48976
|
The MCP server exposes twenty-one tools:
|
|
@@ -48242,9 +48985,10 @@ function registerMcpCommand(program3) {
|
|
|
48242
48985
|
- get_device_history fetch raw JSONL history records for a device
|
|
48243
48986
|
- query_device_history filter + page history records with field/time predicates
|
|
48244
48987
|
- aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records
|
|
48245
|
-
- policy_validate check policy.yaml against the embedded schema
|
|
48988
|
+
- policy_validate check policy.yaml against the embedded schema + offline semantics
|
|
48989
|
+
(set live=true to resolve aliases and rule targets against current inventory)
|
|
48246
48990
|
- policy_new scaffold a starter policy.yaml (action \u2014 confirm first)
|
|
48247
|
-
- policy_migrate
|
|
48991
|
+
- policy_migrate rewrite policy.yaml between currently supported schemas (action \u2014 preserves comments)
|
|
48248
48992
|
- policy_diff compare two policy files with structural + line diff output
|
|
48249
48993
|
- plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM)
|
|
48250
48994
|
- plan_run validate + execute a Plan JSON document
|
|
@@ -48276,6 +49020,8 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
|
|
|
48276
49020
|
Inspect locally:
|
|
48277
49021
|
$ npx @modelcontextprotocol/inspector switchbot mcp serve
|
|
48278
49022
|
`);
|
|
49023
|
+
mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() => printMcpToolDirectory());
|
|
49024
|
+
mcp.command("list-tools").description("Alias of `mcp tools`").action(() => printMcpToolDirectory());
|
|
48279
49025
|
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", `
|
|
48280
49026
|
Examples:
|
|
48281
49027
|
$ switchbot mcp serve
|
|
@@ -48517,6 +49263,42 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
48517
49263
|
init_cjs_shim();
|
|
48518
49264
|
init_output();
|
|
48519
49265
|
init_quota();
|
|
49266
|
+
function runQuotaStatus() {
|
|
49267
|
+
const usage = todayUsage();
|
|
49268
|
+
const history = loadQuota();
|
|
49269
|
+
if (isJsonMode()) {
|
|
49270
|
+
printJson({
|
|
49271
|
+
today: {
|
|
49272
|
+
date: usage.date,
|
|
49273
|
+
total: usage.total,
|
|
49274
|
+
remaining: usage.remaining,
|
|
49275
|
+
dailyLimit: DAILY_QUOTA,
|
|
49276
|
+
endpoints: usage.endpoints
|
|
49277
|
+
},
|
|
49278
|
+
history: history.days
|
|
49279
|
+
});
|
|
49280
|
+
return;
|
|
49281
|
+
}
|
|
49282
|
+
console.log(`Today (${usage.date}):`);
|
|
49283
|
+
console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`);
|
|
49284
|
+
console.log(` Remaining budget: ${usage.remaining}`);
|
|
49285
|
+
if (Object.keys(usage.endpoints).length === 0) {
|
|
49286
|
+
console.log(" (no requests recorded yet)");
|
|
49287
|
+
} else {
|
|
49288
|
+
console.log(" Endpoint breakdown:");
|
|
49289
|
+
const entries = Object.entries(usage.endpoints).sort((a, b2) => b2[1] - a[1]);
|
|
49290
|
+
for (const [endpoint, count] of entries) {
|
|
49291
|
+
console.log(` ${endpoint.padEnd(48)} ${count}`);
|
|
49292
|
+
}
|
|
49293
|
+
}
|
|
49294
|
+
const otherDays = Object.entries(history.days).filter(([d]) => d !== usage.date).sort((a, b2) => b2[0].localeCompare(a[0]));
|
|
49295
|
+
if (otherDays.length > 0) {
|
|
49296
|
+
console.log("\nRecent history:");
|
|
49297
|
+
for (const [date5, bucket] of otherDays) {
|
|
49298
|
+
console.log(` ${date5} ${bucket.total}`);
|
|
49299
|
+
}
|
|
49300
|
+
}
|
|
49301
|
+
}
|
|
48520
49302
|
function registerQuotaCommand(program3) {
|
|
48521
49303
|
const quota = program3.command("quota").description("Inspect and manage the local SwitchBot API request counter").addHelpText("after", `
|
|
48522
49304
|
Every request the CLI makes is counted locally in ~/.switchbot/quota.json.
|
|
@@ -48534,41 +49316,11 @@ Examples:
|
|
|
48534
49316
|
$ switchbot quota status --json
|
|
48535
49317
|
$ switchbot quota reset
|
|
48536
49318
|
`);
|
|
49319
|
+
quota.action(() => {
|
|
49320
|
+
runQuotaStatus();
|
|
49321
|
+
});
|
|
48537
49322
|
quota.command("status").alias("show").description("Show today's usage and the last 7 days (alias: show)").action(() => {
|
|
48538
|
-
|
|
48539
|
-
const history = loadQuota();
|
|
48540
|
-
if (isJsonMode()) {
|
|
48541
|
-
printJson({
|
|
48542
|
-
today: {
|
|
48543
|
-
date: usage.date,
|
|
48544
|
-
total: usage.total,
|
|
48545
|
-
remaining: usage.remaining,
|
|
48546
|
-
dailyLimit: DAILY_QUOTA,
|
|
48547
|
-
endpoints: usage.endpoints
|
|
48548
|
-
},
|
|
48549
|
-
history: history.days
|
|
48550
|
-
});
|
|
48551
|
-
return;
|
|
48552
|
-
}
|
|
48553
|
-
console.log(`Today (${usage.date}):`);
|
|
48554
|
-
console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`);
|
|
48555
|
-
console.log(` Remaining budget: ${usage.remaining}`);
|
|
48556
|
-
if (Object.keys(usage.endpoints).length === 0) {
|
|
48557
|
-
console.log(" (no requests recorded yet)");
|
|
48558
|
-
} else {
|
|
48559
|
-
console.log(" Endpoint breakdown:");
|
|
48560
|
-
const entries = Object.entries(usage.endpoints).sort((a, b2) => b2[1] - a[1]);
|
|
48561
|
-
for (const [endpoint, count] of entries) {
|
|
48562
|
-
console.log(` ${endpoint.padEnd(48)} ${count}`);
|
|
48563
|
-
}
|
|
48564
|
-
}
|
|
48565
|
-
const otherDays = Object.entries(history.days).filter(([d]) => d !== usage.date).sort((a, b2) => b2[0].localeCompare(a[0]));
|
|
48566
|
-
if (otherDays.length > 0) {
|
|
48567
|
-
console.log("\nRecent history:");
|
|
48568
|
-
for (const [date5, bucket] of otherDays) {
|
|
48569
|
-
console.log(` ${date5} ${bucket.total}`);
|
|
48570
|
-
}
|
|
48571
|
-
}
|
|
49323
|
+
runQuotaStatus();
|
|
48572
49324
|
});
|
|
48573
49325
|
quota.command("reset").description("Delete the local quota counter file").action(() => {
|
|
48574
49326
|
resetQuota();
|
|
@@ -48714,40 +49466,61 @@ Total: ${entries.length} device type(s) (source: ${source})`);
|
|
|
48714
49466
|
handleError(error48);
|
|
48715
49467
|
}
|
|
48716
49468
|
});
|
|
48717
|
-
catalog.command("search").description("Fuzzy search the effective catalog by type name, alias, role, or command name").argument("<keyword>", "Substring to match (case-insensitive) against type, alias, role, or command").action((keyword) => {
|
|
49469
|
+
catalog.command("search").description("Fuzzy search the effective catalog by type name, alias, role, or command name").argument("<keyword>", "Substring to match (case-insensitive) against type, alias, role, or command").option("--strict", "Only return entries whose type name matches (skip alias/role/command fallbacks)").action((keyword, options) => {
|
|
48718
49470
|
try {
|
|
48719
49471
|
const q = keyword.toLowerCase();
|
|
48720
49472
|
const entries = getEffectiveCatalog();
|
|
48721
|
-
const
|
|
48722
|
-
|
|
48723
|
-
|
|
48724
|
-
|
|
48725
|
-
|
|
48726
|
-
|
|
48727
|
-
|
|
49473
|
+
const strict = options.strict === true;
|
|
49474
|
+
const hits = [];
|
|
49475
|
+
for (const e of entries) {
|
|
49476
|
+
const matched = [];
|
|
49477
|
+
const typeHit = e.type.toLowerCase().includes(q);
|
|
49478
|
+
const aliasExact = (e.aliases ?? []).some((a) => a.toLowerCase() === q);
|
|
49479
|
+
const aliasSubstr = (e.aliases ?? []).some((a) => a.toLowerCase().includes(q) && a.toLowerCase() !== q);
|
|
49480
|
+
const roleHit = (e.role ?? "").toLowerCase().includes(q);
|
|
49481
|
+
const cmdMatches = e.commands.filter((c) => c.command.toLowerCase().includes(q)).map((c) => c.command);
|
|
49482
|
+
if (typeHit) matched.push("type");
|
|
49483
|
+
if (aliasExact) matched.push("alias");
|
|
49484
|
+
else if (aliasSubstr) matched.push("alias-only");
|
|
49485
|
+
if (roleHit) matched.push("role");
|
|
49486
|
+
if (cmdMatches.length > 0) matched.push(`commands[${cmdMatches.join(",")}]`);
|
|
49487
|
+
if (strict) {
|
|
49488
|
+
if (!typeHit) continue;
|
|
49489
|
+
} else if (matched.length === 0) {
|
|
49490
|
+
continue;
|
|
49491
|
+
}
|
|
49492
|
+
let tier;
|
|
49493
|
+
if (typeHit || aliasExact) tier = 0;
|
|
49494
|
+
else if (roleHit || cmdMatches.length > 0) tier = 1;
|
|
49495
|
+
else tier = 2;
|
|
49496
|
+
hits.push({ entry: e, tier, matched });
|
|
49497
|
+
}
|
|
49498
|
+
hits.sort((a, b2) => a.tier - b2.tier);
|
|
48728
49499
|
if (isJsonMode()) {
|
|
48729
|
-
printJson({
|
|
49500
|
+
printJson({
|
|
49501
|
+
query: keyword,
|
|
49502
|
+
strict,
|
|
49503
|
+
matches: hits.map((h) => ({ ...h.entry, _matchedOn: h.matched, _tier: h.tier }))
|
|
49504
|
+
});
|
|
48730
49505
|
return;
|
|
48731
49506
|
}
|
|
48732
49507
|
if (hits.length === 0) {
|
|
48733
|
-
|
|
49508
|
+
const suffix = strict ? " (strict mode \u2014 try without --strict)" : "";
|
|
49509
|
+
console.log(`No catalog entries match "${keyword}"${suffix}.`);
|
|
48734
49510
|
return;
|
|
48735
49511
|
}
|
|
48736
49512
|
const fmt = resolveFormat();
|
|
48737
|
-
const headers = ["type", "category", "role", "
|
|
48738
|
-
const rows = hits.map((
|
|
48739
|
-
|
|
48740
|
-
|
|
48741
|
-
|
|
48742
|
-
|
|
48743
|
-
|
|
48744
|
-
if (cmdMatches.length > 0) matched.push(`commands[${cmdMatches.join(",")}]`);
|
|
48745
|
-
return [e.type, e.category, e.role ?? "\u2014", matched.join(", ") || "\u2014"];
|
|
48746
|
-
});
|
|
49513
|
+
const headers = ["type", "category", "role", "matched_on"];
|
|
49514
|
+
const rows = hits.map((h) => [
|
|
49515
|
+
h.entry.type,
|
|
49516
|
+
h.entry.category,
|
|
49517
|
+
h.entry.role ?? "\u2014",
|
|
49518
|
+
h.matched.join(", ") || "\u2014"
|
|
49519
|
+
]);
|
|
48747
49520
|
renderRows(headers, rows, fmt, resolveFields());
|
|
48748
49521
|
if (fmt === "table") {
|
|
48749
49522
|
console.log(`
|
|
48750
|
-
${hits.length} match${hits.length === 1 ? "" : "es"} for "${keyword}"`);
|
|
49523
|
+
${hits.length} match${hits.length === 1 ? "" : "es"} for "${keyword}"${strict ? " (strict)" : ""}`);
|
|
48751
49524
|
}
|
|
48752
49525
|
} catch (error48) {
|
|
48753
49526
|
handleError(error48);
|
|
@@ -49252,6 +50025,13 @@ function extractDeviceId(parsed) {
|
|
|
49252
50025
|
if (typeof id === "string" && id.length > 0) return id;
|
|
49253
50026
|
return null;
|
|
49254
50027
|
}
|
|
50028
|
+
function emitJsonStreamRecord(record2) {
|
|
50029
|
+
const { schemaVersion, ...rest } = record2;
|
|
50030
|
+
printJson({
|
|
50031
|
+
payloadVersion: schemaVersion,
|
|
50032
|
+
...rest
|
|
50033
|
+
});
|
|
50034
|
+
}
|
|
49255
50035
|
function matchFilterDetail(body, clauses) {
|
|
49256
50036
|
if (!clauses || clauses.length === 0) return { matched: true, matchedKeys: [] };
|
|
49257
50037
|
if (!body || typeof body !== "object") return { matched: false, matchedKeys: [] };
|
|
@@ -49407,7 +50187,7 @@ Examples:
|
|
|
49407
50187
|
if (!ev.matched) return;
|
|
49408
50188
|
matchedCount++;
|
|
49409
50189
|
if (isJsonMode()) {
|
|
49410
|
-
|
|
50190
|
+
emitJsonStreamRecord(ev);
|
|
49411
50191
|
} else {
|
|
49412
50192
|
const when = new Date(ev.t).toLocaleTimeString();
|
|
49413
50193
|
console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`);
|
|
@@ -49554,7 +50334,7 @@ Examples:
|
|
|
49554
50334
|
if (isJsonMode()) emitStreamHeader({ eventKind: "event", cadence: "push" });
|
|
49555
50335
|
if (isJsonMode()) {
|
|
49556
50336
|
const sessionStartAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
49557
|
-
|
|
50337
|
+
emitJsonStreamRecord({
|
|
49558
50338
|
schemaVersion: EVENTS_SCHEMA_VERSION,
|
|
49559
50339
|
source: "mqtt",
|
|
49560
50340
|
kind: "control",
|
|
@@ -49606,7 +50386,7 @@ Examples:
|
|
|
49606
50386
|
payload: parsed
|
|
49607
50387
|
};
|
|
49608
50388
|
if (isJsonMode()) {
|
|
49609
|
-
|
|
50389
|
+
emitJsonStreamRecord(record2);
|
|
49610
50390
|
} else {
|
|
49611
50391
|
console.log(JSON.stringify(record2));
|
|
49612
50392
|
}
|
|
@@ -49638,7 +50418,7 @@ Examples:
|
|
|
49638
50418
|
at
|
|
49639
50419
|
};
|
|
49640
50420
|
if (isJsonMode()) {
|
|
49641
|
-
|
|
50421
|
+
emitJsonStreamRecord(ctl);
|
|
49642
50422
|
} else {
|
|
49643
50423
|
console.log(JSON.stringify(ctl));
|
|
49644
50424
|
}
|
|
@@ -49905,6 +50685,33 @@ Examples:
|
|
|
49905
50685
|
|
|
49906
50686
|
// src/commands/doctor.ts
|
|
49907
50687
|
init_catalog();
|
|
50688
|
+
|
|
50689
|
+
// src/version-notes.ts
|
|
50690
|
+
init_cjs_shim();
|
|
50691
|
+
var RELEASE_METADATA = [];
|
|
50692
|
+
function semverParts(v2) {
|
|
50693
|
+
const [maj, min, pat] = v2.replace(/-.*$/, "").split(".").map((n) => Number.parseInt(n, 10));
|
|
50694
|
+
return [maj ?? 0, min ?? 0, pat ?? 0];
|
|
50695
|
+
}
|
|
50696
|
+
function semverCompare(a, b2) {
|
|
50697
|
+
const [aMaj, aMin, aPat] = semverParts(a);
|
|
50698
|
+
const [bMaj, bMin, bPat] = semverParts(b2);
|
|
50699
|
+
if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
|
|
50700
|
+
if (aMin !== bMin) return aMin < bMin ? -1 : 1;
|
|
50701
|
+
if (aPat !== bPat) return aPat < bPat ? -1 : 1;
|
|
50702
|
+
const aPre = a.includes("-");
|
|
50703
|
+
const bPre = b2.includes("-");
|
|
50704
|
+
if (aPre === bPre) return 0;
|
|
50705
|
+
return aPre ? -1 : 1;
|
|
50706
|
+
}
|
|
50707
|
+
function findBreakingChangeBetween(current, latest) {
|
|
50708
|
+
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;
|
|
50709
|
+
}
|
|
50710
|
+
function getReleaseMetadata(version2) {
|
|
50711
|
+
return RELEASE_METADATA.find((m2) => m2.version === version2) ?? null;
|
|
50712
|
+
}
|
|
50713
|
+
|
|
50714
|
+
// src/commands/doctor.ts
|
|
49908
50715
|
init_keychain();
|
|
49909
50716
|
init_request_context();
|
|
49910
50717
|
|
|
@@ -50290,6 +51097,42 @@ function checkCatalogSchema() {
|
|
|
50290
51097
|
}
|
|
50291
51098
|
};
|
|
50292
51099
|
}
|
|
51100
|
+
function checkInventoryConsistency() {
|
|
51101
|
+
const cache2 = loadCache();
|
|
51102
|
+
if (!cache2) {
|
|
51103
|
+
return {
|
|
51104
|
+
name: "inventory",
|
|
51105
|
+
status: "ok",
|
|
51106
|
+
detail: "no local inventory cache \u2014 run 'switchbot devices list' to enable hub-reference checks"
|
|
51107
|
+
};
|
|
51108
|
+
}
|
|
51109
|
+
const dangling = Object.entries(cache2.devices).filter(([, device]) => device.category === "physical").filter(([deviceId, device]) => {
|
|
51110
|
+
const hubDeviceId = device.hubDeviceId;
|
|
51111
|
+
return Boolean(
|
|
51112
|
+
hubDeviceId && hubDeviceId !== "000000000000" && hubDeviceId !== deviceId && !cache2.devices[hubDeviceId]
|
|
51113
|
+
);
|
|
51114
|
+
}).map(([deviceId, device]) => ({
|
|
51115
|
+
deviceId,
|
|
51116
|
+
deviceName: device.name,
|
|
51117
|
+
hubDeviceId: device.hubDeviceId,
|
|
51118
|
+
deviceType: device.type
|
|
51119
|
+
}));
|
|
51120
|
+
if (dangling.length === 0) {
|
|
51121
|
+
return {
|
|
51122
|
+
name: "inventory",
|
|
51123
|
+
status: "ok",
|
|
51124
|
+
detail: `inventory graph consistent across ${Object.keys(cache2.devices).length} cached devices`
|
|
51125
|
+
};
|
|
51126
|
+
}
|
|
51127
|
+
return {
|
|
51128
|
+
name: "inventory",
|
|
51129
|
+
status: "warn",
|
|
51130
|
+
detail: {
|
|
51131
|
+
message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`,
|
|
51132
|
+
dangling: dangling.slice(0, 10)
|
|
51133
|
+
}
|
|
51134
|
+
};
|
|
51135
|
+
}
|
|
50293
51136
|
function checkAudit() {
|
|
50294
51137
|
const p2 = path15.join(os16.homedir(), ".switchbot", "audit.log");
|
|
50295
51138
|
if (!fs19.existsSync(p2)) {
|
|
@@ -50728,6 +51571,26 @@ function checkMcp() {
|
|
|
50728
51571
|
};
|
|
50729
51572
|
}
|
|
50730
51573
|
}
|
|
51574
|
+
function checkReleaseNotes() {
|
|
51575
|
+
const meta4 = getReleaseMetadata(VERSION);
|
|
51576
|
+
if (!meta4 || !meta4.breaking) {
|
|
51577
|
+
return {
|
|
51578
|
+
name: "release-notes",
|
|
51579
|
+
status: "ok",
|
|
51580
|
+
detail: { version: VERSION, message: "no known breaking-change notice for the current release" }
|
|
51581
|
+
};
|
|
51582
|
+
}
|
|
51583
|
+
return {
|
|
51584
|
+
name: "release-notes",
|
|
51585
|
+
status: "warn",
|
|
51586
|
+
detail: {
|
|
51587
|
+
version: VERSION,
|
|
51588
|
+
breaking: true,
|
|
51589
|
+
message: meta4.summary,
|
|
51590
|
+
hint: "If you have scripts pinned to 3.2.x JSON output, update them before rolling this release wider."
|
|
51591
|
+
}
|
|
51592
|
+
};
|
|
51593
|
+
}
|
|
50731
51594
|
var CHECK_REGISTRY = [
|
|
50732
51595
|
{ name: "node", description: "Node.js version compatibility", run: () => checkNodeVersion() },
|
|
50733
51596
|
{ name: "path", description: "switchbot binary reachable on PATH", run: () => checkPathDiscoverability() },
|
|
@@ -50736,6 +51599,7 @@ var CHECK_REGISTRY = [
|
|
|
50736
51599
|
{ name: "profiles", description: "profile definitions valid", run: () => checkProfiles() },
|
|
50737
51600
|
{ name: "catalog", description: "catalog loads", run: () => checkCatalog() },
|
|
50738
51601
|
{ name: "catalog-schema", description: "catalog vs agent-bootstrap version aligned", run: () => checkCatalogSchema() },
|
|
51602
|
+
{ name: "inventory", description: "cached inventory graph consistency (hubDeviceId references)", run: () => checkInventoryConsistency() },
|
|
50739
51603
|
{ name: "cache", description: "device cache state", run: () => checkCache() },
|
|
50740
51604
|
{ name: "quota", description: "API quota headroom", run: () => checkQuotaFile() },
|
|
50741
51605
|
{ name: "clock", description: "system clock skew", run: () => checkClockSkew() },
|
|
@@ -50745,6 +51609,7 @@ var CHECK_REGISTRY = [
|
|
|
50745
51609
|
run: ({ probe }) => probe ? checkMqttProbe() : checkMqtt()
|
|
50746
51610
|
},
|
|
50747
51611
|
{ name: "mcp", description: "MCP server instantiable + tool count", run: () => checkMcp() },
|
|
51612
|
+
{ name: "release-notes", description: "current release breaking-change notice", run: () => checkReleaseNotes() },
|
|
50748
51613
|
{ name: "policy", description: "policy.yaml present + schema-valid (if configured)", run: () => checkPolicy() },
|
|
50749
51614
|
{ name: "audit", description: "recent command errors (last 24h)", run: () => checkAudit() },
|
|
50750
51615
|
{ name: "daemon", description: "daemon state file + runtime status", run: () => checkDaemon() },
|
|
@@ -50949,88 +51814,50 @@ function projectFields2(entry, fields) {
|
|
|
50949
51814
|
}
|
|
50950
51815
|
return out;
|
|
50951
51816
|
}
|
|
50952
|
-
function
|
|
50953
|
-
const
|
|
50954
|
-
|
|
50955
|
-
|
|
50956
|
-
|
|
50957
|
-
|
|
50958
|
-
|
|
50959
|
-
|
|
50960
|
-
|
|
50961
|
-
|
|
50962
|
-
|
|
50963
|
-
|
|
50964
|
-
|
|
50965
|
-
|
|
50966
|
-
|
|
50967
|
-
|
|
50968
|
-
|
|
50969
|
-
|
|
50970
|
-
|
|
50971
|
-
|
|
50972
|
-
|
|
50973
|
-
|
|
50974
|
-
|
|
50975
|
-
|
|
50976
|
-
|
|
50977
|
-
|
|
50978
|
-
|
|
50979
|
-
|
|
50980
|
-
$ switchbot schema export --role lighting | jq '[.data.types[].type]'
|
|
50981
|
-
$ switchbot schema export --role security --category physical
|
|
50982
|
-
$ switchbot schema export --project type,commands,statusFields
|
|
50983
|
-
`).action(async (options) => {
|
|
50984
|
-
const catalog = getEffectiveCatalog();
|
|
50985
|
-
let filtered = catalog;
|
|
50986
|
-
if (options.type) {
|
|
50987
|
-
const q = options.type.toLowerCase();
|
|
50988
|
-
filtered = filtered.filter(
|
|
50989
|
-
(e) => e.type.toLowerCase() === q || (e.aliases ?? []).some((a) => a.toLowerCase() === q)
|
|
51817
|
+
function runSchemaExport(options) {
|
|
51818
|
+
const catalog = getEffectiveCatalog();
|
|
51819
|
+
let filtered = catalog;
|
|
51820
|
+
if (options.type) {
|
|
51821
|
+
const q = options.type.toLowerCase();
|
|
51822
|
+
filtered = filtered.filter(
|
|
51823
|
+
(e) => e.type.toLowerCase() === q || (e.aliases ?? []).some((a) => a.toLowerCase() === q)
|
|
51824
|
+
);
|
|
51825
|
+
}
|
|
51826
|
+
if (options.types) {
|
|
51827
|
+
const set3 = new Set(options.types.split(",").map((s2) => s2.trim().toLowerCase()).filter(Boolean));
|
|
51828
|
+
filtered = filtered.filter(
|
|
51829
|
+
(e) => set3.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => set3.has(a.toLowerCase()))
|
|
51830
|
+
);
|
|
51831
|
+
}
|
|
51832
|
+
if (options.role) {
|
|
51833
|
+
const q = options.role.toLowerCase();
|
|
51834
|
+
filtered = filtered.filter((e) => (e.role ?? "other") === q);
|
|
51835
|
+
}
|
|
51836
|
+
if (options.category) {
|
|
51837
|
+
const q = options.category.toLowerCase();
|
|
51838
|
+
filtered = filtered.filter((e) => e.category === q);
|
|
51839
|
+
}
|
|
51840
|
+
if (options.used) {
|
|
51841
|
+
const cache2 = loadCache();
|
|
51842
|
+
if (cache2) {
|
|
51843
|
+
const usedTypes = new Set(
|
|
51844
|
+
Object.values(cache2.devices).map((d) => d.type.toLowerCase())
|
|
50990
51845
|
);
|
|
50991
|
-
}
|
|
50992
|
-
if (options.types) {
|
|
50993
|
-
const set3 = new Set(options.types.split(",").map((s2) => s2.trim().toLowerCase()).filter(Boolean));
|
|
50994
51846
|
filtered = filtered.filter(
|
|
50995
|
-
(e) =>
|
|
50996
|
-
);
|
|
50997
|
-
}
|
|
50998
|
-
if (options.role) {
|
|
50999
|
-
const q = options.role.toLowerCase();
|
|
51000
|
-
filtered = filtered.filter((e) => (e.role ?? "other") === q);
|
|
51001
|
-
}
|
|
51002
|
-
if (options.category) {
|
|
51003
|
-
const q = options.category.toLowerCase();
|
|
51004
|
-
filtered = filtered.filter((e) => e.category === q);
|
|
51005
|
-
}
|
|
51006
|
-
if (options.used) {
|
|
51007
|
-
const cache2 = loadCache();
|
|
51008
|
-
if (cache2) {
|
|
51009
|
-
const usedTypes = new Set(
|
|
51010
|
-
Object.values(cache2.devices).map((d) => d.type.toLowerCase())
|
|
51011
|
-
);
|
|
51012
|
-
filtered = filtered.filter(
|
|
51013
|
-
(e) => usedTypes.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase()))
|
|
51014
|
-
);
|
|
51015
|
-
} else {
|
|
51016
|
-
filtered = [];
|
|
51017
|
-
}
|
|
51018
|
-
}
|
|
51019
|
-
const mapped = options.compact ? filtered.map(toCompactEntry) : filtered.map(toSchemaEntry);
|
|
51020
|
-
const projected = options.project ? mapped.map(
|
|
51021
|
-
(e) => projectFields2(e, options.project.split(",").map((s2) => s2.trim()).filter(Boolean))
|
|
51022
|
-
) : mapped;
|
|
51023
|
-
let finalTypes = projected;
|
|
51024
|
-
if (options.capabilities) {
|
|
51025
|
-
const { COMMAND_META: COMMAND_META2 } = await Promise.resolve().then(() => (init_capabilities(), capabilities_exports));
|
|
51026
|
-
const devicesMeta = Object.fromEntries(
|
|
51027
|
-
Object.entries(COMMAND_META2).filter(([k2]) => k2.startsWith("devices "))
|
|
51847
|
+
(e) => usedTypes.has(e.type.toLowerCase()) || (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase()))
|
|
51028
51848
|
);
|
|
51029
|
-
|
|
51849
|
+
} else {
|
|
51850
|
+
filtered = [];
|
|
51030
51851
|
}
|
|
51852
|
+
}
|
|
51853
|
+
const mapped = options.compact ? filtered.map(toCompactEntry) : filtered.map(toSchemaEntry);
|
|
51854
|
+
const projected = options.project ? mapped.map(
|
|
51855
|
+
(e) => projectFields2(e, options.project.split(",").map((s2) => s2.trim()).filter(Boolean))
|
|
51856
|
+
) : mapped;
|
|
51857
|
+
const finish = (finalTypes2) => {
|
|
51031
51858
|
const payload = {
|
|
51032
51859
|
version: "1.0",
|
|
51033
|
-
types:
|
|
51860
|
+
types: finalTypes2
|
|
51034
51861
|
};
|
|
51035
51862
|
if (!options.compact) {
|
|
51036
51863
|
payload.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -51063,6 +51890,56 @@ Examples:
|
|
|
51063
51890
|
];
|
|
51064
51891
|
}
|
|
51065
51892
|
printJson(payload);
|
|
51893
|
+
};
|
|
51894
|
+
const finalTypes = projected;
|
|
51895
|
+
if (options.capabilities) {
|
|
51896
|
+
return Promise.resolve().then(() => (init_capabilities(), capabilities_exports)).then(({ COMMAND_META: COMMAND_META2 }) => {
|
|
51897
|
+
const devicesMeta = Object.fromEntries(
|
|
51898
|
+
Object.entries(COMMAND_META2).filter(([k2]) => k2.startsWith("devices "))
|
|
51899
|
+
);
|
|
51900
|
+
finish(finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta })));
|
|
51901
|
+
});
|
|
51902
|
+
}
|
|
51903
|
+
finish(finalTypes);
|
|
51904
|
+
}
|
|
51905
|
+
function registerSchemaCommand(program3) {
|
|
51906
|
+
const ROLES = ["lighting", "security", "sensor", "climate", "media", "cleaning", "curtain", "fan", "power", "hub", "other"];
|
|
51907
|
+
const CATEGORIES = ["physical", "ir"];
|
|
51908
|
+
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)");
|
|
51909
|
+
schema2.action(async (options) => {
|
|
51910
|
+
await runSchemaExport(options);
|
|
51911
|
+
});
|
|
51912
|
+
schema2.command("export").description("Print the catalog as structured JSON (one object per type)").addHelpText("after", `
|
|
51913
|
+
Output is always JSON (this command ignores --format). The output is a
|
|
51914
|
+
catalog export \u2014 not a formal JSON Schema standard document \u2014 suitable for
|
|
51915
|
+
pre-baking LLM prompts or regenerating docs when the catalog changes.
|
|
51916
|
+
\`statusFields\` are advisory offline hints; actual live status can differ by
|
|
51917
|
+
firmware and device variant.
|
|
51918
|
+
|
|
51919
|
+
Size tips:
|
|
51920
|
+
--compact --used Smallest realistic payload for a given account
|
|
51921
|
+
(< 15 KB on most accounts).
|
|
51922
|
+
--fields type,commands Strip statusFields / role / etc. when only
|
|
51923
|
+
commands are needed.
|
|
51924
|
+
--type + --compact Inspect one type with minimum footprint.
|
|
51925
|
+
|
|
51926
|
+
Common top-level fields:
|
|
51927
|
+
schemaVersion CLI schema version (stable for agent contracts)
|
|
51928
|
+
data.version Catalog schema version
|
|
51929
|
+
data.types Array of SchemaEntry (or CompactSchemaEntry with --compact)
|
|
51930
|
+
data._fetchedAt CLI-added; present on live-query responses ('devices status'),
|
|
51931
|
+
not on this offline export.
|
|
51932
|
+
|
|
51933
|
+
Examples:
|
|
51934
|
+
$ switchbot schema export > catalog.json
|
|
51935
|
+
$ switchbot schema export --compact --used | wc -c # small prompt-ready payload
|
|
51936
|
+
$ switchbot schema export --type Bot | jq '.data.types[0].commands'
|
|
51937
|
+
$ switchbot schema export --types "Bot,Curtain,Color Bulb"
|
|
51938
|
+
$ switchbot schema export --role lighting | jq '[.data.types[].type]'
|
|
51939
|
+
$ switchbot schema export --role security --category physical
|
|
51940
|
+
$ switchbot schema export --project type,commands,statusFields
|
|
51941
|
+
`).action(async (_options, cmd) => {
|
|
51942
|
+
await runSchemaExport(cmd.optsWithGlobals());
|
|
51066
51943
|
});
|
|
51067
51944
|
}
|
|
51068
51945
|
|
|
@@ -51331,7 +52208,6 @@ var import_yaml8 = __toESM(require_dist(), 1);
|
|
|
51331
52208
|
init_output();
|
|
51332
52209
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync, mkdirSync, copyFileSync, statSync } from "node:fs";
|
|
51333
52210
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
51334
|
-
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
51335
52211
|
|
|
51336
52212
|
// src/policy/format.ts
|
|
51337
52213
|
init_cjs_shim();
|
|
@@ -51389,10 +52265,10 @@ function formatValidationResult(result, source, opts = {}) {
|
|
|
51389
52265
|
}
|
|
51390
52266
|
|
|
51391
52267
|
// src/commands/policy.ts
|
|
52268
|
+
init_config();
|
|
51392
52269
|
var LATEST_SUPPORTED_VERSION2 = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1];
|
|
51393
52270
|
function readEmbeddedTemplate() {
|
|
51394
|
-
|
|
51395
|
-
return readFileSync3(fileURLToPath3(url2), "utf-8");
|
|
52271
|
+
return readPolicyExampleYaml();
|
|
51396
52272
|
}
|
|
51397
52273
|
var PolicyFileExistsError = class extends Error {
|
|
51398
52274
|
constructor(policyPath) {
|
|
@@ -51432,6 +52308,18 @@ function exitPolicyError(kind, message, extra = {}) {
|
|
|
51432
52308
|
}
|
|
51433
52309
|
process.exit(code);
|
|
51434
52310
|
}
|
|
52311
|
+
function validationScopeLine(scope) {
|
|
52312
|
+
if (scope === "schema+offline-semantics+live-inventory") {
|
|
52313
|
+
return "Validation scope: schema + offline semantics + live inventory checks + local safety guards.";
|
|
52314
|
+
}
|
|
52315
|
+
return "Validation scope: schema + offline semantics + local safety guards.";
|
|
52316
|
+
}
|
|
52317
|
+
function validationNotCheckedLine(scope) {
|
|
52318
|
+
if (scope === "schema+offline-semantics+live-inventory") {
|
|
52319
|
+
return "Not checked: live capabilities, current firmware, and runtime-only device behavior.";
|
|
52320
|
+
}
|
|
52321
|
+
return "Not checked: alias targets against live devices; command support on the real target device, live capabilities, or current firmware.";
|
|
52322
|
+
}
|
|
51435
52323
|
function summarizeChangeValue(v2) {
|
|
51436
52324
|
if (v2 === null) return "null";
|
|
51437
52325
|
if (v2 === void 0) return "undefined";
|
|
@@ -51450,10 +52338,11 @@ audit log path, and which actions always or never need confirmation.
|
|
|
51450
52338
|
Default location: ${DEFAULT_POLICY_PATH}
|
|
51451
52339
|
|
|
51452
52340
|
Subcommands:
|
|
51453
|
-
validate [path] Check a policy file against the embedded schema
|
|
52341
|
+
validate [path] Check a policy file against the embedded schema + offline semantics
|
|
52342
|
+
(add --live to resolve aliases and rule targets against current inventory)
|
|
51454
52343
|
new [path] Write a starter policy to the default location (or a given path)
|
|
51455
|
-
migrate [path]
|
|
51456
|
-
(v${CURRENT_POLICY_SCHEMA_VERSION}
|
|
52344
|
+
migrate [path] Rewrite a policy file between schema versions this CLI still supports
|
|
52345
|
+
(this build only supports v${CURRENT_POLICY_SCHEMA_VERSION}; legacy v0.1 files cannot be migrated here)
|
|
51457
52346
|
diff <left> <right>
|
|
51458
52347
|
Compare two policy files and print structural + line diff
|
|
51459
52348
|
add-rule Append a rule YAML (from stdin) into automation.rules[]
|
|
@@ -51482,7 +52371,9 @@ Examples:
|
|
|
51482
52371
|
$ switchbot policy diff ./policy.before.yaml ./policy.after.yaml
|
|
51483
52372
|
`
|
|
51484
52373
|
);
|
|
51485
|
-
policy.command("validate [path]").description(
|
|
52374
|
+
policy.command("validate [path]").description(
|
|
52375
|
+
`Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema, offline semantics, and local safety guards`
|
|
52376
|
+
).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) => {
|
|
51486
52377
|
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
51487
52378
|
let loaded;
|
|
51488
52379
|
try {
|
|
@@ -51502,7 +52393,22 @@ Examples:
|
|
|
51502
52393
|
}
|
|
51503
52394
|
exitPolicyError("internal", `unexpected error loading policy: ${String(err)}`);
|
|
51504
52395
|
}
|
|
51505
|
-
|
|
52396
|
+
let result = validateLoadedPolicy(loaded);
|
|
52397
|
+
if (opts.live) {
|
|
52398
|
+
if (!tryLoadConfig()) {
|
|
52399
|
+
exitWithError({
|
|
52400
|
+
code: 1,
|
|
52401
|
+
kind: "runtime",
|
|
52402
|
+
message: "policy validate --live requires configured SwitchBot credentials.",
|
|
52403
|
+
extra: {
|
|
52404
|
+
hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
52405
|
+
}
|
|
52406
|
+
});
|
|
52407
|
+
return;
|
|
52408
|
+
}
|
|
52409
|
+
const inventory = await fetchDeviceList(void 0, { bypassCache: true });
|
|
52410
|
+
result = validateLoadedPolicyAgainstInventory(loaded, inventory);
|
|
52411
|
+
}
|
|
51506
52412
|
if (isJsonMode()) {
|
|
51507
52413
|
printJson(result);
|
|
51508
52414
|
process.exit(result.valid ? 0 : 1);
|
|
@@ -51513,10 +52419,13 @@ Examples:
|
|
|
51513
52419
|
noSnippet: opts.snippet === false
|
|
51514
52420
|
})
|
|
51515
52421
|
);
|
|
52422
|
+
console.log("");
|
|
52423
|
+
console.log(validationScopeLine(result.validationScope));
|
|
52424
|
+
console.log(validationNotCheckedLine(result.validationScope));
|
|
51516
52425
|
process.exit(result.valid ? 0 : 1);
|
|
51517
52426
|
});
|
|
51518
|
-
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) => {
|
|
51519
|
-
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
52427
|
+
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) => {
|
|
52428
|
+
const policyPath = resolvePolicyPath({ flag: opts.output ?? pathArg });
|
|
51520
52429
|
const force = opts.force === true;
|
|
51521
52430
|
let result;
|
|
51522
52431
|
try {
|
|
@@ -51545,7 +52454,7 @@ Examples:
|
|
|
51545
52454
|
console.log(` 2. run \`switchbot policy validate\``);
|
|
51546
52455
|
}
|
|
51547
52456
|
});
|
|
51548
|
-
policy.command("migrate [path]").description(`
|
|
52457
|
+
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(
|
|
51549
52458
|
"--to <version>",
|
|
51550
52459
|
`target schema version (default: ${LATEST_SUPPORTED_VERSION2})`,
|
|
51551
52460
|
LATEST_SUPPORTED_VERSION2
|
|
@@ -51585,8 +52494,9 @@ Examples:
|
|
|
51585
52494
|
return;
|
|
51586
52495
|
}
|
|
51587
52496
|
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
51588
|
-
const
|
|
51589
|
-
const
|
|
52497
|
+
const isLegacy = fileVersion === "0.1";
|
|
52498
|
+
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(", ")})`;
|
|
52499
|
+
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";
|
|
51590
52500
|
if (isJsonMode())
|
|
51591
52501
|
emitJsonError({ code: 6, kind: "unsupported-version", ...basePayload, message, hint });
|
|
51592
52502
|
else {
|
|
@@ -52206,183 +53116,6 @@ var ThrottleGate = class {
|
|
|
52206
53116
|
}
|
|
52207
53117
|
};
|
|
52208
53118
|
|
|
52209
|
-
// src/rules/action.ts
|
|
52210
|
-
init_cjs_shim();
|
|
52211
|
-
var DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
|
|
52212
|
-
function parseRuleCommand(cmd) {
|
|
52213
|
-
const m2 = DEVICES_COMMAND_RE.exec(cmd.trim());
|
|
52214
|
-
if (!m2) return null;
|
|
52215
|
-
const deviceIdSlot = m2[1];
|
|
52216
|
-
const verb = m2[2];
|
|
52217
|
-
const rest = (m2[3] ?? "").trim();
|
|
52218
|
-
return {
|
|
52219
|
-
deviceIdSlot,
|
|
52220
|
-
verb,
|
|
52221
|
-
parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/)
|
|
52222
|
-
};
|
|
52223
|
-
}
|
|
52224
|
-
function resolveActionDevice(explicit, slot, aliases) {
|
|
52225
|
-
const candidate = explicit ?? (slot && slot !== "<id>" ? slot : null);
|
|
52226
|
-
if (!candidate) return null;
|
|
52227
|
-
if (aliases[candidate]) return aliases[candidate];
|
|
52228
|
-
return candidate;
|
|
52229
|
-
}
|
|
52230
|
-
function renderParameter(tokens) {
|
|
52231
|
-
if (tokens.length === 0) return void 0;
|
|
52232
|
-
if (tokens.length === 1) return tokens[0];
|
|
52233
|
-
return tokens.join(":");
|
|
52234
|
-
}
|
|
52235
|
-
async function executeRuleAction(action, ctx) {
|
|
52236
|
-
const parsed = parseRuleCommand(action.command);
|
|
52237
|
-
if (!parsed) {
|
|
52238
|
-
writeAudit({
|
|
52239
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52240
|
-
kind: "rule-fire",
|
|
52241
|
-
deviceId: "unknown",
|
|
52242
|
-
command: action.command,
|
|
52243
|
-
parameter: null,
|
|
52244
|
-
commandType: "command",
|
|
52245
|
-
dryRun: true,
|
|
52246
|
-
result: "error",
|
|
52247
|
-
error: "unparseable-command",
|
|
52248
|
-
rule: {
|
|
52249
|
-
name: ctx.rule.name,
|
|
52250
|
-
triggerSource: ctx.rule.when.source,
|
|
52251
|
-
fireId: ctx.fireId,
|
|
52252
|
-
reason: "unparseable-command"
|
|
52253
|
-
}
|
|
52254
|
-
});
|
|
52255
|
-
return { ok: false, error: "unparseable-command", blocked: true };
|
|
52256
|
-
}
|
|
52257
|
-
if (isDestructiveCommand2(action.command)) {
|
|
52258
|
-
writeAudit({
|
|
52259
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52260
|
-
kind: "rule-fire",
|
|
52261
|
-
deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? "unknown",
|
|
52262
|
-
command: action.command,
|
|
52263
|
-
parameter: null,
|
|
52264
|
-
commandType: "command",
|
|
52265
|
-
dryRun: true,
|
|
52266
|
-
result: "error",
|
|
52267
|
-
error: `destructive-verb:${parsed.verb}`,
|
|
52268
|
-
rule: {
|
|
52269
|
-
name: ctx.rule.name,
|
|
52270
|
-
triggerSource: ctx.rule.when.source,
|
|
52271
|
-
fireId: ctx.fireId,
|
|
52272
|
-
reason: `destructive verb "${parsed.verb}" refused at runtime`
|
|
52273
|
-
}
|
|
52274
|
-
});
|
|
52275
|
-
return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
|
|
52276
|
-
}
|
|
52277
|
-
const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
|
|
52278
|
-
if (!deviceId || deviceId === "<id>") {
|
|
52279
|
-
writeAudit({
|
|
52280
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52281
|
-
kind: "rule-fire",
|
|
52282
|
-
deviceId: "unknown",
|
|
52283
|
-
command: action.command,
|
|
52284
|
-
parameter: null,
|
|
52285
|
-
commandType: "command",
|
|
52286
|
-
dryRun: true,
|
|
52287
|
-
result: "error",
|
|
52288
|
-
error: "missing-device",
|
|
52289
|
-
rule: {
|
|
52290
|
-
name: ctx.rule.name,
|
|
52291
|
-
triggerSource: ctx.rule.when.source,
|
|
52292
|
-
fireId: ctx.fireId,
|
|
52293
|
-
reason: "action omitted `device` and command used `<id>` placeholder"
|
|
52294
|
-
}
|
|
52295
|
-
});
|
|
52296
|
-
return { ok: false, error: "missing-device", verb: parsed.verb };
|
|
52297
|
-
}
|
|
52298
|
-
const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
|
|
52299
|
-
const parameter = renderParameter(parsed.parameterTokens);
|
|
52300
|
-
if (dryRun) {
|
|
52301
|
-
writeAudit({
|
|
52302
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52303
|
-
kind: "rule-fire-dry",
|
|
52304
|
-
deviceId,
|
|
52305
|
-
command: parsed.verb,
|
|
52306
|
-
parameter: parameter ?? "default",
|
|
52307
|
-
commandType: "command",
|
|
52308
|
-
dryRun: true,
|
|
52309
|
-
result: "ok",
|
|
52310
|
-
rule: {
|
|
52311
|
-
name: ctx.rule.name,
|
|
52312
|
-
triggerSource: ctx.rule.when.source,
|
|
52313
|
-
matchedDevice: deviceId,
|
|
52314
|
-
fireId: ctx.fireId
|
|
52315
|
-
}
|
|
52316
|
-
});
|
|
52317
|
-
return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
|
|
52318
|
-
}
|
|
52319
|
-
if (ctx.skipApiCall) {
|
|
52320
|
-
writeAudit({
|
|
52321
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52322
|
-
kind: "rule-fire",
|
|
52323
|
-
deviceId,
|
|
52324
|
-
command: parsed.verb,
|
|
52325
|
-
parameter: parameter ?? "default",
|
|
52326
|
-
commandType: "command",
|
|
52327
|
-
dryRun: false,
|
|
52328
|
-
result: "ok",
|
|
52329
|
-
rule: {
|
|
52330
|
-
name: ctx.rule.name,
|
|
52331
|
-
triggerSource: ctx.rule.when.source,
|
|
52332
|
-
matchedDevice: deviceId,
|
|
52333
|
-
fireId: ctx.fireId,
|
|
52334
|
-
reason: "api-skipped"
|
|
52335
|
-
}
|
|
52336
|
-
});
|
|
52337
|
-
return { ok: true, deviceId, verb: parsed.verb };
|
|
52338
|
-
}
|
|
52339
|
-
try {
|
|
52340
|
-
await executeCommand(deviceId, parsed.verb, parameter, "command", ctx.httpClient);
|
|
52341
|
-
writeAudit({
|
|
52342
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52343
|
-
kind: "rule-fire",
|
|
52344
|
-
deviceId,
|
|
52345
|
-
command: parsed.verb,
|
|
52346
|
-
parameter: parameter ?? "default",
|
|
52347
|
-
commandType: "command",
|
|
52348
|
-
dryRun: false,
|
|
52349
|
-
result: "ok",
|
|
52350
|
-
rule: {
|
|
52351
|
-
name: ctx.rule.name,
|
|
52352
|
-
triggerSource: ctx.rule.when.source,
|
|
52353
|
-
matchedDevice: deviceId,
|
|
52354
|
-
fireId: ctx.fireId
|
|
52355
|
-
}
|
|
52356
|
-
});
|
|
52357
|
-
return { ok: true, deviceId, verb: parsed.verb };
|
|
52358
|
-
} catch (err) {
|
|
52359
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
52360
|
-
writeAudit({
|
|
52361
|
-
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52362
|
-
kind: "rule-fire",
|
|
52363
|
-
deviceId,
|
|
52364
|
-
command: parsed.verb,
|
|
52365
|
-
parameter: parameter ?? "default",
|
|
52366
|
-
commandType: "command",
|
|
52367
|
-
dryRun: false,
|
|
52368
|
-
result: "error",
|
|
52369
|
-
error: msg,
|
|
52370
|
-
rule: {
|
|
52371
|
-
name: ctx.rule.name,
|
|
52372
|
-
triggerSource: ctx.rule.when.source,
|
|
52373
|
-
matchedDevice: deviceId,
|
|
52374
|
-
fireId: ctx.fireId
|
|
52375
|
-
}
|
|
52376
|
-
});
|
|
52377
|
-
return { ok: false, error: msg, deviceId, verb: parsed.verb };
|
|
52378
|
-
}
|
|
52379
|
-
}
|
|
52380
|
-
function extractDeviceIdFromAction(action) {
|
|
52381
|
-
if (action.device) return action.device;
|
|
52382
|
-
const m2 = /\bdevices\s+command\s+(\S+)/.exec(action.command ?? "");
|
|
52383
|
-
return m2 ? m2[1] : null;
|
|
52384
|
-
}
|
|
52385
|
-
|
|
52386
53119
|
// src/rules/cron-scheduler.ts
|
|
52387
53120
|
init_cjs_shim();
|
|
52388
53121
|
|
|
@@ -54822,30 +55555,34 @@ function registerSuggest(rules) {
|
|
|
54822
55555
|
[]
|
|
54823
55556
|
).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(
|
|
54824
55557
|
(opts) => {
|
|
54825
|
-
|
|
54826
|
-
|
|
54827
|
-
|
|
54828
|
-
const
|
|
54829
|
-
|
|
54830
|
-
|
|
54831
|
-
|
|
54832
|
-
|
|
54833
|
-
|
|
54834
|
-
|
|
54835
|
-
|
|
54836
|
-
|
|
54837
|
-
|
|
54838
|
-
|
|
54839
|
-
|
|
54840
|
-
|
|
55558
|
+
try {
|
|
55559
|
+
const trigger = opts.trigger;
|
|
55560
|
+
const days = opts.days ? opts.days.split(",").map((d) => d.trim()) : void 0;
|
|
55561
|
+
const devices = opts.device.map((ref) => {
|
|
55562
|
+
const cached2 = getCachedDevice(ref);
|
|
55563
|
+
return { id: ref, name: cached2?.name, type: cached2?.type };
|
|
55564
|
+
});
|
|
55565
|
+
const { rule, ruleYaml, warnings } = suggestRule({
|
|
55566
|
+
intent: opts.intent,
|
|
55567
|
+
trigger,
|
|
55568
|
+
devices,
|
|
55569
|
+
event: opts.event,
|
|
55570
|
+
schedule: opts.schedule,
|
|
55571
|
+
days,
|
|
55572
|
+
webhookPath: opts.webhookPath
|
|
55573
|
+
});
|
|
55574
|
+
for (const w2 of warnings) process.stderr.write(`warning: ${w2}
|
|
54841
55575
|
`);
|
|
54842
|
-
|
|
54843
|
-
|
|
54844
|
-
|
|
54845
|
-
|
|
54846
|
-
|
|
54847
|
-
|
|
54848
|
-
|
|
55576
|
+
if (opts.out) {
|
|
55577
|
+
fs21.writeFileSync(opts.out, ruleYaml, "utf8");
|
|
55578
|
+
if (!isJsonMode()) console.log(`\u2713 rule YAML written to ${opts.out}`);
|
|
55579
|
+
} else if (isJsonMode()) {
|
|
55580
|
+
printJson({ rule, rule_yaml: ruleYaml, warnings });
|
|
55581
|
+
} else {
|
|
55582
|
+
process.stdout.write(ruleYaml);
|
|
55583
|
+
}
|
|
55584
|
+
} catch (err) {
|
|
55585
|
+
handleError(err);
|
|
54849
55586
|
}
|
|
54850
55587
|
}
|
|
54851
55588
|
);
|
|
@@ -56221,19 +56958,129 @@ function resolveStatusSyncRuntime(options) {
|
|
|
56221
56958
|
}
|
|
56222
56959
|
const openclawToken = options.openclawToken ?? process.env.OPENCLAW_TOKEN;
|
|
56223
56960
|
if (!openclawToken) {
|
|
56224
|
-
throw new UsageError(
|
|
56961
|
+
throw new UsageError(
|
|
56962
|
+
[
|
|
56963
|
+
"OpenClaw token missing. Provide one of:",
|
|
56964
|
+
" 1. --openclaw-token <token>",
|
|
56965
|
+
" 2. OPENCLAW_TOKEN=<token> in the environment",
|
|
56966
|
+
"",
|
|
56967
|
+
"The token is issued by your OpenClaw server admin (same token you use for `events mqtt-tail --sink openclaw`).",
|
|
56968
|
+
"After setting it, re-run the command and verify with `switchbot status-sync status`."
|
|
56969
|
+
].join("\n")
|
|
56970
|
+
);
|
|
56225
56971
|
}
|
|
56226
56972
|
const openclawModel = options.openclawModel ?? process.env.OPENCLAW_MODEL;
|
|
56227
56973
|
if (!openclawModel) {
|
|
56228
|
-
throw new UsageError(
|
|
56974
|
+
throw new UsageError(
|
|
56975
|
+
[
|
|
56976
|
+
"OpenClaw model missing. Provide one of:",
|
|
56977
|
+
" 1. --openclaw-model <model>",
|
|
56978
|
+
" 2. OPENCLAW_MODEL=<model> in the environment",
|
|
56979
|
+
"",
|
|
56980
|
+
"The model name maps this CLI to a registered agent/device on the OpenClaw side.",
|
|
56981
|
+
"After setting it, re-run the command and verify with `switchbot status-sync status`."
|
|
56982
|
+
].join("\n")
|
|
56983
|
+
);
|
|
56984
|
+
}
|
|
56985
|
+
const openclawUrl = options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL;
|
|
56986
|
+
let parsedUrl;
|
|
56987
|
+
try {
|
|
56988
|
+
parsedUrl = new URL(openclawUrl);
|
|
56989
|
+
} catch {
|
|
56990
|
+
throw new UsageError(
|
|
56991
|
+
[
|
|
56992
|
+
`OpenClaw URL is invalid: ${openclawUrl}`,
|
|
56993
|
+
"Provide a full http:// or https:// URL via one of:",
|
|
56994
|
+
" 1. --openclaw-url <url>",
|
|
56995
|
+
" 2. OPENCLAW_URL=<url> in the environment",
|
|
56996
|
+
"",
|
|
56997
|
+
"After fixing it, re-run the command and verify with `switchbot status-sync status`."
|
|
56998
|
+
].join("\n")
|
|
56999
|
+
);
|
|
57000
|
+
}
|
|
57001
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
57002
|
+
throw new UsageError(
|
|
57003
|
+
[
|
|
57004
|
+
`OpenClaw URL must use http:// or https:// (received ${parsedUrl.protocol})`,
|
|
57005
|
+
"Provide a full http:// or https:// URL via one of:",
|
|
57006
|
+
" 1. --openclaw-url <url>",
|
|
57007
|
+
" 2. OPENCLAW_URL=<url> in the environment"
|
|
57008
|
+
].join("\n")
|
|
57009
|
+
);
|
|
56229
57010
|
}
|
|
56230
57011
|
return {
|
|
56231
|
-
openclawUrl
|
|
57012
|
+
openclawUrl,
|
|
56232
57013
|
openclawToken,
|
|
56233
57014
|
openclawModel,
|
|
56234
57015
|
...options.topic ? { topic: options.topic } : {}
|
|
56235
57016
|
};
|
|
56236
57017
|
}
|
|
57018
|
+
async function probeStatusSyncStart(options = {}) {
|
|
57019
|
+
const runtime = resolveStatusSyncRuntime(options);
|
|
57020
|
+
const config2 = tryLoadConfig();
|
|
57021
|
+
if (!config2) {
|
|
57022
|
+
throw new UsageError(
|
|
57023
|
+
"No credentials found. Run 'switchbot config set-token' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
57024
|
+
);
|
|
57025
|
+
}
|
|
57026
|
+
const { fetchMqttCredential: fetchMqttCredential2 } = await Promise.resolve().then(() => (init_credential(), credential_exports));
|
|
57027
|
+
let mqttBrokerUrl = "";
|
|
57028
|
+
let mqttRegion = "";
|
|
57029
|
+
try {
|
|
57030
|
+
const cred = await fetchMqttCredential2(config2.token, config2.secret);
|
|
57031
|
+
mqttBrokerUrl = cred.brokerUrl;
|
|
57032
|
+
mqttRegion = cred.region;
|
|
57033
|
+
} catch (err) {
|
|
57034
|
+
throw new UsageError(
|
|
57035
|
+
[
|
|
57036
|
+
"SwitchBot MQTT credential probe failed.",
|
|
57037
|
+
`Reason: ${err instanceof Error ? err.message : String(err)}`,
|
|
57038
|
+
"Verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET first, then re-run `switchbot status-sync start --probe`."
|
|
57039
|
+
].join("\n")
|
|
57040
|
+
);
|
|
57041
|
+
}
|
|
57042
|
+
const probeUrl = `${runtime.openclawUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
|
57043
|
+
let res;
|
|
57044
|
+
try {
|
|
57045
|
+
res = await fetch(probeUrl, {
|
|
57046
|
+
method: "POST",
|
|
57047
|
+
headers: {
|
|
57048
|
+
"content-type": "application/json",
|
|
57049
|
+
authorization: `Bearer ${runtime.openclawToken}`
|
|
57050
|
+
},
|
|
57051
|
+
body: JSON.stringify({
|
|
57052
|
+
model: runtime.openclawModel,
|
|
57053
|
+
messages: [{ role: "user", content: "status-sync probe" }]
|
|
57054
|
+
}),
|
|
57055
|
+
signal: AbortSignal.timeout(5e3)
|
|
57056
|
+
});
|
|
57057
|
+
} catch (err) {
|
|
57058
|
+
throw new UsageError(
|
|
57059
|
+
[
|
|
57060
|
+
`OpenClaw probe failed for ${probeUrl}.`,
|
|
57061
|
+
`Reason: ${err instanceof Error ? err.message : String(err)}`,
|
|
57062
|
+
"Check URL reachability, TLS/certificate trust, and whether the OpenClaw server is listening."
|
|
57063
|
+
].join("\n")
|
|
57064
|
+
);
|
|
57065
|
+
}
|
|
57066
|
+
if (!res.ok) {
|
|
57067
|
+
const body = await res.text().catch(() => "");
|
|
57068
|
+
const preview = body ? ` \u2014 ${body.slice(0, 200).replace(/\s+/g, " ").trim()}` : "";
|
|
57069
|
+
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.";
|
|
57070
|
+
throw new UsageError(
|
|
57071
|
+
[
|
|
57072
|
+
`OpenClaw probe failed for ${probeUrl}.`,
|
|
57073
|
+
`Reason: HTTP ${res.status} ${res.statusText}${preview}`,
|
|
57074
|
+
hint
|
|
57075
|
+
].join("\n")
|
|
57076
|
+
);
|
|
57077
|
+
}
|
|
57078
|
+
return {
|
|
57079
|
+
openclawUrl: runtime.openclawUrl,
|
|
57080
|
+
mqttBrokerUrl,
|
|
57081
|
+
mqttRegion
|
|
57082
|
+
};
|
|
57083
|
+
}
|
|
56237
57084
|
function resolveStatusSyncPaths(explicitStateDir) {
|
|
56238
57085
|
const stateDir = path23.resolve(
|
|
56239
57086
|
explicitStateDir ?? process.env.SWITCHBOT_STATUS_SYNC_HOME ?? path23.join(os23.homedir(), ".switchbot", "status-sync")
|
|
@@ -56507,12 +57354,21 @@ Examples:
|
|
|
56507
57354
|
handleError(error48);
|
|
56508
57355
|
}
|
|
56509
57356
|
});
|
|
56510
|
-
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(
|
|
57357
|
+
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(
|
|
56511
57358
|
"after",
|
|
56512
57359
|
`
|
|
56513
57360
|
Starts a detached child process that runs:
|
|
56514
57361
|
switchbot status-sync run ...
|
|
56515
57362
|
|
|
57363
|
+
Local preflight before spawning:
|
|
57364
|
+
- SwitchBot credentials must be configured
|
|
57365
|
+
- OpenClaw token + model must be present
|
|
57366
|
+
- OpenClaw URL must parse as http:// or https://
|
|
57367
|
+
|
|
57368
|
+
Optional online preflight with --probe:
|
|
57369
|
+
- fetch MQTT credentials from SwitchBot
|
|
57370
|
+
- perform a short HTTP probe against the OpenClaw URL
|
|
57371
|
+
|
|
56516
57372
|
State files:
|
|
56517
57373
|
state.json process metadata (pid, startedAt, command)
|
|
56518
57374
|
stdout.log redirected stdout from the child process
|
|
@@ -56523,8 +57379,11 @@ Examples:
|
|
|
56523
57379
|
$ OPENCLAW_TOKEN=abc OPENCLAW_MODEL=home-agent switchbot status-sync start
|
|
56524
57380
|
$ switchbot status-sync start --state-dir ~/.switchbot/custom-status-sync --force
|
|
56525
57381
|
`
|
|
56526
|
-
).action((options) => {
|
|
57382
|
+
).action(async (options) => {
|
|
56527
57383
|
try {
|
|
57384
|
+
if (options.probe) {
|
|
57385
|
+
await probeStatusSyncStart(options);
|
|
57386
|
+
}
|
|
56528
57387
|
const status = startStatusSync(options);
|
|
56529
57388
|
if (isJsonMode()) {
|
|
56530
57389
|
printJson(status);
|
|
@@ -56661,6 +57520,46 @@ function toPrometheusText(report) {
|
|
|
56661
57520
|
// src/commands/health.ts
|
|
56662
57521
|
init_arg_parsers();
|
|
56663
57522
|
var HEALTHZ_SCHEMA_VERSION = "1.1";
|
|
57523
|
+
function runHealthCheck(opts) {
|
|
57524
|
+
const report = getHealthReport(opts.auditLog);
|
|
57525
|
+
if (opts.prometheus) {
|
|
57526
|
+
process.stdout.write(toPrometheusText(report));
|
|
57527
|
+
return;
|
|
57528
|
+
}
|
|
57529
|
+
if (isJsonMode()) {
|
|
57530
|
+
printJson(report);
|
|
57531
|
+
return;
|
|
57532
|
+
}
|
|
57533
|
+
const statusEmoji = report.overall === "ok" ? "\u2713" : report.overall === "degraded" ? "\u26A0" : "\u2717";
|
|
57534
|
+
console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
|
|
57535
|
+
console.log("");
|
|
57536
|
+
printTable(
|
|
57537
|
+
["Component", "Status", "Detail"],
|
|
57538
|
+
[
|
|
57539
|
+
[
|
|
57540
|
+
"quota",
|
|
57541
|
+
report.quota.status,
|
|
57542
|
+
`${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`
|
|
57543
|
+
],
|
|
57544
|
+
[
|
|
57545
|
+
"audit",
|
|
57546
|
+
report.audit.status,
|
|
57547
|
+
report.audit.present ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` : "log not present"
|
|
57548
|
+
],
|
|
57549
|
+
[
|
|
57550
|
+
"circuit",
|
|
57551
|
+
report.circuit.status,
|
|
57552
|
+
`${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`
|
|
57553
|
+
],
|
|
57554
|
+
[
|
|
57555
|
+
"process",
|
|
57556
|
+
"ok",
|
|
57557
|
+
`pid ${report.process.pid} \xB7 uptime ${report.process.uptimeSeconds}s \xB7 mem ${report.process.memoryMb}MB`
|
|
57558
|
+
]
|
|
57559
|
+
]
|
|
57560
|
+
);
|
|
57561
|
+
if (report.overall !== "ok") process.exit(1);
|
|
57562
|
+
}
|
|
56664
57563
|
function createHealthHandler(auditLogPath) {
|
|
56665
57564
|
return (req, res) => {
|
|
56666
57565
|
const url2 = (req.url ?? "/").split("?")[0];
|
|
@@ -56680,48 +57579,14 @@ function createHealthHandler(auditLogPath) {
|
|
|
56680
57579
|
};
|
|
56681
57580
|
}
|
|
56682
57581
|
function registerHealthCommand(program3) {
|
|
56683
|
-
const health = program3.command("health").description("Report process health: quota, audit error rate, circuit breaker state.");
|
|
56684
|
-
health.
|
|
56685
|
-
|
|
56686
|
-
|
|
56687
|
-
|
|
56688
|
-
|
|
56689
|
-
}
|
|
56690
|
-
if (isJsonMode()) {
|
|
56691
|
-
printJson(report);
|
|
56692
|
-
return;
|
|
56693
|
-
}
|
|
56694
|
-
const statusEmoji = report.overall === "ok" ? "\u2713" : report.overall === "degraded" ? "\u26A0" : "\u2717";
|
|
56695
|
-
console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`);
|
|
56696
|
-
console.log("");
|
|
56697
|
-
printTable(
|
|
56698
|
-
["Component", "Status", "Detail"],
|
|
56699
|
-
[
|
|
56700
|
-
[
|
|
56701
|
-
"quota",
|
|
56702
|
-
report.quota.status,
|
|
56703
|
-
`${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`
|
|
56704
|
-
],
|
|
56705
|
-
[
|
|
56706
|
-
"audit",
|
|
56707
|
-
report.audit.status,
|
|
56708
|
-
report.audit.present ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` : "log not present"
|
|
56709
|
-
],
|
|
56710
|
-
[
|
|
56711
|
-
"circuit",
|
|
56712
|
-
report.circuit.status,
|
|
56713
|
-
`${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`
|
|
56714
|
-
],
|
|
56715
|
-
[
|
|
56716
|
-
"process",
|
|
56717
|
-
"ok",
|
|
56718
|
-
`pid ${report.process.pid} \xB7 uptime ${report.process.uptimeSeconds}s \xB7 mem ${report.process.memoryMb}MB`
|
|
56719
|
-
]
|
|
56720
|
-
]
|
|
56721
|
-
);
|
|
56722
|
-
if (report.overall !== "ok") process.exit(1);
|
|
57582
|
+
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).");
|
|
57583
|
+
health.action((opts) => {
|
|
57584
|
+
runHealthCheck(opts);
|
|
57585
|
+
});
|
|
57586
|
+
health.command("check").description("Print a one-shot health report.").action((_opts, cmd) => {
|
|
57587
|
+
runHealthCheck(cmd.optsWithGlobals());
|
|
56723
57588
|
});
|
|
56724
|
-
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").
|
|
57589
|
+
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", `
|
|
56725
57590
|
Endpoints:
|
|
56726
57591
|
GET /healthz JSON health report (HTTP 200 ok/degraded, 503 when circuit is open).
|
|
56727
57592
|
GET /metrics Prometheus text metrics.
|
|
@@ -56729,7 +57594,8 @@ Endpoints:
|
|
|
56729
57594
|
Example:
|
|
56730
57595
|
$ switchbot health serve --port 3100
|
|
56731
57596
|
$ curl http://127.0.0.1:3100/healthz
|
|
56732
|
-
`).action((
|
|
57597
|
+
`).action((_opts, cmd) => {
|
|
57598
|
+
const opts = cmd.optsWithGlobals();
|
|
56733
57599
|
const port = parseInt(opts.port, 10);
|
|
56734
57600
|
const handler = createHealthHandler(opts.auditLog);
|
|
56735
57601
|
const server = http3.createServer(handler);
|
|
@@ -56832,12 +57698,17 @@ function registerUpgradeCheckCommand(program3) {
|
|
|
56832
57698
|
}
|
|
56833
57699
|
return;
|
|
56834
57700
|
}
|
|
57701
|
+
const breakingNotice = findBreakingChangeBetween(VERSION, latestVersion);
|
|
56835
57702
|
const result = {
|
|
56836
57703
|
current: VERSION,
|
|
56837
57704
|
latest: latestVersion,
|
|
56838
57705
|
upToDate,
|
|
56839
57706
|
updateAvailable: !upToDate,
|
|
56840
|
-
breakingChange: latestMajor > currentMajor,
|
|
57707
|
+
breakingChange: latestMajor > currentMajor || breakingNotice !== null,
|
|
57708
|
+
...breakingNotice ? {
|
|
57709
|
+
breakingVersion: breakingNotice.version,
|
|
57710
|
+
breakingSummary: breakingNotice.summary
|
|
57711
|
+
} : {},
|
|
56841
57712
|
installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`
|
|
56842
57713
|
};
|
|
56843
57714
|
if (isJsonMode()) {
|
|
@@ -56848,6 +57719,9 @@ function registerUpgradeCheckCommand(program3) {
|
|
|
56848
57719
|
console.log(`${source_default.green("\u2713")} You are running the latest version (${VERSION}).`);
|
|
56849
57720
|
} else {
|
|
56850
57721
|
console.log(`${source_default.yellow("!")} Update available: ${source_default.bold(VERSION)} \u2192 ${source_default.bold(latestVersion)}`);
|
|
57722
|
+
if (breakingNotice) {
|
|
57723
|
+
console.log(source_default.red(` Breaking change in ${breakingNotice.version}: ${breakingNotice.summary}`));
|
|
57724
|
+
}
|
|
56851
57725
|
console.log(` Run: ${source_default.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`);
|
|
56852
57726
|
process.exit(1);
|
|
56853
57727
|
}
|
|
@@ -56860,7 +57734,7 @@ init_output();
|
|
|
56860
57734
|
import { spawn as spawn5 } from "node:child_process";
|
|
56861
57735
|
import fs29 from "node:fs";
|
|
56862
57736
|
import path25 from "node:path";
|
|
56863
|
-
import { fileURLToPath as
|
|
57737
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
56864
57738
|
init_arg_parsers();
|
|
56865
57739
|
init_source();
|
|
56866
57740
|
function readDaemonPid() {
|
|
@@ -57010,7 +57884,7 @@ The daemon reads the same policy file as \`switchbot rules run\`.
|
|
|
57010
57884
|
} catch {
|
|
57011
57885
|
}
|
|
57012
57886
|
}
|
|
57013
|
-
const thisFile =
|
|
57887
|
+
const thisFile = fileURLToPath2(import.meta.url);
|
|
57014
57888
|
const cliEntry = path25.resolve(path25.dirname(thisFile), "..", "index.js");
|
|
57015
57889
|
const args = ["rules", "run"];
|
|
57016
57890
|
if (opts.policy) args.push(opts.policy);
|