@switchbot/openapi-cli 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.js +2185 -1560
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7622,6 +7622,15 @@ function deriveStatusQueries(entry) {
|
|
|
7622
7622
|
safetyTier: "read"
|
|
7623
7623
|
}));
|
|
7624
7624
|
}
|
|
7625
|
+
function canonicalizeDeviceType(deviceType) {
|
|
7626
|
+
const catalog = getEffectiveCatalog();
|
|
7627
|
+
const lower = deviceType.toLowerCase();
|
|
7628
|
+
for (const entry of catalog) {
|
|
7629
|
+
if (entry.type.toLowerCase() === lower) return entry.type;
|
|
7630
|
+
if (entry.aliases?.some((a) => a.toLowerCase() === lower)) return entry.type;
|
|
7631
|
+
}
|
|
7632
|
+
return deviceType;
|
|
7633
|
+
}
|
|
7625
7634
|
function findCatalogEntry(query) {
|
|
7626
7635
|
const q = query.trim().toLowerCase();
|
|
7627
7636
|
if (!q) return null;
|
|
@@ -7735,7 +7744,7 @@ function getEffectiveCatalog() {
|
|
|
7735
7744
|
}
|
|
7736
7745
|
return Array.from(byType.values());
|
|
7737
7746
|
}
|
|
7738
|
-
var CATALOG_SCHEMA_VERSION, STATUS_FIELD_DESCRIPTIONS, onOff, onOffToggle, lightControls, DEVICE_CATALOG, overlayCache;
|
|
7747
|
+
var CATALOG_SCHEMA_VERSION, STATUS_FIELD_DESCRIPTIONS, onOff, onOffToggle, lightControls, rgbLightControls0To100, rgbOnlyLightControls0To100, DEVICE_CATALOG, overlayCache;
|
|
7739
7748
|
var init_catalog = __esm({
|
|
7740
7749
|
"src/devices/catalog.ts"() {
|
|
7741
7750
|
"use strict";
|
|
@@ -7803,6 +7812,15 @@ var init_catalog = __esm({
|
|
|
7803
7812
|
{ command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] },
|
|
7804
7813
|
{ command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000", "6500"] }
|
|
7805
7814
|
];
|
|
7815
|
+
rgbLightControls0To100 = [
|
|
7816
|
+
{ command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] },
|
|
7817
|
+
{ command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] },
|
|
7818
|
+
{ command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000", "6500"] }
|
|
7819
|
+
];
|
|
7820
|
+
rgbOnlyLightControls0To100 = [
|
|
7821
|
+
{ command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] },
|
|
7822
|
+
{ command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] }
|
|
7823
|
+
];
|
|
7806
7824
|
DEVICE_CATALOG = [
|
|
7807
7825
|
// ---------- Physical devices ----------
|
|
7808
7826
|
{
|
|
@@ -7835,7 +7853,7 @@ var init_catalog = __esm({
|
|
|
7835
7853
|
category: "physical",
|
|
7836
7854
|
description: "Bluetooth/Wi-Fi electronic deadbolt that locks and unlocks a door via cloud API.",
|
|
7837
7855
|
role: "security",
|
|
7838
|
-
aliases: ["Smart Lock Pro"],
|
|
7856
|
+
aliases: ["Lock", "Smart Lock Pro", "Lock Pro"],
|
|
7839
7857
|
commands: [
|
|
7840
7858
|
{ command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
|
|
7841
7859
|
{ command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." },
|
|
@@ -7848,6 +7866,7 @@ var init_catalog = __esm({
|
|
|
7848
7866
|
category: "physical",
|
|
7849
7867
|
description: "Compact electronic deadbolt with lock and unlock control; no deadbolt mode.",
|
|
7850
7868
|
role: "security",
|
|
7869
|
+
aliases: ["Lock Lite"],
|
|
7851
7870
|
commands: [
|
|
7852
7871
|
{ command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
|
|
7853
7872
|
{ command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." }
|
|
@@ -7859,6 +7878,7 @@ var init_catalog = __esm({
|
|
|
7859
7878
|
category: "physical",
|
|
7860
7879
|
description: "Premium electronic deadbolt with full lock, unlock, and deadbolt control.",
|
|
7861
7880
|
role: "security",
|
|
7881
|
+
aliases: ["Lock Ultra"],
|
|
7862
7882
|
commands: [
|
|
7863
7883
|
{ command: "lock", parameter: "\u2014", description: "Lock the door", idempotent: true },
|
|
7864
7884
|
{ command: "unlock", parameter: "\u2014", description: "Unlock the door", idempotent: true, safetyTier: "destructive", safetyReason: "Physically unlocks the door \u2014 anyone nearby can open it." },
|
|
@@ -7869,9 +7889,9 @@ var init_catalog = __esm({
|
|
|
7869
7889
|
{
|
|
7870
7890
|
type: "Plug",
|
|
7871
7891
|
category: "physical",
|
|
7872
|
-
description: "Smart wall outlet plug with on/off
|
|
7892
|
+
description: "Smart wall outlet plug with on/off control and basic power status.",
|
|
7873
7893
|
role: "power",
|
|
7874
|
-
commands:
|
|
7894
|
+
commands: onOff,
|
|
7875
7895
|
statusFields: ["power", "version"]
|
|
7876
7896
|
},
|
|
7877
7897
|
{
|
|
@@ -7879,7 +7899,7 @@ var init_catalog = __esm({
|
|
|
7879
7899
|
category: "physical",
|
|
7880
7900
|
description: "Compact smart plug with voltage, current, and daily energy consumption reporting.",
|
|
7881
7901
|
role: "power",
|
|
7882
|
-
aliases: ["Plug Mini (JP)"],
|
|
7902
|
+
aliases: ["Plug Mini (JP)", "Plug Mini (EU)"],
|
|
7883
7903
|
commands: onOffToggle,
|
|
7884
7904
|
statusFields: ["voltage", "weight", "electricityOfDay", "electricCurrent", "power", "version"]
|
|
7885
7905
|
},
|
|
@@ -7957,12 +7977,63 @@ var init_catalog = __esm({
|
|
|
7957
7977
|
{
|
|
7958
7978
|
type: "Strip Light",
|
|
7959
7979
|
category: "physical",
|
|
7960
|
-
description: "Addressable LED strip with on/off, brightness, RGB color
|
|
7980
|
+
description: "Addressable LED strip with on/off, brightness, and RGB color control.",
|
|
7961
7981
|
role: "lighting",
|
|
7962
|
-
|
|
7963
|
-
|
|
7982
|
+
commands: [
|
|
7983
|
+
...onOffToggle,
|
|
7984
|
+
{ command: "setBrightness", parameter: "1-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["50", "80"] },
|
|
7985
|
+
{ command: "setColor", parameter: "R:G:B (0-255 each)", description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ["255:0:0", "255:255:255"] }
|
|
7986
|
+
],
|
|
7964
7987
|
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
7965
7988
|
},
|
|
7989
|
+
{
|
|
7990
|
+
type: "Floor Lamp",
|
|
7991
|
+
category: "physical",
|
|
7992
|
+
description: "Smart floor lamp with 0-100 brightness, RGB color, and color temperature control.",
|
|
7993
|
+
role: "lighting",
|
|
7994
|
+
commands: [...onOffToggle, ...rgbLightControls0To100],
|
|
7995
|
+
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
7996
|
+
},
|
|
7997
|
+
{
|
|
7998
|
+
type: "Strip Light 3",
|
|
7999
|
+
category: "physical",
|
|
8000
|
+
description: "Third-generation strip light with 0-100 brightness, RGB color, and color temperature control.",
|
|
8001
|
+
role: "lighting",
|
|
8002
|
+
commands: [...onOffToggle, ...rgbLightControls0To100],
|
|
8003
|
+
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
8004
|
+
},
|
|
8005
|
+
{
|
|
8006
|
+
type: "RGBICWW Strip Light",
|
|
8007
|
+
category: "physical",
|
|
8008
|
+
description: "RGBICWW strip light with 0-100 brightness, RGB color, and color temperature control.",
|
|
8009
|
+
role: "lighting",
|
|
8010
|
+
commands: [...onOffToggle, ...rgbLightControls0To100],
|
|
8011
|
+
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
8012
|
+
},
|
|
8013
|
+
{
|
|
8014
|
+
type: "RGBICWW Floor Lamp",
|
|
8015
|
+
category: "physical",
|
|
8016
|
+
description: "RGBICWW floor lamp with 0-100 brightness, RGB color, and color temperature control.",
|
|
8017
|
+
role: "lighting",
|
|
8018
|
+
commands: [...onOffToggle, ...rgbLightControls0To100],
|
|
8019
|
+
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
8020
|
+
},
|
|
8021
|
+
{
|
|
8022
|
+
type: "RGBIC Neon Wire Rope Light",
|
|
8023
|
+
category: "physical",
|
|
8024
|
+
description: "RGBIC neon wire rope light with 0-100 brightness and RGB color control.",
|
|
8025
|
+
role: "lighting",
|
|
8026
|
+
commands: [...onOffToggle, ...rgbOnlyLightControls0To100],
|
|
8027
|
+
statusFields: ["power", "brightness", "color", "version"]
|
|
8028
|
+
},
|
|
8029
|
+
{
|
|
8030
|
+
type: "RGBIC Neon Rope Light",
|
|
8031
|
+
category: "physical",
|
|
8032
|
+
description: "RGBIC neon rope light with 0-100 brightness and RGB color control.",
|
|
8033
|
+
role: "lighting",
|
|
8034
|
+
commands: [...onOffToggle, ...rgbOnlyLightControls0To100],
|
|
8035
|
+
statusFields: ["power", "brightness", "color", "version"]
|
|
8036
|
+
},
|
|
7966
8037
|
{
|
|
7967
8038
|
type: "Ceiling Light",
|
|
7968
8039
|
category: "physical",
|
|
@@ -7984,7 +8055,7 @@ var init_catalog = __esm({
|
|
|
7984
8055
|
commands: [
|
|
7985
8056
|
...onOff,
|
|
7986
8057
|
{ command: "setMode", parameter: "0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat", description: "Operating mode", idempotent: true, exampleParams: ["1", "3"] },
|
|
7987
|
-
{ command: "setManualModeTemperature", parameter: "
|
|
8058
|
+
{ command: "setManualModeTemperature", parameter: "4-35 (\xB0C)", description: "Target temperature in manual mode", idempotent: true, exampleParams: ["20", "22"] }
|
|
7988
8059
|
],
|
|
7989
8060
|
statusFields: ["power", "temperature", "humidity", "battery", "version", "mode", "targetTemperature"]
|
|
7990
8061
|
},
|
|
@@ -7993,7 +8064,7 @@ var init_catalog = __esm({
|
|
|
7993
8064
|
category: "physical",
|
|
7994
8065
|
description: "Entry-level robot vacuum with start/stop/dock and four suction power levels.",
|
|
7995
8066
|
role: "cleaning",
|
|
7996
|
-
aliases: ["Robot Vacuum", "Robot Vacuum Cleaner S1 Plus", "K10+"],
|
|
8067
|
+
aliases: ["Robot Vacuum", "Robot Vacuum Cleaner S1 Plus", "K10+", "K10+ Pro"],
|
|
7997
8068
|
commands: [
|
|
7998
8069
|
{ command: "start", parameter: "\u2014", description: "Start cleaning", idempotent: true },
|
|
7999
8070
|
{ command: "stop", parameter: "\u2014", description: "Stop cleaning", idempotent: true },
|
|
@@ -8003,17 +8074,17 @@ var init_catalog = __esm({
|
|
|
8003
8074
|
statusFields: ["workingStatus", "onlineStatus", "battery", "version"]
|
|
8004
8075
|
},
|
|
8005
8076
|
{
|
|
8006
|
-
type: "K10+ Pro Combo",
|
|
8077
|
+
type: "Robot Vacuum Cleaner K10+ Pro Combo",
|
|
8007
8078
|
category: "physical",
|
|
8008
8079
|
description: "Compact robot vacuum and mop combo with sweep/mop sessions, fan level, and water level.",
|
|
8009
8080
|
role: "cleaning",
|
|
8010
|
-
aliases: ["K20+ Pro"],
|
|
8081
|
+
aliases: ["K10+ Pro Combo", "K20+ Pro", "K11+", "Robot Vacuum Cleaner K11+"],
|
|
8011
8082
|
commands: [
|
|
8012
8083
|
{ command: "startClean", parameter: `'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}'`, description: "Begin a cleaning session", idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"times":1}}'] },
|
|
8013
8084
|
{ command: "pause", parameter: "\u2014", description: "Pause cleaning", idempotent: true },
|
|
8014
8085
|
{ command: "dock", parameter: "\u2014", description: "Return to dock", idempotent: true },
|
|
8015
8086
|
{ command: "setVolume", parameter: "0-100", description: "Set voice volume", idempotent: true, exampleParams: ["0", "50", "100"] },
|
|
8016
|
-
{ command: "changeParam", parameter: `'{"fanLevel":1-4,"
|
|
8087
|
+
{ command: "changeParam", parameter: `'{"fanLevel":1-4,"times":1-2639999}'`, description: "Change parameters mid-run", idempotent: true, exampleParams: ['{"fanLevel":3,"times":1}'] }
|
|
8017
8088
|
],
|
|
8018
8089
|
statusFields: ["workingStatus", "onlineStatus", "battery", "taskType"]
|
|
8019
8090
|
},
|
|
@@ -8022,7 +8093,7 @@ var init_catalog = __esm({
|
|
|
8022
8093
|
category: "physical",
|
|
8023
8094
|
description: "Advanced floor cleaning robot with sweep/mop modes, self-wash dock, and humidifier refill.",
|
|
8024
8095
|
role: "cleaning",
|
|
8025
|
-
aliases: ["Robot Vacuum Cleaner S10", "Robot Vacuum Cleaner S20"],
|
|
8096
|
+
aliases: ["Robot Vacuum Cleaner S10", "Robot Vacuum Cleaner S20", "S20"],
|
|
8026
8097
|
commands: [
|
|
8027
8098
|
{ command: "startClean", parameter: `'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}'`, description: "Begin a cleaning session", idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"waterLevel":1,"times":1}}'] },
|
|
8028
8099
|
{ command: "pause", parameter: "\u2014", description: "Pause cleaning", idempotent: true },
|
|
@@ -8039,7 +8110,7 @@ var init_catalog = __esm({
|
|
|
8039
8110
|
category: "physical",
|
|
8040
8111
|
description: "Rechargeable table/floor fan with wind modes, speed control, night-light, and auto-off timer.",
|
|
8041
8112
|
role: "fan",
|
|
8042
|
-
aliases: ["Circulator Fan"],
|
|
8113
|
+
aliases: ["Circulator Fan", "Standing Circulator Fan"],
|
|
8043
8114
|
commands: [
|
|
8044
8115
|
...onOffToggle,
|
|
8045
8116
|
{ command: "setNightLightMode", parameter: "off | 1 | 2", description: "Night-light mode", idempotent: true, exampleParams: ["off", "1", "2"] },
|
|
@@ -8101,7 +8172,7 @@ var init_catalog = __esm({
|
|
|
8101
8172
|
category: "physical",
|
|
8102
8173
|
description: "PIN-pad access controller that creates and deletes door passcodes for a Smart Lock.",
|
|
8103
8174
|
role: "security",
|
|
8104
|
-
aliases: ["Keypad Touch"],
|
|
8175
|
+
aliases: ["Keypad Touch", "Keypad Vision", "Keypad Vision Pro"],
|
|
8105
8176
|
commands: [
|
|
8106
8177
|
{ command: "createKey", parameter: `'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}'`, description: "Create a passcode (async; result via webhook)", idempotent: false, safetyTier: "destructive", safetyReason: "Provisions a new access credential \u2014 anyone with this passcode can unlock the door." },
|
|
8107
8178
|
{ command: "deleteKey", parameter: `'{"id":<passcode_id>}'`, description: "Delete a passcode (async; result via webhook)", idempotent: true, safetyTier: "destructive", safetyReason: "Permanently removes a passcode \u2014 the holder immediately loses door access." }
|
|
@@ -8111,15 +8182,25 @@ var init_catalog = __esm({
|
|
|
8111
8182
|
{
|
|
8112
8183
|
type: "Candle Warmer Lamp",
|
|
8113
8184
|
category: "physical",
|
|
8114
|
-
description: "Decorative candle-warmer lamp with adjustable brightness
|
|
8185
|
+
description: "Decorative candle-warmer lamp with adjustable 0-100 brightness.",
|
|
8115
8186
|
role: "lighting",
|
|
8116
8187
|
commands: [
|
|
8117
8188
|
...onOffToggle,
|
|
8118
|
-
{ command: "setBrightness", parameter: "
|
|
8119
|
-
{ command: "setColorTemperature", parameter: "2700-6500", description: "Set color temperature (Kelvin)", idempotent: true, exampleParams: ["2700", "4000"] }
|
|
8189
|
+
{ command: "setBrightness", parameter: "0-100", description: "Set brightness percentage", idempotent: true, exampleParams: ["0", "50", "100"] }
|
|
8120
8190
|
],
|
|
8121
8191
|
statusFields: ["power", "brightness", "colorTemperature", "version"]
|
|
8122
8192
|
},
|
|
8193
|
+
{
|
|
8194
|
+
type: "AI Art Frame",
|
|
8195
|
+
category: "physical",
|
|
8196
|
+
description: "Digital art frame that can switch to the next or previous image.",
|
|
8197
|
+
role: "other",
|
|
8198
|
+
commands: [
|
|
8199
|
+
{ command: "next", parameter: "\u2014", description: "Switch to the next image", idempotent: false },
|
|
8200
|
+
{ command: "previous", parameter: "\u2014", description: "Switch to the previous image", idempotent: false }
|
|
8201
|
+
],
|
|
8202
|
+
statusFields: ["version"]
|
|
8203
|
+
},
|
|
8123
8204
|
// Status-only devices (no commands)
|
|
8124
8205
|
{
|
|
8125
8206
|
type: "Meter",
|
|
@@ -8155,6 +8236,7 @@ var init_catalog = __esm({
|
|
|
8155
8236
|
description: "Water sensor that reports leak status; read-only, no control commands.",
|
|
8156
8237
|
role: "sensor",
|
|
8157
8238
|
readOnly: true,
|
|
8239
|
+
aliases: ["Water Detector"],
|
|
8158
8240
|
commands: [],
|
|
8159
8241
|
statusFields: ["battery", "version", "status"]
|
|
8160
8242
|
},
|
|
@@ -31885,6 +31967,7 @@ var CSS_COLORS = {
|
|
|
31885
31967
|
};
|
|
31886
31968
|
|
|
31887
31969
|
// src/devices/param-validator.ts
|
|
31970
|
+
init_catalog();
|
|
31888
31971
|
var AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
31889
31972
|
var AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
|
|
31890
31973
|
var CURTAIN_MODE_MAP = { default: "ff", performance: "0", silent: "1" };
|
|
@@ -31936,6 +32019,9 @@ function buildBlindTiltSetPosition(opts) {
|
|
|
31936
32019
|
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
|
|
31937
32020
|
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
|
|
31938
32021
|
}
|
|
32022
|
+
if (angle % 2 !== 0) {
|
|
32023
|
+
throw new UsageError(`--angle must be a multiple of 2 (got "${opts.angle}"). Example: --angle 50`);
|
|
32024
|
+
}
|
|
31939
32025
|
return `${dir};${angle}`;
|
|
31940
32026
|
}
|
|
31941
32027
|
function buildRelaySetMode(opts) {
|
|
@@ -31951,11 +32037,12 @@ function buildRelaySetMode(opts) {
|
|
|
31951
32037
|
}
|
|
31952
32038
|
return `${ch};${modeInt}`;
|
|
31953
32039
|
}
|
|
31954
|
-
function buildBrightnessSet(opts) {
|
|
31955
|
-
|
|
32040
|
+
function buildBrightnessSet(opts, deviceType) {
|
|
32041
|
+
const [min, max] = deviceType && brightnessRange(deviceType) || [1, 100];
|
|
32042
|
+
if (!opts.brightness) throw new UsageError(`--brightness is required (${min}-${max})`);
|
|
31956
32043
|
const b2 = parseInt(opts.brightness, 10);
|
|
31957
|
-
if (!Number.isFinite(b2) || b2 <
|
|
31958
|
-
throw new UsageError(`--brightness must be an integer between
|
|
32044
|
+
if (!Number.isFinite(b2) || b2 < min || b2 > max) {
|
|
32045
|
+
throw new UsageError(`--brightness must be an integer between ${min} and ${max} (got "${opts.brightness}")`);
|
|
31959
32046
|
}
|
|
31960
32047
|
return String(b2);
|
|
31961
32048
|
}
|
|
@@ -31971,67 +32058,187 @@ function buildColorTemperatureSet(opts) {
|
|
|
31971
32058
|
if (!result.ok) throw new UsageError(result.error);
|
|
31972
32059
|
return result.normalized ?? opts.colorTemp;
|
|
31973
32060
|
}
|
|
32061
|
+
function parseParameterForWire(parameter) {
|
|
32062
|
+
if (parameter === void 0) return "default";
|
|
32063
|
+
const trimmed = parameter.trim();
|
|
32064
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
32065
|
+
try {
|
|
32066
|
+
return JSON.parse(parameter);
|
|
32067
|
+
} catch {
|
|
32068
|
+
return parameter;
|
|
32069
|
+
}
|
|
32070
|
+
}
|
|
32071
|
+
return parameter;
|
|
32072
|
+
}
|
|
31974
32073
|
function validateParameter(deviceType, command, raw) {
|
|
31975
32074
|
if (!deviceType) return { ok: true };
|
|
31976
|
-
|
|
32075
|
+
const dt = canonicalizeDeviceType(deviceType);
|
|
32076
|
+
if (dt === "Air Conditioner" && command === "setAll") {
|
|
31977
32077
|
return validateAcSetAll(raw);
|
|
31978
32078
|
}
|
|
31979
|
-
if (
|
|
32079
|
+
if (dt.startsWith("Curtain") && command === "setPosition") {
|
|
31980
32080
|
return validateCurtainSetPosition(raw);
|
|
31981
32081
|
}
|
|
31982
|
-
if (
|
|
32082
|
+
if (dt.startsWith("Blind Tilt") && command === "setPosition") {
|
|
31983
32083
|
return validateBlindTiltSetPosition(raw);
|
|
31984
32084
|
}
|
|
31985
|
-
if (
|
|
31986
|
-
return
|
|
32085
|
+
if ((dt === "Relay Switch 1" || dt === "Relay Switch 1PM") && command === "setMode") {
|
|
32086
|
+
return validateIntRange(raw, "setMode", 0, 3, "Relay Switch mode (0=toggle 1=edge 2=detached 3=momentary)");
|
|
32087
|
+
}
|
|
32088
|
+
if (dt === "Relay Switch 2PM" && command === "setMode") {
|
|
32089
|
+
return validateRelay2PmSetMode(raw);
|
|
32090
|
+
}
|
|
32091
|
+
if (dt === "Relay Switch 2PM" && (command === "turnOn" || command === "turnOff" || command === "toggle")) {
|
|
32092
|
+
return validateRelayChannel(raw);
|
|
32093
|
+
}
|
|
32094
|
+
if (dt === "Relay Switch 2PM" && command === "setPosition") {
|
|
32095
|
+
return validateIntRange(raw, "setPosition", 0, 100, "Relay Switch 2PM roller-shade percentage");
|
|
31987
32096
|
}
|
|
31988
|
-
if (command === "setBrightness" && isBrightnessDevice(
|
|
31989
|
-
return validateSetBrightness(raw);
|
|
32097
|
+
if (command === "setBrightness" && isBrightnessDevice(dt)) {
|
|
32098
|
+
return validateSetBrightness(raw, dt);
|
|
31990
32099
|
}
|
|
31991
|
-
if (command === "setColor" && isColorDevice(
|
|
32100
|
+
if (command === "setColor" && isColorDevice(dt)) {
|
|
31992
32101
|
return validateSetColor(raw);
|
|
31993
32102
|
}
|
|
31994
|
-
if (command === "setColorTemperature" &&
|
|
32103
|
+
if (command === "setColorTemperature" && isColorTemperatureDevice(dt)) {
|
|
31995
32104
|
return validateSetColorTemperature(raw);
|
|
31996
32105
|
}
|
|
32106
|
+
if (dt === "Humidifier" && command === "setMode") {
|
|
32107
|
+
return validateHumidifierSetMode(raw);
|
|
32108
|
+
}
|
|
32109
|
+
if (dt === "Humidifier2" && command === "setMode") {
|
|
32110
|
+
return validateHumidifier2SetMode(raw);
|
|
32111
|
+
}
|
|
32112
|
+
if (dt === "Humidifier2" && command === "setChildLock") {
|
|
32113
|
+
return validateEnum(raw, "setChildLock", ["true", "false"]);
|
|
32114
|
+
}
|
|
32115
|
+
if (isAirPurifierDevice(dt) && command === "setMode") {
|
|
32116
|
+
return validateAirPurifierSetMode(raw);
|
|
32117
|
+
}
|
|
32118
|
+
if (isAirPurifierDevice(dt) && command === "setChildLock") {
|
|
32119
|
+
return validateEnum(raw, "setChildLock", ["0", "1"]);
|
|
32120
|
+
}
|
|
32121
|
+
if (isPowLevelVacuum(dt) && command === "PowLevel") {
|
|
32122
|
+
return validateIntRange(raw, "PowLevel", 0, 3, "suction level (0=Quiet 1=Standard 2=Strong 3=Max)");
|
|
32123
|
+
}
|
|
32124
|
+
if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "startClean") {
|
|
32125
|
+
return validateVacuumStartClean(raw, dt);
|
|
32126
|
+
}
|
|
32127
|
+
if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "setVolume") {
|
|
32128
|
+
return validateIntRange(raw, "setVolume", 0, 100, "volume percentage");
|
|
32129
|
+
}
|
|
32130
|
+
if ((isComboVacuum(dt) || isFloorCleaningVacuum(dt)) && command === "changeParam") {
|
|
32131
|
+
return validateVacuumChangeParam(raw, dt);
|
|
32132
|
+
}
|
|
32133
|
+
if (isFloorCleaningVacuum(dt) && command === "selfClean") {
|
|
32134
|
+
return validateEnum(raw, "selfClean", ["1", "2", "3"], "1=wash mop, 2=dry, 3=terminate");
|
|
32135
|
+
}
|
|
32136
|
+
if (isCirculatorFan(dt) && command === "setNightLightMode") {
|
|
32137
|
+
return validateEnum(raw, "setNightLightMode", ["off", "1", "2"]);
|
|
32138
|
+
}
|
|
32139
|
+
if (isCirculatorFan(dt) && command === "setWindMode") {
|
|
32140
|
+
return validateEnum(raw, "setWindMode", ["direct", "natural", "sleep", "baby"]);
|
|
32141
|
+
}
|
|
32142
|
+
if (isCirculatorFan(dt) && command === "setWindSpeed") {
|
|
32143
|
+
return validateIntRange(raw, "setWindSpeed", 1, 100, "fan speed percentage");
|
|
32144
|
+
}
|
|
32145
|
+
if (isCirculatorFan(dt) && command === "closeDelay") {
|
|
32146
|
+
return validateIntRange(raw, "closeDelay", 1, 36e3, "auto-off delay in seconds");
|
|
32147
|
+
}
|
|
32148
|
+
if (dt === "Smart Radiator Thermostat" && command === "setMode") {
|
|
32149
|
+
return validateIntRange(raw, "setMode", 0, 5, "mode (0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat)");
|
|
32150
|
+
}
|
|
32151
|
+
if (dt === "Smart Radiator Thermostat" && command === "setManualModeTemperature") {
|
|
32152
|
+
return validateIntRange(raw, "setManualModeTemperature", 4, 35, "temperature in \xB0C");
|
|
32153
|
+
}
|
|
32154
|
+
if (dt.startsWith("Keypad") && command === "createKey") {
|
|
32155
|
+
return validateKeypadCreateKey(raw);
|
|
32156
|
+
}
|
|
32157
|
+
if (dt.startsWith("Keypad") && command === "deleteKey") {
|
|
32158
|
+
return validateKeypadDeleteKey(raw);
|
|
32159
|
+
}
|
|
32160
|
+
if (dt === "TV" && command === "SetChannel") {
|
|
32161
|
+
return validateIntRange(raw, "SetChannel", 1, 999, "channel number");
|
|
32162
|
+
}
|
|
32163
|
+
if (dt === "Roller Shade" && command === "setPosition") {
|
|
32164
|
+
return validateIntRange(raw, "setPosition", 0, 100, "position percentage (0=open, 100=closed)");
|
|
32165
|
+
}
|
|
31997
32166
|
return { ok: true };
|
|
31998
32167
|
}
|
|
31999
32168
|
function isBrightnessDevice(deviceType) {
|
|
32000
|
-
return deviceType
|
|
32169
|
+
return brightnessRange(deviceType) !== null;
|
|
32170
|
+
}
|
|
32171
|
+
function brightnessRange(deviceType) {
|
|
32172
|
+
if (deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro") {
|
|
32173
|
+
return [1, 100];
|
|
32174
|
+
}
|
|
32175
|
+
if (deviceType === "Floor Lamp" || deviceType === "Strip Light 3" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "RGBIC Neon Wire Rope Light" || deviceType === "RGBIC Neon Rope Light" || deviceType === "Candle Warmer Lamp") {
|
|
32176
|
+
return [0, 100];
|
|
32177
|
+
}
|
|
32178
|
+
if (deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light") {
|
|
32179
|
+
return [1, 100];
|
|
32180
|
+
}
|
|
32181
|
+
return null;
|
|
32001
32182
|
}
|
|
32002
32183
|
function isColorDevice(deviceType) {
|
|
32003
|
-
return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Floor Lamp" || deviceType === "Light Strip" || deviceType === "Fill Light";
|
|
32184
|
+
return deviceType === "Color Bulb" || deviceType === "Strip Light" || deviceType === "Strip Light 3" || deviceType === "Floor Lamp" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "RGBIC Neon Wire Rope Light" || deviceType === "RGBIC Neon Rope Light" || deviceType === "Light Strip" || deviceType === "Fill Light";
|
|
32185
|
+
}
|
|
32186
|
+
function isColorTemperatureDevice(deviceType) {
|
|
32187
|
+
return deviceType === "Color Bulb" || deviceType === "Floor Lamp" || deviceType === "Strip Light 3" || deviceType === "Ceiling Light" || deviceType === "Ceiling Light Pro" || deviceType === "RGBICWW Strip Light" || deviceType === "RGBICWW Floor Lamp" || deviceType === "Light Strip" || deviceType === "Dimmer" || deviceType === "Fill Light";
|
|
32188
|
+
}
|
|
32189
|
+
function isAirPurifierDevice(deviceType) {
|
|
32190
|
+
return deviceType === "Air Purifier VOC" || deviceType === "Air Purifier Table VOC" || deviceType === "Air Purifier PM2.5" || deviceType === "Air Purifier Table PM2.5";
|
|
32191
|
+
}
|
|
32192
|
+
function isPowLevelVacuum(deviceType) {
|
|
32193
|
+
return deviceType === "Robot Vacuum Cleaner S1" || deviceType === "Robot Vacuum Cleaner S1 Plus" || deviceType === "K10+" || deviceType === "K10+ Pro";
|
|
32194
|
+
}
|
|
32195
|
+
function isComboVacuum(deviceType) {
|
|
32196
|
+
return deviceType === "K10+ Pro Combo" || deviceType === "Robot Vacuum Cleaner K10+ Pro Combo" || deviceType === "K20+ Pro" || deviceType === "K11+" || deviceType === "Robot Vacuum Cleaner K11+";
|
|
32197
|
+
}
|
|
32198
|
+
function isFloorCleaningVacuum(deviceType) {
|
|
32199
|
+
return deviceType === "Floor Cleaning Robot S10" || deviceType === "S20" || deviceType === "Robot Vacuum Cleaner S20";
|
|
32200
|
+
}
|
|
32201
|
+
function isCirculatorFan(deviceType) {
|
|
32202
|
+
return deviceType === "Battery Circulator Fan" || deviceType === "Circulator Fan" || deviceType === "Standing Circulator Fan";
|
|
32004
32203
|
}
|
|
32005
32204
|
function isLightingCommandSupported(deviceType, command) {
|
|
32006
|
-
|
|
32007
|
-
if (command === "
|
|
32205
|
+
const dt = canonicalizeDeviceType(deviceType);
|
|
32206
|
+
if (command === "setBrightness") return isBrightnessDevice(dt);
|
|
32207
|
+
if (command === "setColorTemperature") return isColorTemperatureDevice(dt);
|
|
32208
|
+
if (command === "setColor") return isColorDevice(dt);
|
|
32209
|
+
return false;
|
|
32210
|
+
}
|
|
32211
|
+
function isNumericish(v2) {
|
|
32212
|
+
if (typeof v2 === "number") return true;
|
|
32213
|
+
if (typeof v2 === "string" && v2.trim() !== "") return true;
|
|
32008
32214
|
return false;
|
|
32009
32215
|
}
|
|
32010
|
-
function validateSetBrightness(raw) {
|
|
32216
|
+
function validateSetBrightness(raw, deviceType) {
|
|
32217
|
+
const [min, max] = brightnessRange(deviceType) ?? [1, 100];
|
|
32011
32218
|
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32012
32219
|
return {
|
|
32013
32220
|
ok: false,
|
|
32014
|
-
error: `setBrightness requires an integer
|
|
32221
|
+
error: `setBrightness requires an integer ${min}-${max} (percent). Example: "50".`
|
|
32015
32222
|
};
|
|
32016
32223
|
}
|
|
32017
|
-
const trimmed = raw.trim();
|
|
32224
|
+
const trimmed = stripQuotes(raw.trim());
|
|
32018
32225
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
32019
32226
|
return {
|
|
32020
32227
|
ok: false,
|
|
32021
|
-
error: `setBrightness must be an integer
|
|
32228
|
+
error: `setBrightness must be an integer ${min}-${max}, got ${JSON.stringify(raw)}. ${hintBrightnessRetry(min, max)}`
|
|
32022
32229
|
};
|
|
32023
32230
|
}
|
|
32024
32231
|
const n = Number(trimmed);
|
|
32025
|
-
if (!Number.isInteger(n) || n <
|
|
32232
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
32026
32233
|
return {
|
|
32027
32234
|
ok: false,
|
|
32028
|
-
error: `setBrightness must be an integer
|
|
32235
|
+
error: `setBrightness must be an integer ${min}-${max}, got "${raw}". ${hintBrightnessRetry(min, max)}`
|
|
32029
32236
|
};
|
|
32030
32237
|
}
|
|
32031
32238
|
return { ok: true, normalized: String(n) };
|
|
32032
32239
|
}
|
|
32033
|
-
function hintBrightnessRetry() {
|
|
32034
|
-
return `Ask the user whether they meant a percentage (
|
|
32240
|
+
function hintBrightnessRetry(min = 1, max = 100) {
|
|
32241
|
+
return `Ask the user whether they meant a percentage (${min}-${max}). Example: "50".`;
|
|
32035
32242
|
}
|
|
32036
32243
|
var CUSTOM_COLORS = {
|
|
32037
32244
|
warm: [255, 180, 100]
|
|
@@ -32047,7 +32254,7 @@ function validateSetColor(raw) {
|
|
|
32047
32254
|
error: `setColor requires a color. Use a CSS color name (e.g. coral, teal, salmon), hex (#RRGGBB / #RGB), or R:G:B format.`
|
|
32048
32255
|
};
|
|
32049
32256
|
}
|
|
32050
|
-
const trimmed = raw.trim();
|
|
32257
|
+
const trimmed = stripQuotes(raw.trim());
|
|
32051
32258
|
const named = NAMED_COLORS[trimmed.toLowerCase()];
|
|
32052
32259
|
if (named) {
|
|
32053
32260
|
return { ok: true, normalized: `${named[0]}:${named[1]}:${named[2]}` };
|
|
@@ -32114,7 +32321,7 @@ function validateSetColorTemperature(raw) {
|
|
|
32114
32321
|
error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`
|
|
32115
32322
|
};
|
|
32116
32323
|
}
|
|
32117
|
-
const trimmed = raw.trim();
|
|
32324
|
+
const trimmed = stripQuotes(raw.trim());
|
|
32118
32325
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
32119
32326
|
return {
|
|
32120
32327
|
ok: false,
|
|
@@ -32137,13 +32344,14 @@ function validateAcSetAll(raw) {
|
|
|
32137
32344
|
error: `setAll requires a parameter "<temp>,<mode>,<fan>,<on|off>". Example: "26,2,2,on".`
|
|
32138
32345
|
};
|
|
32139
32346
|
}
|
|
32140
|
-
|
|
32347
|
+
const stripped = stripQuotes(raw.trim());
|
|
32348
|
+
if (stripped.startsWith("{") || stripped.startsWith("[")) {
|
|
32141
32349
|
return {
|
|
32142
32350
|
ok: false,
|
|
32143
32351
|
error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`
|
|
32144
32352
|
};
|
|
32145
32353
|
}
|
|
32146
|
-
const parts =
|
|
32354
|
+
const parts = stripped.split(",");
|
|
32147
32355
|
if (parts.length !== 4) {
|
|
32148
32356
|
return {
|
|
32149
32357
|
ok: false,
|
|
@@ -32188,8 +32396,9 @@ function validateCurtainSetPosition(raw) {
|
|
|
32188
32396
|
error: `setPosition requires a parameter. Expected: "<0-100>" or "<index>,<ff|0|1>,<0-100>". Example: "50" or "0,ff,50".`
|
|
32189
32397
|
};
|
|
32190
32398
|
}
|
|
32191
|
-
|
|
32192
|
-
|
|
32399
|
+
const stripped = stripQuotes(raw.trim());
|
|
32400
|
+
if (!stripped.includes(",")) {
|
|
32401
|
+
const pos2 = Number(stripped);
|
|
32193
32402
|
if (!Number.isInteger(pos2) || pos2 < 0 || pos2 > 100) {
|
|
32194
32403
|
return {
|
|
32195
32404
|
ok: false,
|
|
@@ -32198,7 +32407,7 @@ function validateCurtainSetPosition(raw) {
|
|
|
32198
32407
|
}
|
|
32199
32408
|
return { ok: true, normalized: String(pos2) };
|
|
32200
32409
|
}
|
|
32201
|
-
const parts =
|
|
32410
|
+
const parts = stripped.split(",").map((s2) => s2.trim());
|
|
32202
32411
|
if (parts.length !== 3) {
|
|
32203
32412
|
return {
|
|
32204
32413
|
ok: false,
|
|
@@ -32236,7 +32445,8 @@ function validateBlindTiltSetPosition(raw) {
|
|
|
32236
32445
|
error: `Blind Tilt setPosition requires a parameter. Expected: "<up|down>;<0-100>". Example: "up;50".`
|
|
32237
32446
|
};
|
|
32238
32447
|
}
|
|
32239
|
-
const
|
|
32448
|
+
const stripped = stripQuotes(raw.trim());
|
|
32449
|
+
const parts = stripped.split(";");
|
|
32240
32450
|
if (parts.length !== 2) {
|
|
32241
32451
|
return {
|
|
32242
32452
|
ok: false,
|
|
@@ -32257,16 +32467,23 @@ function validateBlindTiltSetPosition(raw) {
|
|
|
32257
32467
|
error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`
|
|
32258
32468
|
};
|
|
32259
32469
|
}
|
|
32470
|
+
if (angle % 2 !== 0) {
|
|
32471
|
+
return {
|
|
32472
|
+
ok: false,
|
|
32473
|
+
error: `Blind Tilt setPosition angle must be a multiple of 2, got "${parts[1]}". Example: "up;48".`
|
|
32474
|
+
};
|
|
32475
|
+
}
|
|
32260
32476
|
return { ok: true, normalized: `${dir};${angle}` };
|
|
32261
32477
|
}
|
|
32262
|
-
function
|
|
32478
|
+
function validateRelay2PmSetMode(raw) {
|
|
32263
32479
|
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32264
32480
|
return {
|
|
32265
32481
|
ok: false,
|
|
32266
32482
|
error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`
|
|
32267
32483
|
};
|
|
32268
32484
|
}
|
|
32269
|
-
const
|
|
32485
|
+
const stripped = stripQuotes(raw.trim());
|
|
32486
|
+
const parts = stripped.split(";");
|
|
32270
32487
|
if (parts.length !== 2) {
|
|
32271
32488
|
return {
|
|
32272
32489
|
ok: false,
|
|
@@ -32289,6 +32506,342 @@ function validateRelaySetMode(raw) {
|
|
|
32289
32506
|
}
|
|
32290
32507
|
return { ok: true, normalized: `${ch};${mode}` };
|
|
32291
32508
|
}
|
|
32509
|
+
function validateRelayChannel(raw) {
|
|
32510
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32511
|
+
return {
|
|
32512
|
+
ok: false,
|
|
32513
|
+
error: `Relay Switch 2PM turnOn/turnOff/toggle requires a channel parameter: "1" or "2". Example: turnOff 1`
|
|
32514
|
+
};
|
|
32515
|
+
}
|
|
32516
|
+
const n = stripQuotes(raw.trim());
|
|
32517
|
+
if (n !== "1" && n !== "2") {
|
|
32518
|
+
return {
|
|
32519
|
+
ok: false,
|
|
32520
|
+
error: `Relay Switch 2PM channel must be "1" or "2", got ${JSON.stringify(raw)}.`
|
|
32521
|
+
};
|
|
32522
|
+
}
|
|
32523
|
+
return { ok: true, normalized: n };
|
|
32524
|
+
}
|
|
32525
|
+
function stripQuotes(s2) {
|
|
32526
|
+
if (s2.length >= 2 && s2.startsWith('"') && s2.endsWith('"')) {
|
|
32527
|
+
return s2.slice(1, -1);
|
|
32528
|
+
}
|
|
32529
|
+
return s2;
|
|
32530
|
+
}
|
|
32531
|
+
function validateIntRange(raw, command, min, max, label) {
|
|
32532
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32533
|
+
return {
|
|
32534
|
+
ok: false,
|
|
32535
|
+
error: `${command} requires an integer ${min}-${max} (${label}). Example: "${Math.round((min + max) / 2)}".`
|
|
32536
|
+
};
|
|
32537
|
+
}
|
|
32538
|
+
const trimmed = stripQuotes(raw.trim());
|
|
32539
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
32540
|
+
return {
|
|
32541
|
+
ok: false,
|
|
32542
|
+
error: `${command} must be an integer ${min}-${max} (${label}), got ${JSON.stringify(raw)}.`
|
|
32543
|
+
};
|
|
32544
|
+
}
|
|
32545
|
+
const n = Number(trimmed);
|
|
32546
|
+
if (n < min || n > max) {
|
|
32547
|
+
return {
|
|
32548
|
+
ok: false,
|
|
32549
|
+
error: `${command} must be an integer ${min}-${max} (${label}), got ${n}.`
|
|
32550
|
+
};
|
|
32551
|
+
}
|
|
32552
|
+
return { ok: true, normalized: String(n) };
|
|
32553
|
+
}
|
|
32554
|
+
function validateEnum(raw, command, allowed, hint) {
|
|
32555
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32556
|
+
return {
|
|
32557
|
+
ok: false,
|
|
32558
|
+
error: `${command} requires a parameter: ${allowed.join(" | ")}${hint ? ` (${hint})` : ""}. Example: "${allowed[0]}".`
|
|
32559
|
+
};
|
|
32560
|
+
}
|
|
32561
|
+
const trimmed = stripQuotes(raw.trim()).toLowerCase();
|
|
32562
|
+
const match = allowed.find((a) => a.toLowerCase() === trimmed);
|
|
32563
|
+
if (!match) {
|
|
32564
|
+
return {
|
|
32565
|
+
ok: false,
|
|
32566
|
+
error: `${command} must be one of: ${allowed.join(", ")}. Got ${JSON.stringify(raw)}.`
|
|
32567
|
+
};
|
|
32568
|
+
}
|
|
32569
|
+
return { ok: true, normalized: match };
|
|
32570
|
+
}
|
|
32571
|
+
function validateHumidifierSetMode(raw) {
|
|
32572
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32573
|
+
return {
|
|
32574
|
+
ok: false,
|
|
32575
|
+
error: `Humidifier setMode requires a parameter: "auto", "101", "102", "103", or 0-100 (humidity %). Example: "auto".`
|
|
32576
|
+
};
|
|
32577
|
+
}
|
|
32578
|
+
const trimmed = stripQuotes(raw.trim()).toLowerCase();
|
|
32579
|
+
if (trimmed === "auto") return { ok: true, normalized: "auto" };
|
|
32580
|
+
if (["101", "102", "103"].includes(trimmed)) return { ok: true, normalized: trimmed };
|
|
32581
|
+
if (/^\d+$/.test(trimmed)) {
|
|
32582
|
+
const n = Number(trimmed);
|
|
32583
|
+
if (n >= 0 && n <= 100) return { ok: true, normalized: String(n) };
|
|
32584
|
+
}
|
|
32585
|
+
return {
|
|
32586
|
+
ok: false,
|
|
32587
|
+
error: `Humidifier setMode must be "auto", "101" (34%), "102" (67%), "103" (100%), or 0-100. Got ${JSON.stringify(raw)}.`
|
|
32588
|
+
};
|
|
32589
|
+
}
|
|
32590
|
+
function validateHumidifier2SetMode(raw) {
|
|
32591
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32592
|
+
return {
|
|
32593
|
+
ok: false,
|
|
32594
|
+
error: `Humidifier2 setMode requires a JSON parameter: {"mode":1-8,"targetHumidify":0-100}. Example: '{"mode":7,"targetHumidify":50}'.`
|
|
32595
|
+
};
|
|
32596
|
+
}
|
|
32597
|
+
let obj;
|
|
32598
|
+
try {
|
|
32599
|
+
obj = JSON.parse(raw);
|
|
32600
|
+
} catch {
|
|
32601
|
+
return {
|
|
32602
|
+
ok: false,
|
|
32603
|
+
error: `Humidifier2 setMode expects JSON: {"mode":1-8,"targetHumidify":0-100}. Got ${JSON.stringify(raw)}.`
|
|
32604
|
+
};
|
|
32605
|
+
}
|
|
32606
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
32607
|
+
return { ok: false, error: `Humidifier2 setMode expects a JSON object, got ${typeof obj}.` };
|
|
32608
|
+
}
|
|
32609
|
+
const o = obj;
|
|
32610
|
+
if (!isNumericish(o.mode)) {
|
|
32611
|
+
return { ok: false, error: `Humidifier2 setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` };
|
|
32612
|
+
}
|
|
32613
|
+
const mode = Number(o.mode);
|
|
32614
|
+
if (!Number.isInteger(mode) || mode < 1 || mode > 8) {
|
|
32615
|
+
return { ok: false, error: `Humidifier2 setMode "mode" must be 1-8, got ${JSON.stringify(o.mode)}.` };
|
|
32616
|
+
}
|
|
32617
|
+
if (!isNumericish(o.targetHumidify)) {
|
|
32618
|
+
return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be a number or numeric string, got ${JSON.stringify(o.targetHumidify)}.` };
|
|
32619
|
+
}
|
|
32620
|
+
const hum = Number(o.targetHumidify);
|
|
32621
|
+
if (!Number.isInteger(hum) || hum < 0 || hum > 100) {
|
|
32622
|
+
return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be 0-100, got ${JSON.stringify(o.targetHumidify)}.` };
|
|
32623
|
+
}
|
|
32624
|
+
return { ok: true, normalized: JSON.stringify({ mode, targetHumidify: hum }) };
|
|
32625
|
+
}
|
|
32626
|
+
function validateAirPurifierSetMode(raw) {
|
|
32627
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32628
|
+
return {
|
|
32629
|
+
ok: false,
|
|
32630
|
+
error: `Air Purifier setMode requires a JSON parameter: {"mode":1-4} or {"mode":1,"fanGear":1-3}. Example: '{"mode":2}'.`
|
|
32631
|
+
};
|
|
32632
|
+
}
|
|
32633
|
+
let obj;
|
|
32634
|
+
try {
|
|
32635
|
+
obj = JSON.parse(raw);
|
|
32636
|
+
} catch {
|
|
32637
|
+
return {
|
|
32638
|
+
ok: false,
|
|
32639
|
+
error: `Air Purifier setMode expects JSON: {"mode":1-4,"fanGear":1-3}. Got ${JSON.stringify(raw)}.`
|
|
32640
|
+
};
|
|
32641
|
+
}
|
|
32642
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
32643
|
+
return { ok: false, error: `Air Purifier setMode expects a JSON object, got ${typeof obj}.` };
|
|
32644
|
+
}
|
|
32645
|
+
const o = obj;
|
|
32646
|
+
if (!isNumericish(o.mode)) {
|
|
32647
|
+
return { ok: false, error: `Air Purifier setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` };
|
|
32648
|
+
}
|
|
32649
|
+
const mode = Number(o.mode);
|
|
32650
|
+
if (!Number.isInteger(mode) || mode < 1 || mode > 4) {
|
|
32651
|
+
return { ok: false, error: `Air Purifier setMode "mode" must be 1-4 (1=normal 2=auto 3=sleep 4=pet), got ${JSON.stringify(o.mode)}.` };
|
|
32652
|
+
}
|
|
32653
|
+
const normalized = { mode };
|
|
32654
|
+
if (o.fanGear !== void 0) {
|
|
32655
|
+
if (mode !== 1) {
|
|
32656
|
+
return { ok: false, error: `Air Purifier setMode "fanGear" can only be set when "mode" is 1 (normal/fan mode).` };
|
|
32657
|
+
}
|
|
32658
|
+
if (!isNumericish(o.fanGear)) {
|
|
32659
|
+
return { ok: false, error: `Air Purifier setMode "fanGear" must be a number or numeric string, got ${JSON.stringify(o.fanGear)}.` };
|
|
32660
|
+
}
|
|
32661
|
+
const fg = Number(o.fanGear);
|
|
32662
|
+
if (!Number.isInteger(fg) || fg < 1 || fg > 3) {
|
|
32663
|
+
return { ok: false, error: `Air Purifier setMode "fanGear" must be 1-3, got ${JSON.stringify(o.fanGear)}.` };
|
|
32664
|
+
}
|
|
32665
|
+
normalized.fanGear = fg;
|
|
32666
|
+
}
|
|
32667
|
+
return { ok: true, normalized: JSON.stringify(normalized) };
|
|
32668
|
+
}
|
|
32669
|
+
function validateVacuumStartClean(raw, deviceType) {
|
|
32670
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32671
|
+
const actions = isFloorCleaningVacuum(deviceType) ? "sweep | sweep_mop" : "sweep | mop";
|
|
32672
|
+
return {
|
|
32673
|
+
ok: false,
|
|
32674
|
+
error: `${deviceType} startClean requires a JSON parameter: {"action":"${actions.split(" | ")[0]}","param":{"fanLevel":1-4,"times":1}}. Example: '{"action":"${actions.split(" | ")[0]}","param":{"fanLevel":2,"times":1}}'.`
|
|
32675
|
+
};
|
|
32676
|
+
}
|
|
32677
|
+
let obj;
|
|
32678
|
+
try {
|
|
32679
|
+
obj = JSON.parse(raw);
|
|
32680
|
+
} catch {
|
|
32681
|
+
return {
|
|
32682
|
+
ok: false,
|
|
32683
|
+
error: `${deviceType} startClean expects JSON. Got ${JSON.stringify(raw)}.`
|
|
32684
|
+
};
|
|
32685
|
+
}
|
|
32686
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
32687
|
+
return { ok: false, error: `${deviceType} startClean expects a JSON object.` };
|
|
32688
|
+
}
|
|
32689
|
+
const o = obj;
|
|
32690
|
+
const validActions = isFloorCleaningVacuum(deviceType) ? ["sweep", "sweep_mop"] : ["sweep", "mop"];
|
|
32691
|
+
if (typeof o.action !== "string" || !validActions.includes(o.action)) {
|
|
32692
|
+
return { ok: false, error: `${deviceType} startClean "action" must be one of: ${validActions.join(", ")}. Got ${JSON.stringify(o.action)}.` };
|
|
32693
|
+
}
|
|
32694
|
+
const normalized = { action: o.action };
|
|
32695
|
+
if (o.param !== void 0) {
|
|
32696
|
+
if (typeof o.param !== "object" || o.param === null || Array.isArray(o.param)) {
|
|
32697
|
+
return { ok: false, error: `${deviceType} startClean "param" must be an object.` };
|
|
32698
|
+
}
|
|
32699
|
+
const p2 = o.param;
|
|
32700
|
+
const normalizedParam = {};
|
|
32701
|
+
if (p2.fanLevel !== void 0) {
|
|
32702
|
+
if (!isNumericish(p2.fanLevel)) {
|
|
32703
|
+
return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be a number or numeric string, got ${JSON.stringify(p2.fanLevel)}.` };
|
|
32704
|
+
}
|
|
32705
|
+
const fl = Number(p2.fanLevel);
|
|
32706
|
+
if (!Number.isInteger(fl) || fl < 1 || fl > 4) {
|
|
32707
|
+
return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be 1-4, got ${JSON.stringify(p2.fanLevel)}.` };
|
|
32708
|
+
}
|
|
32709
|
+
normalizedParam.fanLevel = fl;
|
|
32710
|
+
}
|
|
32711
|
+
if (p2.waterLevel !== void 0) {
|
|
32712
|
+
if (!isFloorCleaningVacuum(deviceType)) {
|
|
32713
|
+
return { ok: false, error: `${deviceType} startClean "param.waterLevel" is only supported for Floor Cleaning Robot S10/S20.` };
|
|
32714
|
+
}
|
|
32715
|
+
if (!isNumericish(p2.waterLevel)) {
|
|
32716
|
+
return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be a number or numeric string, got ${JSON.stringify(p2.waterLevel)}.` };
|
|
32717
|
+
}
|
|
32718
|
+
const wl = Number(p2.waterLevel);
|
|
32719
|
+
if (!Number.isInteger(wl) || wl < 1 || wl > 2) {
|
|
32720
|
+
return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be 1-2, got ${JSON.stringify(p2.waterLevel)}.` };
|
|
32721
|
+
}
|
|
32722
|
+
normalizedParam.waterLevel = wl;
|
|
32723
|
+
}
|
|
32724
|
+
if (p2.times !== void 0) {
|
|
32725
|
+
if (!isNumericish(p2.times)) {
|
|
32726
|
+
return { ok: false, error: `${deviceType} startClean "param.times" must be a number or numeric string, got ${JSON.stringify(p2.times)}.` };
|
|
32727
|
+
}
|
|
32728
|
+
const t = Number(p2.times);
|
|
32729
|
+
if (!Number.isInteger(t) || t < 1 || t > 2639999) {
|
|
32730
|
+
return { ok: false, error: `${deviceType} startClean "param.times" must be an integer 1-2639999, got ${JSON.stringify(p2.times)}.` };
|
|
32731
|
+
}
|
|
32732
|
+
normalizedParam.times = t;
|
|
32733
|
+
}
|
|
32734
|
+
normalized.param = normalizedParam;
|
|
32735
|
+
}
|
|
32736
|
+
return { ok: true, normalized: JSON.stringify(normalized) };
|
|
32737
|
+
}
|
|
32738
|
+
function validateVacuumChangeParam(raw, deviceType) {
|
|
32739
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32740
|
+
return {
|
|
32741
|
+
ok: false,
|
|
32742
|
+
error: `changeParam requires a JSON parameter: {"fanLevel":1-4,"waterLevel":1-2,"times":1}. Example: '{"fanLevel":3,"waterLevel":1,"times":1}'.`
|
|
32743
|
+
};
|
|
32744
|
+
}
|
|
32745
|
+
let obj;
|
|
32746
|
+
try {
|
|
32747
|
+
obj = JSON.parse(raw);
|
|
32748
|
+
} catch {
|
|
32749
|
+
return {
|
|
32750
|
+
ok: false,
|
|
32751
|
+
error: `changeParam expects JSON: {"fanLevel":1-4,"waterLevel":1-2,"times":...}. Got ${JSON.stringify(raw)}.`
|
|
32752
|
+
};
|
|
32753
|
+
}
|
|
32754
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
32755
|
+
return { ok: false, error: `changeParam expects a JSON object.` };
|
|
32756
|
+
}
|
|
32757
|
+
const p2 = obj;
|
|
32758
|
+
const normalized = {};
|
|
32759
|
+
if (p2.fanLevel !== void 0) {
|
|
32760
|
+
if (!isNumericish(p2.fanLevel)) {
|
|
32761
|
+
return { ok: false, error: `changeParam "fanLevel" must be a number or numeric string, got ${JSON.stringify(p2.fanLevel)}.` };
|
|
32762
|
+
}
|
|
32763
|
+
const fl = Number(p2.fanLevel);
|
|
32764
|
+
if (!Number.isInteger(fl) || fl < 1 || fl > 4) {
|
|
32765
|
+
return { ok: false, error: `changeParam "fanLevel" must be 1-4, got ${JSON.stringify(p2.fanLevel)}.` };
|
|
32766
|
+
}
|
|
32767
|
+
normalized.fanLevel = fl;
|
|
32768
|
+
}
|
|
32769
|
+
if (p2.waterLevel !== void 0) {
|
|
32770
|
+
if (isComboVacuum(deviceType)) {
|
|
32771
|
+
return { ok: false, error: `${deviceType} changeParam does not support "waterLevel" according to the API docs.` };
|
|
32772
|
+
}
|
|
32773
|
+
if (!isNumericish(p2.waterLevel)) {
|
|
32774
|
+
return { ok: false, error: `changeParam "waterLevel" must be a number or numeric string, got ${JSON.stringify(p2.waterLevel)}.` };
|
|
32775
|
+
}
|
|
32776
|
+
const wl = Number(p2.waterLevel);
|
|
32777
|
+
if (!Number.isInteger(wl) || wl < 1 || wl > 2) {
|
|
32778
|
+
return { ok: false, error: `changeParam "waterLevel" must be 1-2, got ${JSON.stringify(p2.waterLevel)}.` };
|
|
32779
|
+
}
|
|
32780
|
+
normalized.waterLevel = wl;
|
|
32781
|
+
}
|
|
32782
|
+
if (p2.times !== void 0) {
|
|
32783
|
+
if (!isNumericish(p2.times)) {
|
|
32784
|
+
return { ok: false, error: `changeParam "times" must be a number or numeric string, got ${JSON.stringify(p2.times)}.` };
|
|
32785
|
+
}
|
|
32786
|
+
const t = Number(p2.times);
|
|
32787
|
+
if (!Number.isInteger(t) || t < 1 || t > 2639999) {
|
|
32788
|
+
return { ok: false, error: `changeParam "times" must be an integer 1-2639999, got ${JSON.stringify(p2.times)}.` };
|
|
32789
|
+
}
|
|
32790
|
+
normalized.times = t;
|
|
32791
|
+
}
|
|
32792
|
+
return { ok: true, normalized: JSON.stringify(normalized) };
|
|
32793
|
+
}
|
|
32794
|
+
function validateKeypadCreateKey(raw) {
|
|
32795
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32796
|
+
return {
|
|
32797
|
+
ok: false,
|
|
32798
|
+
error: `createKey requires a JSON parameter: {"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits",...}.`
|
|
32799
|
+
};
|
|
32800
|
+
}
|
|
32801
|
+
let obj;
|
|
32802
|
+
try {
|
|
32803
|
+
obj = JSON.parse(raw);
|
|
32804
|
+
} catch {
|
|
32805
|
+
return { ok: false, error: `createKey expects a JSON object. Got ${JSON.stringify(raw)}.` };
|
|
32806
|
+
}
|
|
32807
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
32808
|
+
return { ok: false, error: `createKey expects a JSON object.` };
|
|
32809
|
+
}
|
|
32810
|
+
const o = obj;
|
|
32811
|
+
if (typeof o.name !== "string" || o.name.length === 0) {
|
|
32812
|
+
return { ok: false, error: `createKey "name" is required and must be a non-empty string.` };
|
|
32813
|
+
}
|
|
32814
|
+
const validTypes = ["permanent", "timeLimit", "disposable", "urgent"];
|
|
32815
|
+
if (typeof o.type !== "string" || !validTypes.includes(o.type)) {
|
|
32816
|
+
return { ok: false, error: `createKey "type" must be one of: ${validTypes.join(", ")}. Got ${JSON.stringify(o.type)}.` };
|
|
32817
|
+
}
|
|
32818
|
+
if (typeof o.password !== "string" || !/^\d{6,12}$/.test(o.password)) {
|
|
32819
|
+
return { ok: false, error: `createKey "password" must be a 6-12 digit string. Got ${JSON.stringify(o.password)}.` };
|
|
32820
|
+
}
|
|
32821
|
+
return { ok: true };
|
|
32822
|
+
}
|
|
32823
|
+
function validateKeypadDeleteKey(raw) {
|
|
32824
|
+
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32825
|
+
return {
|
|
32826
|
+
ok: false,
|
|
32827
|
+
error: `deleteKey requires a JSON parameter: {"id":<passcode_id>}. Example: '{"id":12345}'.`
|
|
32828
|
+
};
|
|
32829
|
+
}
|
|
32830
|
+
let obj;
|
|
32831
|
+
try {
|
|
32832
|
+
obj = JSON.parse(raw);
|
|
32833
|
+
} catch {
|
|
32834
|
+
return { ok: false, error: `deleteKey expects a JSON object: {"id":<passcode_id>}. Got ${JSON.stringify(raw)}.` };
|
|
32835
|
+
}
|
|
32836
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
32837
|
+
return { ok: false, error: `deleteKey expects a JSON object.` };
|
|
32838
|
+
}
|
|
32839
|
+
const o = obj;
|
|
32840
|
+
if (o.id === void 0 || typeof o.id !== "number" && typeof o.id !== "string") {
|
|
32841
|
+
return { ok: false, error: `deleteKey "id" is required (passcode ID). Got ${JSON.stringify(o.id)}.` };
|
|
32842
|
+
}
|
|
32843
|
+
return { ok: true };
|
|
32844
|
+
}
|
|
32292
32845
|
|
|
32293
32846
|
// src/commands/batch.ts
|
|
32294
32847
|
init_cjs_shim();
|
|
@@ -32518,13 +33071,7 @@ Examples:
|
|
|
32518
33071
|
}
|
|
32519
33072
|
});
|
|
32520
33073
|
}
|
|
32521
|
-
|
|
32522
|
-
if (parameter) {
|
|
32523
|
-
try {
|
|
32524
|
-
parsedParam = JSON.parse(parameter);
|
|
32525
|
-
} catch {
|
|
32526
|
-
}
|
|
32527
|
-
}
|
|
33074
|
+
const parsedParam = parseParameterForWire(parameter);
|
|
32528
33075
|
const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency;
|
|
32529
33076
|
const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
|
|
32530
33077
|
const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
|
|
@@ -33138,7 +33685,7 @@ init_catalog();
|
|
|
33138
33685
|
init_flags();
|
|
33139
33686
|
init_client();
|
|
33140
33687
|
function registerExpandCommand(devices) {
|
|
33141
|
-
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), setBrightness/setColor/setColorTemperature (lighting)").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("--brightness <percent>", "setBrightness:
|
|
33688
|
+
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), setBrightness/setColor/setColorTemperature (lighting)").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("--brightness <percent>", "setBrightness: 0-100 percent (minimum depends on device)", intArg("--brightness", { min: 0, max: 100 })).option("--color <value>", "setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)", stringArg("--color")).option("--color-temp <kelvin>", "setColorTemperature: 2700-6500 Kelvin", intArg("--color-temp", { min: 2700, max: 6500 })).option("--yes", "Confirm destructive commands").addHelpText("after", `
|
|
33142
33689
|
Translates semantic flags into the wire parameter format, then sends the command.
|
|
33143
33690
|
|
|
33144
33691
|
Supported expansions:
|
|
@@ -33259,7 +33806,7 @@ Examples:
|
|
|
33259
33806
|
}
|
|
33260
33807
|
}
|
|
33261
33808
|
if (command === "setBrightness") {
|
|
33262
|
-
parameter = buildBrightnessSet(options);
|
|
33809
|
+
parameter = buildBrightnessSet(options, cached2?.type);
|
|
33263
33810
|
} else if (command === "setColor") {
|
|
33264
33811
|
parameter = buildColorSet(options);
|
|
33265
33812
|
} else {
|
|
@@ -33943,13 +34490,7 @@ ${extra}` : extra;
|
|
|
33943
34490
|
if (options.yes && !destructive && !isDryRun()) {
|
|
33944
34491
|
console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
|
|
33945
34492
|
}
|
|
33946
|
-
|
|
33947
|
-
if (parameter) {
|
|
33948
|
-
try {
|
|
33949
|
-
parsedParam = JSON.parse(parameter);
|
|
33950
|
-
} catch {
|
|
33951
|
-
}
|
|
33952
|
-
}
|
|
34493
|
+
const parsedParam = parseParameterForWire(parameter);
|
|
33953
34494
|
_cmd = cmd;
|
|
33954
34495
|
_parsedParam = parsedParam;
|
|
33955
34496
|
const body = await executeCommand(
|
|
@@ -49269,6 +49810,47 @@ var EventSubscriptionManager = class {
|
|
|
49269
49810
|
}
|
|
49270
49811
|
};
|
|
49271
49812
|
|
|
49813
|
+
// src/mcp/tool-profiles.ts
|
|
49814
|
+
init_cjs_shim();
|
|
49815
|
+
var CORE_READ = [
|
|
49816
|
+
"list_devices",
|
|
49817
|
+
"get_device_status",
|
|
49818
|
+
"get_device_history",
|
|
49819
|
+
"query_device_history",
|
|
49820
|
+
"list_scenes",
|
|
49821
|
+
"search_catalog",
|
|
49822
|
+
"describe_device",
|
|
49823
|
+
"aggregate_device_history",
|
|
49824
|
+
"account_overview",
|
|
49825
|
+
"plan_suggest"
|
|
49826
|
+
];
|
|
49827
|
+
var CORE_ACTION = ["send_command", "run_scene", "plan_run"];
|
|
49828
|
+
var ADMIN = [
|
|
49829
|
+
"policy_validate",
|
|
49830
|
+
"policy_diff",
|
|
49831
|
+
"policy_new",
|
|
49832
|
+
"policy_migrate",
|
|
49833
|
+
"policy_add_rule",
|
|
49834
|
+
"audit_query",
|
|
49835
|
+
"audit_stats",
|
|
49836
|
+
"rule_notifications",
|
|
49837
|
+
"rules_suggest",
|
|
49838
|
+
"rules_explain",
|
|
49839
|
+
"rules_simulate"
|
|
49840
|
+
];
|
|
49841
|
+
var TOOL_PROFILES = {
|
|
49842
|
+
readonly: new Set(CORE_READ),
|
|
49843
|
+
default: /* @__PURE__ */ new Set([...CORE_READ, ...CORE_ACTION]),
|
|
49844
|
+
all: /* @__PURE__ */ new Set([...CORE_READ, ...CORE_ACTION, ...ADMIN])
|
|
49845
|
+
};
|
|
49846
|
+
var VALID_PROFILES = Object.keys(TOOL_PROFILES);
|
|
49847
|
+
function resolveToolProfile(name) {
|
|
49848
|
+
if (!name || name === "default") return "default";
|
|
49849
|
+
if (name === "readonly" || name === "all") return name;
|
|
49850
|
+
const valid = VALID_PROFILES.join(", ");
|
|
49851
|
+
throw new Error(`Unknown tool profile "${name}". Valid profiles: ${valid}`);
|
|
49852
|
+
}
|
|
49853
|
+
|
|
49272
49854
|
// src/devices/history-query.ts
|
|
49273
49855
|
init_cjs_shim();
|
|
49274
49856
|
import fs10 from "node:fs";
|
|
@@ -51139,6 +51721,8 @@ function buildRiskProfile(typeName, command, commandType, isDestructive) {
|
|
|
51139
51721
|
}
|
|
51140
51722
|
function createSwitchBotMcpServer(options) {
|
|
51141
51723
|
const eventManager = options?.eventManager;
|
|
51724
|
+
const allowedTools = TOOL_PROFILES[options?.toolProfile ?? "default"];
|
|
51725
|
+
const profileName = options?.toolProfile ?? "default";
|
|
51142
51726
|
const server = new McpServer(
|
|
51143
51727
|
{
|
|
51144
51728
|
name: "switchbot",
|
|
@@ -51162,1075 +51746,1093 @@ Recommended bootstrap sequence:
|
|
|
51162
51746
|
2. search_catalog or describe_device \u2192 confirm supported commands offline/online
|
|
51163
51747
|
3. send_command (with confirm:true for destructive commands)
|
|
51164
51748
|
|
|
51165
|
-
API docs: https://github.com/OpenWonderLabs/SwitchBotAPI
|
|
51749
|
+
API docs: https://github.com/OpenWonderLabs/SwitchBotAPI
|
|
51750
|
+
|
|
51751
|
+
Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName !== "all" ? " Use --tools all to access admin tools (policy, audit, rules)." : ""}`
|
|
51166
51752
|
}
|
|
51167
51753
|
);
|
|
51168
|
-
|
|
51169
|
-
|
|
51170
|
-
|
|
51171
|
-
|
|
51172
|
-
|
|
51173
|
-
|
|
51174
|
-
|
|
51175
|
-
|
|
51176
|
-
|
|
51177
|
-
|
|
51178
|
-
|
|
51179
|
-
|
|
51180
|
-
|
|
51181
|
-
|
|
51182
|
-
|
|
51183
|
-
|
|
51184
|
-
|
|
51185
|
-
|
|
51186
|
-
|
|
51187
|
-
|
|
51188
|
-
|
|
51189
|
-
|
|
51190
|
-
|
|
51191
|
-
|
|
51192
|
-
|
|
51193
|
-
|
|
51194
|
-
|
|
51195
|
-
|
|
51196
|
-
async () => {
|
|
51197
|
-
const body = await fetchDeviceList();
|
|
51198
|
-
return {
|
|
51199
|
-
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
51200
|
-
structuredContent: {
|
|
51201
|
-
deviceList: body.deviceList.map(toMcpDeviceListShape),
|
|
51202
|
-
infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape)
|
|
51754
|
+
const skip = (name) => !allowedTools.has(name);
|
|
51755
|
+
if (!skip("list_devices"))
|
|
51756
|
+
server.registerTool(
|
|
51757
|
+
"list_devices",
|
|
51758
|
+
{
|
|
51759
|
+
title: "List all devices on the account",
|
|
51760
|
+
description: "Fetch the complete inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local metadata cache and groups devices by type. Use this as the bootstrap call to discover available deviceIds. Devices without enableCloudService cannot receive commands via API. IR remotes depend on a Hub for connectivity.",
|
|
51761
|
+
_meta: { agentSafetyTier: "read" },
|
|
51762
|
+
inputSchema: external_exports.object({}).strict(),
|
|
51763
|
+
outputSchema: {
|
|
51764
|
+
deviceList: external_exports.array(external_exports.object({
|
|
51765
|
+
deviceId: external_exports.string(),
|
|
51766
|
+
deviceName: external_exports.string(),
|
|
51767
|
+
deviceType: external_exports.string().optional(),
|
|
51768
|
+
enableCloudService: external_exports.boolean(),
|
|
51769
|
+
hubDeviceId: external_exports.string(),
|
|
51770
|
+
roomID: external_exports.string().optional(),
|
|
51771
|
+
roomName: external_exports.string().nullable().optional(),
|
|
51772
|
+
familyName: external_exports.string().optional(),
|
|
51773
|
+
controlType: external_exports.string().optional()
|
|
51774
|
+
}).passthrough()).describe("Physical SwitchBot devices"),
|
|
51775
|
+
infraredRemoteList: external_exports.array(external_exports.object({
|
|
51776
|
+
deviceId: external_exports.string(),
|
|
51777
|
+
deviceName: external_exports.string(),
|
|
51778
|
+
remoteType: external_exports.string(),
|
|
51779
|
+
hubDeviceId: external_exports.string(),
|
|
51780
|
+
controlType: external_exports.string().optional()
|
|
51781
|
+
}).passthrough()).describe("IR remote devices")
|
|
51203
51782
|
}
|
|
51204
|
-
}
|
|
51205
|
-
|
|
51206
|
-
|
|
51207
|
-
server.registerTool(
|
|
51208
|
-
"get_device_status",
|
|
51209
|
-
{
|
|
51210
|
-
title: "Get live status for a device",
|
|
51211
|
-
description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
|
|
51212
|
-
_meta: { agentSafetyTier: "read" },
|
|
51213
|
-
inputSchema: external_exports.object({
|
|
51214
|
-
deviceId: external_exports.string().describe("Device ID from list_devices")
|
|
51215
|
-
}).strict(),
|
|
51216
|
-
outputSchema: {
|
|
51217
|
-
status: external_exports.object({
|
|
51218
|
-
deviceId: external_exports.string().optional(),
|
|
51219
|
-
deviceType: external_exports.string().optional(),
|
|
51220
|
-
hubDeviceId: external_exports.string().optional(),
|
|
51221
|
-
connectionStatus: external_exports.string().optional()
|
|
51222
|
-
}).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
|
|
51223
|
-
}
|
|
51224
|
-
},
|
|
51225
|
-
async ({ deviceId }) => {
|
|
51226
|
-
const body = await fetchDeviceStatus(deviceId);
|
|
51227
|
-
return {
|
|
51228
|
-
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
51229
|
-
structuredContent: { status: body }
|
|
51230
|
-
};
|
|
51231
|
-
}
|
|
51232
|
-
);
|
|
51233
|
-
server.registerTool(
|
|
51234
|
-
"get_device_history",
|
|
51235
|
-
{
|
|
51236
|
-
title: "Get locally-persisted device state history",
|
|
51237
|
-
description: "Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). No API call \u2014 zero quota cost. Use when you need recent historical readings or want to avoid a live API call. Omit deviceId to list all devices with stored history.",
|
|
51238
|
-
_meta: { agentSafetyTier: "read" },
|
|
51239
|
-
inputSchema: external_exports.object({
|
|
51240
|
-
deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
|
|
51241
|
-
limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
|
|
51242
|
-
}).strict(),
|
|
51243
|
-
outputSchema: {
|
|
51244
|
-
deviceId: external_exports.string().optional(),
|
|
51245
|
-
latest: external_exports.unknown().optional(),
|
|
51246
|
-
history: external_exports.array(external_exports.unknown()).optional(),
|
|
51247
|
-
devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
|
|
51248
|
-
}
|
|
51249
|
-
},
|
|
51250
|
-
async ({ deviceId, limit }) => {
|
|
51251
|
-
if (deviceId) {
|
|
51252
|
-
const latest = deviceHistoryStore.getLatest(deviceId);
|
|
51253
|
-
const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
|
|
51254
|
-
const result2 = { deviceId, latest, history };
|
|
51783
|
+
},
|
|
51784
|
+
async () => {
|
|
51785
|
+
const body = await fetchDeviceList();
|
|
51255
51786
|
return {
|
|
51256
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51257
|
-
structuredContent:
|
|
51787
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
51788
|
+
structuredContent: {
|
|
51789
|
+
deviceList: body.deviceList.map(toMcpDeviceListShape),
|
|
51790
|
+
infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape)
|
|
51791
|
+
}
|
|
51258
51792
|
};
|
|
51259
51793
|
}
|
|
51260
|
-
|
|
51261
|
-
|
|
51262
|
-
|
|
51263
|
-
|
|
51264
|
-
|
|
51265
|
-
|
|
51266
|
-
|
|
51267
|
-
|
|
51268
|
-
|
|
51269
|
-
|
|
51270
|
-
|
|
51271
|
-
|
|
51272
|
-
|
|
51273
|
-
|
|
51274
|
-
|
|
51275
|
-
|
|
51276
|
-
|
|
51277
|
-
|
|
51278
|
-
|
|
51279
|
-
|
|
51280
|
-
|
|
51281
|
-
|
|
51282
|
-
|
|
51283
|
-
|
|
51284
|
-
|
|
51285
|
-
|
|
51286
|
-
records: external_exports.array(external_exports.object({
|
|
51287
|
-
t: external_exports.string(),
|
|
51288
|
-
topic: external_exports.string(),
|
|
51289
|
-
deviceType: external_exports.string().optional(),
|
|
51290
|
-
payload: external_exports.unknown()
|
|
51291
|
-
}))
|
|
51292
|
-
}
|
|
51293
|
-
},
|
|
51294
|
-
async ({ deviceId, since, from, to, fields, limit }) => {
|
|
51295
|
-
if (since && (from || to)) {
|
|
51296
|
-
return mcpError("usage", 2, "--since is mutually exclusive with --from/--to.");
|
|
51794
|
+
);
|
|
51795
|
+
if (!skip("get_device_status"))
|
|
51796
|
+
server.registerTool(
|
|
51797
|
+
"get_device_status",
|
|
51798
|
+
{
|
|
51799
|
+
title: "Get live status for a device",
|
|
51800
|
+
description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
|
|
51801
|
+
_meta: { agentSafetyTier: "read" },
|
|
51802
|
+
inputSchema: external_exports.object({
|
|
51803
|
+
deviceId: external_exports.string().describe("Device ID from list_devices")
|
|
51804
|
+
}).strict(),
|
|
51805
|
+
outputSchema: {
|
|
51806
|
+
status: external_exports.object({
|
|
51807
|
+
deviceId: external_exports.string().optional(),
|
|
51808
|
+
deviceType: external_exports.string().optional(),
|
|
51809
|
+
hubDeviceId: external_exports.string().optional(),
|
|
51810
|
+
connectionStatus: external_exports.string().optional()
|
|
51811
|
+
}).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
|
|
51812
|
+
}
|
|
51813
|
+
},
|
|
51814
|
+
async ({ deviceId }) => {
|
|
51815
|
+
const body = await fetchDeviceStatus(deviceId);
|
|
51816
|
+
return {
|
|
51817
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
51818
|
+
structuredContent: { status: body }
|
|
51819
|
+
};
|
|
51297
51820
|
}
|
|
51298
|
-
|
|
51299
|
-
|
|
51300
|
-
|
|
51821
|
+
);
|
|
51822
|
+
if (!skip("get_device_history"))
|
|
51823
|
+
server.registerTool(
|
|
51824
|
+
"get_device_history",
|
|
51825
|
+
{
|
|
51826
|
+
title: "Get locally-persisted device state history",
|
|
51827
|
+
description: "Return device state history recorded from MQTT events (persisted to ~/.switchbot/device-history/). No API call \u2014 zero quota cost. Use when you need recent historical readings or want to avoid a live API call. Omit deviceId to list all devices with stored history.",
|
|
51828
|
+
_meta: { agentSafetyTier: "read" },
|
|
51829
|
+
inputSchema: external_exports.object({
|
|
51830
|
+
deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
|
|
51831
|
+
limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
|
|
51832
|
+
}).strict(),
|
|
51833
|
+
outputSchema: {
|
|
51834
|
+
deviceId: external_exports.string().optional(),
|
|
51835
|
+
latest: external_exports.unknown().optional(),
|
|
51836
|
+
history: external_exports.array(external_exports.unknown()).optional(),
|
|
51837
|
+
devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
|
|
51838
|
+
}
|
|
51839
|
+
},
|
|
51840
|
+
async ({ deviceId, limit }) => {
|
|
51841
|
+
if (deviceId) {
|
|
51842
|
+
const latest = deviceHistoryStore.getLatest(deviceId);
|
|
51843
|
+
const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
|
|
51844
|
+
const result2 = { deviceId, latest, history };
|
|
51845
|
+
return {
|
|
51846
|
+
content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
|
|
51847
|
+
structuredContent: result2
|
|
51848
|
+
};
|
|
51849
|
+
}
|
|
51850
|
+
const ids = deviceHistoryStore.listDevices();
|
|
51851
|
+
const devices = ids.map((id) => ({ deviceId: id, latest: deviceHistoryStore.getLatest(id) }));
|
|
51852
|
+
const result = { devices };
|
|
51301
51853
|
return {
|
|
51302
51854
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
51303
51855
|
structuredContent: result
|
|
51304
51856
|
};
|
|
51305
|
-
} catch (err) {
|
|
51306
|
-
const msg = err instanceof Error ? err.message : "history query failed";
|
|
51307
|
-
return mcpError("usage", 2, msg);
|
|
51308
51857
|
}
|
|
51309
|
-
|
|
51310
|
-
)
|
|
51311
|
-
|
|
51312
|
-
|
|
51313
|
-
|
|
51314
|
-
|
|
51315
|
-
|
|
51316
|
-
|
|
51317
|
-
|
|
51318
|
-
|
|
51319
|
-
|
|
51320
|
-
|
|
51321
|
-
|
|
51322
|
-
|
|
51323
|
-
|
|
51324
|
-
|
|
51325
|
-
|
|
51326
|
-
dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
|
|
51327
|
-
}).strict(),
|
|
51328
|
-
outputSchema: {
|
|
51329
|
-
ok: external_exports.literal(true),
|
|
51330
|
-
command: external_exports.string().optional(),
|
|
51331
|
-
deviceId: external_exports.string().optional(),
|
|
51332
|
-
result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
|
|
51333
|
-
riskProfile: external_exports.object({
|
|
51334
|
-
riskLevel: external_exports.enum(["high", "medium", "low"]),
|
|
51335
|
-
requiresConfirmation: external_exports.boolean(),
|
|
51336
|
-
supportsDryRun: external_exports.literal(true),
|
|
51337
|
-
idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
|
|
51338
|
-
recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
|
|
51339
|
-
}).optional().describe(
|
|
51340
|
-
'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
|
|
51341
|
-
),
|
|
51342
|
-
verification: external_exports.object({
|
|
51343
|
-
verifiable: external_exports.boolean(),
|
|
51344
|
-
reason: external_exports.string(),
|
|
51345
|
-
suggestedFollowup: external_exports.string()
|
|
51346
|
-
}).optional().describe(
|
|
51347
|
-
'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
|
|
51348
|
-
),
|
|
51349
|
-
dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
|
|
51350
|
-
wouldSend: external_exports.object({
|
|
51858
|
+
);
|
|
51859
|
+
if (!skip("query_device_history"))
|
|
51860
|
+
server.registerTool(
|
|
51861
|
+
"query_device_history",
|
|
51862
|
+
{
|
|
51863
|
+
title: "Query time-ranged device history",
|
|
51864
|
+
description: 'Return records from the append-only JSONL history (~/.switchbot/device-history/<deviceId>.jsonl) filtered by a relative duration (since) or absolute ISO-8601 range (from/to). No API call \u2014 zero quota cost. Use for trend questions like "how many times did this switch turn on last week".',
|
|
51865
|
+
_meta: { agentSafetyTier: "read" },
|
|
51866
|
+
inputSchema: external_exports.object({
|
|
51867
|
+
deviceId: external_exports.string().describe("Device ID to query"),
|
|
51868
|
+
since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
51869
|
+
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
51870
|
+
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
51871
|
+
fields: external_exports.array(external_exports.string()).optional().describe("Project these payload fields; omit for the full payload."),
|
|
51872
|
+
limit: external_exports.number().int().min(1).max(1e4).optional().describe("Max records to return (default 1000).")
|
|
51873
|
+
}).strict(),
|
|
51874
|
+
outputSchema: {
|
|
51351
51875
|
deviceId: external_exports.string(),
|
|
51352
|
-
|
|
51353
|
-
|
|
51354
|
-
|
|
51355
|
-
|
|
51876
|
+
count: external_exports.number().int(),
|
|
51877
|
+
records: external_exports.array(external_exports.object({
|
|
51878
|
+
t: external_exports.string(),
|
|
51879
|
+
topic: external_exports.string(),
|
|
51880
|
+
deviceType: external_exports.string().optional(),
|
|
51881
|
+
payload: external_exports.unknown()
|
|
51882
|
+
}))
|
|
51883
|
+
}
|
|
51884
|
+
},
|
|
51885
|
+
async ({ deviceId, since, from, to, fields, limit }) => {
|
|
51886
|
+
if (since && (from || to)) {
|
|
51887
|
+
return mcpError("usage", 2, "--since is mutually exclusive with --from/--to.");
|
|
51888
|
+
}
|
|
51889
|
+
try {
|
|
51890
|
+
const records = await queryDeviceHistory(deviceId, { since, from, to, fields, limit });
|
|
51891
|
+
const result = { deviceId, count: records.length, records };
|
|
51892
|
+
return {
|
|
51893
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
51894
|
+
structuredContent: result
|
|
51895
|
+
};
|
|
51896
|
+
} catch (err) {
|
|
51897
|
+
const msg = err instanceof Error ? err.message : "history query failed";
|
|
51898
|
+
return mcpError("usage", 2, msg);
|
|
51899
|
+
}
|
|
51356
51900
|
}
|
|
51357
|
-
|
|
51358
|
-
|
|
51359
|
-
|
|
51360
|
-
|
|
51361
|
-
|
|
51362
|
-
|
|
51363
|
-
|
|
51364
|
-
|
|
51365
|
-
|
|
51366
|
-
|
|
51367
|
-
|
|
51368
|
-
|
|
51369
|
-
|
|
51370
|
-
|
|
51901
|
+
);
|
|
51902
|
+
if (!skip("send_command"))
|
|
51903
|
+
server.registerTool(
|
|
51904
|
+
"send_command",
|
|
51905
|
+
{
|
|
51906
|
+
title: "Send a control command to a device",
|
|
51907
|
+
description: "Execute a control command on a device (turnOn, setColor, startClean, unlock, openDoor, createKey, etc.). Destructive commands require confirm:true and are still blocked in the default safety profile; use the reviewed plan workflow unless an explicit dev profile allows direct execution. Commands are validated offline against the device catalog. Use idempotencyKey to safely deduplicate retries within 60 seconds.",
|
|
51908
|
+
_meta: { agentSafetyTier: "action" },
|
|
51909
|
+
inputSchema: external_exports.object({
|
|
51910
|
+
deviceId: external_exports.string().describe("Device ID from list_devices"),
|
|
51911
|
+
command: external_exports.string().describe("Command name, case-sensitive (e.g. turnOn, setColor, unlock)"),
|
|
51912
|
+
parameter: external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.record(external_exports.string(), external_exports.unknown()), external_exports.array(external_exports.unknown())]).optional().describe("Command parameter. Omit for no-arg commands."),
|
|
51913
|
+
commandType: external_exports.enum(["command", "customize"]).optional().default("command").describe('"command" for built-in commands; "customize" for user-defined IR buttons'),
|
|
51914
|
+
confirm: external_exports.boolean().optional().default(false).describe("Required true for destructive commands (unlock, garage open, createKey, ...)"),
|
|
51915
|
+
idempotencyKey: external_exports.string().optional().describe(
|
|
51916
|
+
"Deduplication key \u2014 repeat calls with the same key within 60s replay the first result (adds replayed:true). Same key + different (command, parameter) within 60s returns an idempotency_conflict guard error."
|
|
51917
|
+
),
|
|
51918
|
+
dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
|
|
51919
|
+
}).strict(),
|
|
51920
|
+
outputSchema: {
|
|
51921
|
+
ok: external_exports.literal(true),
|
|
51922
|
+
command: external_exports.string().optional(),
|
|
51923
|
+
deviceId: external_exports.string().optional(),
|
|
51924
|
+
result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
|
|
51925
|
+
riskProfile: external_exports.object({
|
|
51926
|
+
riskLevel: external_exports.enum(["high", "medium", "low"]),
|
|
51927
|
+
requiresConfirmation: external_exports.boolean(),
|
|
51928
|
+
supportsDryRun: external_exports.literal(true),
|
|
51929
|
+
idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
|
|
51930
|
+
recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
|
|
51931
|
+
}).optional().describe(
|
|
51932
|
+
'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
|
|
51933
|
+
),
|
|
51934
|
+
verification: external_exports.object({
|
|
51935
|
+
verifiable: external_exports.boolean(),
|
|
51936
|
+
reason: external_exports.string(),
|
|
51937
|
+
suggestedFollowup: external_exports.string()
|
|
51938
|
+
}).optional().describe(
|
|
51939
|
+
'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
|
|
51940
|
+
),
|
|
51941
|
+
dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
|
|
51942
|
+
wouldSend: external_exports.object({
|
|
51943
|
+
deviceId: external_exports.string(),
|
|
51944
|
+
command: external_exports.string(),
|
|
51945
|
+
parameter: external_exports.unknown(),
|
|
51946
|
+
commandType: external_exports.string()
|
|
51947
|
+
}).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
|
|
51371
51948
|
}
|
|
51372
|
-
|
|
51373
|
-
|
|
51949
|
+
},
|
|
51950
|
+
async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
|
|
51951
|
+
const effectiveType = commandType ?? "command";
|
|
51952
|
+
let effectiveCommand = command;
|
|
51953
|
+
let effectiveParameter = parameter;
|
|
51954
|
+
const stringifiedParam = parameter === void 0 ? void 0 : typeof parameter === "string" ? parameter : JSON.stringify(parameter);
|
|
51955
|
+
if (dryRun) {
|
|
51956
|
+
const cached2 = getCachedDevice(deviceId);
|
|
51957
|
+
if (!cached2) {
|
|
51958
|
+
return mcpError("usage", 2, `Device "${deviceId}" not found in local cache.`, {
|
|
51959
|
+
subKind: "device-not-found",
|
|
51960
|
+
hint: "Run 'list_devices' first to warm the cache, then retry with dryRun:true.",
|
|
51961
|
+
context: { deviceId }
|
|
51962
|
+
});
|
|
51963
|
+
}
|
|
51964
|
+
const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
|
|
51965
|
+
if (!dryValidation.ok) {
|
|
51966
|
+
return mcpError(
|
|
51967
|
+
"usage",
|
|
51968
|
+
2,
|
|
51969
|
+
dryValidation.error.message,
|
|
51970
|
+
{
|
|
51971
|
+
hint: dryValidation.error.hint,
|
|
51972
|
+
context: {
|
|
51973
|
+
validationKind: dryValidation.error.kind,
|
|
51974
|
+
deviceType: cached2.type,
|
|
51975
|
+
command: effectiveCommand
|
|
51976
|
+
}
|
|
51977
|
+
}
|
|
51978
|
+
);
|
|
51979
|
+
}
|
|
51980
|
+
if (dryValidation.normalized) {
|
|
51981
|
+
effectiveCommand = dryValidation.normalized;
|
|
51982
|
+
}
|
|
51983
|
+
if (effectiveType !== "customize") {
|
|
51984
|
+
const pv = validateParameter(cached2.type, effectiveCommand, stringifiedParam);
|
|
51985
|
+
if (!pv.ok) {
|
|
51986
|
+
return mcpError("usage", 2, pv.error, {
|
|
51987
|
+
hint: "Dry-run rejected the parameter client-side; the API would reject it too.",
|
|
51988
|
+
context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
|
|
51989
|
+
});
|
|
51990
|
+
}
|
|
51991
|
+
if (pv.normalized !== void 0) {
|
|
51992
|
+
effectiveParameter = parseParameterForWire(pv.normalized);
|
|
51993
|
+
}
|
|
51994
|
+
}
|
|
51995
|
+
const wouldSend = {
|
|
51996
|
+
deviceId,
|
|
51997
|
+
command: effectiveCommand,
|
|
51998
|
+
parameter: effectiveParameter ?? "default",
|
|
51999
|
+
commandType: effectiveType
|
|
52000
|
+
};
|
|
52001
|
+
const dryIsDestructive = isDestructiveCommand(cached2.type, effectiveCommand, effectiveType);
|
|
52002
|
+
const dryRiskProfile = buildRiskProfile(cached2.type, effectiveCommand, effectiveType, dryIsDestructive);
|
|
52003
|
+
const structured2 = { ok: true, dryRun: true, riskProfile: dryRiskProfile, wouldSend };
|
|
52004
|
+
return {
|
|
52005
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52006
|
+
structuredContent: structured2
|
|
52007
|
+
};
|
|
52008
|
+
}
|
|
52009
|
+
let typeName = getCachedDevice(deviceId)?.type;
|
|
52010
|
+
if (!typeName) {
|
|
52011
|
+
const body = await fetchDeviceList();
|
|
52012
|
+
const physical = body.deviceList.find((d) => d.deviceId === deviceId);
|
|
52013
|
+
const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
52014
|
+
if (!physical && !ir) {
|
|
52015
|
+
return mcpError("runtime", 152, `Device not found: ${deviceId}`, {
|
|
52016
|
+
hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive)."
|
|
52017
|
+
});
|
|
52018
|
+
}
|
|
52019
|
+
typeName = physical ? physical.deviceType : ir.remoteType;
|
|
52020
|
+
}
|
|
52021
|
+
const destructive = isDestructiveCommand(typeName, effectiveCommand, effectiveType);
|
|
52022
|
+
if (destructive && !allowsDirectDestructiveExecution()) {
|
|
52023
|
+
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
51374
52024
|
return mcpError(
|
|
51375
|
-
"
|
|
51376
|
-
|
|
51377
|
-
|
|
52025
|
+
"guard",
|
|
52026
|
+
3,
|
|
52027
|
+
`Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
|
|
51378
52028
|
{
|
|
51379
|
-
hint:
|
|
52029
|
+
hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
|
|
51380
52030
|
context: {
|
|
51381
|
-
|
|
51382
|
-
deviceType:
|
|
51383
|
-
|
|
52031
|
+
command: effectiveCommand,
|
|
52032
|
+
deviceType: typeName,
|
|
52033
|
+
directExecutionAllowed: false,
|
|
52034
|
+
requiredWorkflow: "plan-approval",
|
|
52035
|
+
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
51384
52036
|
}
|
|
51385
52037
|
}
|
|
51386
52038
|
);
|
|
51387
52039
|
}
|
|
51388
|
-
if (
|
|
51389
|
-
|
|
52040
|
+
if (destructive && !confirm) {
|
|
52041
|
+
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
52042
|
+
const entry = typeName ? findCatalogEntry(typeName) : null;
|
|
52043
|
+
const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
|
|
52044
|
+
const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
|
|
52045
|
+
return mcpError(
|
|
52046
|
+
"guard",
|
|
52047
|
+
3,
|
|
52048
|
+
`Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
|
|
52049
|
+
{
|
|
52050
|
+
hint,
|
|
52051
|
+
context: {
|
|
52052
|
+
command: effectiveCommand,
|
|
52053
|
+
deviceType: typeName,
|
|
52054
|
+
description: spec?.description ?? null,
|
|
52055
|
+
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
52056
|
+
}
|
|
52057
|
+
}
|
|
52058
|
+
);
|
|
52059
|
+
}
|
|
52060
|
+
const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
|
|
52061
|
+
if (!validation.ok) {
|
|
52062
|
+
return mcpError(
|
|
52063
|
+
"usage",
|
|
52064
|
+
2,
|
|
52065
|
+
validation.error.message,
|
|
52066
|
+
{
|
|
52067
|
+
hint: validation.error.hint,
|
|
52068
|
+
context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }
|
|
52069
|
+
}
|
|
52070
|
+
);
|
|
52071
|
+
}
|
|
52072
|
+
if (validation.normalized) {
|
|
52073
|
+
effectiveCommand = validation.normalized;
|
|
51390
52074
|
}
|
|
51391
52075
|
if (effectiveType !== "customize") {
|
|
51392
|
-
const pv = validateParameter(
|
|
52076
|
+
const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
|
|
51393
52077
|
if (!pv.ok) {
|
|
51394
52078
|
return mcpError("usage", 2, pv.error, {
|
|
51395
|
-
|
|
51396
|
-
context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
|
|
52079
|
+
context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
|
|
51397
52080
|
});
|
|
51398
52081
|
}
|
|
51399
52082
|
if (pv.normalized !== void 0) {
|
|
51400
|
-
effectiveParameter = pv.normalized;
|
|
52083
|
+
effectiveParameter = parseParameterForWire(pv.normalized);
|
|
51401
52084
|
}
|
|
51402
52085
|
}
|
|
51403
|
-
|
|
51404
|
-
|
|
51405
|
-
|
|
51406
|
-
|
|
51407
|
-
|
|
51408
|
-
}
|
|
51409
|
-
|
|
51410
|
-
|
|
51411
|
-
|
|
52086
|
+
let result;
|
|
52087
|
+
try {
|
|
52088
|
+
result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, void 0, {
|
|
52089
|
+
idempotencyKey
|
|
52090
|
+
});
|
|
52091
|
+
} catch (err) {
|
|
52092
|
+
if (err instanceof Error && err.name === "IdempotencyConflictError") {
|
|
52093
|
+
return mcpError("guard", 2, err.message, {
|
|
52094
|
+
hint: "Use a fresh idempotencyKey, or wait for the prior key to expire (60s TTL).",
|
|
52095
|
+
context: {
|
|
52096
|
+
existingShape: err.existingShape,
|
|
52097
|
+
newShape: err.newShape
|
|
52098
|
+
}
|
|
52099
|
+
});
|
|
52100
|
+
}
|
|
52101
|
+
return apiErrorToMcpError(err);
|
|
52102
|
+
}
|
|
52103
|
+
const isIr = getCachedDevice(deviceId)?.category === "ir";
|
|
52104
|
+
const liveIsDestructive = destructive;
|
|
52105
|
+
const riskProfile = buildRiskProfile(typeName, effectiveCommand, effectiveType, liveIsDestructive);
|
|
52106
|
+
const structured = { ok: true, command: effectiveCommand, deviceId, result, riskProfile };
|
|
52107
|
+
if (isIr) {
|
|
52108
|
+
structured.verification = {
|
|
52109
|
+
verifiable: false,
|
|
52110
|
+
reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
|
|
52111
|
+
suggestedFollowup: "Confirm visible change manually or via a paired state sensor."
|
|
52112
|
+
};
|
|
52113
|
+
}
|
|
51412
52114
|
return {
|
|
51413
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51414
|
-
structuredContent:
|
|
52115
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52116
|
+
structuredContent: structured
|
|
51415
52117
|
};
|
|
51416
52118
|
}
|
|
51417
|
-
|
|
51418
|
-
|
|
51419
|
-
|
|
51420
|
-
|
|
51421
|
-
|
|
51422
|
-
|
|
51423
|
-
|
|
51424
|
-
|
|
51425
|
-
|
|
52119
|
+
);
|
|
52120
|
+
if (!skip("run_scene"))
|
|
52121
|
+
server.registerTool(
|
|
52122
|
+
"run_scene",
|
|
52123
|
+
{
|
|
52124
|
+
title: "Execute a manual scene",
|
|
52125
|
+
description: "Execute a manual SwitchBot scene by its sceneId (from list_scenes).",
|
|
52126
|
+
_meta: { agentSafetyTier: "action" },
|
|
52127
|
+
inputSchema: external_exports.object({
|
|
52128
|
+
sceneId: external_exports.string().describe("Scene ID from list_scenes"),
|
|
52129
|
+
dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
|
|
52130
|
+
}).strict(),
|
|
52131
|
+
outputSchema: {
|
|
52132
|
+
ok: external_exports.literal(true),
|
|
52133
|
+
sceneId: external_exports.string().optional(),
|
|
52134
|
+
dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
|
|
52135
|
+
wouldSend: external_exports.object({
|
|
52136
|
+
sceneId: external_exports.string()
|
|
52137
|
+
}).optional().describe("The request shape that would have been POSTed (present when dryRun:true)")
|
|
51426
52138
|
}
|
|
51427
|
-
|
|
51428
|
-
}
|
|
51429
|
-
|
|
51430
|
-
|
|
51431
|
-
|
|
51432
|
-
|
|
51433
|
-
|
|
51434
|
-
3,
|
|
51435
|
-
`Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
|
|
51436
|
-
{
|
|
51437
|
-
hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
|
|
51438
|
-
context: {
|
|
51439
|
-
command: effectiveCommand,
|
|
51440
|
-
deviceType: typeName,
|
|
51441
|
-
directExecutionAllowed: false,
|
|
51442
|
-
requiredWorkflow: "plan-approval",
|
|
51443
|
-
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
51444
|
-
}
|
|
51445
|
-
}
|
|
51446
|
-
);
|
|
51447
|
-
}
|
|
51448
|
-
if (destructive && !confirm) {
|
|
51449
|
-
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
51450
|
-
const entry = typeName ? findCatalogEntry(typeName) : null;
|
|
51451
|
-
const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
|
|
51452
|
-
const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
|
|
51453
|
-
return mcpError(
|
|
51454
|
-
"guard",
|
|
51455
|
-
3,
|
|
51456
|
-
`Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
|
|
51457
|
-
{
|
|
51458
|
-
hint,
|
|
51459
|
-
context: {
|
|
51460
|
-
command: effectiveCommand,
|
|
51461
|
-
deviceType: typeName,
|
|
51462
|
-
description: spec?.description ?? null,
|
|
51463
|
-
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
51464
|
-
}
|
|
52139
|
+
},
|
|
52140
|
+
async ({ sceneId, dryRun }) => {
|
|
52141
|
+
if (dryRun) {
|
|
52142
|
+
let scenes = [];
|
|
52143
|
+
try {
|
|
52144
|
+
scenes = await fetchScenes();
|
|
52145
|
+
} catch {
|
|
51465
52146
|
}
|
|
51466
|
-
|
|
51467
|
-
|
|
51468
|
-
|
|
51469
|
-
|
|
51470
|
-
|
|
51471
|
-
|
|
51472
|
-
|
|
51473
|
-
validation.error.message,
|
|
51474
|
-
{
|
|
51475
|
-
hint: validation.error.hint,
|
|
51476
|
-
context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand }
|
|
52147
|
+
const found = scenes.find((s2) => s2.sceneId === sceneId);
|
|
52148
|
+
if (scenes.length > 0 && !found) {
|
|
52149
|
+
return mcpError("usage", 2, `Scene not found: ${sceneId}`, {
|
|
52150
|
+
subKind: "scene-not-found",
|
|
52151
|
+
hint: "Check the sceneId with 'list_scenes' (IDs are case-sensitive).",
|
|
52152
|
+
context: { sceneId, candidates: scenes.map((s2) => ({ sceneId: s2.sceneId, sceneName: s2.sceneName })).slice(0, 5) }
|
|
52153
|
+
});
|
|
51477
52154
|
}
|
|
51478
|
-
|
|
51479
|
-
|
|
51480
|
-
|
|
51481
|
-
|
|
51482
|
-
|
|
51483
|
-
|
|
51484
|
-
const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
|
|
51485
|
-
if (!pv.ok) {
|
|
51486
|
-
return mcpError("usage", 2, pv.error, {
|
|
51487
|
-
context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
|
|
51488
|
-
});
|
|
52155
|
+
const wouldSend = { sceneId, sceneName: found?.sceneName ?? null };
|
|
52156
|
+
const structured2 = { ok: true, dryRun: true, wouldSend };
|
|
52157
|
+
return {
|
|
52158
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52159
|
+
structuredContent: structured2
|
|
52160
|
+
};
|
|
51489
52161
|
}
|
|
51490
|
-
|
|
51491
|
-
|
|
52162
|
+
try {
|
|
52163
|
+
await executeScene(sceneId);
|
|
52164
|
+
} catch (err) {
|
|
52165
|
+
return apiErrorToMcpError(err);
|
|
51492
52166
|
}
|
|
52167
|
+
const structured = { ok: true, sceneId };
|
|
52168
|
+
return {
|
|
52169
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52170
|
+
structuredContent: structured
|
|
52171
|
+
};
|
|
51493
52172
|
}
|
|
51494
|
-
|
|
51495
|
-
|
|
51496
|
-
|
|
51497
|
-
|
|
51498
|
-
|
|
51499
|
-
|
|
51500
|
-
|
|
51501
|
-
|
|
51502
|
-
|
|
51503
|
-
|
|
51504
|
-
|
|
51505
|
-
newShape: err.newShape
|
|
51506
|
-
}
|
|
51507
|
-
});
|
|
52173
|
+
);
|
|
52174
|
+
if (!skip("list_scenes"))
|
|
52175
|
+
server.registerTool(
|
|
52176
|
+
"list_scenes",
|
|
52177
|
+
{
|
|
52178
|
+
title: "List all manual scenes",
|
|
52179
|
+
description: "Fetch all manual scenes configured in the SwitchBot app.",
|
|
52180
|
+
_meta: { agentSafetyTier: "read" },
|
|
52181
|
+
inputSchema: external_exports.object({}).strict(),
|
|
52182
|
+
outputSchema: {
|
|
52183
|
+
scenes: external_exports.array(external_exports.object({ sceneId: external_exports.string(), sceneName: external_exports.string() }))
|
|
51508
52184
|
}
|
|
51509
|
-
|
|
51510
|
-
|
|
51511
|
-
|
|
51512
|
-
|
|
51513
|
-
|
|
51514
|
-
|
|
51515
|
-
if (isIr) {
|
|
51516
|
-
structured.verification = {
|
|
51517
|
-
verifiable: false,
|
|
51518
|
-
reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
|
|
51519
|
-
suggestedFollowup: "Confirm visible change manually or via a paired state sensor."
|
|
52185
|
+
},
|
|
52186
|
+
async () => {
|
|
52187
|
+
const scenes = await fetchScenes();
|
|
52188
|
+
return {
|
|
52189
|
+
content: [{ type: "text", text: JSON.stringify(scenes, null, 2) }],
|
|
52190
|
+
structuredContent: { scenes }
|
|
51520
52191
|
};
|
|
51521
52192
|
}
|
|
51522
|
-
|
|
51523
|
-
|
|
51524
|
-
|
|
51525
|
-
|
|
51526
|
-
|
|
51527
|
-
|
|
51528
|
-
|
|
51529
|
-
|
|
51530
|
-
|
|
51531
|
-
|
|
51532
|
-
|
|
51533
|
-
|
|
51534
|
-
|
|
51535
|
-
|
|
51536
|
-
|
|
51537
|
-
|
|
51538
|
-
|
|
51539
|
-
|
|
51540
|
-
|
|
51541
|
-
|
|
51542
|
-
|
|
51543
|
-
|
|
51544
|
-
|
|
51545
|
-
|
|
51546
|
-
|
|
51547
|
-
|
|
51548
|
-
|
|
51549
|
-
|
|
51550
|
-
|
|
51551
|
-
|
|
51552
|
-
|
|
52193
|
+
);
|
|
52194
|
+
if (!skip("search_catalog"))
|
|
52195
|
+
server.registerTool(
|
|
52196
|
+
"search_catalog",
|
|
52197
|
+
{
|
|
52198
|
+
title: "Search the offline device catalog",
|
|
52199
|
+
description: "Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.",
|
|
52200
|
+
_meta: { agentSafetyTier: "read" },
|
|
52201
|
+
inputSchema: external_exports.object({
|
|
52202
|
+
query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
|
|
52203
|
+
limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
|
|
52204
|
+
}).strict(),
|
|
52205
|
+
outputSchema: {
|
|
52206
|
+
results: external_exports.array(external_exports.object({
|
|
52207
|
+
type: external_exports.string(),
|
|
52208
|
+
category: external_exports.enum(["physical", "ir"]),
|
|
52209
|
+
commands: external_exports.array(external_exports.object({
|
|
52210
|
+
command: external_exports.string(),
|
|
52211
|
+
parameter: external_exports.string(),
|
|
52212
|
+
description: external_exports.string(),
|
|
52213
|
+
commandType: external_exports.enum(["command", "customize"]).optional(),
|
|
52214
|
+
idempotent: external_exports.boolean().optional(),
|
|
52215
|
+
safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
|
|
52216
|
+
safetyReason: external_exports.string().optional()
|
|
52217
|
+
}).passthrough()),
|
|
52218
|
+
aliases: external_exports.array(external_exports.string()).optional(),
|
|
52219
|
+
statusFields: external_exports.array(external_exports.string()).optional(),
|
|
52220
|
+
role: external_exports.string().optional(),
|
|
52221
|
+
readOnly: external_exports.boolean().optional()
|
|
52222
|
+
}).passthrough()).describe("Matching catalog entries"),
|
|
52223
|
+
total: external_exports.number().int().describe("Number of entries returned")
|
|
51553
52224
|
}
|
|
51554
|
-
|
|
51555
|
-
|
|
51556
|
-
|
|
51557
|
-
|
|
51558
|
-
|
|
51559
|
-
|
|
51560
|
-
|
|
52225
|
+
},
|
|
52226
|
+
async ({ query, limit }) => {
|
|
52227
|
+
if (query.trim() === "") {
|
|
52228
|
+
return mcpError(
|
|
52229
|
+
"usage",
|
|
52230
|
+
2,
|
|
52231
|
+
"search_catalog requires a non-empty query.",
|
|
52232
|
+
{
|
|
52233
|
+
hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
|
|
52234
|
+
}
|
|
52235
|
+
);
|
|
51561
52236
|
}
|
|
51562
|
-
const
|
|
51563
|
-
const
|
|
52237
|
+
const hits = searchCatalog(query, limit);
|
|
52238
|
+
const normalised = hits.map((e) => ({
|
|
52239
|
+
...e,
|
|
52240
|
+
commands: e.commands.map((c) => {
|
|
52241
|
+
const tier = deriveSafetyTier(c, e);
|
|
52242
|
+
const reason = getCommandSafetyReason(c);
|
|
52243
|
+
return {
|
|
52244
|
+
...c,
|
|
52245
|
+
safetyTier: tier,
|
|
52246
|
+
...reason ? { safetyReason: reason } : {}
|
|
52247
|
+
};
|
|
52248
|
+
})
|
|
52249
|
+
}));
|
|
52250
|
+
const structured = { results: normalised, total: normalised.length };
|
|
51564
52251
|
return {
|
|
51565
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51566
|
-
structuredContent:
|
|
52252
|
+
content: [{ type: "text", text: JSON.stringify(normalised, null, 2) }],
|
|
52253
|
+
structuredContent: structured
|
|
51567
52254
|
};
|
|
51568
52255
|
}
|
|
51569
|
-
|
|
51570
|
-
|
|
51571
|
-
|
|
51572
|
-
|
|
51573
|
-
|
|
51574
|
-
|
|
51575
|
-
|
|
51576
|
-
|
|
51577
|
-
|
|
51578
|
-
|
|
51579
|
-
|
|
51580
|
-
|
|
51581
|
-
|
|
51582
|
-
|
|
51583
|
-
|
|
51584
|
-
|
|
51585
|
-
|
|
51586
|
-
|
|
51587
|
-
|
|
51588
|
-
|
|
51589
|
-
|
|
51590
|
-
|
|
51591
|
-
|
|
51592
|
-
|
|
51593
|
-
|
|
51594
|
-
|
|
51595
|
-
|
|
51596
|
-
|
|
51597
|
-
|
|
51598
|
-
|
|
51599
|
-
|
|
51600
|
-
|
|
51601
|
-
|
|
51602
|
-
|
|
51603
|
-
|
|
51604
|
-
description: "Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.",
|
|
51605
|
-
_meta: { agentSafetyTier: "read" },
|
|
51606
|
-
inputSchema: external_exports.object({
|
|
51607
|
-
query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
|
|
51608
|
-
limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
|
|
51609
|
-
}).strict(),
|
|
51610
|
-
outputSchema: {
|
|
51611
|
-
results: external_exports.array(external_exports.object({
|
|
51612
|
-
type: external_exports.string(),
|
|
51613
|
-
category: external_exports.enum(["physical", "ir"]),
|
|
51614
|
-
commands: external_exports.array(external_exports.object({
|
|
51615
|
-
command: external_exports.string(),
|
|
51616
|
-
parameter: external_exports.string(),
|
|
51617
|
-
description: external_exports.string(),
|
|
51618
|
-
commandType: external_exports.enum(["command", "customize"]).optional(),
|
|
51619
|
-
idempotent: external_exports.boolean().optional(),
|
|
51620
|
-
safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
|
|
51621
|
-
safetyReason: external_exports.string().optional()
|
|
51622
|
-
}).passthrough()),
|
|
51623
|
-
aliases: external_exports.array(external_exports.string()).optional(),
|
|
51624
|
-
statusFields: external_exports.array(external_exports.string()).optional(),
|
|
51625
|
-
role: external_exports.string().optional(),
|
|
51626
|
-
readOnly: external_exports.boolean().optional()
|
|
51627
|
-
}).passthrough()).describe("Matching catalog entries"),
|
|
51628
|
-
total: external_exports.number().int().describe("Number of entries returned")
|
|
51629
|
-
}
|
|
51630
|
-
},
|
|
51631
|
-
async ({ query, limit }) => {
|
|
51632
|
-
if (query.trim() === "") {
|
|
51633
|
-
return mcpError(
|
|
51634
|
-
"usage",
|
|
51635
|
-
2,
|
|
51636
|
-
"search_catalog requires a non-empty query.",
|
|
51637
|
-
{
|
|
51638
|
-
hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
|
|
51639
|
-
}
|
|
51640
|
-
);
|
|
51641
|
-
}
|
|
51642
|
-
const hits = searchCatalog(query, limit);
|
|
51643
|
-
const normalised = hits.map((e) => ({
|
|
51644
|
-
...e,
|
|
51645
|
-
commands: e.commands.map((c) => {
|
|
51646
|
-
const tier = deriveSafetyTier(c, e);
|
|
51647
|
-
const reason = getCommandSafetyReason(c);
|
|
52256
|
+
);
|
|
52257
|
+
if (!skip("describe_device"))
|
|
52258
|
+
server.registerTool(
|
|
52259
|
+
"describe_device",
|
|
52260
|
+
{
|
|
52261
|
+
title: "Describe a specific device",
|
|
52262
|
+
description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
|
|
52263
|
+
_meta: { agentSafetyTier: "read" },
|
|
52264
|
+
inputSchema: external_exports.object({
|
|
52265
|
+
deviceId: external_exports.string().describe("Device ID from list_devices"),
|
|
52266
|
+
live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
|
|
52267
|
+
}).strict(),
|
|
52268
|
+
outputSchema: {
|
|
52269
|
+
device: external_exports.object({
|
|
52270
|
+
device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
|
|
52271
|
+
isPhysical: external_exports.boolean(),
|
|
52272
|
+
typeName: external_exports.string(),
|
|
52273
|
+
controlType: external_exports.string().nullable(),
|
|
52274
|
+
source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
|
|
52275
|
+
capabilities: external_exports.unknown().nullable(),
|
|
52276
|
+
suggestedActions: external_exports.array(external_exports.object({
|
|
52277
|
+
command: external_exports.string(),
|
|
52278
|
+
parameter: external_exports.string().optional(),
|
|
52279
|
+
description: external_exports.string()
|
|
52280
|
+
})).optional(),
|
|
52281
|
+
inheritedLocation: external_exports.object({
|
|
52282
|
+
family: external_exports.string().optional(),
|
|
52283
|
+
room: external_exports.string().optional()
|
|
52284
|
+
}).optional()
|
|
52285
|
+
}).passthrough().describe("Device metadata, catalog entry, capabilities, and optional live status")
|
|
52286
|
+
}
|
|
52287
|
+
},
|
|
52288
|
+
async ({ deviceId, live }) => {
|
|
52289
|
+
try {
|
|
52290
|
+
const result = await describeDevice(deviceId, { live });
|
|
51648
52291
|
return {
|
|
51649
|
-
|
|
51650
|
-
|
|
51651
|
-
...reason ? { safetyReason: reason } : {}
|
|
52292
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
52293
|
+
structuredContent: { device: toMcpDescribeShape(result) }
|
|
51652
52294
|
};
|
|
51653
|
-
})
|
|
51654
|
-
|
|
51655
|
-
|
|
51656
|
-
|
|
51657
|
-
|
|
51658
|
-
|
|
51659
|
-
|
|
51660
|
-
|
|
51661
|
-
|
|
51662
|
-
server.registerTool(
|
|
51663
|
-
"describe_device",
|
|
51664
|
-
{
|
|
51665
|
-
title: "Describe a specific device",
|
|
51666
|
-
description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
|
|
51667
|
-
_meta: { agentSafetyTier: "read" },
|
|
51668
|
-
inputSchema: external_exports.object({
|
|
51669
|
-
deviceId: external_exports.string().describe("Device ID from list_devices"),
|
|
51670
|
-
live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
|
|
51671
|
-
}).strict(),
|
|
51672
|
-
outputSchema: {
|
|
51673
|
-
device: external_exports.object({
|
|
51674
|
-
device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
|
|
51675
|
-
isPhysical: external_exports.boolean(),
|
|
51676
|
-
typeName: external_exports.string(),
|
|
51677
|
-
controlType: external_exports.string().nullable(),
|
|
51678
|
-
source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
|
|
51679
|
-
capabilities: external_exports.unknown().nullable(),
|
|
51680
|
-
suggestedActions: external_exports.array(external_exports.object({
|
|
51681
|
-
command: external_exports.string(),
|
|
51682
|
-
parameter: external_exports.string().optional(),
|
|
51683
|
-
description: external_exports.string()
|
|
51684
|
-
})).optional(),
|
|
51685
|
-
inheritedLocation: external_exports.object({
|
|
51686
|
-
family: external_exports.string().optional(),
|
|
51687
|
-
room: external_exports.string().optional()
|
|
51688
|
-
}).optional()
|
|
51689
|
-
}).passthrough().describe("Device metadata, catalog entry, capabilities, and optional live status")
|
|
52295
|
+
} catch (err) {
|
|
52296
|
+
if (err instanceof DeviceNotFoundError) {
|
|
52297
|
+
return mcpError("runtime", 152, err.message, {
|
|
52298
|
+
hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
|
|
52299
|
+
context: { deviceId }
|
|
52300
|
+
});
|
|
52301
|
+
}
|
|
52302
|
+
return apiErrorToMcpError(err);
|
|
52303
|
+
}
|
|
51690
52304
|
}
|
|
51691
|
-
|
|
51692
|
-
|
|
51693
|
-
|
|
51694
|
-
|
|
52305
|
+
);
|
|
52306
|
+
if (!skip("aggregate_device_history"))
|
|
52307
|
+
server.registerTool(
|
|
52308
|
+
"aggregate_device_history",
|
|
52309
|
+
{
|
|
52310
|
+
title: "Aggregate device history",
|
|
52311
|
+
description: "Bucketed statistics (count/min/max/avg/sum/p50/p95) over JSONL-recorded device history. Read-only; no network calls.",
|
|
52312
|
+
_meta: { agentSafetyTier: "read" },
|
|
52313
|
+
inputSchema: external_exports.object({
|
|
52314
|
+
deviceId: external_exports.string().min(1).describe("Device ID to aggregate over (must exist in ~/.switchbot/device-history/)."),
|
|
52315
|
+
since: external_exports.string().optional().describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'),
|
|
52316
|
+
from: external_exports.string().optional().describe("Range start (ISO-8601). Requires `to`."),
|
|
52317
|
+
to: external_exports.string().optional().describe("Range end (ISO-8601). Requires `from`."),
|
|
52318
|
+
metrics: external_exports.array(external_exports.string().min(1)).min(1).describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'),
|
|
52319
|
+
aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).optional().describe('Aggregation functions to apply per metric (default: ["count","avg"]).'),
|
|
52320
|
+
bucket: external_exports.string().optional().describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'),
|
|
52321
|
+
maxBucketSamples: external_exports.number().int().positive().max(MAX_SAMPLE_CAP).optional().describe(`Sample cap per bucket to bound memory (default ${1e4}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`)
|
|
52322
|
+
}).strict(),
|
|
52323
|
+
outputSchema: {
|
|
52324
|
+
deviceId: external_exports.string(),
|
|
52325
|
+
bucket: external_exports.string().optional().describe("Bucket width echoed back when specified; omitted for single-bucket results."),
|
|
52326
|
+
from: external_exports.string().describe("Effective range start (ISO-8601)."),
|
|
52327
|
+
to: external_exports.string().describe("Effective range end (ISO-8601)."),
|
|
52328
|
+
metrics: external_exports.array(external_exports.string()).describe("Metrics that were requested."),
|
|
52329
|
+
aggs: external_exports.array(external_exports.enum(ALL_AGG_FNS)).describe("Aggregation functions that were applied."),
|
|
52330
|
+
buckets: external_exports.array(
|
|
52331
|
+
external_exports.object({
|
|
52332
|
+
t: external_exports.string().describe("Bucket start timestamp (ISO-8601)."),
|
|
52333
|
+
metrics: external_exports.record(
|
|
52334
|
+
external_exports.string(),
|
|
52335
|
+
external_exports.object({
|
|
52336
|
+
count: external_exports.number().optional(),
|
|
52337
|
+
min: external_exports.number().optional(),
|
|
52338
|
+
max: external_exports.number().optional(),
|
|
52339
|
+
avg: external_exports.number().optional(),
|
|
52340
|
+
sum: external_exports.number().optional(),
|
|
52341
|
+
p50: external_exports.number().optional(),
|
|
52342
|
+
p95: external_exports.number().optional()
|
|
52343
|
+
}).describe("Per-aggregate function result for this metric in this bucket.")
|
|
52344
|
+
).describe("Per-metric result keyed by metric name.")
|
|
52345
|
+
})
|
|
52346
|
+
).describe("Time-ordered buckets; empty when no records match."),
|
|
52347
|
+
partial: external_exports.boolean().describe("True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values."),
|
|
52348
|
+
notes: external_exports.array(external_exports.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").')
|
|
52349
|
+
}
|
|
52350
|
+
},
|
|
52351
|
+
async (args) => {
|
|
52352
|
+
const opts = {
|
|
52353
|
+
since: args.since,
|
|
52354
|
+
from: args.from,
|
|
52355
|
+
to: args.to,
|
|
52356
|
+
metrics: args.metrics,
|
|
52357
|
+
aggs: args.aggs,
|
|
52358
|
+
bucket: args.bucket,
|
|
52359
|
+
maxBucketSamples: args.maxBucketSamples
|
|
52360
|
+
};
|
|
52361
|
+
const res = await aggregateDeviceHistory(args.deviceId, opts);
|
|
52362
|
+
const structured = {
|
|
52363
|
+
deviceId: res.deviceId,
|
|
52364
|
+
from: res.from,
|
|
52365
|
+
to: res.to,
|
|
52366
|
+
metrics: res.metrics,
|
|
52367
|
+
aggs: res.aggs,
|
|
52368
|
+
buckets: res.buckets,
|
|
52369
|
+
partial: res.partial,
|
|
52370
|
+
notes: res.notes
|
|
52371
|
+
};
|
|
52372
|
+
if (res.bucket !== void 0) structured.bucket = res.bucket;
|
|
51695
52373
|
return {
|
|
51696
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51697
|
-
structuredContent:
|
|
52374
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
52375
|
+
structuredContent: structured
|
|
51698
52376
|
};
|
|
51699
|
-
} catch (err) {
|
|
51700
|
-
if (err instanceof DeviceNotFoundError) {
|
|
51701
|
-
return mcpError("runtime", 152, err.message, {
|
|
51702
|
-
hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
|
|
51703
|
-
context: { deviceId }
|
|
51704
|
-
});
|
|
51705
|
-
}
|
|
51706
|
-
return apiErrorToMcpError(err);
|
|
51707
52377
|
}
|
|
51708
|
-
|
|
51709
|
-
)
|
|
51710
|
-
|
|
51711
|
-
|
|
51712
|
-
|
|
51713
|
-
|
|
51714
|
-
|
|
51715
|
-
|
|
51716
|
-
|
|
51717
|
-
|
|
51718
|
-
|
|
51719
|
-
|
|
51720
|
-
|
|
51721
|
-
|
|
51722
|
-
|
|
51723
|
-
|
|
51724
|
-
|
|
51725
|
-
|
|
51726
|
-
|
|
51727
|
-
|
|
51728
|
-
|
|
51729
|
-
|
|
51730
|
-
|
|
51731
|
-
|
|
51732
|
-
|
|
51733
|
-
|
|
51734
|
-
external_exports.object({
|
|
51735
|
-
|
|
51736
|
-
|
|
51737
|
-
|
|
51738
|
-
|
|
51739
|
-
|
|
51740
|
-
|
|
51741
|
-
|
|
51742
|
-
|
|
51743
|
-
|
|
51744
|
-
|
|
51745
|
-
|
|
51746
|
-
|
|
51747
|
-
)
|
|
51748
|
-
|
|
51749
|
-
|
|
51750
|
-
|
|
51751
|
-
|
|
52378
|
+
);
|
|
52379
|
+
if (!skip("account_overview"))
|
|
52380
|
+
server.registerTool(
|
|
52381
|
+
"account_overview",
|
|
52382
|
+
{
|
|
52383
|
+
title: "Bootstrap account overview",
|
|
52384
|
+
description: "Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.",
|
|
52385
|
+
_meta: { agentSafetyTier: "read" },
|
|
52386
|
+
inputSchema: external_exports.object({}).strict(),
|
|
52387
|
+
outputSchema: {
|
|
52388
|
+
version: external_exports.string(),
|
|
52389
|
+
schemaVersion: external_exports.string(),
|
|
52390
|
+
devices: external_exports.array(external_exports.object({
|
|
52391
|
+
deviceId: external_exports.string(),
|
|
52392
|
+
deviceName: external_exports.string(),
|
|
52393
|
+
deviceType: external_exports.string().optional()
|
|
52394
|
+
}).passthrough()).describe("All physical devices"),
|
|
52395
|
+
infraredRemotes: external_exports.array(external_exports.object({
|
|
52396
|
+
deviceId: external_exports.string(),
|
|
52397
|
+
deviceName: external_exports.string(),
|
|
52398
|
+
remoteType: external_exports.string()
|
|
52399
|
+
}).passthrough()).describe("All IR remotes"),
|
|
52400
|
+
scenes: external_exports.array(external_exports.object({
|
|
52401
|
+
sceneId: external_exports.string(),
|
|
52402
|
+
sceneName: external_exports.string()
|
|
52403
|
+
}).passthrough()).describe("All manual scenes"),
|
|
52404
|
+
quota: external_exports.object({
|
|
52405
|
+
date: external_exports.string(),
|
|
52406
|
+
total: external_exports.number(),
|
|
52407
|
+
remaining: external_exports.number(),
|
|
52408
|
+
endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
|
|
52409
|
+
}).describe("Today's quota usage"),
|
|
52410
|
+
cache: external_exports.object({
|
|
52411
|
+
list: external_exports.object({
|
|
52412
|
+
path: external_exports.string(),
|
|
52413
|
+
exists: external_exports.boolean(),
|
|
52414
|
+
lastUpdated: external_exports.string().optional(),
|
|
52415
|
+
ageMs: external_exports.number().optional(),
|
|
52416
|
+
deviceCount: external_exports.number().optional()
|
|
52417
|
+
}),
|
|
52418
|
+
status: external_exports.object({
|
|
52419
|
+
path: external_exports.string(),
|
|
52420
|
+
exists: external_exports.boolean(),
|
|
52421
|
+
entryCount: external_exports.number(),
|
|
52422
|
+
oldestFetchedAt: external_exports.string().optional(),
|
|
52423
|
+
newestFetchedAt: external_exports.string().optional()
|
|
52424
|
+
})
|
|
52425
|
+
}).describe("Cache status"),
|
|
52426
|
+
mqtt: external_exports.object({
|
|
52427
|
+
state: external_exports.string(),
|
|
52428
|
+
subscribers: external_exports.number()
|
|
52429
|
+
}).optional().describe("MQTT connection state (present when REST credentials are configured; auto-provisioned via POST /v1.1/iot/credential)")
|
|
52430
|
+
}
|
|
52431
|
+
},
|
|
52432
|
+
async () => {
|
|
52433
|
+
const deviceList = await fetchDeviceList();
|
|
52434
|
+
const sceneList = await fetchScenes();
|
|
52435
|
+
const cacheInfo = describeCache();
|
|
52436
|
+
const quota = todayUsage();
|
|
52437
|
+
const overview = {
|
|
52438
|
+
version: VERSION,
|
|
52439
|
+
schemaVersion: "1.1",
|
|
52440
|
+
devices: deviceList.deviceList.map(toMcpDeviceListShape),
|
|
52441
|
+
infraredRemotes: deviceList.infraredRemoteList.map(toMcpIrDeviceShape),
|
|
52442
|
+
scenes: sceneList.map((s2) => ({
|
|
52443
|
+
sceneId: s2.sceneId,
|
|
52444
|
+
sceneName: s2.sceneName
|
|
52445
|
+
})),
|
|
52446
|
+
quota: {
|
|
52447
|
+
date: quota.date,
|
|
52448
|
+
total: quota.total,
|
|
52449
|
+
remaining: quota.remaining,
|
|
52450
|
+
endpoints: quota.endpoints
|
|
52451
|
+
},
|
|
52452
|
+
cache: {
|
|
52453
|
+
list: cacheInfo.list,
|
|
52454
|
+
status: cacheInfo.status
|
|
52455
|
+
},
|
|
52456
|
+
...eventManager ? {
|
|
52457
|
+
mqtt: {
|
|
52458
|
+
state: eventManager.getState(),
|
|
52459
|
+
subscribers: eventManager.getSubscriberCount()
|
|
52460
|
+
}
|
|
52461
|
+
} : {}
|
|
52462
|
+
};
|
|
52463
|
+
return {
|
|
52464
|
+
content: [{
|
|
52465
|
+
type: "text",
|
|
52466
|
+
text: JSON.stringify(overview, null, 2)
|
|
52467
|
+
}],
|
|
52468
|
+
structuredContent: overview
|
|
52469
|
+
};
|
|
51752
52470
|
}
|
|
51753
|
-
|
|
51754
|
-
|
|
51755
|
-
|
|
51756
|
-
|
|
51757
|
-
|
|
51758
|
-
|
|
51759
|
-
|
|
51760
|
-
|
|
51761
|
-
|
|
51762
|
-
|
|
51763
|
-
|
|
51764
|
-
|
|
51765
|
-
|
|
51766
|
-
|
|
51767
|
-
|
|
51768
|
-
|
|
51769
|
-
|
|
51770
|
-
|
|
51771
|
-
|
|
51772
|
-
|
|
51773
|
-
notes: res.notes
|
|
51774
|
-
};
|
|
51775
|
-
if (res.bucket !== void 0) structured.bucket = res.bucket;
|
|
51776
|
-
return {
|
|
51777
|
-
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
51778
|
-
structuredContent: structured
|
|
51779
|
-
};
|
|
51780
|
-
}
|
|
51781
|
-
);
|
|
51782
|
-
server.registerTool(
|
|
51783
|
-
"account_overview",
|
|
51784
|
-
{
|
|
51785
|
-
title: "Bootstrap account overview",
|
|
51786
|
-
description: "Get a complete account snapshot: devices, scenes, quota usage, cache status, and MQTT connection state. Use this for cold-start initialization or periodic health checks.",
|
|
51787
|
-
_meta: { agentSafetyTier: "read" },
|
|
51788
|
-
inputSchema: external_exports.object({}).strict(),
|
|
51789
|
-
outputSchema: {
|
|
51790
|
-
version: external_exports.string(),
|
|
51791
|
-
schemaVersion: external_exports.string(),
|
|
51792
|
-
devices: external_exports.array(external_exports.object({
|
|
51793
|
-
deviceId: external_exports.string(),
|
|
51794
|
-
deviceName: external_exports.string(),
|
|
51795
|
-
deviceType: external_exports.string().optional()
|
|
51796
|
-
}).passthrough()).describe("All physical devices"),
|
|
51797
|
-
infraredRemotes: external_exports.array(external_exports.object({
|
|
51798
|
-
deviceId: external_exports.string(),
|
|
51799
|
-
deviceName: external_exports.string(),
|
|
51800
|
-
remoteType: external_exports.string()
|
|
51801
|
-
}).passthrough()).describe("All IR remotes"),
|
|
51802
|
-
scenes: external_exports.array(external_exports.object({
|
|
51803
|
-
sceneId: external_exports.string(),
|
|
51804
|
-
sceneName: external_exports.string()
|
|
51805
|
-
}).passthrough()).describe("All manual scenes"),
|
|
51806
|
-
quota: external_exports.object({
|
|
51807
|
-
date: external_exports.string(),
|
|
51808
|
-
total: external_exports.number(),
|
|
51809
|
-
remaining: external_exports.number(),
|
|
51810
|
-
endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
|
|
51811
|
-
}).describe("Today's quota usage"),
|
|
51812
|
-
cache: external_exports.object({
|
|
51813
|
-
list: external_exports.object({
|
|
51814
|
-
path: external_exports.string(),
|
|
51815
|
-
exists: external_exports.boolean(),
|
|
51816
|
-
lastUpdated: external_exports.string().optional(),
|
|
51817
|
-
ageMs: external_exports.number().optional(),
|
|
51818
|
-
deviceCount: external_exports.number().optional()
|
|
51819
|
-
}),
|
|
51820
|
-
status: external_exports.object({
|
|
52471
|
+
);
|
|
52472
|
+
if (!skip("policy_validate"))
|
|
52473
|
+
server.registerTool(
|
|
52474
|
+
"policy_validate",
|
|
52475
|
+
{
|
|
52476
|
+
title: "Validate a policy.yaml file",
|
|
52477
|
+
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.",
|
|
52478
|
+
_meta: { agentSafetyTier: "read" },
|
|
52479
|
+
inputSchema: external_exports.object({
|
|
52480
|
+
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
52481
|
+
live: external_exports.boolean().optional().describe("When true, also resolve aliases and rule targets against the current account inventory")
|
|
52482
|
+
}).strict(),
|
|
52483
|
+
outputSchema: {
|
|
52484
|
+
policyPath: external_exports.string(),
|
|
52485
|
+
schemaVersion: external_exports.string(),
|
|
52486
|
+
validationScope: external_exports.string(),
|
|
52487
|
+
limitations: external_exports.array(external_exports.string()),
|
|
52488
|
+
present: external_exports.boolean().describe("false when the file does not exist"),
|
|
52489
|
+
valid: external_exports.boolean().nullable().describe("null when present=false"),
|
|
52490
|
+
errors: external_exports.array(external_exports.object({
|
|
51821
52491
|
path: external_exports.string(),
|
|
51822
|
-
|
|
51823
|
-
|
|
51824
|
-
|
|
51825
|
-
|
|
51826
|
-
|
|
51827
|
-
|
|
51828
|
-
|
|
51829
|
-
|
|
51830
|
-
|
|
51831
|
-
|
|
51832
|
-
|
|
51833
|
-
|
|
51834
|
-
|
|
51835
|
-
|
|
51836
|
-
|
|
51837
|
-
|
|
51838
|
-
|
|
51839
|
-
|
|
51840
|
-
|
|
51841
|
-
|
|
51842
|
-
|
|
51843
|
-
|
|
51844
|
-
scenes: sceneList.map((s2) => ({
|
|
51845
|
-
sceneId: s2.sceneId,
|
|
51846
|
-
sceneName: s2.sceneName
|
|
51847
|
-
})),
|
|
51848
|
-
quota: {
|
|
51849
|
-
date: quota.date,
|
|
51850
|
-
total: quota.total,
|
|
51851
|
-
remaining: quota.remaining,
|
|
51852
|
-
endpoints: quota.endpoints
|
|
51853
|
-
},
|
|
51854
|
-
cache: {
|
|
51855
|
-
list: cacheInfo.list,
|
|
51856
|
-
status: cacheInfo.status
|
|
51857
|
-
},
|
|
51858
|
-
...eventManager ? {
|
|
51859
|
-
mqtt: {
|
|
51860
|
-
state: eventManager.getState(),
|
|
51861
|
-
subscribers: eventManager.getSubscriberCount()
|
|
52492
|
+
line: external_exports.number().optional(),
|
|
52493
|
+
col: external_exports.number().optional(),
|
|
52494
|
+
keyword: external_exports.string(),
|
|
52495
|
+
message: external_exports.string(),
|
|
52496
|
+
hint: external_exports.string().optional(),
|
|
52497
|
+
schemaPath: external_exports.string()
|
|
52498
|
+
})).describe("Empty when valid or when the file is missing")
|
|
52499
|
+
}
|
|
52500
|
+
},
|
|
52501
|
+
async ({ path: pathArg, live }) => {
|
|
52502
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
52503
|
+
try {
|
|
52504
|
+
const loaded = loadPolicyFile(policyPath);
|
|
52505
|
+
let result = validateLoadedPolicy(loaded);
|
|
52506
|
+
if (live) {
|
|
52507
|
+
if (!tryLoadConfig()) {
|
|
52508
|
+
return mcpError("runtime", 151, "policy_validate live=true requires configured SwitchBot credentials.", {
|
|
52509
|
+
hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET."
|
|
52510
|
+
});
|
|
52511
|
+
}
|
|
52512
|
+
const inventory = await fetchDeviceList(void 0, { bypassCache: true });
|
|
52513
|
+
result = validateLoadedPolicyAgainstInventory(loaded, inventory);
|
|
51862
52514
|
}
|
|
51863
|
-
|
|
51864
|
-
|
|
51865
|
-
|
|
51866
|
-
|
|
51867
|
-
|
|
51868
|
-
|
|
51869
|
-
|
|
51870
|
-
|
|
51871
|
-
|
|
51872
|
-
|
|
51873
|
-
|
|
51874
|
-
|
|
51875
|
-
|
|
51876
|
-
|
|
51877
|
-
|
|
51878
|
-
|
|
51879
|
-
|
|
51880
|
-
|
|
51881
|
-
|
|
51882
|
-
|
|
51883
|
-
|
|
51884
|
-
|
|
51885
|
-
|
|
51886
|
-
|
|
51887
|
-
|
|
51888
|
-
|
|
51889
|
-
|
|
51890
|
-
|
|
51891
|
-
|
|
51892
|
-
|
|
51893
|
-
|
|
51894
|
-
|
|
51895
|
-
|
|
51896
|
-
|
|
51897
|
-
|
|
51898
|
-
|
|
51899
|
-
|
|
51900
|
-
|
|
51901
|
-
|
|
51902
|
-
|
|
51903
|
-
|
|
51904
|
-
|
|
51905
|
-
|
|
51906
|
-
|
|
51907
|
-
|
|
51908
|
-
|
|
51909
|
-
|
|
51910
|
-
|
|
51911
|
-
|
|
52515
|
+
const structured = {
|
|
52516
|
+
policyPath: result.policyPath,
|
|
52517
|
+
schemaVersion: result.schemaVersion,
|
|
52518
|
+
validationScope: result.validationScope,
|
|
52519
|
+
limitations: result.limitations,
|
|
52520
|
+
present: true,
|
|
52521
|
+
valid: result.valid,
|
|
52522
|
+
errors: result.errors
|
|
52523
|
+
};
|
|
52524
|
+
return {
|
|
52525
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52526
|
+
structuredContent: structured
|
|
52527
|
+
};
|
|
52528
|
+
} catch (err) {
|
|
52529
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
52530
|
+
const structured = {
|
|
52531
|
+
policyPath,
|
|
52532
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
52533
|
+
validationScope: "schema+offline-semantics",
|
|
52534
|
+
limitations: [
|
|
52535
|
+
"Does not resolve aliases against the live device inventory.",
|
|
52536
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
52537
|
+
],
|
|
52538
|
+
present: false,
|
|
52539
|
+
valid: null,
|
|
52540
|
+
errors: []
|
|
52541
|
+
};
|
|
52542
|
+
return {
|
|
52543
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52544
|
+
structuredContent: structured
|
|
52545
|
+
};
|
|
52546
|
+
}
|
|
52547
|
+
if (err instanceof PolicyYamlParseError) {
|
|
52548
|
+
const structured = {
|
|
52549
|
+
policyPath,
|
|
52550
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
52551
|
+
validationScope: "schema+offline-semantics",
|
|
52552
|
+
limitations: [
|
|
52553
|
+
"Does not resolve aliases against the live device inventory.",
|
|
52554
|
+
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
52555
|
+
],
|
|
52556
|
+
present: true,
|
|
52557
|
+
valid: false,
|
|
52558
|
+
errors: err.yamlErrors.map((e) => ({
|
|
52559
|
+
path: "",
|
|
52560
|
+
line: e.line,
|
|
52561
|
+
col: e.col,
|
|
52562
|
+
keyword: "yaml-parse",
|
|
52563
|
+
message: e.message,
|
|
52564
|
+
schemaPath: ""
|
|
52565
|
+
}))
|
|
52566
|
+
};
|
|
52567
|
+
return {
|
|
52568
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52569
|
+
structuredContent: structured
|
|
52570
|
+
};
|
|
51912
52571
|
}
|
|
51913
|
-
|
|
51914
|
-
result = validateLoadedPolicyAgainstInventory(loaded, inventory);
|
|
52572
|
+
throw err;
|
|
51915
52573
|
}
|
|
52574
|
+
}
|
|
52575
|
+
);
|
|
52576
|
+
if (!skip("policy_new"))
|
|
52577
|
+
server.registerTool(
|
|
52578
|
+
"policy_new",
|
|
52579
|
+
{
|
|
52580
|
+
title: "Scaffold a starter policy.yaml",
|
|
52581
|
+
description: "Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. This is a write action: the agent should only call it after confirming with the user.",
|
|
52582
|
+
_meta: { agentSafetyTier: "action" },
|
|
52583
|
+
inputSchema: external_exports.object({
|
|
52584
|
+
path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
|
|
52585
|
+
force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
|
|
52586
|
+
}).strict(),
|
|
52587
|
+
outputSchema: {
|
|
52588
|
+
policyPath: external_exports.string(),
|
|
52589
|
+
schemaVersion: external_exports.string(),
|
|
52590
|
+
bytesWritten: external_exports.number(),
|
|
52591
|
+
overwritten: external_exports.boolean()
|
|
52592
|
+
}
|
|
52593
|
+
},
|
|
52594
|
+
async ({ path: pathArg, force }) => {
|
|
52595
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
52596
|
+
const doForce = force === true;
|
|
52597
|
+
if (fs18.existsSync(policyPath) && !doForce) {
|
|
52598
|
+
return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
|
|
52599
|
+
hint: "pass force=true to overwrite, or choose a different path",
|
|
52600
|
+
context: { policyPath }
|
|
52601
|
+
});
|
|
52602
|
+
}
|
|
52603
|
+
const template = readPolicyExampleYaml();
|
|
52604
|
+
fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
|
|
52605
|
+
fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
|
|
51916
52606
|
const structured = {
|
|
51917
|
-
policyPath
|
|
51918
|
-
schemaVersion:
|
|
51919
|
-
|
|
51920
|
-
|
|
51921
|
-
present: true,
|
|
51922
|
-
valid: result.valid,
|
|
51923
|
-
errors: result.errors
|
|
52607
|
+
policyPath,
|
|
52608
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
52609
|
+
bytesWritten: Buffer.byteLength(template, "utf-8"),
|
|
52610
|
+
overwritten: doForce
|
|
51924
52611
|
};
|
|
51925
52612
|
return {
|
|
51926
52613
|
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
51927
52614
|
structuredContent: structured
|
|
51928
52615
|
};
|
|
51929
|
-
}
|
|
51930
|
-
|
|
51931
|
-
|
|
51932
|
-
|
|
51933
|
-
|
|
51934
|
-
|
|
51935
|
-
|
|
51936
|
-
|
|
51937
|
-
|
|
51938
|
-
|
|
51939
|
-
|
|
51940
|
-
|
|
51941
|
-
|
|
52616
|
+
}
|
|
52617
|
+
);
|
|
52618
|
+
if (!skip("policy_migrate"))
|
|
52619
|
+
server.registerTool(
|
|
52620
|
+
"policy_migrate",
|
|
52621
|
+
{
|
|
52622
|
+
title: "Migrate a policy file to the latest supported schema",
|
|
52623
|
+
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.',
|
|
52624
|
+
_meta: { agentSafetyTier: "action" },
|
|
52625
|
+
inputSchema: external_exports.object({
|
|
52626
|
+
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
52627
|
+
dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
|
|
52628
|
+
to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
|
|
52629
|
+
}).strict(),
|
|
52630
|
+
outputSchema: {
|
|
52631
|
+
policyPath: external_exports.string(),
|
|
52632
|
+
fileVersion: external_exports.string().optional(),
|
|
52633
|
+
targetVersion: external_exports.string(),
|
|
52634
|
+
supportedVersions: external_exports.array(external_exports.string()),
|
|
52635
|
+
status: external_exports.enum([
|
|
52636
|
+
"already-current",
|
|
52637
|
+
"migrated",
|
|
52638
|
+
"dry-run",
|
|
52639
|
+
"no-version-field",
|
|
52640
|
+
"unsupported",
|
|
52641
|
+
"precheck-failed",
|
|
52642
|
+
"file-not-found"
|
|
52643
|
+
]),
|
|
52644
|
+
from: external_exports.string().optional(),
|
|
52645
|
+
to: external_exports.string().optional(),
|
|
52646
|
+
bytesWritten: external_exports.number().optional(),
|
|
52647
|
+
message: external_exports.string(),
|
|
52648
|
+
errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
|
|
52649
|
+
}
|
|
52650
|
+
},
|
|
52651
|
+
async ({ path: pathArg, dryRun, to }) => {
|
|
52652
|
+
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
52653
|
+
const target = to ?? LATEST_SUPPORTED_VERSION;
|
|
52654
|
+
let loaded;
|
|
52655
|
+
try {
|
|
52656
|
+
loaded = loadPolicyFile(policyPath);
|
|
52657
|
+
} catch (err) {
|
|
52658
|
+
if (err instanceof PolicyFileNotFoundError) {
|
|
52659
|
+
const structured2 = {
|
|
52660
|
+
policyPath,
|
|
52661
|
+
targetVersion: target,
|
|
52662
|
+
supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS],
|
|
52663
|
+
status: "file-not-found",
|
|
52664
|
+
message: `policy file not found: ${policyPath}`
|
|
52665
|
+
};
|
|
52666
|
+
return {
|
|
52667
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52668
|
+
structuredContent: structured2
|
|
52669
|
+
};
|
|
52670
|
+
}
|
|
52671
|
+
throw err;
|
|
52672
|
+
}
|
|
52673
|
+
const data = loaded.data;
|
|
52674
|
+
const fileVersion = typeof data?.version === "string" ? data.version : void 0;
|
|
52675
|
+
const base = {
|
|
52676
|
+
policyPath,
|
|
52677
|
+
fileVersion,
|
|
52678
|
+
targetVersion: target,
|
|
52679
|
+
supportedVersions: [...SUPPORTED_POLICY_SCHEMA_VERSIONS]
|
|
52680
|
+
};
|
|
52681
|
+
if (!fileVersion) {
|
|
52682
|
+
const structured2 = {
|
|
52683
|
+
...base,
|
|
52684
|
+
status: "no-version-field",
|
|
52685
|
+
message: `policy has no \`version\` field \u2014 add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\``
|
|
51942
52686
|
};
|
|
51943
52687
|
return {
|
|
51944
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51945
|
-
structuredContent:
|
|
52688
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52689
|
+
structuredContent: structured2
|
|
51946
52690
|
};
|
|
51947
52691
|
}
|
|
51948
|
-
if (
|
|
51949
|
-
const
|
|
51950
|
-
|
|
51951
|
-
|
|
51952
|
-
|
|
51953
|
-
|
|
51954
|
-
"Does not resolve aliases against the live device inventory.",
|
|
51955
|
-
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
51956
|
-
],
|
|
51957
|
-
present: true,
|
|
51958
|
-
valid: false,
|
|
51959
|
-
errors: err.yamlErrors.map((e) => ({
|
|
51960
|
-
path: "",
|
|
51961
|
-
line: e.line,
|
|
51962
|
-
col: e.col,
|
|
51963
|
-
keyword: "yaml-parse",
|
|
51964
|
-
message: e.message,
|
|
51965
|
-
schemaPath: ""
|
|
51966
|
-
}))
|
|
52692
|
+
if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion)) {
|
|
52693
|
+
const isLegacy = fileVersion === "0.1";
|
|
52694
|
+
const structured2 = {
|
|
52695
|
+
...base,
|
|
52696
|
+
status: "unsupported",
|
|
52697
|
+
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(", ")})`
|
|
51967
52698
|
};
|
|
51968
52699
|
return {
|
|
51969
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51970
|
-
structuredContent:
|
|
52700
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52701
|
+
structuredContent: structured2
|
|
51971
52702
|
};
|
|
51972
52703
|
}
|
|
51973
|
-
|
|
51974
|
-
}
|
|
51975
|
-
}
|
|
51976
|
-
);
|
|
51977
|
-
server.registerTool(
|
|
51978
|
-
"policy_new",
|
|
51979
|
-
{
|
|
51980
|
-
title: "Scaffold a starter policy.yaml",
|
|
51981
|
-
description: "Write a starter policy file to the resolved default path (or a given path). Refuses to overwrite unless force=true. This is a write action: the agent should only call it after confirming with the user.",
|
|
51982
|
-
_meta: { agentSafetyTier: "action" },
|
|
51983
|
-
inputSchema: external_exports.object({
|
|
51984
|
-
path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
|
|
51985
|
-
force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
|
|
51986
|
-
}).strict(),
|
|
51987
|
-
outputSchema: {
|
|
51988
|
-
policyPath: external_exports.string(),
|
|
51989
|
-
schemaVersion: external_exports.string(),
|
|
51990
|
-
bytesWritten: external_exports.number(),
|
|
51991
|
-
overwritten: external_exports.boolean()
|
|
51992
|
-
}
|
|
51993
|
-
},
|
|
51994
|
-
async ({ path: pathArg, force }) => {
|
|
51995
|
-
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
51996
|
-
const doForce = force === true;
|
|
51997
|
-
if (fs18.existsSync(policyPath) && !doForce) {
|
|
51998
|
-
return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
|
|
51999
|
-
hint: "pass force=true to overwrite, or choose a different path",
|
|
52000
|
-
context: { policyPath }
|
|
52001
|
-
});
|
|
52002
|
-
}
|
|
52003
|
-
const template = readPolicyExampleYaml();
|
|
52004
|
-
fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
|
|
52005
|
-
fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
|
|
52006
|
-
const structured = {
|
|
52007
|
-
policyPath,
|
|
52008
|
-
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
52009
|
-
bytesWritten: Buffer.byteLength(template, "utf-8"),
|
|
52010
|
-
overwritten: doForce
|
|
52011
|
-
};
|
|
52012
|
-
return {
|
|
52013
|
-
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52014
|
-
structuredContent: structured
|
|
52015
|
-
};
|
|
52016
|
-
}
|
|
52017
|
-
);
|
|
52018
|
-
server.registerTool(
|
|
52019
|
-
"policy_migrate",
|
|
52020
|
-
{
|
|
52021
|
-
title: "Migrate a policy file to the latest supported schema",
|
|
52022
|
-
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.',
|
|
52023
|
-
_meta: { agentSafetyTier: "action" },
|
|
52024
|
-
inputSchema: external_exports.object({
|
|
52025
|
-
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
52026
|
-
dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
|
|
52027
|
-
to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
|
|
52028
|
-
}).strict(),
|
|
52029
|
-
outputSchema: {
|
|
52030
|
-
policyPath: external_exports.string(),
|
|
52031
|
-
fileVersion: external_exports.string().optional(),
|
|
52032
|
-
targetVersion: external_exports.string(),
|
|
52033
|
-
supportedVersions: external_exports.array(external_exports.string()),
|
|
52034
|
-
status: external_exports.enum([
|
|
52035
|
-
"already-current",
|
|
52036
|
-
"migrated",
|
|
52037
|
-
"dry-run",
|
|
52038
|
-
"no-version-field",
|
|
52039
|
-
"unsupported",
|
|
52040
|
-
"precheck-failed",
|
|
52041
|
-
"file-not-found"
|
|
52042
|
-
]),
|
|
52043
|
-
from: external_exports.string().optional(),
|
|
52044
|
-
to: external_exports.string().optional(),
|
|
52045
|
-
bytesWritten: external_exports.number().optional(),
|
|
52046
|
-
message: external_exports.string(),
|
|
52047
|
-
errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
|
|
52048
|
-
}
|
|
52049
|
-
},
|
|
52050
|
-
async ({ path: pathArg, dryRun, to }) => {
|
|
52051
|
-
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
52052
|
-
const target = to ?? LATEST_SUPPORTED_VERSION;
|
|
52053
|
-
let loaded;
|
|
52054
|
-
try {
|
|
52055
|
-
loaded = loadPolicyFile(policyPath);
|
|
52056
|
-
} catch (err) {
|
|
52057
|
-
if (err instanceof PolicyFileNotFoundError) {
|
|
52704
|
+
if (fileVersion === target) {
|
|
52058
52705
|
const structured2 = {
|
|
52059
|
-
|
|
52060
|
-
|
|
52061
|
-
|
|
52062
|
-
|
|
52063
|
-
message: `policy file not found: ${policyPath}`
|
|
52706
|
+
...base,
|
|
52707
|
+
status: "already-current",
|
|
52708
|
+
message: `already on schema v${target}; no migration needed`,
|
|
52709
|
+
bytesWritten: 0
|
|
52064
52710
|
};
|
|
52065
52711
|
return {
|
|
52066
52712
|
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52067
52713
|
structuredContent: structured2
|
|
52068
52714
|
};
|
|
52069
52715
|
}
|
|
52070
|
-
|
|
52071
|
-
|
|
52072
|
-
|
|
52073
|
-
|
|
52074
|
-
|
|
52075
|
-
|
|
52076
|
-
|
|
52077
|
-
|
|
52078
|
-
|
|
52079
|
-
|
|
52080
|
-
|
|
52081
|
-
|
|
52082
|
-
|
|
52083
|
-
|
|
52084
|
-
|
|
52085
|
-
|
|
52086
|
-
|
|
52087
|
-
|
|
52088
|
-
|
|
52089
|
-
|
|
52090
|
-
|
|
52091
|
-
|
|
52092
|
-
|
|
52093
|
-
|
|
52094
|
-
|
|
52095
|
-
|
|
52096
|
-
|
|
52097
|
-
}
|
|
52098
|
-
|
|
52099
|
-
|
|
52100
|
-
structuredContent: structured2
|
|
52101
|
-
};
|
|
52102
|
-
}
|
|
52103
|
-
if (fileVersion === target) {
|
|
52104
|
-
const structured2 = {
|
|
52105
|
-
...base,
|
|
52106
|
-
status: "already-current",
|
|
52107
|
-
message: `already on schema v${target}; no migration needed`,
|
|
52108
|
-
bytesWritten: 0
|
|
52109
|
-
};
|
|
52110
|
-
return {
|
|
52111
|
-
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52112
|
-
structuredContent: structured2
|
|
52113
|
-
};
|
|
52114
|
-
}
|
|
52115
|
-
const plan = planMigration(loaded, fileVersion, target);
|
|
52116
|
-
if (!plan.precheck.valid) {
|
|
52117
|
-
const structured2 = {
|
|
52118
|
-
...base,
|
|
52119
|
-
status: "precheck-failed",
|
|
52120
|
-
message: `migrated policy fails schema v${target} precheck; file not written`,
|
|
52121
|
-
errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
|
|
52122
|
-
};
|
|
52123
|
-
return {
|
|
52124
|
-
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52125
|
-
structuredContent: structured2
|
|
52126
|
-
};
|
|
52127
|
-
}
|
|
52128
|
-
const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
|
|
52129
|
-
if (dryRun) {
|
|
52130
|
-
const structured2 = {
|
|
52716
|
+
const plan = planMigration(loaded, fileVersion, target);
|
|
52717
|
+
if (!plan.precheck.valid) {
|
|
52718
|
+
const structured2 = {
|
|
52719
|
+
...base,
|
|
52720
|
+
status: "precheck-failed",
|
|
52721
|
+
message: `migrated policy fails schema v${target} precheck; file not written`,
|
|
52722
|
+
errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
|
|
52723
|
+
};
|
|
52724
|
+
return {
|
|
52725
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52726
|
+
structuredContent: structured2
|
|
52727
|
+
};
|
|
52728
|
+
}
|
|
52729
|
+
const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
|
|
52730
|
+
if (dryRun) {
|
|
52731
|
+
const structured2 = {
|
|
52732
|
+
...base,
|
|
52733
|
+
status: "dry-run",
|
|
52734
|
+
from: plan.fromVersion,
|
|
52735
|
+
to: plan.toVersion,
|
|
52736
|
+
bytesWritten: 0,
|
|
52737
|
+
message: `dry-run: would upgrade v${plan.fromVersion} \u2192 v${plan.toVersion} (${bytes} bytes)`
|
|
52738
|
+
};
|
|
52739
|
+
return {
|
|
52740
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52741
|
+
structuredContent: structured2
|
|
52742
|
+
};
|
|
52743
|
+
}
|
|
52744
|
+
writeFileSync(policyPath, plan.nextSource, { encoding: "utf-8" });
|
|
52745
|
+
const structured = {
|
|
52131
52746
|
...base,
|
|
52132
|
-
status: "
|
|
52747
|
+
status: "migrated",
|
|
52133
52748
|
from: plan.fromVersion,
|
|
52134
52749
|
to: plan.toVersion,
|
|
52135
|
-
bytesWritten:
|
|
52136
|
-
message: `
|
|
52750
|
+
bytesWritten: bytes,
|
|
52751
|
+
message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`
|
|
52137
52752
|
};
|
|
52138
52753
|
return {
|
|
52139
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
52140
|
-
structuredContent:
|
|
52754
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52755
|
+
structuredContent: structured
|
|
52141
52756
|
};
|
|
52142
52757
|
}
|
|
52143
|
-
|
|
52144
|
-
|
|
52145
|
-
|
|
52146
|
-
|
|
52147
|
-
|
|
52148
|
-
|
|
52149
|
-
|
|
52150
|
-
|
|
52151
|
-
|
|
52152
|
-
|
|
52153
|
-
|
|
52154
|
-
|
|
52155
|
-
|
|
52156
|
-
|
|
52157
|
-
|
|
52158
|
-
|
|
52159
|
-
|
|
52160
|
-
|
|
52161
|
-
|
|
52162
|
-
|
|
52163
|
-
|
|
52164
|
-
|
|
52165
|
-
|
|
52166
|
-
|
|
52167
|
-
|
|
52168
|
-
|
|
52169
|
-
|
|
52170
|
-
|
|
52171
|
-
|
|
52172
|
-
|
|
52173
|
-
truncated: external_exports.boolean(),
|
|
52174
|
-
stats: external_exports.object({
|
|
52175
|
-
added: external_exports.number().int(),
|
|
52176
|
-
removed: external_exports.number().int(),
|
|
52177
|
-
changed: external_exports.number().int()
|
|
52178
|
-
}),
|
|
52179
|
-
changes: external_exports.array(external_exports.object({
|
|
52180
|
-
path: external_exports.string(),
|
|
52181
|
-
kind: external_exports.enum(["added", "removed", "changed"]),
|
|
52182
|
-
before: external_exports.unknown().optional(),
|
|
52183
|
-
after: external_exports.unknown().optional()
|
|
52184
|
-
})),
|
|
52185
|
-
diff: external_exports.string()
|
|
52186
|
-
}
|
|
52187
|
-
},
|
|
52188
|
-
({ left_path, right_path }) => {
|
|
52189
|
-
let leftSource = "";
|
|
52190
|
-
let rightSource = "";
|
|
52191
|
-
try {
|
|
52192
|
-
leftSource = fs18.readFileSync(left_path, "utf-8");
|
|
52193
|
-
} catch (err) {
|
|
52194
|
-
if (err?.code === "ENOENT") {
|
|
52195
|
-
return mcpError("usage", 2, `policy file not found: ${left_path}`, {
|
|
52196
|
-
context: { policyPath: left_path }
|
|
52197
|
-
});
|
|
52758
|
+
);
|
|
52759
|
+
if (!skip("policy_diff"))
|
|
52760
|
+
server.registerTool(
|
|
52761
|
+
"policy_diff",
|
|
52762
|
+
{
|
|
52763
|
+
title: "Compare two policy files",
|
|
52764
|
+
description: "Compare two policy YAML files and return the same contract as `switchbot --json policy diff`: { leftPath, rightPath, equal, changeCount, truncated, stats, changes, diff }.",
|
|
52765
|
+
_meta: { agentSafetyTier: "read" },
|
|
52766
|
+
inputSchema: external_exports.object({
|
|
52767
|
+
left_path: external_exports.string().min(1).describe("Path to the baseline policy file."),
|
|
52768
|
+
right_path: external_exports.string().min(1).describe("Path to the candidate policy file.")
|
|
52769
|
+
}).strict(),
|
|
52770
|
+
outputSchema: {
|
|
52771
|
+
leftPath: external_exports.string(),
|
|
52772
|
+
rightPath: external_exports.string(),
|
|
52773
|
+
equal: external_exports.boolean(),
|
|
52774
|
+
changeCount: external_exports.number().int(),
|
|
52775
|
+
truncated: external_exports.boolean(),
|
|
52776
|
+
stats: external_exports.object({
|
|
52777
|
+
added: external_exports.number().int(),
|
|
52778
|
+
removed: external_exports.number().int(),
|
|
52779
|
+
changed: external_exports.number().int()
|
|
52780
|
+
}),
|
|
52781
|
+
changes: external_exports.array(external_exports.object({
|
|
52782
|
+
path: external_exports.string(),
|
|
52783
|
+
kind: external_exports.enum(["added", "removed", "changed"]),
|
|
52784
|
+
before: external_exports.unknown().optional(),
|
|
52785
|
+
after: external_exports.unknown().optional()
|
|
52786
|
+
})),
|
|
52787
|
+
diff: external_exports.string()
|
|
52198
52788
|
}
|
|
52199
|
-
|
|
52200
|
-
}
|
|
52201
|
-
|
|
52202
|
-
rightSource =
|
|
52203
|
-
|
|
52204
|
-
|
|
52205
|
-
|
|
52206
|
-
|
|
52207
|
-
|
|
52789
|
+
},
|
|
52790
|
+
({ left_path, right_path }) => {
|
|
52791
|
+
let leftSource = "";
|
|
52792
|
+
let rightSource = "";
|
|
52793
|
+
try {
|
|
52794
|
+
leftSource = fs18.readFileSync(left_path, "utf-8");
|
|
52795
|
+
} catch (err) {
|
|
52796
|
+
if (err?.code === "ENOENT") {
|
|
52797
|
+
return mcpError("usage", 2, `policy file not found: ${left_path}`, {
|
|
52798
|
+
context: { policyPath: left_path }
|
|
52799
|
+
});
|
|
52800
|
+
}
|
|
52801
|
+
return mcpError("runtime", 1, `failed to read ${left_path}: ${String(err)}`);
|
|
52208
52802
|
}
|
|
52209
|
-
|
|
52210
|
-
|
|
52211
|
-
|
|
52212
|
-
|
|
52213
|
-
|
|
52214
|
-
|
|
52215
|
-
|
|
52216
|
-
|
|
52217
|
-
|
|
52218
|
-
|
|
52219
|
-
|
|
52220
|
-
|
|
52221
|
-
|
|
52803
|
+
try {
|
|
52804
|
+
rightSource = fs18.readFileSync(right_path, "utf-8");
|
|
52805
|
+
} catch (err) {
|
|
52806
|
+
if (err?.code === "ENOENT") {
|
|
52807
|
+
return mcpError("usage", 2, `policy file not found: ${right_path}`, {
|
|
52808
|
+
context: { policyPath: right_path }
|
|
52809
|
+
});
|
|
52810
|
+
}
|
|
52811
|
+
return mcpError("runtime", 1, `failed to read ${right_path}: ${String(err)}`);
|
|
52812
|
+
}
|
|
52813
|
+
let leftDoc;
|
|
52814
|
+
let rightDoc;
|
|
52815
|
+
try {
|
|
52816
|
+
leftDoc = (0, import_yaml7.parse)(leftSource);
|
|
52817
|
+
} catch (err) {
|
|
52818
|
+
return mcpError("usage", 2, `YAML parse error in ${left_path}: ${err.message}`);
|
|
52819
|
+
}
|
|
52820
|
+
try {
|
|
52821
|
+
rightDoc = (0, import_yaml7.parse)(rightSource);
|
|
52822
|
+
} catch (err) {
|
|
52823
|
+
return mcpError("usage", 2, `YAML parse error in ${right_path}: ${err.message}`);
|
|
52824
|
+
}
|
|
52825
|
+
const result = {
|
|
52826
|
+
leftPath: left_path,
|
|
52827
|
+
rightPath: right_path,
|
|
52828
|
+
...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
|
|
52829
|
+
};
|
|
52830
|
+
return {
|
|
52831
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
52832
|
+
structuredContent: result
|
|
52833
|
+
};
|
|
52222
52834
|
}
|
|
52223
|
-
|
|
52224
|
-
leftPath: left_path,
|
|
52225
|
-
rightPath: right_path,
|
|
52226
|
-
...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
|
|
52227
|
-
};
|
|
52228
|
-
return {
|
|
52229
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
52230
|
-
structuredContent: result
|
|
52231
|
-
};
|
|
52232
|
-
}
|
|
52233
|
-
);
|
|
52835
|
+
);
|
|
52234
52836
|
if (eventManager) {
|
|
52235
52837
|
server.registerResource(
|
|
52236
52838
|
"events",
|
|
@@ -52253,559 +52855,568 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
52253
52855
|
}
|
|
52254
52856
|
);
|
|
52255
52857
|
}
|
|
52256
|
-
|
|
52257
|
-
|
|
52258
|
-
|
|
52259
|
-
|
|
52260
|
-
|
|
52261
|
-
|
|
52262
|
-
|
|
52263
|
-
|
|
52264
|
-
|
|
52265
|
-
|
|
52266
|
-
|
|
52267
|
-
|
|
52268
|
-
|
|
52269
|
-
|
|
52270
|
-
|
|
52271
|
-
|
|
52272
|
-
|
|
52273
|
-
const
|
|
52274
|
-
|
|
52275
|
-
|
|
52276
|
-
try {
|
|
52277
|
-
const { plan, warnings } = suggestPlan({ intent, devices });
|
|
52278
|
-
return {
|
|
52279
|
-
content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
|
|
52280
|
-
structuredContent: { plan, warnings }
|
|
52281
|
-
};
|
|
52282
|
-
} catch (err) {
|
|
52283
|
-
return apiErrorToMcpError(err);
|
|
52284
|
-
}
|
|
52285
|
-
}
|
|
52286
|
-
);
|
|
52287
|
-
server.registerTool(
|
|
52288
|
-
"plan_run",
|
|
52289
|
-
{
|
|
52290
|
-
title: "Validate and execute a SwitchBot plan",
|
|
52291
|
-
description: "Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true, and the default safety profile still refuses direct destructive execution in favor of the reviewed plan workflow. Scene and wait steps run in order. Returns per-step results and a summary.",
|
|
52292
|
-
_meta: { agentSafetyTier: "action" },
|
|
52293
|
-
inputSchema: external_exports.object({
|
|
52294
|
-
plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
|
|
52295
|
-
yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
|
|
52296
|
-
continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
|
|
52297
|
-
}).strict(),
|
|
52298
|
-
outputSchema: {
|
|
52299
|
-
ran: external_exports.boolean(),
|
|
52300
|
-
plan: external_exports.unknown(),
|
|
52301
|
-
results: external_exports.array(external_exports.unknown()),
|
|
52302
|
-
summary: external_exports.object({
|
|
52303
|
-
total: external_exports.number().int(),
|
|
52304
|
-
ok: external_exports.number().int(),
|
|
52305
|
-
error: external_exports.number().int(),
|
|
52306
|
-
skipped: external_exports.number().int()
|
|
52307
|
-
})
|
|
52308
|
-
}
|
|
52309
|
-
},
|
|
52310
|
-
async ({ plan, yes, continue_on_error }) => {
|
|
52311
|
-
const validated = validatePlan(plan);
|
|
52312
|
-
if (!validated.ok) {
|
|
52313
|
-
return mcpError("usage", 2, "plan invalid", {
|
|
52314
|
-
context: { issues: validated.issues },
|
|
52315
|
-
hint: "Fix the reported issues and retry plan_run."
|
|
52316
|
-
});
|
|
52317
|
-
}
|
|
52318
|
-
const out = {
|
|
52319
|
-
ran: true,
|
|
52320
|
-
plan: validated.plan,
|
|
52321
|
-
results: [],
|
|
52322
|
-
summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
|
|
52323
|
-
};
|
|
52324
|
-
const continueOnError = continue_on_error === true;
|
|
52325
|
-
const allowDestructive = yes === true;
|
|
52326
|
-
const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
|
|
52327
|
-
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
52328
|
-
const commandType = step.commandType ?? "command";
|
|
52329
|
-
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
52330
|
-
return {
|
|
52331
|
-
index: index + 1,
|
|
52332
|
-
deviceId: resolvedDeviceId,
|
|
52333
|
-
command: step.command,
|
|
52334
|
-
commandType,
|
|
52335
|
-
deviceType: deviceType ?? null,
|
|
52336
|
-
destructive: isDestructiveCommand(deviceType, step.command, commandType)
|
|
52337
|
-
};
|
|
52338
|
-
}).filter((step) => step.destructive);
|
|
52339
|
-
if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
|
|
52340
|
-
return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
|
|
52341
|
-
hint: destructiveExecutionHint(),
|
|
52342
|
-
context: {
|
|
52343
|
-
destructiveSteps: destructiveSteps.map((step) => ({
|
|
52344
|
-
step: step.index,
|
|
52345
|
-
deviceId: step.deviceId,
|
|
52346
|
-
deviceType: step.deviceType,
|
|
52347
|
-
command: step.command,
|
|
52348
|
-
commandType: step.commandType
|
|
52349
|
-
})),
|
|
52350
|
-
requiredWorkflow: "plan-approval"
|
|
52351
|
-
}
|
|
52858
|
+
if (!skip("plan_suggest"))
|
|
52859
|
+
server.registerTool(
|
|
52860
|
+
"plan_suggest",
|
|
52861
|
+
{
|
|
52862
|
+
title: "Draft a SwitchBot execution plan from intent",
|
|
52863
|
+
description: "Generate a candidate Plan JSON from a natural language intent and a list of device IDs. Uses keyword heuristics (no LLM) to pick the command. The returned plan is ready to pass to `plan run` \u2014 review and edit before executing. Recognised commands: turnOn, turnOff, press, lock, unlock, open, close, pause. Falls back to turnOn with a warning when intent is unclear.",
|
|
52864
|
+
_meta: { agentSafetyTier: "read" },
|
|
52865
|
+
inputSchema: external_exports.object({
|
|
52866
|
+
intent: external_exports.string().min(1).describe('Natural language description of what to do (e.g. "turn off all lights").'),
|
|
52867
|
+
device_ids: external_exports.array(external_exports.string().min(1)).min(1).describe("Device IDs to act on.")
|
|
52868
|
+
}).strict(),
|
|
52869
|
+
outputSchema: {
|
|
52870
|
+
plan: external_exports.unknown().describe("Candidate Plan JSON (version 1.0) ready to pass to plan run."),
|
|
52871
|
+
warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted to turnOn).")
|
|
52872
|
+
}
|
|
52873
|
+
},
|
|
52874
|
+
({ intent, device_ids }) => {
|
|
52875
|
+
const devices = device_ids.map((id) => {
|
|
52876
|
+
const cached2 = getCachedDevice(id);
|
|
52877
|
+
return { id, name: cached2?.name, type: cached2?.type };
|
|
52352
52878
|
});
|
|
52879
|
+
try {
|
|
52880
|
+
const { plan, warnings } = suggestPlan({ intent, devices });
|
|
52881
|
+
return {
|
|
52882
|
+
content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
|
|
52883
|
+
structuredContent: { plan, warnings }
|
|
52884
|
+
};
|
|
52885
|
+
} catch (err) {
|
|
52886
|
+
return apiErrorToMcpError(err);
|
|
52887
|
+
}
|
|
52353
52888
|
}
|
|
52354
|
-
|
|
52355
|
-
|
|
52356
|
-
|
|
52357
|
-
|
|
52358
|
-
|
|
52359
|
-
|
|
52360
|
-
|
|
52361
|
-
|
|
52889
|
+
);
|
|
52890
|
+
if (!skip("plan_run"))
|
|
52891
|
+
server.registerTool(
|
|
52892
|
+
"plan_run",
|
|
52893
|
+
{
|
|
52894
|
+
title: "Validate and execute a SwitchBot plan",
|
|
52895
|
+
description: "Execute a Plan JSON object (version 1.0). Destructive command steps are skipped unless yes=true, and the default safety profile still refuses direct destructive execution in favor of the reviewed plan workflow. Scene and wait steps run in order. Returns per-step results and a summary.",
|
|
52896
|
+
_meta: { agentSafetyTier: "action" },
|
|
52897
|
+
inputSchema: external_exports.object({
|
|
52898
|
+
plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
|
|
52899
|
+
yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
|
|
52900
|
+
continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
|
|
52901
|
+
}).strict(),
|
|
52902
|
+
outputSchema: {
|
|
52903
|
+
ran: external_exports.boolean(),
|
|
52904
|
+
plan: external_exports.unknown(),
|
|
52905
|
+
results: external_exports.array(external_exports.unknown()),
|
|
52906
|
+
summary: external_exports.object({
|
|
52907
|
+
total: external_exports.number().int(),
|
|
52908
|
+
ok: external_exports.number().int(),
|
|
52909
|
+
error: external_exports.number().int(),
|
|
52910
|
+
skipped: external_exports.number().int()
|
|
52911
|
+
})
|
|
52362
52912
|
}
|
|
52363
|
-
|
|
52364
|
-
|
|
52365
|
-
|
|
52366
|
-
|
|
52367
|
-
|
|
52368
|
-
|
|
52369
|
-
|
|
52370
|
-
|
|
52371
|
-
out.summary.error++;
|
|
52372
|
-
if (!continueOnError) break;
|
|
52373
|
-
}
|
|
52374
|
-
continue;
|
|
52913
|
+
},
|
|
52914
|
+
async ({ plan, yes, continue_on_error }) => {
|
|
52915
|
+
const validated = validatePlan(plan);
|
|
52916
|
+
if (!validated.ok) {
|
|
52917
|
+
return mcpError("usage", 2, "plan invalid", {
|
|
52918
|
+
context: { issues: validated.issues },
|
|
52919
|
+
hint: "Fix the reported issues and retry plan_run."
|
|
52920
|
+
});
|
|
52375
52921
|
}
|
|
52376
|
-
|
|
52377
|
-
|
|
52378
|
-
|
|
52922
|
+
const out = {
|
|
52923
|
+
ran: true,
|
|
52924
|
+
plan: validated.plan,
|
|
52925
|
+
results: [],
|
|
52926
|
+
summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
|
|
52927
|
+
};
|
|
52928
|
+
const continueOnError = continue_on_error === true;
|
|
52929
|
+
const allowDestructive = yes === true;
|
|
52930
|
+
const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
|
|
52931
|
+
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
52379
52932
|
const commandType = step.commandType ?? "command";
|
|
52380
52933
|
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
52381
|
-
|
|
52382
|
-
|
|
52934
|
+
return {
|
|
52935
|
+
index: index + 1,
|
|
52936
|
+
deviceId: resolvedDeviceId,
|
|
52937
|
+
command: step.command,
|
|
52938
|
+
commandType,
|
|
52939
|
+
deviceType: deviceType ?? null,
|
|
52940
|
+
destructive: isDestructiveCommand(deviceType, step.command, commandType)
|
|
52941
|
+
};
|
|
52942
|
+
}).filter((step) => step.destructive);
|
|
52943
|
+
if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
|
|
52944
|
+
return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
|
|
52945
|
+
hint: destructiveExecutionHint(),
|
|
52946
|
+
context: {
|
|
52947
|
+
destructiveSteps: destructiveSteps.map((step) => ({
|
|
52948
|
+
step: step.index,
|
|
52949
|
+
deviceId: step.deviceId,
|
|
52950
|
+
deviceType: step.deviceType,
|
|
52951
|
+
command: step.command,
|
|
52952
|
+
commandType: step.commandType
|
|
52953
|
+
})),
|
|
52954
|
+
requiredWorkflow: "plan-approval"
|
|
52955
|
+
}
|
|
52956
|
+
});
|
|
52957
|
+
}
|
|
52958
|
+
for (let i = 0; i < validated.plan.steps.length; i++) {
|
|
52959
|
+
const step = validated.plan.steps[i];
|
|
52960
|
+
const idx = i + 1;
|
|
52961
|
+
if (step.type === "wait") {
|
|
52962
|
+
await new Promise((resolve2) => setTimeout(resolve2, step.ms));
|
|
52963
|
+
out.results.push({ step: idx, type: "wait", ms: step.ms, status: "ok" });
|
|
52964
|
+
out.summary.ok++;
|
|
52965
|
+
continue;
|
|
52966
|
+
}
|
|
52967
|
+
if (step.type === "scene") {
|
|
52968
|
+
try {
|
|
52969
|
+
await executeScene(step.sceneId);
|
|
52970
|
+
out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "ok" });
|
|
52971
|
+
out.summary.ok++;
|
|
52972
|
+
} catch (err) {
|
|
52973
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
52974
|
+
out.results.push({ step: idx, type: "scene", sceneId: step.sceneId, status: "error", error: msg });
|
|
52975
|
+
out.summary.error++;
|
|
52976
|
+
if (!continueOnError) break;
|
|
52977
|
+
}
|
|
52978
|
+
continue;
|
|
52979
|
+
}
|
|
52980
|
+
let resolvedDeviceId = "";
|
|
52981
|
+
try {
|
|
52982
|
+
resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
52983
|
+
const commandType = step.commandType ?? "command";
|
|
52984
|
+
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
52985
|
+
const destructive = isDestructiveCommand(deviceType, step.command, commandType);
|
|
52986
|
+
if (destructive && !allowDestructive) {
|
|
52987
|
+
out.results.push({
|
|
52988
|
+
step: idx,
|
|
52989
|
+
type: "command",
|
|
52990
|
+
deviceId: resolvedDeviceId,
|
|
52991
|
+
command: step.command,
|
|
52992
|
+
status: "skipped",
|
|
52993
|
+
error: "destructive \u2014 rerun with yes=true"
|
|
52994
|
+
});
|
|
52995
|
+
out.summary.skipped++;
|
|
52996
|
+
if (!continueOnError) break;
|
|
52997
|
+
continue;
|
|
52998
|
+
}
|
|
52999
|
+
await executeCommand(resolvedDeviceId, step.command, step.parameter, commandType);
|
|
52383
53000
|
out.results.push({
|
|
52384
53001
|
step: idx,
|
|
52385
53002
|
type: "command",
|
|
52386
53003
|
deviceId: resolvedDeviceId,
|
|
52387
53004
|
command: step.command,
|
|
52388
|
-
status: "
|
|
52389
|
-
error: "destructive \u2014 rerun with yes=true"
|
|
53005
|
+
status: "ok"
|
|
52390
53006
|
});
|
|
52391
|
-
out.summary.
|
|
52392
|
-
|
|
52393
|
-
|
|
52394
|
-
|
|
52395
|
-
|
|
52396
|
-
|
|
52397
|
-
|
|
52398
|
-
|
|
52399
|
-
|
|
52400
|
-
|
|
52401
|
-
|
|
52402
|
-
|
|
52403
|
-
|
|
52404
|
-
|
|
52405
|
-
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
53007
|
+
out.summary.ok++;
|
|
53008
|
+
} catch (err) {
|
|
53009
|
+
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
53010
|
+
out.results.push({
|
|
53011
|
+
step: idx,
|
|
53012
|
+
type: "command",
|
|
53013
|
+
deviceId: resolvedDeviceId || step.deviceId || "unknown",
|
|
53014
|
+
command: step.command,
|
|
53015
|
+
status: "ok"
|
|
53016
|
+
});
|
|
53017
|
+
out.summary.ok++;
|
|
53018
|
+
continue;
|
|
53019
|
+
}
|
|
53020
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
52406
53021
|
out.results.push({
|
|
52407
53022
|
step: idx,
|
|
52408
53023
|
type: "command",
|
|
52409
53024
|
deviceId: resolvedDeviceId || step.deviceId || "unknown",
|
|
52410
53025
|
command: step.command,
|
|
52411
|
-
status: "
|
|
53026
|
+
status: "error",
|
|
53027
|
+
error: msg
|
|
52412
53028
|
});
|
|
52413
|
-
out.summary.
|
|
52414
|
-
|
|
53029
|
+
out.summary.error++;
|
|
53030
|
+
if (!continueOnError) break;
|
|
52415
53031
|
}
|
|
52416
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
52417
|
-
out.results.push({
|
|
52418
|
-
step: idx,
|
|
52419
|
-
type: "command",
|
|
52420
|
-
deviceId: resolvedDeviceId || step.deviceId || "unknown",
|
|
52421
|
-
command: step.command,
|
|
52422
|
-
status: "error",
|
|
52423
|
-
error: msg
|
|
52424
|
-
});
|
|
52425
|
-
out.summary.error++;
|
|
52426
|
-
if (!continueOnError) break;
|
|
52427
53032
|
}
|
|
52428
|
-
}
|
|
52429
|
-
return {
|
|
52430
|
-
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52431
|
-
structuredContent: out
|
|
52432
|
-
};
|
|
52433
|
-
}
|
|
52434
|
-
);
|
|
52435
|
-
server.registerTool(
|
|
52436
|
-
"audit_query",
|
|
52437
|
-
{
|
|
52438
|
-
title: "Query command/rule audit log entries",
|
|
52439
|
-
description: "Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. Useful for review flows and rule-fire inspection without leaving MCP.",
|
|
52440
|
-
_meta: { agentSafetyTier: "read" },
|
|
52441
|
-
inputSchema: external_exports.object({
|
|
52442
|
-
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
52443
|
-
since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
|
|
52444
|
-
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
52445
|
-
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
52446
|
-
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
52447
|
-
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
52448
|
-
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
52449
|
-
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
52450
|
-
limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
|
|
52451
|
-
}).strict(),
|
|
52452
|
-
outputSchema: {
|
|
52453
|
-
file: external_exports.string(),
|
|
52454
|
-
totalMatched: external_exports.number().int(),
|
|
52455
|
-
returned: external_exports.number().int(),
|
|
52456
|
-
entries: external_exports.array(external_exports.unknown())
|
|
52457
|
-
}
|
|
52458
|
-
},
|
|
52459
|
-
({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
|
|
52460
|
-
const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
|
|
52461
|
-
const entries = readAudit(filePath);
|
|
52462
|
-
try {
|
|
52463
|
-
const filtered = filterAuditEntries(entries, {
|
|
52464
|
-
since,
|
|
52465
|
-
from,
|
|
52466
|
-
to,
|
|
52467
|
-
kinds,
|
|
52468
|
-
deviceId: device_id,
|
|
52469
|
-
ruleName: rule_name,
|
|
52470
|
-
results
|
|
52471
|
-
});
|
|
52472
|
-
const bounded = filtered.slice(-Math.max(1, limit ?? 200));
|
|
52473
|
-
const out = {
|
|
52474
|
-
file: filePath,
|
|
52475
|
-
totalMatched: filtered.length,
|
|
52476
|
-
returned: bounded.length,
|
|
52477
|
-
entries: bounded
|
|
52478
|
-
};
|
|
52479
53033
|
return {
|
|
52480
53034
|
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52481
53035
|
structuredContent: out
|
|
52482
53036
|
};
|
|
52483
|
-
} catch (err) {
|
|
52484
|
-
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
|
|
52485
53037
|
}
|
|
52486
|
-
|
|
52487
|
-
)
|
|
52488
|
-
|
|
52489
|
-
|
|
52490
|
-
|
|
52491
|
-
|
|
52492
|
-
|
|
52493
|
-
|
|
52494
|
-
|
|
52495
|
-
|
|
52496
|
-
|
|
52497
|
-
|
|
52498
|
-
|
|
52499
|
-
|
|
52500
|
-
|
|
52501
|
-
|
|
52502
|
-
|
|
52503
|
-
|
|
52504
|
-
|
|
52505
|
-
|
|
52506
|
-
|
|
52507
|
-
|
|
52508
|
-
|
|
52509
|
-
|
|
52510
|
-
|
|
52511
|
-
|
|
53038
|
+
);
|
|
53039
|
+
if (!skip("audit_query"))
|
|
53040
|
+
server.registerTool(
|
|
53041
|
+
"audit_query",
|
|
53042
|
+
{
|
|
53043
|
+
title: "Query command/rule audit log entries",
|
|
53044
|
+
description: "Filter entries from the local audit log (default ~/.switchbot/audit.log) by time range, kind, device, rule, and result. Useful for review flows and rule-fire inspection without leaving MCP.",
|
|
53045
|
+
_meta: { agentSafetyTier: "read" },
|
|
53046
|
+
inputSchema: external_exports.object({
|
|
53047
|
+
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
53048
|
+
since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
|
|
53049
|
+
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
53050
|
+
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
53051
|
+
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
53052
|
+
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
53053
|
+
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
53054
|
+
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
53055
|
+
limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
|
|
53056
|
+
}).strict(),
|
|
53057
|
+
outputSchema: {
|
|
53058
|
+
file: external_exports.string(),
|
|
53059
|
+
totalMatched: external_exports.number().int(),
|
|
53060
|
+
returned: external_exports.number().int(),
|
|
53061
|
+
entries: external_exports.array(external_exports.unknown())
|
|
53062
|
+
}
|
|
53063
|
+
},
|
|
53064
|
+
({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
|
|
53065
|
+
const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
|
|
53066
|
+
const entries = readAudit(filePath);
|
|
53067
|
+
try {
|
|
53068
|
+
const filtered = filterAuditEntries(entries, {
|
|
53069
|
+
since,
|
|
53070
|
+
from,
|
|
53071
|
+
to,
|
|
53072
|
+
kinds,
|
|
53073
|
+
deviceId: device_id,
|
|
53074
|
+
ruleName: rule_name,
|
|
53075
|
+
results
|
|
53076
|
+
});
|
|
53077
|
+
const bounded = filtered.slice(-Math.max(1, limit ?? 200));
|
|
53078
|
+
const out = {
|
|
53079
|
+
file: filePath,
|
|
53080
|
+
totalMatched: filtered.length,
|
|
53081
|
+
returned: bounded.length,
|
|
53082
|
+
entries: bounded
|
|
53083
|
+
};
|
|
53084
|
+
return {
|
|
53085
|
+
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
53086
|
+
structuredContent: out
|
|
53087
|
+
};
|
|
53088
|
+
} catch (err) {
|
|
53089
|
+
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
|
|
53090
|
+
}
|
|
52512
53091
|
}
|
|
52513
|
-
|
|
52514
|
-
|
|
52515
|
-
|
|
52516
|
-
|
|
52517
|
-
|
|
52518
|
-
|
|
52519
|
-
|
|
52520
|
-
|
|
52521
|
-
|
|
52522
|
-
|
|
52523
|
-
|
|
52524
|
-
|
|
52525
|
-
|
|
52526
|
-
|
|
52527
|
-
|
|
52528
|
-
|
|
52529
|
-
|
|
52530
|
-
|
|
52531
|
-
|
|
52532
|
-
|
|
52533
|
-
|
|
52534
|
-
|
|
52535
|
-
|
|
52536
|
-
|
|
52537
|
-
|
|
52538
|
-
|
|
52539
|
-
|
|
52540
|
-
|
|
52541
|
-
|
|
52542
|
-
|
|
52543
|
-
|
|
52544
|
-
|
|
52545
|
-
|
|
53092
|
+
);
|
|
53093
|
+
if (!skip("audit_stats"))
|
|
53094
|
+
server.registerTool(
|
|
53095
|
+
"audit_stats",
|
|
53096
|
+
{
|
|
53097
|
+
title: "Aggregate audit log counts for review dashboards",
|
|
53098
|
+
description: "Compute summary counters over the local audit log: by kind, by result, top devices, and top rules. Supports the same filters as audit_query.",
|
|
53099
|
+
_meta: { agentSafetyTier: "read" },
|
|
53100
|
+
inputSchema: external_exports.object({
|
|
53101
|
+
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
53102
|
+
since: external_exports.string().optional().describe('Relative window ending now (e.g. "6h"). Mutually exclusive with from/to.'),
|
|
53103
|
+
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
53104
|
+
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
53105
|
+
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
53106
|
+
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
53107
|
+
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
53108
|
+
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
53109
|
+
top_n: external_exports.number().int().min(1).max(100).optional().describe("Number of top device/rule rows to return (default 10).")
|
|
53110
|
+
}).strict(),
|
|
53111
|
+
outputSchema: {
|
|
53112
|
+
file: external_exports.string(),
|
|
53113
|
+
totalMatched: external_exports.number().int(),
|
|
53114
|
+
byKind: external_exports.record(external_exports.string(), external_exports.number().int()),
|
|
53115
|
+
byResult: external_exports.record(external_exports.string(), external_exports.number().int()),
|
|
53116
|
+
topDevices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), count: external_exports.number().int() })),
|
|
53117
|
+
topRules: external_exports.array(external_exports.object({ ruleName: external_exports.string(), count: external_exports.number().int() }))
|
|
53118
|
+
}
|
|
53119
|
+
},
|
|
53120
|
+
({ file: file2, since, from, to, kinds, device_id, rule_name, results, top_n }) => {
|
|
53121
|
+
const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
|
|
53122
|
+
const entries = readAudit(filePath);
|
|
53123
|
+
try {
|
|
53124
|
+
const filtered = filterAuditEntries(entries, {
|
|
53125
|
+
since,
|
|
53126
|
+
from,
|
|
53127
|
+
to,
|
|
53128
|
+
kinds,
|
|
53129
|
+
deviceId: device_id,
|
|
53130
|
+
ruleName: rule_name,
|
|
53131
|
+
results
|
|
53132
|
+
});
|
|
53133
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
53134
|
+
const byResult = /* @__PURE__ */ new Map();
|
|
53135
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
53136
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
53137
|
+
for (const entry of filtered) {
|
|
53138
|
+
byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
|
|
53139
|
+
if (entry.result) byResult.set(entry.result, (byResult.get(entry.result) ?? 0) + 1);
|
|
53140
|
+
if (entry.deviceId) byDevice.set(entry.deviceId, (byDevice.get(entry.deviceId) ?? 0) + 1);
|
|
53141
|
+
if (entry.rule?.name) byRule.set(entry.rule.name, (byRule.get(entry.rule.name) ?? 0) + 1);
|
|
53142
|
+
}
|
|
53143
|
+
const topN = top_n ?? 10;
|
|
53144
|
+
const out = {
|
|
53145
|
+
file: filePath,
|
|
53146
|
+
totalMatched: filtered.length,
|
|
53147
|
+
byKind: Object.fromEntries([...byKind.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
|
|
53148
|
+
byResult: Object.fromEntries([...byResult.entries()].sort((a, b2) => a[0].localeCompare(b2[0]))),
|
|
53149
|
+
topDevices: topNFromMap(byDevice, topN).map((item) => ({ deviceId: item.key, count: item.count })),
|
|
53150
|
+
topRules: topNFromMap(byRule, topN).map((item) => ({ ruleName: item.key, count: item.count }))
|
|
53151
|
+
};
|
|
53152
|
+
return {
|
|
53153
|
+
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
53154
|
+
structuredContent: out
|
|
53155
|
+
};
|
|
53156
|
+
} catch (err) {
|
|
53157
|
+
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
|
|
53158
|
+
}
|
|
53159
|
+
}
|
|
53160
|
+
);
|
|
53161
|
+
if (!skip("rule_notifications"))
|
|
53162
|
+
server.registerTool(
|
|
53163
|
+
"rule_notifications",
|
|
53164
|
+
{
|
|
53165
|
+
title: "Query rule notification delivery history",
|
|
53166
|
+
description: "Returns audit entries for notify actions (kind: rule-notify). Useful for confirming whether a notification rule fired and whether delivery succeeded. Filter by rule name, time range, result, or channel.",
|
|
53167
|
+
_meta: { agentSafetyTier: "read" },
|
|
53168
|
+
inputSchema: external_exports.object({
|
|
53169
|
+
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
53170
|
+
rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
|
|
53171
|
+
since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
|
|
53172
|
+
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
53173
|
+
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
53174
|
+
result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
|
|
53175
|
+
channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
|
|
53176
|
+
limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
|
|
53177
|
+
}).strict(),
|
|
53178
|
+
outputSchema: {
|
|
53179
|
+
entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
|
|
53180
|
+
total: external_exports.number().int().describe("Count after filtering.")
|
|
53181
|
+
}
|
|
53182
|
+
},
|
|
53183
|
+
({ file: file2, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
|
|
53184
|
+
const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
|
|
53185
|
+
let entries = readAudit(filePath).filter((e) => e.kind === "rule-notify");
|
|
53186
|
+
if (ruleName) entries = entries.filter((e) => e.rule?.name === ruleName);
|
|
53187
|
+
if (resultFilter) entries = entries.filter((e) => e.result === resultFilter);
|
|
53188
|
+
if (channel) entries = entries.filter((e) => e.notifyChannel === channel);
|
|
53189
|
+
try {
|
|
53190
|
+
entries = filterAuditEntries(entries, { since, from, to });
|
|
53191
|
+
} catch (err) {
|
|
53192
|
+
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid filter options");
|
|
53193
|
+
}
|
|
53194
|
+
const bounded = entries.slice(-limit).reverse();
|
|
53195
|
+
const out = { entries: bounded, total: entries.length };
|
|
52546
53196
|
return {
|
|
52547
53197
|
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52548
53198
|
structuredContent: out
|
|
52549
53199
|
};
|
|
52550
|
-
} catch (err) {
|
|
52551
|
-
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
|
|
52552
|
-
}
|
|
52553
|
-
}
|
|
52554
|
-
);
|
|
52555
|
-
server.registerTool(
|
|
52556
|
-
"rule_notifications",
|
|
52557
|
-
{
|
|
52558
|
-
title: "Query rule notification delivery history",
|
|
52559
|
-
description: "Returns audit entries for notify actions (kind: rule-notify). Useful for confirming whether a notification rule fired and whether delivery succeeded. Filter by rule name, time range, result, or channel.",
|
|
52560
|
-
_meta: { agentSafetyTier: "read" },
|
|
52561
|
-
inputSchema: external_exports.object({
|
|
52562
|
-
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
52563
|
-
rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
|
|
52564
|
-
since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
|
|
52565
|
-
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
52566
|
-
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
52567
|
-
result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
|
|
52568
|
-
channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
|
|
52569
|
-
limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
|
|
52570
|
-
}).strict(),
|
|
52571
|
-
outputSchema: {
|
|
52572
|
-
entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
|
|
52573
|
-
total: external_exports.number().int().describe("Count after filtering.")
|
|
52574
|
-
}
|
|
52575
|
-
},
|
|
52576
|
-
({ file: file2, rule: ruleName, since, from, to, result: resultFilter, channel, limit }) => {
|
|
52577
|
-
const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
|
|
52578
|
-
let entries = readAudit(filePath).filter((e) => e.kind === "rule-notify");
|
|
52579
|
-
if (ruleName) entries = entries.filter((e) => e.rule?.name === ruleName);
|
|
52580
|
-
if (resultFilter) entries = entries.filter((e) => e.result === resultFilter);
|
|
52581
|
-
if (channel) entries = entries.filter((e) => e.notifyChannel === channel);
|
|
52582
|
-
try {
|
|
52583
|
-
entries = filterAuditEntries(entries, { since, from, to });
|
|
52584
|
-
} catch (err) {
|
|
52585
|
-
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid filter options");
|
|
52586
|
-
}
|
|
52587
|
-
const bounded = entries.slice(-limit).reverse();
|
|
52588
|
-
const out = { entries: bounded, total: entries.length };
|
|
52589
|
-
return {
|
|
52590
|
-
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52591
|
-
structuredContent: out
|
|
52592
|
-
};
|
|
52593
|
-
}
|
|
52594
|
-
);
|
|
52595
|
-
server.registerTool(
|
|
52596
|
-
"rules_suggest",
|
|
52597
|
-
{
|
|
52598
|
-
title: "Draft a SwitchBot automation rule from intent",
|
|
52599
|
-
description: "Generate a candidate automation rule YAML from a natural language intent. Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). Always emits dry_run: true \u2014 the rule must be reviewed before arming. Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.",
|
|
52600
|
-
_meta: { agentSafetyTier: "read" },
|
|
52601
|
-
inputSchema: external_exports.object({
|
|
52602
|
-
intent: external_exports.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
|
|
52603
|
-
trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
|
|
52604
|
-
device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
|
|
52605
|
-
event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
|
|
52606
|
-
schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
|
|
52607
|
-
days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
|
|
52608
|
-
webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
|
|
52609
|
-
llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
|
|
52610
|
-
}).strict(),
|
|
52611
|
-
outputSchema: {
|
|
52612
|
-
rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
|
|
52613
|
-
rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
|
|
52614
|
-
warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
|
|
52615
53200
|
}
|
|
52616
|
-
|
|
52617
|
-
|
|
52618
|
-
|
|
52619
|
-
|
|
52620
|
-
|
|
52621
|
-
|
|
52622
|
-
|
|
52623
|
-
|
|
52624
|
-
|
|
52625
|
-
|
|
52626
|
-
|
|
52627
|
-
|
|
52628
|
-
|
|
52629
|
-
|
|
52630
|
-
|
|
52631
|
-
|
|
53201
|
+
);
|
|
53202
|
+
if (!skip("rules_suggest"))
|
|
53203
|
+
server.registerTool(
|
|
53204
|
+
"rules_suggest",
|
|
53205
|
+
{
|
|
53206
|
+
title: "Draft a SwitchBot automation rule from intent",
|
|
53207
|
+
description: "Generate a candidate automation rule YAML from a natural language intent. Uses keyword heuristics by default; pass llm to use an LLM backend (auto | openai | anthropic). Always emits dry_run: true \u2014 the rule must be reviewed before arming. Pass the returned rule_yaml to policy_add_rule to inject it into policy.yaml.",
|
|
53208
|
+
_meta: { agentSafetyTier: "read" },
|
|
53209
|
+
inputSchema: external_exports.object({
|
|
53210
|
+
intent: external_exports.string().min(1).describe('Natural language description (e.g. "turn off lights at 10pm").'),
|
|
53211
|
+
trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
|
|
53212
|
+
device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
|
|
53213
|
+
event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
|
|
53214
|
+
schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
|
|
53215
|
+
days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
|
|
53216
|
+
webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
|
|
53217
|
+
llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
|
|
53218
|
+
}).strict(),
|
|
53219
|
+
outputSchema: {
|
|
53220
|
+
rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
|
|
53221
|
+
rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
|
|
53222
|
+
warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
|
|
53223
|
+
}
|
|
53224
|
+
},
|
|
53225
|
+
async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
|
|
53226
|
+
const devices = (device_ids ?? []).map((id) => {
|
|
53227
|
+
const cached2 = getCachedDevice(id);
|
|
53228
|
+
return { id, name: cached2?.name, type: cached2?.type };
|
|
52632
53229
|
});
|
|
52633
|
-
return {
|
|
52634
|
-
content: [{ type: "text", text: ruleYaml }],
|
|
52635
|
-
structuredContent: { rule, rule_yaml: ruleYaml, warnings }
|
|
52636
|
-
};
|
|
52637
|
-
} catch (err) {
|
|
52638
|
-
return apiErrorToMcpError(err);
|
|
52639
|
-
}
|
|
52640
|
-
}
|
|
52641
|
-
);
|
|
52642
|
-
server.registerTool(
|
|
52643
|
-
"rules_explain",
|
|
52644
|
-
{
|
|
52645
|
-
title: "Show why a rule evaluation fired or was blocked",
|
|
52646
|
-
description: 'Read rule-evaluate trace records from the audit log and format them for inspection. Pass fire_id to explain a specific evaluation; or pass rule_name with last:true for the most recent evaluation; or pass rule_name + since for a window. Returns trace records only when automation.audit.evaluate_trace is "sampled" or "full".',
|
|
52647
|
-
_meta: { agentSafetyTier: "read" },
|
|
52648
|
-
inputSchema: external_exports.object({
|
|
52649
|
-
fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
|
|
52650
|
-
rule_name: external_exports.string().optional().describe("Filter to this rule name."),
|
|
52651
|
-
since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
|
|
52652
|
-
last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
|
|
52653
|
-
audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
|
|
52654
|
-
}).strict(),
|
|
52655
|
-
outputSchema: {
|
|
52656
|
-
records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
|
|
52657
|
-
count: external_exports.number().describe("Number of trace records returned.")
|
|
52658
|
-
}
|
|
52659
|
-
},
|
|
52660
|
-
async ({ fire_id, rule_name, since, last, audit_log }) => {
|
|
52661
|
-
const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
|
|
52662
|
-
const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
|
|
52663
|
-
const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
|
|
52664
|
-
let records = loadTraceRecords(auditFile, {
|
|
52665
|
-
fireId: fire_id,
|
|
52666
|
-
ruleName: rule_name,
|
|
52667
|
-
since: sinceIso
|
|
52668
|
-
});
|
|
52669
|
-
if (records.length === 0) {
|
|
52670
|
-
return {
|
|
52671
|
-
content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
|
|
52672
|
-
structuredContent: { records: [], count: 0 }
|
|
52673
|
-
};
|
|
52674
|
-
}
|
|
52675
|
-
if (last) {
|
|
52676
|
-
records = [records[records.length - 1]];
|
|
52677
|
-
}
|
|
52678
|
-
const output = records.map((record2) => {
|
|
52679
|
-
const related = loadRelatedAudit(auditFile, record2.fireId);
|
|
52680
|
-
return JSON.parse(formatExplainJson(record2, related));
|
|
52681
|
-
});
|
|
52682
|
-
return {
|
|
52683
|
-
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
52684
|
-
structuredContent: { records: output, count: output.length }
|
|
52685
|
-
};
|
|
52686
|
-
}
|
|
52687
|
-
);
|
|
52688
|
-
server.registerTool(
|
|
52689
|
-
"rules_simulate",
|
|
52690
|
-
{
|
|
52691
|
-
title: "Simulate a rule against historical events",
|
|
52692
|
-
description: "Replay historical events from the audit log or a JSONL file against a rule definition and report would-fire / blocked-by-condition / throttled outcomes. Useful for validating a new or modified rule before deployment. Pass rule_yaml to test an unpublished rule, or rule_name + policy_path to test a deployed rule.",
|
|
52693
|
-
_meta: { agentSafetyTier: "read" },
|
|
52694
|
-
inputSchema: external_exports.object({
|
|
52695
|
-
rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
|
|
52696
|
-
policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
|
|
52697
|
-
rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
|
|
52698
|
-
since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
|
|
52699
|
-
against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
|
|
52700
|
-
live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
|
|
52701
|
-
audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
|
|
52702
|
-
}).strict(),
|
|
52703
|
-
outputSchema: {
|
|
52704
|
-
report: external_exports.unknown().describe("SimulateReport object.")
|
|
52705
|
-
}
|
|
52706
|
-
},
|
|
52707
|
-
async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
|
|
52708
|
-
const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
|
|
52709
|
-
const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
|
|
52710
|
-
let rule;
|
|
52711
|
-
if (rule_yaml) {
|
|
52712
53230
|
try {
|
|
52713
|
-
rule =
|
|
53231
|
+
const { rule, ruleYaml, warnings } = await suggestRule({
|
|
53232
|
+
intent,
|
|
53233
|
+
trigger,
|
|
53234
|
+
devices,
|
|
53235
|
+
event,
|
|
53236
|
+
schedule,
|
|
53237
|
+
days,
|
|
53238
|
+
webhookPath: webhook_path,
|
|
53239
|
+
llm
|
|
53240
|
+
});
|
|
53241
|
+
return {
|
|
53242
|
+
content: [{ type: "text", text: ruleYaml }],
|
|
53243
|
+
structuredContent: { rule, rule_yaml: ruleYaml, warnings }
|
|
53244
|
+
};
|
|
52714
53245
|
} catch (err) {
|
|
53246
|
+
return apiErrorToMcpError(err);
|
|
53247
|
+
}
|
|
53248
|
+
}
|
|
53249
|
+
);
|
|
53250
|
+
if (!skip("rules_explain"))
|
|
53251
|
+
server.registerTool(
|
|
53252
|
+
"rules_explain",
|
|
53253
|
+
{
|
|
53254
|
+
title: "Show why a rule evaluation fired or was blocked",
|
|
53255
|
+
description: 'Read rule-evaluate trace records from the audit log and format them for inspection. Pass fire_id to explain a specific evaluation; or pass rule_name with last:true for the most recent evaluation; or pass rule_name + since for a window. Returns trace records only when automation.audit.evaluate_trace is "sampled" or "full".',
|
|
53256
|
+
_meta: { agentSafetyTier: "read" },
|
|
53257
|
+
inputSchema: external_exports.object({
|
|
53258
|
+
fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
|
|
53259
|
+
rule_name: external_exports.string().optional().describe("Filter to this rule name."),
|
|
53260
|
+
since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
|
|
53261
|
+
last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
|
|
53262
|
+
audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
|
|
53263
|
+
}).strict(),
|
|
53264
|
+
outputSchema: {
|
|
53265
|
+
records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
|
|
53266
|
+
count: external_exports.number().describe("Number of trace records returned.")
|
|
53267
|
+
}
|
|
53268
|
+
},
|
|
53269
|
+
async ({ fire_id, rule_name, since, last, audit_log }) => {
|
|
53270
|
+
const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
|
|
53271
|
+
const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
|
|
53272
|
+
const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
|
|
53273
|
+
let records = loadTraceRecords(auditFile, {
|
|
53274
|
+
fireId: fire_id,
|
|
53275
|
+
ruleName: rule_name,
|
|
53276
|
+
since: sinceIso
|
|
53277
|
+
});
|
|
53278
|
+
if (records.length === 0) {
|
|
52715
53279
|
return {
|
|
52716
|
-
content: [{ type: "text", text:
|
|
52717
|
-
structuredContent: {
|
|
53280
|
+
content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
|
|
53281
|
+
structuredContent: { records: [], count: 0 }
|
|
52718
53282
|
};
|
|
52719
53283
|
}
|
|
52720
|
-
|
|
52721
|
-
|
|
52722
|
-
|
|
52723
|
-
|
|
52724
|
-
const
|
|
52725
|
-
|
|
52726
|
-
|
|
52727
|
-
|
|
53284
|
+
if (last) {
|
|
53285
|
+
records = [records[records.length - 1]];
|
|
53286
|
+
}
|
|
53287
|
+
const output = records.map((record2) => {
|
|
53288
|
+
const related = loadRelatedAudit(auditFile, record2.fireId);
|
|
53289
|
+
return JSON.parse(formatExplainJson(record2, related));
|
|
53290
|
+
});
|
|
53291
|
+
return {
|
|
53292
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
53293
|
+
structuredContent: { records: output, count: output.length }
|
|
53294
|
+
};
|
|
53295
|
+
}
|
|
53296
|
+
);
|
|
53297
|
+
if (!skip("rules_simulate"))
|
|
53298
|
+
server.registerTool(
|
|
53299
|
+
"rules_simulate",
|
|
53300
|
+
{
|
|
53301
|
+
title: "Simulate a rule against historical events",
|
|
53302
|
+
description: "Replay historical events from the audit log or a JSONL file against a rule definition and report would-fire / blocked-by-condition / throttled outcomes. Useful for validating a new or modified rule before deployment. Pass rule_yaml to test an unpublished rule, or rule_name + policy_path to test a deployed rule.",
|
|
53303
|
+
_meta: { agentSafetyTier: "read" },
|
|
53304
|
+
inputSchema: external_exports.object({
|
|
53305
|
+
rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
|
|
53306
|
+
policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
|
|
53307
|
+
rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
|
|
53308
|
+
since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
|
|
53309
|
+
against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
|
|
53310
|
+
live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
|
|
53311
|
+
audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
|
|
53312
|
+
}).strict(),
|
|
53313
|
+
outputSchema: {
|
|
53314
|
+
report: external_exports.unknown().describe("SimulateReport object.")
|
|
53315
|
+
}
|
|
53316
|
+
},
|
|
53317
|
+
async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
|
|
53318
|
+
const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
|
|
53319
|
+
const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
|
|
53320
|
+
let rule;
|
|
53321
|
+
if (rule_yaml) {
|
|
53322
|
+
try {
|
|
53323
|
+
rule = (0, import_yaml7.parse)(rule_yaml);
|
|
53324
|
+
} catch (err) {
|
|
52728
53325
|
return {
|
|
52729
|
-
content: [{ type: "text", text: `
|
|
53326
|
+
content: [{ type: "text", text: `Failed to parse rule_yaml: ${String(err)}` }],
|
|
52730
53327
|
structuredContent: { report: null }
|
|
52731
53328
|
};
|
|
52732
53329
|
}
|
|
52733
|
-
|
|
53330
|
+
} else if (policy_path || rule_name) {
|
|
53331
|
+
const { loadPolicyFile: loadPolicyFile2 } = await Promise.resolve().then(() => (init_load(), load_exports));
|
|
53332
|
+
const policyFile = policy_path ?? pathJoin(os14.homedir(), ".switchbot", "policy.yaml");
|
|
53333
|
+
try {
|
|
53334
|
+
const policy = loadPolicyFile2(policyFile);
|
|
53335
|
+
const data = policy.data ?? {};
|
|
53336
|
+
const found = data.automation?.rules?.find((r) => r.name === rule_name);
|
|
53337
|
+
if (!found) {
|
|
53338
|
+
return {
|
|
53339
|
+
content: [{ type: "text", text: `Rule "${rule_name}" not found in ${policyFile}.` }],
|
|
53340
|
+
structuredContent: { report: null }
|
|
53341
|
+
};
|
|
53342
|
+
}
|
|
53343
|
+
rule = found;
|
|
53344
|
+
} catch (err) {
|
|
53345
|
+
return {
|
|
53346
|
+
content: [{ type: "text", text: `Failed to load policy: ${String(err)}` }],
|
|
53347
|
+
structuredContent: { report: null }
|
|
53348
|
+
};
|
|
53349
|
+
}
|
|
53350
|
+
} else {
|
|
53351
|
+
return {
|
|
53352
|
+
content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
|
|
53353
|
+
structuredContent: { report: null }
|
|
53354
|
+
};
|
|
53355
|
+
}
|
|
53356
|
+
try {
|
|
53357
|
+
const report = await simulateRule({
|
|
53358
|
+
rule,
|
|
53359
|
+
since,
|
|
53360
|
+
against,
|
|
53361
|
+
auditLog: auditFile,
|
|
53362
|
+
liveLlm: live_llm ?? false
|
|
53363
|
+
});
|
|
53364
|
+
return {
|
|
53365
|
+
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
53366
|
+
structuredContent: { report }
|
|
53367
|
+
};
|
|
52734
53368
|
} catch (err) {
|
|
52735
53369
|
return {
|
|
52736
|
-
content: [{ type: "text", text: `
|
|
53370
|
+
content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
|
|
52737
53371
|
structuredContent: { report: null }
|
|
52738
53372
|
};
|
|
52739
53373
|
}
|
|
52740
|
-
} else {
|
|
52741
|
-
return {
|
|
52742
|
-
content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
|
|
52743
|
-
structuredContent: { report: null }
|
|
52744
|
-
};
|
|
52745
53374
|
}
|
|
52746
|
-
|
|
52747
|
-
|
|
52748
|
-
|
|
52749
|
-
|
|
52750
|
-
|
|
52751
|
-
|
|
52752
|
-
|
|
52753
|
-
}
|
|
52754
|
-
|
|
52755
|
-
|
|
52756
|
-
|
|
52757
|
-
|
|
52758
|
-
|
|
52759
|
-
|
|
52760
|
-
|
|
52761
|
-
|
|
52762
|
-
|
|
52763
|
-
|
|
52764
|
-
|
|
52765
|
-
|
|
52766
|
-
|
|
52767
|
-
|
|
52768
|
-
|
|
52769
|
-
|
|
52770
|
-
|
|
52771
|
-
|
|
52772
|
-
|
|
52773
|
-
|
|
52774
|
-
|
|
52775
|
-
|
|
52776
|
-
|
|
52777
|
-
|
|
52778
|
-
|
|
52779
|
-
|
|
52780
|
-
|
|
52781
|
-
|
|
52782
|
-
|
|
52783
|
-
|
|
52784
|
-
|
|
52785
|
-
|
|
52786
|
-
|
|
52787
|
-
|
|
52788
|
-
try {
|
|
52789
|
-
const result = addRuleToPolicyFile({
|
|
52790
|
-
ruleYaml: rule_yaml,
|
|
52791
|
-
policyPath,
|
|
52792
|
-
enableAutomation: enable_automation,
|
|
52793
|
-
dryRun: dry_run,
|
|
52794
|
-
force
|
|
52795
|
-
});
|
|
52796
|
-
const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
|
|
52797
|
-
return {
|
|
52798
|
-
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52799
|
-
structuredContent: out
|
|
52800
|
-
};
|
|
52801
|
-
} catch (err) {
|
|
52802
|
-
if (err instanceof AddRuleError) {
|
|
52803
|
-
return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
|
|
53375
|
+
);
|
|
53376
|
+
if (!skip("policy_add_rule"))
|
|
53377
|
+
server.registerTool(
|
|
53378
|
+
"policy_add_rule",
|
|
53379
|
+
{
|
|
53380
|
+
title: "Append a rule to automation.rules[] in policy.yaml",
|
|
53381
|
+
description: "Inject a rule YAML snippet (as produced by rules_suggest) into the automation.rules[] array in policy.yaml. Preserves existing comments and formatting. Always run with dry_run: true first so the agent can show the diff for user approval. Never set enable_automation: true without explicitly informing the user.",
|
|
53382
|
+
_meta: { agentSafetyTier: "action" },
|
|
53383
|
+
inputSchema: external_exports.object({
|
|
53384
|
+
rule_yaml: external_exports.string().min(1).describe("YAML string of a single rule object (e.g. from rules_suggest)."),
|
|
53385
|
+
policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to $SWITCHBOT_POLICY_PATH or ~/.switchbot/policy.yaml)."),
|
|
53386
|
+
enable_automation: external_exports.boolean().default(false).describe("If true, sets automation.enabled: true after inserting the rule."),
|
|
53387
|
+
dry_run: external_exports.boolean().default(false).describe("If true, compute and return the diff without writing to disk."),
|
|
53388
|
+
force: external_exports.boolean().default(false).describe("If true, overwrite an existing rule with the same name.")
|
|
53389
|
+
}).strict(),
|
|
53390
|
+
outputSchema: {
|
|
53391
|
+
policyPath: external_exports.string().describe("Resolved path to the policy file."),
|
|
53392
|
+
ruleName: external_exports.string().describe("Name of the rule that was (or would be) inserted."),
|
|
53393
|
+
written: external_exports.boolean().describe("True when the file was actually written."),
|
|
53394
|
+
diff: external_exports.string().describe("Unified-style diff showing lines added/removed.")
|
|
53395
|
+
}
|
|
53396
|
+
},
|
|
53397
|
+
({ rule_yaml, policy_path, enable_automation, dry_run, force }) => {
|
|
53398
|
+
const policyPath = resolvePolicyPath({ flag: policy_path });
|
|
53399
|
+
try {
|
|
53400
|
+
const result = addRuleToPolicyFile({
|
|
53401
|
+
ruleYaml: rule_yaml,
|
|
53402
|
+
policyPath,
|
|
53403
|
+
enableAutomation: enable_automation,
|
|
53404
|
+
dryRun: dry_run,
|
|
53405
|
+
force
|
|
53406
|
+
});
|
|
53407
|
+
const out = { policyPath, ruleName: result.ruleName, written: result.written, diff: result.diff };
|
|
53408
|
+
return {
|
|
53409
|
+
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
53410
|
+
structuredContent: out
|
|
53411
|
+
};
|
|
53412
|
+
} catch (err) {
|
|
53413
|
+
if (err instanceof AddRuleError) {
|
|
53414
|
+
return apiErrorToMcpError(new Error(`${err.code}: ${err.message}`));
|
|
53415
|
+
}
|
|
53416
|
+
return apiErrorToMcpError(err);
|
|
52804
53417
|
}
|
|
52805
|
-
return apiErrorToMcpError(err);
|
|
52806
53418
|
}
|
|
52807
|
-
|
|
52808
|
-
);
|
|
53419
|
+
);
|
|
52809
53420
|
return server;
|
|
52810
53421
|
}
|
|
52811
53422
|
function listRegisteredTools(server) {
|
|
@@ -52831,8 +53442,8 @@ function listRegisteredToolsWithMeta(server) {
|
|
|
52831
53442
|
function listRegisteredResources() {
|
|
52832
53443
|
return ["switchbot://events"];
|
|
52833
53444
|
}
|
|
52834
|
-
function printMcpToolDirectory() {
|
|
52835
|
-
const server = createSwitchBotMcpServer();
|
|
53445
|
+
function printMcpToolDirectory(toolProfile) {
|
|
53446
|
+
const server = createSwitchBotMcpServer({ toolProfile });
|
|
52836
53447
|
const tools = listRegisteredToolsWithMeta(server);
|
|
52837
53448
|
const resources = listRegisteredResources().map((uri) => ({ uri }));
|
|
52838
53449
|
if (isJsonMode()) {
|
|
@@ -52904,16 +53515,30 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
|
|
|
52904
53515
|
Inspect locally:
|
|
52905
53516
|
$ npx @modelcontextprotocol/inspector switchbot mcp serve
|
|
52906
53517
|
`);
|
|
52907
|
-
mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() =>
|
|
52908
|
-
|
|
52909
|
-
|
|
53518
|
+
mcp.command("tools").description("Print the registered MCP tools in human or JSON form").option("--tools <profile>", "Tool profile: default, readonly, all (default: all)", stringArg("--tools"), "all").action((opts) => {
|
|
53519
|
+
try {
|
|
53520
|
+
printMcpToolDirectory(resolveToolProfile(opts.tools));
|
|
53521
|
+
} catch (e) {
|
|
53522
|
+
handleError(e);
|
|
53523
|
+
}
|
|
53524
|
+
});
|
|
53525
|
+
mcp.command("list-tools").description("Alias of `mcp tools`").option("--tools <profile>", "Tool profile: default, readonly, all (default: all)", stringArg("--tools"), "all").action((opts) => {
|
|
53526
|
+
try {
|
|
53527
|
+
printMcpToolDirectory(resolveToolProfile(opts.tools));
|
|
53528
|
+
} catch (e) {
|
|
53529
|
+
handleError(e);
|
|
53530
|
+
}
|
|
53531
|
+
});
|
|
53532
|
+
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").option("--tools <profile>", "Tool profile: default, readonly, all (default: default)", stringArg("--tools"), "default").addHelpText("after", `
|
|
52910
53533
|
Examples:
|
|
52911
53534
|
$ switchbot mcp serve
|
|
53535
|
+
$ switchbot mcp serve --tools all
|
|
52912
53536
|
$ switchbot mcp serve --port 8787
|
|
52913
53537
|
$ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
|
|
52914
53538
|
$ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
|
|
52915
53539
|
`).action(async (options) => {
|
|
52916
53540
|
try {
|
|
53541
|
+
const toolProfile = resolveToolProfile(options.tools);
|
|
52917
53542
|
if (options.port) {
|
|
52918
53543
|
const port = Number(options.port);
|
|
52919
53544
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
@@ -53062,7 +53687,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
53062
53687
|
}
|
|
53063
53688
|
}
|
|
53064
53689
|
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
53065
|
-
const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2 });
|
|
53690
|
+
const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2, toolProfile });
|
|
53066
53691
|
res.on("close", () => {
|
|
53067
53692
|
reqTransport.close();
|
|
53068
53693
|
reqServer.close();
|
|
@@ -53114,7 +53739,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
53114
53739
|
console.error("MQTT initialization failed:", err instanceof Error ? err.message : String(err));
|
|
53115
53740
|
});
|
|
53116
53741
|
}
|
|
53117
|
-
const server = createSwitchBotMcpServer({ eventManager });
|
|
53742
|
+
const server = createSwitchBotMcpServer({ eventManager, toolProfile });
|
|
53118
53743
|
const transport = new StdioServerTransport();
|
|
53119
53744
|
await server.connect(transport);
|
|
53120
53745
|
let isShuttingDown = false;
|